@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,14 +1,14 @@
1
1
  import {useRef, useCallback, useEffect} from 'react'
2
- import type {DrawerAnchor} from './Drawer'
3
-
4
- /** Threshold: fraction of panel size the user must drag to dismiss */
5
- const DISMISS_THRESHOLD = 0.3
6
-
7
- /** Minimum velocity (px/ms) to dismiss even below threshold */
8
- const VELOCITY_THRESHOLD = 0.5
9
-
10
- /** Duration for the dismiss slide-out animation (ms) */
11
- const DISMISS_DURATION = 160
2
+ import type {DrawerAnchor} from './parts'
3
+ import {
4
+ DISMISS_DURATION,
5
+ DRAG_SLOP,
6
+ SNAP_DURATION,
7
+ SNAP_MAX_OVERSHOOT,
8
+ rubberBand,
9
+ settleDetent,
10
+ shouldDismissEdge,
11
+ } from './sheetPhysics'
12
12
 
13
13
  /**
14
14
  * Max pixels the panel visually "grows" when dragged in the opposite direction.
@@ -17,6 +17,16 @@ const DISMISS_DURATION = 160
17
17
  */
18
18
  const OPPOSITE_MAX_OVERSCROLL = 12
19
19
 
20
+ /** A bottom sheet's detents: the drag moves between them before it dismisses. */
21
+ export interface SwipeSnap {
22
+ /** Each detent's height in px, ascending */
23
+ heights: readonly number[]
24
+ /** The detent the sheet rests at */
25
+ index: number
26
+ /** A drag settled on another detent */
27
+ onSnap: (index: number) => void
28
+ }
29
+
20
30
  interface UseSwipeDismissOptions {
21
31
  anchor: DrawerAnchor
22
32
  enabled: boolean
@@ -27,6 +37,8 @@ interface UseSwipeDismissOptions {
27
37
  * exists, and listeners must follow the element that is actually shown.
28
38
  */
29
39
  panel: HTMLDivElement | null
40
+ /** Bottom anchor with snapPoints: drag between detents, dismiss below the lowest */
41
+ snap?: SwipeSnap | null
30
42
  }
31
43
 
32
44
  interface SwipeState {
@@ -39,6 +51,8 @@ interface SwipeState {
39
51
  panelSize: number
40
52
  /** Inner Duro scroll viewport the gesture started in; capture is deferred until the axis is decided */
41
53
  deferredScroll: HTMLElement | null
54
+ /** Started on a snap sheet's grabber: a tap stays its click, a drag moves the sheet */
55
+ fromGrabber: boolean
42
56
  pointerId: number
43
57
  }
44
58
 
@@ -49,7 +63,13 @@ interface SwipeState {
49
63
  * Opposite direction: iOS-style — panel stays anchored, shows subtle
50
64
  * overscroll feedback (box-shadow intensifies), then snaps back.
51
65
  */
52
- export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDismissOptions) {
66
+ export function useSwipeDismiss({
67
+ anchor,
68
+ enabled,
69
+ onDismiss,
70
+ panel,
71
+ snap = null,
72
+ }: UseSwipeDismissOptions) {
53
73
  const stateRef = useRef<SwipeState>({
54
74
  startX: 0,
55
75
  startY: 0,
@@ -59,8 +79,13 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
59
79
  swiping: false,
60
80
  panelSize: 0,
61
81
  deferredScroll: null,
82
+ fromGrabber: false,
62
83
  pointerId: 0,
63
84
  })
85
+ // Read at event time, so a settle that re-renders mid-gesture is never stale.
86
+ const snapRef = useRef(snap)
87
+ snapRef.current = snap
88
+ const snapping = snap !== null
64
89
 
65
90
  const isHorizontal = anchor === 'left' || anchor === 'right'
66
91
 
@@ -117,6 +142,8 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
117
142
  const resetStyles = useCallback((panelEl: HTMLDivElement) => {
118
143
  panelEl.style.transform = ''
119
144
  panelEl.style.boxShadow = ''
145
+ panelEl.style.height = ''
146
+ panelEl.style.transition = ''
120
147
 
121
148
  const backdrop = panelEl.closest('[data-drawer-viewport]')
122
149
  ?.previousElementSibling as HTMLElement | null
@@ -178,19 +205,79 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
178
205
  setTimeout(cleanup, 250)
179
206
  }, [])
180
207
 
208
+ /**
209
+ * Snap sheet drag: the visible height follows the finger. Between the lowest
210
+ * and the largest detent the panel resizes; past the largest it resists;
211
+ * below the lowest it slides down and the backdrop fades.
212
+ */
213
+ const applySnapDrag = useCallback((movement: number, panelEl: HTMLDivElement) => {
214
+ const current = snapRef.current
215
+ if (!current) return
216
+ const {heights, index} = current
217
+ const low = heights[0]
218
+ const high = heights[heights.length - 1]
219
+ const visible = heights[index] - movement
220
+ const backdrop = backdropOf(panelEl)
221
+ if (visible > high) {
222
+ panelEl.style.height = `${high + rubberBand(visible - high, SNAP_MAX_OVERSHOOT)}px`
223
+ panelEl.style.transform = ''
224
+ } else if (visible >= low) {
225
+ panelEl.style.height = `${visible}px`
226
+ panelEl.style.transform = ''
227
+ } else {
228
+ panelEl.style.height = `${low}px`
229
+ panelEl.style.transform = `translateY(${low - visible}px)`
230
+ }
231
+ if (backdrop) backdrop.style.opacity = String(Math.min(1, Math.max(0, visible / low)))
232
+ }, [])
233
+
234
+ /** Settle a snap sheet on a detent, then hand the height back to React */
235
+ const animateToDetent = useCallback((panelEl: HTMLDivElement, target: number) => {
236
+ const current = snapRef.current
237
+ if (!current) return
238
+ const ease = `${SNAP_DURATION}ms cubic-bezier(0.25, 1, 0.5, 1)`
239
+ panelEl.style.transition = `height ${ease}, transform ${ease}`
240
+ panelEl.style.height = `${current.heights[target]}px`
241
+ panelEl.style.transform = ''
242
+ const backdrop = backdropOf(panelEl)
243
+ if (backdrop) {
244
+ backdrop.style.transition = `opacity ${ease}`
245
+ backdrop.style.opacity = ''
246
+ }
247
+ let settled = false
248
+ const done = () => {
249
+ if (settled) return
250
+ settled = true
251
+ current.onSnap(target)
252
+ // React now sizes the panel at this detent: drop the inline copy.
253
+ requestAnimationFrame(() => {
254
+ panelEl.style.transition = ''
255
+ panelEl.style.height = ''
256
+ if (backdrop) backdrop.style.transition = ''
257
+ })
258
+ }
259
+ panelEl.addEventListener('transitionend', done, {once: true})
260
+ setTimeout(done, SNAP_DURATION + 50)
261
+ }, [])
262
+
181
263
  useEffect(() => {
182
264
  if (!enabled || !panel) return
183
265
 
184
266
  function handlePointerDown(e: PointerEvent) {
185
267
  if (e.button !== 0) return
186
268
  const target = e.target as HTMLElement
187
- const scrollEl = target.closest<HTMLElement>('[data-duro-scroll]')
269
+ const fromGrabber = snapping && target.closest('[data-drawer-grabber]') !== null
270
+ const scrollEl = fromGrabber ? null : target.closest<HTMLElement>('[data-duro-scroll]')
188
271
  // Outside a ScrollArea a control keeps its press. Inside one the gesture
189
272
  // is decided on the first move even from a control: a list of toggles is
190
273
  // all buttons, and touch adjustment snaps a finger onto one. A tap
191
274
  // (under the 8px slop) still presses it; a drag never captures before
192
275
  // the decision, so the release lands on the panel, not the control.
193
- if (!scrollEl && target.closest('button, a, input, textarea, select, [role="button"]')) {
276
+ if (
277
+ !fromGrabber &&
278
+ !scrollEl &&
279
+ target.closest('button, a, input, textarea, select, [role="button"]')
280
+ ) {
194
281
  return
195
282
  }
196
283
 
@@ -206,11 +293,13 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
206
293
 
207
294
  state.pointerId = e.pointerId
208
295
  state.deferredScroll = scrollEl
296
+ state.fromGrabber = fromGrabber
209
297
 
210
298
  panel!.dataset.panelSize = String(state.panelSize)
211
299
 
212
- // Inside a Duro ScrollArea the scroll may own the gesture: wait for the first move.
213
- if (!state.deferredScroll) beginCapture(e.pointerId)
300
+ // Inside a Duro ScrollArea the scroll may own the gesture, and a press on
301
+ // the grabber may be a tap: wait for the first move.
302
+ if (!state.deferredScroll && !fromGrabber) beginCapture(e.pointerId)
214
303
  }
215
304
 
216
305
  function beginCapture(pointerId: number) {
@@ -219,6 +308,14 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
219
308
  // Freeze CSS animation — we take over positioning.
220
309
  // Never restore it; the panel is already at its final resting position.
221
310
  panel!.style.animation = 'none'
311
+ // A snap sheet animates its height between detents; the finger drives it now.
312
+ if (snapping) panel!.style.transition = 'none'
313
+ }
314
+
315
+ /** A snap sheet below its largest detent grows when dragged up from its content */
316
+ function canGrow() {
317
+ const current = snapRef.current
318
+ return current !== null && current.index < current.heights.length - 1
222
319
  }
223
320
 
224
321
  /** True when the viewport can't scroll further against the dismiss direction */
@@ -240,7 +337,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
240
337
 
241
338
  if (!state.swiping) {
242
339
  const totalMovement = Math.sqrt(dx * dx + dy * dy)
243
- if (totalMovement < 8) return
340
+ if (totalMovement < DRAG_SLOP) return
244
341
 
245
342
  if (isHorizontal && dx > dy) {
246
343
  state.swiping = true
@@ -250,13 +347,19 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
250
347
  // Wrong axis — abort gesture
251
348
  state.startTime = 0
252
349
  state.deferredScroll = null
350
+ state.fromGrabber = false
253
351
  return
254
352
  }
255
353
 
256
354
  const scrollEl = state.deferredScroll
257
- if (scrollEl) {
355
+ if (state.fromGrabber) {
356
+ beginCapture(state.pointerId)
357
+ } else if (scrollEl) {
258
358
  state.deferredScroll = null
259
- if (getMovement(state) <= 0 || !isAtDismissEdge(scrollEl)) {
359
+ const movement = getMovement(state)
360
+ const sheetOwns =
361
+ movement > 0 ? isAtDismissEdge(scrollEl) : snapping && movement < 0 && canGrow()
362
+ if (!sheetOwns) {
260
363
  // The scroll owns this gesture: no swiping, no capture, no preventDefault
261
364
  state.swiping = false
262
365
  state.startTime = 0
@@ -269,7 +372,8 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
269
372
  if (state.swiping) {
270
373
  e.preventDefault()
271
374
  const movement = getMovement(state)
272
- applyTransform(movement, panel!)
375
+ if (snapping) applySnapDrag(movement, panel!)
376
+ else applyTransform(movement, panel!)
273
377
  }
274
378
  }
275
379
 
@@ -283,6 +387,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
283
387
 
284
388
  state.startTime = 0
285
389
  state.deferredScroll = null
390
+ state.fromGrabber = false
286
391
 
287
392
  if (!state.swiping) {
288
393
  return
@@ -290,16 +395,26 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
290
395
 
291
396
  state.swiping = false
292
397
 
398
+ const current = snapRef.current
399
+ if (current) {
400
+ const settled = settleDetent({
401
+ heights: current.heights,
402
+ from: current.index,
403
+ height: current.heights[current.index] - movement,
404
+ velocity,
405
+ })
406
+ if (settled === 'dismiss') animateDismiss(panel!)
407
+ else animateToDetent(panel!, settled)
408
+ return
409
+ }
410
+
293
411
  if (movement <= 0) {
294
412
  // Was dragging in opposite direction — just reset (no translate was applied)
295
413
  resetStyles(panel!)
296
414
  return
297
415
  }
298
416
 
299
- const fraction = movement / state.panelSize
300
- const shouldDismiss = fraction > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD
301
-
302
- if (shouldDismiss) {
417
+ if (shouldDismissEdge(movement, state.panelSize, velocity)) {
303
418
  animateDismiss(panel!)
304
419
  } else {
305
420
  animateSnapBack(panel!)
@@ -311,6 +426,7 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
311
426
  state.startTime = 0
312
427
  state.swiping = false
313
428
  state.deferredScroll = null
429
+ state.fromGrabber = false
314
430
  resetStyles(panel!)
315
431
  }
316
432
 
@@ -340,8 +456,15 @@ export function useSwipeDismiss({anchor, enabled, onDismiss, panel}: UseSwipeDis
340
456
  isHorizontal,
341
457
  getMovement,
342
458
  applyTransform,
459
+ applySnapDrag,
460
+ animateToDetent,
343
461
  resetStyles,
344
462
  animateSnapBack,
345
463
  animateDismiss,
464
+ snapping,
346
465
  ])
347
466
  }
467
+
468
+ function backdropOf(panelEl: HTMLDivElement) {
469
+ return panelEl.closest('[data-drawer-viewport]')?.previousElementSibling as HTMLElement | null
470
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
5
+ 'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components. On React Native, @duro-app/ui/form resolves to a native Form (a view, not a <form>): a Button type="submit" inside it runs the validated submit, since there is no DOM submit event.',
6
6
  whenToUse: ['Any form that needs validation', 'Multi-field forms with submit handling'],
7
7
  whenNotToUse: [
8
8
  'Single standalone field without validation — use Field.Root directly',
@@ -0,0 +1,120 @@
1
+ import {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {
4
+ useForm,
5
+ FormProvider,
6
+ type UseFormReturn,
7
+ type DefaultValues,
8
+ type FieldValues,
9
+ type FieldErrors,
10
+ type Path,
11
+ } from 'react-hook-form'
12
+ import {effectTsResolver} from '@hookform/resolvers/effect-ts'
13
+ import type {Schema} from 'effect'
14
+ import {FormContext, type LabelPosition, type NecessityIndicator} from './FormContext'
15
+ import {FormSubmitContext} from './FormSubmitContext'
16
+
17
+ export interface FormProps<T extends FieldValues> {
18
+ schema: Schema.Schema<T>
19
+ defaultValues: DefaultValues<T>
20
+ onSubmit: (data: T) => void | Promise<void>
21
+ disabled?: boolean
22
+ labelPosition?: LabelPosition
23
+ necessityIndicator?: NecessityIndicator
24
+ validationErrors?: Partial<Record<Path<T>, string>>
25
+ children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
26
+ }
27
+
28
+ const formStyles = css.create({
29
+ disabled: {
30
+ opacity: 0.5,
31
+ pointerEvents: 'none',
32
+ },
33
+ })
34
+
35
+ /**
36
+ * Native Form (plan N0, Slice C): the web Form's validation and server-error
37
+ * sync, in a view rather than a <form>. A `type="submit"` Button inside it
38
+ * submits through FormSubmitContext.
39
+ */
40
+ export function Form<T extends FieldValues>({
41
+ schema,
42
+ defaultValues,
43
+ onSubmit,
44
+ disabled = false,
45
+ labelPosition = 'top',
46
+ necessityIndicator = false,
47
+ validationErrors,
48
+ children,
49
+ }: FormProps<T>) {
50
+ const methods = useForm<T>({
51
+ resolver: effectTsResolver(schema),
52
+ defaultValues,
53
+ mode: 'onTouched',
54
+ reValidateMode: 'onChange',
55
+ })
56
+
57
+ // Sync server-side validation errors into RHF
58
+ const prevErrorKeysRef = useRef<string[]>([])
59
+ useEffect(() => {
60
+ const prevKeys = prevErrorKeysRef.current
61
+ const nextKeys = validationErrors ? Object.keys(validationErrors) : []
62
+
63
+ // Clear errors that were removed
64
+ for (const key of prevKeys) {
65
+ if (!nextKeys.includes(key)) {
66
+ methods.clearErrors(key as Path<T>)
67
+ }
68
+ }
69
+
70
+ // Set new errors
71
+ if (validationErrors) {
72
+ for (const [key, message] of Object.entries(validationErrors)) {
73
+ if (message) {
74
+ methods.setError(key as Path<T>, {type: 'server', message: message as string})
75
+ }
76
+ }
77
+ }
78
+
79
+ prevErrorKeysRef.current = nextKeys
80
+ }, [validationErrors, methods])
81
+
82
+ const onInvalid = useCallback(
83
+ (errors: FieldErrors<T>) => {
84
+ const firstField = Object.keys(errors)[0]
85
+ if (firstField) {
86
+ methods.setFocus(firstField as Path<T>)
87
+ }
88
+ },
89
+ [methods],
90
+ )
91
+
92
+ // Native has no submit event: a type="submit" Button calls this instead.
93
+ const submit = useCallback(() => {
94
+ // A rejected onSubmit reaches the app's global error handler, not silence.
95
+ methods
96
+ .handleSubmit(onSubmit, onInvalid)()
97
+ .catch((error: unknown) => {
98
+ setTimeout(() => {
99
+ throw error
100
+ })
101
+ })
102
+ }, [methods, onSubmit, onInvalid])
103
+
104
+ const formCtx = useMemo(
105
+ () => ({disabled, labelPosition, necessityIndicator}),
106
+ [disabled, labelPosition, necessityIndicator],
107
+ )
108
+
109
+ return (
110
+ <FormProvider {...methods}>
111
+ <FormContext.Provider value={formCtx}>
112
+ <FormSubmitContext.Provider value={disabled ? null : submit}>
113
+ <html.div aria-disabled={disabled || undefined} style={disabled && formStyles.disabled}>
114
+ {typeof children === 'function' ? children(methods) : children}
115
+ </html.div>
116
+ </FormSubmitContext.Provider>
117
+ </FormContext.Provider>
118
+ </FormProvider>
119
+ )
120
+ }
@@ -0,0 +1,12 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ /**
4
+ * How a `type="submit"` Button submits its Form where there is no DOM submit
5
+ * event: on native, Form provides its validated submit here. The web Form
6
+ * provides nothing; the browser's form submission does it.
7
+ */
8
+ export const FormSubmitContext = createContext<(() => void) | null>(null)
9
+
10
+ export function useFormSubmit() {
11
+ return useContext(FormSubmitContext)
12
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH).',
5
+ 'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH). On React Native it is a plain list laid out where it is rendered (inside a Select or Combobox sheet): nothing is anchored or highlighted, the selected option is filled with a trailing check, and a press calls onSelect.',
6
6
  whenToUse: [
7
7
  'Typeahead inside a rich-text editor, where focus must stay in the editor',
8
8
  'A custom combobox whose input is not Combobox.Input',
@@ -0,0 +1,153 @@
1
+ import {createContext, type ReactNode, type Ref, useContext} from 'react'
2
+ import {View} from 'react-native'
3
+ import {Pressable} from 'react-native-gesture-handler'
4
+ import {html} from 'react-strict-dom'
5
+ import {sheetStyles} from './native.css'
6
+ import {textOf} from '../../shared/textOf'
7
+
8
+ /**
9
+ * Native Listbox (plan N0, Slice C): a plain list of options, laid out where
10
+ * it is rendered — inside a Select or Combobox sheet. Nothing is anchored and
11
+ * nothing is highlighted: native has no arrow keys (Decision 6), so each option
12
+ * is its own screen-reader stop and a press chooses it.
13
+ */
14
+
15
+ export type ListboxMaxHeight = 'listMaxH' | 'listMaxHSm'
16
+
17
+ /** Default option id: the listbox id plus the value, made safe for an id. */
18
+ export function defaultOptionId(listboxId: string, value: string): string {
19
+ return `${listboxId}-option-${value.replace(/[^\w-]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`)}`
20
+ }
21
+
22
+ export interface ListboxAnchorProps {
23
+ role?: 'combobox'
24
+ 'aria-controls': string | undefined
25
+ 'aria-expanded': boolean
26
+ 'aria-activedescendant': string | undefined
27
+ 'aria-autocomplete': 'list'
28
+ 'aria-haspopup': 'listbox'
29
+ }
30
+
31
+ /** The web's anchor ARIA, kept for one API: RSD drops these props on native. */
32
+ export function getAnchorProps({
33
+ id,
34
+ open,
35
+ highlightedId,
36
+ }: {
37
+ id: string
38
+ open: boolean
39
+ highlightedId: string | null | undefined
40
+ }): ListboxAnchorProps {
41
+ return {
42
+ 'aria-controls': open ? id : undefined,
43
+ 'aria-expanded': open,
44
+ 'aria-activedescendant': open && highlightedId ? highlightedId : undefined,
45
+ 'aria-autocomplete': 'list',
46
+ 'aria-haspopup': 'listbox',
47
+ }
48
+ }
49
+
50
+ interface ListboxContextValue {
51
+ onSelect?: (value: string) => void
52
+ }
53
+
54
+ const ListboxContext = createContext<ListboxContextValue | null>(null)
55
+
56
+ function useListbox(part: string) {
57
+ const ctx = useContext(ListboxContext)
58
+ if (!ctx) throw new Error(`Listbox.${part} must be used within Listbox.Root`)
59
+ return ctx
60
+ }
61
+
62
+ interface RootProps {
63
+ id: string
64
+ open: boolean
65
+ /** A press on an option. */
66
+ onSelect?: (value: string) => void
67
+ /** Accessible name of the list. */
68
+ 'aria-label'?: string
69
+ children: ReactNode
70
+ // The web's placement and keyboard props; a native list flows in place.
71
+ anchor?: () => unknown
72
+ matchAnchorWidth?: boolean
73
+ highlightedId?: string | null
74
+ onHighlight?: (id: string | null) => void
75
+ getOptionId?: (value: string) => string
76
+ maxHeight?: ListboxMaxHeight
77
+ }
78
+
79
+ function Root({id, open, onSelect, 'aria-label': ariaLabel, children}: RootProps) {
80
+ if (!open) return null
81
+ return (
82
+ <ListboxContext.Provider value={{onSelect}}>
83
+ <html.div id={id} role="listbox" aria-label={ariaLabel} style={sheetStyles.list}>
84
+ {children}
85
+ </html.div>
86
+ </ListboxContext.Provider>
87
+ )
88
+ }
89
+
90
+ interface OptionProps {
91
+ value: string
92
+ id?: string
93
+ /** The committed choice: filled, with a trailing check. */
94
+ selected?: boolean
95
+ disabled?: boolean
96
+ children: ReactNode
97
+ ref?: Ref<unknown>
98
+ }
99
+
100
+ function Option({value, selected = false, disabled = false, children, ref}: OptionProps) {
101
+ const {onSelect} = useListbox('Option')
102
+ const choose = () => {
103
+ if (!disabled) onSelect?.(value)
104
+ }
105
+ // Two hosts, one job each. The View is what a screen reader reads: a
106
+ // selected button named by its text alone, activated by a double-tap.
107
+ // Gesture Handler's Pressable takes the touch natively, since an RN
108
+ // Pressable loses the first tap with the keyboard up to any ScrollView
109
+ // above the sheet's Modal (keyboardShouldPersistTaps 'never', the default);
110
+ // its native button does not expose a role or state, so it is hidden.
111
+ return (
112
+ <View
113
+ ref={ref as Ref<never>}
114
+ accessible
115
+ accessibilityRole="button"
116
+ accessibilityLabel={textOf(children) || undefined}
117
+ accessibilityState={{selected, disabled}}
118
+ accessibilityActions={ACTIVATE}
119
+ onAccessibilityAction={(e) => {
120
+ if (e.nativeEvent.actionName === 'activate') choose()
121
+ }}
122
+ >
123
+ <Pressable accessible={false} disabled={disabled} onPress={choose}>
124
+ {({pressed}) => (
125
+ <html.div style={[sheetStyles.option, (selected || pressed) && sheetStyles.optionFilled]}>
126
+ <html.span style={[sheetStyles.label, disabled && sheetStyles.labelDisabled]}>
127
+ {children}
128
+ </html.span>
129
+ {selected && (
130
+ <html.span aria-hidden style={sheetStyles.check}>
131
+ ✓
132
+ </html.span>
133
+ )}
134
+ </html.div>
135
+ )}
136
+ </Pressable>
137
+ </View>
138
+ )
139
+ }
140
+
141
+ const ACTIVATE = [{name: 'activate'}] as const
142
+
143
+ /** Shown in place of options when there are none, or none match. */
144
+ function Empty({children}: {children: ReactNode}) {
145
+ return <html.div style={sheetStyles.empty}>{children}</html.div>
146
+ }
147
+
148
+ export const Listbox = {
149
+ Root,
150
+ Option,
151
+ Empty,
152
+ getAnchorProps,
153
+ }