@charcuterie/ui 1.1.0 → 2.0.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 (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -15,8 +15,7 @@ const {
15
15
  } = composeStories(stories)
16
16
 
17
17
  test("the menu is named, and its items are menuitems", async () => {
18
- const { canvas, canvasElement } =
19
- await mountStory(AllStates)
18
+ const { body } = await mountStory(AllStates)
20
19
 
21
20
  // **Named by its trigger**, not by a `label` prop. `useRole` puts
22
21
  // `aria-labelledby` on the panel pointing at the button, and
@@ -27,17 +26,17 @@ test("the menu is named, and its items are menuitems", async () => {
27
26
  // overlay with **no trigger relationship** (`Spinner`'s
28
27
  // `role="status"`, `Popover`'s `role="dialog"`) needs its own
29
28
  // name. A menu has one.
30
- expectAgentDrivable(canvas, {
29
+ const menu = expectAgentDrivable(body, {
31
30
  name: "Bay 3",
32
31
  role: "menu",
33
32
  })
34
33
 
35
- expectAgentDrivable(canvas, {
34
+ expectAgentDrivable(body, {
36
35
  name: "Retry title",
37
36
  role: "menuitem",
38
37
  })
39
38
 
40
- await expectNoAxeViolations(canvasElement)
39
+ await expectNoAxeViolations(menu)
41
40
  })
42
41
 
43
42
  /**
@@ -46,7 +45,7 @@ test("the menu is named, and its items are menuitems", async () => {
46
45
  * still on the trigger, with a menu they cannot reach.
47
46
  */
48
47
  test("opening moves focus into the menu", async () => {
49
- const { canvas } = await mountStory(Interactive)
48
+ const { body, canvas } = await mountStory(Interactive)
50
49
 
51
50
  await userEvent.click(
52
51
  expectAgentDrivable(canvas, {
@@ -57,7 +56,7 @@ test("opening moves focus into the menu", async () => {
57
56
 
58
57
  await waitFor(() => {
59
58
  expect(
60
- expectAgentDrivable(canvas, {
59
+ expectAgentDrivable(body, {
61
60
  name: "Retry title",
62
61
  role: "menuitem",
63
62
  }),
@@ -66,14 +65,14 @@ test("opening moves focus into the menu", async () => {
66
65
  })
67
66
 
68
67
  test("the arrow keys move within the menu and skip the disabled item", async () => {
69
- const { canvas } = await mountStory(AllStates)
68
+ const { body } = await mountStory(AllStates)
70
69
 
71
- const skip = expectAgentDrivable(canvas, {
70
+ const skip = expectAgentDrivable(body, {
72
71
  name: "Skip title",
73
72
  role: "menuitem",
74
73
  })
75
74
 
76
- const eject = expectAgentDrivable(canvas, {
75
+ const eject = expectAgentDrivable(body, {
77
76
  name: "Eject disc",
78
77
  role: "menuitem",
79
78
  })
@@ -84,7 +83,7 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
84
83
  // clicking an item to "focus" it closes the thing under test. This
85
84
  // story opens already visible, and opening is what puts focus on
86
85
  // the first item.
87
- const retry = expectAgentDrivable(canvas, {
86
+ const retry = expectAgentDrivable(body, {
88
87
  name: "Retry title",
89
88
  role: "menuitem",
90
89
  })
@@ -118,9 +117,9 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
118
117
  * that is the pattern, not a bug.
119
118
  */
120
119
  test("the menu has one tab stop", async () => {
121
- const { canvas } = await mountStory(AllStates)
120
+ const { body } = await mountStory(AllStates)
122
121
 
123
- const menu = expectAgentDrivable(canvas, {
122
+ const menu = expectAgentDrivable(body, {
124
123
  name: "Bay 3",
125
124
  role: "menu",
126
125
  })
@@ -135,7 +134,7 @@ test("the menu has one tab stop", async () => {
135
134
  })
136
135
 
137
136
  test("choosing an item dismisses the menu", async () => {
138
- const { canvas } = await mountStory(Interactive)
137
+ const { body, canvas } = await mountStory(Interactive)
139
138
 
140
139
  await userEvent.click(
141
140
  expectAgentDrivable(canvas, {
@@ -145,7 +144,7 @@ test("choosing an item dismisses the menu", async () => {
145
144
  )
146
145
 
147
146
  await userEvent.click(
148
- expectAgentDrivable(canvas, {
147
+ expectAgentDrivable(body, {
149
148
  name: "Retry title",
150
149
  role: "menuitem",
151
150
  }),
@@ -154,14 +153,12 @@ test("choosing an item dismisses the menu", async () => {
154
153
  // A menu that stays open after an action has fired is the one
155
154
  // interaction users read as "it didn't work".
156
155
  await waitFor(() => {
157
- expect(canvas.queryAllByRole("menuitem")).toHaveLength(
158
- 0,
159
- )
156
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
160
157
  })
161
158
  })
162
159
 
163
160
  test("Escape dismisses without choosing", async () => {
164
- const { canvas } = await mountStory(Interactive)
161
+ const { body, canvas } = await mountStory(Interactive)
165
162
 
166
163
  await userEvent.click(
167
164
  expectAgentDrivable(canvas, {
@@ -172,37 +169,50 @@ test("Escape dismisses without choosing", async () => {
172
169
 
173
170
  await waitFor(() => {
174
171
  expect(
175
- canvas.queryAllByRole("menuitem").length,
172
+ body.queryAllByRole("menuitem").length,
176
173
  ).toBeGreaterThan(0)
177
174
  })
178
175
 
179
176
  await userEvent.keyboard("{Escape}")
180
177
 
181
178
  await waitFor(() => {
182
- expect(canvas.queryAllByRole("menuitem")).toHaveLength(
183
- 0,
184
- )
179
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
185
180
  })
186
181
  })
187
182
 
188
183
  /**
189
- * The panel is in the top layer via `popover="manual"` and **stays
190
- * in the DOM where it was written** — so a scoped query still finds
191
- * it. A portalled menu would move to `document.body` and every story
192
- * here would have to reach for `screen`.
184
+ * The panel portals to `document.body` — escaping any
185
+ * `overflow: hidden` ancestor — and the trigger→panel link survives
186
+ * the boundary: `useRole` writes `aria-labelledby` on the menu
187
+ * pointing back at the button that names it. A scoped query for the
188
+ * panel now reaches for `body`; the trigger stays in the canvas.
193
189
  */
194
- test("the menu is not portalled out of its canvas", async () => {
195
- const { canvas, canvasElement } =
190
+ test("the menu portals to the body, still named by its trigger", async () => {
191
+ const { body, canvas, canvasElement } =
196
192
  await mountStory(AllVariants)
197
193
 
198
- const menu = expectAgentDrivable(canvas, {
194
+ const trigger = expectAgentDrivable(canvas, {
195
+ name: "Bay 2",
196
+ role: "button",
197
+ })
198
+
199
+ const menu = expectAgentDrivable(body, {
199
200
  name: "Bay 2",
200
201
  role: "menu",
201
202
  })
202
203
 
203
- await expect(canvasElement).toContainElement(menu)
204
+ await expect(canvasElement).not.toContainElement(menu)
205
+
206
+ await expect(document.body).toContainElement(menu)
207
+
208
+ // The link, across the portal: the menu is named by pointing at its
209
+ // trigger, and the id really resolves.
210
+ await expect(menu).toHaveAttribute(
211
+ "aria-labelledby",
212
+ trigger.id,
213
+ )
204
214
 
205
- await expectNoAxeViolations(canvasElement)
215
+ await expectNoAxeViolations(menu)
206
216
  })
207
217
 
208
218
  /**
@@ -239,8 +249,7 @@ const expectAnchoredTrigger = (trigger: HTMLElement) => {
239
249
  }
240
250
 
241
251
  test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
242
- const { canvas, canvasElement } =
243
- await mountStory(SharedTrigger)
252
+ const { body, canvas } = await mountStory(SharedTrigger)
244
253
 
245
254
  const trigger = expectAgentDrivable(canvas, {
246
255
  name: "Bay 5",
@@ -252,7 +261,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
252
261
  await userEvent.click(trigger)
253
262
 
254
263
  const menu = await waitFor(() =>
255
- expectAgentDrivable(canvas, { role: "menu" }),
264
+ expectAgentDrivable(body, { role: "menu" }),
256
265
  )
257
266
 
258
267
  // `bottom-start` with `offset(4)`: flush with the trigger's
@@ -273,7 +282,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
273
282
  )
274
283
  })
275
284
 
276
- await expectNoAxeViolations(canvasElement)
285
+ await expectNoAxeViolations(menu)
277
286
  })
278
287
 
279
288
  /**
@@ -282,7 +291,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
282
291
  * pointing at nothing and the test above would still pass.
283
292
  */
284
293
  test("the tooltip sharing that trigger is anchored to it too", async () => {
285
- const { canvas } = await mountStory(SharedTrigger)
294
+ const { body, canvas } = await mountStory(SharedTrigger)
286
295
 
287
296
  const trigger = expectAgentDrivable(canvas, {
288
297
  name: "Bay 5",
@@ -294,7 +303,7 @@ test("the tooltip sharing that trigger is anchored to it too", async () => {
294
303
  trigger.focus()
295
304
 
296
305
  const tip = await waitFor(() =>
297
- expectAgentDrivable(canvas, { role: "tooltip" }),
306
+ expectAgentDrivable(body, { role: "tooltip" }),
298
307
  )
299
308
 
300
309
  // The **main axis** only: `top` with `offset(6)`. The cross axis
package/src/Menu/Menu.tsx CHANGED
@@ -1,23 +1,17 @@
1
1
  import {
2
2
  selectTabIndex,
3
- useClonedChild,
4
3
  useRovingFocus,
5
4
  } from "@charcuterie/logic"
6
5
  import type { Placement } from "@floating-ui/react"
7
6
  import {
8
- autoUpdate,
9
7
  FloatingFocusManager,
10
- flip,
11
- offset,
12
- shift,
13
- useDismiss,
14
- useFloating,
15
- useInteractions,
16
- useRole,
8
+ FloatingPortal,
17
9
  } from "@floating-ui/react"
18
10
  import type { ReactElement, ReactNode } from "react"
19
11
  import { useEffect, useRef } from "react"
20
12
 
13
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
14
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
21
15
  import { toClassName } from "../toClassName.ts"
22
16
  import { MenuAction } from "./MenuAction.tsx"
23
17
 
@@ -112,8 +106,6 @@ export const Menu = ({
112
106
  new Map<string, HTMLButtonElement>(),
113
107
  )
114
108
 
115
- const panelElement = useRef<HTMLDivElement>(null)
116
-
117
109
  const focus = useRovingFocus<string>()
118
110
 
119
111
  const { activeValue, registeredValues, setActiveValue } =
@@ -121,53 +113,21 @@ export const Menu = ({
121
113
 
122
114
  const [firstValue] = registeredValues
123
115
 
124
- const { context, floatingStyles, refs } = useFloating({
125
- open: isVisible,
126
- onOpenChange: (isNextVisible) => {
127
- if (!isNextVisible) {
128
- onDismiss()
129
- }
130
- },
116
+ const {
117
+ clonedTrigger,
118
+ context,
119
+ floatingStyles,
120
+ getFloatingProps,
121
+ setFloating,
122
+ } = useAnchoredOverlay({
123
+ isVisible,
124
+ offsetValue: 4,
125
+ onDismiss,
131
126
  placement,
132
- middleware: [offset(4), flip(), shift({ padding: 8 })],
133
- strategy: "fixed",
134
- whileElementsMounted: autoUpdate,
135
- })
136
-
137
- const { getFloatingProps, getReferenceProps } =
138
- useInteractions([
139
- useDismiss(context),
140
- useRole(context, { role: "menu" }),
141
- ])
142
-
143
- const clonedTrigger = useClonedChild(trigger, {
144
- ...getReferenceProps(),
145
- ref: refs.setReference,
127
+ role: "menu",
128
+ trigger,
146
129
  })
147
130
 
148
- /**
149
- * The other half of the `isConnected` guard below. If the ref
150
- * fired while the node was still detached, nothing has shown the
151
- * popover — and a `[popover]` that was never shown is
152
- * `display: none`, so the menu renders, passes typecheck, and is
153
- * invisible to `getByRole` and to a user alike.
154
- *
155
- * Effects run after the tree is connected, so this is the
156
- * backstop. It is idempotent: `:popover-open` is the same question
157
- * the ref asks.
158
- */
159
- useEffect(() => {
160
- const panel = panelElement.current
161
-
162
- if (
163
- isVisible &&
164
- panel?.isConnected &&
165
- !panel.matches(":popover-open")
166
- ) {
167
- panel.showPopover()
168
- }
169
- }, [isVisible])
170
-
171
131
  /**
172
132
  * Opening **makes** a member active, and this is the step the
173
133
  * first version was missing.
@@ -205,95 +165,74 @@ export const Menu = ({
205
165
  {clonedTrigger}
206
166
 
207
167
  {isVisible ? (
208
- <FloatingFocusManager
209
- context={context}
210
- // `false`, so Tab leaves the menu and lands on the next
211
- // control in the page — which is what closes it, through
212
- // `useDismiss`. A trapped menu is a dialog.
213
- modal={false}
214
- // Focus is moved by the effect above, to whichever item
215
- // `RovingFocus` made active. Letting the manager also aim
216
- // it is two owners for one caret.
217
- initialFocus={-1}
218
- >
219
- <div
220
- {...getFloatingProps()}
221
- className={toClassName(
222
- "inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium",
223
- className,
224
- )}
225
- onKeyDown={(keyEvent) => {
226
- const commands: Record<string, () => void> = {
227
- ArrowDown: focus.next,
228
- ArrowUp: focus.previous,
229
- End: focus.last,
230
- Home: focus.first,
231
- }
232
-
233
- const command = commands[keyEvent.key]
234
-
235
- if (command) {
236
- // Arrow keys scroll the page by default, and a menu
237
- // that scrolls the document behind it while moving
238
- // focus is the hand-rolled version of this
239
- // component.
240
- keyEvent.preventDefault()
241
-
242
- command()
243
- }
244
- }}
245
- popover="manual"
246
- ref={(node) => {
247
- refs.setFloating(node)
248
-
249
- panelElement.current = node
250
-
251
- // A ref callback rather than an effect, for the same
252
- // reason as `Popover`: a `[popover]` is
253
- // `display: none` until shown, and every child effect
254
- // — including the focus move above — would otherwise
255
- // be aiming at a hidden element.
256
- //
257
- // `isConnected` is the guard `Popover` does not need
258
- // and this does. `FloatingFocusManager` renders a pair
259
- // of focus guards around its child, so React commits
260
- // this subtree in an order where the ref can fire
261
- // while the node is still detached — and
262
- // `showPopover()` on a disconnected element throws
263
- // `InvalidStateError`, which surfaces as an unhandled
264
- // rejection rather than as a failing assertion.
265
- if (
266
- node?.isConnected &&
267
- !node.matches(":popover-open")
268
- ) {
269
- node.showPopover()
270
- }
271
- }}
272
- // Duplicated from `getFloatingProps()` on purpose, and
273
- // the two cannot differ — `useRole(context, { role:
274
- // "menu" })` above is where it comes from. Stated here
275
- // because a linter cannot see a role through a spread.
276
- role="menu"
277
- style={floatingStyles}
168
+ <FloatingPortal>
169
+ <FloatingFocusManager
170
+ context={context}
171
+ // `false`, so Tab leaves the menu and lands on the next
172
+ // control in the page — which is what closes it, through
173
+ // `useDismiss`. A trapped menu is a dialog.
174
+ modal={false}
175
+ // Focus is moved by the effect above, to whichever item
176
+ // `RovingFocus` made active. Letting the manager also aim
177
+ // it is two owners for one caret.
178
+ initialFocus={-1}
278
179
  >
279
- {items.map((item) => (
280
- <MenuAction
281
- item={item}
282
- key={item.key}
283
- onDismiss={onDismiss}
284
- register={focus.register}
285
- tabIndex={selectTabIndex(focus, item.key)}
286
- trackElement={(key, element) => {
287
- if (element) {
288
- itemElements.current.set(key, element)
289
- } else {
290
- itemElements.current.delete(key)
180
+ <div
181
+ {...getFloatingProps()}
182
+ className={toClassName(
183
+ PANEL_SURFACE_CLASS,
184
+ "z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 p-1",
185
+ className,
186
+ )}
187
+ onKeyDown={(keyEvent) => {
188
+ const commands: Record<string, () => void> =
189
+ {
190
+ ArrowDown: focus.next,
191
+ ArrowUp: focus.previous,
192
+ End: focus.last,
193
+ Home: focus.first,
291
194
  }
292
- }}
293
- />
294
- ))}
295
- </div>
296
- </FloatingFocusManager>
195
+
196
+ const command = commands[keyEvent.key]
197
+
198
+ if (command) {
199
+ // Arrow keys scroll the page by default, and a menu
200
+ // that scrolls the document behind it while moving
201
+ // focus is the hand-rolled version of this
202
+ // component.
203
+ keyEvent.preventDefault()
204
+
205
+ command()
206
+ }
207
+ }}
208
+ ref={setFloating}
209
+ // Duplicated from `getFloatingProps()` on purpose, and
210
+ // the two cannot differ — `useRole(context, { role:
211
+ // "menu" })` in the hook is where it comes from. Stated
212
+ // here because a linter cannot see a role through a
213
+ // spread.
214
+ role="menu"
215
+ style={floatingStyles}
216
+ >
217
+ {items.map((item) => (
218
+ <MenuAction
219
+ item={item}
220
+ key={item.key}
221
+ onDismiss={onDismiss}
222
+ register={focus.register}
223
+ tabIndex={selectTabIndex(focus, item.key)}
224
+ trackElement={(key, element) => {
225
+ if (element) {
226
+ itemElements.current.set(key, element)
227
+ } else {
228
+ itemElements.current.delete(key)
229
+ }
230
+ }}
231
+ />
232
+ ))}
233
+ </div>
234
+ </FloatingFocusManager>
235
+ </FloatingPortal>
297
236
  ) : null}
298
237
  </>
299
238
  )
@@ -6,10 +6,11 @@ import * as ModalStories from "./Modal.stories";
6
6
 
7
7
  # Modal
8
8
 
9
- Four repos, and **plex-channels and ripdeck arrived at native `<dialog>` independently**
10
- before this library existed. That is the design, not a shortcut: `showModal()` gives a
11
- focus trap, Escape, `::backdrop`, `inert` on everything behind it, and the top layer —
12
- from the platform, correctly, in every browser the fleet runs on.
9
+ The **base layer**: a backdrop, a focus trap, Escape, and an outside press — and no
10
+ chrome. A dialog is one kind of modal; so is a confirmation, a media lightbox, and
11
+ anything else that wants a scrim over the page. `Modal` is that foundation, and
12
+ [`Dialog`](?path=/docs/components-dialog--docs) is `Modal` plus the
13
+ heading/Close/footer/size chrome the component shipped with before the M8 rebuild.
13
14
 
14
15
  <Canvas of={ModalStories.Default} />
15
16
 
@@ -17,97 +18,43 @@ from the platform, correctly, in every browser the fleet runs on.
17
18
 
18
19
  <Controls />
19
20
 
20
- ## The one thing a component has to add
21
+ ## Portalled, not the top layer
21
22
 
22
- A native `<dialog>` is a **self-owning** control: call `showModal()` and it decides it is
23
- open. That is the same conflict Radix has, and it gets the same answer as everywhere else
24
- here — `isVisible` is the truth, and an effect makes the element agree with it. Nothing
25
- reads `dialogElement.open` as state; it is read only to avoid calling a method that would
26
- throw.
23
+ `Modal` renders an `OverlayPanel`, which portals to `document.body`. The old `Modal` rode
24
+ the platform top layer (`<dialog>.showModal()`); this one supersedes that
25
+ ([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)),
26
+ because the top layer cannot escape an `overflow: hidden` ancestor's clipping in every
27
+ case the fleet hit, and a portal does. The objection a portal used to raise — that it
28
+ moves the node out of a scoped query — is answered by `useRole` writing `aria-controls` /
29
+ `aria-labelledby` across the boundary, and by stories scoping panel queries to
30
+ `within(document.body)`.
27
31
 
28
32
  ```tsx
29
33
  const { hide, isVisible, show } = useVisibility()
30
34
 
31
- <Button onClick={show}>Stop the rip</Button>
35
+ <Button onClick={show}>Open</Button>
32
36
 
33
- <Modal heading="Stop the rip?" isVisible={isVisible} onClose={hide}>
34
- …
37
+ <Modal aria-label="Read error on title 4" isVisible={isVisible} onClose={hide}>
38
+ …your own markup…
35
39
  </Modal>
36
40
  ```
37
41
 
38
- `onClose` is every route out — the close button, Escape, a backdrop press — so there is
39
- one place to wire and no way to end up with the element shut and the state still open.
42
+ `onClose` is every route out — Escape, a backdrop press — so there is one place to wire
43
+ and no way to end up with the element shut and the state still open.
40
44
 
41
- ## No portal, and that is load-bearing
45
+ ## One accessible name, required
42
46
 
43
- The top layer is a **paint** concept. A `<dialog>` renders above everything while staying
44
- exactly where it is in the DOM, so it needs no portal — and that matters well past
45
- tidiness:
47
+ A `role="dialog"` takes no name from its content, so pass exactly one of `aria-label` or
48
+ `aria-labelledby`. Missing both is a dev-time warning, not a throw.
46
49
 
47
- > A portal moves the node to `document.body`, **out of whatever element a test or an agent
48
- > scoped its queries to.** `canvas.getByRole("dialog")` finds this modal. It would not find
49
- > a portalled one, and every story here would have to reach for `screen` and give up its
50
- > scoping.
50
+ <Canvas of={ModalStories.Alert} />
51
51
 
52
- The same reasoning is why `Popover` uses `popover="manual"` rather than a portal.
52
+ ## Stacking is zero-config, until it is not
53
53
 
54
- ## `open:flex`, not `flex`
55
-
56
- A closed `<dialog>` is hidden by a UA rule, and any `display: flex` of ours beats it — so
57
- a modal laid out with a plain `flex` renders permanently, in the page flow, with no error
58
- anywhere. The layout is therefore behind Tailwind's `open:` variant, keyed on the `[open]`
59
- attribute the browser manages.
60
-
61
- <Canvas of={ModalStories.AllVariants} />
62
-
63
- ## What a story cannot press
64
-
65
- `play`'s `userEvent` is `@testing-library/user-event`, which dispatches **untrusted**
66
- events, and a browser runs a default action only for trusted input. So a synthetic Escape
67
- does not make a native `<dialog>` fire `cancel` — it does nothing at all, and a story
68
- asserting "Escape did not close the non-dismissable dialog" would pass for the wrong
69
- reason forever.
70
-
71
- The stories dispatch the close request where the browser would raise it instead, which
72
- splits the contract honestly: keystroke → `cancel` is the platform's and is not ours to
73
- test; everything downstream of `cancel` is ours and is asserted. `AllStates` pairs it with
74
- a differential — the dialog that refuses the close request is then closed by its own
75
- button — because "still open" is also true of a dialog nothing can close at all.
76
-
77
- <Canvas of={ModalStories.AllStates} />
78
-
79
- ## `isDismissable={false}` cancels, it does not ignore
80
-
81
- Escape is `preventDefault`ed rather than dropped. Letting the browser close a dialog the
82
- app said was not dismissable would leave `isVisible` true and the element shut — the exact
83
- desync this component exists to prevent.
84
-
85
- ## Scroll behind it is locked, and the count is why
86
-
87
- `showModal()` makes the page `inert` and then lets you scroll it anyway. The lock is a
88
- counted module-level one rather than a hook, because nested dialogs are legal and a naive
89
- lock/unlock pair has the inner dialog's cleanup unlock the page while the outer is still
90
- open. The release is idempotent, because StrictMode runs an effect's cleanup twice.
91
-
92
- ## `dvh`, and the scrim
93
-
94
- Every size clamps at `85dvh`; `full` takes the lot. `dvh` rather than `vh` because on a
95
- phone the two differ by the address bar, and a dialog whose footer is under it is a dialog
96
- with no buttons.
97
-
98
- `::backdrop` reads `--color-scrim`, which is a **variant** token: `legible` dims at 0.84
99
- and `daylight` at 0.66, because how hard a direction separates a dialog from the page is
100
- exactly the sort of thing a visual direction decides. ePaper's is `transparent` — a panel
101
- with no opacity cannot dim, so it says so instead of faking it. The `Responsive` story
102
- asserts the computed `::backdrop` colour is really there, since a `var()` that failed to
103
- inherit would just look like a design choice.
104
-
105
- <Canvas of={ModalStories.Responsive} />
106
-
107
- ## A modal is not a container-query citizen
108
-
109
- It gets no `ContainerBoard`. Its size is measured against the viewport by definition — it
110
- is the thing covering the viewport — so boarding it at three container widths would be
111
- measuring something with no bearing on it.
54
+ A lone `Modal` renders its own scrim with no setup. Only when modals **stack** — one
55
+ opened from inside another — does the app need an `OverlayStackProvider` at its root, so
56
+ the N of them share **one** scrim, keep the top live, mark the rest `inert`, and dismiss
57
+ top-first. The scroll lock is ref-counted through the stack, so the page frees only when
58
+ the last modal closes.
112
59
 
113
60
  <Canvas of={ModalStories.Interactive} />