mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-10-29 18:03:45 +08:00
dbeaver/pro#6756 adds Menu to ui-kit, core-blocks + uses it in connection properties form
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
@import './__base.css';
|
||||
|
||||
@layer base {
|
||||
.dbv-kit-menu__button {
|
||||
padding-inline: var(--dbv-kit-menu-button-padding-inline);
|
||||
background-color: var(--dbv-kit-menu-background);
|
||||
color: var(--dbv-kit-menu-foreground);
|
||||
border-color: var(--dbv-kit-menu-border-color);
|
||||
border-radius: var(--dbv-kit-menu-border-radius);
|
||||
border-width: var(--dbv-kit-menu-border-width);
|
||||
border-style: var(--dbv-kit-menu-border-style);
|
||||
font-weight: var(--dbv-kit-menu-font-weight);
|
||||
font-size: var(--dbv-kit-menu-font-size);
|
||||
outline-width: var(--dbv-kit-control-outline-width);
|
||||
outline-color: var(--dbv-kit-control-outline-color);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--dbv-kit-menu-hover-border-color);
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
color: var(--dbv-kit-menu-disabled-foreground);
|
||||
background-color: var(--dbv-kit-menu-disabled-background);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-style: solid;
|
||||
outline-offset: var(--dbv-kit-control-outline-offset);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-menu__popover {
|
||||
z-index: 1000;
|
||||
min-width: max-content;
|
||||
max-height: var(--dbv-kit-menu-popover-max-height);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--dbv-kit-menu-popover-background);
|
||||
color: var(--dbv-kit-menu-popover-foreground);
|
||||
border-color: var(--dbv-kit-menu-border-color);
|
||||
border-radius: var(--dbv-kit-menu-border-radius);
|
||||
border-width: var(--dbv-kit-menu-border-width);
|
||||
border-style: var(--dbv-kit-menu-border-style);
|
||||
box-shadow: var(--dbv-kit-menu-popover-shadow);
|
||||
outline: 0;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
opacity: 0;
|
||||
transform-origin: top;
|
||||
transition-property: opacity, scale, translate;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 150ms;
|
||||
scale: 0.95;
|
||||
translate: 0 -0.5rem;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-inline: var(--dbv-kit-menu-item-padding-inline);
|
||||
padding-block: var(--dbv-kit-menu-item-padding-block);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-weight: var(--dbv-kit-menu-font-weight);
|
||||
font-size: var(--dbv-kit-menu-font-size);
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--dbv-kit-menu-item-selected-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-menu__separator {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background-color: var(--dbv-kit-menu-separator-color);
|
||||
}
|
||||
|
||||
.dbv-kit-menu__heading {
|
||||
padding: 0.25rem var(--dbv-kit-menu-item-padding-inline);
|
||||
font-weight: var(--tw-font-weight-medium);
|
||||
color: var(--tw-color-gray-500);
|
||||
}
|
||||
|
||||
.dbv-kit-menu__description {
|
||||
padding: 0.25rem var(--dbv-kit-menu-item-padding-inline);
|
||||
color: var(--tw-color-gray-500);
|
||||
font-size: calc(var(--dbv-kit-menu-font-size) * 0.9);
|
||||
}
|
||||
|
||||
.dbv-kit-menu__group {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
.dbv-kit-menu__group-label {
|
||||
padding: 0.25rem var(--dbv-kit-menu-item-padding-inline);
|
||||
color: var(--tw-color-gray-500);
|
||||
font-size: calc(var(--dbv-kit-menu-font-size) * 0.9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
MenuProvider as AriaMenuProvider,
|
||||
Menu as AriaMenu,
|
||||
MenuButton as AriaMenuButton,
|
||||
MenuButtonArrow as AriaMenuButtonArrow,
|
||||
MenuItem as AriaMenuItem,
|
||||
MenuSeparator as AriaMenuSeparator,
|
||||
MenuHeading as AriaMenuHeading,
|
||||
MenuDescription as AriaMenuDescription,
|
||||
MenuDismiss as AriaMenuDismiss,
|
||||
MenuGroup as AriaMenuGroup,
|
||||
MenuGroupLabel as AriaMenuGroupLabel,
|
||||
MenuItemCheckbox as AriaMenuItemCheckbox,
|
||||
MenuItemRadio as AriaMenuItemRadio,
|
||||
type MenuProviderProps,
|
||||
type MenuProps,
|
||||
type MenuButtonProps,
|
||||
type MenuItemProps,
|
||||
type MenuSeparatorProps,
|
||||
type MenuHeadingProps,
|
||||
type MenuDescriptionProps,
|
||||
type MenuDismissProps,
|
||||
type MenuGroupProps,
|
||||
type MenuGroupLabelProps,
|
||||
type MenuItemCheckboxProps,
|
||||
type MenuItemRadioProps,
|
||||
useMenuStore,
|
||||
useMenuContext,
|
||||
useStoreState,
|
||||
type MenuStoreProps,
|
||||
} from '@ariakit/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import './Menu.css';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function MenuProvider({ children, ...props }: MenuProviderProps): ReactElement {
|
||||
return <AriaMenuProvider {...props}>{children}</AriaMenuProvider>;
|
||||
}
|
||||
|
||||
export function MenuButton({ className, ...props }: MenuButtonProps): ReactElement {
|
||||
return <AriaMenuButton className={clsx('dbv-kit-menu__button', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Menu({ children, className, ...props }: MenuProps): ReactElement {
|
||||
return (
|
||||
<AriaMenu className={clsx('dbv-kit-menu__popover', className)} portal={props.portal ?? true} gutter={props.gutter ?? 4} {...props}>
|
||||
{children}
|
||||
</AriaMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItem({ children, className, ...props }: MenuItemProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuItem className={clsx('dbv-kit-menu__item', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator({ className, ...props }: MenuSeparatorProps): ReactElement {
|
||||
return <AriaMenuSeparator className={clsx('dbv-kit-menu__separator', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function MenuHeading({ children, className, ...props }: MenuHeadingProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuHeading className={clsx('dbv-kit-menu__heading', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuHeading>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuDescription({ children, className, ...props }: MenuDescriptionProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuDescription className={clsx('dbv-kit-menu__description', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuDescription>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuDismiss({ className, ...props }: MenuDismissProps): ReactElement {
|
||||
return <AriaMenuDismiss className={clsx('dbv-kit-menu__dismiss', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function MenuGroup({ children, className, ...props }: MenuGroupProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuGroup className={clsx('dbv-kit-menu__group', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuGroupLabel({ children, className, ...props }: MenuGroupLabelProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuGroupLabel className={clsx('dbv-kit-menu__group-label', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuGroupLabel>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItemCheckbox({ children, className, ...props }: MenuItemCheckboxProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuItemCheckbox className={clsx('dbv-kit-menu__item dbv-kit-menu__item--checkbox', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuItemCheckbox>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItemRadio({ children, className, ...props }: MenuItemRadioProps): ReactElement {
|
||||
return (
|
||||
<AriaMenuItemRadio className={clsx('dbv-kit-menu__item dbv-kit-menu__item--radio', className)} {...props}>
|
||||
{children}
|
||||
</AriaMenuItemRadio>
|
||||
);
|
||||
}
|
||||
|
||||
export const MenuButtonArrow = AriaMenuButtonArrow;
|
||||
|
||||
export {
|
||||
useMenuStore,
|
||||
useStoreState,
|
||||
useMenuContext,
|
||||
type MenuProviderProps,
|
||||
type MenuProps,
|
||||
type MenuButtonProps,
|
||||
type MenuItemProps,
|
||||
type MenuSeparatorProps,
|
||||
type MenuHeadingProps,
|
||||
type MenuDescriptionProps,
|
||||
type MenuDismissProps,
|
||||
type MenuGroupProps,
|
||||
type MenuGroupLabelProps,
|
||||
type MenuItemCheckboxProps,
|
||||
type MenuItemRadioProps,
|
||||
type MenuStoreProps,
|
||||
};
|
||||
|
||||
Menu.Provider = MenuProvider;
|
||||
Menu.Button = MenuButton;
|
||||
Menu.Item = MenuItem;
|
||||
Menu.Separator = MenuSeparator;
|
||||
Menu.Heading = MenuHeading;
|
||||
Menu.Description = MenuDescription;
|
||||
Menu.Dismiss = MenuDismiss;
|
||||
Menu.Group = MenuGroup;
|
||||
Menu.GroupLabel = MenuGroupLabel;
|
||||
Menu.ItemCheckbox = MenuItemCheckbox;
|
||||
Menu.ItemRadio = MenuItemRadio;
|
||||
Menu.ButtonArrow = MenuButtonArrow;
|
||||
Menu.Menu = Menu;
|
||||
@@ -0,0 +1,27 @@
|
||||
@layer base {
|
||||
.dbv-kit-menu__empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--tw-color-gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dbv-kit-menu-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dbv-kit-menu__label--required {
|
||||
&:after {
|
||||
content: '*';
|
||||
color: var(--dbv-kit-color-control-required);
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-menu__description {
|
||||
padding-inline-start: var(--dbv-kit-menu-button-padding-inline);
|
||||
color: var(--tw-color-gray-500);
|
||||
font-size: var(--dbv-kit-menu-font-size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { MenuProvider, Menu, MenuButton, MenuItem, MenuButtonArrow, useMenuStore, useStoreState, type MenuProps } from './Menu.js';
|
||||
import './MenuField.css';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
export interface MenuItemData<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type PropertyGetter<ItemType, ValueType> = (item: ItemType) => ValueType;
|
||||
|
||||
export interface MenuFieldProps<T, ItemType = MenuItemData<T>> {
|
||||
/** Options array - can be MenuItemData objects or arbitrary objects */
|
||||
items: ItemType[];
|
||||
|
||||
/**
|
||||
* Function to extract value from items
|
||||
* Example: (item) => item.id
|
||||
*/
|
||||
itemValue?: PropertyGetter<ItemType, T>;
|
||||
|
||||
/**
|
||||
* Function to extract label or render content from items
|
||||
* Example: (item) => item.firstName + ' ' + item.lastName
|
||||
*/
|
||||
itemRender?: PropertyGetter<ItemType, React.ReactNode>;
|
||||
|
||||
/**
|
||||
* Function to extract disabled state
|
||||
* Example: (item) => !item.isActive
|
||||
*/
|
||||
itemDisabled?: PropertyGetter<ItemType, boolean>;
|
||||
|
||||
onChange?: (value: T) => void;
|
||||
|
||||
onSwitch?: (open: boolean) => void;
|
||||
|
||||
label?: React.ReactNode;
|
||||
|
||||
description?: React.ReactNode;
|
||||
|
||||
name?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
required?: boolean;
|
||||
|
||||
className?: string;
|
||||
|
||||
noItemsPlaceholder?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Custom arrow icon React Node that will be rendered instead default one
|
||||
*/
|
||||
buttonElement?: React.ReactNode;
|
||||
|
||||
getAnchorRect?: MenuProps['getAnchorRect'];
|
||||
|
||||
store?: ReturnType<typeof useMenuStore>;
|
||||
|
||||
'aria-labelledby'?: string;
|
||||
|
||||
'aria-label'?: string;
|
||||
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Utility function to get value by it's key or using getter function
|
||||
function getValueByPath<Item, Value>(item: Item, getter: PropertyGetter<Item, Value> | undefined, defaultGetter: (item: Item) => Value): Value {
|
||||
return getter ? getter(item) : defaultGetter(item);
|
||||
}
|
||||
|
||||
function isNonNullObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function MenuField<T, ItemType extends {} = MenuItemData<T>>({
|
||||
items,
|
||||
onChange,
|
||||
itemValue,
|
||||
onSwitch,
|
||||
itemRender,
|
||||
itemDisabled,
|
||||
getAnchorRect,
|
||||
label,
|
||||
noItemsPlaceholder = 'No items',
|
||||
description,
|
||||
disabled,
|
||||
required,
|
||||
className,
|
||||
buttonElement,
|
||||
store,
|
||||
id,
|
||||
}: MenuFieldProps<T, ItemType>): React.ReactElement {
|
||||
const storeState = useStoreState(store);
|
||||
|
||||
const getItemValue = (item: ItemType): T =>
|
||||
getValueByPath<ItemType, T>(item, itemValue, i =>
|
||||
isNonNullObject(i) && 'value' in i ? (i as unknown as MenuItemData<T>).value : (i as unknown as T),
|
||||
);
|
||||
|
||||
const renderItem = (item: ItemType): React.ReactNode =>
|
||||
getValueByPath<ItemType, React.ReactNode>(item, itemRender, i =>
|
||||
isNonNullObject(i) && 'label' in i ? (i as unknown as MenuItemData<T>).label : String(i),
|
||||
);
|
||||
|
||||
const isItemDisabled = (item: ItemType): boolean =>
|
||||
getValueByPath<ItemType, boolean>(item, itemDisabled, i =>
|
||||
isNonNullObject(i) && 'disabled' in i ? Boolean((i as unknown as MenuItemData<T>).disabled) : false,
|
||||
);
|
||||
|
||||
const isOpen = storeState?.open ?? false;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onSwitch?.(isOpen);
|
||||
}, [isOpen, onSwitch]);
|
||||
|
||||
return (
|
||||
<div className={clsx('dbv-kit-menu-field', className)}>
|
||||
<MenuProvider store={store}>
|
||||
{label && <label className={clsx('dbv-kit-menu__label', required && 'dbv-kit-menu__label--required')}>{label}</label>}
|
||||
<MenuButton id={id} disabled={disabled}>
|
||||
{buttonElement ?? <MenuButtonArrow className="dbv-kit-menu__arrow-icon" />}
|
||||
</MenuButton>
|
||||
{description && <span className="dbv-kit-menu__description">{description}</span>}
|
||||
<Menu getAnchorRect={getAnchorRect}>
|
||||
{items.length === 0 ? (
|
||||
<div className="dbv-kit-menu__empty">{noItemsPlaceholder}</div>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<MenuItem key={String(getItemValue(item))} disabled={isItemDisabled(item)} onClick={() => onChange?.(getItemValue(item))}>
|
||||
{renderItem(item)}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--dbv-kit-menu-button-padding-inline: 0;
|
||||
--dbv-kit-menu-foreground: var(--dbv-kit-color-foreground);
|
||||
--dbv-kit-menu-background: var(--dbv-kit-color-background);
|
||||
--dbv-kit-menu-disabled-foreground: var(--dbv-kit-control-disabled-foreground);
|
||||
--dbv-kit-menu-disabled-background: var(--dbv-kit-control-disabled-background);
|
||||
|
||||
--dbv-kit-menu-border-width: var(--dbv-kit-control-border-width);
|
||||
--dbv-kit-menu-border-color: var(--dbv-kit-color-control-inactive);
|
||||
--dbv-kit-menu-border-style: none;
|
||||
--dbv-kit-menu-border-radius: var(--dbv-kit-control-border-radius);
|
||||
--dbv-kit-menu-hover-border-color: var(--dbv-kit-color-control-hover);
|
||||
|
||||
--dbv-kit-menu-font-weight: var(--tw-font-weight-normal);
|
||||
--dbv-kit-menu-font-size: calc(var(--dbv-kit-font-size-base) * 0.875);
|
||||
|
||||
--dbv-kit-menu-popover-background: var(--dbv-kit-color-background);
|
||||
--dbv-kit-menu-popover-foreground: var(--dbv-kit-color-foreground);
|
||||
--dbv-kit-menu-popover-shadow: var(--tw-shadow-md);
|
||||
--dbv-kit-menu-popover-max-height: 18rem;
|
||||
|
||||
--dbv-kit-menu-item-padding-inline: calc(var(--tw-spacing) * 3);
|
||||
--dbv-kit-menu-item-padding-block: var(--tw-spacing);
|
||||
--dbv-kit-menu-item-hover-background: var(--tw-color-gray-200);
|
||||
--dbv-kit-menu-item-hover-foreground: var(--dbv-kit-menu-foreground);
|
||||
--dbv-kit-menu-item-selected-background: var(--dbv-kit-color-primary-100);
|
||||
|
||||
--dbv-kit-menu-separator-color: var(--dbv-kit-color-control-inactive);
|
||||
}
|
||||
}
|
||||
@@ -42,3 +42,5 @@ export { Radio, RadioGroup, useRadioContext, useRadioStore, type RadioProviderPr
|
||||
export { Icon, type IconProps } from './Icon/Icon.js';
|
||||
export * from './utils/clsx.js';
|
||||
export * from './ComponentProvider.js';
|
||||
export * from './Menu/Menu.js';
|
||||
export * from './Menu/MenuField.js';
|
||||
|
||||
Reference in New Issue
Block a user