@duro-app/ui 5.1.0 → 5.2.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 (123) hide show
  1. package/dist/Table-C70zdSkM.js +1419 -0
  2. package/dist/Table-C70zdSkM.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +11 -2
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/Button.stories.d.ts +1 -0
  6. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  7. package/dist/components/Combobox/Combobox.d.ts +1 -1
  8. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  9. package/dist/components/Combobox/styles.css.d.ts +0 -64
  10. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  11. package/dist/components/Dialog/Dialog.d.ts +11 -3
  12. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  13. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  14. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  15. package/dist/components/Dialog/styles.css.d.ts +1 -0
  16. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  17. package/dist/components/DragDrop/styles.css.d.ts +10 -2
  18. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  19. package/dist/components/Grid/Grid.d.ts +11 -2
  20. package/dist/components/Grid/Grid.d.ts.map +1 -1
  21. package/dist/components/Grid/Grid.stories.d.ts +2 -0
  22. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  23. package/dist/components/Input/Input.d.ts +6 -1
  24. package/dist/components/Input/Input.d.ts.map +1 -1
  25. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  26. package/dist/components/Input/Input.stories.d.ts +1 -0
  27. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  28. package/dist/components/Input/styles.css.d.ts +18 -0
  29. package/dist/components/Input/styles.css.d.ts.map +1 -1
  30. package/dist/components/InputGroup/InputGroup.d.ts +6 -1
  31. package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
  32. package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
  33. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  34. package/dist/components/Listbox/Listbox.d.ts +75 -0
  35. package/dist/components/Listbox/Listbox.d.ts.map +1 -0
  36. package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
  37. package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
  38. package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
  39. package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
  40. package/dist/components/Listbox/styles.css.d.ts +75 -0
  41. package/dist/components/Listbox/styles.css.d.ts.map +1 -0
  42. package/dist/components/Menu/Menu.d.ts +18 -5
  43. package/dist/components/Menu/Menu.d.ts.map +1 -1
  44. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  45. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  46. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  47. package/dist/components/Menu/MenuContext.d.ts +3 -0
  48. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  49. package/dist/components/Menu/styles.css.d.ts +24 -15
  50. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  51. package/dist/components/Menu/useMenuRoot.d.ts +14 -1
  52. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  53. package/dist/components/TextLink/TextLink.d.ts +24 -0
  54. package/dist/components/TextLink/TextLink.d.ts.map +1 -0
  55. package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
  56. package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
  57. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  58. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
  59. package/dist/components/TextLink/styles.css.d.ts +28 -0
  60. package/dist/components/TextLink/styles.css.d.ts.map +1 -0
  61. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
  62. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
  63. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
  64. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  65. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
  66. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
  67. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
  68. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
  69. package/dist/docs/FlexGrow.stories.d.ts +7 -0
  70. package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
  71. package/dist/index.css +1 -1
  72. package/dist/index.d.ts +5 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +2943 -2778
  75. package/dist/index.js.map +1 -1
  76. package/dist/index.native.d.ts +1 -0
  77. package/dist/index.native.d.ts.map +1 -1
  78. package/dist/styles/visually-hidden.css.d.ts +6 -0
  79. package/dist/styles/visually-hidden.css.d.ts.map +1 -1
  80. package/dist/table.js +1 -1
  81. package/package.json +2 -2
  82. package/src/components/Button/Button.stories.tsx +41 -0
  83. package/src/components/Button/Button.tsx +16 -1
  84. package/src/components/Combobox/Combobox.tsx +25 -79
  85. package/src/components/Combobox/styles.css.ts +0 -74
  86. package/src/components/Dialog/Dialog.meta.ts +1 -1
  87. package/src/components/Dialog/Dialog.stories.tsx +109 -3
  88. package/src/components/Dialog/Dialog.tsx +63 -9
  89. package/src/components/Dialog/styles.css.ts +2 -0
  90. package/src/components/DragDrop/styles.css.ts +16 -3
  91. package/src/components/Grid/Grid.meta.ts +1 -1
  92. package/src/components/Grid/Grid.stories.tsx +53 -1
  93. package/src/components/Grid/Grid.tsx +23 -11
  94. package/src/components/Input/Input.meta.ts +10 -3
  95. package/src/components/Input/Input.stories.tsx +52 -2
  96. package/src/components/Input/Input.tsx +12 -3
  97. package/src/components/Input/styles.css.ts +29 -0
  98. package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
  99. package/src/components/InputGroup/InputGroup.tsx +20 -2
  100. package/src/components/Listbox/Listbox.meta.ts +52 -0
  101. package/src/components/Listbox/Listbox.stories.tsx +170 -0
  102. package/src/components/Listbox/Listbox.tsx +278 -0
  103. package/src/components/Listbox/styles.css.ts +89 -0
  104. package/src/components/Menu/Menu.meta.ts +2 -1
  105. package/src/components/Menu/Menu.stories.tsx +289 -8
  106. package/src/components/Menu/Menu.tsx +100 -29
  107. package/src/components/Menu/MenuContext.ts +3 -0
  108. package/src/components/Menu/styles.css.ts +31 -15
  109. package/src/components/Menu/useMenuRoot.ts +105 -48
  110. package/src/components/TextLink/TextLink.meta.ts +33 -0
  111. package/src/components/TextLink/TextLink.stories.tsx +63 -0
  112. package/src/components/TextLink/TextLink.tsx +46 -0
  113. package/src/components/TextLink/styles.css.ts +58 -0
  114. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
  115. package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
  116. package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
  117. package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
  118. package/src/docs/FlexGrow.stories.tsx +83 -0
  119. package/src/index.native.ts +1 -0
  120. package/src/index.ts +5 -0
  121. package/src/styles/visually-hidden.css.ts +8 -0
  122. package/dist/Table-BkrgH0jb.js +0 -1199
  123. package/dist/Table-BkrgH0jb.js.map +0 -1
