@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
@@ -245,3 +245,36 @@ export const Interactive: Story = {
245
245
  />
246
246
  ),
247
247
  }
248
+
249
+ /**
250
+ * The bug the whole M8 portal reversal exists to fix. The trigger sits
251
+ * inside a small `overflow: hidden` box — the top layer was still
252
+ * clipped by it; a `FloatingPortal` to `document.body` is not. Open it
253
+ * and the panel escapes the clip whole.
254
+ */
255
+ export const EscapesOverflowClip: Story = {
256
+ args: {
257
+ children: null,
258
+ heading: "Filters",
259
+ isVisible: false,
260
+ onDismiss: () => {},
261
+ trigger: <Button>Filters</Button>,
262
+ },
263
+ render: () => (
264
+ <div className="h-24 w-56 overflow-hidden rounded-md border border-border-default bg-surface-raised p-3">
265
+ <p className="mb-2 text-content-secondary text-xs">
266
+ This box is `overflow: hidden`.
267
+ </p>
268
+
269
+ <PopoverDemo
270
+ heading="Escapes the clip"
271
+ triggerLabel="Open (escapes the clip)"
272
+ >
273
+ <p className="text-content-secondary">
274
+ Portalled to the body, so the clip cannot reach
275
+ it.
276
+ </p>
277
+ </PopoverDemo>
278
+ </div>
279
+ ),
280
+ }
@@ -11,13 +11,13 @@ const {
11
11
  AllStates,
12
12
  AllVariants,
13
13
  Default,
14
+ EscapesOverflowClip,
14
15
  Interactive,
15
16
  Responsive,
16
17
  } = composeStories(stories)
17
18
 
