@duro-app/ui 5.2.0 → 5.3.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 (139) hide show
  1. package/dist/{Table-C70zdSkM.js → Table-Cm5cCnZD.js} +730 -625
  2. package/dist/Table-Cm5cCnZD.js.map +1 -0
  3. package/dist/components/ActionBar/styles.css.d.ts +1 -1
  4. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  5. package/dist/components/Badge/Badge.d.ts +1 -1
  6. package/dist/components/Badge/Badge.d.ts.map +1 -1
  7. package/dist/components/Badge/Badge.stories.d.ts +3 -0
  8. package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
  9. package/dist/components/Badge/styles.css.d.ts +4 -0
  10. package/dist/components/Badge/styles.css.d.ts.map +1 -1
  11. package/dist/components/Button/Button.d.ts.map +1 -1
  12. package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
  13. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  14. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
  16. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  17. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
  18. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
  19. package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
  20. package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
  21. package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
  22. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
  23. package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
  24. package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
  25. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  26. package/dist/components/Dialog/styles.css.d.ts +2 -2
  27. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  28. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  29. package/dist/components/DragDrop/styles.css.d.ts +1 -1
  30. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  31. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  32. package/dist/components/Drawer/styles.css.d.ts +2 -2
  33. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  34. package/dist/components/Listbox/styles.css.d.ts +1 -1
  35. package/dist/components/Listbox/styles.css.d.ts.map +1 -1
  36. package/dist/components/Menu/Menu.d.ts.map +1 -1
  37. package/dist/components/Menu/styles.css.d.ts +1 -1
  38. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  39. package/dist/components/Popover/Popover.d.ts +7 -2
  40. package/dist/components/Popover/Popover.d.ts.map +1 -1
  41. package/dist/components/Popover/styles.css.d.ts +4 -1
  42. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  43. package/dist/components/ScrollArea/styles.css.d.ts +1 -1
  44. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  45. package/dist/components/Select/Select.d.ts.map +1 -1
  46. package/dist/components/Select/Select.stories.d.ts +2 -0
  47. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  48. package/dist/components/Select/styles.css.d.ts +2 -2
  49. package/dist/components/Select/styles.css.d.ts.map +1 -1
  50. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  51. package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
  52. package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
  53. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  54. package/dist/components/Toast/styles.css.d.ts +1 -0
  55. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  56. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  57. package/dist/components/Toggle/styles.css.d.ts +3 -0
  58. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  59. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  60. package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
  61. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  62. package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
  63. package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
  64. package/dist/components/Toolbar/Toolbar.d.ts +22 -0
  65. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
  66. package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
  67. package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
  68. package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
  69. package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  70. package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
  71. package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
  72. package/dist/components/Toolbar/styles.css.d.ts +16 -0
  73. package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
  74. package/dist/components/Tooltip/styles.css.d.ts +1 -1
  75. package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
  76. package/dist/components/VirtualTable/styles.css.d.ts +1 -1
  77. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  78. package/dist/index.css +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +3164 -2917
  82. package/dist/index.js.map +1 -1
  83. package/dist/shared/ModalContext.d.ts +14 -0
  84. package/dist/shared/ModalContext.d.ts.map +1 -0
  85. package/dist/shared/mergeRefs.d.ts +4 -0
  86. package/dist/shared/mergeRefs.d.ts.map +1 -0
  87. package/dist/shared/useOrderedRegistry.d.ts +26 -0
  88. package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
  89. package/dist/table.js +45 -45
  90. package/dist/table.js.map +1 -1
  91. package/package.json +2 -2
  92. package/src/components/ActionBar/styles.css.ts +2 -1
  93. package/src/components/Badge/Badge.stories.tsx +62 -1
  94. package/src/components/Badge/Badge.tsx +1 -1
  95. package/src/components/Badge/styles.css.ts +5 -0
  96. package/src/components/Button/Button.tsx +7 -1
  97. package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
  98. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
  99. package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
  100. package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
  101. package/src/components/ButtonGroup/attached.css.ts +44 -0
  102. package/src/components/ButtonGroup/styles.css.ts +8 -0
  103. package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
  104. package/src/components/Dialog/Dialog.tsx +5 -1
  105. package/src/components/Dialog/styles.css.ts +3 -2
  106. package/src/components/DragDrop/DragDrop.tsx +15 -8
  107. package/src/components/DragDrop/styles.css.ts +4 -1
  108. package/src/components/Drawer/Drawer.tsx +5 -1
  109. package/src/components/Drawer/styles.css.ts +3 -2
  110. package/src/components/Listbox/styles.css.ts +2 -1
  111. package/src/components/Menu/Menu.tsx +10 -3
  112. package/src/components/Menu/styles.css.ts +2 -1
  113. package/src/components/Popover/Popover.tsx +26 -5
  114. package/src/components/Popover/styles.css.ts +9 -1
  115. package/src/components/ScrollArea/styles.css.ts +2 -1
  116. package/src/components/Select/Select.stories.tsx +20 -1
  117. package/src/components/Select/Select.tsx +9 -3
  118. package/src/components/Select/styles.css.ts +3 -2
  119. package/src/components/Select/useSelectRoot.ts +17 -0
  120. package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
  121. package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
  122. package/src/components/Toast/styles.css.ts +3 -0
  123. package/src/components/Toggle/Toggle.tsx +20 -4
  124. package/src/components/Toggle/styles.css.ts +6 -0
  125. package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
  126. package/src/components/ToggleGroup/styles.css.ts +7 -0
  127. package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
  128. package/src/components/Toolbar/Toolbar.meta.ts +48 -0
  129. package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
  130. package/src/components/Toolbar/Toolbar.tsx +113 -0
  131. package/src/components/Toolbar/ToolbarContext.ts +15 -0
  132. package/src/components/Toolbar/styles.css.ts +17 -0
  133. package/src/components/Tooltip/styles.css.ts +2 -1
  134. package/src/components/VirtualTable/styles.css.ts +2 -1
  135. package/src/index.ts +2 -1
  136. package/src/shared/ModalContext.ts +18 -0
  137. package/src/shared/mergeRefs.ts +11 -0
  138. package/src/shared/useOrderedRegistry.ts +57 -0
  139. package/dist/Table-C70zdSkM.js.map +0 -1