@@ -1,7 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn, spyOn} from 'storybook/test'
2
+ import {expect, fn, spyOn, waitFor, within} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
5
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
6
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
7
  import {Menu} from './Menu'
4
8
  import {Button} from '../Button/Button'
9
+ import {Dialog} from '../Dialog/Dialog'
10
+ import {Icon} from '../Icon/Icon'
5
11
 
6
12
  const meta: Meta = {
7
13
  title: 'Components/Menu',
@@ -65,11 +71,11 @@ export const OpenClose: Story = {
65
71
 
66
72
  await userEvent.click(trigger)
67
73
  await expect(trigger).toHaveAttribute('aria-expanded', 'true')
68
- const items = canvas.getAllByRole('menuitem')
74
+ const items = page().getAllByRole('menuitem')
69
75
  await expect(items.length).toBe(3)
70
76
 
71
77
  await userEvent.keyboard('{Escape}')
72
- await expect(canvas.queryByRole('menu')).not.toBeInTheDocument()
78
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
73
79
  },
74
80
  }
75
81
 
@@ -103,7 +109,7 @@ export const KeyboardNavigation: Story = {
103
109
  play: async ({canvas, userEvent}) => {
104
110
  const trigger = canvas.getByRole('button', {name: /Navigate/})
105
111
  await userEvent.click(trigger)
106
- await expect(canvas.getByRole('menu')).toBeInTheDocument()
112
+ await expect(page().getByRole('menu')).toBeInTheDocument()
107
113
  },
108
114
  }
109
115
 
