@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.
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +47 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +7 -7
- package/dist/Lightbox/Lightbox.js +9 -9
- package/dist/Lightbox/Lightbox.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -2
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +9 -8
- package/src/Lightbox/Lightbox.stories.tsx +5 -5
- package/src/Lightbox/Lightbox.test.tsx +25 -36
- package/src/Lightbox/Lightbox.tsx +10 -10
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/index.ts +13 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +12 -3
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /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 {
|
|
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 `
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const panel = expectAgentDrivable(
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
await
|
|
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(
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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(
|
|
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(
|
|
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
|
|
149
|
-
* for a keydown rather than relying on
|
|
150
|
-
* synthetic keydown is a real keydown as
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
})
|
package/src/Popover/Popover.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
55
|
-
* wiring come from the platform and
|
|
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
|
-
* ###
|
|
52
|
+
* ### Portalled, not the top layer
|
|
58
53
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* no `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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 {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
)
|
package/src/Tooltip/Tooltip.mdx
CHANGED
|
@@ -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
|
-
##
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
24
|
-
)
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
78
|
+
const { body } = await mountStory(Interactive)
|
|
81
79
|
|
|
82
80
|
await userEvent.tab()
|
|
83
81
|
|
|
84
82
|
await waitFor(() => {
|
|
85
|
-
expectAgentDrivable(
|
|
83
|
+
expectAgentDrivable(body, { role: "tooltip" })
|
|
86
84
|
})
|
|
87
85
|
|
|
88
86
|
await userEvent.tab()
|
|
89
87
|
|
|
90
88
|
await waitFor(() => {
|
|
91
|
-
expect(
|
|
89
|
+
expect(body.queryAllByRole("tooltip")).toHaveLength(0)
|
|
92
90
|
})
|
|
93
91
|
})
|
|
94
92
|
|
|
95
93
|
test("Escape dismisses it", async () => {
|
|
96
|
-
const {
|
|
94
|
+
const { body } = await mountStory(Interactive)
|
|
97
95
|
|
|
98
96
|
await userEvent.tab()
|
|
99
97
|
|
|
100
98
|
await waitFor(() => {
|
|
101
|
-
expectAgentDrivable(
|
|
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(
|
|
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(
|
|
128
|
+
expectAgentDrivable(body, { role: "tooltip" })
|
|
131
129
|
})
|
|
132
130
|
|
|
133
131
|
// Still "Bay settings", not "Opens the bay's rip settings."
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -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
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
)
|