@@ -0,0 +1,468 @@
1
+ import {useEffect, useRef, useState} from 'react'
2
+ import {hydrateRoot} from 'react-dom/client'
3
+ import {renderToString} from 'react-dom/server'
4
+ import type {Meta, StoryObj} from '@storybook/react'
5
+ import {html} from 'react-strict-dom'
6
+ import {expect, userEvent, waitFor, within} from 'storybook/test'
7
+ import {RADII_PX} from '@duro-app/tokens/keys'
8
+ import {Toolbar} from './Toolbar'
9
+ import {Button} from '../Button/Button'
10
+ import {ButtonGroup} from '../ButtonGroup/ButtonGroup'
11
+ import {Field} from '../Field/Field'
12
+ import {Input} from '../Input/Input'
13
+ import {Menu} from '../Menu/Menu'
14
+ import {Popover} from '../Popover/Popover'
15
+ import {Select} from '../Select/Select'
16
+ import {Stack} from '../Stack/Stack'
17
+ import {Toggle} from '../Toggle/Toggle'
18
+
19
+ const meta: Meta<typeof Toolbar> = {
20
+ title: 'Components/Toolbar',
21
+ component: Toolbar,
22
+ }
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof Toolbar>
26
+
27
+ const page = () => within(document.body)
28
+ const R = `${RADII_PX.sm}px`
29
+
30
+ function TextStyle() {
31
+ return (
32
+ <ButtonGroup attached aria-label="Text style">
33
+ <Select.Root defaultValue="normal">
34
+ <Select.Trigger aria-label="Block type">
35
+ <Select.Value />
36
+ <Select.Icon />
37
+ </Select.Trigger>
38
+ <Select.Popup>
39
+ <Select.Item value="normal">Normal</Select.Item>
40
+ <Select.Item value="h1">Heading 1</Select.Item>
41
+ </Select.Popup>
42
+ </Select.Root>
43
+ <Select.Root defaultValue="arial">
44
+ <Select.Trigger aria-label="Font">
45
+ <Select.Value />
46
+ <Select.Icon />
47
+ </Select.Trigger>
48
+ <Select.Popup>
49
+ <Select.Item value="arial">Arial</Select.Item>
50
+ <Select.Item value="georgia">Georgia</Select.Item>
51
+ </Select.Popup>
52
+ </Select.Root>
53
+ <Select.Root defaultValue="15">
54
+ <Select.Trigger aria-label="Font size">
55
+ <Select.Value />
56
+ <Select.Icon />
57
+ </Select.Trigger>
58
+ <Select.Popup>
59
+ <Select.Item value="13">13px</Select.Item>
60
+ <Select.Item value="15">15px</Select.Item>
61
+ </Select.Popup>
62
+ </Select.Root>
63
+ </ButtonGroup>
64
+ )
65
+ }
66
+
67
+ function LinkPopover() {
68
+ return (
69
+ <Popover.Root>
70
+ <Popover.Trigger aria-label="Link">↗</Popover.Trigger>
71
+ <Popover.Popup label="Link">
72
+ <Stack gap="sm">
73
+ <Field.Root>
74
+ <Field.Label>URL</Field.Label>
75
+ <Input defaultValue="https://duro.app" />
76
+ </Field.Root>
77
+ <Button size="small">Apply</Button>
78
+ </Stack>
79
+ </Popover.Popup>
80
+ </Popover.Root>
81
+ )
82
+ }
83
+
84
+ function Format() {
85
+ const [pressed, setPressed] = useState<Record<string, boolean>>({})
86
+ const toggle = (key: string) => (next: boolean) => setPressed((p) => ({...p, [key]: next}))
87
+ return (
88
+ <ButtonGroup attached aria-label="Format">
89
+ <Toggle aria-label="Bold" pressed={!!pressed.b} onPressedChange={toggle('b')}>
90
+ B
91
+ </Toggle>
92
+ <Toggle aria-label="Italic" pressed={!!pressed.i} onPressedChange={toggle('i')}>
93
+ I
94
+ </Toggle>
95
+ <Toggle aria-label="Underline" pressed={!!pressed.u} onPressedChange={toggle('u')}>
96
+ U
97
+ </Toggle>
98
+ <Toggle aria-label="Code" pressed={!!pressed.code} onPressedChange={toggle('code')}>
99
+ {'<>'}
100
+ </Toggle>
101
+ <LinkPopover />
102
+ <Button variant="secondary" aria-label="Clear formatting">
103
+ T
104
+ </Button>
105
+ <Popover.Root>
106
+ <Popover.Trigger aria-label="Highlight">H</Popover.Trigger>
107
+ <Popover.Popup label="Highlight">
108
+ <Button size="small">Purple</Button>
109
+ </Popover.Popup>
110
+ </Popover.Root>
111
+ </ButtonGroup>
112
+ )
113
+ }
114
+
115
+ function InsertMenu() {
116
+ return (
117
+ <Menu.Root>
118
+ <Menu.Trigger>Insert</Menu.Trigger>
119
+ <Menu.Popup>
120
+ <Menu.Item>Table</Menu.Item>
121
+ <Menu.Item>Image</Menu.Item>
122
+ </Menu.Popup>
123
+ </Menu.Root>
124
+ )
125
+ }
126
+
127
+ function Editor() {
128
+ return (
129
+ <Toolbar aria-label="Formatting">
130
+ <TextStyle />
131
+ <Format />
132
+ <InsertMenu />
133
+ </Toolbar>
134
+ )
135
+ }
136
+
137
+ /** Every focusable control of the toolbar, in order: 3 + 7 + 1. */
138
+ const CONTROLS = [
139
+ 'Block type',
140
+ 'Font',
141
+ 'Font size',
142
+ 'Bold',
143
+ 'Italic',
144
+ 'Underline',
145
+ 'Code',
146
+ 'Link',
147
+ 'Clear formatting',
148
+ 'Highlight',
149
+ 'Insert',
150
+ ]
151
+
152
+ function control(name: string) {
153
+ return page().getByRole(
154
+ ['Block type', 'Font', 'Font size'].includes(name) ? 'combobox' : 'button',
155
+ {name},
156
+ )
157
+ }
158
+
159
+ /** [Normal | Arial | 15px] and [B | I | U | <> | link | T | highlight], then Insert. */
160
+ export const Default: Story = {
161
+ render: () => <Editor />,
162
+ play: async ({canvas}) => {
163
+ await expect(canvas.getByRole('toolbar', {name: 'Formatting'})).toBeInTheDocument()
164
+ // One tab stop.
165
+ await waitFor(() =>
166
+ expect(CONTROLS.map((name) => control(name).tabIndex)).toEqual(
167
+ CONTROLS.map((_, i) => (i === 0 ? 0 : -1)),
168
+ ),
169
+ )
170
+ },
171
+ }
172
+
173
+ /** Tab enters once; ArrowRight crosses the groups; End and Home reach the ends. */
174
+ export const ArrowKeys: Story = {
175
+ render: () => (
176
+ <Stack gap="sm">
177
+ <Button variant="secondary">Before</Button>
178
+ <Editor />
179
+ </Stack>
180
+ ),
181
+ play: async ({canvas}) => {
182
+ canvas.getByRole('button', {name: 'Before'}).focus()
183
+ await userEvent.tab()
184
+ await expect(control(CONTROLS[0])).toHaveFocus()
185
+ for (let i = 1; i <= 8; i++) {
186
+ await userEvent.keyboard('{ArrowRight}')
187
+ await expect(control(CONTROLS[i])).toHaveFocus()
188
+ }
189
+ await userEvent.keyboard('{End}')
190
+ await expect(control(CONTROLS[CONTROLS.length - 1])).toHaveFocus()
191
+ await userEvent.keyboard('{Home}')
192
+ await expect(control(CONTROLS[0])).toHaveFocus()
193
+ await userEvent.keyboard('{ArrowLeft}')
194
+ await expect(control(CONTROLS[0])).toHaveFocus()
195
+ // Tab leaves the toolbar in one press, and Shift+Tab comes back to the
196
+ // control last focused.
197
+ await userEvent.keyboard('{End}')
198
+ await userEvent.tab()
199
+ await expect(document.activeElement?.closest('[role="toolbar"]')).toBeNull()
200
+ await userEvent.tab({shift: true})
201
+ await expect(control('Insert')).toHaveFocus()
202
+ },
203
+ }
204
+
205
+ /** A closed Select takes ArrowDown: it opens, and Escape closes it on its trigger. */
206
+ export const SelectArrowDown: Story = {
207
+ render: () => <Editor />,
208
+ play: async () => {
209
+ const font = control('Font')
210
+ font.focus()
211
+ await userEvent.keyboard('{ArrowDown}')
212
+ await expect(font).toHaveAttribute('aria-expanded', 'true')
213
+ await expect(font).toHaveFocus()
214
+ await expect(page().getByRole('listbox')).toBeVisible()
215
+ await userEvent.keyboard('{Escape}')
216
+ await expect(font).toHaveAttribute('aria-expanded', 'false')
217
+ await expect(font).toHaveFocus()
218
+ },
219
+ }
220
+
221
+ /** ArrowRight while a Select is open: focus stays on its trigger, the listbox stays open. */
222
+ export const SelectOpenKeepsArrows: Story = {
223
+ render: () => <Editor />,
224
+ play: async () => {
225
+ const font = control('Font')
226
+ font.focus()
227
+ await userEvent.keyboard('{ArrowUp}')
228
+ await expect(font).toHaveAttribute('aria-expanded', 'true')
229
+ await userEvent.keyboard('{ArrowRight}')
230
+ await expect(font).toHaveFocus()
231
+ await expect(font).toHaveAttribute('aria-expanded', 'true')
232
+ await expect(page().getByRole('listbox')).toBeVisible()
233
+ await userEvent.keyboard('{Escape}')
234
+ },
235
+ }
236
+
237
+ /** The link Popover's contents are outside the group and the toolbar: its
238
+ * Apply button is one Tab away and has all four corners round. */
239
+ export const PopoverInsideToolbar: Story = {
240
+ render: () => <Editor />,
241
+ play: async ({userEvent}) => {
242
+ await userEvent.click(control('Link'))
243
+ const url = await page().findByRole('textbox', {name: 'URL'})
244
+ await waitFor(() => expect(url).toHaveFocus())
245
+ await userEvent.tab()
246
+ const apply = page().getByRole('button', {name: 'Apply'})
247
+ await expect(apply).toHaveFocus()
248
+ const cs = getComputedStyle(apply)
249
+ await expect([
250
+ cs.borderTopLeftRadius,
251
+ cs.borderTopRightRadius,
252
+ cs.borderBottomRightRadius,
253
+ cs.borderBottomLeftRadius,
254
+ ]).toEqual([R, R, R, R])
255
+ await expect(apply).not.toHaveAttribute('tabindex', '-1')
256
+ },
257
+ }
258
+
259
+ /** ArrowLeft typed in the Popover's input moves the caret, not toolbar focus. */
260
+ export const PopoverInputKeepsArrows: Story = {
261
+ render: () => <Editor />,
262
+ play: async ({userEvent}) => {
263
+ await userEvent.click(control('Link'))
264
+ const url = (await page().findByRole('textbox', {name: 'URL'})) as HTMLInputElement
265
+ await waitFor(() => expect(url).toHaveFocus())
266
+ url.setSelectionRange(url.value.length, url.value.length)
267
+ const end = url.selectionStart ?? 0
268
+ await userEvent.keyboard('{ArrowLeft}{ArrowLeft}')
269
+ await expect(url).toHaveFocus()
270
+ await expect(url.selectionStart).toBe(end - 2)
271
+ },
272
+ }
273
+
274
+ /** A control that handles ArrowRight itself (preventDefault) keeps it. */
275
+ function GreedyButton() {
276
+ const ref = useRef<HTMLButtonElement>(null)
277
+ useEffect(() => {
278
+ const el = ref.current
279
+ if (!el) return
280
+ const onKeyDown = (e: KeyboardEvent) => {
281
+ if (e.key === 'ArrowRight') e.preventDefault()
282
+ }
283
+ el.addEventListener('keydown', onKeyDown)
284
+ return () => el.removeEventListener('keydown', onKeyDown)
285
+ }, [])
286
+ return (
287
+ <Button ref={ref} variant="secondary">
288
+ Stepper
289
+ </Button>
290
+ )
291
+ }
292
+
293
+ export const ControlKeepsHandledKeys: Story = {
294
+ render: () => (
295
+ <Toolbar aria-label="Playback">
296
+ <GreedyButton />
297
+ <Button variant="secondary">Next</Button>
298
+ </Toolbar>
299
+ ),
300
+ play: async ({canvas}) => {
301
+ const stepper = canvas.getByRole('button', {name: 'Stepper'})
302
+ stepper.focus()
303
+ await userEvent.keyboard('{ArrowRight}')
304
+ await expect(stepper).toHaveFocus()
305
+ },
306
+ }
307
+
308
+ /** Vertical: Up/Down move. */
309
+ export const Vertical: Story = {
310
+ render: () => (
311
+ <Toolbar aria-label="Tools" orientation="vertical">
312
+ <ButtonGroup attached orientation="vertical" aria-label="Draw">
313
+ <Button variant="secondary">Pen</Button>
314
+ <Button variant="secondary">Eraser</Button>
315
+ </ButtonGroup>
316
+ <Button variant="secondary">Undo</Button>
317
+ </Toolbar>
318
+ ),
319
+ play: async ({canvas}) => {
320
+ const pen = canvas.getByRole('button', {name: 'Pen'})
321
+ pen.focus()
322
+ await userEvent.keyboard('{ArrowDown}')
323
+ await expect(canvas.getByRole('button', {name: 'Eraser'})).toHaveFocus()
324
+ await userEvent.keyboard('{ArrowDown}')
325
+ await expect(canvas.getByRole('button', {name: 'Undo'})).toHaveFocus()
326
+ await userEvent.keyboard('{ArrowUp}')
327
+ await expect(canvas.getByRole('button', {name: 'Eraser'})).toHaveFocus()
328
+ },
329
+ }
330
+
331
+ /**
332
+ * Server render: no control has registered yet, so every control in an
333
+ * attached group renders `middle` (square, joined) and every control is in
334
+ * the tab order. Hydration matches that markup exactly, then the outer
335
+ * corners round and the toolbar takes one tab stop.
336
+ */
337
+ function SsrToolbar() {
338
+ return (
339
+ <Toolbar aria-label="Server">
340
+ <ButtonGroup attached aria-label="Actions">
341
+ <Button variant="secondary">One</Button>
342
+ <Button variant="secondary">Two</Button>
343
+ <Button variant="secondary">Three</Button>
344
+ </ButtonGroup>
345
+ </Toolbar>
346
+ )
347
+ }
348
+
349
+ export const ServerRender: Story = {
350
+ render: () => <html.div data-ssr-host="" />,
351
+ play: async ({canvasElement}) => {
352
+ const host = canvasElement.querySelector('[data-ssr-host]') as HTMLElement
353
+ host.innerHTML = renderToString(<SsrToolbar />)
354
+ const buttons = () => [...host.querySelectorAll('button')]
355
+ const radii = (el: Element) => {
356
+ const cs = getComputedStyle(el)
357
+ return [
358
+ cs.borderTopLeftRadius,
359
+ cs.borderTopRightRadius,
360
+ cs.borderBottomRightRadius,
361
+ cs.borderBottomLeftRadius,
362
+ ]
363
+ }
364
+ // Server markup: all middle, no roving tabindex yet.
365
+ for (const b of buttons()) {
366
+ await expect(radii(b)).toEqual(['0px', '0px', '0px', '0px'])
367
+ await expect(b.hasAttribute('tabindex')).toBe(false)
368
+ }
369
+ const errors: unknown[] = []
370
+ const consoleError = console.error
371
+ console.error = (...args: unknown[]) => {
372
+ errors.push(args)
373
+ consoleError(...args)
374
+ }
375
+ const root = hydrateRoot(host, <SsrToolbar />, {
376
+ onRecoverableError: (error) => errors.push(error),
377
+ })
378
+ try {
379
+ await waitFor(() => expect(radii(buttons()[0])).toEqual([R, '0px', '0px', R]))
380
+ await expect(radii(buttons()[1])).toEqual(['0px', '0px', '0px', '0px'])
381
+ await expect(radii(buttons()[2])).toEqual(['0px', R, R, '0px'])
382
+ await expect(buttons().map((b) => b.tabIndex)).toEqual([0, -1, -1])
383
+ await expect(errors).toEqual([])
384
+ } finally {
385
+ console.error = consoleError
386
+ root.unmount()
387
+ }
388
+ },
389
+ }
390
+
391
+ /** Keyed controls reordered without remounting: corners and arrow order
392
+ * follow the new DOM order. */
393
+ function Reorderable() {
394
+ const [names, setNames] = useState(['One', 'Two', 'Three'])
395
+ return (
396
+ <Stack gap="md">
397
+ <Button variant="secondary" onClick={() => setNames((n) => [...n].reverse())}>
398
+ Reverse
399
+ </Button>
400
+ <Toolbar aria-label="Ordered">
401
+ <ButtonGroup attached aria-label="Items">
402
+ {names.map((name) => (
403
+ <Button key={name} variant="secondary">
404
+ {name}
405
+ </Button>
406
+ ))}
407
+ </ButtonGroup>
408
+ </Toolbar>
409
+ </Stack>
410
+ )
411
+ }
412
+
413
+ export const ReorderKeyedChildren: Story = {
414
+ render: () => <Reorderable />,
415
+ play: async ({canvas}) => {
416
+ const one = canvas.getByRole('button', {name: 'One'})
417
+ await userEvent.click(canvas.getByRole('button', {name: 'Reverse'}))
418
+ // Same element, moved: no remount.
419
+ await expect(canvas.getByRole('button', {name: 'One'})).toBe(one)
420
+ const radii = (el: Element) => {
421
+ const cs = getComputedStyle(el)
422
+ return [
423
+ cs.borderTopLeftRadius,
424
+ cs.borderTopRightRadius,
425
+ cs.borderBottomRightRadius,
426
+ cs.borderBottomLeftRadius,
427
+ ]
428
+ }
429
+ const three = canvas.getByRole('button', {name: 'Three'})
430
+ await waitFor(() => expect(radii(three)).toEqual([R, '0px', '0px', R]))
431
+ await expect(radii(one)).toEqual(['0px', R, R, '0px'])
432
+ await expect(three.tabIndex).toBe(0)
433
+ three.focus()
434
+ await userEvent.keyboard('{ArrowRight}')
435
+ await expect(canvas.getByRole('button', {name: 'Two'})).toHaveFocus()
436
+ await userEvent.keyboard('{ArrowRight}')
437
+ await expect(one).toHaveFocus()
438
+ },
439
+ }
440
+
441
+ /** Vertical, with a Menu: a closed Menu.Trigger handles no arrow key, so
442
+ * ArrowDown moves on (Enter or Space opens it). Unlike a Select, a Menu can
443
+ * sit in a vertical toolbar. */
444
+ export const VerticalWithMenu: Story = {
445
+ render: () => (
446
+ <Toolbar aria-label="Tools" orientation="vertical">
447
+ <Menu.Root>
448
+ <Menu.Trigger>Insert</Menu.Trigger>
449
+ <Menu.Popup>
450
+ <Menu.Item>Table</Menu.Item>
451
+ </Menu.Popup>
452
+ </Menu.Root>
453
+ <Button variant="secondary">Undo</Button>
454
+ </Toolbar>
455
+ ),
456
+ play: async ({canvas}) => {
457
+ const insert = canvas.getByRole('button', {name: 'Insert'})
458
+ insert.focus()
459
+ await userEvent.keyboard('{ArrowDown}')
460
+ await expect(insert).toHaveAttribute('aria-expanded', 'false')
461
+ await expect(canvas.getByRole('button', {name: 'Undo'})).toHaveFocus()
462
+ await userEvent.keyboard('{ArrowUp}')
463
+ await expect(insert).toHaveFocus()
464
+ await userEvent.keyboard('{Enter}')
465
+ await expect(insert).toHaveAttribute('aria-expanded', 'true')
466
+ await userEvent.keyboard('{Escape}')
467
+ },
468
+ }
@@ -0,0 +1,113 @@
1
+ import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import type {Orientation} from '../../shared/types'
4
+ import {useOrderedRegistry} from '../../shared/useOrderedRegistry'
5
+ import {ToolbarContext, type ToolbarContextValue} from './ToolbarContext'
6
+ import {styles} from './styles.css'
7
+
8
+ export interface ToolbarProps {
9
+ /** Names the toolbar ("Formatting"). Required: a toolbar is a landmark-like widget. */
10
+ 'aria-label': string
11
+ /**
12
+ * Arrow keys follow the orientation: Left/Right (default) or Up/Down. A
13
+ * vertical toolbar cannot hold a Select, whose closed trigger opens on
14
+ * ArrowDown/ArrowUp. A Menu can: its closed trigger handles no arrow key,
15
+ * so Up/Down move past it and Enter or Space opens it.
16
+ */
17
+ orientation?: Orientation
18
+ children: ReactNode
19
+ }
20
+
21
+ /**
22
+ * A row of controls with one tab stop (the WAI-ARIA toolbar pattern). Every
23
+ * Button, Toggle and Select/Menu/Popover trigger inside it, through any
24
+ * ButtonGroup, joins one roving tabindex: Tab enters at the last focused
25
+ * control, Left/Right (Up/Down when vertical) move, Home/End jump to the ends.
26
+ */
27
+ export function Toolbar({
28
+ 'aria-label': ariaLabel,
29
+ orientation = 'horizontal',
30
+ children,
31
+ }: ToolbarProps) {
32
+ const rootRef = useRef<HTMLDivElement>(null)
33
+ const {elements, order, register} = useOrderedRegistry()
34
+ const [focusedId, setFocusedId] = useState<string | null>(null)
35
+
36
+ const enabled = order.filter((entry) => !entry.disabled)
37
+ const tabStop =
38
+ focusedId !== null && enabled.some((entry) => entry.id === focusedId)
39
+ ? focusedId
40
+ : (enabled[0]?.id ?? null)
41
+
42
+ const tabIndexOf = useCallback<ToolbarContextValue['tabIndexOf']>(
43
+ (id) => {
44
+ if (!order.some((entry) => entry.id === id)) return undefined
45
+ return id === tabStop ? 0 : -1
46
+ },
47
+ [order, tabStop],
48
+ )
49
+
50
+ // One listener on the document, in the bubble phase, so it runs after the
51
+ // controls' own handlers (React dispatches from the app root, below the
52
+ // document). It acts only on a key aimed at one of this toolbar's own
53
+ // registered controls, so keys typed in a portalled popup (a Popover's
54
+ // input) never move toolbar focus. A control whose popup is open
55
+ // (aria-expanded="true") keeps its keys, and so does one that already
56
+ // handled the key (defaultPrevented).
57
+ useEffect(() => {
58
+ const doc = rootRef.current?.ownerDocument
59
+ if (!doc) return
60
+ const [prev, next] =
61
+ orientation === 'vertical' ? ['ArrowUp', 'ArrowDown'] : ['ArrowLeft', 'ArrowRight']
62
+ function onKeyDown(e: KeyboardEvent) {
63
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
64
+ const els = order
65
+ .filter((entry) => !entry.disabled)
66
+ .map((entry) => elements.current.get(entry.id)?.el)
67
+ .filter((el): el is HTMLElement => el !== undefined)
68
+ const target = e.target as HTMLElement
69
+ const i = els.indexOf(target)
70
+ if (i < 0 || target.getAttribute('aria-expanded') === 'true') return
71
+ let to: HTMLElement | undefined
72
+ switch (e.key) {
73
+ case next:
74
+ to = els[Math.min(i + 1, els.length - 1)]
75
+ break
76
+ case prev:
77
+ to = els[Math.max(i - 1, 0)]
78
+ break
79
+ case 'Home':
80
+ to = els[0]
81
+ break
82
+ case 'End':
83
+ to = els[els.length - 1]
84
+ break
85
+ default:
86
+ return
87
+ }
88
+ e.preventDefault()
89
+ to?.focus()
90
+ }
91
+ doc.addEventListener('keydown', onKeyDown)
92
+ return () => doc.removeEventListener('keydown', onKeyDown)
93
+ }, [order, orientation, elements])
94
+
95
+ const ctx = useMemo<ToolbarContextValue>(
96
+ () => ({register, tabIndexOf, onItemFocus: setFocusedId}),
97
+ [register, tabIndexOf],
98
+ )
99
+
100
+ return (
101
+ <ToolbarContext.Provider value={ctx}>
102
+ <html.div
103
+ ref={rootRef}
104
+ role="toolbar"
105
+ aria-label={ariaLabel}
106
+ aria-orientation={orientation}
107
+ style={[styles.root, orientation === 'vertical' && styles.vertical]}
108
+ >
109
+ {children}
110
+ </html.div>
111
+ </ToolbarContext.Provider>
112
+ )
113
+ }
@@ -0,0 +1,15 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface ToolbarContextValue {
4
+ register: (id: string, el: HTMLElement, disabled: boolean) => () => void
5
+ /** 0 for the toolbar's one tab stop, -1 for the rest; undefined before
6
+ * the control has registered (server render, first paint). */
7
+ tabIndexOf: (id: string) => 0 | -1 | undefined
8
+ onItemFocus: (id: string) => void
9
+ }
10
+
11
+ export const ToolbarContext = createContext<ToolbarContextValue | null>(null)
12
+
13
+ export function useToolbar() {
14
+ return useContext(ToolbarContext)
15
+ }
@@ -0,0 +1,17 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+
4
+ export const styles = css.create({
5
+ root: {
6
+ display: 'flex',
7
+ flexDirection: 'row',
8
+ flexWrap: 'wrap',
9
+ alignItems: 'center',
10
+ gap: spacing.sm,
11
+ },
12
+ vertical: {
13
+ flexDirection: 'column',
14
+ flexWrap: 'nowrap',
15
+ alignItems: 'stretch',
16
+ },
17
+ })
@@ -4,6 +4,7 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
7
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
7
8
 