@@ -137,7 +143,7 @@ export const WithLinks: Story = {
137
143
  play: async ({canvas, userEvent}) => {
138
144
  await userEvent.click(canvas.getByRole('button', {name: /Account/}))
139
145
 
140
- const items = canvas.getAllByRole('menuitem')
146
+ const items = page().getAllByRole('menuitem')
141
147
  await expect(items.length).toBe(3)
142
148
 
143
149
  // Link items should be anchor elements with href
@@ -160,11 +166,11 @@ export const WithSeparator: Story = {
160
166
  ),
161
167
  play: async ({canvas, userEvent}) => {
162
168
  await userEvent.click(canvas.getByRole('button', {name: /History/}))
163
- const menu = canvas.getByRole('menu')
164
- const separator = canvas.getByRole('separator')
169
+ const menu = page().getByRole('menu')
170
+ const separator = page().getByRole('separator')
165
171
  await expect(separator).toHaveAttribute('aria-orientation', 'horizontal')
166
172
  // The separator is not an item: three items, and arrows pass over it.
167
- const items = canvas.getAllByRole('menuitem')
173
+ const items = page().getAllByRole('menuitem')
168
174
  await expect(items.length).toBe(3)
169
175
  await userEvent.keyboard('{ArrowDown}')
170
176
  await userEvent.keyboard('{ArrowDown}')
@@ -196,3 +202,278 @@ export const NestedControlWarns: Story = {
196
202
  )
197
203
  },
198
204
  }
205
+
206
+ // The popup is portalled into the ThemeProvider mount: query the document.
207
+ const page = () => within(document.body)
208
+
209
+ const storyStyles = css.create({
210
+ scroller: {
211
+ height: sizes.panelSm,
212
+ overflow: 'auto',
213
+ borderWidth: 0,
214
+ },
215
+ toolbar: {
216
+ position: 'sticky',
217
+ top: 0,
218
+ overflow: 'auto',
219
+ display: 'flex',
220
+ gap: spacing.sm,
221
+ padding: spacing.sm,
222
+ backgroundColor: colors.bgCard,
223
+ },
224
+ filler: {
225
+ height: sizes.pageMd,
226
+ },
227
+ editor: {
228
+ minHeight: sizes.editorMinH,
229
+ padding: spacing.sm,
230
+ borderStyle: 'solid',
231
+ borderColor: colors.border,
232
+ },
233
+ })
234
+
235
+ // react-strict-dom has no contentEditable prop; a rich-text editor sets it.
236
+ const makeEditable = (el: HTMLElement | null) => el?.setAttribute('contenteditable', 'true')
237
+
238
+ const THIRTY = Array.from({length: 30}, (_, i) => `Action ${i + 1}`)
239
+
240
+ // A sticky, overflow:auto toolbar used to clip the popup; portalled, it is
241
+ // placed in the viewport and scrolls inside its own max height.
242
+ export const InStickyOverflowToolbar: Story = {
243
+ render: () => (
244
+ <html.div style={storyStyles.scroller}>
245
+ <html.div style={storyStyles.toolbar}>
246
+ <Menu.Root>
247
+ <Menu.Trigger>Insert</Menu.Trigger>
248
+ <Menu.Popup>
249
+ {THIRTY.map((label) => (
250
+ <Menu.Item key={label}>{label}</Menu.Item>
251
+ ))}
252
+ </Menu.Popup>
253
+ </Menu.Root>
254
+ </html.div>
255
+ <html.div style={storyStyles.filler} />
256
+ </html.div>
257
+ ),
258
+ play: async ({canvas, userEvent}) => {
259
+ await userEvent.click(canvas.getByRole('button', {name: 'Insert'}))
260
+ const menu = await page().findByRole('menu', {name: 'Insert'})
261
+ const box = menu.getBoundingClientRect()
262
+ await expect(box.top).toBeGreaterThanOrEqual(0)
263
+ await expect(box.left).toBeGreaterThanOrEqual(0)
264
+ await expect(box.bottom).toBeLessThanOrEqual(window.innerHeight)
265
+ await expect(box.right).toBeLessThanOrEqual(window.innerWidth)
266
+ await expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight)
267
+ await expect(page().getAllByRole('menuitem')).toHaveLength(30)
268
+ // The last item scrolls into view when it is highlighted.
269
+ await userEvent.keyboard('{End}')
270
+ const last = page().getByRole('menuitem', {name: 'Action 30'})
271
+ const lastBox = last.getBoundingClientRect()
272
+ await expect(lastBox.bottom).toBeLessThanOrEqual(menu.getBoundingClientRect().bottom + 1)
273
+ },
274
+ }
275
+
276
+ // APG menu button: focus moves to the menu, and the menu's
277
+ // aria-activedescendant names the highlighted item.
278
+ export const FocusAndActiveDescendant: Story = {
279
+ render: () => (
280
+ <Menu.Root>
281
+ <Menu.Trigger>Edit</Menu.Trigger>
282
+ <Menu.Popup>
283
+ <Menu.Item>Cut</Menu.Item>
284
+ <Menu.Item>Copy</Menu.Item>
285
+ <Menu.Item>Paste</Menu.Item>
286
+ </Menu.Popup>
287
+ </Menu.Root>
288
+ ),
289
+ play: async ({canvas, userEvent}) => {
290
+ const trigger = canvas.getByRole('button', {name: 'Edit'})
291
+ await userEvent.click(trigger)
292
+ const menu = await page().findByRole('menu', {name: 'Edit'})
293
+ await waitFor(() => expect(document.activeElement).toBe(menu))
294
+ await userEvent.keyboard('{ArrowDown}')
295
+ const copy = page().getByRole('menuitem', {name: 'Copy'})
296
+ await expect(document.activeElement).toBe(menu)
297
+ await expect(menu).toHaveAttribute('aria-activedescendant', copy.id)
298
+ await userEvent.keyboard('{Escape}')
299
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
300
+ await expect(trigger).toHaveFocus()
301
+ },
302
+ }
303
+
304
+ // One Escape closes the innermost layer only: the Menu, not its Dialog.
305
+ const onMenuOpenChange = fn()
306
+ const onDialogOpenChange = fn()
307
+
308
+ export const EscapeInsideDialog: Story = {
309
+ beforeEach: () => {
310
+ onMenuOpenChange.mockClear()
311
+ onDialogOpenChange.mockClear()
312
+ },
313
+ render: () => (
314
+ <Dialog.Root defaultOpen onOpenChange={onDialogOpenChange}>
315
+ <Dialog.Portal>
316
+ <Dialog.Header>
317
+ <Dialog.Title>Format</Dialog.Title>
318
+ </Dialog.Header>
319
+ <Dialog.Body>
320
+ <Menu.Root onOpenChange={onMenuOpenChange}>
321
+ <Menu.Trigger>Align</Menu.Trigger>
322
+ <Menu.Popup>
323
+ <Menu.Item>Left</Menu.Item>
324
+ <Menu.Item>Center</Menu.Item>
325
+ </Menu.Popup>
326
+ </Menu.Root>
327
+ </Dialog.Body>
328
+ </Dialog.Portal>
329
+ </Dialog.Root>
330
+ ),
331
+ play: async ({userEvent}) => {
332
+ await userEvent.click(page().getByRole('button', {name: 'Align'}))
333
+ await page().findByRole('menu', {name: 'Align'})
334
+ await expect(onMenuOpenChange).toHaveBeenCalledWith(true)
335
+ // The popup stacks above the dialog: its item is what a press hits.
336
+ const center = page().getByRole('menuitem', {name: 'Center'})
337
+ const box = center.getBoundingClientRect()
338
+ await expect(
339
+ document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2),
340
+ ).toBe(center)
341
+ await userEvent.keyboard('{Escape}')
342
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
343
+ // The Escape closes the menu once: onOpenChange(true) on open, then
344
+ // exactly one onOpenChange(false).
345
+ await expect(onMenuOpenChange.mock.calls.filter(([open]) => open === false)).toHaveLength(1)
346
+ await expect(onMenuOpenChange).toHaveBeenLastCalledWith(false)
347
+ await expect(onDialogOpenChange).toHaveBeenCalledTimes(0)
348
+ await expect(page().getByRole('dialog', {name: 'Format'})).toBeInTheDocument()
349
+ },
350
+ }
351
+
352
+ // The keydown handler sits on the menu, never on document: typing in an
353
+ // editor while the menu is open reaches the editor.
354
+ export const TypingElsewhereIsNotIntercepted: Story = {
355
+ render: () => (
356
+ <html.div>
357
+ <Menu.Root>
358
+ <Menu.Trigger>Blocks</Menu.Trigger>
359
+ <Menu.Popup>
360
+ <Menu.Item>Heading</Menu.Item>
361
+ <Menu.Item>Quote</Menu.Item>
362
+ </Menu.Popup>
363
+ </Menu.Root>
364
+ <html.div ref={makeEditable} role="textbox" aria-label="Editor" style={storyStyles.editor} />
365
+ </html.div>
366
+ ),
367
+ play: async ({canvas, userEvent}) => {
368
+ await userEvent.click(canvas.getByRole('button', {name: 'Blocks'}))
369
+ await page().findByRole('menu', {name: 'Blocks'})
370
+ const editor = canvas.getByRole('textbox', {name: 'Editor'})
371
+ editor.focus()
372
+ await userEvent.keyboard('a b{ArrowDown}c')
373
+ await expect(editor).toHaveTextContent('a bc')
374
+ await expect(page().getByRole('menu', {name: 'Blocks'})).not.toHaveAttribute(
375
+ 'aria-activedescendant',
376
+ page().getByRole('menuitem', {name: 'Quote'}).id,
377
+ )
378
+ },
379
+ }
380
+
381
+ // A toolbar icon trigger: ghost variant, named by aria-label, ref forwarded.
382
+ export const GhostIconTrigger: Story = {
383
+ render: () => (
384
+ <Menu.Root>
385
+ <Menu.Trigger variant="ghost" aria-label="More actions">
386
+ <Icon name="menu" size="sm" />
387
+ </Menu.Trigger>
388
+ <Menu.Popup align="end">
389
+ <Menu.Item>Rename</Menu.Item>
390
+ <Menu.Item>Delete</Menu.Item>
391
+ </Menu.Popup>
392
+ </Menu.Root>
393
+ ),
394
+ play: async ({canvas, userEvent}) => {
395
+ const trigger = canvas.getByRole('button', {name: 'More actions'})
396
+ await userEvent.click(trigger)
397
+ await expect(await page().findByRole('menu', {name: 'More actions'})).toBeVisible()
398
+ },
399
+ }
400
+
401
+ const onRename = fn()
402
+
403
+ // Enter activates the highlighted item exactly once.
404
+ export const EnterActivatesOnce: Story = {
405
+ beforeEach: () => {
406
+ onRename.mockClear()
407
+ },
408
+ render: () => (
409
+ <Menu.Root>
410
+ <Menu.Trigger>File</Menu.Trigger>
411
+ <Menu.Popup>
412
+ <Menu.Item onClick={onRename}>Rename</Menu.Item>
413
+ <Menu.Item>Delete</Menu.Item>
414
+ </Menu.Popup>
415
+ </Menu.Root>
416
+ ),
417
+ play: async ({canvas, userEvent}) => {
418
+ await userEvent.click(canvas.getByRole('button', {name: 'File'}))
419
+ await page().findByRole('menu', {name: 'File'})
420
+ await userEvent.keyboard('{Enter}')
421
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
422
+ await expect(onRename).toHaveBeenCalledTimes(1)
423
+ },
424
+ }
425
+
426
+ // Tab from a menu portalled out of a Dialog closes it and continues from the
427
+ // trigger, so focus lands on the next control in the dialog. Under the Vitest
428
+ // browser runner a real keyboard (Playwright) presses Tab; the simulated
429
+ // keydown below is what the Storybook UI can run.
430
+ export const TabInsideDialog: Story = {
431
+ render: () => (
432
+ <Dialog.Root defaultOpen>
433
+ <Dialog.Portal>
434
+ <Dialog.Header>
435
+ <Dialog.Title>Insert</Dialog.Title>
436
+ </Dialog.Header>
437
+ <Dialog.Body>
438
+ <Menu.Root>
439
+ <Menu.Trigger>Block</Menu.Trigger>
440
+ <Menu.Popup>
441
+ <Menu.Item>Quote</Menu.Item>
442
+ </Menu.Popup>
443
+ </Menu.Root>
444
+ <Button variant="secondary">After</Button>
445
+ </Dialog.Body>
446
+ </Dialog.Portal>
447
+ </Dialog.Root>
448
+ ),
449
+ play: async ({userEvent}) => {
450
+ const trigger = page().getByRole('button', {name: 'Block'})
451
+ await userEvent.click(trigger)
452
+ const menu = await page().findByRole('menu', {name: 'Block'})
453
+ menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab', bubbles: true}))
454
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
455
+ await expect(trigger).toHaveFocus()
456
+
457
+ const keyboard = await realKeyboard()
458
+ if (!keyboard) return
459
+ await userEvent.click(trigger)
460
+ await page().findByRole('menu', {name: 'Block'})
461
+ await keyboard('{Tab}')
462
+ await waitFor(() => expect(page().queryByRole('menu')).not.toBeInTheDocument())
463
+ await expect(page().getByRole('button', {name: 'After'})).toHaveFocus()
464
+ },
465
+ }
466
+
467
+ /**
468
+ * The Vitest browser runner's keyboard, which drives the browser itself
469
+ * (Playwright) so Tab moves focus the way a person's would. Outside that
470
+ * runner (the Storybook UI) the module throws on import, and there is none.
471
+ */
472
+ async function realKeyboard(): Promise<((keys: string) => Promise<void>) | null> {
473
+ try {
474
+ const {userEvent} = await import('vitest/browser')
475
+ return (keys) => userEvent.keyboard(keys)
476
+ } catch {
477
+ return null
478
+ }
479
+ }
@@ -1,17 +1,37 @@
1
- import {type ReactNode, useRef, useId, useEffect} from 'react'
1
+ import {
2
+ type ReactNode,
3
+ type Ref,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
11
+ import {createPortal} from 'react-dom'
2
12
  import {html} from 'react-strict-dom'
13
+ import {SPACING_PX} from '@duro-app/tokens/keys'
3
14
  import {styles} from './styles.css'
4
15
  import {MenuContext, useMenu} from './MenuContext'
5
16
  import {useMenuRoot} from './useMenuRoot'
6
17
  import {devWarnOnce} from '../../shared/devWarnOnce'
18
+ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
19
+ import {computePopoverPosition} from '../Popover/position'
20
+ import {usePopoverLayer} from '../Popover/PopoverLayerContext'
7
21
 
8
22
  // --- Root ---
9
23
  interface RootProps {
10
24
  children: ReactNode
25
+ /** Controlled open state. */
26
+ open?: boolean
27
+ /** Initial open state (uncontrolled). */
28
+ defaultOpen?: boolean
29
+ /** Called when the menu opens or closes. */
30
+ onOpenChange?: (open: boolean) => void
11
31
  }
12
32
 
13
- function Root({children}: RootProps) {
14
- const {ctx, rootRef} = useMenuRoot()
33
+ function Root({children, open, defaultOpen, onOpenChange}: RootProps) {
34
+ const {ctx, rootRef} = useMenuRoot({open, defaultOpen, onOpenChange})
15
35
 
16
36
  return (
17
37
  <MenuContext.Provider value={ctx}>
@@ -28,35 +48,51 @@ function Root({children}: RootProps) {
28
48
  // Its children are the label (text, an Icon) — never a Button or other control.
29
49
  const NESTED_CONTROL = 'button, a[href], input, select, textarea, [role="button"]'
30
50
 
31
- function Trigger({children}: {children: ReactNode}) {
32
- const {open, toggle, menuId, triggerRef} = useMenu()
33
- const localRef = useRef<HTMLButtonElement>(null)
51
+ export type MenuTriggerVariant = 'default' | 'ghost'
34
52
 
35
- // Sync local ref to context triggerRef
36
- useEffect(() => {
37
- triggerRef.current = localRef.current
38
- })
53
+ interface TriggerProps {
54
+ children: ReactNode
55
+ /** Accessible name — required when the label is an icon only. */
56
+ 'aria-label'?: string
57
+ /** `ghost`: no border or fill until hover, for toolbars. Default: `default`. */
58
+ variant?: MenuTriggerVariant
59
+ ref?: Ref<HTMLButtonElement>
60
+ }
61
+
62
+ function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}: TriggerProps) {
63
+ const {open, toggle, menuId, triggerId, triggerRef} = useMenu()
64
+
65
+ const setRef = useCallback(
66
+ (el: HTMLButtonElement | null) => {
67
+ triggerRef.current = el
68
+ if (typeof ref === 'function') ref(el)
69
+ else if (ref) ref.current = el
70
+ },
71
+ [ref, triggerRef],
72
+ )
39
73
 
40
74
  // TypeScript can't stop a <Button> child; the DOM can tell.
41
75
  useEffect(() => {
42
- if (localRef.current?.querySelector(NESTED_CONTROL)) {
76
+ if (triggerRef.current?.querySelector(NESTED_CONTROL)) {
43
77
  devWarnOnce(
44
78
  'Menu',
45
79
  'trigger-nested-control',
46
80
  'Menu.Trigger is itself the button — give it the label (text, an Icon), not a Button, link or other control. A control inside a <button> is invalid HTML: it breaks hydration and hands assistive tech a button inside a button.',
47
81
  )
48
82
  }
49
- }, [])
83
+ }, [triggerRef])
50
84
 
51
85
  return (
52
86
  <html.button
53
- ref={localRef}
87
+ ref={setRef}
88
+ id={triggerId}
54
89
  type="button"
55
90
  onClick={toggle}
91
+ aria-label={ariaLabel}
56
92
  aria-expanded={open}
57
93
  aria-haspopup="menu"
58
94
  aria-controls={open ? menuId : undefined}
59
- style={styles.trigger}
95
+ style={[styles.trigger, variant === 'ghost' && styles.triggerGhost]}
60
96
  >
61
97
  {children}
62
98
  </html.button>
@@ -69,23 +105,58 @@ interface PopupProps {
69
105
  align?: 'start' | 'end'
70
106
  }
71
107
 
108
+ // Portalled into the ThemeProvider mount and placed against the trigger, so a
109
+ // sticky or overflow-clipped toolbar never cuts it off. Capped at
110
+ // `sizes.listMaxH`; longer menus scroll.
72
111
  function Popup({children, align = 'start'}: PopupProps) {
73
- const {open, close, menuId, highlightedId} = useMenu()
112
+ const {open, menuId, triggerId, highlightedId, triggerRef, popupRef} = useMenu()
113
+ const [coords, setCoords] = useState({top: 0, left: 0})
114
+ const mount = usePortalMount()
115
+ // Inside a Popover, a press on this portalled popup is not an outside press.
116
+ usePopoverLayer(popupRef, {active: open})
117
+
118
+ useLayoutEffect(() => {
119
+ if (!open) return
120
+ const update = () => {
121
+ const popup = popupRef.current
122
+ const rect = triggerRef.current?.getBoundingClientRect()
123
+ if (!popup || !rect) return
124
+ const next = computePopoverPosition({
125
+ anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
126
+ popup: {width: popup.offsetWidth, height: popup.offsetHeight},
127
+ viewport: {width: window.innerWidth, height: window.innerHeight},
128
+ side: 'bottom',
129
+ align,
130
+ offset: SPACING_PX.xs,
131
+ })
132
+ setCoords((prev) =>
133
+ prev.top === next.top && prev.left === next.left ? prev : {top: next.top, left: next.left},
134
+ )
135
+ }
136
+ update()
137
+ window.addEventListener('resize', update)
138
+ window.addEventListener('scroll', update, true)
139
+ return () => {
140
+ window.removeEventListener('resize', update)
141
+ window.removeEventListener('scroll', update, true)
142
+ }
143
+ }, [open, align, popupRef, triggerRef])
74
144
 
75
- if (!open) return null
145
+ if (!open || typeof document === 'undefined') return null
76
146
 
77
- return (
78
- <>
79
- <html.div style={styles.backdrop} onClick={close} />
80
- <html.div
81
- id={menuId}
82
- role="menu"
83
- aria-activedescendant={highlightedId ?? undefined}
84
- style={[styles.popup, align === 'end' && styles.popupEnd]}
85
- >
86
- {children}
87
- </html.div>
88
- </>
147
+ return createPortal(
148
+ <html.div
149
+ ref={popupRef}
150
+ id={menuId}
151
+ role="menu"
152
+ tabIndex={-1}
153
+ aria-labelledby={triggerId}
154
+ aria-activedescendant={highlightedId ?? undefined}
155
+ style={[styles.popup, styles.popupPosition(coords.top, coords.left)]}
156
+ >
157
+ {children}
158
+ </html.div>,
159
+ mount ?? document.body,
89
160
  )
90
161
  }
91
162
 
@@ -149,7 +220,7 @@ function LinkItem({href, children}: LinkItemProps) {
149
220
  ref={ref}
150
221
  id={id}
151
222
  href={href}
152
- onClick={close}
223
+ onClick={() => close()}
153
224
  role="menuitem"
154
225
  onPointerEnter={() => setHighlightedId(id)}
155
226
  style={[styles.item, styles.linkItem, isHighlighted && styles.itemHighlighted]}
@@ -3,12 +3,15 @@ import {createContext, useContext} from 'react'
3
3
  export interface MenuContextValue {
4
4
  open: boolean
5
5
  toggle: () => void
6
+ /** Close the menu and put focus back on the trigger. */
6
7
  close: () => void
7
8
  menuId: string
9
+ triggerId: string
8
10
  highlightedId: string | null
9
11
  setHighlightedId: (id: string | null) => void
10
12
  registerItem: (id: string, element: HTMLElement) => () => void
11
13
  triggerRef: React.RefObject<HTMLButtonElement | null>
14
+ popupRef: React.RefObject<HTMLDivElement | null>
12
15
  }
13
16
 
14
17
  export const MenuContext = createContext<MenuContextValue | null>(null)
@@ -35,19 +35,21 @@ export const styles = css.create({
35
35
  transitionProperty: 'background-color, border-color',
36
36
  transitionDuration: duration.fast,
37
37
  },
38
- backdrop: {
39
- position: 'fixed',
40
- top: 0,
41
- left: 0,
42
- right: 0,
43
- bottom: 0,
44
- zIndex: 49,
38
+ // Toolbar trigger: no border or fill until hover, at least icon-button size.
39
+ triggerGhost: {
40
+ justifyContent: 'center',
41
+ minWidth: sizes.iconButton,
42
+ minHeight: sizes.iconButton,
43
+ paddingTop: spacing.xs,
44
+ paddingBottom: spacing.xs,
45
+ paddingLeft: spacing.xs,
46
+ paddingRight: spacing.xs,
47
+ borderColor: 'transparent',
45
48
  },
46
49
  popup: {
47
- position: 'absolute',
48
- top: '100%',
50
+ position: 'fixed',
51
+ top: 0,
49
52
  left: 0,
50
- marginTop: spacing.xs,
51
53
  backgroundColor: colors.bgCard,
52
54
  borderWidth: borders.hairline,
53
55
  borderStyle: 'solid',
@@ -57,12 +59,20 @@ export const styles = css.create({
57
59
  paddingTop: spacing.xs,
58
60
  paddingBottom: spacing.xs,
59
61
  minWidth: sizes.popupMinW,
60
- zIndex: 50,
61
- },
62
- popupEnd: {
63
- left: 'auto',
64
- right: 0,
62
+ maxHeight: sizes.listMaxH,
63
+ overflowY: 'auto',
64
+ // Above Dialog/Drawer (1000/1001), like Select and Combobox: they portal
65
+ // into the same mount, so a menu opened inside a dialog must out-rank it.
66
+ zIndex: 1050,
67
+ // The portal layer is pointer-events: none so clicks fall through it;
68
+ // the popup takes them back.
69
+ pointerEvents: 'auto',
70
+ outlineStyle: 'none',
65
71
  },
72
+ popupPosition: (top: number, left: number) => ({
73
+ top,
74
+ left,
75
+ }),
66
76
  item: {
67
77
  display: 'flex',
68
78
  alignItems: 'center',
@@ -79,8 +89,14 @@ export const styles = css.create({
79
89
  transitionProperty: 'background-color',
80
90
  transitionDuration: duration.fast,
81
91
  },
92
+ // The fill is the highlight; the transparent outline is what forced-colors
93
+ // mode paints, where fills are dropped.
82
94
  itemHighlighted: {
83
95
  backgroundColor: colors.bgCardHover,
96
+ outlineStyle: 'solid',
97
+ outlineWidth: borders.focusRing,
98
+ outlineColor: 'transparent',
99
+ outlineOffset: `calc(-1 * ${borders.focusRing})`,
84
100
  },
85
101
  separator: {
86
102
  height: 0,