diff --git a/common-react/@dbeaver/ui-kit/src/Checkbox/Checkbox.css b/common-react/@dbeaver/ui-kit/src/Checkbox/Checkbox.css index f54727cfc5..070837597b 100644 --- a/common-react/@dbeaver/ui-kit/src/Checkbox/Checkbox.css +++ b/common-react/@dbeaver/ui-kit/src/Checkbox/Checkbox.css @@ -47,12 +47,15 @@ outline-offset: var(--dbv-kit-control-outline-offset); outline-width: var(--dbv-kit-control-outline-width); outline-color: var(--dbv-kit-control-outline-color); - transition-property: stroke-dashoffset; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; stroke-dasharray: 20; stroke-dashoffset: 20; + @media (prefers-reduced-motion: no-preference) { + transition-property: stroke-dashoffset; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } + &[data-focus-visible] { outline-style: solid; } diff --git a/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.css b/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.css new file mode 100644 index 0000000000..1bc1431eaa --- /dev/null +++ b/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.css @@ -0,0 +1,99 @@ +@import './_base.css'; + +@layer base { + .dbv-kit-combobox { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + cursor: pointer; + text-align: left; + width: 100%; + height: var(--dbv-kit-combobox-height); + padding-inline: var(--dbv-kit-combobox-padding-inline); + background-color: var(--dbv-kit-combobox-background); + color: var(--dbv-kit-combobox-foreground); + border-color: var(--dbv-kit-combobox-border-color); + border-radius: var(--dbv-kit-combobox-border-radius); + border-width: var(--dbv-kit-combobox-border-width); + border-style: var(--dbv-kit-combobox-border-style); + font-weight: var(--dbv-kit-combobox-font-weight); + font-size: var(--dbv-kit-combobox-font-size); + + &:not(:disabled):hover { + border-color: var(--dbv-kit-combobox-hover-border-color); + } + + &[aria-disabled='true'] { + opacity: var(--dbv-kit-control-disabled-opacity); + } + &[data-focus-visible], + &:focus-visible { + outline-offset: var(--dbv-kit-control-outline-offset); + outline: var(--dbv-kit-control-outline-width) solid var(--dbv-kit-control-outline-color); + } + } + + .dbv-kit-combobox__popover { + z-index: 1000; + min-width: 100%; + max-height: var(--dbv-kit-combobox-popover-height); + overflow-y: auto; + background-color: var(--dbv-kit-combobox-popover-background); + color: var(--dbv-kit-combobox-popover-foreground); + border-color: var(--dbv-kit-combobox-border-color); + border-radius: var(--dbv-kit-combobox-border-radius); + border-width: var(--dbv-kit-combobox-border-width); + border-style: var(--dbv-kit-combobox-border-style); + box-shadow: var(--dbv-kit-combobox-popover-shadow); + outline: 0; + + @media (prefers-reduced-motion: no-preference) { + opacity: 0; + transform-origin: top; + scale: 0.95; + translate: 0 -0.5rem; + transition-property: opacity, scale, translate; + transition-timing-function: var(--tw-ease-in-out); + transition-duration: 150ms; + + &[data-enter] { + opacity: 1; + scale: 1; + translate: 0; + } + } + } + + .dbv-kit-combobox__item { + display: block; + width: 100%; + padding-inline: var(--dbv-kit-combobox-item-padding-inline); + padding-block: var(--dbv-kit-combobox-item-padding-block); + text-align: left; + cursor: pointer; + border: none; + background: none; + + &[aria-disabled='true'] { + opacity: var(--dbv-kit-control-disabled-opacity); + user-select: none; + } + + &[aria-selected='true'] { + background-color: var(--dbv-kit-combobox-item-selected-background); + } + + &:hover, + &[data-focus-visible], + &[data-active-item] { + color: var(--dbv-kit-combobox-item-hover-foreground); + background-color: var(--dbv-kit-combobox-item-hover-background); + } + } + + .dbv-kit-combobox__empty { + gap: 0.5rem; + padding: 0.5rem; + } +} diff --git a/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.tsx b/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.tsx new file mode 100644 index 0000000000..5dd220498c --- /dev/null +++ b/common-react/@dbeaver/ui-kit/src/Combobox/Combobox.tsx @@ -0,0 +1,60 @@ +/* + * 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 { + ComboboxProvider, + Combobox, + ComboboxPopover as AriaComboboxPopover, + ComboboxItem as AriaComboboxItem, + ComboboxDisclosure, + ComboboxValue, + type ComboboxValueProps, + type ComboboxProviderProps, + type ComboboxProps as AriaComboboxProps, + type ComboboxPopoverProps as AriaComboboxPopoverProps, + type ComboboxItemProps, + useComboboxContext, + useComboboxStore, + useStoreState, +} from '@ariakit/react'; +import clsx from 'clsx'; +import './Combobox.css'; + +export function ComboboxInput(props: AriaComboboxProps) { + return ; +} + +export interface ComboboxPopoverProps extends AriaComboboxPopoverProps { + children?: React.ReactNode; +} + +/** + * ComboboxPopover - Wrapper around AriaKit's popover with default styles + */ +export function ComboboxPopover({ children, className, ...props }: ComboboxPopoverProps) { + return ( + + {children} + + ); +} + +export function ComboboxItem(props: ComboboxItemProps) { + return ; +} + +export { + useComboboxContext, + useComboboxStore, + useStoreState, + ComboboxDisclosure, + ComboboxValue, + ComboboxProvider, + type ComboboxProviderProps, + type ComboboxValueProps, +}; diff --git a/common-react/@dbeaver/ui-kit/src/Combobox/_base.css b/common-react/@dbeaver/ui-kit/src/Combobox/_base.css new file mode 100644 index 0000000000..a08e3ed402 --- /dev/null +++ b/common-react/@dbeaver/ui-kit/src/Combobox/_base.css @@ -0,0 +1,35 @@ +@layer base { + :root { + --dbv-kit-combobox-height: var(--dbv-kit-control-height-medium); + --dbv-kit-combobox-padding-inline: calc(var(--tw-spacing) * 2.5); + + --dbv-kit-combobox-foreground: var(--dbv-kit-color-foreground); + --dbv-kit-combobox-background: var(--dbv-kit-color-background); + --dbv-kit-combobox-disabled-foreground: var(--dbv-kit-control-disabled-foreground); + --dbv-kit-combobox-disabled-background: var(--dbv-kit-control-disabled-background); + + --dbv-kit-combobox-border-width: var(--dbv-kit-control-border-width); + --dbv-kit-combobox-border-color: var(--dbv-kit-color-control-inactive); + --dbv-kit-combobox-border-style: solid; + --dbv-kit-combobox-border-radius: var(--dbv-kit-control-border-radius); + --dbv-kit-combobox-hover-border-color: var(--dbv-kit-color-control-hover); + + --dbv-kit-combobox-font-weight: var(--tw-font-weight-normal); + --dbv-kit-combobox-font-size: calc(var(--dbv-kit-font-size-base) * 0.875); + + --dbv-kit-combobox-label-foreground: var(--dbv-kit-color-control-label); + --dbv-kit-combobox-label-padding-inline: var(--tw-spacing); + + --dbv-kit-combobox-popover-background: var(--dbv-kit-color-background); + --dbv-kit-combobox-popover-foreground: var(--dbv-kit-color-foreground); + --dbv-kit-combobox-popover-shadow: var(--tw-shadow-md); + --dbv-kit-combobox-popover-height: calc(var(--tw-spacing) * 80); + + --dbv-kit-combobox-item-foreground: var(--tw-color-gray-500); + --dbv-kit-combobox-item-padding-inline: calc(var(--tw-spacing) * 3); + --dbv-kit-combobox-item-padding-block: var(--tw-spacing); + --dbv-kit-combobox-item-hover-background: var(--tw-color-gray-200); + --dbv-kit-combobox-item-hover-foreground: var(--dbv-kit-combobox-foreground); + --dbv-kit-combobox-item-selected-background: var(--dbv-kit-color-primary-100); + } +} diff --git a/common-react/@dbeaver/ui-kit/src/Radio/Radio.css b/common-react/@dbeaver/ui-kit/src/Radio/Radio.css index cab310873a..671e0515aa 100644 --- a/common-react/@dbeaver/ui-kit/src/Radio/Radio.css +++ b/common-react/@dbeaver/ui-kit/src/Radio/Radio.css @@ -88,9 +88,12 @@ border-radius: 50%; border: 2px solid var(--dbv-kit-radio-inactive-border); background-color: var(--dbv-kit-radio-inactive-background); - transition: - background-color 0.3s, - border-color 0.3s; + + @media (prefers-reduced-motion: no-preference) { + transition: + background-color 0.3s, + border-color 0.3s; + } &::after { content: ''; @@ -100,9 +103,11 @@ background-color: var(--dbv-kit-radio-active-foreground); scale: 0; - transition-property: scale; - transition-timing-function: var(--tw-ease-in-out); - transition-duration: 0.2s; + @media (prefers-reduced-motion: no-preference) { + transition-property: scale; + transition-timing-function: var(--tw-ease-in-out); + transition-duration: 0.2s; + } } } diff --git a/common-react/@dbeaver/ui-kit/src/Select/Select.css b/common-react/@dbeaver/ui-kit/src/Select/Select.css index 6488ec52a8..68ade9a035 100644 --- a/common-react/@dbeaver/ui-kit/src/Select/Select.css +++ b/common-react/@dbeaver/ui-kit/src/Select/Select.css @@ -57,18 +57,20 @@ box-shadow: var(--dbv-kit-select-popover-shadow); outline: 0; - 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; + @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; + &[data-enter] { + opacity: 1; + scale: 1; + translate: 0; + } } } diff --git a/common-react/@dbeaver/ui-kit/src/Spinner/Spinner.css b/common-react/@dbeaver/ui-kit/src/Spinner/Spinner.css index ea7aaac3dc..b8591af469 100644 --- a/common-react/@dbeaver/ui-kit/src/Spinner/Spinner.css +++ b/common-react/@dbeaver/ui-kit/src/Spinner/Spinner.css @@ -39,6 +39,10 @@ animation: var(--dbv-kit-animate-spin); animation-delay: var(--dbv-kit-spinner-animation-delay); + + @media screen and (prefers-reduced-motion: reduce) { + animation-timing-function: linear; + } } .dbv-kit-spinner__circle { diff --git a/common-react/@dbeaver/ui-kit/src/index.ts b/common-react/@dbeaver/ui-kit/src/index.ts index e93eef64fb..17dda548c3 100644 --- a/common-react/@dbeaver/ui-kit/src/index.ts +++ b/common-react/@dbeaver/ui-kit/src/index.ts @@ -35,6 +35,7 @@ export { type SelectPopoverProps, type SelectItemProps, } from './Select/Select.js'; +export * from './Combobox/Combobox.js'; export { SelectField, type SelectFieldProps, type SelectItem } from './Select/SelectField.js'; export { Spinner, type SpinnerProps } from './Spinner/Spinner.js'; export { Radio, RadioGroup, useRadioContext, useRadioStore, type RadioProviderProps, type RadioProps, type RadioGroupProps } from './Radio/index.js'; diff --git a/common-react/@dbeaver/ui-kit/src/stories/components/Combobox/Combobox.stories.tsx b/common-react/@dbeaver/ui-kit/src/stories/components/Combobox/Combobox.stories.tsx new file mode 100644 index 0000000000..ff37e7b78f --- /dev/null +++ b/common-react/@dbeaver/ui-kit/src/stories/components/Combobox/Combobox.stories.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { ComboboxProvider, ComboboxInput, ComboboxPopover, ComboboxItem } from '../../../Combobox/Combobox.js'; + +function Simple() { + const [value, setValue] = useState(''); + return ( + setValue(item as string)}> + + Apple + Banana + Cherry + + + ); +} + +function FlexibleLayout() { + return ( + +
+ {/* Input in header */} +
+

Food Search

+ +
+ + {/* Results in main content area */} +
+ +
+

🍎 Fruits

+ Apple + Banana + Orange +
+ +
+

🥕 Vegetables

+ Carrot + Broccoli + Spinach +
+
+
+ + {/* Quick access sidebar */} + +
+
+ ); +} + +function MultiInput() { + return ( + +
+ {/* Primary search */} +
+ + +
+ + {/* Quick search */} +
+ + +
+ + {/* Shared results */} + + React + Vue + Angular + Svelte + +
+
+ ); +} + +function DynamicItems() { + const [items, setItems] = useState([ + { id: 1, name: 'Task 1', category: 'work' }, + { id: 2, name: 'Task 2', category: 'personal' }, + { id: 3, name: 'Task 3', category: 'work' }, + ]); + + const [newItemName, setNewItemName] = useState(''); + + const addItem = () => { + if (newItemName.trim()) { + setItems(prev => [ + ...prev, + { + id: Date.now(), + name: newItemName, + category: 'work', + }, + ]); + setNewItemName(''); + } + }; + + const removeItem = (id: number) => { + setItems(prev => prev.filter(item => item.id !== id)); + }; + + return ( + +
+
+ + +
+ setNewItemName(e.target.value)} placeholder="New task name..." /> + +
+
+ + +
+
+

Work Tasks

+ {items + .filter(item => item.category === 'work') + .map(item => ( + +
+ {item.name} + +
+
+ ))} +
+ +
+

Personal Tasks

+ {items + .filter(item => item.category === 'personal') + .map(item => ( + +
+ {item.name} + +
+
+ ))} +
+
+
+
+
+ ); +} + +function MultiSelectExample() { + const [selectedValues, setSelectedValues] = useState([]); + + const items = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'orange', label: 'Orange' }, + { value: 'grape', label: 'Grape' }, + ]; + + return ( + +
+
+ Selected: {selectedValues.join(', ') || 'None'} +
+ + + {items.map(item => ( + + + {item.label} + + ))} + +
+
+ ); +} +export { Simple, FlexibleLayout, MultiInput, DynamicItems, MultiSelectExample }; diff --git a/webapp/packages/core-blocks/src/FormControls/Combobox.css b/webapp/packages/core-blocks/src/FormControls/Combobox.css new file mode 100644 index 0000000000..77b1457ba6 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/Combobox.css @@ -0,0 +1,34 @@ +@layer components { + .dbv-kit-combobox, + .dbv-kit-combobox__popover { + --dbv-kit-combobox-border-width: 2px; + --dbv-kit-combobox-hover-border-color: var(--theme-background); + + --dbv-kit-combobox-border-color: var(--theme-input-border); + --dbv-kit-combobox-foreground: var(--theme-input-color); + --dbv-kit-combobox-background: var(--theme-input-background); + + --dbv-kit-combobox-disabled-background: var(--theme-input-background-disabled); + + --dbv-kit-control-outline-offset: -2px; + + --dbv-kit-combobox-popover-background: var(--theme-input-background); + --dbv-kit-combobox-popover-foreground: var(--theme-input-color); + + --dbv-kit-combobox-item-foreground: var(--theme-secondary); + --dbv-kit-combobox-item-hover-background: var(--theme-background); + --dbv-kit-combobox-item-hover-foreground: var(--theme-input-color); + --dbv-kit-combobox-item-selected-background: none; + } + + .dbv-kit-combobox__item { + --tw-leading: 1; + + display: flex; + align-items: center; + padding-inline: calc(var(--tw-spacing) * 3); + padding-block: calc(var(--tw-spacing) * 2); + gap: calc(var(--tw-spacing) * 2); + line-height: 1; + } +} diff --git a/webapp/packages/core-blocks/src/FormControls/Combobox.module.css b/webapp/packages/core-blocks/src/FormControls/Combobox.module.css deleted file mode 100644 index 5c1d60976b..0000000000 --- a/webapp/packages/core-blocks/src/FormControls/Combobox.module.css +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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. - */ -.field.inline { - display: flex; - align-items: center; - - & .fieldLabel { - padding-right: 8px; - padding-bottom: 0; - width: max-content; - } -} - -.field .input { - margin: 0; -} - -.fieldLabel { - display: block; - padding-bottom: 10px; - composes: theme-typography--body1 from global; - font-weight: 500; -} - -.input { - padding-right: 24px !important; -} - -.input { - font-size: 12px; -} - -.menuButton { - position: absolute; - right: 0; - background: transparent; - outline: none; - display: flex; - align-items: center; - height: 100%; - padding: 0 8px 0 0; - cursor: pointer; - &:hover, - &:focus { - opacity: 0.7; - } -} - -.menuItem { - composes: theme-ripple from global; -} - -.menu { - composes: theme-text-on-surface theme-background-surface theme-typography--caption theme-elevation-z3 from global; - display: flex; - flex-direction: column; - max-height: 300px; - overflow: auto; - outline: none; - z-index: 999; - border-radius: var(--theme-form-element-radius); - - & .menuItem { - background: transparent; - display: flex; - flex-direction: row; - align-items: center; - padding: 8px 12px; - text-align: left; - outline: none; - color: inherit; - cursor: pointer; - gap: 8px; - - & .itemIcon, - & .itemTitle { - position: relative; - } - - & .itemIcon { - width: 16px; - height: 16px; - overflow: hidden; - flex-shrink: 0; - - & .iconOrImage { - width: 100%; - height: 100%; - } - } - } -} - -.icon { - height: 16px; - display: block; -} - -.menuButton .icon.focus { - transform: rotate(180deg); -} -.inputBox { - flex: 1; - position: relative; - display: flex; - align-items: center; - - & .inputIcon { - position: absolute; - left: 0; - width: 16px; - height: 16px; - margin-left: 12px; - - & .iconOrImage { - width: 100%; - height: 100%; - } - - &:not(:empty) + .input { - padding-left: 34px !important; - } - } -} - -.validationInput { - z-index: -1; - opacity: 0; - position: absolute; - width: 100%; - height: 100%; -} diff --git a/webapp/packages/core-blocks/src/FormControls/Combobox.tsx b/webapp/packages/core-blocks/src/FormControls/Combobox.tsx index e338b75973..e3dcd5c876 100644 --- a/webapp/packages/core-blocks/src/FormControls/Combobox.tsx +++ b/webapp/packages/core-blocks/src/FormControls/Combobox.tsx @@ -6,30 +6,33 @@ * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { Menu, MenuButton, MenuItem, useMenuState } from 'reakit'; +import { useCallback, useContext, useId, useState } from 'react'; +import { + ComboboxInput, + ComboboxItem, + clsx, + Spinner, + ComboboxPopover, + ComboboxDisclosure, + ComboboxProvider, + type ComboboxProviderProps, +} from '@dbeaver/ui-kit'; import { filterLayoutFakeProps, getLayoutProps } from '../Containers/filterLayoutFakeProps.js'; import type { ILayoutSizeProps } from '../Containers/ILayoutSizeProps.js'; -import { getComputed } from '../getComputed.js'; -import { Icon } from '../Icon.js'; import { IconOrImage } from '../IconOrImage.js'; -import { Loader } from '../Loader/Loader.js'; import { useTranslate } from '../localization/useTranslate.js'; -import { s } from '../s.js'; -import { useS } from '../useS.js'; -import comboboxStyles from './Combobox.module.css'; import { Field } from './Field.js'; import { FieldDescription } from './FieldDescription.js'; import { FieldLabel } from './FieldLabel.js'; import { FormContext } from './FormContext.js'; +import './Combobox.css'; export type ComboboxBaseProps = Omit< React.InputHTMLAttributes, 'onChange' | 'onSelect' | 'name' | 'value' | 'defaultValue' > & ILayoutSizeProps & { - propertyName?: string; items: TValue[]; defaultValue?: TKey; loading?: boolean; @@ -39,7 +42,6 @@ export type ComboboxBaseProps = Omit< titleSelector?: (item: TValue) => string | undefined; iconSelector?: (item: TValue) => string | React.ReactElement | undefined; isDisabled?: (item: TValue) => boolean; - onSwitch?: (state: boolean) => void; inline?: boolean; }; @@ -47,7 +49,6 @@ type ControlledProps = ComboboxBaseProps & { name?: string; value?: TKey; onSelect?: (value: TKey, name: string | undefined, prev: TKey) => void; - onChange?: (value: string, name: string | undefined) => any; state?: never; }; @@ -55,7 +56,6 @@ type ObjectProps = ComboboxBaseProps< name: TKey; state: TState; onSelect?: (value: TState[TKey], name: TKey | undefined, prev: TState[TKey]) => void; - onChange?: (value: string, name: TKey | undefined) => any; value?: never; }; @@ -64,15 +64,11 @@ export interface ComboboxType { (props: ObjectProps): React.JSX.Element; } -{ - /* TODO rewrite whole component to select attribute instead of input type text so it has an okay form validation */ -} export const Combobox: ComboboxType = observer(function Combobox({ value: controlledValue, defaultValue, name, state, - propertyName, items, loading, children, @@ -87,245 +83,153 @@ export const Combobox: ComboboxType = observer(function Combobox({ iconSelector, titleSelector, isDisabled, - onChange = () => {}, onSelect, - onSwitch, ...rest }: ControlledProps | ObjectProps) { const layoutProps = getLayoutProps(rest); + const inputId = useId(); rest = filterLayoutFakeProps(rest); const translate = useTranslate(); const context = useContext(FormContext); - const menuRef = useRef(null); - const [inputRef, setInputRef] = useState(null); - const styles = useS(comboboxStyles); - - const menu = useMenuState({ - placement: 'bottom-end', - currentId: null, - gutter: 4, - unstable_fixed: true, - }); - - const [searchValue, setSearchValue] = useState(null); - - const filteredItems = getComputed(() => { - const result = items.filter(item => !searchValue || valueSelector(item).toUpperCase().includes(searchValue.toUpperCase())); - - if (isDisabled) { - return result.sort((a, b) => Number(isDisabled(a)) - Number(isDisabled(b))); - } - - return result; - }); - - let value: string | number | readonly string[] | undefined = controlledValue ?? defaultValue ?? undefined; + let selectedKey: string | string[] = controlledValue ?? defaultValue ?? undefined; if (state && name !== undefined && name in state) { - value = state[name]; + selectedKey = state[name]; } - const selectedItem = items.find((item, index) => keySelector(item, index) === value); + const selectedItem = items.find((item, index) => keySelector(item, index) === selectedKey); + const [inputValue, setInputValue] = useState(null); - let inputValue = (selectedItem ? valueSelector(selectedItem) : searchValue) ?? ''; + const selectedValue = selectedItem ? valueSelector(selectedItem) : ''; + const displayValue = inputValue ?? selectedValue; - if (searchValue !== null && selectedItem && valueSelector(selectedItem) !== searchValue) { - inputValue = searchValue; - } + const filteredItems = items + .map((item, index) => { + const itemKey = String(keySelector(item, index)); + const itemValue = valueSelector(item); + const itemTitle = titleSelector?.(item); + const itemIcon = iconSelector?.(item); + const itemDisabled = isDisabled?.(item); - const hideMenu = items.length === 1 && (!!selectedItem || isDisabled?.(items[0]) === true); + const isVisible = inputValue === null || !inputValue.trim() || itemValue.toLowerCase().includes(inputValue.trim().toLowerCase()); - function handleClick() { - if (menu.visible) { - menu.hide(); - } else { - menu.show(); - } - } + return { + item, + index, + itemKey, + itemValue, + itemTitle, + itemIcon, + itemDisabled, + isVisible, + }; + }) + .filter(({ isVisible }) => isVisible); - const handleChange = useCallback( - (event: React.ChangeEvent) => { - const value = event.target.value; - onChange(value, name); - setSearchValue(value); - }, - [name, onChange], - ); - - const handleSelect = useCallback( - (id: any) => { - id = id ?? value ?? ''; - const changed = id !== value; - - menu.hide(); - if (state && changed) { - state[name] = id; - } - if (onSelect && changed) { - onSelect(id, name, value); - } - if (context && changed) { - context.change(id, name); - } - setSearchValue(null); - }, - [value, state, name, menu, context, onSelect], - ); - - const matchItems = useCallback( - (input?: boolean) => { - if (searchValue === null) { + const handleSelect: ComboboxProviderProps['setSelectedValue'] = useCallback( + (selectedValue: string | string[]) => { + const item = items.find((item, idx) => keySelector(item, idx) === selectedValue); + if (!item || selectedValue === selectedKey) { return; } - if (filteredItems.length === 0) { - setSearchValue(null); - return; + if (state) { + state[name] = selectedValue; } - - const filteredItemIndex = items.indexOf(filteredItems[0]); - - if (filteredItems.length === 1) { - handleSelect(keySelector(filteredItems[0], filteredItemIndex)); - return; + if (onSelect) { + onSelect(selectedValue, name, selectedKey); } - - if (filteredItems.length > 0) { - if (input) { - handleSelect(keySelector(filteredItems[0], filteredItemIndex)); - } else { - setSearchValue(null); - } + if (context) { + context.change(selectedValue as string, name); } }, - [items, filteredItems, keySelector, handleSelect, searchValue], + [items, selectedKey, state, onSelect, context, keySelector, name], ); - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - matchItems(true); - } - }, - [matchItems], - ); - - useEffect(() => { - if (inputRef === document.activeElement) { - if (inputValue === searchValue) { - menu.show(); - } - } else { - if (!menu.visible) { - matchItems(); - } - } - }, [inputValue, searchValue, matchItems, menu]); - - useLayoutEffect(() => { - onSwitch?.(menu.visible); - }, [onSwitch, menu.visible]); - - useEffect(() => { - if (!inputRef) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - if (menuRef.current) { - const size = inputRef.getBoundingClientRect(); - menuRef.current.style.width = size.width + 'px'; - } - }); - - resizeObserver.observe(inputRef); - - return () => { - resizeObserver.disconnect(); - }; - }, [inputRef]); - const icon = selectedItem && iconSelector?.(selectedItem); - const focus = menu.visible; - if (loading && items.length === 0) { - inputValue = translate('ui_processing_loading'); + const comboboxDefaultSelectedValue = defaultValue; + let comboboxDefaultValue = undefined; + + if (comboboxDefaultSelectedValue !== undefined) { + const defaultItem = items.find((item, index) => keySelector(item, index) === comboboxDefaultSelectedValue); + + if (defaultItem) { + comboboxDefaultValue = valueSelector(defaultItem); + } } return ( - + {children && ( - + {children} )} -
- - {(icon || loading) && ( -
- {loading ? ( - - ) : typeof icon === 'string' ? ( - - ) : ( - icon - )} -
- )} - - - - - - {!filteredItems.length ? ( - - {translate('combobox_no_results_placeholder')} - + +
+ setInputValue(null)} + {...rest} + /> + {loading ? ( + ) : ( - filteredItems.map((item, index) => { - const icon = iconSelector?.(item); - const title = titleSelector?.(item); - const disabled = isDisabled?.(item); - - return ( - handleSelect(event.currentTarget.id)} + + )} + {icon &&
{typeof icon === 'string' ? : icon}
} + + {filteredItems.length > 0 ? ( + filteredItems.map(({ itemKey, itemValue, itemTitle, itemIcon, itemDisabled }) => ( + { + setInputValue(null); + return false; + }} + className={clsx({ + 'tw:cursor-pointer': !itemDisabled, + 'tw:cursor-not-allowed': itemDisabled, + })} > {iconSelector && ( -
- {icon && typeof icon === 'string' ? : icon} +
+ {itemIcon && typeof itemIcon === 'string' ? : itemIcon}
)} -
{valueSelector(item)}
- - ); - }) - )} -
-
+
{itemValue}
+ + )) + ) : ( +
{translate('combobox_no_results_placeholder')}
+ )} + + + {description && {description}}
); diff --git a/webapp/packages/core-blocks/src/FormControls/Radio.css b/webapp/packages/core-blocks/src/FormControls/Radio.css index 4029c01386..95577033cb 100644 --- a/webapp/packages/core-blocks/src/FormControls/Radio.css +++ b/webapp/packages/core-blocks/src/FormControls/Radio.css @@ -26,9 +26,9 @@ --dbv-kit-radio-inactive-border: color-mix(in srgb, var(--theme-on-secondary), white 30%); --dbv-kit-radio-hover-shadow-color: color-mix(in srgb, var(--theme-on-secondary), white 80%); + } - &__title { - white-space: nowrap; - } + .dbv-kit-radio__title { + white-space: nowrap; } }