@estiva-app/ui 0.9.0 → 0.10.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 (151) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/tailwind-preset.js +7 -0
  150. package/src/Menu.fit.test.ts +0 -90
  151. package/src/Select.fit.test.ts +0 -101
package/src/Button.tsx CHANGED
@@ -1,7 +1,7 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from 'react'
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { cn } from './cn'
4
- import { WithTooltip } from './Tooltip'
4
+ import { TooltipTrigger } from './Tooltip'
5
5
 
6
6
  /**
7
7
  * Peek's Button (2026-08-28), verbatim, plus what Ship added and Peek should
@@ -26,7 +26,17 @@ import { WithTooltip } from './Tooltip'
26
26
  export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive'
27
27
  export type ButtonSize = 'default' | 'small'
28
28
 
29
- export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
29
+ /**
30
+ * `ComponentPropsWithRef`, not `ButtonHTMLAttributes`, so a `ref` reaches the
31
+ * element. React 19 passes `ref` to a function component as an ordinary prop,
32
+ * so nothing here forwards it by hand — it rides in on the spread below. What
33
+ * was missing was only the *type* saying so, which is enough to stop a caller.
34
+ *
35
+ * It matters because a Base UI part composes through `render`: at stage 4 a
36
+ * `Menu.Trigger` or a `Dialog.Close` **is** this Button rather than wrapping
37
+ * one, and a trigger that cannot be measured or focused is not a trigger.
38
+ */
39
+ export interface ButtonProps extends ComponentPropsWithRef<'button'> {
30
40
  variant?: ButtonVariant
31
41
  size?: ButtonSize
32
42
  /** 16px, stroke 1.5 on the default size; 14px on small. */
@@ -66,7 +76,13 @@ export function Button({
66
76
  !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
67
77
  !state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
68
78
  !state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
69
- state.disabled && 'bg-bg-disabled text-text-disabled pointer-events-none',
79
+ state.disabled && 'bg-bg-disabled text-text-disabled',
80
+ // `pointer-events-none` only where the button is truly out of reach.
81
+ // With a `disabledReason` the button IS the tooltip's trigger, and a
82
+ // trigger the pointer cannot land on never opens one — Base UI already
83
+ // swallows the click (`focusableWhenDisabled` gives `aria-disabled`
84
+ // and a prevented `onClick`), so nothing else needs it.
85
+ state.disabled && !disabledReason && 'pointer-events-none',
70
86
  state.disabled && variant === 'outlined' && 'border border-border-default',
71
87
  className,
72
88
  )
@@ -77,5 +93,5 @@ export function Button({
77
93
  {children}
78
94
  </BaseButton>
79
95
  )
80
- return disabledReason ? <WithTooltip label={disabledReason}>{button}</WithTooltip> : button
96
+ return disabledReason ? <TooltipTrigger label={disabledReason}>{button}</TooltipTrigger> : button
81
97
  }
package/src/Checkbox.tsx CHANGED
@@ -30,6 +30,8 @@ export interface CheckboxProps {
30
30
  onChange?: (checked: boolean) => void
31
31
  disabled?: boolean
32
32
  'aria-label'?: string
33
+ /** Set by a `Field` with `required`; a caller inside one owes nothing. */
34
+ 'aria-required'?: boolean | 'true' | 'false'
33
35
  className?: string
34
36
  }
35
37
 
@@ -61,6 +63,7 @@ export function Checkbox({ checked, onChange, disabled = false, className, ...ar
61
63
  checked={checked}
62
64
  disabled={disabled}
63
65
  aria-label={aria['aria-label']}
66
+ aria-required={aria['aria-required']}
64
67
  onCheckedChange={(next) => onChange(next)}
65
68
  onClick={(e) => e.stopPropagation()}
66
69
  className={(state) => squareClasses(state.checked, state.disabled, true, className)}
package/src/Chip.tsx CHANGED
@@ -8,8 +8,9 @@ import { cn } from './cn'
8
8
  * slots centre their icon (Ship's addition — an icon beside text otherwise
9
9
  * rides on the baseline).
10
10
  *
11
- * The label is the `chip` type token, 11px / 500; it is a plain class here,
12
- * never merged, so tailwind-merge cannot drop it.
11
+ * The label is the `chip` type token, 11px / 500. It used to be spelled as a
12
+ * plain class under a note that it must never be merged; `cn()` knows the ramp
13
+ * now, so a token size survives beside a colour and the note is retired.
13
14
  */
14
15
  export type ChipType = 'neutral' | 'brand' | 'info' | 'warning' | 'success' | 'error'
15
16
 
package/src/ChipInput.tsx CHANGED
@@ -94,6 +94,8 @@ export interface ChipInputProps<T extends ChipInputOption = ChipInputOption> {
94
94
  chipLeading?: (option: T) => ReactNode
95
95
  /** Before a suggestion row's label, 32px. */
96
96
  rowLeading?: (option: T) => ReactNode
97
+ /** Set by a `Field` with `required`; a caller inside one owes nothing. */
98
+ 'aria-required'?: boolean | 'true' | 'false'
97
99
  }
98
100
 
99
101
  export function ChipInput<T extends ChipInputOption = ChipInputOption>({
@@ -105,6 +107,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
105
107
  excludeIds = [],
106
108
  chipLeading,
107
109
  rowLeading,
110
+ ...aria
108
111
  }: ChipInputProps<T>) {
109
112
  const [query, setQuery] = useState('')
110
113
  const [highlight, setHighlight] = useState(0)
@@ -211,6 +214,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
211
214
  setTimeout(() => setIsFocused(false), 150)
212
215
  }}
213
216
  placeholder={value.length === 0 ? placeholder : ''}
217
+ aria-required={aria['aria-required']}
214
218
  className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
215
219
  />
216
220
  </div>
@@ -44,7 +44,26 @@ import { ConfirmDialog } from '@estiva-app/ui'
44
44
  - While `onConfirm` runs, both buttons wait. Resolving `false` keeps the
45
45
  dialog open — your surface has said why; a resolved `void`/`true`
46
46
  closes it.
47
- - Escape and the backdrop click cancel, via the shell.
47
+ - **Escape cancels; a press on the backdrop does not.** This is Base UI's
48
+ `AlertDialog`, through the shell's `alert` (Katerina, D20, 2026-09-07): a
49
+ destructive question is answered rather than clicked away. The backdrop
50
+ used to cancel, and that is the one behaviour stage 3 deliberately changed.
51
+ - It announces itself as an alert (`role="alertdialog"`), so a screen reader
52
+ reads the question on arrival instead of waiting to be asked.
53
+ - Focus is trapped in the card and returns to whatever opened it, as for
54
+ every dialog since stage 3.
55
+
56
+ ## Keys
57
+
58
+ Different from `DialogShell`'s in one row, and it is the row that matters.
59
+
60
+ | Input | What happens |
61
+ |---|---|
62
+ | Escape | cancels |
63
+ | backdrop click | **nothing** — the question has to be answered (D20) |
64
+ | close button | cancels |
65
+ | Tab / Shift+Tab | cycles the controls inside the card, and cannot leave it |
66
+ | on close | focus returns to whatever opened it |
48
67
 
49
68
  ## Props
50
69
 
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the ConfirmDialog page claims, pinned.
4
+ *
5
+ * It had no test file. Its page makes four behavioural claims — the button's
6
+ * variant follows `destructive`, the buttons wait while the action runs, a
7
+ * refusal keeps the dialog open, and a backdrop press no longer closes it
8
+ * (D20) — and none of them was held by anything.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { ConfirmDialog } from './ConfirmDialog'
14
+
15
+ afterEach(cleanup)
16
+
17
+ const Fixture = (props: Partial<Parameters<typeof ConfirmDialog>[0]> = {}) => (
18
+ <ConfirmDialog title="Delete this?" confirmLabel="Delete" onConfirm={() => {}} onClose={() => {}} {...props}>
19
+ This cannot be undone.
20
+ </ConfirmDialog>
21
+ )
22
+
23
+ describe('ConfirmDialog', () => {
24
+ it('is an alert dialog, named by its title', async () => {
25
+ render(<Fixture />)
26
+ expect(await screen.findByRole('alertdialog', { name: /Delete this\?/ })).toBeTruthy()
27
+ })
28
+
29
+ /** D20, Katerina 2026-09-07: a destructive question is answered, not clicked
30
+ * away. Escape and the ✕ still cancel. */
31
+ it('does not close on a press outside', async () => {
32
+ const user = userEvent.setup()
33
+ const onClose = vi.fn()
34
+ render(<Fixture onClose={onClose} />)
35
+ const dialog = await screen.findByRole('alertdialog')
36
+ await user.click(dialog.ownerDocument.body)
37
+ expect(onClose).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('closes on Escape', async () => {
41
+ const user = userEvent.setup()
42
+ const onClose = vi.fn()
43
+ render(<Fixture onClose={onClose} />)
44
+ await screen.findByRole('alertdialog')
45
+ await user.keyboard('{Escape}')
46
+ expect(onClose).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('closes on the ✕, which is the dialog’s own Close part', async () => {
50
+ const user = userEvent.setup()
51
+ const onClose = vi.fn()
52
+ render(<Fixture onClose={onClose} />)
53
+ await user.click(await screen.findByRole('button', { name: 'Close' }))
54
+ expect(onClose).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('Cancel closes without confirming', async () => {
58
+ const user = userEvent.setup()
59
+ const onClose = vi.fn()
60
+ const onConfirm = vi.fn()
61
+ render(<Fixture onClose={onClose} onConfirm={onConfirm} />)
62
+ await user.click(await screen.findByRole('button', { name: 'Cancel' }))
63
+ expect(onConfirm).not.toHaveBeenCalled()
64
+ expect(onClose).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('confirming runs the action and then closes', async () => {
68
+ const user = userEvent.setup()
69
+ const onClose = vi.fn()
70
+ const onConfirm = vi.fn()
71
+ render(<Fixture onClose={onClose} onConfirm={onConfirm} />)
72
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
73
+ expect(onConfirm).toHaveBeenCalledTimes(1)
74
+ expect(onClose).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ /** An action that resolves `false` has already said why, through the
78
+ * caller's banner — so the dialog stays, with the question still on it. */
79
+ it('a refused action keeps it open', async () => {
80
+ const user = userEvent.setup()
81
+ const onClose = vi.fn()
82
+ render(<Fixture onClose={onClose} onConfirm={() => false} />)
83
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
84
+ expect(onClose).not.toHaveBeenCalled()
85
+ expect(screen.getByRole('alertdialog')).toBeTruthy()
86
+ })
87
+
88
+ it('an action that resolves nothing still closes', async () => {
89
+ const user = userEvent.setup()
90
+ const onClose = vi.fn()
91
+ render(<Fixture onClose={onClose} onConfirm={async () => {}} />)
92
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
93
+ expect(onClose).toHaveBeenCalledTimes(1)
94
+ })
95
+
96
+ /** While it runs, neither answer can be given again. */
97
+ it('the buttons wait while the action runs', async () => {
98
+ const user = userEvent.setup()
99
+ let release: (value: boolean) => void = () => {}
100
+ const onConfirm = vi.fn(() => new Promise<boolean>((resolve) => { release = resolve }))
101
+ render(<Fixture onConfirm={onConfirm} />)
102
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
103
+ // Plainly disabled, not `aria-disabled`: neither answer carries a reason
104
+ // to give, so the controls are out of reach rather than focusable.
105
+ expect((screen.getByRole('button', { name: 'Delete' }) as HTMLButtonElement).disabled).toBe(true)
106
+ expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
107
+ release(true)
108
+ })
109
+ })
@@ -11,6 +11,11 @@ import { DialogShell } from './DialogShell'
11
11
  * archive); `primary` otherwise. While the action runs the buttons wait;
12
12
  * an action that resolves `false` keeps the dialog open, so the caller's
13
13
  * banner can say why.
14
+ *
15
+ * On Base UI's `AlertDialog` since stage 3 of the migration (2026-09-07),
16
+ * through `DialogShell`'s `alert`. **A press on the backdrop no longer
17
+ * closes it** (Katerina, D20): a destructive question is answered, not
18
+ * clicked away. Escape and the close button still cancel, as they did.
14
19
  */
15
20
  export interface ConfirmDialogProps {
16
21
  title: string
@@ -35,6 +40,7 @@ export function ConfirmDialog({ title, children, confirmLabel, destructive = fal
35
40
  }
36
41
  return (
37
42
  <DialogShell
43
+ alert
38
44
  title={title}
39
45
  onClose={onClose}
40
46
  bodyClassName="flex flex-col gap-3 text-body-2 text-text-primary"
@@ -23,7 +23,10 @@ footer for the buttons. A dialog is just what goes in the three slots.
23
23
 
24
24
  - A short list of verbs from a trigger → **Menu**.
25
25
  - "Are you sure?" → **ConfirmDialog** — it is this shell with the question
26
- already wired.
26
+ already wired, and it is the *only* thing that uses `alert`. There is no
27
+ `alert` story here, on purpose: any example of it would be a confirmation
28
+ dialog rebuilt by hand, which is the one thing this bullet exists to stop.
29
+ Its canvases and its nine tests are on that page.
27
30
  - Telling someone what happened → **Toast**.
28
31
 
29
32
  ## How
@@ -52,16 +55,27 @@ import { DialogShell, Button } from '@estiva-app/ui'
52
55
  with `overflow-y-auto` for long lists). Rows in a height-capped flex
53
56
  body need `shrink-0`, or overflow crushes them.
54
57
  - The card names itself to assistive tech (`role="dialog"`, labelled by
55
- `title`). Focus is not trapped or moved give the first field
56
- `autoFocus` where typing is the point.
58
+ its header or by `title` when `headerContent` replaces that text).
59
+ - **Focus is trapped and returned.** Tab cannot leave the card, and closing
60
+ puts focus back on whatever opened the dialog. This page said the opposite
61
+ until stage 3; it is Base UI's now, and pinned by `DialogShell.test.tsx`.
62
+ The rest of the page is marked `inert` while it is open, so a screen
63
+ reader cannot wander out either. `autoFocus` on the first field is still
64
+ worth setting where typing is the point — the trap decides where focus
65
+ *cannot* go, not where it starts.
66
+ - `alert` for a question that has to be answered: a press on the backdrop
67
+ stops closing it (Katerina, D20). Escape and the ✕ still do.
68
+ `ConfirmDialog` sets it; a form or a roster should not.
57
69
 
58
70
  ## Keys
59
71
 
60
72
  | Input | What happens |
61
73
  |---|---|
62
74
  | Escape | closes |
63
- | backdrop click | closes |
75
+ | backdrop click | closes — **except with `alert`, where nothing happens** |
64
76
  | close button | closes (it is an IconButton with its label) |
77
+ | Tab / Shift+Tab | cycles the controls inside the card, and cannot leave it |
78
+ | on close | focus returns to whatever opened the dialog |
65
79
 
66
80
  ## Props
67
81
 
@@ -1,7 +1,10 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { useState } from 'react'
3
+ import { IconArrowLeft } from '@tabler/icons-react'
3
4
  import { Button } from './Button'
5
+ import { Chip } from './Chip'
4
6
  import { DialogShell } from './DialogShell'
7
+ import { IconButton } from './IconButton'
5
8
  import { Field } from './Field'
6
9
  import { TextInput } from './TextInput'
7
10
  import { Textarea } from './Textarea'
@@ -49,21 +52,68 @@ export const Default: Story = {
49
52
  },
50
53
  }
51
54
 
52
- /** A minimal confirmation dialog — single line of body, two buttons. */
53
- export const Confirmation: Story = {
55
+ /**
56
+ * **`headerContent` replaces the title text** — a back button beside it, a
57
+ * count after it — and the ✕ stays. The dialog is then named by its `title`
58
+ * anyway, since there is no longer a heading to point at.
59
+ *
60
+ * There used to be a `Confirmation` story here. It hand-built what
61
+ * `ConfirmDialog` *is* — same question, same two buttons — and hand-built it
62
+ * wrongly: a plain dialog, so a press on the backdrop dismissed the question,
63
+ * which is exactly what D20 stopped. A story showing the thing the page's own
64
+ * "When not" tells you not to build is worse than no story (Katerina,
65
+ * 2026-09-08). These two show what this shell does that `ConfirmDialog`
66
+ * cannot.
67
+ */
68
+ export const WithHeaderContent: Story = {
54
69
  args: {
55
- title: 'Delete this?',
70
+ headerContent: (
71
+ <div className="flex items-center gap-2">
72
+ <IconButton aria-label="Back" tooltip="Back">
73
+ <IconArrowLeft size={16} stroke={1.5} />
74
+ </IconButton>
75
+ <span className="text-h4 text-text-primary">Item one</span>
76
+ <Chip label="3" />
77
+ </div>
78
+ ),
56
79
  footer: (
57
80
  <>
58
81
  <Button variant="muted">Cancel</Button>
59
- <Button variant="destructive">Delete</Button>
82
+ <Button variant="primary">Save</Button>
83
+ </>
84
+ ),
85
+ children: <p className="text-body-2 leading-[1.4] text-text-primary">The header is the caller's, and the ✕ is still the dialog's.</p>,
86
+ },
87
+ }
88
+
89
+ /**
90
+ * **No footer.** The body keeps the card's own bottom edge — a roster, a list,
91
+ * anything that simply ends rather than asking a question. There is no divider
92
+ * under the body either, because there is nothing to divide it from.
93
+ */
94
+ export const WithoutAFooter: Story = {
95
+ args: {
96
+ title: 'Item one',
97
+ footer: null,
98
+ bodyClassName: 'flex flex-col gap-3',
99
+ children: (
100
+ <>
101
+ {['Item one', 'Item two', 'Item three'].map((label) => (
102
+ <div key={label} className="flex items-center justify-between">
103
+ <span className="text-body-2 text-text-primary">{label}</span>
104
+ <span className="text-caption text-text-secondary">Value</span>
105
+ </div>
106
+ ))}
60
107
  </>
61
108
  ),
62
- children: <p className="text-body-2 leading-[1.4] text-text-primary">This permanently removes it. It cannot be undone.</p>,
63
109
  },
64
110
  }
65
111
 
66
- /** Open it, then close it with the ✕, the backdrop, or Escape. */
112
+ /**
113
+ * Open it, then close it with the ✕, the backdrop, or Escape. Tab around while
114
+ * it is open: focus cannot leave the card, and when the dialog closes it
115
+ * returns to the button that opened it. Neither was true before stage 3.
116
+ */
67
117
  export const OpenAndClose: Story = {
68
118
  args: { footer: null, children: null },
69
119
  render: (args) => {
@@ -0,0 +1,184 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the pages claim after stage 3, pinned.
4
+ *
5
+ * `DialogShell.mdx` said, in as many words, that this component did not trap
6
+ * focus. It does now, and a sentence that changed from "it does not" to "it
7
+ * does" is exactly the kind that needs a test under it — otherwise the page is
8
+ * the only thing holding the claim up.
9
+ *
10
+ * The one that is a *decision* rather than a capability is the last pair:
11
+ * a plain dialog closes when you press outside it, and a `ConfirmDialog` does
12
+ * not (Katerina, D20, 2026-09-07). Both are asserted, because the difference
13
+ * is the whole reason `alert` exists.
14
+ */
15
+ import { useState } from 'react'
16
+ import { afterEach, describe, expect, it, vi } from 'vitest'
17
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
18
+ import userEvent from '@testing-library/user-event'
19
+ import { Button } from './Button'
20
+ import { ConfirmDialog } from './ConfirmDialog'
21
+ import { DialogShell } from './DialogShell'
22
+ import { TextInput } from './TextInput'
23
+
24
+ afterEach(cleanup)
25
+
26
+ const Shell = ({ onClose }: { onClose: () => void }) => (
27
+ <DialogShell title="Rename topic" onClose={onClose} footer={<Button variant="primary">Save</Button>}>
28
+ <TextInput aria-label="Name" defaultValue="" />
29
+ </DialogShell>
30
+ )
31
+
32
+ describe('DialogShell', () => {
33
+ it('is named by its header', () => {
34
+ render(<Shell onClose={() => {}} />)
35
+ expect(screen.getByRole('dialog', { name: 'Rename topic' })).toBeTruthy()
36
+ })
37
+
38
+ it('is named by the title even when headerContent replaces the header text', () => {
39
+ // The name used to be spelled with `aria-label` in both branches. Now the
40
+ // header names it when the header is the title, so this is the branch that
41
+ // still needs the label — and the one that would silently go unnamed.
42
+ render(
43
+ <DialogShell title="Members" onClose={() => {}} headerContent={<span>Back · Members · 4</span>}>
44
+ <p>Body</p>
45
+ </DialogShell>,
46
+ )
47
+ expect(screen.getByRole('dialog', { name: 'Members' })).toBeTruthy()
48
+ })
49
+
50
+ it('closes on Escape', async () => {
51
+ const onClose = vi.fn()
52
+ const user = userEvent.setup()
53
+ render(<Shell onClose={onClose} />)
54
+ await user.keyboard('{Escape}')
55
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
56
+ })
57
+
58
+ it('closes on the close button', async () => {
59
+ const onClose = vi.fn()
60
+ const user = userEvent.setup()
61
+ render(<Shell onClose={onClose} />)
62
+ await user.click(screen.getByRole('button', { name: 'Close' }))
63
+ expect(onClose).toHaveBeenCalled()
64
+ })
65
+
66
+ it('traps focus, which this component could not do before', async () => {
67
+ // Written expecting only that Tab could not reach the page behind the
68
+ // card. Base UI does more than that, and the first version of this test
69
+ // failed on it: the rest of the document is marked "inert" and
70
+ // "aria-hidden", so the button behind is not findable by role at all.
71
+ // Both halves are asserted, because the stronger one is what would go
72
+ // unnoticed if it weakened.
73
+ //
74
+ // The dialog portals to the body, so `container` holds nothing but the
75
+ // page behind it — which makes "focus never enters the page" one check.
76
+ const user = userEvent.setup()
77
+ const { container } = render(
78
+ <>
79
+ <button type="button">Behind the dialog</button>
80
+ <Shell onClose={() => {}} />
81
+ </>,
82
+ )
83
+ const behind = container.querySelector('button')!
84
+ expect(behind.textContent).toBe('Behind the dialog')
85
+ expect(screen.queryByRole('button', { name: 'Behind the dialog' })).toBeNull()
86
+
87
+ // Three controls in the card, so five tabs walk past the end of them.
88
+ for (let i = 0; i < 5; i++) {
89
+ await user.tab()
90
+ expect(container.contains(document.activeElement)).toBe(false)
91
+ }
92
+ })
93
+
94
+ it('focuses the card, not the ✕', async () => {
95
+ // Base UI's default is the first tabbable element, which here is the close
96
+ // button — so every dialog opened with a ring on its ✕. The screenshot
97
+ // diff caught it as a 28px square, and this is the guard so it cannot come
98
+ // back quietly.
99
+ render(<Shell onClose={() => {}} />)
100
+ await waitFor(() => {
101
+ expect(document.activeElement).toBe(screen.getByRole('dialog'))
102
+ })
103
+ })
104
+
105
+ it('still lets a field with autoFocus take it', async () => {
106
+ // The two dialogs in Ship rely on this, and pinning the card as the initial
107
+ // focus is exactly the change that could have broken it.
108
+ render(
109
+ <DialogShell title="Rename topic" onClose={() => {}}>
110
+ <TextInput aria-label="Name" autoFocus defaultValue="" />
111
+ </DialogShell>,
112
+ )
113
+ await waitFor(() => {
114
+ expect(document.activeElement).toBe(screen.getByLabelText('Name'))
115
+ })
116
+ })
117
+
118
+ it('returns focus to whatever opened it', async () => {
119
+ const user = userEvent.setup()
120
+ function Opener() {
121
+ const [open, setOpen] = useState(false)
122
+ return (
123
+ <>
124
+ <button type="button" onClick={() => setOpen(true)}>
125
+ Open
126
+ </button>
127
+ {open && (
128
+ <DialogShell title="Rename topic" onClose={() => setOpen(false)}>
129
+ Body
130
+ </DialogShell>
131
+ )}
132
+ </>
133
+ )
134
+ }
135
+ render(<Opener />)
136
+ const opener = screen.getByRole('button', { name: 'Open' })
137
+ await user.click(opener)
138
+ await screen.findByRole('dialog', { name: 'Rename topic' })
139
+ await user.keyboard('{Escape}')
140
+ await waitFor(() => expect(document.activeElement).toBe(opener))
141
+ })
142
+
143
+ it('closes when you press outside it', async () => {
144
+ const onClose = vi.fn()
145
+ const user = userEvent.setup()
146
+ render(<Shell onClose={onClose} />)
147
+ await user.click(document.querySelector('.bg-scrim') as HTMLElement)
148
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
149
+ })
150
+ })
151
+
152
+ describe('ConfirmDialog', () => {
153
+ const Confirm = ({ onClose }: { onClose: () => void }) => (
154
+ <ConfirmDialog title="Delete project?" confirmLabel="Delete" destructive onConfirm={() => true} onClose={onClose}>
155
+ This cannot be undone.
156
+ </ConfirmDialog>
157
+ )
158
+
159
+ it('does NOT close when you press outside it (D20)', async () => {
160
+ // The difference from the dialog above, and the reason `alert` exists: a
161
+ // destructive question is answered, not clicked away.
162
+ const onClose = vi.fn()
163
+ const user = userEvent.setup()
164
+ render(<Confirm onClose={onClose} />)
165
+ await user.click(document.querySelector('.bg-scrim') as HTMLElement)
166
+ // Give the close a chance to happen before asserting that it did not.
167
+ await new Promise((r) => setTimeout(r, 50))
168
+ expect(onClose).not.toHaveBeenCalled()
169
+ expect(screen.getByRole('alertdialog', { name: 'Delete project?' })).toBeTruthy()
170
+ })
171
+
172
+ it('still closes on Escape', async () => {
173
+ const onClose = vi.fn()
174
+ const user = userEvent.setup()
175
+ render(<Confirm onClose={onClose} />)
176
+ await user.keyboard('{Escape}')
177
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
178
+ })
179
+
180
+ it('announces itself as an alert rather than a plain dialog', () => {
181
+ render(<Confirm onClose={() => {}} />)
182
+ expect(screen.getByRole('alertdialog')).toBeTruthy()
183
+ })
184
+ })