dbeaver/pro#6756 adds Menu to ui-kit, core-blocks + uses it in connection properties form

This commit is contained in:
sergeyteleshev
2025-09-17 16:23:32 +02:00
parent 534526e5e4
commit a59c7aff5d
10 changed files with 560 additions and 11 deletions
@@ -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';