18
19
  test("the trigger and the panel agree about each other", async () => {
19
- const { canvas, canvasElement } =
20
- await mountStory(Default)
20
+ const { body, canvas } = await mountStory(Default)
21
21
 
22
22
  const trigger = expectAgentDrivable(canvas, {
23
23
  name: "Filters",
@@ -31,31 +31,31 @@ test("the trigger and the panel agree about each other", async () => {
31
31
 
32
32
  await userEvent.click(trigger)
33
33
 
34
- // Found through `canvas`, not `screen` — the panel is in the top
35
- // layer and still in this story's DOM, which a portal would have
36
- // cost.
37
- const panel = expectAgentDrivable(canvas, {
34
+ // Found through `body`, not `canvas` — the panel portals to
35
+ // `document.body`, which is the whole point of portalling over the
36
+ // top layer. The trigger stays in the canvas.
37
+ const panel = expectAgentDrivable(body, {
38
38
  name: "Filters",
39
39
  role: "dialog",
40
40
  })
41
41
 
42
- // `aria-controls` really points at the panel. An id that has
43
- // drifted renders identically and announces nothing, which is why
44
- // `useRole` owns both ends of it.
42
+ // `aria-controls` really points at the panel across the portal
43
+ // boundary — the direct answer to the superseded top-layer
44
+ // decision's one real objection. An id that has drifted renders
45
+ // identically and announces nothing, which is why `useRole` owns
46
+ // both ends of it.
45
47
  await expect(trigger).toHaveAttribute(
46
48
  "aria-controls",
47
49
  panel.id,
48
50
  )
49
51
 
50
- // `manual`, not `auto`: the UA's light-dismiss closes the element
51
- // by itself and leaves `isVisible` true.
52
- await expect(panel).toHaveAttribute("popover", "manual")
53
-
54
- await expectNoAxeViolations(canvasElement)
52
+ // The open panel audits itself; the a11y addon's own pass fires on
53
+ // the canvas, which the panel has now left.
54
+ await expectNoAxeViolations(panel)
55
55
  })
56
56
 
57
57
  test("the panel is positioned rather than centred by the UA", async () => {
58
- const { canvas } = await mountStory(AllVariants)
58
+ const { body, canvas } = await mountStory(AllVariants)
59
59
 
60
60
  await userEvent.click(
61
61
  expectAgentDrivable(canvas, {
@@ -64,14 +64,14 @@ test("the panel is positioned rather than centred by the UA", async () => {
64
64
  }),
65
65
  )
66
66
 
67
- const panel = expectAgentDrivable(canvas, {
67
+ const panel = expectAgentDrivable(body, {
68
68
  name: "Filters — top",
69
69
  role: "dialog",
70
70
  })
71
71
 
72
- // The thing that silently fails if the UA's `inset: 0; margin:
73
- // auto` is left in place: the panel would sit dead centre of the
74
- // viewport looking deliberate.
72
+ // The thing that silently fails if a portalled panel is left at its
73
+ // default static position: it would sit at the top-left of the
74
+ // document rather than anchored to its trigger.
75
75
  const { left, top } = panel.getBoundingClientRect()
76
76
 
77
77
  await expect(left).toBeGreaterThan(0)
@@ -80,7 +80,7 @@ test("the panel is positioned rather than centred by the UA", async () => {
80
80
  })
81
81
 
82
82
  test("a panel with nothing tabbable inside is still reachable", async () => {
83
- const { canvas } = await mountStory(AllStates)
83
+ const { body, canvas } = await mountStory(AllStates)
84
84
 
85
85
  await userEvent.click(
86
86
  expectAgentDrivable(canvas, {
@@ -89,7 +89,7 @@ test("a panel with nothing tabbable inside is still reachable", async () => {
89
89
  }),
90
90
  )
91
91
 
92
- const panel = expectAgentDrivable(canvas, {
92
+ const panel = expectAgentDrivable(body, {
93
93
  name: "Read error detail",
94
94
  role: "dialog",
95
95
  })
@@ -108,7 +108,7 @@ test("a panel with nothing tabbable inside is still reachable", async () => {
108
108
  })
109
109
 
110
110
  test("flip and shift keep the panel inside the viewport", async () => {
111
- const { canvas } = await mountStory(Responsive)
111
+ const { body, canvas } = await mountStory(Responsive)
112
112
 
113
113
  await userEvent.click(
114
114
  expectAgentDrivable(canvas, {
@@ -117,7 +117,7 @@ test("flip and shift keep the panel inside the viewport", async () => {
117
117
  }),
118
118
  )
119
119
 
120
- const panel = expectAgentDrivable(canvas, {
120
+ const panel = expectAgentDrivable(body, {
121
121
  name: "Filters — top start",
122
122
  role: "dialog",
123
123
  })
@@ -145,13 +145,13 @@ test("flip and shift keep the panel inside the viewport", async () => {
145
145
  * through `useDismiss` → `onDismiss` → `hide()`, so the panel is
146
146
  * never closed by anything except the state saying so.
147
147
  *
148
- * Escape *is* pressable here, unlike `Modal`'s: `useDismiss` listens
149
- * for a keydown rather than relying on a UA default action, and a
150
- * synthetic keydown is a real keydown as far as a listener is
151
- * concerned.
148
+ * Escape *is* pressable here, unlike the old native-`<dialog>`
149
+ * `Modal`'s: `useDismiss` listens for a keydown rather than relying on
150
+ * a UA default action, and a synthetic keydown is a real keydown as
151
+ * far as a listener is concerned.
152
152
  */
153
153
  test("Escape and an outside press both dismiss through the state", async () => {
154
- const { canvas } = await mountStory(Interactive)
154
+ const { body, canvas } = await mountStory(Interactive)
155
155
 
156
156
  const trigger = expectAgentDrivable(canvas, {
157
157
  name: "Filters",
@@ -160,7 +160,7 @@ test("Escape and an outside press both dismiss through the state", async () => {
160
160
 
161
161
  await userEvent.click(trigger)
162
162
 
163
- expectAgentDrivable(canvas, {
163
+ expectAgentDrivable(body, {
164
164
  name: "Bay 3 filters",
165
165
  role: "dialog",
166
166
  })
@@ -168,7 +168,7 @@ test("Escape and an outside press both dismiss through the state", async () => {
168
168
  await userEvent.keyboard("{Escape}")
169
169
 
170
170
  await waitFor(() => {
171
- expect(canvas.queryByRole("dialog")).toBeNull()
171
+ expect(body.queryByRole("dialog")).toBeNull()
172
172
  })
173
173
 
174
174
  await expect(trigger).toHaveAttribute(
@@ -180,12 +180,48 @@ test("Escape and an outside press both dismiss through the state", async () => {
180
180
  await userEvent.click(trigger)
181
181
 
182
182
  await waitFor(() => {
183
- expect(canvas.getByRole("dialog")).toBeInTheDocument()
183
+ expect(body.getByRole("dialog")).toBeInTheDocument()
184
184
  })
185
185
 
186
186
  await userEvent.click(document.body)
187
187
 
188
188
  await waitFor(() => {
189
- expect(canvas.queryByRole("dialog")).toBeNull()
189
+ expect(body.queryByRole("dialog")).toBeNull()
190
+ })
191
+ })
192
+
193
+ /**
194
+ * The bug the whole M8 portal reversal exists to fix: the top layer was
195
+ * still clipped by an `overflow: hidden` ancestor, and a portal to
196
+ * `document.body` is not. The proof is structural — the panel is not a
197
+ * descendant of the clipping box at all.
198
+ */
199
+ test("the panel escapes an overflow:hidden ancestor", async () => {
200
+ const { body, canvas, canvasElement } = await mountStory(
201
+ EscapesOverflowClip,
202
+ )
203
+
204
+ const clipBox = canvasElement.querySelector(
205
+ ".overflow-hidden",
206
+ )
207
+
208
+ await userEvent.click(
209
+ expectAgentDrivable(canvas, {
210
+ name: "Open (escapes the clip)",
211
+ role: "button",
212
+ }),
213
+ )
214
+
215
+ const panel = expectAgentDrivable(body, {
216
+ name: "Escapes the clip",
217
+ role: "dialog",
190
218
  })
219
+
220
+ // Portalled out of the clipping subtree entirely — so nothing clips
221
+ // it — while the trigger stays inside it.
222
+ await expect(clipBox).not.toBeNull()
223
+
224
+ await expect(clipBox?.contains(panel)).toBe(false)
225
+
226
+ await expect(document.body.contains(panel)).toBe(true)
191
227
  })
@@ -1,18 +1,12 @@
1
- import { useClonedChild } from "@charcuterie/logic"
2
1
  import type { Placement } from "@floating-ui/react"
3
2
  import {
4
- autoUpdate,
5
3
  FloatingFocusManager,
6
- flip,
7
- offset,
8
- shift,
9
- useDismiss,
10
- useFloating,
11
- useInteractions,
12
- useRole,
4
+ FloatingPortal,
13
5
  } from "@floating-ui/react"
14
6
  import type { ReactElement, ReactNode } from "react"
15
7
 
8
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
9
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
16
10
  import { toClassName } from "../toClassName.ts"
17
11
 
18
12
  export type PopoverProps = {
@@ -51,32 +45,24 @@ export type PopoverProps = {
51
45
  *
52
46
  * What floating-ui is used for is what only it can do: collision
53
47
  * handling (`flip`, `shift`), a real dismiss layer, and focus
54
- * management. Positioning strategy, the top layer, and the ARIA
55
- * wiring come from the platform and from `useRole`.
48
+ * management — all of it consolidated in `useAnchoredOverlay`.
49
+ * Positioning strategy and the ARIA wiring come from the platform and
50
+ * from `useRole`.
56
51
  *
57
- * ### The top layer, from the platform — not a portal
52
+ * ### Portalled, not the top layer
58
53
  *
59
- * `popover="manual"` puts the panel in the top layer, which is
60
- * what a portal is normally reached for: no `z-index` contest, and
61
- * no `overflow: hidden` ancestor clipping it. Unlike a portal it
62
- * **stays where it is in the DOM**, so `canvas.getByRole("dialog")`
63
- * still finds it and a story keeps its scoping. Same argument as
64
- * `Modal`, one layer down.
65
- *
66
- * `manual`, not `auto`, and that is the state-ownership decision
67
- * again: `auto` brings the UA's own light-dismiss, which closes the
68
- * element by itself and leaves `isVisible` true. `useDismiss` does
69
- * the same job through our callback, so Charcuterie stays the only
70
- * thing that decides.
71
- *
72
- * ### Two UA defaults that have to be undone
73
- *
74
- * `[popover]` ships `position: fixed; inset: 0; margin: auto`,
75
- * which centres the panel in the viewport and fights every
76
- * coordinate floating-ui computes — hence `inset-auto m-0`, and
77
- * `strategy: "fixed"` so the numbers are viewport-relative like the
78
- * top layer itself. Getting either wrong reads as a positioning
79
- * bug rather than as a UA default.
54
+ * The panel goes in a `FloatingPortal` to `document.body`. The top
55
+ * layer (`popover="manual"`) was reached for first — it needs no
56
+ * portal and no `z-index` — but it does not escape an
57
+ * `overflow: hidden` ancestor's *clipping* in every case the fleet
58
+ * hit, and a portal does. The objection a portal used to raise, that
59
+ * it moves the panel out of a scoped `canvas` query, is answered two
60
+ * ways: `useRole` writes `aria-controls` on the trigger pointing at
61
+ * the panel `id`, so trigger→panel stays followable across the
62
+ * boundary; and the story scopes panel queries to
63
+ * `within(document.body)` while the structural `expectAgentDrivable`
64
+ * needs no change. `strategy: "fixed"` keeps the coordinates
65
+ * viewport-relative.
80
66
  */
81
67
  export const Popover = ({
82
68
  children,
@@ -87,34 +73,19 @@ export const Popover = ({
87
73
  placement = "bottom-start",
88
74
  trigger,
89
75
  }: PopoverProps): ReactNode => {
90
- const { context, floatingStyles, refs } = useFloating({
91
- // Read-only. Charcuterie remains the sole owner; floating-ui
92
- // is told what is true and never decides it.
93
- open: isVisible,
94
- onOpenChange: (isNextVisible) => {
95
- if (!isNextVisible) {
96
- onDismiss()
97
- }
98
- },
76
+ const {
77
+ clonedTrigger,
78
+ context,
79
+ floatingStyles,
80
+ getFloatingProps,
81
+ setFloating,
82
+ } = useAnchoredOverlay({
83
+ isVisible,
84
+ offsetValue: 8,
85
+ onDismiss,
99
86
  placement,
100
- middleware: [offset(8), flip(), shift({ padding: 8 })],
101
- strategy: "fixed",
102
- whileElementsMounted: autoUpdate,
103
- })
104
-
105
- // `useRole` writes `aria-expanded`, `aria-haspopup`, and
106
- // `aria-controls` on the trigger and `id` + `role="dialog"` on
107
- // the panel, from one source. Hand-writing them alongside is how
108
- // an `aria-controls` ends up pointing at an id that moved.
109
- const { getFloatingProps, getReferenceProps } =
110
- useInteractions([
111
- useDismiss(context),
112
- useRole(context, { role: "dialog" }),
113
- ])
114
-
115
- const clonedTrigger = useClonedChild(trigger, {
116
- ...getReferenceProps(),
117
- ref: refs.setReference,
87
+ role: "dialog",
88
+ trigger,
118
89
  })
119
90
 
120
91
  return (
@@ -122,42 +93,32 @@ export const Popover = ({
122
93
  {clonedTrigger}
123
94
 
124
95
  {isVisible ? (
125
- <FloatingFocusManager
126
- context={context}
127
- modal={false}
128
- >
129
- <div
130
- {...getFloatingProps()}
131
- aria-label={heading}
132
- className={toClassName(
133
- "inset-auto m-0 max-w-xs rounded-md border border-border-default bg-surface-overlay p-3 text-content-primary text-sm shadow-medium",
134
- className,
135
- )}
136
- popover="manual"
137
- // Duplicated from `getFloatingProps()` on purpose, and
138
- // the two cannot differ — `useRole(context, { role:
139
- // "dialog" })` above is where it comes from. Stated
140
- // here because a linter cannot see a role through a
141
- // spread, and neither can the next reader.
142
- role="dialog"
143
- ref={(node) => {
144
- refs.setFloating(node)
145
-
146
- // In a ref callback rather than an effect, because a
147
- // `[popover]` is `display: none` until it is shown —
148
- // and `FloatingFocusManager` is a *child*, so its
149
- // effect runs first and would be focusing into a
150
- // hidden element. Refs are set during commit, before
151
- // any of that.
152
- if (node && !node.matches(":popover-open")) {
153
- node.showPopover()
154
- }
155
- }}
156
- style={floatingStyles}
96
+ <FloatingPortal>
97
+ <FloatingFocusManager
98
+ context={context}
99
+ modal={false}
157
100
  >
158
- {children}
159
- </div>
160
- </FloatingFocusManager>
101
+ <div
102
+ {...getFloatingProps()}
103
+ aria-label={heading}
104
+ className={toClassName(
105
+ PANEL_SURFACE_CLASS,
106
+ "z-[var(--layer-modal)] max-w-xs p-3 text-content-primary text-sm",
107
+ className,
108
+ )}
109
+ ref={setFloating}
110
+ // Duplicated from `getFloatingProps()` on purpose, and
111
+ // the two cannot differ — `useRole(context, { role:
112
+ // "dialog" })` inside the hook is where it comes from.
113
+ // Stated here because a linter cannot see a role through
114
+ // a spread, and neither can the next reader.
115
+ role="dialog"
116
+ style={floatingStyles}
117
+ >
118
+ {children}
119
+ </div>
120
+ </FloatingFocusManager>
121
+ </FloatingPortal>
161
122
  ) : null}
162
123
  </>
163
124
  )
@@ -45,15 +45,18 @@ instead of falling out of it.** Here `useRole` writes `aria-describedby` on the
45
45
  An `IconButton` under a tooltip still carries its own `aria-label`. `AllVariants` proves the
46
46
  button stays findable by the same query whether or not the tip is open.
47
47
 
48
- ## No `popover` and no focus manager
49
-
50
- Unlike `Popover`, which is in the top layer and traps nothing. Focus never enters a tooltip
51
- — moving it there is what makes one inescapable — so there is no `FloatingFocusManager` and
52
- no `popover="manual"`.
53
-
54
- The trade is real and worth stating: a tooltip is therefore **not** in the top layer and can
55
- be clipped by an `overflow: hidden` ancestor. `strategy: "fixed"` keeps that to ancestors
56
- which actually clip fixed descendants.
48
+ ## Portalled, and no focus manager
49
+
50
+ The tip renders in a `FloatingPortal` at `document.body` — the same reversal as the other
51
+ overlays
52
+ ([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)):
53
+ `strategy: "fixed"` alone still let an `overflow: hidden` ancestor clip it, and a portal
54
+ does not. `--layer-tooltip` keeps it above a modal.
55
+
56
+ There is no `FloatingFocusManager`, unlike `Popover`. Focus never enters a tooltip — moving
57
+ it there is what would make one inescapable — so the tip stays out of the focus manager and
58
+ the overlay stack. A scoped query for the tip now reaches for `body`; `useRole` writes
59
+ `aria-describedby` on the trigger, so the link survives the boundary.
57
60
 
58
61
  <Canvas of={TooltipStories.AllStates} />
59
62
 
@@ -11,7 +11,7 @@ const { AllVariants, Default, Interactive } =
11
11
  composeStories(stories)
12
12
 
13
13
  test("nothing is shown until something asks", async () => {
14
- const { canvas, canvasElement } =
14
+ const { body, canvas, canvasElement } =
15
15
  await mountStory(Default)
16
16
 
17
17
  expectAgentDrivable(canvas, {
@@ -19,9 +19,9 @@ test("nothing is shown until something asks", async () => {
19
19
  role: "button",
20
20
  })
21
21
 
22
- await expect(
23
- canvas.queryAllByRole("tooltip"),
24
- ).toHaveLength(0)
22
+ await expect(body.queryAllByRole("tooltip")).toHaveLength(
23
+ 0,
24
+ )
25
25
 
26
26
  await expectNoAxeViolations(canvasElement)
27
27
  })
@@ -34,8 +34,7 @@ test("nothing is shown until something asks", async () => {
34
34
  * floating node nobody is pointed at.
35
35
  */
36
36
  test("the trigger is described by the tip that opened", async () => {
37
- const { canvas, canvasElement } =
38
- await mountStory(Interactive)
37
+ const { body, canvas } = await mountStory(Interactive)
39
38
 
40
39
  const trigger = expectAgentDrivable(canvas, {
41
40
  name: "Keyboard tip",
@@ -52,8 +51,10 @@ test("the trigger is described by the tip that opened", async () => {
52
51
  expect(trigger).toHaveFocus()
53
52
  })
54
53
 
54
+ // Portalled now, so found through `body`; the `aria-describedby`
55
+ // link survives the boundary.
55
56
  const tip = await waitFor(() =>
56
- expectAgentDrivable(canvas, { role: "tooltip" }),
57
+ expectAgentDrivable(body, { role: "tooltip" }),
57
58
  )
58
59
 
59
60
  await expect(trigger).toHaveAttribute(
@@ -65,10 +66,7 @@ test("the trigger is described by the tip that opened", async () => {
65
66
  "Opened by focus, closed by Escape.",
66
67
  )
67
68
 
68
- // Audited in the *driven* state, not at mount. The a11y addon
69
- // runs axe once, when `run()` resolves — before anything has been
70
- // focused — so an open tip would never be seen by it.
71
- await expectNoAxeViolations(canvasElement)
69
+ await expectNoAxeViolations(tip)
72
70
  })
73
71
 
74
72
  /**
@@ -77,28 +75,28 @@ test("the trigger is described by the tip that opened", async () => {
77
75
  * failure on the one control whose explanation lives there.
78
76
  */
79
77
  test("focus opens it and blur closes it", async () => {
80
- const { canvas } = await mountStory(Interactive)
78
+ const { body } = await mountStory(Interactive)
81
79
 
82
80
  await userEvent.tab()
83
81
 
84
82
  await waitFor(() => {
85
- expectAgentDrivable(canvas, { role: "tooltip" })
83
+ expectAgentDrivable(body, { role: "tooltip" })
86
84
  })
87
85
 
88
86
  await userEvent.tab()
89
87
 
90
88
  await waitFor(() => {
91
- expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
89
+ expect(body.queryAllByRole("tooltip")).toHaveLength(0)
92
90
  })
93
91
  })
94
92
 
95
93
  test("Escape dismisses it", async () => {
96
- const { canvas } = await mountStory(Interactive)
94
+ const { body } = await mountStory(Interactive)
97
95
 
98
96
  await userEvent.tab()
99
97
 
100
98
  await waitFor(() => {
101
- expectAgentDrivable(canvas, { role: "tooltip" })
99
+ expectAgentDrivable(body, { role: "tooltip" })
102
100
  })
103
101
 
104
102
  // WCAG 1.4.13 requires content shown on hover or focus to be
@@ -106,7 +104,7 @@ test("Escape dismisses it", async () => {
106
104
  await userEvent.keyboard("{Escape}")
107
105
 
108
106
  await waitFor(() => {
109
- expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
107
+ expect(body.queryAllByRole("tooltip")).toHaveLength(0)
110
108
  })
111
109
  })
112
110
 
@@ -117,7 +115,7 @@ test("Escape dismisses it", async () => {
117
115
  * whether or not the tip is open.
118
116
  */
119
117
  test("the tip does not become the control's name", async () => {
120
- const { canvas } = await mountStory(AllVariants)
118
+ const { body, canvas } = await mountStory(AllVariants)
121
119
 
122
120
  const iconButton = expectAgentDrivable(canvas, {
123
121
  name: "Bay settings",
@@ -127,7 +125,7 @@ test("the tip does not become the control's name", async () => {
127
125
  await userEvent.hover(iconButton)
128
126
 
129
127
  await waitFor(() => {
130
- expectAgentDrivable(canvas, { role: "tooltip" })
128
+ expectAgentDrivable(body, { role: "tooltip" })
131
129
  })
132
130
 
133
131
  // Still "Bay settings", not "Opens the bay's rip settings."
@@ -5,6 +5,7 @@ import {
5
5
  import type { Placement } from "@floating-ui/react"
6
6
  import {
7
7
  autoUpdate,
8
+ FloatingPortal,
8
9
  flip,
9
10
  offset,
10
11
  shift,
@@ -155,38 +156,40 @@ export const Tooltip = ({
155
156
  {clonedTrigger}
156
157
 
157
158
  {isVisible ? (
158
- <div
159
- {...getFloatingProps()}
160
- className={toClassName(
161
- // `intent-neutral-solid` + its own `on-solid`, rather
162
- // than `surface-inverse` + `content-on-accent`. The
163
- // second pair looks like the obvious one and is not a
164
- // *pair* at all — nothing in the token set promises those
165
- // two contrast, and in `daylight` dark they land at
166
- // 2.9:1. The `solid`/`on-solid` roles exist precisely to
167
- // be a guaranteed couple, and the contrast gate checks
168
- // them as one.
169
- "inset-auto m-0 max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
170
- className,
171
- )}
172
- // No `popover="manual"` and no `FloatingFocusManager`,
173
- // unlike `Popover`. Both exist to manage focus, and focus
174
- // never enters a tooltip — moving it here is what makes
175
- // one unescapable. The trade is that a tooltip is not in
176
- // the top layer, so it can be clipped by an
177
- // `overflow: hidden` ancestor; `strategy: "fixed"` keeps
178
- // that to ancestors that actually clip fixed descendants.
179
- ref={refs.setFloating}
180
- // Duplicated from `getFloatingProps()` deliberately, and
181
- // the two cannot differ — `useRole(context, { role:
182
- // "tooltip" })` above is where it comes from. Stated here
183
- // because a linter cannot see a role through a spread, and
184
- // neither can the next reader.
185
- role="tooltip"
186
- style={floatingStyles}
187
- >
188
- {label}
189
- </div>
159
+ <FloatingPortal>
160
+ <div
161
+ {...getFloatingProps()}
162
+ className={toClassName(
163
+ // `intent-neutral-solid` + its own `on-solid`, rather
164
+ // than `surface-inverse` + `content-on-accent`. The
165
+ // second pair looks like the obvious one and is not a
166
+ // *pair* at all — nothing in the token set promises those
167
+ // two contrast, and in `daylight` dark they land at
168
+ // 2.9:1. The `solid`/`on-solid` roles exist precisely to
169
+ // be a guaranteed couple, and the contrast gate checks
170
+ // them as one.
171
+ "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
172
+ className,
173
+ )}
174
+ // Portalled to `document.body`, the same reversal as the
175
+ // other overlays: `strategy: "fixed"` alone still let an
176
+ // `overflow: hidden` ancestor clip the tip, and a portal
177
+ // does not. No `FloatingFocusManager`, unlike `Popover` —
178
+ // focus never enters a tooltip, and moving it here is what
179
+ // would make one unescapable. `--layer-tooltip` keeps it
180
+ // above a modal.
181
+ ref={refs.setFloating}
182
+ // Duplicated from `getFloatingProps()` deliberately, and
183
+ // the two cannot differ — `useRole(context, { role:
184
+ // "tooltip" })` above is where it comes from. Stated here
185
+ // because a linter cannot see a role through a spread, and
186
+ // neither can the next reader.
187
+ role="tooltip"
188
+ style={floatingStyles}
189
+ >
190
+ {label}
191
+ </div>
192
+ </FloatingPortal>
190
193
  ) : null}
191
194
  </>
192
195
  )