From c3be008ba20a8d55c101dff5a31cbbf086731082 Mon Sep 17 00:00:00 2001 From: Sychev Andrey <44414066+SychevAndrey@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:07:28 +0200 Subject: [PATCH] dbeaver/pro#6283 Add Combobox component (#3597) * dbeaver/pro#6283 feat: add simple Combobox implementation It has fuzzy search inside to filter items. * dbeaver/pro#6283 feat: implement Combobox component with search functionality and update related files * dbeaver/pro#6283 refactor: remove unused onChange prop * dbeaver/pro#6283 style: update imports in Combobox component * dbeaver/pro#6283 feat: update styles, add popoverClassName prop * dbeaver/pro#6283 refactor: keep Combobox composable for better control * dbeaver/pro#6283 refactor: SRP in Combobox, split provider and components * dbeaver/pro#6283 feat: implement advanced search see AdvancedSearch example in stories * dbeaver/pro#6283 refactor: use strict comparison * dbeaver/pro#6283 refactor: replace fuzzy search with standard string search * dbeaver/pro#6283 refactor: simplify Combobox component * dbeaver/pro#6283 feat: add value formatter for ComboboxInput It might be useful for cases when value is not in user friendly format. Combobox doesn't support such transformations from the box Updated examples, added valueFormatter to advanced search, added MultiSelect example * dbeaver/pro#6283 feat: sync styles with select * dbeaver/pro#6283 fix: formatter * dbeaver/pro#6283 fix: respect the user's reduce motion preference * dbeaver/pro#6283 refactor: simplify component remove search context, use some built-in features for input value manipulations and for auto selection of the first item * dbeaver/pro#6283 refactor: use tailwind for Field styles * dbeaver/pro#6283 fix: should open all options on first interaction with any value * dbeaver/pro#6283 refactor: strip Combobox component Due to difficulties with items and search value managing in Combobox using only native combobox store, it was decided to move all that logic to CloudBeaver combobox component. This commit removes all the logic from UIKit Combobox and partially restores it in Cloudbeaver Combobox * dbeaver/pro#6283 feat: show no results when no items * dbeaver/pro#6283 feat: restore input to the last valid value * dbeaver/pro#6283 refactor: simplify restore input logic * dbeaver/pro#6283 refactor: reset input value on blur * dbeaver/pro#6283 refactor: don't sent input value on item click * dbeaver/pro#6283 refactor: remove getComputed for filteredItems getComputed will not work as expected for not primitive values( for example, for arrays) * dbeaver/pro#6283 refactor: remove getComputed for filteredItems * dbeaver/pro#6283 style: update z-index for ComboboxPopover component our backdrop component has 999 z-index(weird?), we need more to show popover in modals * dbeaver/pro#6283 feat: put CB Combobox near the component, fix layers issue * dbeaver/pro#6283 fix: radio title wrap * dbeaver/pro#6283 fix: use the same letter spacing as in other inputs * dbeaver/pro#6283 fix:combobox item styles * dbeaver/pro#6283 fix: clear value on click this fix is for situation when user started to type a word, found an option and clicked on it. Previously we didn't clear value and just setSelectedValue on click. This fix clears value as it happens onBlur and we use the value from selectedItem. --------- Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com> --- .../@dbeaver/ui-kit/src/Checkbox/Checkbox.css | 9 +- .../@dbeaver/ui-kit/src/Combobox/Combobox.css | 99 ++++++ .../@dbeaver/ui-kit/src/Combobox/Combobox.tsx | 60 ++++ .../@dbeaver/ui-kit/src/Combobox/_base.css | 35 ++ .../@dbeaver/ui-kit/src/Radio/Radio.css | 17 +- .../@dbeaver/ui-kit/src/Select/Select.css | 24 +- .../@dbeaver/ui-kit/src/Spinner/Spinner.css | 4 + common-react/@dbeaver/ui-kit/src/index.ts | 1 + .../components/Combobox/Combobox.stories.tsx | 213 +++++++++++ .../core-blocks/src/FormControls/Combobox.css | 34 ++ .../src/FormControls/Combobox.module.css | 137 ------- .../core-blocks/src/FormControls/Combobox.tsx | 336 +++++++----------- .../core-blocks/src/FormControls/Radio.css | 6 +- 13 files changed, 599 insertions(+), 376 deletions(-) create mode 100644 common-react/@dbeaver/ui-kit/src/Combobox/Combobox.css create mode 100644 common-react/@dbeaver/ui-kit/src/Combobox/Combobox.tsx create mode 100644 common-react/@dbeaver/ui-kit/src/Combobox/_base.css create mode 100644 common-react/@dbeaver/ui-kit/src/stories/components/Combobox/Combobox.stories.tsx create mode 100644 webapp/packages/core-blocks/src/FormControls/Combobox.css delete mode 100644 webapp/packages/core-blocks/src/FormControls/Combobox.module.css 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 */} + + Main Search: + + + + {/* Quick 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..." /> + Add Task + + + + + + + Work Tasks + {items + .filter(item => item.category === 'work') + .map(item => ( + + + {item.name} + { + e.stopPropagation(); + removeItem(item.id); + }} + > + ✕ + + + + ))} + + + + Personal Tasks + {items + .filter(item => item.category === 'personal') + .map(item => ( + + + {item.name} + { + e.stopPropagation(); + removeItem(item.id); + }} + > + ✕ + + + + ))} + + + + + + ); +} + +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; } }