@duro-app/ui 4.3.0 → 4.5.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 (82) hide show
  1. package/dist/{Table-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
  2. package/dist/Table-CUp12BKZ.js.map +1 -0
  3. package/dist/components/ActionBar/ActionBar.d.ts +7 -0
  4. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  5. package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
  6. package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
  7. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  8. package/dist/components/ActionBar/styles.css.d.ts +25 -2
  9. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  10. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  11. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
  12. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  13. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  14. package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
  15. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
  16. package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
  17. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  18. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  19. package/dist/components/Popover/Popover.d.ts.map +1 -1
  20. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  21. package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
  22. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
  23. package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
  24. package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
  25. package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
  26. package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
  27. package/dist/components/Select/Select.d.ts.map +1 -1
  28. package/dist/components/Select/Select.meta.d.ts.map +1 -1
  29. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  30. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  31. package/dist/components/Toggle/rowHeight.d.ts +17 -0
  32. package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
  33. package/dist/components/Toggle/styles.css.d.ts +10 -0
  34. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  35. package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
  36. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  37. package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
  38. package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
  39. package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
  40. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
  41. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
  42. package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
  43. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  44. package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
  45. package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
  46. package/dist/index.css +1 -1
  47. package/dist/index.js +2987 -2682
  48. package/dist/index.js.map +1 -1
  49. package/dist/table.js +1 -1
  50. package/package.json +2 -2
  51. package/src/components/ActionBar/ActionBar.meta.ts +7 -0
  52. package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
  53. package/src/components/ActionBar/ActionBar.tsx +12 -1
  54. package/src/components/ActionBar/styles.css.ts +34 -2
  55. package/src/components/Combobox/Combobox.meta.ts +6 -0
  56. package/src/components/Combobox/Combobox.tsx +5 -0
  57. package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
  58. package/src/components/Drawer/Drawer.meta.ts +1 -1
  59. package/src/components/Drawer/Drawer.tsx +12 -2
  60. package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
  61. package/src/components/Drawer/useSwipeDismiss.ts +53 -8
  62. package/src/components/Menu/useMenuRoot.ts +5 -1
  63. package/src/components/Popover/Popover.meta.ts +7 -0
  64. package/src/components/Popover/Popover.tsx +30 -24
  65. package/src/components/Popover/PopoverLayerContext.ts +20 -0
  66. package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
  67. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  68. package/src/components/ScrollArea/ScrollArea.tsx +12 -6
  69. package/src/components/Select/Select.meta.ts +6 -0
  70. package/src/components/Select/Select.tsx +8 -1
  71. package/src/components/Select/useSelectRoot.ts +6 -2
  72. package/src/components/Toggle/Toggle.meta.ts +1 -1
  73. package/src/components/Toggle/Toggle.tsx +19 -2
  74. package/src/components/Toggle/rowHeight.ts +12 -0
  75. package/src/components/Toggle/styles.css.ts +12 -0
  76. package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
  77. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
  78. package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
  79. package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
  80. package/src/components/ToggleGroup/styles.css.ts +30 -1
  81. package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
  82. package/dist/Table-JTgcR_Fh.js.map +0 -1
package/dist/table.js CHANGED
@@ -1,4 +1,4 @@
1
- import { M as F, s as xt, R as rt, H as kt, a as pt, B as bt, c as mt, C as ft, b as ht, d as yt } from "./Table-JTgcR_Fh.js";
1
+ import { M as F, s as xt, R as rt, H as kt, a as pt, B as bt, c as mt, C as ft, b as ht, d as yt } from "./Table-CUp12BKZ.js";
2
2
  import { jsx as u, jsxs as B } from "react/jsx-runtime";
3
3
  import { c as H, m as p, d as b } from "./compiler-runtime-B2IcI8_O.js";
4
4
  import { flexRender as E, useReactTable as it, getPaginationRowModel as St, getFilteredRowModel as Tt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "@tanstack/react-virtual": "^3.14.6",
69
- "@duro-app/tokens": "^4.3.0"
69
+ "@duro-app/tokens": "^4.5.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@babel/preset-typescript": "^7.28.0",
@@ -7,6 +7,7 @@ export const meta: ComponentMeta = {
7
7
  'Bulk actions on table selections (delete, revoke, export)',
8
8
  'Multi-select workflows where actions apply to all selected items',
9
9
  'Selection-scoped verbs on a canvas/editor surface (set `bottomOffset` to clear any bottom chrome such as zoom controls)',
10
+ 'A docked side panel (DetailPanel) is open: pass its width as `insetInlineEnd` so the bar centres beside it, not under it (follows the writing direction: the left edge in right-to-left)',
10
11
  ],
11
12
  whenNotToUse: [
12
13
  'Single-item actions that have an inline home — use inline buttons or a Menu',
@@ -18,6 +19,12 @@ export const meta: ComponentMeta = {
18
19
  kind: 'composition',
19
20
  relationship: 'Common pattern: Table checkboxes + ActionBar for bulk ops',
20
21
  },
22
+ {
23
+ component: 'DetailPanel',
24
+ kind: 'composition',
25
+ relationship:
26
+ "With a DetailPanel docked at the end edge, pass the panel's width as insetInlineEnd",
27
+ },
21
28
  ],
22
29
  example: `<ActionBar selectedItemCount={selected.size} onClearSelection={() => setSelected(new Set())}>
23
30
  <Button variant="danger" size="small" onClick={handleDelete}>Delete</Button>
@@ -1,6 +1,6 @@
1
1
  import {useState} from 'react'
2
2
  import type {Meta, StoryObj} from '@storybook/react'
3
- import {expect, fn, userEvent, within} from 'storybook/test'
3
+ import {expect, fn, within} from 'storybook/test'
4
4
  import {css, html} from 'react-strict-dom'
5
5
  import {ActionBar} from './ActionBar'
6
6
  import {Button} from '../Button/Button'
@@ -155,8 +155,7 @@ export const Default: Story = {
155
155
  onClearSelection={args.onClearSelection as () => void}
156
156
  />
157
157
  ),
158
- play: async ({canvasElement}) => {
159
- const canvas = within(canvasElement)
158
+ play: async () => {
160
159
  // The ActionBar is portalled to document.body, so query from there
161
160
  const toolbar = document.querySelector('[role="toolbar"]')
162
161
  await expect(toolbar).toBeInTheDocument()
@@ -310,3 +309,96 @@ export const AllVariants: Story = {
310
309
  </html.div>
311
310
  ),
312
311
  }
312
+
313
+ // A panel docked at the window's end edge, standing in for an open DetailPanel.
314
+ const docked = css.create({
315
+ panel: {
316
+ position: 'fixed',
317
+ top: 0,
318
+ bottom: 0,
319
+ insetInlineEnd: 0,
320
+ width: 360,
321
+ borderInlineStartWidth: 1,
322
+ borderInlineStartStyle: 'solid',
323
+ },
324
+ })
325
+
326
+ // Real buttons: they don't shrink below their labels, so together they are
327
+ // wider than the free area beside the panel.
328
+ const WIDE_ACTIONS = [
329
+ 'Duplicate',
330
+ 'Move to section',
331
+ 'Copy style',
332
+ 'Paste style',
333
+ 'Group',
334
+ 'Ungroup',
335
+ 'Bring forward',
336
+ 'Send backward',
337
+ 'Delete',
338
+ ]
339
+
340
+ const barRect = () =>
341
+ (document.querySelector('[role="toolbar"]') as Element).getBoundingClientRect()
342
+ const panelRect = () =>
343
+ (document.querySelector('[data-testid="docked-panel"]') as Element).getBoundingClientRect()
344
+
345
+ export const NoInset: Story = {
346
+ render: () => (
347
+ <ActionBar selectedItemCount={2} onClearSelection={() => {}}>
348
+ <Button variant="secondary" size="small">
349
+ Delete
350
+ </Button>
351
+ </ActionBar>
352
+ ),
353
+ play: async () => {
354
+ const bar = barRect()
355
+ const centre = document.documentElement.clientWidth / 2
356
+ // Today's placement: centred on the window.
357
+ await expect(Math.abs(bar.left + bar.width / 2 - centre)).toBeLessThanOrEqual(1)
358
+ },
359
+ }
360
+
361
+ export const DockedPanel: Story = {
362
+ render: () => (
363
+ <>
364
+ <html.div style={docked.panel} data-testid="docked-panel" />
365
+ <ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
366
+ <Button variant="secondary" size="small">
367
+ Delete
368
+ </Button>
369
+ </ActionBar>
370
+ </>
371
+ ),
372
+ play: async () => {
373
+ const bar = barRect()
374
+ const panel = panelRect()
375
+ // Clear of the panel, and centred in what is left of the window.
376
+ await expect(bar.right).toBeLessThanOrEqual(panel.left - 24)
377
+ await expect(Math.abs(bar.left + bar.width / 2 - panel.left / 2)).toBeLessThanOrEqual(1)
378
+ },
379
+ }
380
+
381
+ export const DockedPanelWideBar: Story = {
382
+ render: () => (
383
+ <>
384
+ <html.div style={docked.panel} data-testid="docked-panel" />
385
+ <ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
386
+ {WIDE_ACTIONS.map((a) => (
387
+ <Button key={a} variant="secondary" size="small">
388
+ {a}
389
+ </Button>
390
+ ))}
391
+ </ActionBar>
392
+ </>
393
+ ),
394
+ play: async () => {
395
+ // A bar wider than the free area still keeps 24px clear of the panel,
396
+ // and so does what it holds: the actions scroll, they don't spill.
397
+ const limit = panelRect().left - 24
398
+ await expect(barRect().right).toBeLessThanOrEqual(limit)
399
+ const last = await within(document.body).findByRole('button', {name: 'Delete'})
400
+ const actions = last.parentElement!
401
+ await expect(actions.getBoundingClientRect().right).toBeLessThanOrEqual(limit)
402
+ await expect(actions.scrollWidth).toBeGreaterThan(actions.clientWidth)
403
+ },
404
+ }
@@ -22,6 +22,13 @@ export interface ActionBarProps {
22
22
  * height. Defaults to the spacing.lg token.
23
23
  */
24
24
  bottomOffset?: SpacingToken | number
25
+ /**
26
+ * Width in px of chrome docked at the window's inline-end edge (right in
27
+ * left-to-right, left in right-to-left), such as an open DetailPanel: the
28
+ * bar centres in what is left and keeps 24px clear of it. 0 or omitted
29
+ * centres on the window.
30
+ */
31
+ insetInlineEnd?: number
25
32
  /** Action buttons to display. */
26
33
  children: ReactNode
27
34
  }
@@ -33,6 +40,7 @@ function ActionBarContent({
33
40
  onClearSelection,
34
41
  dismissible = true,
35
42
  bottomOffset,
43
+ insetInlineEnd = 0,
36
44
  children,
37
45
  }: ActionBarProps) {
38
46
  const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
@@ -44,6 +52,7 @@ function ActionBarContent({
44
52
  aria-label={label}
45
53
  style={[
46
54
  styles.overlay,
55
+ insetInlineEnd > 0 ? styles.inset(insetInlineEnd) : styles.centered,
47
56
  bottomOffset != null &&
48
57
  styles.overlayOffset(
49
58
  typeof bottomOffset === 'number' ? bottomOffset : SPACING_PX[bottomOffset],
@@ -55,7 +64,9 @@ function ActionBarContent({
55
64
  {label}
56
65
  </html.span>
57
66
  <html.div style={[styles.separator, isEmphasized && styles.separatorEmphasized]} />
58
- <html.div style={styles.actions}>{children}</html.div>
67
+ <html.div style={[styles.actions, insetInlineEnd > 0 && styles.actionsScroll]}>
68
+ {children}
69
+ </html.div>
59
70
  {dismissible && (
60
71
  <html.button
61
72
  type="button"
@@ -9,8 +9,6 @@ export const styles = css.create({
9
9
  overlay: {
10
10
  position: 'fixed',
11
11
  bottom: spacing.lg,
12
- left: '50%',
13
- transform: 'translateX(-50%)',
14
12
  display: 'flex',
15
13
  flexDirection: 'row',
16
14
  alignItems: 'center',
@@ -26,8 +24,25 @@ export const styles = css.create({
26
24
  borderRadius: radii.md,
27
25
  boxShadow: shadows.lg,
28
26
  zIndex: 50,
27
+ },
28
+ // Centred on the window: the default, with nothing docked at its edge.
29
+ centered: {
30
+ left: '50%',
31
+ transform: 'translateX(-50%)',
29
32
  maxWidth: 'calc(100vw - 48px)',
30
33
  },
34
+ // Centred in the window minus chrome docked at the inline-end edge (a
35
+ // DetailPanel). Both insets and auto margins centre a fixed box of
36
+ // fit-content width in what is left, measured on the containing block
37
+ // (the window without a classic scrollbar), not on 100vw.
38
+ inset: (end: number) => ({
39
+ insetInlineStart: 0,
40
+ insetInlineEnd: end,
41
+ marginInlineStart: 'auto',
42
+ marginInlineEnd: 'auto',
43
+ width: 'fit-content',
44
+ maxWidth: `calc(100% - ${end}px - 48px)`,
45
+ }),
31
46
  overlayOffset: (bottom: number) => ({
32
47
  bottom,
33
48
  }),
@@ -51,6 +66,23 @@ export const styles = css.create({
51
66
  alignItems: 'center',
52
67
  gap: spacing.sm,
53
68
  },
69
+ // Beside a docked panel the room is narrower: more actions than fit scroll
70
+ // instead of spilling past the bar. A scroll box clips vertically too, so
71
+ // the row gets room on every side for the buttons' focus rings, taken back
72
+ // by its margin so the bar keeps its size.
73
+ actionsScroll: {
74
+ minWidth: 0,
75
+ overflowX: 'auto',
76
+ overflowY: 'hidden',
77
+ paddingTop: spacing.xs,
78
+ paddingBottom: spacing.xs,
79
+ paddingLeft: spacing.xs,
80
+ paddingRight: spacing.xs,
81
+ marginTop: `calc(-1 * ${spacing.xs})`,
82
+ marginBottom: `calc(-1 * ${spacing.xs})`,
83
+ marginLeft: `calc(-1 * ${spacing.xs})`,
84
+ marginRight: `calc(-1 * ${spacing.xs})`,
85
+ },
54
86
  closeButton: {
55
87
  display: 'inline-flex',
56
88
  alignItems: 'center',
@@ -18,6 +18,12 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Empty'],
19
19
  },
20
20
  relatedTo: [
21
+ {
22
+ component: 'Popover',
23
+ kind: 'composition',
24
+ relationship:
25
+ 'Inside Popover.Popup a Combobox keeps the popover open while you choose; Escape closes only the list',
26
+ },
21
27
  {
22
28
  component: 'Select',
23
29
  kind: 'contrast',
@@ -5,6 +5,7 @@ import {styles} from './styles.css'
5
5
  import {ComboboxContext, useCombobox} from './ComboboxContext'
6
6
  import {useComboboxRoot} from './useComboboxRoot'
7
7
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
8
+ import {usePopoverLayer} from '../Popover/PopoverLayerContext'
8
9
 
9
10
  // --- Root ---
10
11
  interface RootProps {
@@ -137,6 +138,7 @@ function Trigger({children}: {children?: ReactNode}) {
137
138
  function Popup({children}: {children: ReactNode}) {
138
139
  const {open, listboxId, rootRef} = useCombobox()
139
140
  const mount = usePortalMount()
141
+ const listboxRef = useRef<HTMLDivElement>(null)
140
142
  const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
141
143
 
142
144
  // Measure the root each time the popup opens, and re-measure on scroll/resize
@@ -163,10 +165,13 @@ function Popup({children}: {children: ReactNode}) {
163
165
  }
164
166
  }, [open, rootRef])
165
167
 
168
+ usePopoverLayer(listboxRef, {active: open && coords !== null})
169
+
166
170
  if (!open || !coords) return null
167
171
 
168
172
  const node = (
169
173
  <html.div
174
+ ref={listboxRef}
170
175
  id={listboxId}
171
176
  role="listbox"
172
177
  style={[styles.popup, styles.popupPosition(coords.top, coords.left, coords.width)]}
@@ -18,6 +18,12 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Title', 'Footer', 'Close'],
19
19
  },
20
20
  relatedTo: [
21
+ {
22
+ component: 'ActionBar',
23
+ kind: 'composition',
24
+ relationship:
25
+ "An ActionBar shown while the panel is open takes the panel's width as insetInlineEnd",
26
+ },
21
27
  {
22
28
  component: 'Drawer',
23
29
  kind: 'contrast',
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Use for secondary workflows, settings panels, or mobile-friendly detail views.',
5
+ 'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Swipe yields to an inner Duro ScrollArea: a drag there scrolls it, and dismisses only toward the edge from the scroll start (a native overflow:auto child is not covered — use ScrollArea). Use for secondary workflows, settings panels, or mobile-friendly detail views.',
6
6
  whenToUse: [
7
7
  'Settings or configuration panels that slide in from the side',
8
8
  'Mobile-friendly detail views (bottom sheet pattern)',
@@ -233,6 +233,16 @@ function Portal({children, size = 'md'}: PortalProps) {
233
233
  panelRef,
234
234
  } = useDrawer()
235
235
  const mount = usePortalMount()
236
+ // The shown panel element, tracked as state so swipe listeners follow it
237
+ // when the portal mount arrives and the panel is remounted into it.
238
+ const [panel, setPanel] = useState<HTMLDivElement | null>(null)
239
+ const attachPanel = useCallback(
240
+ (el: HTMLDivElement | null) => {
241
+ panelRef.current = el
242
+ setPanel(el)
243
+ },
244
+ [panelRef],
245
+ )
236
246
 
237
247
  const handleBackdropClick = useCallback(() => {
238
248
  if (dismissable) {
@@ -245,7 +255,7 @@ function Portal({children, size = 'md'}: PortalProps) {
245
255
  anchor,
246
256
  enabled: swipeEnabled && open && !closing,
247
257
  onDismiss: requestCloseImmediate,
248
- panelRef,
258
+ panel,
249
259
  })
250
260
 
251
261
  if (!open) return null
@@ -269,7 +279,7 @@ function Portal({children, size = 'md'}: PortalProps) {
269
279
  {/* Viewport */}
270
280
  <html.div style={[styles.viewport, viewportAnchorMap[anchor]]} data-drawer-viewport="">
271
281
  <html.div
272
- ref={panelRef}
282
+ ref={attachPanel}
273
283
  role="dialog"
274
284
  aria-modal={true}
275
285
  aria-labelledby={titleId}
@@ -0,0 +1,292 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import {cdp, page} from 'vitest/browser'
4
+ import {Drawer} from './Drawer'
5
+ import {ToggleGroup} from '../ToggleGroup/ToggleGroup'
6
+ import {Toggle} from '../Toggle/Toggle'
7
+ import {Icon, type IconName} from '../Icon'
8
+ import {ROW_HEIGHT} from '../Toggle/rowHeight'
9
+
10
+ const meta: Meta = {
11
+ title: 'Components/Drawer/Scroll',
12
+ parameters: {layout: 'fullscreen'},
13
+ }
14
+
15
+ export default meta
16
+ type Story = StoryObj
17
+
18
+ const ICON_NAMES: IconName[] = [
19
+ 'x-circle',
20
+ 'check-circle',
21
+ 'check-done',
22
+ 'clock',
23
+ 'forbidden',
24
+ 'info-circle',
25
+ 'alert-triangle',
26
+ 'shield',
27
+ 'lock',
28
+ 'key',
29
+ 'map',
30
+ 'layers',
31
+ 'repeat',
32
+ 'database',
33
+ 'shield-check',
34
+ 'route',
35
+ 'git-branch',
36
+ 'menu',
37
+ 'pin',
38
+ 'server',
39
+ 'hard-drive',
40
+ 'box',
41
+ 'image',
42
+ 'tag',
43
+ 'pie-chart',
44
+ 'users',
45
+ 'user-plus',
46
+ 'mail',
47
+ 'file-text',
48
+ 'plug',
49
+ 'search',
50
+ 'mic',
51
+ 'sun',
52
+ 'moon',
53
+ 'monitor',
54
+ 'contrast',
55
+ 'info-circle-filled',
56
+ 'alert-triangle-filled',
57
+ 'check-circle-filled',
58
+ 'x-circle-filled',
59
+ 'shield-filled',
60
+ 'lock-filled',
61
+ ]
62
+
63
+ function Scene({pressed}: {pressed?: string} = {}) {
64
+ return (
65
+ <Drawer.Root anchor="bottom" defaultOpen>
66
+ <Drawer.Portal>
67
+ <Drawer.Header>
68
+ <Drawer.Title>Pick an icon</Drawer.Title>
69
+ </Drawer.Header>
70
+ <Drawer.Body>
71
+ <ToggleGroup
72
+ wrap
73
+ maxRows={3}
74
+ size="small"
75
+ aria-label="Icon"
76
+ defaultValue={pressed ? [pressed] : []}
77
+ >
78
+ {ICON_NAMES.map((name) => (
79
+ <Toggle key={name} value={name} aria-label={name}>
80
+ <Icon name={name} size="sm" />
81
+ </Toggle>
82
+ ))}
83
+ </ToggleGroup>
84
+ </Drawer.Body>
85
+ </Drawer.Portal>
86
+ </Drawer.Root>
87
+ )
88
+ }
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
+ const getViewport = () => document.querySelector<HTMLElement>('[data-duro-scroll]')!
141
+ const getDialog = () => document.querySelector('[role="dialog"]')
142
+
143
+ /**
144
+ * A point inside the list that is not on a toggle, so a drag starting there
145
+ * is the case the deferral decides (a drag starting on a toggle is decided
146
+ * the same way, but on origin/main it was left to the native scroll).
147
+ */
148
+ function freePoint(el: HTMLElement, fy: number): Point {
149
+ const r = el.getBoundingClientRect()
150
+ const y = r.top + r.height * fy
151
+ for (let dy = 0; dy < 12; dy++) {
152
+ for (let x = r.left + 2; x < r.right; x += 2) {
153
+ const hit = document.elementFromPoint(x, y + dy)
154
+ if (hit && el.contains(hit) && !hit.closest('button')) return {x, y: y + dy}
155
+ }
156
+ }
157
+ throw new Error('no free point in the list')
158
+ }
159
+
160
+ async function setup() {
161
+ await page.viewport(390, 844)
162
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
163
+ await waitFor(() => expect(getViewport()).toBeTruthy())
164
+ const viewport = getViewport()
165
+ await expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight)
166
+ // let the slide-in animation finish
167
+ await pause(500)
168
+ return viewport
169
+ }
170
+
171
+ export const DragUpAtTopScrolls: Story = {
172
+ render: () => <Scene />,
173
+ play: async () => {
174
+ const viewport = await setup()
175
+ await expect(viewport.scrollTop).toBe(0)
176
+ const from = freePoint(viewport, 0.7)
177
+ await touchDrag(from, {x: from.x, y: from.y - 60})
178
+ await pause(300)
179
+ await expect(viewport.scrollTop).toBeGreaterThan(0)
180
+ await expect(getDialog()).not.toBeNull()
181
+ },
182
+ }
183
+
184
+ export const DragDownMidListScrolls: Story = {
185
+ render: () => <Scene />,
186
+ play: async () => {
187
+ const viewport = await setup()
188
+ viewport.scrollTop = 40
189
+ await pause(100)
190
+ const before = viewport.scrollTop
191
+ await expect(before).toBeGreaterThan(0)
192
+ const from = freePoint(viewport, 0.5)
193
+ await touchDrag(from, {x: from.x, y: from.y + 20})
194
+ await pause(300)
195
+ await expect(viewport.scrollTop).toBeLessThan(before)
196
+ await expect(getDialog()).not.toBeNull()
197
+ },
198
+ }
199
+
200
+ export const DragDownAtTopDismisses: Story = {
201
+ render: () => <Scene />,
202
+ play: async () => {
203
+ const viewport = await setup()
204
+ await expect(viewport.scrollTop).toBe(0)
205
+ const from = freePoint(viewport, 0.5)
206
+ await touchDrag(from, {x: from.x, y: from.y + 300})
207
+ await waitFor(() => expect(getDialog()).toBeNull())
208
+ },
209
+ }
210
+
211
+ export const DragDownOnHeaderDismisses: Story = {
212
+ render: () => <Scene />,
213
+ play: async () => {
214
+ await setup()
215
+ const title = [...document.querySelectorAll('[role="dialog"] *')].find(
216
+ (el) => el.textContent === 'Pick an icon',
217
+ ) as HTMLElement
218
+ const r = title.getBoundingClientRect()
219
+ const from = {x: r.left + r.width / 2, y: r.top + r.height / 2}
220
+ await touchDrag(from, {x: from.x, y: from.y + 300})
221
+ await waitFor(() => expect(getDialog()).toBeNull())
222
+ },
223
+ }
224
+
225
+ export const TapOnToggleStillPresses: Story = {
226
+ render: () => <Scene />,
227
+ play: async () => {
228
+ await setup()
229
+ const key = document.querySelector<HTMLElement>('[role="dialog"] button[aria-label="key"]')!
230
+ const r = key.getBoundingClientRect()
231
+ const at = toPage({x: r.left + r.width / 2, y: r.top + r.height / 2})
232
+ await cdpSend('Input.dispatchTouchEvent', {
233
+ type: 'touchStart',
234
+ touchPoints: [{...at, radiusX: 1, radiusY: 1}],
235
+ })
236
+ await cdpSend('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []})
237
+ await waitFor(() => expect(key).toHaveAttribute('aria-pressed', 'true'))
238
+ await expect(getDialog()).not.toBeNull()
239
+ },
240
+ }
241
+
242
+ export const MountKeepsTheDrawerStill: Story = {
243
+ // size the page before the render: a drawer mounts at its final width
244
+ beforeEach: async () => {
245
+ await page.viewport(390, 844)
246
+ },
247
+ render: () => <Scene pressed={ICON_NAMES[39]} />,
248
+ play: async () => {
249
+ const pageScroll = document.scrollingElement?.scrollTop
250
+ await waitFor(() => expect(getViewport()).toBeTruthy())
251
+ await pause(500)
252
+ const viewport = getViewport()
253
+ const body = viewport.closest('[role="dialog"]')!
254
+ // the list scrolled itself to the pressed icon …
255
+ await expect(viewport.scrollTop).toBeGreaterThan(0)
256
+ const pressed = viewport.querySelector('[aria-pressed="true"]')!.getBoundingClientRect()
257
+ const v = viewport.getBoundingClientRect()
258
+ await expect(pressed.top).toBeGreaterThanOrEqual(v.top - 1)
259
+ await expect(pressed.bottom).toBeLessThanOrEqual(v.bottom + 1)
260
+ // … and nothing outside it moved
261
+ for (const el of body.querySelectorAll<HTMLElement>('*')) {
262
+ if (el !== viewport && !viewport.contains(el)) await expect(el.scrollTop).toBe(0)
263
+ }
264
+ await expect(document.scrollingElement?.scrollTop).toBe(pageScroll)
265
+ },
266
+ }
267
+
268
+ /** The coarse-pointer rows of a wrapping group, measured, not inferred. */
269
+ export const CoarsePointerRows: Story = {
270
+ render: () => (
271
+ <ToggleGroup wrap maxRows={3} size="small" aria-label="Icon">
272
+ {ICON_NAMES.map((name) => (
273
+ <Toggle key={name} value={name} aria-label={name}>
274
+ <Icon name={name} size="sm" />
275
+ </Toggle>
276
+ ))}
277
+ </ToggleGroup>
278
+ ),
279
+ play: async () => {
280
+ // phone width, so 42 icons overflow three rows
281
+ await page.viewport(390, 844)
282
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
283
+ await waitFor(() => expect(matchMedia('(pointer: coarse)').matches).toBe(true))
284
+ const viewport = getViewport()
285
+ const expected = 3.5 * ROW_HEIGHT.coarse + 3 * 4 + 2 * 4
286
+ await waitFor(() => expect(Math.abs(viewport.clientHeight - expected)).toBeLessThanOrEqual(2))
287
+ const toggle = viewport.querySelector('button')!.getBoundingClientRect()
288
+ // exact: Toggle's styles repeat ROW_HEIGHT, and a drift must fail here
289
+ await expect(Math.abs(toggle.height - ROW_HEIGHT.coarse)).toBeLessThanOrEqual(0.5)
290
+ await expect(toggle.width).toBeGreaterThanOrEqual(ROW_HEIGHT.coarse)
291
+ },
292
+ }