@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.
Files changed (106) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
  106. 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
+ }