@duro-app/ui 4.2.0 → 4.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.
Files changed (45) hide show
  1. package/dist/components/ActionBar/ActionBar.d.ts +7 -0
  2. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  3. package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
  4. package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
  5. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  6. package/dist/components/ActionBar/styles.css.d.ts +25 -2
  7. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  8. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  9. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
  10. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  11. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  12. package/dist/components/Popover/Popover.d.ts +41 -0
  13. package/dist/components/Popover/Popover.d.ts.map +1 -0
  14. package/dist/components/Popover/Popover.meta.d.ts +3 -0
  15. package/dist/components/Popover/Popover.meta.d.ts.map +1 -0
  16. package/dist/components/Popover/Popover.stories.d.ts +11 -0
  17. package/dist/components/Popover/Popover.stories.d.ts.map +1 -0
  18. package/dist/components/Popover/outside.d.ts +2 -0
  19. package/dist/components/Popover/outside.d.ts.map +1 -0
  20. package/dist/components/Popover/position.d.ts +27 -0
  21. package/dist/components/Popover/position.d.ts.map +1 -0
  22. package/dist/components/Popover/styles.css.d.ts +61 -0
  23. package/dist/components/Popover/styles.css.d.ts.map +1 -0
  24. package/dist/index.css +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2021 -1720
  28. package/dist/index.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/components/ActionBar/ActionBar.meta.ts +7 -0
  31. package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
  32. package/src/components/ActionBar/ActionBar.tsx +12 -1
  33. package/src/components/ActionBar/styles.css.ts +34 -2
  34. package/src/components/DetailPanel/DetailPanel.meta.ts +12 -0
  35. package/src/components/Dialog/Dialog.meta.ts +6 -0
  36. package/src/components/Drawer/Drawer.meta.ts +6 -0
  37. package/src/components/Menu/Menu.meta.ts +5 -1
  38. package/src/components/Popover/Popover.meta.ts +69 -0
  39. package/src/components/Popover/Popover.stories.tsx +250 -0
  40. package/src/components/Popover/Popover.tsx +293 -0
  41. package/src/components/Popover/outside.ts +9 -0
  42. package/src/components/Popover/position.ts +48 -0
  43. package/src/components/Popover/styles.css.ts +75 -0
  44. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  45. package/src/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "@tanstack/react-virtual": "^3.14.6",
69
- "@duro-app/tokens": "^4.2.0"
69
+ "@duro-app/tokens": "^4.4.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@babel/preset-typescript": "^7.28.0",
@@ -7,6 +7,7 @@ export const meta: ComponentMeta = {
7
7
  'Bulk actions on table selections (delete, revoke, export)',
8
8
  'Multi-select workflows where actions apply to all selected items',
9
9
  'Selection-scoped verbs on a canvas/editor surface (set `bottomOffset` to clear any bottom chrome such as zoom controls)',
10
+ 'A docked side panel (DetailPanel) is open: pass its width as `insetInlineEnd` so the bar centres beside it, not under it (follows the writing direction: the left edge in right-to-left)',
10
11
  ],
11
12
  whenNotToUse: [
12
13
  'Single-item actions that have an inline home — use inline buttons or a Menu',
@@ -18,6 +19,12 @@ export const meta: ComponentMeta = {
18
19
  kind: 'composition',
19
20
  relationship: 'Common pattern: Table checkboxes + ActionBar for bulk ops',
20
21
  },
22
+ {
23
+ component: 'DetailPanel',
24
+ kind: 'composition',
25
+ relationship:
26
+ "With a DetailPanel docked at the end edge, pass the panel's width as insetInlineEnd",
27
+ },
21
28
  ],
