@duro-app/ui 5.2.0 → 5.4.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-C70zdSkM.js → Table-Cm5cCnZD.js} +730 -625
- package/dist/Table-Cm5cCnZD.js.map +1 -0
- package/dist/components/ActionBar/styles.css.d.ts +1 -1
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Badge/Badge.d.ts +1 -1
- package/dist/components/Badge/Badge.d.ts.map +1 -1
- package/dist/components/Badge/Badge.stories.d.ts +3 -0
- package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
- package/dist/components/Badge/styles.css.d.ts +4 -0
- package/dist/components/Badge/styles.css.d.ts.map +1 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
- package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
- package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
- package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
- package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
- package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +2 -2
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/styles.css.d.ts +1 -1
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +2 -2
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Listbox/styles.css.d.ts +1 -1
- package/dist/components/Listbox/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +1 -1
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts +7 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/styles.css.d.ts +4 -1
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +2 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +2 -2
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
- package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +1 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +3 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
- package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.d.ts +22 -0
- package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
- package/dist/components/Toolbar/styles.css.d.ts +16 -0
- package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
- package/dist/components/Tooltip/styles.css.d.ts +1 -1
- package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3164 -2917
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +14 -0
- package/dist/shared/ModalContext.d.ts.map +1 -0
- package/dist/shared/mergeRefs.d.ts +4 -0
- package/dist/shared/mergeRefs.d.ts.map +1 -0
- package/dist/shared/useOrderedRegistry.d.ts +26 -0
- package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
- package/dist/table.js +45 -45
- package/dist/table.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/styles.css.ts +2 -1
- package/src/components/Badge/Badge.stories.tsx +62 -1
- package/src/components/Badge/Badge.tsx +1 -1
- package/src/components/Badge/styles.css.ts +5 -0
- package/src/components/Button/Button.tsx +7 -1
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
- package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
- package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
- package/src/components/ButtonGroup/attached.css.ts +44 -0
- package/src/components/ButtonGroup/styles.css.ts +8 -0
- package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
- package/src/components/Dialog/Dialog.tsx +5 -1
- package/src/components/Dialog/styles.css.ts +3 -2
- package/src/components/DragDrop/DragDrop.tsx +15 -8
- package/src/components/DragDrop/styles.css.ts +4 -1
- package/src/components/Drawer/Drawer.tsx +5 -1
- package/src/components/Drawer/styles.css.ts +3 -2
- package/src/components/Listbox/styles.css.ts +2 -1
- package/src/components/Menu/Menu.tsx +10 -3
- package/src/components/Menu/styles.css.ts +2 -1
- package/src/components/Popover/Popover.tsx +26 -5
- package/src/components/Popover/styles.css.ts +9 -1
- package/src/components/ScrollArea/styles.css.ts +2 -1
- package/src/components/Select/Select.stories.tsx +20 -1
- package/src/components/Select/Select.tsx +9 -3
- package/src/components/Select/styles.css.ts +3 -2
- package/src/components/Select/useSelectRoot.ts +17 -0
- package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
- package/src/components/Toast/styles.css.ts +3 -0
- package/src/components/Toggle/Toggle.tsx +20 -4
- package/src/components/Toggle/styles.css.ts +6 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
- package/src/components/ToggleGroup/styles.css.ts +7 -0
- package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
- package/src/components/Toolbar/Toolbar.meta.ts +48 -0
- package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
- package/src/components/Toolbar/Toolbar.tsx +113 -0
- package/src/components/Toolbar/ToolbarContext.ts +15 -0
- package/src/components/Toolbar/styles.css.ts +17 -0
- package/src/components/Tooltip/styles.css.ts +2 -1
- package/src/components/VirtualTable/styles.css.ts +2 -1
- package/src/index.ts +2 -1
- package/src/shared/ModalContext.ts +18 -0
- package/src/shared/mergeRefs.ts +11 -0
- package/src/shared/useOrderedRegistry.ts +57 -0
- package/dist/Table-C70zdSkM.js.map +0 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, waitFor, within} from 'storybook/test'
|
|
3
|
+
import {Dialog} from '../Dialog/Dialog'
|
|
4
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
5
|
+
import {DragDrop} from '../DragDrop/DragDrop'
|
|
6
|
+
import {Popover} from '../Popover/Popover'
|
|
7
|
+
import {Select} from '../Select/Select'
|
|
8
|
+
import {ToastProvider, useToast} from '../Toast/ToastProvider'
|
|
9
|
+
import {Button} from '../Button/Button'
|
|
10
|
+
import {Stack} from '../Stack/Stack'
|
|
11
|
+
import {Text} from '../Text/Text'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What stacks above what. Dialog, Drawer, the popups and the toast region all
|
|
15
|
+
* portal into the ThemeProvider mount, so their `layers` values order them in
|
|
16
|
+
* one stacking context: a popup or a toast opened over a modal must be the
|
|
17
|
+
* topmost element where it is drawn.
|
|
18
|
+
*/
|
|
19
|
+
const meta: Meta = {
|
|
20
|
+
title: 'Theme/Layers',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default meta
|
|
24
|
+
type Story = StoryObj
|
|
25
|
+
|
|
26
|
+
const page = () => within(document.body)
|
|
27
|
+
|
|
28
|
+
/** True when the element drawn at `el`'s centre is `el` or inside it. */
|
|
29
|
+
function onTop(el: Element): boolean {
|
|
30
|
+
const r = el.getBoundingClientRect()
|
|
31
|
+
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
|
32
|
+
return hit !== null && el.contains(hit)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function LinkPopover() {
|
|
36
|
+
return (
|
|
37
|
+
<Popover.Root>
|
|
38
|
+
<Popover.Trigger>Insert link</Popover.Trigger>
|
|
39
|
+
<Popover.Popup label="Link">
|
|
40
|
+
<Stack gap="sm">
|
|
41
|
+
<Text>Paste a URL.</Text>
|
|
42
|
+
<Popover.Close>Apply</Popover.Close>
|
|
43
|
+
</Stack>
|
|
44
|
+
</Popover.Popup>
|
|
45
|
+
</Popover.Root>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const PopoverInDialog: Story = {
|
|
50
|
+
render: () => (
|
|
51
|
+
<Dialog.Root>
|
|
52
|
+
<Dialog.Trigger>
|
|
53
|
+
<Button>Open editor</Button>
|
|
54
|
+
</Dialog.Trigger>
|
|
55
|
+
<Dialog.Portal size="sm">
|
|
56
|
+
<Dialog.Header>
|
|
57
|
+
<Dialog.Title>Editor</Dialog.Title>
|
|
58
|
+
</Dialog.Header>
|
|
59
|
+
<Dialog.Body>
|
|
60
|
+
<LinkPopover />
|
|
61
|
+
</Dialog.Body>
|
|
62
|
+
</Dialog.Portal>
|
|
63
|
+
</Dialog.Root>
|
|
64
|
+
),
|
|
65
|
+
play: async ({canvas, userEvent}) => {
|
|
66
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open editor'}))
|
|
67
|
+
await userEvent.click(await page().findByRole('button', {name: 'Insert link'}))
|
|
68
|
+
const popup = await page().findByRole('dialog', {name: 'Link'})
|
|
69
|
+
await waitFor(() => expect(onTop(popup)).toBe(true))
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const PopoverInDrawer: Story = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<Drawer.Root>
|
|
76
|
+
<Drawer.Trigger>
|
|
77
|
+
<Button>Open details</Button>
|
|
78
|
+
</Drawer.Trigger>
|
|
79
|
+
<Drawer.Portal>
|
|
80
|
+
<Drawer.Header>
|
|
81
|
+
<Drawer.Title>Details</Drawer.Title>
|
|
82
|
+
</Drawer.Header>
|
|
83
|
+
<Drawer.Body>
|
|
84
|
+
<LinkPopover />
|
|
85
|
+
</Drawer.Body>
|
|
86
|
+
</Drawer.Portal>
|
|
87
|
+
</Drawer.Root>
|
|
88
|
+
),
|
|
89
|
+
play: async ({canvas, userEvent}) => {
|
|
90
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open details'}))
|
|
91
|
+
await userEvent.click(await page().findByRole('button', {name: 'Insert link'}))
|
|
92
|
+
const popup = await page().findByRole('dialog', {name: 'Link'})
|
|
93
|
+
await waitFor(() => expect(onTop(popup)).toBe(true))
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function SaveButton() {
|
|
98
|
+
const {toast} = useToast()
|
|
99
|
+
return (
|
|
100
|
+
<Button onClick={() => toast({variant: 'success', message: 'Draft saved', duration: 0})}>
|
|
101
|
+
Save draft
|
|
102
|
+
</Button>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const ToastOverDialog: Story = {
|
|
107
|
+
render: () => (
|
|
108
|
+
<ToastProvider>
|
|
109
|
+
<Dialog.Root>
|
|
110
|
+
<Dialog.Trigger>
|
|
111
|
+
<Button>Open draft</Button>
|
|
112
|
+
</Dialog.Trigger>
|
|
113
|
+
<Dialog.Portal size="sm">
|
|
114
|
+
<Dialog.Header>
|
|
115
|
+
<Dialog.Title>Draft</Dialog.Title>
|
|
116
|
+
</Dialog.Header>
|
|
117
|
+
<Dialog.Body>
|
|
118
|
+
<SaveButton />
|
|
119
|
+
</Dialog.Body>
|
|
120
|
+
</Dialog.Portal>
|
|
121
|
+
</Dialog.Root>
|
|
122
|
+
</ToastProvider>
|
|
123
|
+
),
|
|
124
|
+
play: async ({canvas, userEvent}) => {
|
|
125
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open draft'}))
|
|
126
|
+
await userEvent.click(await page().findByRole('button', {name: 'Save draft'}))
|
|
127
|
+
const message = await page().findByText('Draft saved')
|
|
128
|
+
const toast = message.closest('[role="status"]') as Element
|
|
129
|
+
await waitFor(() => expect(onTop(toast)).toBe(true))
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ITEMS = [
|
|
134
|
+
{id: 'a', name: 'Alpha'},
|
|
135
|
+
{id: 'b', name: 'Beta'},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
export const DragDropInDialog: Story = {
|
|
139
|
+
render: () => (
|
|
140
|
+
<Dialog.Root>
|
|
141
|
+
<Dialog.Trigger>
|
|
142
|
+
<Button>Open board</Button>
|
|
143
|
+
</Dialog.Trigger>
|
|
144
|
+
<Dialog.Portal size="sm">
|
|
145
|
+
<Dialog.Header>
|
|
146
|
+
<Dialog.Title>Board</Dialog.Title>
|
|
147
|
+
</Dialog.Header>
|
|
148
|
+
<Dialog.Body>
|
|
149
|
+
<DragDrop.Root<{id: string; name: string}> onDrop={() => {}}>
|
|
150
|
+
<Stack gap="md">
|
|
151
|
+
<DragDrop.Zone id="from" label="From">
|
|
152
|
+
<Stack gap="sm">
|
|
153
|
+
{ITEMS.map((item) => (
|
|
154
|
+
<DragDrop.Item
|
|
155
|
+
key={item.id}
|
|
156
|
+
id={item.id}
|
|
157
|
+
zone="from"
|
|
158
|
+
label={item.name}
|
|
159
|
+
data={item}
|
|
160
|
+
>
|
|
161
|
+
<Button variant="secondary">{item.name}</Button>
|
|
162
|
+
</DragDrop.Item>
|
|
163
|
+
))}
|
|
164
|
+
</Stack>
|
|
165
|
+
</DragDrop.Zone>
|
|
166
|
+
<DragDrop.Zone id="to" label="To">
|
|
167
|
+
<Text>Drop here.</Text>
|
|
168
|
+
</DragDrop.Zone>
|
|
169
|
+
</Stack>
|
|
170
|
+
</DragDrop.Root>
|
|
171
|
+
</Dialog.Body>
|
|
172
|
+
</Dialog.Portal>
|
|
173
|
+
</Dialog.Root>
|
|
174
|
+
),
|
|
175
|
+
play: async ({canvas, userEvent}) => {
|
|
176
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open board'}))
|
|
177
|
+
const alpha = await page().findByRole('button', {name: 'Alpha'})
|
|
178
|
+
const target = page().getByText('Drop here.')
|
|
179
|
+
const from = alpha.parentElement as Element
|
|
180
|
+
const a = from.getBoundingClientRect()
|
|
181
|
+
const b = target.getBoundingClientRect()
|
|
182
|
+
const init = (x: number, y: number) => ({
|
|
183
|
+
bubbles: true,
|
|
184
|
+
cancelable: true,
|
|
185
|
+
pointerId: 7,
|
|
186
|
+
pointerType: 'mouse',
|
|
187
|
+
isPrimary: true,
|
|
188
|
+
button: 0,
|
|
189
|
+
buttons: 1,
|
|
190
|
+
clientX: x,
|
|
191
|
+
clientY: y,
|
|
192
|
+
})
|
|
193
|
+
const start = {x: a.left + a.width / 2, y: a.top + a.height / 2}
|
|
194
|
+
const end = {x: b.left + b.width / 2, y: b.top + b.height / 2}
|
|
195
|
+
from.dispatchEvent(new PointerEvent('pointerdown', init(start.x, start.y)))
|
|
196
|
+
for (let i = 1; i <= 6; i++) {
|
|
197
|
+
const x = start.x + ((end.x - start.x) * i) / 6
|
|
198
|
+
const y = start.y + ((end.y - start.y) * i) / 6
|
|
199
|
+
document.dispatchEvent(new PointerEvent('pointermove', init(x, y)))
|
|
200
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
201
|
+
}
|
|
202
|
+
// Mid-drag: the ghost is the aria-hidden copy. It is click-through, so it
|
|
203
|
+
// takes hits for the duration of the check only.
|
|
204
|
+
const ghost = await waitFor(() => {
|
|
205
|
+
const found = [...document.querySelectorAll('[aria-hidden="true"]')].find(
|
|
206
|
+
(el) => el.textContent === 'Alpha' && getComputedStyle(el).position === 'fixed',
|
|
207
|
+
) as HTMLElement | undefined
|
|
208
|
+
if (!found) throw new Error('no ghost yet')
|
|
209
|
+
return found
|
|
210
|
+
})
|
|
211
|
+
ghost.style.pointerEvents = 'auto'
|
|
212
|
+
const visible = onTop(ghost)
|
|
213
|
+
ghost.style.pointerEvents = ''
|
|
214
|
+
document.dispatchEvent(new PointerEvent('pointerup', {...init(end.x, end.y), buttons: 0}))
|
|
215
|
+
await expect(visible).toBe(true)
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A Select opened inside a Popover: its listbox (mounted before the Popover
|
|
220
|
+
* opens) and click-catcher stack above the Popover. */
|
|
221
|
+
export const SelectInPopover: Story = {
|
|
222
|
+
render: () => (
|
|
223
|
+
<Popover.Root>
|
|
224
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
225
|
+
<Popover.Popup label="Style">
|
|
226
|
+
<Select.Root defaultValue="primary">
|
|
227
|
+
<Select.Trigger aria-label="Variant">
|
|
228
|
+
<Select.Value />
|
|
229
|
+
<Select.Icon />
|
|
230
|
+
</Select.Trigger>
|
|
231
|
+
<Select.Popup>
|
|
232
|
+
<Select.Item value="primary">Primary</Select.Item>
|
|
233
|
+
<Select.Item value="secondary">Secondary</Select.Item>
|
|
234
|
+
<Select.Item value="ghost">Ghost</Select.Item>
|
|
235
|
+
</Select.Popup>
|
|
236
|
+
</Select.Root>
|
|
237
|
+
</Popover.Popup>
|
|
238
|
+
</Popover.Root>
|
|
239
|
+
),
|
|
240
|
+
play: async ({canvas, userEvent}) => {
|
|
241
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
242
|
+
await userEvent.click(await page().findByRole('combobox', {name: 'Variant'}))
|
|
243
|
+
for (const name of ['Primary', 'Secondary', 'Ghost']) {
|
|
244
|
+
const option = await page().findByRole('option', {name})
|
|
245
|
+
await waitFor(() => expect(onTop(option)).toBe(true))
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A Dialog opened from a button inside an open Popover: the Dialog is on
|
|
251
|
+
* top, and the Popover does not stay drawn over it. */
|
|
252
|
+
export const DialogFromPopover: Story = {
|
|
253
|
+
render: () => (
|
|
254
|
+
<Popover.Root>
|
|
255
|
+
<Popover.Trigger>Share</Popover.Trigger>
|
|
256
|
+
<Popover.Popup label="Share">
|
|
257
|
+
<Dialog.Root>
|
|
258
|
+
<Dialog.Trigger>
|
|
259
|
+
<Button>Invite people</Button>
|
|
260
|
+
</Dialog.Trigger>
|
|
261
|
+
<Dialog.Portal size="sm">
|
|
262
|
+
<Dialog.Header>
|
|
263
|
+
<Dialog.Title>Invite</Dialog.Title>
|
|
264
|
+
</Dialog.Header>
|
|
265
|
+
<Dialog.Body>
|
|
266
|
+
<Text>Send an invitation.</Text>
|
|
267
|
+
</Dialog.Body>
|
|
268
|
+
</Dialog.Portal>
|
|
269
|
+
</Dialog.Root>
|
|
270
|
+
</Popover.Popup>
|
|
271
|
+
</Popover.Root>
|
|
272
|
+
),
|
|
273
|
+
play: async ({canvas, userEvent}) => {
|
|
274
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Share'}))
|
|
275
|
+
await userEvent.click(await page().findByRole('button', {name: 'Invite people'}))
|
|
276
|
+
const dialog = await page().findByRole('dialog', {name: 'Invite'})
|
|
277
|
+
await waitFor(() => expect(onTop(dialog)).toBe(true))
|
|
278
|
+
// The Popover is either gone or under the modal's backdrop.
|
|
279
|
+
const popover = page().queryByRole('dialog', {name: 'Share'})
|
|
280
|
+
if (popover) await expect(onTop(popover)).toBe(false)
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** DragDrop inside a Dialog whose body is scrolled: the ghost stays under the
|
|
285
|
+
* pointer and on top. */
|
|
286
|
+
export const DragDropInScrolledDialog: Story = {
|
|
287
|
+
render: () => (
|
|
288
|
+
<Dialog.Root>
|
|
289
|
+
<Dialog.Trigger>
|
|
290
|
+
<Button>Open long board</Button>
|
|
291
|
+
</Dialog.Trigger>
|
|
292
|
+
<Dialog.Portal size="sm">
|
|
293
|
+
<Dialog.Header>
|
|
294
|
+
<Dialog.Title>Long board</Dialog.Title>
|
|
295
|
+
</Dialog.Header>
|
|
296
|
+
<Dialog.Body>
|
|
297
|
+
<Stack gap="md">
|
|
298
|
+
{Array.from({length: 30}, (_, i) => (
|
|
299
|
+
<Text key={i}>{`Line ${i + 1}`}</Text>
|
|
300
|
+
))}
|
|
301
|
+
<DragDrop.Root<{id: string; name: string}> onDrop={() => {}}>
|
|
302
|
+
<Stack gap="md">
|
|
303
|
+
<DragDrop.Zone id="from" label="From">
|
|
304
|
+
<Stack gap="sm">
|
|
305
|
+
{ITEMS.map((item) => (
|
|
306
|
+
<DragDrop.Item
|
|
307
|
+
key={item.id}
|
|
308
|
+
id={item.id}
|
|
309
|
+
zone="from"
|
|
310
|
+
label={item.name}
|
|
311
|
+
data={item}
|
|
312
|
+
>
|
|
313
|
+
<Button variant="secondary">{item.name}</Button>
|
|
314
|
+
</DragDrop.Item>
|
|
315
|
+
))}
|
|
316
|
+
</Stack>
|
|
317
|
+
</DragDrop.Zone>
|
|
318
|
+
<DragDrop.Zone id="to" label="To">
|
|
319
|
+
<Text>Drop here.</Text>
|
|
320
|
+
</DragDrop.Zone>
|
|
321
|
+
</Stack>
|
|
322
|
+
</DragDrop.Root>
|
|
323
|
+
</Stack>
|
|
324
|
+
</Dialog.Body>
|
|
325
|
+
</Dialog.Portal>
|
|
326
|
+
</Dialog.Root>
|
|
327
|
+
),
|
|
328
|
+
play: async ({canvas, userEvent}) => {
|
|
329
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open long board'}))
|
|
330
|
+
const alpha = await page().findByRole('button', {name: 'Alpha'})
|
|
331
|
+
alpha.scrollIntoView({block: 'center'})
|
|
332
|
+
let scrolled = 0
|
|
333
|
+
for (let el = alpha.parentElement; el; el = el.parentElement) scrolled += el.scrollTop
|
|
334
|
+
await expect(scrolled).toBeGreaterThan(0)
|
|
335
|
+
const from = alpha.parentElement as Element
|
|
336
|
+
const target = page().getByText('Drop here.')
|
|
337
|
+
const a = from.getBoundingClientRect()
|
|
338
|
+
const b = target.getBoundingClientRect()
|
|
339
|
+
const init = (x: number, y: number) => ({
|
|
340
|
+
bubbles: true,
|
|
341
|
+
cancelable: true,
|
|
342
|
+
pointerId: 7,
|
|
343
|
+
pointerType: 'mouse',
|
|
344
|
+
isPrimary: true,
|
|
345
|
+
button: 0,
|
|
346
|
+
buttons: 1,
|
|
347
|
+
clientX: x,
|
|
348
|
+
clientY: y,
|
|
349
|
+
})
|
|
350
|
+
const start = {x: a.left + a.width / 2, y: a.top + a.height / 2}
|
|
351
|
+
const end = {x: b.left + b.width / 2, y: b.top + b.height / 2}
|
|
352
|
+
from.dispatchEvent(new PointerEvent('pointerdown', init(start.x, start.y)))
|
|
353
|
+
for (let i = 1; i <= 6; i++) {
|
|
354
|
+
const x = start.x + ((end.x - start.x) * i) / 6
|
|
355
|
+
const y = start.y + ((end.y - start.y) * i) / 6
|
|
356
|
+
document.dispatchEvent(new PointerEvent('pointermove', init(x, y)))
|
|
357
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
358
|
+
}
|
|
359
|
+
const ghost = await waitFor(() => {
|
|
360
|
+
const found = [...document.querySelectorAll('[aria-hidden="true"]')].find(
|
|
361
|
+
(el) => el.textContent === 'Alpha' && getComputedStyle(el).position === 'fixed',
|
|
362
|
+
) as HTMLElement | undefined
|
|
363
|
+
if (!found) throw new Error('no ghost yet')
|
|
364
|
+
return found
|
|
365
|
+
})
|
|
366
|
+
const g = ghost.getBoundingClientRect()
|
|
367
|
+
ghost.style.pointerEvents = 'auto'
|
|
368
|
+
const visible = onTop(ghost)
|
|
369
|
+
ghost.style.pointerEvents = ''
|
|
370
|
+
document.dispatchEvent(new PointerEvent('pointerup', {...init(end.x, end.y), buttons: 0}))
|
|
371
|
+
await expect(visible).toBe(true)
|
|
372
|
+
// Under the pointer: the ghost keeps the grab offset, so the pointer is
|
|
373
|
+
// inside it, not displaced by the scroll.
|
|
374
|
+
await expect(end.x).toBeGreaterThanOrEqual(g.left)
|
|
375
|
+
await expect(end.x).toBeLessThanOrEqual(g.right)
|
|
376
|
+
await expect(end.y).toBeGreaterThanOrEqual(g.top)
|
|
377
|
+
await expect(end.y).toBeLessThanOrEqual(g.bottom)
|
|
378
|
+
},
|
|
379
|
+
}
|
|
@@ -3,6 +3,7 @@ import {css, html} from 'react-strict-dom'
|
|
|
3
3
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
4
4
|
import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
|
|
5
5
|
import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
|
|
6
|
+
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
6
7
|
|
|
7
8
|
export type ThemeName = 'dark' | 'light' | 'high-contrast'
|
|
8
9
|
|
|
@@ -32,8 +33,9 @@ const styles = css.create({
|
|
|
32
33
|
right: 0,
|
|
33
34
|
bottom: 0,
|
|
34
35
|
pointerEvents: 'none',
|
|
35
|
-
//
|
|
36
|
-
|
|
36
|
+
// The stacking context the layers scale orders: Dialog, Drawer, the
|
|
37
|
+
// popups and toasts all portal in here.
|
|
38
|
+
zIndex: layers.portal,
|
|
37
39
|
},
|
|
38
40
|
})
|
|
39
41
|
|
|
@@ -6,6 +6,7 @@ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
|
6
6
|
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
7
7
|
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
8
8
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
9
|
+
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
9
10
|
|
|
10
11
|
const enter = css.keyframes({
|
|
11
12
|
from: {opacity: 0, transform: 'translateY(10px)'},
|
|
@@ -28,6 +29,8 @@ export const styles = css.create({
|
|
|
28
29
|
paddingLeft: spacing.md,
|
|
29
30
|
paddingRight: spacing.md,
|
|
30
31
|
pointerEvents: 'none',
|
|
32
|
+
// Above modals and popups: a toast fired while a Dialog is open shows.
|
|
33
|
+
zIndex: layers.toast,
|
|
31
34
|
},
|
|
32
35
|
toast: {
|
|
33
36
|
pointerEvents: 'auto',
|
|
@@ -2,6 +2,8 @@ import {type ReactNode, useCallback, useLayoutEffect, useRef} from 'react'
|
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
|
|
5
|
+
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
6
|
+
import {mergeRefs} from '../../shared/mergeRefs'
|
|
5
7
|
import {styles} from './styles.css'
|
|
6
8
|
|
|
7
9
|
export type ToggleSize = 'default' | 'small'
|
|
@@ -66,6 +68,9 @@ export function Toggle({
|
|
|
66
68
|
)
|
|
67
69
|
|
|
68
70
|
const pressed = groupPressed ?? standalonePressed
|
|
71
|
+
const grouped = useGroupedControl<HTMLButtonElement>({disabled})
|
|
72
|
+
// In a Toolbar the toolbar owns the roving tabindex, not a wrapping group.
|
|
73
|
+
const roving = wrap && value !== undefined && !grouped.onFocus
|
|
69
74
|
|
|
70
75
|
const handleClick = useCallback(() => {
|
|
71
76
|
if (disabled) return
|
|
@@ -78,10 +83,18 @@ export function Toggle({
|
|
|
78
83
|
|
|
79
84
|
return (
|
|
80
85
|
<html.button
|
|
81
|
-
ref={ref}
|
|
86
|
+
ref={mergeRefs(ref, grouped.ref)}
|
|
82
87
|
type="button"
|
|
83
|
-
tabIndex={
|
|
84
|
-
|
|
88
|
+
tabIndex={
|
|
89
|
+
grouped.onFocus
|
|
90
|
+
? grouped.tabIndex
|
|
91
|
+
: roving
|
|
92
|
+
? group?.tabStopValue === value
|
|
93
|
+
? 0
|
|
94
|
+
: -1
|
|
95
|
+
: undefined
|
|
96
|
+
}
|
|
97
|
+
onFocus={grouped.onFocus ?? (roving ? () => group?.onItemFocus(value) : undefined)}
|
|
85
98
|
aria-pressed={pressed}
|
|
86
99
|
aria-label={ariaLabel}
|
|
87
100
|
disabled={disabled}
|
|
@@ -92,8 +105,11 @@ export function Toggle({
|
|
|
92
105
|
sizeMap[size],
|
|
93
106
|
pressed ? styles.pressed : styles.unpressed,
|
|
94
107
|
wrap && wrappedSizeMap[size],
|
|
95
|
-
|
|
108
|
+
// In an attached ButtonGroup the group joins the borders instead.
|
|
109
|
+
isGrouped && !wrap && !grouped.attached && styles.grouped,
|
|
96
110
|
disabled && styles.disabled,
|
|
111
|
+
grouped.style,
|
|
112
|
+
grouped.attached && pressed && styles.attachedPressed,
|
|
97
113
|
]}
|
|
98
114
|
>
|
|
99
115
|
{children}
|
|
@@ -75,6 +75,12 @@ export const styles = css.create({
|
|
|
75
75
|
// Override any borderColor set by pressed state — dividers always use border token
|
|
76
76
|
borderColor: colors.border,
|
|
77
77
|
},
|
|
78
|
+
// Pressed, in an attached group: the accent edge is drawn inset, inside the
|
|
79
|
+
// border a neighbour's negative margin overlaps, so it shows on all four
|
|
80
|
+
// sides without a z-index (which stays for focus).
|
|
81
|
+
attachedPressed: {
|
|
82
|
+
boxShadow: `inset 0 0 0 ${borders.hairline} ${colors.accent}`,
|
|
83
|
+
},
|
|
78
84
|
disabled: {
|
|
79
85
|
opacity: 0.5,
|
|
80
86
|
cursor: 'not-allowed',
|
|
@@ -6,6 +6,8 @@ import {ToggleGroupContext, type Orientation} from './ToggleGroupContext'
|
|
|
6
6
|
import {useFieldGroupLabelling} from '../Field/FieldContext'
|
|
7
7
|
import {ScrollArea} from '../ScrollArea/ScrollArea'
|
|
8
8
|
import {useRovingFocus} from './useRovingFocus'
|
|
9
|
+
import {useToolbar} from '../Toolbar/ToolbarContext'
|
|
10
|
+
import {useButtonGroup} from '../ButtonGroup/ButtonGroupContext'
|
|
9
11
|
import {styles} from './styles.css'
|
|
10
12
|
|
|
11
13
|
interface ToggleGroupProps {
|
|
@@ -72,7 +74,12 @@ export function ToggleGroup({
|
|
|
72
74
|
)
|
|
73
75
|
|
|
74
76
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
75
|
-
|
|
77
|
+
// Inside a Toolbar, the toolbar's roving focus covers these toggles: one
|
|
78
|
+
// handler, never two. Inside an attached ButtonGroup, the group draws the
|
|
79
|
+
// borders and corners.
|
|
80
|
+
const inToolbar = useToolbar() !== null
|
|
81
|
+
const inAttachedGroup = useButtonGroup() !== null
|
|
82
|
+
const roving = useRovingFocus({enabled: wrap && !inToolbar, pressed: value, rootRef})
|
|
76
83
|
const scrolls = wrap && maxRows !== undefined
|
|
77
84
|
const pressedValue = value[0]
|
|
78
85
|
|
|
@@ -107,10 +114,15 @@ export function ToggleGroup({
|
|
|
107
114
|
const group = (
|
|
108
115
|
<html.div
|
|
109
116
|
ref={rootRef}
|
|
110
|
-
role=
|
|
111
|
-
aria-orientation={orientation}
|
|
117
|
+
role={inToolbar ? 'group' : 'toolbar'}
|
|
118
|
+
aria-orientation={inToolbar ? undefined : orientation}
|
|
112
119
|
{...a11y}
|
|
113
|
-
style={[
|
|
120
|
+
style={[
|
|
121
|
+
styles.root,
|
|
122
|
+
orientation === 'vertical' && styles.vertical,
|
|
123
|
+
wrap && styles.wrap,
|
|
124
|
+
inAttachedGroup && styles.inAttachedGroup,
|
|
125
|
+
]}
|
|
114
126
|
>
|
|
115
127
|
{children}
|
|
116
128
|
</html.div>
|
|
@@ -44,4 +44,11 @@ export const styles = css.create({
|
|
|
44
44
|
vertical: {
|
|
45
45
|
flexDirection: 'column',
|
|
46
46
|
},
|
|
47
|
+
// In an attached ButtonGroup: no frame of its own; its toggles join the
|
|
48
|
+
// group's border like any other control.
|
|
49
|
+
inAttachedGroup: {
|
|
50
|
+
borderWidth: 0,
|
|
51
|
+
borderRadius: 0,
|
|
52
|
+
overflow: 'visible',
|
|
53
|
+
},
|
|
47
54
|
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {ButtonGroupContext} from '../ButtonGroup/ButtonGroupContext'
|
|
3
|
+
import {ToolbarContext} from './ToolbarContext'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Popups and modals stop the ButtonGroup and Toolbar contexts. A portal keeps
|
|
7
|
+
* its React parent, so without this the buttons in a Popover opened from a
|
|
8
|
+
* toolbar would come out squared and with tabindex=-1.
|
|
9
|
+
*/
|
|
10
|
+
export function ControlContextBoundary({children}: {children: ReactNode}) {
|
|
11
|
+
return (
|
|
12
|
+
<ButtonGroupContext.Provider value={null}>
|
|
13
|
+
<ToolbarContext.Provider value={null}>{children}</ToolbarContext.Provider>
|
|
14
|
+
</ButtonGroupContext.Provider>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'An editor toolbar: text style, format toggles, insert menus',
|
|
8
|
+
'A dense set of related actions that should cost one Tab, not one per control',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'A dialog footer or a form action row — use ButtonGroup (every button its own tab stop)',
|
|
12
|
+
'A floating bulk-selection bar — use ActionBar',
|
|
13
|
+
'A vertical toolbar holding a Select: a closed Select takes ArrowDown/ArrowUp to open, so use the horizontal orientation (a Menu is fine: its closed trigger handles no arrow key, Enter or Space opens it)',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['Toolbar', 'aria-label'],
|
|
17
|
+
optional: ['ButtonGroup attached', 'Toggle', 'Button', 'Select', 'Menu', 'Popover'],
|
|
18
|
+
},
|
|
19
|
+
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'ButtonGroup',
|
|
22
|
+
kind: 'composition',
|
|
23
|
+
relationship:
|
|
24
|
+
'Place attached ButtonGroups inside a Toolbar: the group joins the controls visually, the toolbar gives them one tab stop and arrow keys',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
component: 'ToggleGroup',
|
|
28
|
+
kind: 'composition',
|
|
29
|
+
relationship:
|
|
30
|
+
'A ToggleGroup inside a Toolbar hands its arrow keys to the toolbar (one roving handler)',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
component: 'ActionBar',
|
|
34
|
+
kind: 'contrast',
|
|
35
|
+
relationship: 'ActionBar floats over a selection; Toolbar sits in the layout',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
example: `<Toolbar aria-label="Formatting">
|
|
39
|
+
<ButtonGroup attached aria-label="Text style">
|
|
40
|
+
<Toggle aria-label="Bold" pressed={bold} onPressedChange={setBold}>B</Toggle>
|
|
41
|
+
<Toggle aria-label="Italic" pressed={italic} onPressedChange={setItalic}>I</Toggle>
|
|
42
|
+
</ButtonGroup>
|
|
43
|
+
<Menu.Root>
|
|
44
|
+
<Menu.Trigger>Insert</Menu.Trigger>
|
|
45
|
+
<Menu.Popup>…</Menu.Popup>
|
|
46
|
+
</Menu.Root>
|
|
47
|
+
</Toolbar>`,
|
|
48
|
+
}
|