@duro-app/ui 5.12.0 → 5.13.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 (188) hide show
  1. package/dist/Table-Bms2ftt3.js +1694 -0
  2. package/dist/Table-Bms2ftt3.js.map +1 -0
  3. package/dist/components/AppShell/native.css.d.ts +53 -0
  4. package/dist/components/AppShell/native.css.d.ts.map +1 -0
  5. package/dist/components/Drawer/Drawer.d.ts +7 -35
  6. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  7. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  8. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
  9. package/dist/components/Drawer/DrawerSnap.stories.d.ts +20 -0
  10. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
  11. package/dist/components/Drawer/native.css.d.ts +55 -0
  12. package/dist/components/Drawer/native.css.d.ts.map +1 -0
  13. package/dist/components/Drawer/parts.css.d.ts +96 -0
  14. package/dist/components/Drawer/parts.css.d.ts.map +1 -0
  15. package/dist/components/Drawer/parts.d.ts +56 -0
  16. package/dist/components/Drawer/parts.d.ts.map +1 -0
  17. package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
  18. package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
  19. package/dist/components/Drawer/styles.css.d.ts +13 -58
  20. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  21. package/dist/components/Drawer/touchInput.d.ts +35 -0
  22. package/dist/components/Drawer/touchInput.d.ts.map +1 -0
  23. package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
  24. package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
  25. package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
  26. package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
  27. package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
  28. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  29. package/dist/components/Menu/Menu.d.ts +7 -1
  30. package/dist/components/Menu/Menu.d.ts.map +1 -1
  31. package/dist/components/Menu/native.css.d.ts +14 -0
  32. package/dist/components/Menu/native.css.d.ts.map +1 -0
  33. package/dist/components/Menu/useMenuOpen.d.ts +16 -0
  34. package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
  35. package/dist/components/Menu/useMenuRoot.d.ts +3 -6
  36. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  37. package/dist/components/Popover/native.css.d.ts +11 -0
  38. package/dist/components/Popover/native.css.d.ts.map +1 -0
  39. package/dist/components/SideNav/Chevron.d.ts +4 -0
  40. package/dist/components/SideNav/Chevron.d.ts.map +1 -0
  41. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/components/SideNav/styles.css.d.ts +6 -0
  43. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  44. package/dist/components/SideNav/types.d.ts +6 -0
  45. package/dist/components/SideNav/types.d.ts.map +1 -0
  46. package/dist/components/SplitPane/SplitPane.d.ts +38 -0
  47. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
  48. package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
  49. package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
  50. package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
  51. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
  52. package/dist/components/SplitPane/styles.css.d.ts +78 -0
  53. package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
  54. package/dist/components/Tabs/TabLayout.d.ts +7 -0
  55. package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
  56. package/dist/components/Tabs/Tabs.d.ts.map +1 -1
  57. package/dist/components/Tabs/indicator.d.ts +28 -0
  58. package/dist/components/Tabs/indicator.d.ts.map +1 -0
  59. package/dist/components/Tabs/styles.css.d.ts +4 -0
  60. package/dist/components/Tabs/styles.css.d.ts.map +1 -1
  61. package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
  62. package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
  63. package/dist/components/Tabs/useTabKeys.d.ts +13 -0
  64. package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
  65. package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
  66. package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
  67. package/dist/components/Tag/Tag.d.ts +2 -24
  68. package/dist/components/Tag/Tag.d.ts.map +1 -1
  69. package/dist/components/Tag/styles.css.d.ts +8 -0
  70. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  71. package/dist/components/Tag/types.d.ts +39 -0
  72. package/dist/components/Tag/types.d.ts.map +1 -0
  73. package/dist/components/Toast/ToastProvider.d.ts +2 -21
  74. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  75. package/dist/components/Toast/native.css.d.ts +81 -0
  76. package/dist/components/Toast/native.css.d.ts.map +1 -0
  77. package/dist/components/Toast/store.d.ts +47 -0
  78. package/dist/components/Toast/store.d.ts.map +1 -0
  79. package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
  80. package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
  81. package/dist/components/Tree/Tree.d.ts.map +1 -1
  82. package/dist/components/Tree/styles.css.d.ts +6 -0
  83. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  84. package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
  85. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
  86. package/dist/components/VirtualList/VirtualList.d.ts +33 -0
  87. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
  88. package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
  89. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
  90. package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
  91. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
  92. package/dist/components/VirtualList/styles.css.d.ts +28 -0
  93. package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
  94. package/dist/index.css +1 -1
  95. package/dist/index.d.ts +3 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +4935 -4260
  98. package/dist/index.js.map +1 -1
  99. package/dist/shared/ButtonHost.d.ts +11 -0
  100. package/dist/shared/ButtonHost.d.ts.map +1 -0
  101. package/dist/shared/pressableChild.d.ts +14 -0
  102. package/dist/shared/pressableChild.d.ts.map +1 -0
  103. package/dist/table.js +1 -1
  104. package/package.json +2 -2
  105. package/src/components/AppShell/AppShell.meta.ts +1 -1
  106. package/src/components/AppShell/AppShell.native.tsx +174 -0
  107. package/src/components/AppShell/native.css.ts +56 -0
  108. package/src/components/Button/Button.tsx +3 -3
  109. package/src/components/Drawer/Drawer.meta.ts +2 -1
  110. package/src/components/Drawer/Drawer.native.tsx +469 -0
  111. package/src/components/Drawer/Drawer.tsx +58 -145
  112. package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
  113. package/src/components/Drawer/DrawerSnap.stories.tsx +205 -0
  114. package/src/components/Drawer/native.css.ts +58 -0
  115. package/src/components/Drawer/parts.css.ts +125 -0
  116. package/src/components/Drawer/parts.tsx +110 -0
  117. package/src/components/Drawer/sheetPhysics.ts +103 -0
  118. package/src/components/Drawer/styles.css.ts +15 -74
  119. package/src/components/Drawer/touchInput.ts +81 -0
  120. package/src/components/Drawer/useDrawerRoot.ts +90 -0
  121. package/src/components/Drawer/useSheetHeights.ts +42 -0
  122. package/src/components/Drawer/useSwipeDismiss.ts +146 -23
  123. package/src/components/Menu/Menu.meta.ts +1 -1
  124. package/src/components/Menu/Menu.native.tsx +268 -0
  125. package/src/components/Menu/Menu.tsx +11 -2
  126. package/src/components/Menu/native.css.ts +18 -0
  127. package/src/components/Menu/useMenuOpen.ts +33 -0
  128. package/src/components/Menu/useMenuRoot.ts +7 -26
  129. package/src/components/Popover/Popover.meta.ts +1 -1
  130. package/src/components/Popover/Popover.native.tsx +205 -0
  131. package/src/components/Popover/native.css.ts +13 -0
  132. package/src/components/SideNav/Chevron.native.tsx +20 -0
  133. package/src/components/SideNav/Chevron.tsx +20 -0
  134. package/src/components/SideNav/SideNav.meta.ts +1 -1
  135. package/src/components/SideNav/SideNav.tsx +57 -41
  136. package/src/components/SideNav/styles.css.ts +8 -0
  137. package/src/components/SideNav/types.ts +6 -0
  138. package/src/components/SplitPane/SplitPane.meta.ts +40 -0
  139. package/src/components/SplitPane/SplitPane.native.tsx +47 -0
  140. package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
  141. package/src/components/SplitPane/SplitPane.tsx +218 -0
  142. package/src/components/SplitPane/styles.css.ts +81 -0
  143. package/src/components/Tabs/TabLayout.native.tsx +9 -0
  144. package/src/components/Tabs/TabLayout.tsx +6 -0
  145. package/src/components/Tabs/Tabs.meta.ts +1 -1
  146. package/src/components/Tabs/Tabs.tsx +38 -136
  147. package/src/components/Tabs/indicator.ts +36 -0
  148. package/src/components/Tabs/styles.css.ts +7 -0
  149. package/src/components/Tabs/useTabIndicator.native.ts +35 -0
  150. package/src/components/Tabs/useTabIndicator.ts +49 -0
  151. package/src/components/Tabs/useTabKeys.native.ts +2 -0
  152. package/src/components/Tabs/useTabKeys.ts +99 -0
  153. package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
  154. package/src/components/Tag/RemoveGlyph.tsx +10 -0
  155. package/src/components/Tag/Tag.meta.ts +1 -1
  156. package/src/components/Tag/Tag.native.tsx +70 -0
  157. package/src/components/Tag/Tag.tsx +9 -37
  158. package/src/components/Tag/styles.css.ts +9 -0
  159. package/src/components/Tag/types.ts +43 -0
  160. package/src/components/Toast/Toast.meta.ts +1 -1
  161. package/src/components/Toast/ToastProvider.native.tsx +226 -0
  162. package/src/components/Toast/ToastProvider.tsx +22 -84
  163. package/src/components/Toast/native.css.ts +78 -0
  164. package/src/components/Toast/store.ts +96 -0
  165. package/src/components/Toggle/Toggle.tsx +3 -3
  166. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  167. package/src/components/Tooltip/Tooltip.native.tsx +36 -0
  168. package/src/components/Tooltip/TooltipHint.ts +13 -0
  169. package/src/components/Tree/Tree.meta.ts +1 -1
  170. package/src/components/Tree/Tree.tsx +23 -135
  171. package/src/components/Tree/styles.css.ts +6 -0
  172. package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
  173. package/src/components/Tree/useTreeKeyboard.ts +152 -0
  174. package/src/components/VirtualList/VirtualList.meta.ts +39 -0
  175. package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
  176. package/src/components/VirtualList/VirtualList.tsx +103 -0
  177. package/src/components/VirtualList/styles.css.ts +31 -0
  178. package/src/hooks/useContainerBelow.native.ts +30 -0
  179. package/src/index.native.ts +33 -0
  180. package/src/index.ts +8 -1
  181. package/src/shared/ButtonHost.native.tsx +70 -0
  182. package/src/shared/ButtonHost.tsx +12 -0
  183. package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
  184. package/src/shared/pressableChild.ts +33 -0
  185. package/src/shared/useKeyboardHeight.native.ts +18 -0
  186. package/src/shared/useScreenReaderEnabled.native.ts +19 -0
  187. package/dist/Table-ColfpgKV.js +0 -1552
  188. package/dist/Table-ColfpgKV.js.map +0 -1
