@charcuterie/ui 0.1.0 → 0.2.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/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
- package/CHANGELOG.md +0 -13
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { placementArgType } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import {
|
|
8
|
+
StoryCell,
|
|
9
|
+
StoryGrid,
|
|
10
|
+
} from "../board.storyHelpers.tsx"
|
|
11
|
+
import {
|
|
12
|
+
RedoIcon,
|
|
13
|
+
SettingsIcon,
|
|
14
|
+
UndoIcon,
|
|
15
|
+
} from "../icons.storyHelpers.tsx"
|
|
16
|
+
import type { MenuItem } from "./Menu.tsx"
|
|
17
|
+
import { Menu } from "./Menu.tsx"
|
|
18
|
+
|
|
19
|
+
const noop = () => undefined
|
|
20
|
+
|
|
21
|
+
const BAY_ACTIONS: MenuItem[] = [
|
|
22
|
+
{
|
|
23
|
+
icon: <UndoIcon />,
|
|
24
|
+
key: "retry",
|
|
25
|
+
label: "Retry title",
|
|
26
|
+
onSelect: noop,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
icon: <RedoIcon />,
|
|
30
|
+
key: "skip",
|
|
31
|
+
label: "Skip title",
|
|
32
|
+
onSelect: noop,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
icon: <SettingsIcon />,
|
|
36
|
+
isDisabled: true,
|
|
37
|
+
key: "eject",
|
|
38
|
+
label: "Eject disc",
|
|
39
|
+
onSelect: noop,
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A menu's visibility belongs to the app, exactly like `Popover`'s —
|
|
45
|
+
* so the stories hold it in a `useVisibility`, which is what a
|
|
46
|
+
* consumer writes too.
|
|
47
|
+
*/
|
|
48
|
+
const MenuHarness = ({
|
|
49
|
+
isInitiallyVisible = false,
|
|
50
|
+
items = BAY_ACTIONS,
|
|
51
|
+
triggerLabel,
|
|
52
|
+
}: {
|
|
53
|
+
isInitiallyVisible?: boolean
|
|
54
|
+
items?: MenuItem[]
|
|
55
|
+
triggerLabel: string
|
|
56
|
+
}): ReactNode => {
|
|
57
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
58
|
+
isVisible: isInitiallyVisible,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Menu
|
|
63
|
+
isVisible={isVisible}
|
|
64
|
+
items={items}
|
|
65
|
+
onDismiss={hide}
|
|
66
|
+
trigger={
|
|
67
|
+
<Button appearance="outline" onClick={toggle}>
|
|
68
|
+
{triggerLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const meta = {
|
|
76
|
+
title: "Components/Menu",
|
|
77
|
+
component: Menu,
|
|
78
|
+
parameters: { layout: "centered" },
|
|
79
|
+
argTypes: { placement: placementArgType },
|
|
80
|
+
args: {
|
|
81
|
+
items: BAY_ACTIONS,
|
|
82
|
+
placement: "bottom-start",
|
|
83
|
+
},
|
|
84
|
+
} satisfies Meta<typeof Menu>
|
|
85
|
+
|
|
86
|
+
export default meta
|
|
87
|
+
|
|
88
|
+
type Story = StoryObj<typeof meta>
|
|
89
|
+
|
|
90
|
+
export const Default: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
isVisible: false,
|
|
93
|
+
onDismiss: noop,
|
|
94
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
95
|
+
},
|
|
96
|
+
render: () => <MenuHarness triggerLabel="Actions" />,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A `menuitem` **does** something; an `option` **is** something. A
|
|
101
|
+
* screen reader announces "menu, 3 items" for one and "listbox,
|
|
102
|
+
* selected, 2 of 4" for the other, and mux-magic's `TypePicker`
|
|
103
|
+
* renders `role="menu"` over items that set a value — a listbox
|
|
104
|
+
* wearing the wrong role.
|
|
105
|
+
*/
|
|
106
|
+
export const AllVariants: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
isVisible: false,
|
|
109
|
+
onDismiss: noop,
|
|
110
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
111
|
+
},
|
|
112
|
+
render: () => (
|
|
113
|
+
<StoryGrid columns={2}>
|
|
114
|
+
<StoryCell label="closed">
|
|
115
|
+
<MenuHarness triggerLabel="Bay 1" />
|
|
116
|
+
</StoryCell>
|
|
117
|
+
|
|
118
|
+
<StoryCell label="open">
|
|
119
|
+
<MenuHarness
|
|
120
|
+
isInitiallyVisible
|
|
121
|
+
triggerLabel="Bay 2"
|
|
122
|
+
/>
|
|
123
|
+
</StoryCell>
|
|
124
|
+
</StoryGrid>
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A disabled item is simply **not registered** with the roving
|
|
130
|
+
* group, so the arrow keys skip it without any command in
|
|
131
|
+
* `RovingFocus` knowing what "disabled" means. It stays in the DOM
|
|
132
|
+
* and stays announced — the difference between "not right now" and
|
|
133
|
+
* "does not exist".
|
|
134
|
+
*/
|
|
135
|
+
export const AllStates: Story = {
|
|
136
|
+
args: {
|
|
137
|
+
isVisible: false,
|
|
138
|
+
onDismiss: noop,
|
|
139
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
140
|
+
},
|
|
141
|
+
render: () => (
|
|
142
|
+
<MenuHarness isInitiallyVisible triggerLabel="Bay 3" />
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Open it and arrow down. Focus lands on the first item **on
|
|
148
|
+
* opening** — a menu that leaves the caret on the trigger is one a
|
|
149
|
+
* keyboard user cannot reach — and Escape closes it without the
|
|
150
|
+
* page moving.
|
|
151
|
+
*/
|
|
152
|
+
export const Interactive: Story = {
|
|
153
|
+
args: {
|
|
154
|
+
isVisible: false,
|
|
155
|
+
onDismiss: noop,
|
|
156
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
157
|
+
},
|
|
158
|
+
render: () => (
|
|
159
|
+
<MenuHarness triggerLabel="Open bay 4 menu" />
|
|
160
|
+
),
|
|
161
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Menu.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("the menu is named, and its items are menuitems", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(AllStates)
|
|
16
|
+
|
|
17
|
+
// **Named by its trigger**, not by a `label` prop. `useRole` puts
|
|
18
|
+
// `aria-labelledby` on the panel pointing at the button, and
|
|
19
|
+
// `aria-labelledby` beats `aria-label` — so the `label` prop this
|
|
20
|
+
// component was first written with did nothing at all, silently.
|
|
21
|
+
//
|
|
22
|
+
// That narrows the M3/M4 lesson rather than contradicting it: an
|
|
23
|
+
// overlay with **no trigger relationship** (`Spinner`'s
|
|
24
|
+
// `role="status"`, `Popover`'s `role="dialog"`) needs its own
|
|
25
|
+
// name. A menu has one.
|
|
26
|
+
expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Bay 3",
|
|
28
|
+
role: "menu",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expectAgentDrivable(canvas, {
|
|
32
|
+
name: "Retry title",
|
|
33
|
+
role: "menuitem",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await expectNoAxeViolations(canvasElement)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A menu is one of the few patterns where focus must jump on open —
|
|
41
|
+
* otherwise the keyboard user who pressed Enter on the trigger is
|
|
42
|
+
* still on the trigger, with a menu they cannot reach.
|
|
43
|
+
*/
|
|
44
|
+
test("opening moves focus into the menu", async () => {
|
|
45
|
+
const { canvas } = await mountStory(Interactive)
|
|
46
|
+
|
|
47
|
+
await userEvent.click(
|
|
48
|
+
expectAgentDrivable(canvas, {
|
|
49
|
+
name: "Open bay 4 menu",
|
|
50
|
+
role: "button",
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
await waitFor(() => {
|
|
55
|
+
expect(
|
|
56
|
+
expectAgentDrivable(canvas, {
|
|
57
|
+
name: "Retry title",
|
|
58
|
+
role: "menuitem",
|
|
59
|
+
}),
|
|
60
|
+
).toHaveFocus()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("the arrow keys move within the menu and skip the disabled item", async () => {
|
|
65
|
+
const { canvas } = await mountStory(AllStates)
|
|
66
|
+
|
|
67
|
+
const skip = expectAgentDrivable(canvas, {
|
|
68
|
+
name: "Skip title",
|
|
69
|
+
role: "menuitem",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const eject = expectAgentDrivable(canvas, {
|
|
73
|
+
name: "Eject disc",
|
|
74
|
+
role: "menuitem",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await expect(eject).toBeDisabled()
|
|
78
|
+
|
|
79
|
+
// Not a click to get started — **choosing dismisses the menu**, so
|
|
80
|
+
// clicking an item to "focus" it closes the thing under test. This
|
|
81
|
+
// story opens already visible, and opening is what puts focus on
|
|
82
|
+
// the first item.
|
|
83
|
+
const retry = expectAgentDrivable(canvas, {
|
|
84
|
+
name: "Retry title",
|
|
85
|
+
role: "menuitem",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(retry).toHaveFocus()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(skip).toHaveFocus()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Registration is membership: the disabled item never joined the
|
|
99
|
+
// roving group, so wrapping goes past it back to the first.
|
|
100
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(retry).toHaveFocus()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await expect(eject).not.toHaveFocus()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Exactly one tab stop for the whole menu — the roving-tabindex
|
|
111
|
+
* rule, read straight from `selectTabIndex`. `expectAgentDrivable`
|
|
112
|
+
* asserts it for every composite member it is handed; this states it
|
|
113
|
+
* outright because a menu's members all carry `tabindex="-1"` and
|
|
114
|
+
* that is the pattern, not a bug.
|
|
115
|
+
*/
|
|
116
|
+
test("the menu has one tab stop", async () => {
|
|
117
|
+
const { canvas } = await mountStory(AllStates)
|
|
118
|
+
|
|
119
|
+
const menu = expectAgentDrivable(canvas, {
|
|
120
|
+
name: "Bay 3",
|
|
121
|
+
role: "menu",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const tabbable = Array.from(
|
|
125
|
+
menu.querySelectorAll<HTMLButtonElement>(
|
|
126
|
+
'[role="menuitem"]',
|
|
127
|
+
),
|
|
128
|
+
).filter((one) => one.tabIndex === 0)
|
|
129
|
+
|
|
130
|
+
await expect(tabbable).toHaveLength(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("choosing an item dismisses the menu", async () => {
|
|
134
|
+
const { canvas } = await mountStory(Interactive)
|
|
135
|
+
|
|
136
|
+
await userEvent.click(
|
|
137
|
+
expectAgentDrivable(canvas, {
|
|
138
|
+
name: "Open bay 4 menu",
|
|
139
|
+
role: "button",
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
await userEvent.click(
|
|
144
|
+
expectAgentDrivable(canvas, {
|
|
145
|
+
name: "Retry title",
|
|
146
|
+
role: "menuitem",
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// A menu that stays open after an action has fired is the one
|
|
151
|
+
// interaction users read as "it didn't work".
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(canvas.queryAllByRole("menuitem")).toHaveLength(
|
|
154
|
+
0,
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("Escape dismisses without choosing", async () => {
|
|
160
|
+
const { canvas } = await mountStory(Interactive)
|
|
161
|
+
|
|
162
|
+
await userEvent.click(
|
|
163
|
+
expectAgentDrivable(canvas, {
|
|
164
|
+
name: "Open bay 4 menu",
|
|
165
|
+
role: "button",
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(
|
|
171
|
+
canvas.queryAllByRole("menuitem").length,
|
|
172
|
+
).toBeGreaterThan(0)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
await userEvent.keyboard("{Escape}")
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(canvas.queryAllByRole("menuitem")).toHaveLength(
|
|
179
|
+
0,
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The panel is in the top layer via `popover="manual"` and **stays
|
|
186
|
+
* in the DOM where it was written** — so a scoped query still finds
|
|
187
|
+
* it. A portalled menu would move to `document.body` and every story
|
|
188
|
+
* here would have to reach for `screen`.
|
|
189
|
+
*/
|
|
190
|
+
test("the menu is not portalled out of its canvas", async () => {
|
|
191
|
+
const { canvas, canvasElement } =
|
|
192
|
+
await mountStory(AllVariants)
|
|
193
|
+
|
|
194
|
+
const menu = expectAgentDrivable(canvas, {
|
|
195
|
+
name: "Bay 2",
|
|
196
|
+
role: "menu",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await expect(canvasElement).toContainElement(menu)
|
|
200
|
+
|
|
201
|
+
await expectNoAxeViolations(canvasElement)
|
|
202
|
+
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import {
|
|
2
|
+
selectTabIndex,
|
|
3
|
+
useClonedChild,
|
|
4
|
+
useRovingFocus,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { Placement } from "@floating-ui/react"
|
|
7
|
+
import {
|
|
8
|
+
autoUpdate,
|
|
9
|
+
FloatingFocusManager,
|
|
10
|
+
flip,
|
|
11
|
+
offset,
|
|
12
|
+
shift,
|
|
13
|
+
useDismiss,
|
|
14
|
+
useFloating,
|
|
15
|
+
useInteractions,
|
|
16
|
+
useRole,
|
|
17
|
+
} from "@floating-ui/react"
|
|
18
|
+
import type { ReactElement, ReactNode } from "react"
|
|
19
|
+
import { useEffect, useRef } from "react"
|
|
20
|
+
|
|
21
|
+
import { toClassName } from "../toClassName.ts"
|
|
22
|
+
import { MenuAction } from "./MenuAction.tsx"
|
|
23
|
+
|
|
24
|
+
export type MenuItem = {
|
|
25
|
+
icon?: ReactNode
|
|
26
|
+
isDisabled?: boolean
|
|
27
|
+
key: string
|
|
28
|
+
label: ReactNode
|
|
29
|
+
onSelect: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MenuProps = {
|
|
33
|
+
className?: string
|
|
34
|
+
isVisible: boolean
|
|
35
|
+
items: MenuItem[]
|
|
36
|
+
/** Outside press, Escape, and choosing an item all land here. */
|
|
37
|
+
onDismiss: () => void
|
|
38
|
+
placement?: Placement
|
|
39
|
+
/** The control the menu hangs off. **Cloned, not wrapped.** */
|
|
40
|
+
trigger: ReactElement
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A menu is **not** a listbox, and mux-magic's `TypePicker` is the
|
|
45
|
+
* fleet's one attempt at telling them apart — it renders
|
|
46
|
+
* `role="menu"` over items that set a value, which is a listbox
|
|
47
|
+
* wearing the wrong role. The distinction is what the items *are*:
|
|
48
|
+
*
|
|
49
|
+
* - `menuitem` **does** something. Rename, Delete, Copy path.
|
|
50
|
+
* - `option` **is** something. It is a value you are choosing.
|
|
51
|
+
*
|
|
52
|
+
* A screen reader announces "menu, 4 items" for one and "listbox,
|
|
53
|
+
* selected, 2 of 4" for the other, and an agent driving the app
|
|
54
|
+
* needs the second to know what state it left behind. Getting it
|
|
55
|
+
* wrong is invisible in a browser and invisible to axe.
|
|
56
|
+
*
|
|
57
|
+
* ### There is no `label` prop, and that corrects a lesson
|
|
58
|
+
*
|
|
59
|
+
* `Spinner` (M3), `Popover` and `Modal` (M4) all take a **required**
|
|
60
|
+
* name, because a `role="status"` and a `role="dialog"` take none
|
|
61
|
+
* from their content. The obvious move here was a `label` prop doing
|
|
62
|
+
* the same job, and it was written that way first — then measured
|
|
63
|
+
* as doing nothing.
|
|
64
|
+
*
|
|
65
|
+
* `useRole(context, { role: "menu" })` puts **`aria-labelledby` on
|
|
66
|
+
* the panel pointing at the trigger**, and `aria-labelledby` beats
|
|
67
|
+
* `aria-label`. So the menu was already named — "Bay 3", from the
|
|
68
|
+
* button — and the prop was silently discarded. That is also the ARIA
|
|
69
|
+
* Authoring Practices menu-button pattern, where a menu is named by
|
|
70
|
+
* the control that opens it.
|
|
71
|
+
*
|
|
72
|
+
* The rule the other three teach is therefore narrower than it
|
|
73
|
+
* looked: **an overlay with no trigger relationship needs its own
|
|
74
|
+
* name.** A menu has one, so the requirement moves to the trigger —
|
|
75
|
+
* which `Button` and `IconButton` already enforce.
|
|
76
|
+
*
|
|
77
|
+
* ### Roving focus, and the composition `Tabs` already proved
|
|
78
|
+
*
|
|
79
|
+
* `RovingFocus` alone this time — there is no selection, because a
|
|
80
|
+
* menu item is not selected, it is invoked. That is the same
|
|
81
|
+
* two-kinds argument from the other side: `Tabs` needed both kinds
|
|
82
|
+
* because a tab is chosen *and* focused, and a menu needs one
|
|
83
|
+
* because a menu item is only ever focused.
|
|
84
|
+
*
|
|
85
|
+
* ### Focus moves in, once, and only on opening
|
|
86
|
+
*
|
|
87
|
+
* A menu is one of the few patterns where focus must jump on open —
|
|
88
|
+
* otherwise the keyboard user who pressed Enter on the trigger is
|
|
89
|
+
* still on the trigger with a menu they cannot reach. It happens in
|
|
90
|
+
* the panel's own effect rather than through
|
|
91
|
+
* `FloatingFocusManager`'s `initialFocus`, because the element to
|
|
92
|
+
* focus is whichever one `RovingFocus` says is active, and that is
|
|
93
|
+
* not known until the items have registered.
|
|
94
|
+
*
|
|
95
|
+
* ### No type-ahead, deliberately
|
|
96
|
+
*
|
|
97
|
+
* The APG lists type-ahead as optional for a menu and required for a
|
|
98
|
+
* listbox, and none of the fleet's menus are long enough to need it
|
|
99
|
+
* — the largest has six items. Adding it would mean a keystroke
|
|
100
|
+
* buffer and a timer in a component that currently has neither, for
|
|
101
|
+
* a menu you can cross in three arrow presses.
|
|
102
|
+
*/
|
|
103
|
+
export const Menu = ({
|
|
104
|
+
className,
|
|
105
|
+
isVisible,
|
|
106
|
+
items,
|
|
107
|
+
onDismiss,
|
|
108
|
+
placement = "bottom-start",
|
|
109
|
+
trigger,
|
|
110
|
+
}: MenuProps): ReactNode => {
|
|
111
|
+
const itemElements = useRef(
|
|
112
|
+
new Map<string, HTMLButtonElement>(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const panelElement = useRef<HTMLDivElement>(null)
|
|
116
|
+
|
|
117
|
+
const focus = useRovingFocus<string>()
|
|
118
|
+
|
|
119
|
+
const { activeValue, registeredValues, setActiveValue } =
|
|
120
|
+
focus
|
|
121
|
+
|
|
122
|
+
const [firstValue] = registeredValues
|
|
123
|
+
|
|
124
|
+
const { context, floatingStyles, refs } = useFloating({
|
|
125
|
+
open: isVisible,
|
|
126
|
+
onOpenChange: (isNextVisible) => {
|
|
127
|
+
if (!isNextVisible) {
|
|
128
|
+
onDismiss()
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
placement,
|
|
132
|
+
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
|
133
|
+
strategy: "fixed",
|
|
134
|
+
whileElementsMounted: autoUpdate,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const { getFloatingProps, getReferenceProps } =
|
|
138
|
+
useInteractions([
|
|
139
|
+
useDismiss(context),
|
|
140
|
+
useRole(context, { role: "menu" }),
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
const clonedTrigger = useClonedChild(trigger, {
|
|
144
|
+
...getReferenceProps(),
|
|
145
|
+
ref: refs.setReference,
|
|
146
|
+
})
|
|
147
|
+
|
|
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
|
+
/**
|
|
172
|
+
* Opening **makes** a member active, and this is the step the
|
|
173
|
+
* first version was missing.
|
|
174
|
+
*
|
|
175
|
+
* `RovingFocus` leaves `activeValue` at `null` until something
|
|
176
|
+
* sets it — `selectTabIndex` covers the first paint by giving the
|
|
177
|
+
* tab stop to `registeredValues[0]` while it is still null, which
|
|
178
|
+
* is exactly why nothing looked wrong: the menu had a correct tab
|
|
179
|
+
* stop, correct roles, and no focus. `Tabs` never hit it because a
|
|
180
|
+
* tab bar seeds itself from its selected key.
|
|
181
|
+
*
|
|
182
|
+
* Re-running on every open is deliberate. The APG says a menu
|
|
183
|
+
* opens on its first item, so a menu reopened after arrowing to
|
|
184
|
+
* the third one starts at the top again rather than resuming a
|
|
185
|
+
* position the user has forgotten.
|
|
186
|
+
*/
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!isVisible || firstValue === undefined) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setActiveValue(firstValue)
|
|
193
|
+
}, [firstValue, isVisible, setActiveValue])
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!isVisible || activeValue === null) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
itemElements.current.get(activeValue)?.focus()
|
|
201
|
+
}, [activeValue, isVisible])
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
205
|
+
{clonedTrigger}
|
|
206
|
+
|
|
207
|
+
{isVisible ? (
|
|
208
|
+
<FloatingFocusManager
|
|
209
|
+
context={context}
|
|
210
|
+
// `false`, so Tab leaves the menu and lands on the next
|
|
211
|
+
// control in the page — which is what closes it, through
|
|
212
|
+
// `useDismiss`. A trapped menu is a dialog.
|
|
213
|
+
modal={false}
|
|
214
|
+
// Focus is moved by the effect above, to whichever item
|
|
215
|
+
// `RovingFocus` made active. Letting the manager also aim
|
|
216
|
+
// it is two owners for one caret.
|
|
217
|
+
initialFocus={-1}
|
|
218
|
+
>
|
|
219
|
+
<div
|
|
220
|
+
{...getFloatingProps()}
|
|
221
|
+
className={toClassName(
|
|
222
|
+
"inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium",
|
|
223
|
+
className,
|
|
224
|
+
)}
|
|
225
|
+
onKeyDown={(keyEvent) => {
|
|
226
|
+
const commands: Record<string, () => void> = {
|
|
227
|
+
ArrowDown: focus.next,
|
|
228
|
+
ArrowUp: focus.previous,
|
|
229
|
+
End: focus.last,
|
|
230
|
+
Home: focus.first,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const command = commands[keyEvent.key]
|
|
234
|
+
|
|
235
|
+
if (command) {
|
|
236
|
+
// Arrow keys scroll the page by default, and a menu
|
|
237
|
+
// that scrolls the document behind it while moving
|
|
238
|
+
// focus is the hand-rolled version of this
|
|
239
|
+
// component.
|
|
240
|
+
keyEvent.preventDefault()
|
|
241
|
+
|
|
242
|
+
command()
|
|
243
|
+
}
|
|
244
|
+
}}
|
|
245
|
+
popover="manual"
|
|
246
|
+
ref={(node) => {
|
|
247
|
+
refs.setFloating(node)
|
|
248
|
+
|
|
249
|
+
panelElement.current = node
|
|
250
|
+
|
|
251
|
+
// A ref callback rather than an effect, for the same
|
|
252
|
+
// reason as `Popover`: a `[popover]` is
|
|
253
|
+
// `display: none` until shown, and every child effect
|
|
254
|
+
// — including the focus move above — would otherwise
|
|
255
|
+
// be aiming at a hidden element.
|
|
256
|
+
//
|
|
257
|
+
// `isConnected` is the guard `Popover` does not need
|
|
258
|
+
// and this does. `FloatingFocusManager` renders a pair
|
|
259
|
+
// of focus guards around its child, so React commits
|
|
260
|
+
// this subtree in an order where the ref can fire
|
|
261
|
+
// while the node is still detached — and
|
|
262
|
+
// `showPopover()` on a disconnected element throws
|
|
263
|
+
// `InvalidStateError`, which surfaces as an unhandled
|
|
264
|
+
// rejection rather than as a failing assertion.
|
|
265
|
+
if (
|
|
266
|
+
node?.isConnected &&
|
|
267
|
+
!node.matches(":popover-open")
|
|
268
|
+
) {
|
|
269
|
+
node.showPopover()
|
|
270
|
+
}
|
|
271
|
+
}}
|
|
272
|
+
// Duplicated from `getFloatingProps()` on purpose, and
|
|
273
|
+
// the two cannot differ — `useRole(context, { role:
|
|
274
|
+
// "menu" })` above is where it comes from. Stated here
|
|
275
|
+
// because a linter cannot see a role through a spread.
|
|
276
|
+
role="menu"
|
|
277
|
+
style={floatingStyles}
|
|
278
|
+
>
|
|
279
|
+
{items.map((item) => (
|
|
280
|
+
<MenuAction
|
|
281
|
+
item={item}
|
|
282
|
+
key={item.key}
|
|
283
|
+
onDismiss={onDismiss}
|
|
284
|
+
register={focus.register}
|
|
285
|
+
tabIndex={selectTabIndex(focus, item.key)}
|
|
286
|
+
trackElement={(key, element) => {
|
|
287
|
+
if (element) {
|
|
288
|
+
itemElements.current.set(key, element)
|
|
289
|
+
} else {
|
|
290
|
+
itemElements.current.delete(key)
|
|
291
|
+
}
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
))}
|
|
295
|
+
</div>
|
|
296
|
+
</FloatingFocusManager>
|
|
297
|
+
) : null}
|
|
298
|
+
</>
|
|
299
|
+
)
|
|
300
|
+
}
|