@charcuterie/ui 1.0.1 → 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/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- 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 +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- 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 +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- 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 +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- 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/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- 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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useClonedChild,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { Placement } from "@floating-ui/react"
|
|
6
|
+
import {
|
|
7
|
+
autoUpdate,
|
|
8
|
+
flip,
|
|
9
|
+
offset,
|
|
10
|
+
shift,
|
|
11
|
+
size,
|
|
12
|
+
useDismiss,
|
|
13
|
+
useFloating,
|
|
14
|
+
useInteractions,
|
|
15
|
+
useRole,
|
|
16
|
+
} from "@floating-ui/react"
|
|
17
|
+
import type { ReactElement } from "react"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The floating-ui block every anchored overlay — `Popover`, `Menu`,
|
|
21
|
+
* `Listbox`, `Combobox` — wrote out for itself, consolidated once.
|
|
22
|
+
*
|
|
23
|
+
* `Popover` and `Menu` had the same twelve-line `useFloating` +
|
|
24
|
+
* `useInteractions` + `useClonedChild` preamble with two constants
|
|
25
|
+
* different (the offset and the role), and the fleet's four pickers
|
|
26
|
+
* hand-rolled a fifth, sixth, seventh and eighth `computePosition`
|
|
27
|
+
* that reimplemented `flip`/`shift` against `window.innerHeight`.
|
|
28
|
+
* This is the one implementation they all take.
|
|
29
|
+
*
|
|
30
|
+
* ### The owner's "configurable, but those are the basics"
|
|
31
|
+
*
|
|
32
|
+
* Escape and outside-press both close, **on by default**, for every
|
|
33
|
+
* anchored overlay — that is what an overlay is. A component switches
|
|
34
|
+
* one off through `dismiss` (a `Combobox`'s Escape clears the query
|
|
35
|
+
* before it closes; a menu opened from a context click wants no
|
|
36
|
+
* outside-press race with the thing that spawned it), but it opts out
|
|
37
|
+
* rather than opting in.
|
|
38
|
+
*
|
|
39
|
+
* ### `size`, for the pickers
|
|
40
|
+
*
|
|
41
|
+
* `matchTriggerWidth` and `maxHeightPx` are the two things
|
|
42
|
+
* `PortalDropdown` did with a hand-measured `TriggerRect` and a
|
|
43
|
+
* `window.innerHeight` clamp. The `size` middleware does both from the
|
|
44
|
+
* platform: the panel takes the trigger's width, and it never grows
|
|
45
|
+
* past the space actually left in the viewport.
|
|
46
|
+
*
|
|
47
|
+
* The computed width/height are written **straight onto the floating
|
|
48
|
+
* element's style** rather than lifted into React state, which is
|
|
49
|
+
* floating-ui's own documented shape and is deliberate twice over: a
|
|
50
|
+
* `setState` inside `apply` re-runs the position, which re-runs
|
|
51
|
+
* `apply`, which is a render loop; and because these properties never
|
|
52
|
+
* enter the `floatingStyles` object React manages, a component
|
|
53
|
+
* re-render (a keystroke in a combobox) does not wipe them — only an
|
|
54
|
+
* `autoUpdate` tick recomputes them.
|
|
55
|
+
*
|
|
56
|
+
* ### Portal-agnostic
|
|
57
|
+
*
|
|
58
|
+
* This returns the pieces — `clonedTrigger`, `getFloatingProps`,
|
|
59
|
+
* `setFloating`, `floatingStyles`, `context` — and renders nothing.
|
|
60
|
+
* Whether the panel goes in a `FloatingPortal`, the top layer, or in
|
|
61
|
+
* place is the component's call, which is what lets the same hook back
|
|
62
|
+
* a portalled `Listbox` and (for now) a top-layer `Popover`.
|
|
63
|
+
*/
|
|
64
|
+
export type AnchoredOverlayRole =
|
|
65
|
+
| "dialog"
|
|
66
|
+
| "listbox"
|
|
67
|
+
| "menu"
|
|
68
|
+
|
|
69
|
+
export type UseAnchoredOverlayOptions = {
|
|
70
|
+
/**
|
|
71
|
+
* Both default **on** — Escape and outside-press are what makes an
|
|
72
|
+
* overlay an overlay. Switch one off for the honest exception (a
|
|
73
|
+
* combobox that swallows the first Escape to clear its query), never
|
|
74
|
+
* to make a plain overlay sticky. Named for the flattened
|
|
75
|
+
* floating-ui `useDismiss` options they carry.
|
|
76
|
+
*/
|
|
77
|
+
isEscapeDismissable?: boolean
|
|
78
|
+
isOutsidePressDismissable?: boolean
|
|
79
|
+
/** Ports `PortalDropdown`'s anchor-width match, via `size`. */
|
|
80
|
+
isTriggerWidthMatched?: boolean
|
|
81
|
+
isVisible: boolean
|
|
82
|
+
/** Clamps the panel to `min(this, available viewport space)`. */
|
|
83
|
+
maxHeightPx?: number
|
|
84
|
+
/** Popover 8, Menu/Listbox/Combobox 4. */
|
|
85
|
+
offsetValue?: number
|
|
86
|
+
onDismiss: () => void
|
|
87
|
+
placement?: Placement
|
|
88
|
+
role: AnchoredOverlayRole
|
|
89
|
+
/** The control the panel hangs off. **Cloned, not wrapped.** */
|
|
90
|
+
trigger: ReactElement
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const useAnchoredOverlay = ({
|
|
94
|
+
isEscapeDismissable = true,
|
|
95
|
+
isOutsidePressDismissable = true,
|
|
96
|
+
isTriggerWidthMatched = false,
|
|
97
|
+
isVisible,
|
|
98
|
+
maxHeightPx,
|
|
99
|
+
offsetValue = 8,
|
|
100
|
+
onDismiss,
|
|
101
|
+
placement = "bottom-start",
|
|
102
|
+
role,
|
|
103
|
+
trigger,
|
|
104
|
+
}: UseAnchoredOverlayOptions) => {
|
|
105
|
+
const middleware = [
|
|
106
|
+
offset(offsetValue),
|
|
107
|
+
flip(),
|
|
108
|
+
shift({ padding: 8 }),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
if (isTriggerWidthMatched || maxHeightPx !== undefined) {
|
|
112
|
+
middleware.push(
|
|
113
|
+
size({
|
|
114
|
+
padding: 8,
|
|
115
|
+
apply({ availableHeight, elements, rects }) {
|
|
116
|
+
const cap =
|
|
117
|
+
maxHeightPx === undefined
|
|
118
|
+
? availableHeight
|
|
119
|
+
: Math.min(maxHeightPx, availableHeight)
|
|
120
|
+
|
|
121
|
+
elements.floating.style.maxHeight = `${cap}px`
|
|
122
|
+
|
|
123
|
+
if (isTriggerWidthMatched) {
|
|
124
|
+
elements.floating.style.width = `${rects.reference.width}px`
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { context, floatingStyles, refs } = useFloating({
|
|
132
|
+
// Read-only. Charcuterie remains the sole owner; floating-ui is
|
|
133
|
+
// told what is true and never decides it.
|
|
134
|
+
open: isVisible,
|
|
135
|
+
onOpenChange: (isNextVisible) => {
|
|
136
|
+
if (!isNextVisible) {
|
|
137
|
+
onDismiss()
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
placement,
|
|
141
|
+
middleware,
|
|
142
|
+
strategy: "fixed",
|
|
143
|
+
whileElementsMounted: autoUpdate,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// `useRole` writes `aria-expanded`/`aria-haspopup`/`aria-controls`
|
|
147
|
+
// on the trigger and `id` + `role` on the panel from one source —
|
|
148
|
+
// which is what keeps `aria-controls` pointing at the panel's real
|
|
149
|
+
// id across the portal boundary, the objection the top-layer
|
|
150
|
+
// decision raised and this answers.
|
|
151
|
+
const { getFloatingProps, getReferenceProps } =
|
|
152
|
+
useInteractions([
|
|
153
|
+
useDismiss(context, {
|
|
154
|
+
escapeKey: isEscapeDismissable,
|
|
155
|
+
outsidePress: isOutsidePressDismissable,
|
|
156
|
+
}),
|
|
157
|
+
useRole(context, { role }),
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
// `useRole` for a `listbox` overwrites the trigger's own role with
|
|
161
|
+
// `combobox` — the select-only-combobox pattern, which then demands
|
|
162
|
+
// its own `aria-label`. The fleet's pattern is a plain button that
|
|
163
|
+
// *opens* a listbox, so the injected `role` is dropped and the
|
|
164
|
+
// trigger keeps its native semantics while still gaining
|
|
165
|
+
// `aria-haspopup="listbox"`/`aria-expanded`/`aria-controls`. `useRole`
|
|
166
|
+
// injects a reference `role` only for listbox/combobox, so this is a
|
|
167
|
+
// no-op for `Popover` (dialog) and `Menu` (menu).
|
|
168
|
+
const { role: _discardedTriggerRole, ...referenceProps } =
|
|
169
|
+
getReferenceProps()
|
|
170
|
+
|
|
171
|
+
// A stable id on the trigger, so a panel that needs a name can point
|
|
172
|
+
// `aria-labelledby` at it across the portal. `useRole` already gives
|
|
173
|
+
// a `menu`'s reference an id (and labels the panel with it); this
|
|
174
|
+
// reuses that when present and supplies one otherwise — which is what
|
|
175
|
+
// a `listbox` panel needs, since a bare `role="listbox"` is an ARIA
|
|
176
|
+
// input field and must be named.
|
|
177
|
+
const generatedTriggerId = useUniqueId()
|
|
178
|
+
|
|
179
|
+
const triggerId =
|
|
180
|
+
(referenceProps.id as string | undefined) ??
|
|
181
|
+
generatedTriggerId
|
|
182
|
+
|
|
183
|
+
const clonedTrigger = useClonedChild(trigger, {
|
|
184
|
+
...referenceProps,
|
|
185
|
+
id: triggerId,
|
|
186
|
+
ref: refs.setReference,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
clonedTrigger,
|
|
191
|
+
context,
|
|
192
|
+
floatingStyles,
|
|
193
|
+
getFloatingProps,
|
|
194
|
+
setFloating: refs.setFloating,
|
|
195
|
+
triggerId,
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/Popover/Popover.mdx
CHANGED
|
@@ -32,35 +32,32 @@ useFloating({
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
It is used for the three things only it can do — collision handling, a real dismiss layer,
|
|
35
|
-
and focus management
|
|
36
|
-
from the platform and from `useRole`.
|
|
35
|
+
and focus management — all consolidated in `useAnchoredOverlay`. The positioning strategy
|
|
36
|
+
and the ARIA wiring come from the platform and from `useRole`.
|
|
37
37
|
|
|
38
38
|
Passing `open:` *into* floating-ui does not violate the `is`/`has` rule; the adapter
|
|
39
39
|
boundary is exactly the carve-out that rule already has
|
|
40
40
|
([decision](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)).
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## Portalled, not the top layer
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
The panel renders in a `FloatingPortal` at `document.body`. `popover="manual"` was reached
|
|
45
|
+
for first — no `z-index` contest, no portal — but the top layer does not escape an
|
|
46
|
+
`overflow: hidden` ancestor's *clipping* in every case the fleet hit, and a portal does
|
|
47
|
+
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)).
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
> `Modal`, one layer down.
|
|
49
|
+
The objection a portal used to raise — that it moves the panel out of a scoped `canvas`
|
|
50
|
+
query — is answered:
|
|
51
51
|
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
> `useRole` writes `aria-controls` on the trigger pointing at the panel `id`, so
|
|
53
|
+
> trigger→panel stays followable across the boundary (the test asserts it). And the story
|
|
54
|
+
> scopes panel queries to `within(document.body)` while `expectAgentDrivable` — structural
|
|
55
|
+
> — needs no change.
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
deliberate, which is the worst kind of bug. `inset-auto m-0` puts it back, and
|
|
62
|
-
`strategy: "fixed"` keeps the numbers viewport-relative like the top layer itself.
|
|
63
|
-
`AllVariants` asserts the panel is positioned at all, for exactly this reason.
|
|
57
|
+
`useDismiss` owns Escape and outside-press through our callback, so Charcuterie stays the
|
|
58
|
+
only thing that decides `isVisible`. `strategy: "fixed"` keeps the coordinates
|
|
59
|
+
viewport-relative; `AllVariants` asserts the panel is positioned at all, for exactly this
|
|
60
|
+
reason.
|
|
64
61
|
|
|
65
62
|
<Canvas of={PopoverStories.AllVariants} />
|
|
66
63
|
|
|
@@ -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
|
)
|