@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,104 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useEffect } from "react"
|
|
3
|
+
|
|
4
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import type { ListboxItem } from "./Listbox.tsx"
|
|
7
|
+
|
|
8
|
+
export type ListboxOptionProps = {
|
|
9
|
+
isSelected: boolean
|
|
10
|
+
item: ListboxItem
|
|
11
|
+
onSelect: (value: string) => void
|
|
12
|
+
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
13
|
+
registerFocus: (value: string) => () => void
|
|
14
|
+
/** `SinglePicker.register` — membership of the choice. */
|
|
15
|
+
registerSelection: (value: string) => () => void
|
|
16
|
+
tabIndex: number
|
|
17
|
+
trackElement: (
|
|
18
|
+
value: string,
|
|
19
|
+
element: HTMLButtonElement | null,
|
|
20
|
+
) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Its own file for the same reason as `MenuAction` and `TabTrigger`:
|
|
25
|
+
* **both registrations are effects**, and an effect cannot run in a
|
|
26
|
+
* loop.
|
|
27
|
+
*
|
|
28
|
+
* The two registrations are separate on purpose, exactly as
|
|
29
|
+
* `TabTrigger` splits them. A disabled option joins the *choice* — it
|
|
30
|
+
* can still be the seeded `selectedValue` and still announces
|
|
31
|
+
* `aria-selected` — and stays out of the *focus* group, so the arrow
|
|
32
|
+
* keys and type-ahead skip it without `RovingFocus` having to know
|
|
33
|
+
* what "disabled" means.
|
|
34
|
+
*
|
|
35
|
+
* ### `option`, not `menuitem`
|
|
36
|
+
*
|
|
37
|
+
* An `option` **is** something — the value being chosen — where a
|
|
38
|
+
* `menuitem` **does** something. So this carries `aria-selected`
|
|
39
|
+
* (which a menuitem must not), and a screen reader announces
|
|
40
|
+
* "listbox, selected, 2 of 4" rather than "menu, 4 items". It is a
|
|
41
|
+
* `<button>` with `role="option"` so it is natively focusable for the
|
|
42
|
+
* roving-tabindex move, the same shape `Menu` uses.
|
|
43
|
+
*/
|
|
44
|
+
export const ListboxOption = ({
|
|
45
|
+
isSelected,
|
|
46
|
+
item,
|
|
47
|
+
onSelect,
|
|
48
|
+
registerFocus,
|
|
49
|
+
registerSelection,
|
|
50
|
+
tabIndex,
|
|
51
|
+
trackElement,
|
|
52
|
+
}: ListboxOptionProps): ReactNode => {
|
|
53
|
+
const { isDisabled = false, label, value } = item
|
|
54
|
+
|
|
55
|
+
useEffect(
|
|
56
|
+
() => registerSelection(value),
|
|
57
|
+
[registerSelection, value],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (isDisabled) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return registerFocus(value)
|
|
66
|
+
}, [isDisabled, registerFocus, value])
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
aria-selected={isSelected}
|
|
71
|
+
className={toClassName(
|
|
72
|
+
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
|
|
73
|
+
"hover:bg-intent-neutral-surface",
|
|
74
|
+
isSelected && "bg-intent-accent-surface",
|
|
75
|
+
isDisabled &&
|
|
76
|
+
"cursor-not-allowed text-content-disabled hover:bg-transparent",
|
|
77
|
+
FOCUS_RING_CLASS,
|
|
78
|
+
)}
|
|
79
|
+
disabled={isDisabled}
|
|
80
|
+
onClick={() => {
|
|
81
|
+
onSelect(value)
|
|
82
|
+
}}
|
|
83
|
+
ref={(element) => {
|
|
84
|
+
trackElement(value, element)
|
|
85
|
+
}}
|
|
86
|
+
role="option"
|
|
87
|
+
tabIndex={tabIndex}
|
|
88
|
+
type="button"
|
|
89
|
+
>
|
|
90
|
+
{label}
|
|
91
|
+
|
|
92
|
+
{isSelected ? (
|
|
93
|
+
<span
|
|
94
|
+
// Decoration: the announced state is `aria-selected`, so the
|
|
95
|
+
// check must not also read out.
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
className="shrink-0 text-intent-accent-content"
|
|
98
|
+
>
|
|
99
|
+
✓
|
|
100
|
+
</span>
|
|
101
|
+
) : null}
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
}
|
package/src/Menu/Menu.mdx
CHANGED
|
@@ -29,19 +29,17 @@ The last row is why it matters to an agent driving the app: after choosing an `o
|
|
|
29
29
|
needs to know what the control now holds. After invoking a `menuitem` there is nothing to
|
|
30
30
|
read back. Getting the role wrong is invisible in a browser and invisible to axe.
|
|
31
31
|
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
The panel
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
The effect is the backstop, because effects run after the tree is connected. Both are
|
|
44
|
-
idempotent: `:popover-open` is the same question.
|
|
32
|
+
## Portalled, and no `[popover]` machinery
|
|
33
|
+
|
|
34
|
+
The panel renders in a `FloatingPortal` at `document.body`, through the same
|
|
35
|
+
`useAnchoredOverlay` `Popover` uses
|
|
36
|
+
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)).
|
|
37
|
+
A portalled node is visible when mounted, so the whole `popover="manual"` /
|
|
38
|
+
`showPopover()` / `isConnected`-guard class of "a `[popover]` that was never shown is
|
|
39
|
+
`display: none`" bug is simply gone. The menu keeps its roving focus, its arrow/Home/End
|
|
40
|
+
keymap, its seed-first-item-on-open effect, and its `MenuAction` items. A scoped query for
|
|
41
|
+
the panel now reaches for `body`; `useRole` names it by pointing `aria-labelledby` at the
|
|
42
|
+
trigger, so the link survives the boundary.
|
|
45
43
|
|
|
46
44
|
## There is no `label` prop, and that corrects a lesson
|
|
47
45
|
|
package/src/Menu/Menu.test.tsx
CHANGED
|
@@ -15,8 +15,7 @@ const {
|
|
|
15
15
|
} = composeStories(stories)
|
|
16
16
|
|
|
17
17
|
test("the menu is named, and its items are menuitems", async () => {
|
|
18
|
-
const {
|
|
19
|
-
await mountStory(AllStates)
|
|
18
|
+
const { body } = await mountStory(AllStates)
|
|
20
19
|
|
|
21
20
|
// **Named by its trigger**, not by a `label` prop. `useRole` puts
|
|
22
21
|
// `aria-labelledby` on the panel pointing at the button, and
|
|
@@ -27,17 +26,17 @@ test("the menu is named, and its items are menuitems", async () => {
|
|
|
27
26
|
// overlay with **no trigger relationship** (`Spinner`'s
|
|
28
27
|
// `role="status"`, `Popover`'s `role="dialog"`) needs its own
|
|
29
28
|
// name. A menu has one.
|
|
30
|
-
expectAgentDrivable(
|
|
29
|
+
const menu = expectAgentDrivable(body, {
|
|
31
30
|
name: "Bay 3",
|
|
32
31
|
role: "menu",
|
|
33
32
|
})
|
|
34
33
|
|
|
35
|
-
expectAgentDrivable(
|
|
34
|
+
expectAgentDrivable(body, {
|
|
36
35
|
name: "Retry title",
|
|
37
36
|
role: "menuitem",
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
await expectNoAxeViolations(
|
|
39
|
+
await expectNoAxeViolations(menu)
|
|
41
40
|
})
|
|
42
41
|
|
|
43
42
|
/**
|
|
@@ -46,7 +45,7 @@ test("the menu is named, and its items are menuitems", async () => {
|
|
|
46
45
|
* still on the trigger, with a menu they cannot reach.
|
|
47
46
|
*/
|
|
48
47
|
test("opening moves focus into the menu", async () => {
|
|
49
|
-
const { canvas } = await mountStory(Interactive)
|
|
48
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
50
49
|
|
|
51
50
|
await userEvent.click(
|
|
52
51
|
expectAgentDrivable(canvas, {
|
|
@@ -57,7 +56,7 @@ test("opening moves focus into the menu", async () => {
|
|
|
57
56
|
|
|
58
57
|
await waitFor(() => {
|
|
59
58
|
expect(
|
|
60
|
-
expectAgentDrivable(
|
|
59
|
+
expectAgentDrivable(body, {
|
|
61
60
|
name: "Retry title",
|
|
62
61
|
role: "menuitem",
|
|
63
62
|
}),
|
|
@@ -66,14 +65,14 @@ test("opening moves focus into the menu", async () => {
|
|
|
66
65
|
})
|
|
67
66
|
|
|
68
67
|
test("the arrow keys move within the menu and skip the disabled item", async () => {
|
|
69
|
-
const {
|
|
68
|
+
const { body } = await mountStory(AllStates)
|
|
70
69
|
|
|
71
|
-
const skip = expectAgentDrivable(
|
|
70
|
+
const skip = expectAgentDrivable(body, {
|
|
72
71
|
name: "Skip title",
|
|
73
72
|
role: "menuitem",
|
|
74
73
|
})
|
|
75
74
|
|
|
76
|
-
const eject = expectAgentDrivable(
|
|
75
|
+
const eject = expectAgentDrivable(body, {
|
|
77
76
|
name: "Eject disc",
|
|
78
77
|
role: "menuitem",
|
|
79
78
|
})
|
|
@@ -84,7 +83,7 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
|
|
|
84
83
|
// clicking an item to "focus" it closes the thing under test. This
|
|
85
84
|
// story opens already visible, and opening is what puts focus on
|
|
86
85
|
// the first item.
|
|
87
|
-
const retry = expectAgentDrivable(
|
|
86
|
+
const retry = expectAgentDrivable(body, {
|
|
88
87
|
name: "Retry title",
|
|
89
88
|
role: "menuitem",
|
|
90
89
|
})
|
|
@@ -118,9 +117,9 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
|
|
|
118
117
|
* that is the pattern, not a bug.
|
|
119
118
|
*/
|
|
120
119
|
test("the menu has one tab stop", async () => {
|
|
121
|
-
const {
|
|
120
|
+
const { body } = await mountStory(AllStates)
|
|
122
121
|
|
|
123
|
-
const menu = expectAgentDrivable(
|
|
122
|
+
const menu = expectAgentDrivable(body, {
|
|
124
123
|
name: "Bay 3",
|
|
125
124
|
role: "menu",
|
|
126
125
|
})
|
|
@@ -135,7 +134,7 @@ test("the menu has one tab stop", async () => {
|
|
|
135
134
|
})
|
|
136
135
|
|
|
137
136
|
test("choosing an item dismisses the menu", async () => {
|
|
138
|
-
const { canvas } = await mountStory(Interactive)
|
|
137
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
139
138
|
|
|
140
139
|
await userEvent.click(
|
|
141
140
|
expectAgentDrivable(canvas, {
|
|
@@ -145,7 +144,7 @@ test("choosing an item dismisses the menu", async () => {
|
|
|
145
144
|
)
|
|
146
145
|
|
|
147
146
|
await userEvent.click(
|
|
148
|
-
expectAgentDrivable(
|
|
147
|
+
expectAgentDrivable(body, {
|
|
149
148
|
name: "Retry title",
|
|
150
149
|
role: "menuitem",
|
|
151
150
|
}),
|
|
@@ -154,14 +153,12 @@ test("choosing an item dismisses the menu", async () => {
|
|
|
154
153
|
// A menu that stays open after an action has fired is the one
|
|
155
154
|
// interaction users read as "it didn't work".
|
|
156
155
|
await waitFor(() => {
|
|
157
|
-
expect(
|
|
158
|
-
0,
|
|
159
|
-
)
|
|
156
|
+
expect(body.queryAllByRole("menuitem")).toHaveLength(0)
|
|
160
157
|
})
|
|
161
158
|
})
|
|
162
159
|
|
|
163
160
|
test("Escape dismisses without choosing", async () => {
|
|
164
|
-
const { canvas } = await mountStory(Interactive)
|
|
161
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
165
162
|
|
|
166
163
|
await userEvent.click(
|
|
167
164
|
expectAgentDrivable(canvas, {
|
|
@@ -172,37 +169,50 @@ test("Escape dismisses without choosing", async () => {
|
|
|
172
169
|
|
|
173
170
|
await waitFor(() => {
|
|
174
171
|
expect(
|
|
175
|
-
|
|
172
|
+
body.queryAllByRole("menuitem").length,
|
|
176
173
|
).toBeGreaterThan(0)
|
|
177
174
|
})
|
|
178
175
|
|
|
179
176
|
await userEvent.keyboard("{Escape}")
|
|
180
177
|
|
|
181
178
|
await waitFor(() => {
|
|
182
|
-
expect(
|
|
183
|
-
0,
|
|
184
|
-
)
|
|
179
|
+
expect(body.queryAllByRole("menuitem")).toHaveLength(0)
|
|
185
180
|
})
|
|
186
181
|
})
|
|
187
182
|
|
|
188
183
|
/**
|
|
189
|
-
* The panel
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
184
|
+
* The panel portals to `document.body` — escaping any
|
|
185
|
+
* `overflow: hidden` ancestor — and the trigger→panel link survives
|
|
186
|
+
* the boundary: `useRole` writes `aria-labelledby` on the menu
|
|
187
|
+
* pointing back at the button that names it. A scoped query for the
|
|
188
|
+
* panel now reaches for `body`; the trigger stays in the canvas.
|
|
193
189
|
*/
|
|
194
|
-
test("the menu
|
|
195
|
-
const { canvas, canvasElement } =
|
|
190
|
+
test("the menu portals to the body, still named by its trigger", async () => {
|
|
191
|
+
const { body, canvas, canvasElement } =
|
|
196
192
|
await mountStory(AllVariants)
|
|
197
193
|
|
|
198
|
-
const
|
|
194
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
195
|
+
name: "Bay 2",
|
|
196
|
+
role: "button",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const menu = expectAgentDrivable(body, {
|
|
199
200
|
name: "Bay 2",
|
|
200
201
|
role: "menu",
|
|
201
202
|
})
|
|
202
203
|
|
|
203
|
-
await expect(canvasElement).toContainElement(menu)
|
|
204
|
+
await expect(canvasElement).not.toContainElement(menu)
|
|
205
|
+
|
|
206
|
+
await expect(document.body).toContainElement(menu)
|
|
207
|
+
|
|
208
|
+
// The link, across the portal: the menu is named by pointing at its
|
|
209
|
+
// trigger, and the id really resolves.
|
|
210
|
+
await expect(menu).toHaveAttribute(
|
|
211
|
+
"aria-labelledby",
|
|
212
|
+
trigger.id,
|
|
213
|
+
)
|
|
204
214
|
|
|
205
|
-
await expectNoAxeViolations(
|
|
215
|
+
await expectNoAxeViolations(menu)
|
|
206
216
|
})
|
|
207
217
|
|
|
208
218
|
/**
|
|
@@ -239,8 +249,7 @@ const expectAnchoredTrigger = (trigger: HTMLElement) => {
|
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
|
|
242
|
-
const {
|
|
243
|
-
await mountStory(SharedTrigger)
|
|
252
|
+
const { body, canvas } = await mountStory(SharedTrigger)
|
|
244
253
|
|
|
245
254
|
const trigger = expectAgentDrivable(canvas, {
|
|
246
255
|
name: "Bay 5",
|
|
@@ -252,7 +261,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
252
261
|
await userEvent.click(trigger)
|
|
253
262
|
|
|
254
263
|
const menu = await waitFor(() =>
|
|
255
|
-
expectAgentDrivable(
|
|
264
|
+
expectAgentDrivable(body, { role: "menu" }),
|
|
256
265
|
)
|
|
257
266
|
|
|
258
267
|
// `bottom-start` with `offset(4)`: flush with the trigger's
|
|
@@ -273,7 +282,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
273
282
|
)
|
|
274
283
|
})
|
|
275
284
|
|
|
276
|
-
await expectNoAxeViolations(
|
|
285
|
+
await expectNoAxeViolations(menu)
|
|
277
286
|
})
|
|
278
287
|
|
|
279
288
|
/**
|
|
@@ -282,7 +291,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
282
291
|
* pointing at nothing and the test above would still pass.
|
|
283
292
|
*/
|
|
284
293
|
test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
285
|
-
const { canvas } = await mountStory(SharedTrigger)
|
|
294
|
+
const { body, canvas } = await mountStory(SharedTrigger)
|
|
286
295
|
|
|
287
296
|
const trigger = expectAgentDrivable(canvas, {
|
|
288
297
|
name: "Bay 5",
|
|
@@ -294,7 +303,7 @@ test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
|
294
303
|
trigger.focus()
|
|
295
304
|
|
|
296
305
|
const tip = await waitFor(() =>
|
|
297
|
-
expectAgentDrivable(
|
|
306
|
+
expectAgentDrivable(body, { role: "tooltip" }),
|
|
298
307
|
)
|
|
299
308
|
|
|
300
309
|
// The **main axis** only: `top` with `offset(6)`. The cross axis
|
package/src/Menu/Menu.tsx
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selectTabIndex,
|
|
3
|
-
useClonedChild,
|
|
4
3
|
useRovingFocus,
|
|
5
4
|
} from "@charcuterie/logic"
|
|
6
5
|
import type { Placement } from "@floating-ui/react"
|
|
7
6
|
import {
|
|
8
|
-
autoUpdate,
|
|
9
7
|
FloatingFocusManager,
|
|
10
|
-
|
|
11
|
-
offset,
|
|
12
|
-
shift,
|
|
13
|
-
useDismiss,
|
|
14
|
-
useFloating,
|
|
15
|
-
useInteractions,
|
|
16
|
-
useRole,
|
|
8
|
+
FloatingPortal,
|
|
17
9
|
} from "@floating-ui/react"
|
|
18
10
|
import type { ReactElement, ReactNode } from "react"
|
|
19
11
|
import { useEffect, useRef } from "react"
|
|
20
12
|
|
|
13
|
+
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
14
|
+
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
21
15
|
import { toClassName } from "../toClassName.ts"
|
|
22
16
|
import { MenuAction } from "./MenuAction.tsx"
|
|
23
17
|
|
|
@@ -112,8 +106,6 @@ export const Menu = ({
|
|
|
112
106
|
new Map<string, HTMLButtonElement>(),
|
|
113
107
|
)
|
|
114
108
|
|
|
115
|
-
const panelElement = useRef<HTMLDivElement>(null)
|
|
116
|
-
|
|
117
109
|
const focus = useRovingFocus<string>()
|
|
118
110
|
|
|
119
111
|
const { activeValue, registeredValues, setActiveValue } =
|
|
@@ -121,53 +113,21 @@ export const Menu = ({
|
|
|
121
113
|
|
|
122
114
|
const [firstValue] = registeredValues
|
|
123
115
|
|
|
124
|
-
const {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
116
|
+
const {
|
|
117
|
+
clonedTrigger,
|
|
118
|
+
context,
|
|
119
|
+
floatingStyles,
|
|
120
|
+
getFloatingProps,
|
|
121
|
+
setFloating,
|
|
122
|
+
} = useAnchoredOverlay({
|
|
123
|
+
isVisible,
|
|
124
|
+
offsetValue: 4,
|
|
125
|
+
onDismiss,
|
|
131
126
|
placement,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
whileElementsMounted: autoUpdate,
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
const { getFloatingProps, getReferenceProps } =
|
|
138
|
-
useInteractions([
|
|
139
|
-
useDismiss(context),
|
|
140
|
-
useRole(context, { role: "menu" }),
|
|
141
|
-
])
|
|
142
|
-
|
|
143
|
-
const clonedTrigger = useClonedChild(trigger, {
|
|
144
|
-
...getReferenceProps(),
|
|
145
|
-
ref: refs.setReference,
|
|
127
|
+
role: "menu",
|
|
128
|
+
trigger,
|
|
146
129
|
})
|
|
147
130
|
|
|
148
|
-
/**
|
|
149
|
-
* The other half of the `isConnected` guard below. If the ref
|
|
150
|
-
* fired while the node was still detached, nothing has shown the
|
|
151
|
-
* popover — and a `[popover]` that was never shown is
|
|
152
|
-
* `display: none`, so the menu renders, passes typecheck, and is
|
|
153
|
-
* invisible to `getByRole` and to a user alike.
|
|
154
|
-
*
|
|
155
|
-
* Effects run after the tree is connected, so this is the
|
|
156
|
-
* backstop. It is idempotent: `:popover-open` is the same question
|
|
157
|
-
* the ref asks.
|
|
158
|
-
*/
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
const panel = panelElement.current
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
isVisible &&
|
|
164
|
-
panel?.isConnected &&
|
|
165
|
-
!panel.matches(":popover-open")
|
|
166
|
-
) {
|
|
167
|
-
panel.showPopover()
|
|
168
|
-
}
|
|
169
|
-
}, [isVisible])
|
|
170
|
-
|
|
171
131
|
/**
|
|
172
132
|
* Opening **makes** a member active, and this is the step the
|
|
173
133
|
* first version was missing.
|
|
@@ -205,95 +165,74 @@ export const Menu = ({
|
|
|
205
165
|
{clonedTrigger}
|
|
206
166
|
|
|
207
167
|
{isVisible ? (
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<div
|
|
220
|
-
{...getFloatingProps()}
|
|
221
|
-
className={toClassName(
|
|
222
|
-
"inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium",
|
|
223
|
-
className,
|
|
224
|
-
)}
|
|
225
|
-
onKeyDown={(keyEvent) => {
|
|
226
|
-
const commands: Record<string, () => void> = {
|
|
227
|
-
ArrowDown: focus.next,
|
|
228
|
-
ArrowUp: focus.previous,
|
|
229
|
-
End: focus.last,
|
|
230
|
-
Home: focus.first,
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const command = commands[keyEvent.key]
|
|
234
|
-
|
|
235
|
-
if (command) {
|
|
236
|
-
// Arrow keys scroll the page by default, and a menu
|
|
237
|
-
// that scrolls the document behind it while moving
|
|
238
|
-
// focus is the hand-rolled version of this
|
|
239
|
-
// component.
|
|
240
|
-
keyEvent.preventDefault()
|
|
241
|
-
|
|
242
|
-
command()
|
|
243
|
-
}
|
|
244
|
-
}}
|
|
245
|
-
popover="manual"
|
|
246
|
-
ref={(node) => {
|
|
247
|
-
refs.setFloating(node)
|
|
248
|
-
|
|
249
|
-
panelElement.current = node
|
|
250
|
-
|
|
251
|
-
// A ref callback rather than an effect, for the same
|
|
252
|
-
// reason as `Popover`: a `[popover]` is
|
|
253
|
-
// `display: none` until shown, and every child effect
|
|
254
|
-
// — including the focus move above — would otherwise
|
|
255
|
-
// be aiming at a hidden element.
|
|
256
|
-
//
|
|
257
|
-
// `isConnected` is the guard `Popover` does not need
|
|
258
|
-
// and this does. `FloatingFocusManager` renders a pair
|
|
259
|
-
// of focus guards around its child, so React commits
|
|
260
|
-
// this subtree in an order where the ref can fire
|
|
261
|
-
// while the node is still detached — and
|
|
262
|
-
// `showPopover()` on a disconnected element throws
|
|
263
|
-
// `InvalidStateError`, which surfaces as an unhandled
|
|
264
|
-
// rejection rather than as a failing assertion.
|
|
265
|
-
if (
|
|
266
|
-
node?.isConnected &&
|
|
267
|
-
!node.matches(":popover-open")
|
|
268
|
-
) {
|
|
269
|
-
node.showPopover()
|
|
270
|
-
}
|
|
271
|
-
}}
|
|
272
|
-
// Duplicated from `getFloatingProps()` on purpose, and
|
|
273
|
-
// the two cannot differ — `useRole(context, { role:
|
|
274
|
-
// "menu" })` above is where it comes from. Stated here
|
|
275
|
-
// because a linter cannot see a role through a spread.
|
|
276
|
-
role="menu"
|
|
277
|
-
style={floatingStyles}
|
|
168
|
+
<FloatingPortal>
|
|
169
|
+
<FloatingFocusManager
|
|
170
|
+
context={context}
|
|
171
|
+
// `false`, so Tab leaves the menu and lands on the next
|
|
172
|
+
// control in the page — which is what closes it, through
|
|
173
|
+
// `useDismiss`. A trapped menu is a dialog.
|
|
174
|
+
modal={false}
|
|
175
|
+
// Focus is moved by the effect above, to whichever item
|
|
176
|
+
// `RovingFocus` made active. Letting the manager also aim
|
|
177
|
+
// it is two owners for one caret.
|
|
178
|
+
initialFocus={-1}
|
|
278
179
|
>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
180
|
+
<div
|
|
181
|
+
{...getFloatingProps()}
|
|
182
|
+
className={toClassName(
|
|
183
|
+
PANEL_SURFACE_CLASS,
|
|
184
|
+
"z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 p-1",
|
|
185
|
+
className,
|
|
186
|
+
)}
|
|
187
|
+
onKeyDown={(keyEvent) => {
|
|
188
|
+
const commands: Record<string, () => void> =
|
|
189
|
+
{
|
|
190
|
+
ArrowDown: focus.next,
|
|
191
|
+
ArrowUp: focus.previous,
|
|
192
|
+
End: focus.last,
|
|
193
|
+
Home: focus.first,
|
|
291
194
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
195
|
+
|
|
196
|
+
const command = commands[keyEvent.key]
|
|
197
|
+
|
|
198
|
+
if (command) {
|
|
199
|
+
// Arrow keys scroll the page by default, and a menu
|
|
200
|
+
// that scrolls the document behind it while moving
|
|
201
|
+
// focus is the hand-rolled version of this
|
|
202
|
+
// component.
|
|
203
|
+
keyEvent.preventDefault()
|
|
204
|
+
|
|
205
|
+
command()
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
ref={setFloating}
|
|
209
|
+
// Duplicated from `getFloatingProps()` on purpose, and
|
|
210
|
+
// the two cannot differ — `useRole(context, { role:
|
|
211
|
+
// "menu" })` in the hook is where it comes from. Stated
|
|
212
|
+
// here because a linter cannot see a role through a
|
|
213
|
+
// spread.
|
|
214
|
+
role="menu"
|
|
215
|
+
style={floatingStyles}
|
|
216
|
+
>
|
|
217
|
+
{items.map((item) => (
|
|
218
|
+
<MenuAction
|
|
219
|
+
item={item}
|
|
220
|
+
key={item.key}
|
|
221
|
+
onDismiss={onDismiss}
|
|
222
|
+
register={focus.register}
|
|
223
|
+
tabIndex={selectTabIndex(focus, item.key)}
|
|
224
|
+
trackElement={(key, element) => {
|
|
225
|
+
if (element) {
|
|
226
|
+
itemElements.current.set(key, element)
|
|
227
|
+
} else {
|
|
228
|
+
itemElements.current.delete(key)
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</FloatingFocusManager>
|
|
235
|
+
</FloatingPortal>
|
|
297
236
|
) : null}
|
|
298
237
|
</>
|
|
299
238
|
)
|