22
29
  example: `<ActionBar selectedItemCount={selected.size} onClearSelection={() => setSelected(new Set())}>
23
30
  <Button variant="danger" size="small" onClick={handleDelete}>Delete</Button>
@@ -1,6 +1,6 @@
1
1
  import {useState} from 'react'
2
2
  import type {Meta, StoryObj} from '@storybook/react'
3
- import {expect, fn, userEvent, within} from 'storybook/test'
3
+ import {expect, fn, within} from 'storybook/test'
4
4
  import {css, html} from 'react-strict-dom'
5
5
  import {ActionBar} from './ActionBar'
6
6
  import {Button} from '../Button/Button'
@@ -155,8 +155,7 @@ export const Default: Story = {
155
155
  onClearSelection={args.onClearSelection as () => void}
156
156
  />
157
157
  ),
158
- play: async ({canvasElement}) => {
159
- const canvas = within(canvasElement)
158
+ play: async () => {
160
159
  // The ActionBar is portalled to document.body, so query from there
161
160
  const toolbar = document.querySelector('[role="toolbar"]')
162
161
  await expect(toolbar).toBeInTheDocument()
@@ -310,3 +309,96 @@ export const AllVariants: Story = {
310
309
  </html.div>
311
310
  ),
312
311
  }
312
+
313
+ // A panel docked at the window's end edge, standing in for an open DetailPanel.
314
+ const docked = css.create({
315
+ panel: {
316
+ position: 'fixed',
317
+ top: 0,
318
+ bottom: 0,
319
+ insetInlineEnd: 0,
320
+ width: 360,
321
+ borderInlineStartWidth: 1,
322
+ borderInlineStartStyle: 'solid',
323
+ },
324
+ })
325
+
326
+ // Real buttons: they don't shrink below their labels, so together they are
327
+ // wider than the free area beside the panel.
328
+ const WIDE_ACTIONS = [
329
+ 'Duplicate',
330
+ 'Move to section',
331
+ 'Copy style',
332
+ 'Paste style',
333
+ 'Group',
334
+ 'Ungroup',
335
+ 'Bring forward',
336
+ 'Send backward',
337
+ 'Delete',
338
+ ]
339
+
340
+ const barRect = () =>
341
+ (document.querySelector('[role="toolbar"]') as Element).getBoundingClientRect()
342
+ const panelRect = () =>
343
+ (document.querySelector('[data-testid="docked-panel"]') as Element).getBoundingClientRect()
344
+
345
+ export const NoInset: Story = {
346
+ render: () => (
347
+ <ActionBar selectedItemCount={2} onClearSelection={() => {}}>
348
+ <Button variant="secondary" size="small">
349
+ Delete
350
+ </Button>
351
+ </ActionBar>
352
+ ),
353
+ play: async () => {
354
+ const bar = barRect()
355
+ const centre = document.documentElement.clientWidth / 2
356
+ // Today's placement: centred on the window.
357
+ await expect(Math.abs(bar.left + bar.width / 2 - centre)).toBeLessThanOrEqual(1)
358
+ },
359
+ }
360
+
361
+ export const DockedPanel: Story = {
362
+ render: () => (
363
+ <>
364
+ <html.div style={docked.panel} data-testid="docked-panel" />
365
+ <ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
366
+ <Button variant="secondary" size="small">
367
+ Delete
368
+ </Button>
369
+ </ActionBar>
370
+ </>
371
+ ),
372
+ play: async () => {
373
+ const bar = barRect()
374
+ const panel = panelRect()
375
+ // Clear of the panel, and centred in what is left of the window.
376
+ await expect(bar.right).toBeLessThanOrEqual(panel.left - 24)
377
+ await expect(Math.abs(bar.left + bar.width / 2 - panel.left / 2)).toBeLessThanOrEqual(1)
378
+ },
379
+ }
380
+
381
+ export const DockedPanelWideBar: Story = {
382
+ render: () => (
383
+ <>
384
+ <html.div style={docked.panel} data-testid="docked-panel" />
385
+ <ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
386
+ {WIDE_ACTIONS.map((a) => (
387
+ <Button key={a} variant="secondary" size="small">
388
+ {a}
389
+ </Button>
390
+ ))}
391
+ </ActionBar>
392
+ </>
393
+ ),
394
+ play: async () => {
395
+ // A bar wider than the free area still keeps 24px clear of the panel,
396
+ // and so does what it holds: the actions scroll, they don't spill.
397
+ const limit = panelRect().left - 24
398
+ await expect(barRect().right).toBeLessThanOrEqual(limit)
399
+ const last = await within(document.body).findByRole('button', {name: 'Delete'})
400
+ const actions = last.parentElement!
401
+ await expect(actions.getBoundingClientRect().right).toBeLessThanOrEqual(limit)
402
+ await expect(actions.scrollWidth).toBeGreaterThan(actions.clientWidth)
403
+ },
404
+ }
@@ -22,6 +22,13 @@ export interface ActionBarProps {
22
22
  * height. Defaults to the spacing.lg token.
23
23
  */
24
24
  bottomOffset?: SpacingToken | number
25
+ /**
26
+ * Width in px of chrome docked at the window's inline-end edge (right in
27
+ * left-to-right, left in right-to-left), such as an open DetailPanel: the
28
+ * bar centres in what is left and keeps 24px clear of it. 0 or omitted
29
+ * centres on the window.
30
+ */
31
+ insetInlineEnd?: number
25
32
  /** Action buttons to display. */
26
33
  children: ReactNode
27
34
  }
@@ -33,6 +40,7 @@ function ActionBarContent({
33
40
  onClearSelection,
34
41
  dismissible = true,
35
42
  bottomOffset,
43
+ insetInlineEnd = 0,
36
44
  children,
37
45
  }: ActionBarProps) {
38
46
  const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
@@ -44,6 +52,7 @@ function ActionBarContent({
44
52
  aria-label={label}
45
53
  style={[
46
54
  styles.overlay,
55
+ insetInlineEnd > 0 ? styles.inset(insetInlineEnd) : styles.centered,
47
56
  bottomOffset != null &&
48
57
  styles.overlayOffset(
49
58
  typeof bottomOffset === 'number' ? bottomOffset : SPACING_PX[bottomOffset],
@@ -55,7 +64,9 @@ function ActionBarContent({
55
64
  {label}
56
65
  </html.span>
57
66
  <html.div style={[styles.separator, isEmphasized && styles.separatorEmphasized]} />
58
- <html.div style={styles.actions}>{children}</html.div>
67
+ <html.div style={[styles.actions, insetInlineEnd > 0 && styles.actionsScroll]}>
68
+ {children}
69
+ </html.div>
59
70
  {dismissible && (
60
71
  <html.button
61
72
  type="button"
@@ -9,8 +9,6 @@ export const styles = css.create({
9
9
  overlay: {
10
10
  position: 'fixed',
11
11
  bottom: spacing.lg,
12
- left: '50%',
13
- transform: 'translateX(-50%)',
14
12
  display: 'flex',
15
13
  flexDirection: 'row',
16
14
  alignItems: 'center',
@@ -26,8 +24,25 @@ export const styles = css.create({
26
24
  borderRadius: radii.md,
27
25
  boxShadow: shadows.lg,
28
26
  zIndex: 50,
27
+ },
28
+ // Centred on the window: the default, with nothing docked at its edge.
29
+ centered: {
30
+ left: '50%',
31
+ transform: 'translateX(-50%)',
29
32
  maxWidth: 'calc(100vw - 48px)',
30
33
  },
34
+ // Centred in the window minus chrome docked at the inline-end edge (a
35
+ // DetailPanel). Both insets and auto margins centre a fixed box of
36
+ // fit-content width in what is left, measured on the containing block
37
+ // (the window without a classic scrollbar), not on 100vw.
38
+ inset: (end: number) => ({
39
+ insetInlineStart: 0,
40
+ insetInlineEnd: end,
41
+ marginInlineStart: 'auto',
42
+ marginInlineEnd: 'auto',
43
+ width: 'fit-content',
44
+ maxWidth: `calc(100% - ${end}px - 48px)`,
45
+ }),
31
46
  overlayOffset: (bottom: number) => ({
32
47
  bottom,
33
48
  }),
@@ -51,6 +66,23 @@ export const styles = css.create({
51
66
  alignItems: 'center',
52
67
  gap: spacing.sm,
53
68
  },
69
+ // Beside a docked panel the room is narrower: more actions than fit scroll
70
+ // instead of spilling past the bar. A scroll box clips vertically too, so
71
+ // the row gets room on every side for the buttons' focus rings, taken back
72
+ // by its margin so the bar keeps its size.
73
+ actionsScroll: {
74
+ minWidth: 0,
75
+ overflowX: 'auto',
76
+ overflowY: 'hidden',
77
+ paddingTop: spacing.xs,
78
+ paddingBottom: spacing.xs,
79
+ paddingLeft: spacing.xs,
80
+ paddingRight: spacing.xs,
81
+ marginTop: `calc(-1 * ${spacing.xs})`,
82
+ marginBottom: `calc(-1 * ${spacing.xs})`,
83
+ marginLeft: `calc(-1 * ${spacing.xs})`,
84
+ marginRight: `calc(-1 * ${spacing.xs})`,
85
+ },
54
86
  closeButton: {
55
87
  display: 'inline-flex',
56
88
  alignItems: 'center',
@@ -18,11 +18,23 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Title', 'Footer', 'Close'],
19
19
  },
20
20
  relatedTo: [
21
+ {
22
+ component: 'ActionBar',
23
+ kind: 'composition',
24
+ relationship:
25
+ "An ActionBar shown while the panel is open takes the panel's width as insetInlineEnd",
26
+ },
21
27
  {
22
28
  component: 'Drawer',
23
29
  kind: 'contrast',
24
30
  relationship: 'Drawer is modal with backdrop; DetailPanel is non-modal and in-flow',
25
31
  },
32
+ {
33
+ component: 'Popover',
34
+ kind: 'contrast',
35
+ relationship:
36
+ 'Popover is a small anchored overlay for a few controls; DetailPanel is an in-flow panel for long editing',
37
+ },
26
38
  {
27
39
  component: 'Table',
28
40
  kind: 'composition',
@@ -30,6 +30,12 @@ export const meta: ComponentMeta = {
30
30
  kind: 'contrast',
31
31
  relationship: 'DetailPanel is non-modal and in-flow; Dialog is modal with backdrop',
32
32
  },
33
+ {
34
+ component: 'Popover',
35
+ kind: 'contrast',
36
+ relationship:
37
+ 'Popover is a small non-modal card anchored to an element; Dialog is a modal decision',
38
+ },
33
39
  ],
34
40
  example: `<Dialog.Root open={open} onOpenChange={setOpen}>
