@duro-app/ui 5.12.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/Table-DnaoH-h5.js +1806 -0
- package/dist/Table-DnaoH-h5.js.map +1 -0
- package/dist/components/AppShell/native.css.d.ts +53 -0
- package/dist/components/AppShell/native.css.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +12 -37
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +22 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
- package/dist/components/Drawer/native.css.d.ts +59 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.css.d.ts +96 -0
- package/dist/components/Drawer/parts.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.d.ts +56 -0
- package/dist/components/Drawer/parts.d.ts.map +1 -0
- package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
- package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +16 -57
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/touchInput.d.ts +35 -0
- package/dist/components/Drawer/touchInput.d.ts.map +1 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
- package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
- package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +7 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/native.css.d.ts +14 -0
- package/dist/components/Menu/native.css.d.ts.map +1 -0
- package/dist/components/Menu/useMenuOpen.d.ts +16 -0
- package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
- package/dist/components/Menu/useMenuRoot.d.ts +3 -6
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/native.css.d.ts +11 -0
- package/dist/components/Popover/native.css.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/SideNav/Chevron.d.ts +4 -0
- package/dist/components/SideNav/Chevron.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +6 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/components/SideNav/types.d.ts +6 -0
- package/dist/components/SideNav/types.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.d.ts +38 -0
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
- package/dist/components/SplitPane/styles.css.d.ts +78 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
- package/dist/components/Tabs/TabLayout.d.ts +7 -0
- package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/components/Tabs/indicator.d.ts +28 -0
- package/dist/components/Tabs/indicator.d.ts.map +1 -0
- package/dist/components/Tabs/styles.css.d.ts +4 -0
- package/dist/components/Tabs/styles.css.d.ts.map +1 -1
- package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
- package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
- package/dist/components/Tabs/useTabKeys.d.ts +13 -0
- package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
- package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
- package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +2 -24
- package/dist/components/Tag/Tag.d.ts.map +1 -1
- package/dist/components/Tag/styles.css.d.ts +8 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -1
- package/dist/components/Tag/types.d.ts +39 -0
- package/dist/components/Tag/types.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.d.ts +2 -21
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/native.css.d.ts +81 -0
- package/dist/components/Toast/native.css.d.ts.map +1 -0
- package/dist/components/Toast/store.d.ts +47 -0
- package/dist/components/Toast/store.d.ts.map +1 -0
- package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
- package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +6 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
- package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +33 -0
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
- package/dist/components/VirtualList/styles.css.d.ts +28 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5090 -4381
- package/dist/index.js.map +1 -1
- package/dist/shared/ButtonHost.d.ts +11 -0
- package/dist/shared/ButtonHost.d.ts.map +1 -0
- package/dist/shared/pressableChild.d.ts +14 -0
- package/dist/shared/pressableChild.d.ts.map +1 -0
- package/dist/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +3 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +174 -0
- package/src/components/AppShell/native.css.ts +56 -0
- package/src/components/Button/Button.tsx +15 -4
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +491 -0
- package/src/components/Drawer/Drawer.tsx +65 -143
- package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
- package/src/components/Drawer/DrawerSnap.stories.tsx +225 -0
- package/src/components/Drawer/native.css.ts +62 -0
- package/src/components/Drawer/parts.css.ts +125 -0
- package/src/components/Drawer/parts.tsx +110 -0
- package/src/components/Drawer/sheetPhysics.ts +103 -0
- package/src/components/Drawer/styles.css.ts +18 -73
- package/src/components/Drawer/touchInput.ts +81 -0
- package/src/components/Drawer/useDrawerRoot.ts +90 -0
- package/src/components/Drawer/useSheetHeights.ts +42 -0
- package/src/components/Drawer/useSwipeDismiss.ts +146 -23
- package/src/components/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.native.tsx +268 -0
- package/src/components/Menu/Menu.tsx +11 -2
- package/src/components/Menu/native.css.ts +18 -0
- package/src/components/Menu/useMenuOpen.ts +33 -0
- package/src/components/Menu/useMenuRoot.ts +7 -26
- package/src/components/Popover/Popover.meta.ts +1 -1
- package/src/components/Popover/Popover.native.tsx +205 -0
- package/src/components/Popover/native.css.ts +13 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- package/src/components/SideNav/Chevron.native.tsx +20 -0
- package/src/components/SideNav/Chevron.tsx +20 -0
- package/src/components/SideNav/SideNav.meta.ts +1 -1
- package/src/components/SideNav/SideNav.tsx +57 -41
- package/src/components/SideNav/styles.css.ts +8 -0
- package/src/components/SideNav/types.ts +6 -0
- package/src/components/SplitPane/SplitPane.meta.ts +40 -0
- package/src/components/SplitPane/SplitPane.native.tsx +47 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
- package/src/components/SplitPane/SplitPane.tsx +218 -0
- package/src/components/SplitPane/styles.css.ts +81 -0
- package/src/components/Tabs/TabLayout.native.tsx +9 -0
- package/src/components/Tabs/TabLayout.tsx +6 -0
- package/src/components/Tabs/Tabs.meta.ts +1 -1
- package/src/components/Tabs/Tabs.tsx +38 -136
- package/src/components/Tabs/indicator.ts +36 -0
- package/src/components/Tabs/styles.css.ts +7 -0
- package/src/components/Tabs/useTabIndicator.native.ts +35 -0
- package/src/components/Tabs/useTabIndicator.ts +49 -0
- package/src/components/Tabs/useTabKeys.native.ts +2 -0
- package/src/components/Tabs/useTabKeys.ts +99 -0
- package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
- package/src/components/Tag/RemoveGlyph.tsx +10 -0
- package/src/components/Tag/Tag.meta.ts +1 -1
- package/src/components/Tag/Tag.native.tsx +70 -0
- package/src/components/Tag/Tag.tsx +9 -37
- package/src/components/Tag/styles.css.ts +9 -0
- package/src/components/Tag/types.ts +43 -0
- package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -0
- package/src/components/Toast/Toast.meta.ts +1 -1
- package/src/components/Toast/ToastProvider.native.tsx +226 -0
- package/src/components/Toast/ToastProvider.tsx +22 -84
- package/src/components/Toast/native.css.ts +78 -0
- package/src/components/Toast/store.ts +96 -0
- package/src/components/Toggle/Toggle.tsx +3 -3
- package/src/components/Tooltip/Tooltip.meta.ts +1 -1
- package/src/components/Tooltip/Tooltip.native.tsx +36 -0
- package/src/components/Tooltip/TooltipHint.ts +13 -0
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.tsx +23 -135
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
- package/src/components/Tree/useTreeKeyboard.ts +152 -0
- package/src/components/VirtualList/VirtualList.meta.ts +39 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
- package/src/components/VirtualList/VirtualList.tsx +103 -0
- package/src/components/VirtualList/styles.css.ts +31 -0
- package/src/form.native.ts +7 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +48 -0
- package/src/index.ts +9 -1
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/textOf.ts +13 -0
- package/src/shared/useKeyboardHeight.native.ts +18 -0
- package/src/shared/useScreenReaderEnabled.native.ts +19 -0
- package/dist/Table-ColfpgKV.js +0 -1552
- package/dist/Table-ColfpgKV.js.map +0 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type Ref,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import {Pressable} from 'react-native'
|
|
14
|
+
import {html} from 'react-strict-dom'
|
|
15
|
+
import {DURATION_MS} from '@duro-app/tokens/keys'
|
|
16
|
+
import {styles} from './styles.css'
|
|
17
|
+
import {nativeStyles} from './native.css'
|
|
18
|
+
import {matchesQuery, useComboboxState} from './comboboxState'
|
|
19
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
20
|
+
import {Listbox} from '../Listbox/Listbox'
|
|
21
|
+
import {sheetStyles} from '../Listbox/native.css'
|
|
22
|
+
import {Button} from '../Button/Button'
|
|
23
|
+
import {Icon} from '../Icon/Icon'
|
|
24
|
+
import {Chevron} from '../SideNav/Chevron'
|
|
25
|
+
import {focusForAccessibility} from '../../shared/accessibilityFocus'
|
|
26
|
+
import {focusInput} from '../../shared/focusInput'
|
|
27
|
+
import {textOf} from '../../shared/textOf'
|
|
28
|
+
|
|
29
|
+
/** The picker sheet's detents (plan N0, Decision 5); a Combobox opens at large. */
|
|
30
|
+
const SNAP_POINTS = ['medium', 'large'] as const
|
|
31
|
+
|
|
32
|
+
interface NativeComboboxContextValue {
|
|
33
|
+
value: string | null
|
|
34
|
+
open: boolean
|
|
35
|
+
setOpen: (open: boolean) => void
|
|
36
|
+
choose: (value: string) => void
|
|
37
|
+
labels: Record<string, string>
|
|
38
|
+
registerLabel: (value: string, label: string) => void
|
|
39
|
+
/** The labels of the Items rendered now, by value: what Empty matches against */
|
|
40
|
+
items: ReadonlyMap<string, string>
|
|
41
|
+
registerItem: (value: string, label: string) => () => void
|
|
42
|
+
/** What is typed in the sheet's search field */
|
|
43
|
+
query: string
|
|
44
|
+
setQuery: (query: string) => void
|
|
45
|
+
/** The field's placeholder, also the search field's */
|
|
46
|
+
placeholder: string | undefined
|
|
47
|
+
setPlaceholder: (placeholder: string | undefined) => void
|
|
48
|
+
listboxId: string
|
|
49
|
+
triggerRef: RefObject<unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ComboboxContext = createContext<NativeComboboxContextValue | null>(null)
|
|
53
|
+
|
|
54
|
+
function useCombobox() {
|
|
55
|
+
const ctx = useContext(ComboboxContext)
|
|
56
|
+
if (!ctx) throw new Error('Combobox compound components must be used within Combobox.Root')
|
|
57
|
+
return ctx
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True while Popup renders its items unseen, so each registers its label. */
|
|
61
|
+
const CollectContext = createContext(false)
|
|
62
|
+
|
|
63
|
+
interface RootProps {
|
|
64
|
+
/** The web's hidden form input; native has no form post. */
|
|
65
|
+
name?: string
|
|
66
|
+
defaultValue?: string
|
|
67
|
+
value?: string
|
|
68
|
+
onValueChange?: (value: string | null) => void
|
|
69
|
+
/** The field's text: the chosen option's label on native */
|
|
70
|
+
inputValue?: string
|
|
71
|
+
onInputChange?: (value: string) => void
|
|
72
|
+
initialLabels?: Record<string, string>
|
|
73
|
+
children: ReactNode
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Native Combobox (plan N0, Slice C): the field opens a bottom sheet at its
|
|
78
|
+
* large detent, above the keyboard, with a search field pinned under the
|
|
79
|
+
* header and focused; the list below filters as you type, and a press picks
|
|
80
|
+
* an option and closes the sheet (C0 direction A, "Check"). The search text
|
|
81
|
+
* is the sheet's own and starts empty each time it opens.
|
|
82
|
+
*/
|
|
83
|
+
function Root({
|
|
84
|
+
defaultValue,
|
|
85
|
+
value,
|
|
86
|
+
onValueChange,
|
|
87
|
+
inputValue,
|
|
88
|
+
onInputChange,
|
|
89
|
+
initialLabels,
|
|
90
|
+
children,
|
|
91
|
+
}: RootProps) {
|
|
92
|
+
const state = useComboboxState({
|
|
93
|
+
defaultValue,
|
|
94
|
+
value,
|
|
95
|
+
onValueChange,
|
|
96
|
+
inputValue,
|
|
97
|
+
onInputChange,
|
|
98
|
+
initialLabels,
|
|
99
|
+
})
|
|
100
|
+
const {open, setOpen, chooseValue} = state
|
|
101
|
+
const [query, setQuery] = useState('')
|
|
102
|
+
const [items, setItems] = useState<ReadonlyMap<string, string>>(new Map())
|
|
103
|
+
const registerItem = useCallback((itemValue: string, label: string) => {
|
|
104
|
+
setItems((prev) => new Map(prev).set(itemValue, label))
|
|
105
|
+
return () =>
|
|
106
|
+
setItems((prev) => {
|
|
107
|
+
const next = new Map(prev)
|
|
108
|
+
next.delete(itemValue)
|
|
109
|
+
return next
|
|
110
|
+
})
|
|
111
|
+
}, [])
|
|
112
|
+
const [placeholder, setPlaceholder] = useState<string | undefined>(undefined)
|
|
113
|
+
const listboxId = useId()
|
|
114
|
+
const triggerRef = useRef<unknown>(null)
|
|
115
|
+
|
|
116
|
+
const wasOpen = useRef(open)
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (open !== wasOpen.current) setQuery('')
|
|
119
|
+
if (wasOpen.current && !open) focusForAccessibility(triggerRef.current, 'Combobox')
|
|
120
|
+
wasOpen.current = open
|
|
121
|
+
}, [open])
|
|
122
|
+
|
|
123
|
+
const ctx: NativeComboboxContextValue = {
|
|
124
|
+
value: state.value,
|
|
125
|
+
open,
|
|
126
|
+
setOpen,
|
|
127
|
+
choose: chooseValue,
|
|
128
|
+
labels: state.labels,
|
|
129
|
+
registerLabel: state.registerLabel,
|
|
130
|
+
items,
|
|
131
|
+
registerItem,
|
|
132
|
+
query,
|
|
133
|
+
setQuery,
|
|
134
|
+
placeholder,
|
|
135
|
+
setPlaceholder,
|
|
136
|
+
listboxId,
|
|
137
|
+
triggerRef,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<ComboboxContext.Provider value={ctx}>
|
|
142
|
+
<Drawer.Root
|
|
143
|
+
open={open}
|
|
144
|
+
onOpenChange={setOpen}
|
|
145
|
+
anchor="bottom"
|
|
146
|
+
snapPoints={SNAP_POINTS}
|
|
147
|
+
defaultSnapPoint="large"
|
|
148
|
+
>
|
|
149
|
+
<html.div style={[styles.root, nativeStyles.nativeFlex]}>{children}</html.div>
|
|
150
|
+
</Drawer.Root>
|
|
151
|
+
</ComboboxContext.Provider>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The closed field: the chosen option's label, or the placeholder. A press
|
|
157
|
+
* opens the sheet, where the typing happens.
|
|
158
|
+
*/
|
|
159
|
+
function Input({placeholder, children}: {placeholder?: string; children?: ReactNode}) {
|
|
160
|
+
const {value, labels, open, setOpen, setPlaceholder, triggerRef} = useCombobox()
|
|
161
|
+
const display = value ? (labels[value] ?? value) : null
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setPlaceholder(placeholder)
|
|
165
|
+
}, [placeholder, setPlaceholder])
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Pressable
|
|
169
|
+
ref={triggerRef as RefObject<never>}
|
|
170
|
+
onPress={() => setOpen(true)}
|
|
171
|
+
accessibilityRole="combobox"
|
|
172
|
+
accessibilityLabel={display ?? placeholder}
|
|
173
|
+
accessibilityState={{expanded: open}}
|
|
174
|
+
>
|
|
175
|
+
<html.div style={[styles.inputWrapper, nativeStyles.field]}>
|
|
176
|
+
<html.span style={[nativeStyles.fieldText, !display && nativeStyles.fieldPlaceholder]}>
|
|
177
|
+
{display ?? placeholder}
|
|
178
|
+
</html.span>
|
|
179
|
+
{children}
|
|
180
|
+
<html.div style={nativeStyles.chevronDown}>
|
|
181
|
+
<Chevron color="textMuted" />
|
|
182
|
+
</html.div>
|
|
183
|
+
</html.div>
|
|
184
|
+
</Pressable>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The web's open/close button; on native the whole field opens the sheet. */
|
|
189
|
+
function Trigger(_props: {children?: ReactNode}) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface PopupProps {
|
|
194
|
+
children: ReactNode
|
|
195
|
+
/** The sheet's title (native) and the list's accessible name. */
|
|
196
|
+
label?: string
|
|
197
|
+
/** The sheet header's text button that closes it unchanged (native). Default: 'Cancel' */
|
|
198
|
+
cancelLabel?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function Popup({children, label, cancelLabel = 'Cancel'}: PopupProps) {
|
|
202
|
+
const {open, choose, query, setQuery, placeholder, listboxId} = useCombobox()
|
|
203
|
+
const inputRef = useRef<unknown>(null)
|
|
204
|
+
|
|
205
|
+
// Focus the search field once the sheet has entered: a TextInput's
|
|
206
|
+
// autoFocus fires on mount, before iOS has presented the Modal.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!open) return
|
|
209
|
+
const timer = setTimeout(() => focusInput(inputRef.current), DURATION_MS.base)
|
|
210
|
+
return () => clearTimeout(timer)
|
|
211
|
+
}, [open])
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
<CollectContext.Provider value={true}>{children}</CollectContext.Provider>
|
|
216
|
+
<Drawer.Portal>
|
|
217
|
+
<Drawer.Header titleAlign="center">
|
|
218
|
+
{label ? <Drawer.Title>{label}</Drawer.Title> : null}
|
|
219
|
+
<Drawer.Close>
|
|
220
|
+
<Button variant="link">{cancelLabel}</Button>
|
|
221
|
+
</Drawer.Close>
|
|
222
|
+
</Drawer.Header>
|
|
223
|
+
<html.div style={sheetStyles.search}>
|
|
224
|
+
<Icon name="search" size="sm" color="textMuted" />
|
|
225
|
+
<html.input
|
|
226
|
+
ref={inputRef as Ref<never>}
|
|
227
|
+
type="search"
|
|
228
|
+
autoComplete="off"
|
|
229
|
+
autoCapitalize="none"
|
|
230
|
+
spellCheck={false}
|
|
231
|
+
value={query}
|
|
232
|
+
placeholder={placeholder}
|
|
233
|
+
aria-label={label ?? placeholder}
|
|
234
|
+
onChange={(e: {target: {value: string}}) => setQuery(e.target.value)}
|
|
235
|
+
style={sheetStyles.searchInput}
|
|
236
|
+
/>
|
|
237
|
+
</html.div>
|
|
238
|
+
<Drawer.Body flush>
|
|
239
|
+
<Listbox.Root id={listboxId} open={open} onSelect={choose} aria-label={label}>
|
|
240
|
+
{children}
|
|
241
|
+
</Listbox.Root>
|
|
242
|
+
</Drawer.Body>
|
|
243
|
+
</Drawer.Portal>
|
|
244
|
+
</>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface ItemProps {
|
|
249
|
+
value: string
|
|
250
|
+
children: ReactNode
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function Item({value: itemValue, children}: ItemProps) {
|
|
254
|
+
const collecting = useContext(CollectContext)
|
|
255
|
+
const {value, labels, query, registerLabel, registerItem} = useCombobox()
|
|
256
|
+
const label = textOf(children)
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (collecting && label) registerLabel(itemValue, label)
|
|
259
|
+
}, [collecting, itemValue, label, registerLabel])
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (collecting) return registerItem(itemValue, label || itemValue)
|
|
262
|
+
}, [collecting, itemValue, label, registerItem])
|
|
263
|
+
if (collecting || !matchesQuery(labels[itemValue] ?? label, query)) return null
|
|
264
|
+
return (
|
|
265
|
+
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
266
|
+
{children}
|
|
267
|
+
</Listbox.Option>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ItemText({children}: {children: ReactNode}) {
|
|
272
|
+
return <html.span>{children}</html.span>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Shown under the search field when nothing matches what is typed. */
|
|
276
|
+
function Empty({children}: {children: ReactNode}) {
|
|
277
|
+
const collecting = useContext(CollectContext)
|
|
278
|
+
const {query, items} = useCombobox()
|
|
279
|
+
if (collecting) return null
|
|
280
|
+
// Only the Items rendered now count: an initialLabels entry with no Item
|
|
281
|
+
// would otherwise hide Empty while the list shows nothing.
|
|
282
|
+
const anyMatch = [...items.values()].some((label) => matchesQuery(label, query))
|
|
283
|
+
if (anyMatch) return null
|
|
284
|
+
return <Listbox.Empty>{children}</Listbox.Empty>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const Combobox = {
|
|
288
|
+
Root,
|
|
289
|
+
Input,
|
|
290
|
+
Trigger,
|
|
291
|
+
Popup,
|
|
292
|
+
Item,
|
|
293
|
+
ItemText,
|
|
294
|
+
Empty,
|
|
295
|
+
}
|
|
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
|
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {ComboboxContext, useCombobox} from './ComboboxContext'
|
|
5
5
|
import {useComboboxRoot} from './useComboboxRoot'
|
|
6
|
+
import {matchesQuery} from './comboboxState'
|
|
6
7
|
import {Listbox, getAnchorProps} from '../Listbox/Listbox'
|
|
7
8
|
|
|
8
9
|
// --- Root ---
|
|
@@ -134,7 +135,15 @@ function Trigger({children}: {children?: ReactNode}) {
|
|
|
134
135
|
// A Listbox placed under the input, as wide as the field: rendered into the
|
|
135
136
|
// ThemeProvider portal mount so it escapes ancestor `overflow: hidden` /
|
|
136
137
|
// `transform` containing blocks (notably Dialog and Drawer).
|
|
137
|
-
|
|
138
|
+
interface PopupProps {
|
|
139
|
+
children: ReactNode
|
|
140
|
+
/** The list's accessible name; on native also the sheet's title. */
|
|
141
|
+
label?: string
|
|
142
|
+
/** Native only: the sheet header's text button that closes it unchanged. Default: 'Cancel' */
|
|
143
|
+
cancelLabel?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Popup({children, label}: PopupProps) {
|
|
138
147
|
const {open, listboxId, rootRef, highlightedId, setHighlightedId, setValue} = useCombobox()
|
|
139
148
|
const anchor = useCallback(() => rootRef.current?.getBoundingClientRect(), [rootRef])
|
|
140
149
|
|
|
@@ -147,6 +156,7 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
147
156
|
highlightedId={highlightedId}
|
|
148
157
|
onHighlight={setHighlightedId}
|
|
149
158
|
onSelect={setValue}
|
|
159
|
+
aria-label={label}
|
|
150
160
|
maxHeight="listMaxHSm"
|
|
151
161
|
>
|
|
152
162
|
{children}
|
|
@@ -167,9 +177,7 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
167
177
|
const isSelected = selectedValue === itemValue
|
|
168
178
|
|
|
169
179
|
// Filter: check if this item matches the input
|
|
170
|
-
const
|
|
171
|
-
const query = inputValue.toLowerCase()
|
|
172
|
-
const isVisible = !query || label.toLowerCase().includes(query)
|
|
180
|
+
const isVisible = matchesQuery(labels[itemValue] ?? itemValue, inputValue)
|
|
173
181
|
|
|
174
182
|
useEffect(() => {
|
|
175
183
|
const el = ref.current
|
|
@@ -205,10 +213,8 @@ function Empty({children}: {children: ReactNode}) {
|
|
|
205
213
|
if (!open) return null
|
|
206
214
|
|
|
207
215
|
// Check if any items are visible
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
? Object.values(labels).some((label) => label.toLowerCase().includes(query))
|
|
211
|
-
: true
|
|
216
|
+
const hasVisibleItems =
|
|
217
|
+
!inputValue || Object.values(labels).some((label) => matchesQuery(label, inputValue))
|
|
212
218
|
|
|
213
219
|
if (hasVisibleItems) return null
|
|
214
220
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {useCallback, useEffect, useState} from 'react'
|
|
2
|
+
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
3
|
+
|
|
4
|
+
export interface ComboboxStateOptions {
|
|
5
|
+
defaultValue?: string
|
|
6
|
+
value?: string
|
|
7
|
+
onValueChange?: (value: string | null) => void
|
|
8
|
+
inputValue?: string
|
|
9
|
+
onInputChange?: (value: string) => void
|
|
10
|
+
initialLabels?: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether an option's label matches what was typed: a case-insensitive substring. */
|
|
14
|
+
export function matchesQuery(label: string, query: string): boolean {
|
|
15
|
+
return !query || label.toLowerCase().includes(query.toLowerCase())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Combobox's state both platforms share (plan N0, Decision 10): the value
|
|
20
|
+
* and the typed text (each controlled or not), whether it is open, each
|
|
21
|
+
* option's label, choosing an option, and the text/value reconcile on close.
|
|
22
|
+
* Highlight, document order, keys and DOM focus are the web adapter's
|
|
23
|
+
* (useComboboxRoot); the sheet and its pinned input are Combobox.native's.
|
|
24
|
+
*/
|
|
25
|
+
export function useComboboxState({
|
|
26
|
+
defaultValue,
|
|
27
|
+
value: controlledValue,
|
|
28
|
+
onValueChange,
|
|
29
|
+
inputValue: controlledInputValue,
|
|
30
|
+
onInputChange,
|
|
31
|
+
initialLabels,
|
|
32
|
+
}: ComboboxStateOptions) {
|
|
33
|
+
const [value, setValue] = useControllableValue<string | null>(
|
|
34
|
+
controlledValue,
|
|
35
|
+
defaultValue ?? null,
|
|
36
|
+
onValueChange,
|
|
37
|
+
)
|
|
38
|
+
// Seed the visible text from the initially-selected value's label, so a
|
|
39
|
+
// defaultValue/value shows its label on mount — and so the reconcile effect
|
|
40
|
+
// below doesn't see "value set but input empty" and wipe the selection.
|
|
41
|
+
const [internalInputValue, setInternalInputValue] = useState<string>(() => {
|
|
42
|
+
const initial = controlledValue ?? defaultValue
|
|
43
|
+
return initial ? (initialLabels?.[initial] ?? '') : ''
|
|
44
|
+
})
|
|
45
|
+
const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
|
|
46
|
+
const setInputValue = useCallback(
|
|
47
|
+
(v: string) => {
|
|
48
|
+
if (controlledInputValue === undefined) setInternalInputValue(v)
|
|
49
|
+
onInputChange?.(v)
|
|
50
|
+
},
|
|
51
|
+
[controlledInputValue, onInputChange],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const [open, setOpen] = useState(false)
|
|
55
|
+
const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
|
|
56
|
+
|
|
57
|
+
const registerLabel = useCallback((v: string, label: string) => {
|
|
58
|
+
setLabels((prev) => (prev[v] === label ? prev : {...prev, [v]: label}))
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
/** Choose an option: commit it, show its label, close. */
|
|
62
|
+
const chooseValue = useCallback(
|
|
63
|
+
(itemValue: string) => {
|
|
64
|
+
setValue(itemValue)
|
|
65
|
+
setInputValue(labels[itemValue] ?? itemValue)
|
|
66
|
+
setOpen(false)
|
|
67
|
+
},
|
|
68
|
+
[setValue, labels, setInputValue],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Reconcile input text with the committed value once the popup closes.
|
|
72
|
+
// Three cases when transitioning from open → closed:
|
|
73
|
+
// - inputValue is empty: user cleared the field, treat that as
|
|
74
|
+
// "remove the selection" so the consumer's onValueChange fires
|
|
75
|
+
// with null and downstream UI (e.g. dependent fields) can reset.
|
|
76
|
+
// - inputValue doesn't match the selected label: user typed something
|
|
77
|
+
// but didn't pick anything; restore the label so the visible text
|
|
78
|
+
// and the committed value stay consistent.
|
|
79
|
+
// - inputValue equals the selected label: nothing to do.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (open) return
|
|
82
|
+
// Treat an empty value (null OR '') as "no selection" — nothing to
|
|
83
|
+
// reconcile. Without this, a controlled value="" (e.g. a table column
|
|
84
|
+
// filter) fires setValue(null) every render → inline onValueChange →
|
|
85
|
+
// parent state change → re-render → infinite "Maximum update depth" loop.
|
|
86
|
+
if (!value) return
|
|
87
|
+
if (inputValue === '') {
|
|
88
|
+
setValue(null)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (labels[value] && inputValue !== labels[value]) {
|
|
92
|
+
setInputValue(labels[value])
|
|
93
|
+
}
|
|
94
|
+
}, [open, value, labels, inputValue, setInputValue, setValue])
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
value,
|
|
98
|
+
setValue,
|
|
99
|
+
inputValue,
|
|
100
|
+
setInputValue,
|
|
101
|
+
open,
|
|
102
|
+
setOpen,
|
|
103
|
+
labels,
|
|
104
|
+
registerLabel,
|
|
105
|
+
chooseValue,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
|
|
6
|
+
/** Native Combobox additions to the shared styles: the closed field. */
|
|
7
|
+
export const nativeStyles = css.create({
|
|
8
|
+
// RSD on native lays out only display: flex, not the web's inline-flex.
|
|
9
|
+
nativeFlex: {
|
|
10
|
+
display: 'flex',
|
|
11
|
+
},
|
|
12
|
+
// The closed field: the web input's padding, a down chevron at its end.
|
|
13
|
+
field: {
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'row',
|
|
16
|
+
paddingTop: spacing.sm,
|
|
17
|
+
paddingBottom: spacing.sm,
|
|
18
|
+
paddingRight: spacing.sm,
|
|
19
|
+
},
|
|
20
|
+
fieldText: {
|
|
21
|
+
color: colors.text,
|
|
22
|
+
fontFamily: typography.fontFamily,
|
|
23
|
+
fontSize: typography.fontSizeSm,
|
|
24
|
+
},
|
|
25
|
+
// SideNav's chevron points right; a picker's points down.
|
|
26
|
+
chevronDown: {
|
|
27
|
+
display: 'flex',
|
|
28
|
+
transform: 'rotate(90deg)',
|
|
29
|
+
},
|
|
30
|
+
fieldPlaceholder: {
|
|
31
|
+
color: colors.textMuted,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
@@ -1,47 +1,14 @@
|
|
|
1
1
|
import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
|
|
2
|
-
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
3
2
|
import type {ComboboxContextValue} from './ComboboxContext'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export function useComboboxRoot({
|
|
15
|
-
defaultValue,
|
|
16
|
-
value: controlledValue,
|
|
17
|
-
onValueChange,
|
|
18
|
-
inputValue: controlledInputValue,
|
|
19
|
-
onInputChange,
|
|
20
|
-
initialLabels,
|
|
21
|
-
}: UseComboboxRootOptions) {
|
|
22
|
-
const [value, setValue] = useControllableValue<string | null>(
|
|
23
|
-
controlledValue,
|
|
24
|
-
defaultValue ?? null,
|
|
25
|
-
onValueChange,
|
|
26
|
-
)
|
|
27
|
-
// Seed the visible text from the initially-selected value's label, so a
|
|
28
|
-
// defaultValue/value shows its label on mount — and so the reconcile effect
|
|
29
|
-
// below doesn't see "value set but input empty" and wipe the selection.
|
|
30
|
-
const [internalInputValue, setInternalInputValue] = useState<string>(() => {
|
|
31
|
-
const initial = controlledValue ?? defaultValue
|
|
32
|
-
return initial ? (initialLabels?.[initial] ?? '') : ''
|
|
33
|
-
})
|
|
34
|
-
const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
|
|
35
|
-
const setInputValue = useCallback(
|
|
36
|
-
(v: string) => {
|
|
37
|
-
if (controlledInputValue === undefined) setInternalInputValue(v)
|
|
38
|
-
onInputChange?.(v)
|
|
39
|
-
},
|
|
40
|
-
[controlledInputValue, onInputChange],
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
const [open, setOpen] = useState(false)
|
|
44
|
-
const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
|
|
3
|
+
import {matchesQuery, useComboboxState, type ComboboxStateOptions} from './comboboxState'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The web adapter over the shared Combobox state: the highlighted option,
|
|
7
|
+
* document order, keys and DOM focus (the WAI-ARIA editable combobox).
|
|
8
|
+
*/
|
|
9
|
+
export function useComboboxRoot(options: ComboboxStateOptions) {
|
|
10
|
+
const {value, inputValue, setInputValue, open, setOpen, labels, registerLabel, chooseValue} =
|
|
11
|
+
useComboboxState(options)
|
|
45
12
|
const [highlightedId, setHighlightedId] = useState<string | null>(null)
|
|
46
13
|
const listboxId = useId()
|
|
47
14
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
@@ -52,7 +19,7 @@ export function useComboboxRoot({
|
|
|
52
19
|
const close = useCallback(() => {
|
|
53
20
|
setOpen(false)
|
|
54
21
|
setHighlightedId(null)
|
|
55
|
-
}, [])
|
|
22
|
+
}, [setOpen])
|
|
56
23
|
|
|
57
24
|
const openPopup = useCallback(() => {
|
|
58
25
|
setOpen(true)
|
|
@@ -68,27 +35,18 @@ export function useComboboxRoot({
|
|
|
68
35
|
}
|
|
69
36
|
}
|
|
70
37
|
setHighlightedId(found ?? (order.length > 0 ? order[0] : null))
|
|
71
|
-
}, [value])
|
|
38
|
+
}, [value, setOpen])
|
|
72
39
|
|
|
40
|
+
// Choosing keeps focus in the input (the web's editable combobox).
|
|
73
41
|
const selectValue = useCallback(
|
|
74
42
|
(itemValue: string) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const label = labels[itemValue] ?? itemValue
|
|
78
|
-
setInputValue(label)
|
|
79
|
-
close()
|
|
43
|
+
chooseValue(itemValue)
|
|
44
|
+
setHighlightedId(null)
|
|
80
45
|
inputRef.current?.focus()
|
|
81
46
|
},
|
|
82
|
-
[
|
|
47
|
+
[chooseValue],
|
|
83
48
|
)
|
|
84
49
|
|
|
85
|
-
const registerLabel = useCallback((v: string, label: string) => {
|
|
86
|
-
setLabels((prev) => {
|
|
87
|
-
if (prev[v] === label) return prev
|
|
88
|
-
return {...prev, [v]: label}
|
|
89
|
-
})
|
|
90
|
-
}, [])
|
|
91
|
-
|
|
92
50
|
const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
|
|
93
51
|
itemsRef.current.set(id, {value: itemValue, element})
|
|
94
52
|
const map = itemsRef.current
|
|
@@ -108,13 +66,10 @@ export function useComboboxRoot({
|
|
|
108
66
|
|
|
109
67
|
// Get visible items (those matching the filter)
|
|
110
68
|
const getVisibleOrder = useCallback(() => {
|
|
111
|
-
|
|
112
|
-
if (!query) return orderRef.current
|
|
69
|
+
if (!inputValue) return orderRef.current
|
|
113
70
|
return orderRef.current.filter((id) => {
|
|
114
71
|
const item = itemsRef.current.get(id)
|
|
115
|
-
|
|
116
|
-
const label = labels[item.value] ?? item.value
|
|
117
|
-
return label.toLowerCase().includes(query)
|
|
72
|
+
return item ? matchesQuery(labels[item.value] ?? item.value, inputValue) : false
|
|
118
73
|
})
|
|
119
74
|
}, [inputValue, labels])
|
|
120
75
|
|
|
@@ -130,10 +85,6 @@ export function useComboboxRoot({
|
|
|
130
85
|
switch (e.key) {
|
|
131
86
|
case 'ArrowDown': {
|
|
132
87
|
e.preventDefault()
|
|
133
|
-
if (!open) {
|
|
134
|
-
setOpen(true)
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
88
|
setHighlightedId((prev) => {
|
|
138
89
|
const idx = prev ? visibleOrder.indexOf(prev) : -1
|
|
139
90
|
return visibleOrder[(idx + 1) % visibleOrder.length] ?? null
|
|
@@ -196,31 +147,6 @@ export function useComboboxRoot({
|
|
|
196
147
|
item?.element.scrollIntoView({block: 'nearest'})
|
|
197
148
|
}, [highlightedId])
|
|
198
149
|
|
|
199
|
-
// Reconcile input text with the committed value once the popup closes.
|
|
200
|
-
// Three cases when transitioning from open → closed:
|
|
201
|
-
// - inputValue is empty: user cleared the field, treat that as
|
|
202
|
-
// "remove the selection" so the consumer's onValueChange fires
|
|
203
|
-
// with null and downstream UI (e.g. dependent fields) can reset.
|
|
204
|
-
// - inputValue doesn't match the selected label: user typed something
|
|
205
|
-
// but didn't pick anything; restore the label so the visible text
|
|
206
|
-
// and the committed value stay consistent.
|
|
207
|
-
// - inputValue equals the selected label: nothing to do.
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
if (open) return
|
|
210
|
-
// Treat an empty value (null OR '') as "no selection" — nothing to
|
|
211
|
-
// reconcile. Without this, a controlled value="" (e.g. a table column
|
|
212
|
-
// filter) fires setValue(null) every render → inline onValueChange →
|
|
213
|
-
// parent state change → re-render → infinite "Maximum update depth" loop.
|
|
214
|
-
if (!value) return
|
|
215
|
-
if (inputValue === '') {
|
|
216
|
-
setValue(null)
|
|
217
|
-
return
|
|
218
|
-
}
|
|
219
|
-
if (labels[value] && inputValue !== labels[value]) {
|
|
220
|
-
setInputValue(labels[value])
|
|
221
|
-
}
|
|
222
|
-
}, [open, value, labels, inputValue, setInputValue, setValue])
|
|
223
|
-
|
|
224
150
|
const ctx: ComboboxContextValue = useMemo(
|
|
225
151
|
() => ({
|
|
226
152
|
open,
|
|
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Swipe yields to an inner Duro ScrollArea: a drag there scrolls it, and dismisses only toward the edge from the scroll start (a native overflow:auto child is not covered — use ScrollArea). Use for secondary workflows, settings panels, or mobile-friendly detail views.',
|
|
5
|
+
'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Swipe yields to an inner Duro ScrollArea: a drag there scrolls it, and dismisses only toward the edge from the scroll start (a native overflow:auto child is not covered — use ScrollArea). A bottom drawer with snapPoints (medium and large) is a phone sheet: it rests at its detents, a grabber steps through them on a tap, a drag moves between them and dismisses below the lowest; its Body is the scroll the drag defers to. Header titleAlign="center" centres the title with a text close at the end. Body flush drops its side padding, so rows run to the panel\'s edges (a picker\'s list); on React Native a bottom drawer rides above the on-screen keyboard. Use for secondary workflows, settings panels, or mobile-friendly detail views.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Settings or configuration panels that slide in from the side',
|
|
8
8
|
'Mobile-friendly detail views (bottom sheet pattern)',
|
|
9
|
+
'Phone pickers and lists that need more room on demand (snapPoints)',
|
|
9
10
|
'Secondary workflows that need more space than a dialog',
|
|
10
11
|
'Filters, navigation drawers, or form panels',
|
|
11
12
|
],
|