8
9
  export const styles = css.create({
9
10
  root: {
@@ -12,7 +13,7 @@ export const styles = css.create({
12
13
  },
13
14
  popup: {
14
15
  position: 'absolute',
15
- zIndex: 50,
16
+ zIndex: layers.floating,
16
17
  paddingTop: spacing.xs,
17
18
  paddingBottom: spacing.xs,
18
19
  paddingLeft: spacing.sm,
@@ -3,6 +3,7 @@ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {microSpacing, radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
6
7
 
7
8
  export const styles = css.create({
8
9
  wrap: {
@@ -27,7 +28,7 @@ export const styles = css.create({
27
28
  head: {
28
29
  position: 'sticky',
29
30
  top: 0,
30
- zIndex: 1,
31
+ zIndex: layers.raised,
31
32
  backgroundColor: colors.bgCard,
32
33
  },
33
34
  headRow: {
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export {Alert, type AlertVariant} from './components/Alert/Alert'
13
13
  export {Icon, type IconName} from './components/Icon'
14
14
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
15
15
  export {Button, type ButtonVariant, type ButtonSize} from './components/Button/Button'
16
- export {ButtonGroup} from './components/ButtonGroup/ButtonGroup'
16
+ export {ButtonGroup, type ButtonGroupProps} from './components/ButtonGroup/ButtonGroup'
17
17
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
18
18
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
19
19
  export {Checkbox} from './components/Checkbox/Checkbox'
@@ -94,6 +94,7 @@ export {Text, type TextVariant, type TextColor} from './components/Text/Text'
94
94
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
95
95
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'
96
96
  export {Tooltip} from './components/Tooltip/Tooltip'
97
+ export {Toolbar, type ToolbarProps} from './components/Toolbar/Toolbar'
97
98
  export {Popover} from './components/Popover/Popover'
98
99
  export type {PopoverHandle} from './components/Popover/Popover'
99
100
  export {Switch} from './components/Switch/Switch'
@@ -0,0 +1,18 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ /**
4
+ * True inside a Dialog or Drawer. A Popover reads it to pick its layer: inside
5
+ * a modal it must clear that modal (`layers.popover`); outside one it stays at
6
+ * `layers.floating`, so a modal opened later, even from inside the Popover,
7
+ * covers it.
8
+ *
9
+ * holds-until: a Dialog opened from a Popover that itself sits inside a Dialog
10
+ * still renders under that Popover (1000/1001 < 1040). It is fixed when a
11
+ * nested Dialog lifts its layer above its opener, or when the parent Popover
12
+ * closes on a nested modal opening; no consumer needs it yet.
13
+ */
14
+ export const ModalContext = createContext(false)
15
+
16
+ export function useInModal() {
17
+ return useContext(ModalContext)
18
+ }