@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.
- package/dist/{Table-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
- package/dist/Table-CUp12BKZ.js.map +1 -0
- package/dist/components/ActionBar/ActionBar.d.ts +7 -0
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/ActionBar/styles.css.d.ts +25 -2
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
- package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.meta.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/rowHeight.d.ts +17 -0
- package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
- package/dist/components/Toggle/styles.css.d.ts +10 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
- package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +2987 -2682
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.meta.ts +7 -0
- package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
- package/src/components/ActionBar/ActionBar.tsx +12 -1
- package/src/components/ActionBar/styles.css.ts +34 -2
- package/src/components/Combobox/Combobox.meta.ts +6 -0
- package/src/components/Combobox/Combobox.tsx +5 -0
- package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.tsx +12 -2
- package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
- package/src/components/Drawer/useSwipeDismiss.ts +53 -8
- package/src/components/Menu/useMenuRoot.ts +5 -1
- package/src/components/Popover/Popover.meta.ts +7 -0
- package/src/components/Popover/Popover.tsx +30 -24
- package/src/components/Popover/PopoverLayerContext.ts +20 -0
- package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.tsx +12 -6
- package/src/components/Select/Select.meta.ts +6 -0
- package/src/components/Select/Select.tsx +8 -1
- package/src/components/Select/useSelectRoot.ts +6 -2
- package/src/components/Toggle/Toggle.meta.ts +1 -1
- package/src/components/Toggle/Toggle.tsx +19 -2
- package/src/components/Toggle/rowHeight.ts +12 -0
- package/src/components/Toggle/styles.css.ts +12 -0
- package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
- package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
- package/src/components/ToggleGroup/styles.css.ts +30 -1
- package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
- 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-
|
|
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
|
+
"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.
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
+
}
|