@@ -1,28 +1,29 @@
1
1
  import {createPortal} from 'react-dom'
2
- import {
3
- type ReactNode,
4
- type RefObject,
5
- createContext,
6
- useContext,
7
- useState,
8
- useEffect,
9
- useCallback,
10
- useId,
11
- useRef,
12
- } from 'react'
2
+ import {type ReactNode, useState, useEffect, useCallback} from 'react'
13
3
  import {html} from 'react-strict-dom'
14
4
  import type {DurationToken} from '@duro-app/tokens/keys'
15
5
  import {styles} from './styles.css'
16
- import {useOverlayOpenState} from '../../shared/useOverlayOpenState'
17
6
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
18
7
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
19
8
  import {ModalContext} from '../../shared/ModalContext'
20
9
  import {useSwipeDismiss} from './useSwipeDismiss'
10
+ import {useSheetHeights} from './useSheetHeights'
11
+ import {cycleDetent} from './sheetPhysics'
12
+ import {partStyles} from './parts.css'
13
+ import {
14
+ DEFAULT_GRABBER_LABELS,
15
+ Description,
16
+ DrawerContext,
17
+ Footer,
18
+ Header,
19
+ Title,
20
+ useDrawer,
21
+ type DrawerSize,
22
+ type GrabberLabels,
23
+ } from './parts'
24
+ import {useDrawerRoot, type DrawerRootProps} from './useDrawerRoot'
21
25
 