35
41
  <Dialog.Trigger>
@@ -30,6 +30,12 @@ export const meta: ComponentMeta = {
30
30
  kind: 'contrast',
31
31
  relationship: 'DetailPanel is non-modal and in-flow; Drawer is modal with backdrop',
32
32
  },
33
+ {
34
+ component: 'Popover',
35
+ kind: 'contrast',
36
+ relationship:
37
+ 'Popover is a small card anchored to an element; Drawer takes its place at phone width',
38
+ },
33
39
  ],
34
40
  example: `<Drawer.Root anchor="right" open={open} onOpenChange={setOpen}>
35
41
  <Drawer.Trigger>
@@ -8,7 +8,11 @@ export const meta: ComponentMeta = {
8
8
  'Navigation links in a dropdown',
9
9
  'Overflow menu for toolbar actions',
10
10
  ],
11
- whenNotToUse: ['Picking a value — use Select', 'Primary navigation — use SideNav or Tabs'],
11
+ whenNotToUse: [
12
+ 'Picking a value — use Select',
13
+ 'Primary navigation — use SideNav or Tabs',
14
+ 'A small form tied to the trigger — use Popover',
15
+ ],
12
16
  anatomy: {
13
17
  required: ['Root', 'Trigger', 'Popup', 'Item'],
14
18
  optional: ['LinkItem', 'Separator'],
@@ -0,0 +1,69 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves.',
6
+ whenToUse: [
7
+ 'A small form or controls tied to one element that stay open while you work in them',
8
+ 'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
9
+ ],
10
+ whenNotToUse: [
11
+ 'A list of actions — use Menu',
12
+ 'Hover-only text — use Tooltip',
13
+ 'A blocking decision — use Dialog or ConfirmDialog',
14
+ 'A long editor, or one that follows the selection — use DetailPanel',
15
+ 'Phone width — use Drawer',
16
+ ],
17
+ anatomy: {
18
+ required: ['Root', 'Popup'],
19
+ optional: ['Trigger', 'Close'],
20
+ },
21
+ relatedTo: [
22
+ {
23
+ component: 'Menu',
24
+ kind: 'contrast',
25
+ relationship: 'Menu opens a list of actions; Popover holds small forms and controls',
26
+ },
27
+ {
28
+ component: 'Tooltip',
29
+ kind: 'contrast',
30
+ relationship:
31
+ 'Tooltip shows hover-only text; Popover holds interactive content and stays open',
32
+ },
33
+ {
34
+ component: 'Dialog',
35
+ kind: 'contrast',
36
+ relationship: 'Dialog is modal and blocks the page; Popover is non-modal and anchored',
37
+ },
38
+ {
39
+ component: 'DetailPanel',
40
+ kind: 'contrast',
41
+ relationship:
42
+ 'DetailPanel is an in-flow panel for long editing; Popover is a small anchored overlay',
43
+ },
44
+ {
45
+ component: 'Drawer',
46
+ kind: 'contrast',
47
+ relationship:
48
+ 'Drawer is the modal edge panel for phone width; Popover is anchored and non-modal',
49
+ },
50
+ {
51
+ component: 'Field',
52
+ kind: 'composition',
53
+ relationship: 'Place Field.Root with its control inside Popover.Popup for a labelled field',
54
+ },
55
+ ],
56
+ example: `const ref = useRef<PopoverHandle>(null)
57
+ const anchor = () => new DOMRect(point.x, point.y, 0, 0)
58
+
59
+ <Popover.Root ref={ref} open={open} onOpenChange={setOpen} anchor={anchor} side="bottom" align="start">
60
+ <Popover.Popup label="Edit label">
61
+ <Field.Root>
62
+ <Field.Label>Label</Field.Label>
63
+ <Input value={label} onChange={(e) => setLabel(e.target.value)} />
64
+ </Field.Root>
65
+ <Popover.Close>Done</Popover.Close>
66
+ </Popover.Popup>
67
+ </Popover.Root>
68
+ // after the anchor moves: ref.current?.reposition()`,
69
+ }
@@ -0,0 +1,250 @@
1
+ import {useRef, useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, fn, waitFor, within} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
7
+ import {Popover, type PopoverHandle} from './Popover'
8
+ import {Button} from '../Button/Button'
9
+ import {Field} from '../Field/Field'
10
+ import {Input} from '../Input/Input'
11
+ import {Select} from '../Select/Select'
12
+ import {Stack} from '../Stack/Stack'
13
+
14
+ const meta: Meta = {
15
+ title: 'Components/Popover',
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj
20
+
21
+ // The popup is portalled out of the story's canvas, so query the document.
22
+ const page = () => within(document.body)
23
+
24
+ const styles = css.create({
25
+ stage: {
26
+ position: 'relative',
27
+ height: 320,
28
+ borderWidth: 1,
29
+ borderStyle: 'dashed',
30
+ borderColor: colors.border,
31
+ padding: spacing.md,
32
+ },
33
+ spot: {
34
+ position: 'absolute',
35
+ top: 120,
36
+ left: 160,
37
+ width: 120,
38
+ padding: spacing.sm,
39
+ borderWidth: 1,
40
+ borderStyle: 'solid',
41
+ borderColor: colors.accent,
42
+ },
43
+ shifted: {
44
+ transform: 'translateX(120px)',
45
+ },
46
+ })
47
+
48
+ export const Default: Story = {
49
+ render: () => (
50
+ <Popover.Root>
51
+ <Popover.Trigger>Rename</Popover.Trigger>
52
+ <Popover.Popup label="Rename">
53
+ <Stack gap="sm">
54
+ <Field.Root>
55
+ <Field.Label>Label</Field.Label>
56
+ <Input defaultValue="Get started" />
57
+ </Field.Root>
58
+ <Popover.Close>Done</Popover.Close>
59
+ </Stack>
60
+ </Popover.Popup>
61
+ </Popover.Root>
62
+ ),
63
+ play: async ({canvas, userEvent}) => {
64
+ const trigger = canvas.getByRole('button', {name: 'Rename'})
65
+ await userEvent.click(trigger)
66
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
67
+ const dialog = page().getByRole('dialog', {name: 'Rename'})
68
+ await expect(dialog).toBeVisible()
69
+ // Focus moves to the first field.
70
+ await waitFor(() => expect(page().getByRole('textbox', {name: 'Label'})).toHaveFocus())
71
+
72
+ await userEvent.keyboard('{Escape}')
73
+ await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
74
+ // ...and comes back to the trigger.
75
+ await waitFor(() => expect(trigger).toHaveFocus())
76
+ },
77
+ }
78
+
79
+ export const CloseButton: Story = {
80
+ render: Default.render,
81
+ play: async ({canvas, userEvent}) => {
82
+ const trigger = canvas.getByRole('button', {name: 'Rename'})
83
+ await userEvent.click(trigger)
84
+ await userEvent.click(page().getByRole('button', {name: 'Done'}))
85
+ await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
86
+ await waitFor(() => expect(trigger).toHaveFocus())
87
+ },
88
+ }
89
+
90
+ function WithIgnored({onOpenChange}: {onOpenChange: (open: boolean) => void}) {
91
+ const ignored = useRef<HTMLButtonElement | null>(null)
92
+ return (
93
+ <Stack gap="md">
94
+ <Popover.Root onOpenChange={onOpenChange} ignore={[() => ignored.current]}>
95
+ <Popover.Trigger>Open</Popover.Trigger>
96
+ <Popover.Popup label="Item">
97
+ <Popover.Close>Close</Popover.Close>
98
+ </Popover.Popup>
99
+ </Popover.Root>
100
+ <html.button ref={ignored} type="button">
101
+ Grip (ignored)
102
+ </html.button>
103
+ <html.button type="button">Elsewhere</html.button>
104
+ </Stack>
105
+ )
106
+ }
107
+
108
+ export const OutsidePress: Story = {
109
+ render: () => <WithIgnored onOpenChange={fn()} />,
110
+ play: async ({canvas, userEvent}) => {
111
+ await userEvent.click(canvas.getByRole('button', {name: 'Open'}))
112
+ await expect(page().getByRole('dialog', {name: 'Item'})).toBeVisible()
113
+
114
+ // A press on an ignored element keeps it open.
115
+ await userEvent.click(canvas.getByRole('button', {name: 'Grip (ignored)'}))
116
+ await expect(page().getByRole('dialog', {name: 'Item'})).toBeVisible()
117
+
118
+ // A press anywhere else closes it.
119
+ await userEvent.click(canvas.getByRole('button', {name: 'Elsewhere'}))
120
+ await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
121
+ },
122
+ }
123
+
124
+ export const NestedSelectOwnsEscape: Story = {
125
+ render: () => (
126
+ <Popover.Root>
127
+ <Popover.Trigger>Style</Popover.Trigger>
128
+ <Popover.Popup label="Style">
129
+ <Select.Root defaultValue="primary">
130
+ <Select.Trigger aria-label="Variant">
131
+ <Select.Value />
132
+ <Select.Icon />
133
+ </Select.Trigger>
134
+ <Select.Popup>
135
+ <Select.Item value="primary">
136
+ <Select.ItemText>Primary</Select.ItemText>
137
+ </Select.Item>
138
+ <Select.Item value="secondary">
139
+ <Select.ItemText>Secondary</Select.ItemText>
140
+ </Select.Item>
141
+ </Select.Popup>
142
+ </Select.Root>
143
+ </Popover.Popup>
144
+ </Popover.Root>
145
+ ),
146
+ play: async ({canvas, userEvent}) => {
147
+ await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
148
+ const variant = page().getByRole('combobox', {name: 'Variant'})
149
+ await userEvent.click(variant)
150
+ await expect(variant).toHaveAttribute('aria-expanded', 'true')
151
+
152
+ // The first Esc closes only the Select...
153
+ await userEvent.keyboard('{Escape}')
154
+ await expect(variant).toHaveAttribute('aria-expanded', 'false')
155
+ await expect(page().getByRole('dialog', {name: 'Style'})).toBeVisible()
156
+
157
+ // ...the second closes the popover.
158
+ await userEvent.keyboard('{Escape}')
159
+ await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
160
+ },
161
+ }
162
+
163
+ function VirtualAnchorDemo() {
164
+ const spot = useRef<HTMLDivElement | null>(null)
165
+ const handle = useRef<PopoverHandle | null>(null)
166
+ const [open, setOpen] = useState(false)
167
+ const [shifted, setShifted] = useState(false)
168
+ return (
169
+ <Stack gap="sm">
170
+ <Button onClick={() => setOpen(true)}>Edit the spot</Button>
171
+ <html.div style={styles.stage}>
172
+ <html.div ref={spot} style={[styles.spot, shifted && styles.shifted]}>
173
+ Spot
174
+ </html.div>
175
+ </html.div>
176
+ <Popover.Root
177
+ ref={handle}
178
+ open={open}
179
+ onOpenChange={setOpen}
180
+ anchor={() => spot.current!.getBoundingClientRect()}
181
+ >
182
+ <Popover.Popup label="Spot">
183
+ <Stack gap="sm">
184
+ <Button
185
+ variant="secondary"
186
+ onClick={() => {
187
+ setShifted((s) => !s)
188
+ // The anchor moved without a scroll or resize: re-measure.
189
+ requestAnimationFrame(() => handle.current?.reposition())
190
+ }}
191
+ >
192
+ Move the spot
193
+ </Button>
194
+ <Popover.Close>Done</Popover.Close>
195
+ </Stack>
196
+ </Popover.Popup>
197
+ </Popover.Root>
198
+ </Stack>
199
+ )
200
+ }
201
+
202
+ export const VirtualAnchor: Story = {
203
+ render: () => <VirtualAnchorDemo />,
204
+ play: async ({canvas, userEvent}) => {
205
+ await userEvent.click(canvas.getByRole('button', {name: 'Edit the spot'}))
206
+ const dialog = page().getByRole('dialog', {name: 'Spot'})
207
+ const spot = canvas.getByText('Spot')
208
+ await waitFor(() =>
209
+ expect(dialog.getBoundingClientRect().left).toBeCloseTo(spot.getBoundingClientRect().left, 0),
210
+ )
211
+ // Below the spot (side bottom).
212
+ await expect(dialog.getBoundingClientRect().top).toBeGreaterThanOrEqual(
213
+ spot.getBoundingClientRect().bottom,
214
+ )
215
+
216
+ const before = spot.getBoundingClientRect().left
217
+ await userEvent.click(page().getByRole('button', {name: 'Move the spot'}))
218
+ await waitFor(() => expect(spot.getBoundingClientRect().left).toBeGreaterThan(before))
219
+ await waitFor(() =>
220
+ expect(dialog.getBoundingClientRect().left).toBeCloseTo(spot.getBoundingClientRect().left, 0),
221
+ )
222
+ },
223
+ }
224
+
225
+ function DisclosureDemo() {
226
+ const [expanded, setExpanded] = useState(true)
227
+ return (
228
+ <Popover.Root>
229
+ <Popover.Trigger>Details</Popover.Trigger>
230
+ <Popover.Popup label="Details">
231
+ <html.button type="button" aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}>
232
+ More
233
+ </html.button>
234
+ </Popover.Popup>
235
+ </Popover.Root>
236
+ )
237
+ }
238
+
239
+ export const DisclosureDoesNotOwnEscape: Story = {
240
+ render: () => <DisclosureDemo />,
241
+ play: async ({canvas, userEvent}) => {
242
+ await userEvent.click(canvas.getByRole('button', {name: 'Details'}))
243
+ const more = page().getByRole('button', {name: 'More'})
244
+ await waitFor(() => expect(more).toHaveFocus())
245
+ await expect(more).toHaveAttribute('aria-expanded', 'true')
246
+ // An expanded disclosure opens no popup of its own: Esc closes the popover.
247
+ await userEvent.keyboard('{Escape}')
248
+ await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
249
+ },
250
+ }