@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.
Files changed (270) hide show
  1. package/README.md +49 -0
  2. package/dist/Table-DnaoH-h5.js +1806 -0
  3. package/dist/Table-DnaoH-h5.js.map +1 -0
  4. package/dist/components/AppShell/native.css.d.ts +53 -0
  5. package/dist/components/AppShell/native.css.d.ts.map +1 -0
  6. package/dist/components/Button/Button.d.ts.map +1 -1
  7. package/dist/components/ColorMode/ColorMode.d.ts +2 -1
  8. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  9. package/dist/components/ColorMode/storage.d.ts +4 -0
  10. package/dist/components/ColorMode/storage.d.ts.map +1 -0
  11. package/dist/components/ColorMode/styles.css.d.ts +4 -0
  12. package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
  13. package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
  14. package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
  15. package/dist/components/Combobox/Combobox.d.ts +7 -2
  16. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  17. package/dist/components/Combobox/comboboxState.d.ts +29 -0
  18. package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
  19. package/dist/components/Combobox/native.css.d.ts +27 -0
  20. package/dist/components/Combobox/native.css.d.ts.map +1 -0
  21. package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
  22. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
  23. package/dist/components/Drawer/Drawer.d.ts +12 -37
  24. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  25. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  26. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
  27. package/dist/components/Drawer/DrawerSnap.stories.d.ts +22 -0
  28. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
  29. package/dist/components/Drawer/native.css.d.ts +59 -0
  30. package/dist/components/Drawer/native.css.d.ts.map +1 -0
  31. package/dist/components/Drawer/parts.css.d.ts +96 -0
  32. package/dist/components/Drawer/parts.css.d.ts.map +1 -0
  33. package/dist/components/Drawer/parts.d.ts +56 -0
  34. package/dist/components/Drawer/parts.d.ts.map +1 -0
  35. package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
  36. package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
  37. package/dist/components/Drawer/styles.css.d.ts +16 -57
  38. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  39. package/dist/components/Drawer/touchInput.d.ts +35 -0
  40. package/dist/components/Drawer/touchInput.d.ts.map +1 -0
  41. package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
  42. package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
  43. package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
  44. package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
  45. package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
  46. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  47. package/dist/components/Form/FormSubmitContext.d.ts +8 -0
  48. package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
  49. package/dist/components/Listbox/native.css.d.ts +84 -0
  50. package/dist/components/Listbox/native.css.d.ts.map +1 -0
  51. package/dist/components/Menu/Menu.d.ts +7 -1
  52. package/dist/components/Menu/Menu.d.ts.map +1 -1
  53. package/dist/components/Menu/native.css.d.ts +14 -0
  54. package/dist/components/Menu/native.css.d.ts.map +1 -0
  55. package/dist/components/Menu/useMenuOpen.d.ts +16 -0
  56. package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
  57. package/dist/components/Menu/useMenuRoot.d.ts +3 -6
  58. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  59. package/dist/components/Popover/native.css.d.ts +11 -0
  60. package/dist/components/Popover/native.css.d.ts.map +1 -0
  61. package/dist/components/Select/Select.d.ts +9 -2
  62. package/dist/components/Select/Select.d.ts.map +1 -1
  63. package/dist/components/Select/Select.stories.d.ts +11 -0
  64. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  65. package/dist/components/Select/native.css.d.ts +11 -0
  66. package/dist/components/Select/native.css.d.ts.map +1 -0
  67. package/dist/components/Select/selectState.d.ts +21 -0
  68. package/dist/components/Select/selectState.d.ts.map +1 -0
  69. package/dist/components/Select/styles.css.d.ts +10 -0
  70. package/dist/components/Select/styles.css.d.ts.map +1 -1
  71. package/dist/components/Select/useSelectRoot.d.ts +6 -8
  72. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  73. package/dist/components/SideNav/Chevron.d.ts +4 -0
  74. package/dist/components/SideNav/Chevron.d.ts.map +1 -0
  75. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  76. package/dist/components/SideNav/styles.css.d.ts +6 -0
  77. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  78. package/dist/components/SideNav/types.d.ts +6 -0
  79. package/dist/components/SideNav/types.d.ts.map +1 -0
  80. package/dist/components/SplitPane/SplitPane.d.ts +38 -0
  81. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
  82. package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
  83. package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
  84. package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
  85. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
  86. package/dist/components/SplitPane/styles.css.d.ts +78 -0
  87. package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
  88. package/dist/components/Tabs/TabLayout.d.ts +7 -0
  89. package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
  90. package/dist/components/Tabs/Tabs.d.ts.map +1 -1
  91. package/dist/components/Tabs/indicator.d.ts +28 -0
  92. package/dist/components/Tabs/indicator.d.ts.map +1 -0
  93. package/dist/components/Tabs/styles.css.d.ts +4 -0
  94. package/dist/components/Tabs/styles.css.d.ts.map +1 -1
  95. package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
  96. package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
  97. package/dist/components/Tabs/useTabKeys.d.ts +13 -0
  98. package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
  99. package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
  100. package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
  101. package/dist/components/Tag/Tag.d.ts +2 -24
  102. package/dist/components/Tag/Tag.d.ts.map +1 -1
  103. package/dist/components/Tag/styles.css.d.ts +8 -0
  104. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  105. package/dist/components/Tag/types.d.ts +39 -0
  106. package/dist/components/Tag/types.d.ts.map +1 -0
  107. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
  108. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
  109. package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
  110. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  111. package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
  112. package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
  113. package/dist/components/ThemeProvider/overrides.d.ts +16 -0
  114. package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
  115. package/dist/components/Toast/ToastProvider.d.ts +2 -21
  116. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  117. package/dist/components/Toast/native.css.d.ts +81 -0
  118. package/dist/components/Toast/native.css.d.ts.map +1 -0
  119. package/dist/components/Toast/store.d.ts +47 -0
  120. package/dist/components/Toast/store.d.ts.map +1 -0
  121. package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
  122. package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
  123. package/dist/components/Tree/Tree.d.ts.map +1 -1
  124. package/dist/components/Tree/styles.css.d.ts +6 -0
  125. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  126. package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
  127. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
  128. package/dist/components/VirtualList/VirtualList.d.ts +33 -0
  129. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
  130. package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
  131. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
  132. package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
  133. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
  134. package/dist/components/VirtualList/styles.css.d.ts +28 -0
  135. package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
  136. package/dist/index.css +1 -1
  137. package/dist/index.d.ts +4 -2
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +5090 -4381
  140. package/dist/index.js.map +1 -1
  141. package/dist/shared/ButtonHost.d.ts +11 -0
  142. package/dist/shared/ButtonHost.d.ts.map +1 -0
  143. package/dist/shared/pressableChild.d.ts +14 -0
  144. package/dist/shared/pressableChild.d.ts.map +1 -0
  145. package/dist/shared/textOf.d.ts +7 -0
  146. package/dist/shared/textOf.d.ts.map +1 -0
  147. package/dist/table.js +1 -1
  148. package/package.json +3 -2
  149. package/src/components/AppShell/AppShell.meta.ts +1 -1
  150. package/src/components/AppShell/AppShell.native.tsx +174 -0
  151. package/src/components/AppShell/native.css.ts +56 -0
  152. package/src/components/Button/Button.tsx +15 -4
  153. package/src/components/ColorMode/ColorMode.meta.ts +1 -1
  154. package/src/components/ColorMode/ColorMode.tsx +16 -37
  155. package/src/components/ColorMode/storage.native.ts +8 -0
  156. package/src/components/ColorMode/storage.ts +19 -0
  157. package/src/components/ColorMode/styles.css.ts +6 -0
  158. package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
  159. package/src/components/ColorMode/useSystemScheme.ts +19 -0
  160. package/src/components/Combobox/Combobox.meta.ts +1 -1
  161. package/src/components/Combobox/Combobox.native.tsx +295 -0
  162. package/src/components/Combobox/Combobox.tsx +14 -8
  163. package/src/components/Combobox/comboboxState.ts +107 -0
  164. package/src/components/Combobox/native.css.ts +33 -0
  165. package/src/components/Combobox/useComboboxRoot.ts +17 -91
  166. package/src/components/Drawer/Drawer.meta.ts +2 -1
  167. package/src/components/Drawer/Drawer.native.tsx +491 -0
  168. package/src/components/Drawer/Drawer.tsx +65 -143
  169. package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
  170. package/src/components/Drawer/DrawerSnap.stories.tsx +225 -0
  171. package/src/components/Drawer/native.css.ts +62 -0
  172. package/src/components/Drawer/parts.css.ts +125 -0
  173. package/src/components/Drawer/parts.tsx +110 -0
  174. package/src/components/Drawer/sheetPhysics.ts +103 -0
  175. package/src/components/Drawer/styles.css.ts +18 -73
  176. package/src/components/Drawer/touchInput.ts +81 -0
  177. package/src/components/Drawer/useDrawerRoot.ts +90 -0
  178. package/src/components/Drawer/useSheetHeights.ts +42 -0
  179. package/src/components/Drawer/useSwipeDismiss.ts +146 -23
  180. package/src/components/Form/Form.meta.ts +1 -1
  181. package/src/components/Form/Form.native.tsx +120 -0
  182. package/src/components/Form/FormSubmitContext.ts +12 -0
  183. package/src/components/Listbox/Listbox.meta.ts +1 -1
  184. package/src/components/Listbox/Listbox.native.tsx +153 -0
  185. package/src/components/Listbox/native.css.ts +91 -0
  186. package/src/components/Menu/Menu.meta.ts +1 -1
  187. package/src/components/Menu/Menu.native.tsx +268 -0
  188. package/src/components/Menu/Menu.tsx +11 -2
  189. package/src/components/Menu/native.css.ts +18 -0
  190. package/src/components/Menu/useMenuOpen.ts +33 -0
  191. package/src/components/Menu/useMenuRoot.ts +7 -26
  192. package/src/components/Popover/Popover.meta.ts +1 -1
  193. package/src/components/Popover/Popover.native.tsx +205 -0
  194. package/src/components/Popover/native.css.ts +13 -0
  195. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  196. package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
  197. package/src/components/Select/Select.meta.ts +1 -1
  198. package/src/components/Select/Select.native.tsx +224 -0
  199. package/src/components/Select/Select.stories.tsx +49 -1
  200. package/src/components/Select/Select.tsx +20 -3
  201. package/src/components/Select/native.css.ts +13 -0
  202. package/src/components/Select/selectState.ts +36 -0
  203. package/src/components/Select/styles.css.ts +11 -0
  204. package/src/components/Select/useSelectRoot.ts +9 -30
  205. package/src/components/SideNav/Chevron.native.tsx +20 -0
  206. package/src/components/SideNav/Chevron.tsx +20 -0
  207. package/src/components/SideNav/SideNav.meta.ts +1 -1
  208. package/src/components/SideNav/SideNav.tsx +57 -41
  209. package/src/components/SideNav/styles.css.ts +8 -0
  210. package/src/components/SideNav/types.ts +6 -0
  211. package/src/components/SplitPane/SplitPane.meta.ts +40 -0
  212. package/src/components/SplitPane/SplitPane.native.tsx +47 -0
  213. package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
  214. package/src/components/SplitPane/SplitPane.tsx +218 -0
  215. package/src/components/SplitPane/styles.css.ts +81 -0
  216. package/src/components/Tabs/TabLayout.native.tsx +9 -0
  217. package/src/components/Tabs/TabLayout.tsx +6 -0
  218. package/src/components/Tabs/Tabs.meta.ts +1 -1
  219. package/src/components/Tabs/Tabs.tsx +38 -136
  220. package/src/components/Tabs/indicator.ts +36 -0
  221. package/src/components/Tabs/styles.css.ts +7 -0
  222. package/src/components/Tabs/useTabIndicator.native.ts +35 -0
  223. package/src/components/Tabs/useTabIndicator.ts +49 -0
  224. package/src/components/Tabs/useTabKeys.native.ts +2 -0
  225. package/src/components/Tabs/useTabKeys.ts +99 -0
  226. package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
  227. package/src/components/Tag/RemoveGlyph.tsx +10 -0
  228. package/src/components/Tag/Tag.meta.ts +1 -1
  229. package/src/components/Tag/Tag.native.tsx +70 -0
  230. package/src/components/Tag/Tag.tsx +9 -37
  231. package/src/components/Tag/styles.css.ts +9 -0
  232. package/src/components/Tag/types.ts +43 -0
  233. package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
  234. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
  235. package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
  236. package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
  237. package/src/components/ThemeProvider/overrideRule.ts +39 -0
  238. package/src/components/ThemeProvider/overrides.ts +27 -0
  239. package/src/components/Toast/Toast.meta.ts +1 -1
  240. package/src/components/Toast/ToastProvider.native.tsx +226 -0
  241. package/src/components/Toast/ToastProvider.tsx +22 -84
  242. package/src/components/Toast/native.css.ts +78 -0
  243. package/src/components/Toast/store.ts +96 -0
  244. package/src/components/Toggle/Toggle.tsx +3 -3
  245. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  246. package/src/components/Tooltip/Tooltip.native.tsx +36 -0
  247. package/src/components/Tooltip/TooltipHint.ts +13 -0
  248. package/src/components/Tree/Tree.meta.ts +1 -1
  249. package/src/components/Tree/Tree.tsx +23 -135
  250. package/src/components/Tree/styles.css.ts +6 -0
  251. package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
  252. package/src/components/Tree/useTreeKeyboard.ts +152 -0
  253. package/src/components/VirtualList/VirtualList.meta.ts +39 -0
  254. package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
  255. package/src/components/VirtualList/VirtualList.tsx +103 -0
  256. package/src/components/VirtualList/styles.css.ts +31 -0
  257. package/src/form.native.ts +7 -0
  258. package/src/hooks/useContainerBelow.native.ts +30 -0
  259. package/src/index.native.ts +48 -0
  260. package/src/index.ts +9 -1
  261. package/src/shared/ButtonHost.native.tsx +70 -0
  262. package/src/shared/ButtonHost.tsx +12 -0
  263. package/src/shared/focusInput.native.ts +18 -0
  264. package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
  265. package/src/shared/pressableChild.ts +33 -0
  266. package/src/shared/textOf.ts +13 -0
  267. package/src/shared/useKeyboardHeight.native.ts +18 -0
  268. package/src/shared/useScreenReaderEnabled.native.ts +19 -0
  269. package/dist/Table-ColfpgKV.js +0 -1552
  270. 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
- function Popup({children}: {children: ReactNode}) {
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 label = labels[itemValue] ?? itemValue
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 query = inputValue.toLowerCase()
209
- const hasVisibleItems = query
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
- interface UseComboboxRootOptions {
6
- defaultValue?: string
7
- value?: string
8
- onValueChange?: (value: string | null) => void
9
- inputValue?: string
10
- onInputChange?: (value: string) => void
11
- initialLabels?: Record<string, string>
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
- setValue(itemValue)
76
- // Set input to show the selected label
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
- [setValue, labels, setInputValue, close],
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
- const query = inputValue.toLowerCase()
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
- if (!item) return false
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
  ],