22
- // --- Types ---
23
-
24
- export type DrawerAnchor = 'right' | 'left' | 'bottom'
25
- export type DrawerSize = 'sm' | 'md' | 'lg'
26
+ export type {DrawerAnchor, DrawerSize, SnapPoint} from './parts'
26
27
 
27
28
  const closeDurationMap = {
28
29
  instant: styles.closeDurationInstant,
@@ -34,79 +35,11 @@ const closeDurationMap = {
34
35
  slow: styles.closeDurationSlow,
35
36
  } as const satisfies Record<DurationToken, unknown>
36
37
 
37
- // --- Context ---
38
-
39
- interface DrawerContextValue {
40
- open: boolean
41
- closing: boolean
42
- closeDuration: DurationToken
43
- anchor: DrawerAnchor
44
- dismissable: boolean
45
- swipeDismiss: boolean
46
- requestOpen: () => void
47
- requestClose: () => void
48
- /** Close immediately without CSS animation (used by swipe dismiss which handles its own exit animation) */
49
- requestCloseImmediate: () => void
50
- titleId: string
51
- descriptionId: string
52
- panelRef: RefObject<HTMLDivElement | null>
53
- }
54
-
55
- const DrawerContext = createContext<DrawerContextValue | null>(null)
56
-
57
- function useDrawer() {
58
- const ctx = useContext(DrawerContext)
59
- if (!ctx) throw new Error('Drawer compound components must be used within Drawer.Root')
60
- return ctx
61
- }
62
-
63
38
  // --- Root ---
64
39
 
65
- interface RootProps {
66
- children: ReactNode
67
- /** Controlled open state */
68
- open?: boolean
69
- /** Default open state (uncontrolled) */
70
- defaultOpen?: boolean
71
- /** Called when drawer open state changes */
72
- onOpenChange?: (open: boolean) => void
73
- /** Which edge the drawer slides from. Default: 'right' */
74
- anchor?: DrawerAnchor
75
- /** Whether clicking backdrop or pressing Escape closes the drawer. Default: true */
76
- dismissable?: boolean
77
- /** Whether swipe gesture dismisses the drawer. Default: true */
78
- swipeDismiss?: boolean
79
- /** Motion token for the close animation. Default: 'fast' (150ms) */
80
- closeAnimationDuration?: DurationToken
81
- }
82
-
83
- function Root({
84
- children,
85
- open: controlledOpen,
86
- defaultOpen = false,
87
- onOpenChange,
88
- anchor = 'right',
89
- dismissable = true,
90
- swipeDismiss = true,
91
- closeAnimationDuration = 'fast',
92
- }: RootProps) {
93
- const {
94
- present,
95
- closing,
96
- requestOpen,
97
- requestClose,
98
- closeNow: requestCloseImmediate,
99
- } = useOverlayOpenState({
100
- open: controlledOpen,
101
- defaultOpen,
102
- onOpenChange,
103
- exitDuration: closeAnimationDuration,
104
- closeNotice: 'after-exit',
105
- })
106
- const panelRef = useRef<HTMLDivElement>(null)
107
-
108
- const titleId = useId()
109
- const descriptionId = useId()
40
+ function Root({children, ...props}: DrawerRootProps) {
41
+ const ctx = useDrawerRoot(props)
42
+ const {open: present, dismissable, requestClose} = ctx
110
43
 
111
44
  // Handle Escape key
112
45
  useEffect(() => {
@@ -134,26 +67,7 @@ function Root({
134
67
  }
135
68
  }, [present])
136
69
 
137
- return (
138
- <DrawerContext.Provider
139
- value={{
140
- open: present,
141
- closing,
142
- closeDuration: closeAnimationDuration,
143
- anchor,
144
- dismissable,
145
- swipeDismiss,
146
- requestOpen,
147
- requestClose,
148
- requestCloseImmediate,
149
- titleId,
150
- descriptionId,
151
- panelRef,
152
- }}
153
- >
154
- {children}
155
- </DrawerContext.Provider>
156
- )
70
+ return <DrawerContext.Provider value={ctx}>{children}</DrawerContext.Provider>
157
71
  }
158
72
 
159
73
  // --- Trigger ---
@@ -162,7 +76,7 @@ function Trigger({children}: {children: ReactNode}) {
162
76
  const {requestOpen} = useDrawer()
163
77
 
164
78
  return (
165
- <html.div onClick={requestOpen} style={styles.inlineWrapper}>
79
+ <html.div onClick={requestOpen} style={partStyles.inlineWrapper}>
166
80
  {children}
167
81
  </html.div>
168
82
  )
@@ -173,6 +87,8 @@ function Trigger({children}: {children: ReactNode}) {
173
87
  interface PortalProps {
174
88
  children: ReactNode
175
89
  size?: DrawerSize
90
+ /** The grabber's accessible names, by what a press does next. Snap sheets only */
91
+ grabberLabels?: GrabberLabels
176
92
  }
177
93
 
178
94
  // Slide animation maps
@@ -194,7 +110,7 @@ const viewportAnchorMap = {
194
110
  bottom: styles.viewportBottom,
195
111
  } as const
196
112
 
197
- function Portal({children, size = 'md'}: PortalProps) {
113
+ function Portal({children, size = 'md', grabberLabels = DEFAULT_GRABBER_LABELS}: PortalProps) {
198
114
  const {
199
115
  open,
200
116
  closing,
@@ -207,8 +123,14 @@ function Portal({children, size = 'md'}: PortalProps) {
207
123
  titleId,
208
124
  descriptionId,
209
125
  panelRef,
126
+ snapPoints,
127
+ snapIndex,
128
+ setSnapIndex,
210
129
  } = useDrawer()
211
130
  const mount = usePortalMount()
131
+ const snapping = snapPoints.length > 0
132
+ const heights = useSheetHeights(snapPoints, snapping && open)
133
+ const sized = snapping && heights.length === snapPoints.length
212
134
  // The shown panel element, tracked as state so swipe listeners follow it
213
135
  // when the portal mount arrives and the panel is remounted into it.
214
136
  const [panel, setPanel] = useState<HTMLDivElement | null>(null)
@@ -232,6 +154,7 @@ function Portal({children, size = 'md'}: PortalProps) {
232
154
  enabled: swipeEnabled && open && !closing,
233
155
  onDismiss: requestCloseImmediate,
234
156
  panel,
157
+ snap: sized ? {heights, index: snapIndex, onSnap: setSnapIndex} : null,
235
158
  })
236
159
 
237
160
  if (!open) return null
@@ -265,12 +188,29 @@ function Portal({children, size = 'md'}: PortalProps) {
265
188
  isHorizontal && styles.panelHorizontal,
266
189
  isHorizontal && styles[size],
267
190
  !isHorizontal && styles.panelVertical,
191
+ sized && styles.panelSnap,
192
+ sized && styles.sheetHeight(heights[snapIndex]),
268
193
  anchor === 'left' && styles.panelLeftBorder,
269
194
  closing && slideOutMap[anchor],
270
195
  closing && closeDurationMap[closeDuration],
271
196
  !closing && slideInMap[anchor],
272
197
  ]}
273
198
  >
199
+ {snapping && (
200
+ <html.button
201
+ type="button"
202
+ data-drawer-grabber=""
203
+ aria-label={
204
+ cycleDetent(snapIndex, snapPoints.length) > snapIndex
205
+ ? grabberLabels.expand
206
+ : grabberLabels.collapse
207
+ }
208
+ onClick={() => setSnapIndex(cycleDetent(snapIndex, snapPoints.length))}
209
+ style={[partStyles.grabberHit, styles.grabberHitWeb]}
210
+ >
211
+ <html.span style={partStyles.grabber} />
212
+ </html.button>
213
+ )}
274
214
  <ModalContext.Provider value={true}>
275
215
  <ControlContextBoundary>{children}</ControlContextBoundary>
276
216
  </ModalContext.Provider>
@@ -287,46 +227,19 @@ function Portal({children, size = 'md'}: PortalProps) {
287
227
  return mount ? createPortal(node, mount) : node
288
228
  }
289
229
 
290
- // --- Header ---
291
-
292
- function Header({children}: {children: ReactNode}) {
293
- return <html.div style={styles.header}>{children}</html.div>
294
- }
295
-
296
- // --- Title ---
297
-
298
- function Title({children}: {children: ReactNode}) {
299
- const {titleId} = useDrawer()
300
- return (
301
- <html.h2 id={titleId} style={styles.title}>
302
- {children}
303
- </html.h2>
304
- )
305
- }
306
-
307
- // --- Description ---
230
+ // --- Body ---
308
231
 
309
- function Description({children}: {children: ReactNode}) {
310
- const {descriptionId} = useDrawer()
232
+ function Body({children}: {children: ReactNode}) {
233
+ const {snapPoints} = useDrawer()
234
+ // A snap sheet's Body is its scroll viewport: a drag there scrolls it, and
235
+ // moves the sheet only from the top edge (or upward, below the largest detent).
311
236
  return (
312
- <html.p id={descriptionId} style={styles.description}>
237
+ <html.div style={styles.body} data-duro-scroll={snapPoints.length > 0 ? '' : undefined}>
313
238
  {children}
314
- </html.p>
239
+ </html.div>
315
240
  )
316
241
  }
317
242
 
318
- // --- Body ---
319
-
320
- function Body({children}: {children: ReactNode}) {
321
- return <html.div style={styles.body}>{children}</html.div>
322
- }
323
-
324
- // --- Footer ---
325
-
326
- function Footer({children}: {children: ReactNode}) {
327
- return <html.div style={styles.footer}>{children}</html.div>
328
- }
329
-
330
243
  // --- Close ---
331
244
 
332
245
  interface CloseProps {
@@ -339,14 +252,14 @@ function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
339
252
 
340
253
  if (children) {
341
254
  return (
342
- <html.div onClick={requestClose} style={styles.inlineWrapper}>
255
+ <html.div onClick={requestClose} style={partStyles.inlineWrapper}>
343
256
  {children}
344
257
  </html.div>
345
258
  )
346
259
  }
347
260
 
348
261
  return (
349
- <html.button onClick={requestClose} aria-label={ariaLabel} style={styles.closeButton}>
262
+ <html.button onClick={requestClose} aria-label={ariaLabel} style={partStyles.closeButton}>
350
263
  <svg width={16} height={16} viewBox="0 0 16 16" fill="none">
351
264
  <path
352
265
  d="M4 4l8 8M12 4l-8 8"
@@ -1,11 +1,11 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, waitFor} from 'storybook/test'
3
- import {cdp, page} from 'vitest/browser'
4
3
  import {Drawer} from './Drawer'
5
4
  import {ToggleGroup} from '../ToggleGroup/ToggleGroup'
6
5
  import {Toggle} from '../Toggle/Toggle'
7
6
  import {Icon, type IconName} from '../Icon'
8
7
  import {SIZES_PX} from '@duro-app/tokens/keys'
8
+ import {cdpSend, pause, setViewport, toPage, touchDrag, type Point} from './touchInput'
9
9
 
10
10
  const meta: Meta = {
11
11
  title: 'Components/Drawer/Scroll',
@@ -87,56 +87,6 @@ function Scene({pressed}: {pressed?: string} = {}) {
87
87
  )
88
88
  }
89
89
 
90
- type Point = {x: number; y: number}
91
-
92
- /** `CDPSession` is empty until the provider's typings load; `send` exists at runtime. */
93
- const cdpSend = (method: string, params: object) =>
94
- (cdp() as unknown as {send: (method: string, params: object) => Promise<unknown>}).send(
95
- method,
96
- params,
97
- )
98
-
99
- const pause = (ms: number) => new Promise((r) => setTimeout(r, ms))
100
-
101
- /**
102
- * CDP input lands in top-level page coordinates; the story runs in an iframe
103
- * the test runner offsets and may scale to fit. Map a point up every frame.
104
- */
105
- function toPage(p: Point): Point {
106
- let {x, y} = p
107
- let win: Window = window
108
- while (win.frameElement && win.parent !== win) {
109
- const rect = win.frameElement.getBoundingClientRect()
110
- const scale = rect.width / win.innerWidth
111
- x = rect.left + x * scale
112
- y = rect.top + y * scale
113
- win = win.parent
114
- }
115
- return {x, y}
116
- }
117
-
118
- /**
119
- * A page-coordinate touch point with a 1px radius: Chrome's touch adjustment
120
- * snaps a fat default touch onto the nearest control, which would turn every
121
- * drag in the list into a drag on a toggle.
122
- */
123
- const touchAt = (p: Point) => ({...toPage(p), radiusX: 1, radiusY: 1})
124
-
125
- /** Real touch drag through CDP; synthetic pointer events don't scroll. */
126
- async function touchDrag(from: Point, to: Point) {
127
- const steps = 12
128
- const send = (type: string, touchPoints: Point[]) =>
129
- cdpSend('Input.dispatchTouchEvent', {type, touchPoints: touchPoints.map(touchAt)})
130
- await send('touchStart', [from])
131
- for (let i = 1; i <= steps; i++) {
132
- await send('touchMove', [
133
- {x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps},
134
- ])
135
- await pause(16)
136
- }
137
- await send('touchEnd', [])
138
- }
139
-
140
90
  const getViewport = () => document.querySelector<HTMLElement>('[data-duro-scroll]')!
141
91
  const getDialog = () => document.querySelector('[role="dialog"]')
142
92
 
@@ -158,7 +108,7 @@ function freePoint(el: HTMLElement, fy: number): Point {
158
108
  }
159
109
 
160
110
  async function setup() {
161
- await page.viewport(390, 844)
111
+ await setViewport(390, 844)
162
112
  await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
163
113
  await waitFor(() => expect(getViewport()).toBeTruthy())
164
114
  const viewport = getViewport()
@@ -242,7 +192,7 @@ export const TapOnToggleStillPresses: Story = {
242
192
  export const MountKeepsTheDrawerStill: Story = {
243
193
  // size the page before the render: a drawer mounts at its final width
244
194
  beforeEach: async () => {
245
- await page.viewport(390, 844)
195
+ await setViewport(390, 844)
246
196
  },
247
197
  render: () => <Scene pressed={ICON_NAMES[39]} />,
248
198
  play: async () => {
@@ -278,7 +228,7 @@ export const CoarsePointerRows: Story = {
278
228
  ),
279
229
  play: async () => {
280
230
  // phone width, so 42 icons overflow three rows
281
- await page.viewport(390, 844)
231
+ await setViewport(390, 844)
282
232
  await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
283
233
  await waitFor(() => expect(matchMedia('(pointer: coarse)').matches).toBe(true))
284
234
  const viewport = getViewport()
@@ -0,0 +1,205 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, userEvent, waitFor} from 'storybook/test'
3
+ import {Drawer, type SnapPoint} from './Drawer'
4
+ import {Button} from '../Button/Button'
5
+ import {List} from '../List/List'
6
+ import {cdpSend, centre, pause, setViewport, touchDrag, type Point} from './touchInput'
7
+
8
+ const meta: Meta = {
9
+ title: 'Components/Drawer/Snap points',
10
+ parameters: {layout: 'fullscreen'},
11
+ }
12
+
13
+ export default meta
14
+ type Story = StoryObj
15
+
16
+ const SESSIONS = Array.from({length: 40}, (_, i) => `Session ${i + 1}`)
17
+
18
+ interface SheetArgs {
19
+ snapPoints: SnapPoint[]
20
+ defaultSnapPoint: SnapPoint
21
+ titleAlign: 'start' | 'center'
22
+ onOpenChange?: (open: boolean) => void
23
+ }
24
+
25
+ const DEFAULT_ARGS: SheetArgs = {
26
+ snapPoints: ['medium', 'large'],
27
+ defaultSnapPoint: 'medium',
28
+ titleAlign: 'center',
29
+ }
30
+
31
+ /** The picked B0 direction (docs/mockups/native-sheet/Done.dc.html): grabber, centred title, a text Done. */
32
+ function Sheet(props: Partial<SheetArgs>) {
33
+ const {snapPoints, defaultSnapPoint, titleAlign, onOpenChange} = {...DEFAULT_ARGS, ...props}
34
+ return (
35
+ <Drawer.Root
36
+ anchor="bottom"
37
+ snapPoints={snapPoints}
38
+ defaultSnapPoint={defaultSnapPoint}
39
+ onOpenChange={onOpenChange}
40
+ defaultOpen
41
+ >
42
+ <Drawer.Portal>
43
+ <Drawer.Header titleAlign={titleAlign}>
44
+ <Drawer.Title>Move to project</Drawer.Title>
45
+ <Drawer.Close>
46
+ <Button variant="link">Done</Button>
47
+ </Drawer.Close>
48
+ </Drawer.Header>
49
+ <Drawer.Body>
50
+ <List.Root aria-label="Sessions">
51
+ {SESSIONS.map((s) => (
52
+ <List.Item key={s}>
53
+ <List.Text>{s}</List.Text>
54
+ </List.Item>
55
+ ))}
56
+ </List.Root>
57
+ </Drawer.Body>
58
+ </Drawer.Portal>
59
+ </Drawer.Root>
60
+ )
61
+ }
62
+
63
+ export const SnapSheet: StoryObj<SheetArgs> = {
64
+ args: {...DEFAULT_ARGS, onOpenChange: fn()},
65
+ argTypes: {
66
+ snapPoints: {
67
+ control: 'check',
68
+ options: ['medium', 'large'],
69
+ description: "The detents: 'medium' half the window, 'large' the window less its top inset",
70
+ },
71
+ defaultSnapPoint: {
72
+ control: 'inline-radio',
73
+ options: ['medium', 'large'],
74
+ description: 'The detent the sheet opens at',
75
+ },
76
+ titleAlign: {
77
+ control: 'inline-radio',
78
+ options: ['start', 'center'],
79
+ description: 'Drawer.Header: the title at the start, or centred with the close at the end',
80
+ },
81
+ },
82
+ // Keyed by the opening detent: it seeds state on mount, so a change remounts the sheet.
83
+ render: (args) => (
84
+ <Sheet
85
+ key={args.defaultSnapPoint}
86
+ {...args}
87
+ snapPoints={args.snapPoints.length > 0 ? args.snapPoints : ['medium']}
88
+ />
89
+ ),
90
+ }
91
+
92
+ const getDialog = () => document.querySelector<HTMLElement>('[role="dialog"]')
93
+ const getGrabber = () => document.querySelector<HTMLElement>('[data-drawer-grabber]')!
94
+ const getBody = () => document.querySelector<HTMLElement>('[role="dialog"] [data-duro-scroll]')!
95
+ const sheetHeight = () => getDialog()!.getBoundingClientRect().height
96
+
97
+ const WINDOW_H = 844
98
+ const MEDIUM = WINDOW_H / 2
99
+ const LARGE = WINDOW_H // no top safe-area inset in a desktop browser
100
+
101
+ async function setup() {
102
+ await setViewport(390, WINDOW_H)
103
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
104
+ await waitFor(() => expect(getDialog()).not.toBeNull())
105
+ // let the slide-in finish
106
+ await pause(500)
107
+ }
108
+
109
+ const near = (actual: number, expected: number) =>
110
+ expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2)
111
+
112
+ async function dragGrabber(dy: number) {
113
+ const from: Point = centre(getGrabber())
114
+ await touchDrag(from, {x: from.x, y: from.y + dy})
115
+ await pause(400)
116
+ }
117
+
118
+ // One drag per story: a test that drags three times can outlast the runner's
119
+ // timeout on a loaded machine.
120
+ export const DragUpGrowsToLarge: Story = {
121
+ render: () => <Sheet />,
122
+ play: async () => {
123
+ await setup()
124
+ await waitFor(() => near(sheetHeight(), MEDIUM))
125
+ await dragGrabber(-300)
126
+ await waitFor(() => near(sheetHeight(), LARGE))
127
+ },
128
+ }
129
+
130
+ export const DragDownShrinksToMedium: Story = {
131
+ render: () => <Sheet defaultSnapPoint="large" />,
132
+ play: async () => {
133
+ await setup()
134
+ await waitFor(() => near(sheetHeight(), LARGE))
135
+ await dragGrabber(300)
136
+ await waitFor(() => near(sheetHeight(), MEDIUM))
137
+ },
138
+ }
139
+
140
+ export const DragDownBelowMediumDismisses: Story = {
141
+ render: () => <Sheet />,
142
+ play: async () => {
143
+ await setup()
144
+ await waitFor(() => near(sheetHeight(), MEDIUM))
145
+ await dragGrabber(300)
146
+ await waitFor(() => expect(getDialog()).toBeNull())
147
+ },
148
+ }
149
+
150
+ export const SlowShortDragSnapsBack: Story = {
151
+ render: () => <Sheet />,
152
+ play: async () => {
153
+ await setup()
154
+ const from = centre(getGrabber())
155
+ // 60px over 20 moves (about 0.2 px/ms): well under the fling speed, well
156
+ // short of the next detent
157
+ await touchDrag(from, {x: from.x, y: from.y - 60}, 20)
158
+ await pause(400)
159
+ await waitFor(() => near(sheetHeight(), MEDIUM))
160
+ },
161
+ }
162
+
163
+ export const GrabberTapCyclesDetents: Story = {
164
+ render: () => <Sheet />,
165
+ play: async () => {
166
+ await setup()
167
+ const grabber = getGrabber()
168
+ await expect(grabber).toHaveAccessibleName('Expand sheet')
169
+ await userEvent.click(grabber)
170
+ await waitFor(() => near(sheetHeight(), LARGE))
171
+ await expect(grabber).toHaveAccessibleName('Shrink sheet')
172
+ await userEvent.click(grabber)
173
+ await waitFor(() => near(sheetHeight(), MEDIUM))
174
+ },
175
+ }
176
+
177
+ export const DragUpOnContentGrowsThenScrolls: Story = {
178
+ render: () => <Sheet />,
179
+ play: async () => {
180
+ await setup()
181
+ const from = centre(getBody())
182
+ // At medium, the content's upward drag grows the sheet …
183
+ await touchDrag(from, {x: from.x, y: from.y - 250})
184
+ await pause(400)
185
+ await waitFor(() => near(sheetHeight(), LARGE))
186
+ await expect(getBody().scrollTop).toBe(0)
187
+ // … at the largest detent it scrolls the list.
188
+ const again = centre(getBody())
189
+ await touchDrag(again, {x: again.x, y: again.y - 120})
190
+ await pause(300)
191
+ await expect(getBody().scrollTop).toBeGreaterThan(0)
192
+ near(sheetHeight(), LARGE)
193
+ },
194
+ }
195
+
196
+ export const DoneCloses: Story = {
197
+ render: () => <Sheet />,
198
+ play: async () => {
199
+ await setup()
200
+ await userEvent.click(
201
+ document.querySelector('[role="dialog"] button:not([data-drawer-grabber])')!,
202
+ )
203
+ await waitFor(() => expect(getDialog()).toBeNull())
204
+ },
205
+ }
@@ -0,0 +1,58 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
5
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+
7
+ /**
8
+ * Native Drawer surfaces. Motion is Reanimated's (plan N0, Decision 4): these
9
+ * styles draw the panel and backdrop, the animated views around them move them.
10
+ */
11
+ export const nativeStyles = css.create({
12
+ fill: {
13
+ position: 'absolute',
14
+ top: 0,
15
+ left: 0,
16
+ right: 0,
17
+ bottom: 0,
18
+ },
19
+ scrim: {
20
+ backgroundColor: colors.scrim,
21
+ },
22
+ panel: {
23
+ flexGrow: 1,
24
+ backgroundColor: colors.bgCard,
25
+ boxShadow: shadows.lg,
26
+ display: 'flex',
27
+ flexDirection: 'column',
28
+ overflow: 'hidden',
29
+ },
30
+ panelBottom: {
31
+ borderTopWidth: borders.hairline,
32
+ borderTopStyle: 'solid',
33
+ borderTopColor: colors.border,
34
+ borderTopLeftRadius: radii.lg,
35
+ borderTopRightRadius: radii.lg,
36
+ },
37
+ panelRight: {
38
+ borderLeftWidth: borders.hairline,
39
+ borderLeftStyle: 'solid',
40
+ borderLeftColor: colors.border,
41
+ },
42
+ panelLeft: {
43
+ borderRightWidth: borders.hairline,
44
+ borderRightStyle: 'solid',
45
+ borderRightColor: colors.border,
46
+ },
47
+ // The safe-area insets (and a snap sheet's overshoot skirt), as padding.
48
+ insets: (top: number, bottom: number) => ({paddingTop: top, paddingBottom: bottom}),
49
+ // The Body's padding inside its ScrollView (no flex: it sizes to content).
50
+ bodyContent: {
51
+ paddingLeft: spacing.lg,
52
+ paddingRight: spacing.lg,
53
+ paddingBottom: spacing.lg,
54
+ },
55
+ // A snap sheet resting below its largest detent: its lower part is off
56
+ // screen, so the list scrolls that far further to show its end.
57
+ hiddenBelow: (height: number) => ({paddingBottom: height}),
58
+ })