@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
@@ -1,17 +1,10 @@
1
- import {
2
- Children,
3
- type ReactNode,
4
- useCallback,
5
- useContext,
6
- useEffect,
7
- useId,
8
- useLayoutEffect,
9
- useRef,
10
- useState,
11
- } from 'react'
1
+ import {Children, type ReactNode, useCallback, useContext, useId, useRef, useState} from 'react'
12
2
  import {html} from 'react-strict-dom'
3
+ import {SPACING_PX} from '@duro-app/tokens/keys'
13
4
  import {styles} from './styles.css'
5
+ import {isNative} from '../../platform'
14
6
  import {TreeContext, TreeLevelContext, useTree} from './TreeContext'
7
+ import {useTreeKeyboard} from './useTreeKeyboard'
15
8
  import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
16
9
 
17
10
  /* Tree — a hierarchy of items (the WAI-ARIA tree pattern): single selection,
@@ -43,8 +36,6 @@ interface RootProps {
43
36
  onExpandedChange?: (expanded: string[]) => void
44
37
  }
45
38
 
46
- const TYPEAHEAD_RESET_MS = 500
47
-
48
39
  function Root({
49
40
  children,
50
41
  'aria-label': ariaLabel,
@@ -86,126 +77,16 @@ function Root({
86
77
  const isExpanded = useCallback((v: string) => open.includes(v), [open])
87
78
  const toggle = useCallback((v: string) => setExpanded(v, !open.includes(v)), [open, setExpanded])
88
79
 
89
- // Exactly one item is tabbable: the focus holder, else the selection, else
90
- // the first item — re-checked when an item appears, closes or goes away.
91
- useLayoutEffect(() => {
92
- const root = rootRef.current
93
- if (!root) return
94
- const tabbable = root.querySelector('[role="treeitem"][tabindex="0"]')
95
- if (tabbable) return
96
- const first = root.querySelector<HTMLElement>('[role="treeitem"]')
97
- const next = first?.dataset.treeValue ?? null
98
- if (next !== focusValue) setFocusValue(next)
99
- }, [focusValue, open, children])
100
-
101
- // Keyboard: DOM order over the rendered items IS the visible order (a
102
- // closed branch renders no children), so navigation reads it directly.
103
- const typeahead = useRef({text: '', at: 0})
104
- useEffect(() => {
105
- const root = rootRef.current
106
- if (!root) return
107
- const rootEl = root
108
- const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
109
- const focusItem = (el: HTMLElement | null | undefined) => {
110
- if (!el) return
111
- el.focus()
112
- }
113
- function onKeyDown(e: KeyboardEvent) {
114
- const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
115
- if (!current || !rootEl.contains(current)) return
116
- const list = items()
117
- const i = list.indexOf(current)
118
- const val = current.dataset.treeValue ?? ''
119
- const isOpen = current.getAttribute('aria-expanded')
120
- switch (e.key) {
121
- case 'ArrowDown':
122
- e.preventDefault()
123
- focusItem(list[i + 1])
124
- return
125
- case 'ArrowUp':
126
- e.preventDefault()
127
- focusItem(list[i - 1])
128
- return
129
- case 'ArrowRight':
130
- e.preventDefault()
131
- if (isOpen === 'false') setExpanded(val, true)
132
- else if (isOpen === 'true') focusItem(list[i + 1])
133
- return
134
- case 'ArrowLeft': {
135
- e.preventDefault()
136
- if (isOpen === 'true') {
137
- setExpanded(val, false)
138
- return
139
- }
140
- const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
141
- if (parent && rootEl.contains(parent)) focusItem(parent)
142
- return
143
- }
144
- case 'Home':
145
- e.preventDefault()
146
- focusItem(list[0])
147
- return
148
- case 'End':
149
- e.preventDefault()
150
- focusItem(list[list.length - 1])
151
- return
152
- case 'Enter':
153
- case ' ': {
154
- e.preventDefault()
155
- // an item with href: follow its link (the click selects it and
156
- // runs onNavigate, or the browser navigates)
157
- const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
158
- if (link) link.click()
159
- else onSelect(val)
160
- return
161
- }
162
- }
163
- // typeahead: a printable character, no modifier
164
- if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
165
- const now = Date.now()
166
- const t = typeahead.current
167
- t.text = now - t.at > TYPEAHEAD_RESET_MS ? e.key : t.text + e.key
168
- t.at = now
169
- // the same letter typed again cycles through the items it starts;
170
- // a real prefix ("fi") narrows, and may match the current item
171
- const typed = t.text.toLocaleLowerCase()
172
- const repeat = [...typed].every((c) => c === typed[0])
173
- const needle = repeat ? typed[0] : typed
174
- const start = repeat ? i + 1 : i
175
- for (let k = 0; k < list.length; k++) {
176
- const el = list[(start + k) % list.length]
177
- if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
178
- focusItem(el)
179
- break
180
- }
181
- }
182
- }
183
- }
184
- // the roving tab stop follows focus, however it moved (keys or pointer)
185
- function onFocusIn(e: FocusEvent) {
186
- const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
187
- const v = item?.dataset.treeValue
188
- if (v) setFocusValue(v)
189
- setKeyboardFocus(item?.matches(':focus-visible') ?? false)
190
- }
191
- function onFocusOut(e: FocusEvent) {
192
- if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
193
- }
194
- const onPointerDown = () => setKeyboardFocus(false)
195
- const onKey = () => setKeyboardFocus(true)
196
- root.addEventListener('keydown', onKeyDown)
197
- root.addEventListener('keydown', onKey)
198
- root.addEventListener('focusin', onFocusIn)
199
- root.addEventListener('focusout', onFocusOut)
200
- root.addEventListener('pointerdown', onPointerDown)
201
- return () => {
202
- root.removeEventListener('keydown', onKeyDown)
203
- root.removeEventListener('keydown', onKey)
204
- root.removeEventListener('focusin', onFocusIn)
205
- root.removeEventListener('focusout', onFocusOut)
206
- root.removeEventListener('pointerdown', onPointerDown)
207
- }
208
- }, [onSelect, setExpanded])
80
+ useTreeKeyboard({
81
+ rootRef,
82
+ focusValue,
83
+ setFocusValue,
84
+ setKeyboardFocus,
85
+ open,
86
+ children,
87
+ onSelect,
88
+ setExpanded,
89
+ })
209
90
 
210
91
  return (
211
92
  <TreeContext.Provider
@@ -272,7 +153,9 @@ function Item({value, label, textValue, children, meta, href, onNavigate}: ItemP
272
153
  // and a name computed from content would read the whole subtree
273
154
  const labelId = useId()
274
155
  const rowStyles = [
275
- styles.indent(level),
156
+ isNative
157
+ ? styles.indentPx(SPACING_PX.sm + Math.max(0, level - 1) * SPACING_PX.lg)
158
+ : styles.indent(level),
276
159
  selected && styles.rowSelected,
277
160
  keyboardFocus && focusValue === value && styles.rowFocused,
278
161
  ]
@@ -287,7 +170,12 @@ function Item({value, label, textValue, children, meta, href, onNavigate}: ItemP
287
170
  e.preventDefault()
288
171
  toggle(value)
289
172
  }}
290
- style={[styles.chevron, open && styles.chevronOpen, !hasChildren && styles.chevronLeaf]}
173
+ style={[
174
+ styles.chevron,
175
+ isNative && styles.nativeFlex,
176
+ open && styles.chevronOpen,
177
+ !hasChildren && styles.chevronLeaf,
178
+ ]}
291
179
  >
292
180
  ▸
293
181
  </html.span>
@@ -56,6 +56,8 @@ export const styles = css.create({
56
56
  outlineWidth: 0,
57
57
  outlineStyle: 'none',
58
58
  },
59
+ // Native has no calc(): the same steps, in px from the spacing scale.
60
+ indentPx: (px: number) => ({paddingLeft: px}),
59
61
  // one indent step per level below the top
60
62
  indent: (level: number) => ({
61
63
  paddingLeft: `calc(${spacing.sm} + ${Math.max(0, level - 1)} * ${spacing.lg})`,
@@ -71,6 +73,10 @@ export const styles = css.create({
71
73
  fontWeight: typography.fontWeightMedium,
72
74
  backgroundColor: colors.bgCardHover,
73
75
  },
76
+ // RSD on native lays out only display: flex, not the web's inline-flex.
77
+ nativeFlex: {
78
+ display: 'flex',
79
+ },
74
80
  chevron: {
75
81
  display: 'inline-flex',
76
82
  alignItems: 'center',
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Native has no hardware-keyboard tree navigation (plan N0, Decision 6):
3
+ * each item is a screen-reader stop with its role, level and expanded state.
4
+ */
5
+ export function useTreeKeyboard(_input: unknown) {}
@@ -0,0 +1,152 @@
1
+ import {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
2
+
3
+ /** Typed characters within this many ms build one typeahead prefix. */
4
+ const TYPEAHEAD_RESET_MS = 500
5
+
6
+ interface TreeKeyboardInput {
7
+ rootRef: React.RefObject<HTMLUListElement | null>
8
+ focusValue: string | null
9
+ setFocusValue: (value: string | null) => void
10
+ setKeyboardFocus: (on: boolean) => void
11
+ open: ReadonlyArray<string>
12
+ children: ReactNode
13
+ onSelect: (value: string) => void
14
+ setExpanded: (value: string, open: boolean) => void
15
+ }
16
+
17
+ /**
18
+ * Web: the tree's roving tab stop, arrow keys, Home/End, Enter and typeahead
19
+ * (WAI-ARIA tree pattern), read from the rendered DOM. Native has none of
20
+ * them (plan N0, Decision 6): useTreeKeyboard.native.ts.
21
+ */
22
+ export function useTreeKeyboard({
23
+ rootRef,
24
+ focusValue,
25
+ setFocusValue,
26
+ setKeyboardFocus,
27
+ open,
28
+ children,
29
+ onSelect,
30
+ setExpanded,
31
+ }: TreeKeyboardInput) {
32
+ // Exactly one item is tabbable: the focus holder, else the selection, else
33
+ // the first item — re-checked when an item appears, closes or goes away.
34
+ useLayoutEffect(() => {
35
+ const root = rootRef.current
36
+ if (!root) return
37
+ const tabbable = root.querySelector('[role="treeitem"][tabindex="0"]')
38
+ if (tabbable) return
39
+ const first = root.querySelector<HTMLElement>('[role="treeitem"]')
40
+ const next = first?.dataset.treeValue ?? null
41
+ if (next !== focusValue) setFocusValue(next)
42
+ }, [rootRef, focusValue, setFocusValue, open, children])
43
+
44
+ // Keyboard: DOM order over the rendered items IS the visible order (a
45
+ // closed branch renders no children), so navigation reads it directly.
46
+ const typeahead = useRef({text: '', at: 0})
47
+ useEffect(() => {
48
+ const root = rootRef.current
49
+ if (!root) return
50
+ const rootEl = root
51
+ const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
52
+ const focusItem = (el: HTMLElement | null | undefined) => {
53
+ if (!el) return
54
+ el.focus()
55
+ }
56
+ function onKeyDown(e: KeyboardEvent) {
57
+ const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
58
+ if (!current || !rootEl.contains(current)) return
59
+ const list = items()
60
+ const i = list.indexOf(current)
61
+ const val = current.dataset.treeValue ?? ''
62
+ const isOpen = current.getAttribute('aria-expanded')
63
+ switch (e.key) {
64
+ case 'ArrowDown':
65
+ e.preventDefault()
66
+ focusItem(list[i + 1])
67
+ return
68
+ case 'ArrowUp':
69
+ e.preventDefault()
70
+ focusItem(list[i - 1])
71
+ return
72
+ case 'ArrowRight':
73
+ e.preventDefault()
74
+ if (isOpen === 'false') setExpanded(val, true)
75
+ else if (isOpen === 'true') focusItem(list[i + 1])
76
+ return
77
+ case 'ArrowLeft': {
78
+ e.preventDefault()
79
+ if (isOpen === 'true') {
80
+ setExpanded(val, false)
81
+ return
82
+ }
83
+ const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
84
+ if (parent && rootEl.contains(parent)) focusItem(parent)
85
+ return
86
+ }
87
+ case 'Home':
88
+ e.preventDefault()
89
+ focusItem(list[0])
90
+ return
91
+ case 'End':
92
+ e.preventDefault()
93
+ focusItem(list[list.length - 1])
94
+ return
95
+ case 'Enter':
96
+ case ' ': {
97
+ e.preventDefault()
98
+ // an item with href: follow its link (the click selects it and
99
+ // runs onNavigate, or the browser navigates)
100
+ const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
101
+ if (link) link.click()
102
+ else onSelect(val)
103
+ return
104
+ }
105
+ }
106
+ // typeahead: a printable character, no modifier
107
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
108
+ const now = Date.now()
109
+ const t = typeahead.current
110
+ t.text = now - t.at > TYPEAHEAD_RESET_MS ? e.key : t.text + e.key
111
+ t.at = now
112
+ // the same letter typed again cycles through the items it starts;
113
+ // a real prefix ("fi") narrows, and may match the current item
114
+ const typed = t.text.toLocaleLowerCase()
115
+ const repeat = [...typed].every((c) => c === typed[0])
116
+ const needle = repeat ? typed[0] : typed
117
+ const start = repeat ? i + 1 : i
118
+ for (let k = 0; k < list.length; k++) {
119
+ const el = list[(start + k) % list.length]
120
+ if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
121
+ focusItem(el)
122
+ break
123
+ }
124
+ }
125
+ }
126
+ }
127
+ // the roving tab stop follows focus, however it moved (keys or pointer)
128
+ function onFocusIn(e: FocusEvent) {
129
+ const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
130
+ const v = item?.dataset.treeValue
131
+ if (v) setFocusValue(v)
132
+ setKeyboardFocus(item?.matches(':focus-visible') ?? false)
133
+ }
134
+ function onFocusOut(e: FocusEvent) {
135
+ if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
136
+ }
137
+ const onPointerDown = () => setKeyboardFocus(false)
138
+ const onKey = () => setKeyboardFocus(true)
139
+ root.addEventListener('keydown', onKeyDown)
140
+ root.addEventListener('keydown', onKey)
141
+ root.addEventListener('focusin', onFocusIn)
142
+ root.addEventListener('focusout', onFocusOut)
143
+ root.addEventListener('pointerdown', onPointerDown)
144
+ return () => {
145
+ root.removeEventListener('keydown', onKeyDown)
146
+ root.removeEventListener('keydown', onKey)
147
+ root.removeEventListener('focusin', onFocusIn)
148
+ root.removeEventListener('focusout', onFocusOut)
149
+ root.removeEventListener('pointerdown', onPointerDown)
150
+ }
151
+ }, [rootRef, onSelect, setExpanded, setFocusValue, setKeyboardFocus])
152
+ }
@@ -0,0 +1,39 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. Web only: on native, host FlashList or Legend List behind your own .native.tsx leaf.',
6
+ whenToUse: [
7
+ 'Hundreds to tens of thousands of rows: sessions, a chat transcript, log lines',
8
+ 'A list that grows at the bottom while the reader follows it (anchor="end")',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Tabular data with columns and sorting — use VirtualTable',
12
+ 'A few dozen rows — use List; windowing only adds cost there',
13
+ 'Selectable options in a picker — use Listbox',
14
+ ],
15
+ anatomy: {
16
+ required: [],
17
+ optional: [],
18
+ },
19
+ relatedTo: [
20
+ {
21
+ component: 'VirtualTable',
22
+ kind: 'contrast',
23
+ relationship: 'VirtualTable windows table rows with columns; VirtualList windows any row',
24
+ },
25
+ {
26
+ component: 'List',
27
+ kind: 'composition',
28
+ relationship: 'Render a List.Item-like row in renderItem for the list look',
29
+ },
30
+ ],
31
+ example: `<VirtualList
32
+ aria-label="Transcript"
33
+ items={messages}
34
+ getKey={(m) => m.id}
35
+ estimateSize="controlLg"
36
+ anchor="end"
37
+ renderItem={(m) => <Text>{m.text}</Text>}
38
+ />`,
39
+ }
@@ -0,0 +1,95 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import type {SizeToken} from '@duro-app/tokens/keys'
4
+ import {VirtualList} from './VirtualList'
5
+ import {Text} from '../Text/Text'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/VirtualList',
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ const ITEMS = Array.from({length: 10_000}, (_, i) => ({id: i, text: `Message ${i + 1}`}))
15
+
16
+ function Rows({anchor}: {anchor?: 'start' | 'end'}) {
17
+ return (
18
+ <VirtualList
19
+ aria-label="Transcript"
20
+ items={ITEMS}
21
+ getKey={(m) => m.id}
22
+ estimateSize="controlLg"
23
+ anchor={anchor}
24
+ maxHeight="previewMaxH"
25
+ renderItem={(m) => <Text>{m.text}</Text>}
26
+ />
27
+ )
28
+ }
29
+
30
+ interface PlaygroundArgs {
31
+ count: number
32
+ anchor: 'start' | 'end'
33
+ overscan: number
34
+ estimateSize: SizeToken
35
+ }
36
+
37
+ export const Playground: StoryObj<PlaygroundArgs> = {
38
+ args: {count: 10_000, anchor: 'start', overscan: 5, estimateSize: 'controlLg'},
39
+ argTypes: {
40
+ count: {
41
+ control: {type: 'number', min: 0, max: 10_000, step: 500},
42
+ description: 'How many items',
43
+ },
44
+ anchor: {
45
+ control: 'inline-radio',
46
+ options: ['start', 'end'],
47
+ description: "'end' opens at the last item and follows new ones",
48
+ },
49
+ overscan: {
50
+ control: {type: 'number', min: 0, max: 50},
51
+ description: 'Rows rendered past each edge',
52
+ },
53
+ estimateSize: {
54
+ control: 'select',
55
+ options: ['controlSm', 'controlLg', 'touchTarget', 'iconXl'],
56
+ description: "Each row's starting height estimate (a size token)",
57
+ },
58
+ },
59
+ render: ({count, anchor, overscan, estimateSize}) => (
60
+ <VirtualList
61
+ aria-label="Transcript"
62
+ items={ITEMS.slice(0, count)}
63
+ getKey={(m) => m.id}
64
+ estimateSize={estimateSize}
65
+ anchor={anchor}
66
+ overscan={overscan}
67
+ maxHeight="previewMaxH"
68
+ renderItem={(m) => <Text>{m.text}</Text>}
69
+ />
70
+ ),
71
+ }
72
+
73
+ export const TenThousand: Story = {
74
+ render: () => <Rows />,
75
+ play: async ({canvas}) => {
76
+ const list = canvas.getByRole('list', {name: 'Transcript'})
77
+ await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBeGreaterThan(0))
78
+ const rows = canvas.getAllByRole('listitem')
79
+ await expect(rows.length).toBeLessThan(50)
80
+ await expect(rows[0]).toHaveAttribute('aria-setsize', '10000')
81
+ await expect(rows[0]).toHaveAttribute('aria-posinset', '1')
82
+ await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight * 100)
83
+ },
84
+ }
85
+
86
+ export const AnchoredToTheEnd: Story = {
87
+ render: () => <Rows anchor="end" />,
88
+ play: async ({canvas}) => {
89
+ await waitFor(() => expect(canvas.getByText('Message 10000')).toBeInTheDocument())
90
+ const rows = canvas.getAllByRole('listitem')
91
+ await expect(rows.length).toBeLessThan(50)
92
+ const last = rows.find((r) => r.getAttribute('aria-posinset') === '10000')
93
+ await expect(last).toBeDefined()
94
+ },
95
+ }
@@ -0,0 +1,103 @@
1
+ import {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useVirtualizer} from '@tanstack/react-virtual'
4
+ import {SIZES_PX, type SizeToken} from '@duro-app/tokens/keys'
5
+ import {resolveLength, type Length} from '../../shared/length'
6
+ import {styles} from './styles.css'
7
+
8
+ export interface VirtualListProps<T> {
9
+ items: readonly T[]
10
+ /** Each row's starting height estimate, a size token; rows are then measured. */
11
+ estimateSize: SizeToken
12
+ renderItem: (item: T, index: number) => ReactNode
13
+ /** A stable key per item (an id), so rows keep their measurement as items change. */
14
+ getKey: (item: T, index: number) => string | number
15
+ /** Rows rendered beyond each edge of the view. Default 5 */
16
+ overscan?: number
17
+ /**
18
+ * `end` starts at the last item and stays pinned to the bottom while new
19
+ * items arrive, as long as the reader is at the bottom (a transcript, a log).
20
+ * Default `start`.
21
+ */
22
+ anchor?: 'start' | 'end'
23
+ /** Cap the list's height; without it, it fills its parent. */
24
+ maxHeight?: Length
25
+ /** The list's accessible name. */
26
+ 'aria-label': string
27
+ }
28
+
29
+ /** Within this many px of the end counts as "at the bottom" for `anchor="end"`. */
30
+ const BOTTOM_SLOP = SIZES_PX.touchTarget
31
+
32
+ /**
33
+ * A windowed list: only the rows in view (plus `overscan`) are mounted, so a
34
+ * list of tens of thousands stays fast. Rows are measured as they render, so
35
+ * they may differ in height. It is a `role="list"`; each row is a listitem
36
+ * with `aria-setsize` and `aria-posinset`, so assistive tech knows the full
37
+ * length while most rows are not in the DOM. Web only: on native, host
38
+ * FlashList or Legend List behind your own `.native.tsx` leaf.
39
+ */
40
+ export function VirtualList<T>({
41
+ items,
42
+ estimateSize,
43
+ renderItem,
44
+ getKey,
45
+ overscan = 5,
46
+ anchor = 'start',
47
+ maxHeight,
48
+ 'aria-label': ariaLabel,
49
+ }: VirtualListProps<T>) {
50
+ const scrollRef = useRef<HTMLDivElement>(null)
51
+ const virtualizer = useVirtualizer({
52
+ count: items.length,
53
+ getScrollElement: () => scrollRef.current,
54
+ estimateSize: () => SIZES_PX[estimateSize],
55
+ getItemKey: (index) => getKey(items[index], index),
56
+ overscan,
57
+ })
58
+
59
+ // `end`: open at the last item, and follow new items while at the bottom.
60
+ const atBottom = useRef(true)
61
+ useEffect(() => {
62
+ const el = scrollRef.current
63
+ if (!el || anchor !== 'end') return
64
+ const onScroll = () => {
65
+ atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLOP
66
+ }
67
+ el.addEventListener('scroll', onScroll, {passive: true})
68
+ return () => el.removeEventListener('scroll', onScroll)
69
+ }, [anchor])
70
+ useLayoutEffect(() => {
71
+ if (anchor !== 'end' || items.length === 0 || !atBottom.current) return
72
+ virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
73
+ }, [anchor, items.length, virtualizer])
74
+
75
+ const rows = virtualizer.getVirtualItems()
76
+ return (
77
+ <html.div
78
+ ref={scrollRef}
79
+ role="list"
80
+ aria-label={ariaLabel}
81
+ style={[
82
+ styles.scroller,
83
+ maxHeight !== undefined && styles.scrollMax(resolveLength(maxHeight)),
84
+ ]}
85
+ >
86
+ <html.div style={styles.total(virtualizer.getTotalSize())}>
87
+ {rows.map((row) => (
88
+ <html.div
89
+ key={row.key}
90
+ ref={virtualizer.measureElement}
91
+ data-index={row.index}
92
+ role="listitem"
93
+ aria-setsize={items.length}
94
+ aria-posinset={row.index + 1}
95
+ style={styles.row(row.start)}
96
+ >
97
+ {renderItem(items[row.index], row.index)}
98
+ </html.div>
99
+ ))}
100
+ </html.div>
101
+ </html.div>
102
+ )
103
+ }
@@ -0,0 +1,31 @@
1
+ import {css} from 'react-strict-dom'
2
+
3
+ export const styles = css.create({
4
+ // The scroll container: it fills its parent (a pane, a flex column) by
5
+ // default, or stops at `maxHeight`.
6
+ scroller: {
7
+ position: 'relative',
8
+ width: '100%',
9
+ height: '100%',
10
+ minHeight: 0,
11
+ overflowY: 'auto',
12
+ overscrollBehavior: 'contain',
13
+ },
14
+ scrollMax: (maxHeight: number | string) => ({
15
+ height: 'auto',
16
+ maxHeight,
17
+ }),
18
+ // The full virtual height, so the scrollbar is the list's real length.
19
+ total: (height: number) => ({
20
+ position: 'relative',
21
+ width: '100%',
22
+ height,
23
+ }),
24
+ row: (y: number) => ({
25
+ position: 'absolute',
26
+ top: 0,
27
+ left: 0,
28
+ width: '100%',
29
+ transform: `translateY(${y}px)`,
30
+ }),
31
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@duro-app/ui/form` on React Native — resolved via the `react-native` export
3
+ * condition. Form validates the same Effect Schema; a `type="submit"` Button
4
+ * inside it submits through FormSubmitContext, since native has no DOM
5
+ * submit event. See `form.ts` for why Form is a subpath.
6
+ */
7
+ export {Form, type FormProps} from './components/Form/Form'
@@ -0,0 +1,30 @@
1
+ import {useEffect, useState, type RefObject} from 'react'
2
+ import {useWindowDimensions} from 'react-native'
3
+ import {breakpointsPx, type Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
4
+
5
+ interface Measurable {
6
+ measure?: (cb: (x: number, y: number, width: number) => void) => void
7
+ }
8
+
9
+ /**
10
+ * Native: whether the view behind `ref` is narrower than a Duro breakpoint.
11
+ * There is no ResizeObserver; the view is measured after layout and again
12
+ * whenever the window changes size (a rotation, a split screen). `false`
13
+ * until the first measurement, as on the web.
14
+ */
15
+ export function useContainerBelow(ref: RefObject<unknown>, breakpoint: Breakpoint): boolean {
16
+ const [below, setBelow] = useState(false)
17
+ const limit = breakpointsPx[breakpoint]
18
+ const {width: windowWidth} = useWindowDimensions()
19
+
20
+ useEffect(() => {
21
+ const frame = requestAnimationFrame(() => {
22
+ ;(ref.current as Measurable | null)?.measure?.((_x, _y, width) => {
23
+ if (width > 0) setBelow(width < limit)
24
+ })
25
+ })
26
+ return () => cancelAnimationFrame(frame)
27
+ }, [ref, limit, windowWidth])
28
+
29
+ return below
30
+ }