@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.
Files changed (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,205 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { placementArgType } from "../argTypes.storyHelpers.ts"
7
+ import { Badge } from "../Badge/Badge.tsx"
8
+ import { Button } from "../Button/Button.tsx"
9
+ import { StoryRow } from "../board.storyHelpers.tsx"
10
+ import type { ListboxItem } from "./Listbox.tsx"
11
+ import { Listbox } from "./Listbox.tsx"
12
+
13
+ const LANGUAGES: ListboxItem[] = [
14
+ { label: "English", value: "eng" },
15
+ { label: "Spanish", value: "spa" },
16
+ { label: "French", value: "fra" },
17
+ { isDisabled: true, label: "German", value: "deu" },
18
+ { label: "Japanese", value: "jpn" },
19
+ ]
20
+
21
+ /**
22
+ * The rich options a native `<option>` cannot render — a leading
23
+ * flag, a two-line detail, a trailing confidence badge — each with a
24
+ * `textValue` so type-ahead and the accessible name still work.
25
+ */
26
+ const TRACKS: ListboxItem[] = [
27
+ {
28
+ label: (
29
+ <span className="flex items-center gap-2">
30
+ <span aria-hidden="true">🇬🇧</span>
31
+ <span className="flex flex-col">
32
+ <span>English</span>
33
+ <span className="text-content-muted text-xs">
34
+ 5.1 · forced
35
+ </span>
36
+ </span>
37
+ </span>
38
+ ),
39
+ textValue: "English",
40
+ value: "eng",
41
+ },
42
+ {
43
+ label: (
44
+ <span className="flex items-center gap-2">
45
+ <span aria-hidden="true">🇯🇵</span>
46
+ <span className="flex flex-col">
47
+ <span>Japanese</span>
48
+ <span className="text-content-muted text-xs">
49
+ 2.0 · original
50
+ </span>
51
+ </span>
52
+ </span>
53
+ ),
54
+ textValue: "Japanese",
55
+ value: "jpn",
56
+ },
57
+ {
58
+ label: (
59
+ <span className="flex items-center gap-2">
60
+ <span aria-hidden="true">🇫🇷</span>
61
+ <span>French</span>
62
+ <Badge intent="warning" size="sm">
63
+ low confidence
64
+ </Badge>
65
+ </span>
66
+ ),
67
+ textValue: "French",
68
+ value: "fra",
69
+ },
70
+ ]
71
+
72
+ const meta = {
73
+ title: "Components/Listbox",
74
+ component: Listbox,
75
+ parameters: { layout: "padded" },
76
+ argTypes: { placement: placementArgType },
77
+ args: { placement: "bottom-start" },
78
+ } satisfies Meta<typeof Listbox>
79
+
80
+ export default meta
81
+
82
+ type Story = StoryObj<typeof meta>
83
+
84
+ const ListboxHarness = ({
85
+ initialSelected,
86
+ options,
87
+ triggerLabel,
88
+ }: {
89
+ initialSelected?: string
90
+ options: ListboxItem[]
91
+ triggerLabel: string
92
+ }): ReactNode => {
93
+ const { hide, isVisible, toggle } = useVisibility()
94
+
95
+ const [selected, setSelected] = useState<
96
+ string | undefined
97
+ >(initialSelected)
98
+
99
+ const current = options.find(
100
+ (option) => option.value === selected,
101
+ )
102
+
103
+ const label =
104
+ current === undefined
105
+ ? triggerLabel
106
+ : (current.textValue ??
107
+ (typeof current.label === "string"
108
+ ? current.label
109
+ : triggerLabel))
110
+
111
+ return (
112
+ <Listbox
113
+ isVisible={isVisible}
114
+ onDismiss={hide}
115
+ onSelect={setSelected}
116
+ options={options}
117
+ selectedValue={selected}
118
+ trigger={
119
+ <Button appearance="outline" onClick={toggle}>
120
+ {label}
121
+ </Button>
122
+ }
123
+ />
124
+ )
125
+ }
126
+
127
+ export const Default: Story = {
128
+ args: {
129
+ isVisible: false,
130
+ onDismiss: () => {},
131
+ onSelect: () => {},
132
+ options: LANGUAGES,
133
+ trigger: <Button>Choose a language</Button>,
134
+ },
135
+ render: () => (
136
+ <ListboxHarness
137
+ options={LANGUAGES}
138
+ triggerLabel="Choose a language"
139
+ />
140
+ ),
141
+ }
142
+
143
+ /**
144
+ * A disabled option stays in the list and stays announced — it joins
145
+ * the choice but not the focus group, so the arrow keys and type-ahead
146
+ * skip it. `Spanish` is seeded as the current value.
147
+ */
148
+ export const AllStates: Story = {
149
+ args: {
150
+ isVisible: false,
151
+ onDismiss: () => {},
152
+ onSelect: () => {},
153
+ options: LANGUAGES,
154
+ trigger: <Button>Choose a language</Button>,
155
+ },
156
+ render: () => (
157
+ <ListboxHarness
158
+ initialSelected="spa"
159
+ options={LANGUAGES}
160
+ triggerLabel="Choose a language"
161
+ />
162
+ ),
163
+ }
164
+
165
+ /**
166
+ * The thing a native `<select>` cannot do: two-line options, a leading
167
+ * flag, and a trailing intent-coloured badge.
168
+ */
169
+ export const AllVariants: Story = {
170
+ args: {
171
+ isVisible: false,
172
+ onDismiss: () => {},
173
+ onSelect: () => {},
174
+ options: TRACKS,
175
+ trigger: <Button>Choose a track</Button>,
176
+ },
177
+ render: () => (
178
+ <StoryRow>
179
+ <ListboxHarness
180
+ options={TRACKS}
181
+ triggerLabel="Choose a track"
182
+ />
183
+ </StoryRow>
184
+ ),
185
+ }
186
+
187
+ /**
188
+ * Open it and drive it from the keyboard: arrows move focus without
189
+ * selecting, type a letter to jump, Enter chooses, Escape closes.
190
+ */
191
+ export const Interactive: Story = {
192
+ args: {
193
+ isVisible: false,
194
+ onDismiss: () => {},
195
+ onSelect: () => {},
196
+ options: LANGUAGES,
197
+ trigger: <Button>Choose a language</Button>,
198
+ },
199
+ render: () => (
200
+ <ListboxHarness
201
+ options={LANGUAGES}
202
+ triggerLabel="Choose a language"
203
+ />
204
+ ),
205
+ }
@@ -0,0 +1,198 @@
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 "./Listbox.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("a button opens a listbox whose rows are options", async () => {
14
+ const { body, canvas } = await mountStory(Default)
15
+
16
+ const trigger = expectAgentDrivable(canvas, {
17
+ name: "Choose a language",
18
+ role: "button",
19
+ })
20
+
21
+ // The trigger stays a native button that *opens* a listbox — it is
22
+ // not a `combobox`. It gains `aria-haspopup="listbox"`.
23
+ await expect(trigger).toHaveAttribute(
24
+ "aria-haspopup",
25
+ "listbox",
26
+ )
27
+
28
+ await userEvent.click(trigger)
29
+
30
+ const listbox = expectAgentDrivable(body, {
31
+ role: "listbox",
32
+ })
33
+
34
+ // `option`, not `menuitem` — a value being chosen, so it carries
35
+ // `aria-selected`.
36
+ const english = expectAgentDrivable(body, {
37
+ name: "English",
38
+ role: "option",
39
+ })
40
+
41
+ await expect(english).toHaveAttribute(
42
+ "aria-selected",
43
+ "false",
44
+ )
45
+
46
+ await expectNoAxeViolations(listbox)
47
+ })
48
+
49
+ test("opening focuses the seeded selection, which reads selected", async () => {
50
+ const { body, canvas } = await mountStory(AllStates)
51
+
52
+ // The trigger reflects the seeded value.
53
+ await userEvent.click(
54
+ expectAgentDrivable(canvas, {
55
+ name: "Spanish",
56
+ role: "button",
57
+ }),
58
+ )
59
+
60
+ const spanish = expectAgentDrivable(body, {
61
+ name: "Spanish",
62
+ role: "option",
63
+ })
64
+
65
+ await expect(spanish).toHaveAttribute(
66
+ "aria-selected",
67
+ "true",
68
+ )
69
+
70
+ // The APG rule: a listbox opens on its current value.
71
+ await waitFor(() => {
72
+ expect(spanish).toHaveFocus()
73
+ })
74
+ })
75
+
76
+ test("the arrow keys move focus and skip the disabled option", async () => {
77
+ const { body, canvas } = await mountStory(Default)
78
+
79
+ await userEvent.click(
80
+ expectAgentDrivable(canvas, {
81
+ name: "Choose a language",
82
+ role: "button",
83
+ }),
84
+ )
85
+
86
+ const english = expectAgentDrivable(body, {
87
+ name: "English",
88
+ role: "option",
89
+ })
90
+
91
+ const japanese = expectAgentDrivable(body, {
92
+ name: "Japanese",
93
+ role: "option",
94
+ })
95
+
96
+ await expect(
97
+ expectAgentDrivable(body, {
98
+ name: "German",
99
+ role: "option",
100
+ }),
101
+ ).toBeDisabled()
102
+
103
+ await waitFor(() => {
104
+ expect(english).toHaveFocus()
105
+ })
106
+
107
+ // English → Spanish → French → Japanese, skipping the disabled
108
+ // German, which never joined the focus group.
109
+ await userEvent.keyboard(
110
+ "{ArrowDown}{ArrowDown}{ArrowDown}",
111
+ )
112
+
113
+ await waitFor(() => {
114
+ expect(japanese).toHaveFocus()
115
+ })
116
+ })
117
+
118
+ test("type-ahead jumps to the first match", async () => {
119
+ const { body, canvas } = await mountStory(Default)
120
+
121
+ await userEvent.click(
122
+ expectAgentDrivable(canvas, {
123
+ name: "Choose a language",
124
+ role: "button",
125
+ }),
126
+ )
127
+
128
+ const japanese = expectAgentDrivable(body, {
129
+ name: "Japanese",
130
+ role: "option",
131
+ })
132
+
133
+ await userEvent.keyboard("j")
134
+
135
+ await waitFor(() => {
136
+ expect(japanese).toHaveFocus()
137
+ })
138
+ })
139
+
140
+ test("Enter selects the focused option and closes", async () => {
141
+ const { body, canvas } = await mountStory(Interactive)
142
+
143
+ await userEvent.click(
144
+ expectAgentDrivable(canvas, {
145
+ name: "Choose a language",
146
+ role: "button",
147
+ }),
148
+ )
149
+
150
+ // English is first and focused; arrow to Spanish and choose it.
151
+ await userEvent.keyboard("{ArrowDown}{Enter}")
152
+
153
+ await waitFor(() => {
154
+ expect(body.queryByRole("listbox")).toBeNull()
155
+ })
156
+
157
+ // Choosing flows back through `onSelect`, so the trigger now reads
158
+ // the chosen value.
159
+ expectAgentDrivable(canvas, {
160
+ name: "Spanish",
161
+ role: "button",
162
+ })
163
+ })
164
+
165
+ test("Escape dismisses without choosing, and there is one tab stop", async () => {
166
+ const { body, canvas } = await mountStory(Default)
167
+
168
+ await userEvent.click(
169
+ expectAgentDrivable(canvas, {
170
+ name: "Choose a language",
171
+ role: "button",
172
+ }),
173
+ )
174
+
175
+ const listbox = expectAgentDrivable(body, {
176
+ role: "listbox",
177
+ })
178
+
179
+ const tabbable = Array.from(
180
+ listbox.querySelectorAll<HTMLButtonElement>(
181
+ '[role="option"]',
182
+ ),
183
+ ).filter((one) => one.tabIndex === 0)
184
+
185
+ await expect(tabbable).toHaveLength(1)
186
+
187
+ await userEvent.keyboard("{Escape}")
188
+
189
+ await waitFor(() => {
190
+ expect(body.queryByRole("listbox")).toBeNull()
191
+ })
192
+
193
+ // Unchanged trigger — nothing was chosen.
194
+ expectAgentDrivable(canvas, {
195
+ name: "Choose a language",
196
+ role: "button",
197
+ })
198
+ })
@@ -0,0 +1,295 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useSinglePicker,
5
+ } from "@charcuterie/logic"
6
+ import type { Placement } from "@floating-ui/react"
7
+ import {
8
+ FloatingFocusManager,
9
+ FloatingPortal,
10
+ } from "@floating-ui/react"
11
+ import type { ReactElement, ReactNode } from "react"
12
+ import { useEffect, useRef } from "react"
13
+
14
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
15
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+ import { ListboxOption } from "./ListboxOption.tsx"
18
+
19
+ export type ListboxItem = {
20
+ isDisabled?: boolean
21
+ label: ReactNode
22
+ /**
23
+ * Required when `label` is rich (an icon, two lines, a badge): it is
24
+ * the type-ahead target and the accessible name a screen reader
25
+ * reads. When `label` is a plain string it is used as the fallback.
26
+ */
27
+ textValue?: string
28
+ value: string
29
+ }
30
+
31
+ export type ListboxProps = {
32
+ className?: string
33
+ isVisible: boolean
34
+ /** Outside press and Escape both land here. */
35
+ onDismiss: () => void
36
+ onSelect: (value: string) => void
37
+ options: readonly ListboxItem[]
38
+ placement?: Placement
39
+ /** The initial seed. The live selection is the listbox's after that. */
40
+ selectedValue?: string
41
+ /** The control the listbox hangs off. **Cloned, not wrapped.** */
42
+ trigger: ReactElement
43
+ }
44
+
45
+ const getOptionText = (item: ListboxItem) =>
46
+ (
47
+ item.textValue ??
48
+ (typeof item.label === "string" ? item.label : "")
49
+ ).toLowerCase()
50
+
51
+ /**
52
+ * The single-select fancy dropdown — the rich-option sibling of the
53
+ * native `Select`. `Select` keeps type-ahead, the mobile wheel, form
54
+ * submission and autofill; `Listbox` is for the cases `<option>`
55
+ * cannot render: an icon, two lines, a trailing badge.
56
+ *
57
+ * A listbox is **not** a menu, and that distinction is the whole
58
+ * reason it is a separate component from `Menu`. An `option` *is*
59
+ * something you are choosing — so it carries `aria-selected`, it
60
+ * type-aheads (which the APG requires of a listbox and `Menu`
61
+ * deliberately omits), and a screen reader announces "listbox,
62
+ * selected, 2 of 4". A `menuitem` only ever *does* something.
63
+ *
64
+ * ### Two kinds of state, composed
65
+ *
66
+ * `useRovingFocus` owns which option is tabbable (exactly one, so Tab
67
+ * enters and leaves the group once and the arrow keys move within it),
68
+ * and `useSinglePicker` owns which option is chosen. They are the same
69
+ * two kinds `Tabs` composes, and they never disturb each other:
70
+ * arrowing moves focus without selecting, Enter selects the focused
71
+ * option.
72
+ *
73
+ * ### Portalled, and named by its trigger
74
+ *
75
+ * Like `Menu`, the panel portals to `document.body` and `useRole` names
76
+ * it by pointing `aria-labelledby` at the trigger — so there is no
77
+ * `heading` prop; the button that opens it is its label.
78
+ */
79
+ export const Listbox = ({
80
+ className,
81
+ isVisible,
82
+ onDismiss,
83
+ onSelect,
84
+ options,
85
+ placement = "bottom-start",
86
+ selectedValue,
87
+ trigger,
88
+ }: ListboxProps): ReactNode => {
89
+ const optionElements = useRef(
90
+ new Map<string, HTMLButtonElement>(),
91
+ )
92
+
93
+ const typeahead = useRef({ buffer: "", stampMs: 0 })
94
+
95
+ const focus = useRovingFocus<string>()
96
+
97
+ const picker = useSinglePicker<string>({ selectedValue })
98
+
99
+ const { activeValue, registeredValues, setActiveValue } =
100
+ focus
101
+
102
+ const [firstValue] = registeredValues
103
+
104
+ const {
105
+ clonedTrigger,
106
+ context,
107
+ floatingStyles,
108
+ getFloatingProps,
109
+ setFloating,
110
+ triggerId,
111
+ } = useAnchoredOverlay({
112
+ isVisible,
113
+ maxHeightPx: 384,
114
+ offsetValue: 4,
115
+ onDismiss,
116
+ placement,
117
+ role: "listbox",
118
+ trigger,
119
+ })
120
+
121
+ const chooseValue = (value: string) => {
122
+ picker.select(value)
123
+
124
+ onSelect(value)
125
+
126
+ // A single choice closes, the way the native `<select>` it stands
127
+ // in for does — the consumer's `onDismiss` is the same one an
128
+ // outside press and Escape use.
129
+ onDismiss()
130
+ }
131
+
132
+ /**
133
+ * Opening lands the focus on the chosen option, or the first when
134
+ * nothing is chosen yet — the APG rule for a listbox, and the reason
135
+ * a reopened one resumes on the current value rather than the top.
136
+ */
137
+ useEffect(() => {
138
+ if (!isVisible) {
139
+ return
140
+ }
141
+
142
+ const seed = picker.selectedValue ?? firstValue
143
+
144
+ if (seed !== undefined && seed !== null) {
145
+ setActiveValue(seed)
146
+ }
147
+ }, [
148
+ firstValue,
149
+ isVisible,
150
+ picker.selectedValue,
151
+ setActiveValue,
152
+ ])
153
+
154
+ useEffect(() => {
155
+ if (!isVisible || activeValue === null) {
156
+ return
157
+ }
158
+
159
+ optionElements.current.get(activeValue)?.focus()
160
+ }, [activeValue, isVisible])
161
+
162
+ const runTypeahead = (character: string) => {
163
+ // Reset the buffer once the typing pauses, so "sk" then a beat
164
+ // then "e" is two searches rather than "ske".
165
+ const now = Date.now()
166
+
167
+ typeahead.current.buffer =
168
+ now - typeahead.current.stampMs > 500
169
+ ? character
170
+ : typeahead.current.buffer + character
171
+
172
+ typeahead.current.stampMs = now
173
+
174
+ const query = typeahead.current.buffer.toLowerCase()
175
+
176
+ const match = options.find(
177
+ (option) =>
178
+ !option.isDisabled &&
179
+ getOptionText(option).startsWith(query),
180
+ )
181
+
182
+ if (match) {
183
+ setActiveValue(match.value)
184
+ }
185
+ }
186
+
187
+ return (
188
+ <>
189
+ {clonedTrigger}
190
+
191
+ {isVisible ? (
192
+ <FloatingPortal>
193
+ <FloatingFocusManager
194
+ context={context}
195
+ initialFocus={-1}
196
+ modal={false}
197
+ >
198
+ <div
199
+ {...getFloatingProps()}
200
+ // A bare `role="listbox"` is an ARIA input field and must
201
+ // be named; pointing at the trigger across the portal is
202
+ // the same link `useRole` makes for a `Menu`.
203
+ aria-labelledby={triggerId}
204
+ className={toClassName(
205
+ PANEL_SURFACE_CLASS,
206
+ // The height is clamped to the viewport by the `size`
207
+ // middleware (`maxHeightPx`), written as an inline
208
+ // style; this scrolls within it.
209
+ "z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 overflow-auto p-1",
210
+ className,
211
+ )}
212
+ onKeyDown={(keyEvent) => {
213
+ const commands: Record<string, () => void> =
214
+ {
215
+ ArrowDown: focus.next,
216
+ ArrowUp: focus.previous,
217
+ End: focus.last,
218
+ Home: focus.first,
219
+ }
220
+
221
+ const command = commands[keyEvent.key]
222
+
223
+ if (command) {
224
+ keyEvent.preventDefault()
225
+
226
+ command()
227
+
228
+ return
229
+ }
230
+
231
+ if (
232
+ (keyEvent.key === "Enter" ||
233
+ keyEvent.key === " ") &&
234
+ activeValue !== null
235
+ ) {
236
+ keyEvent.preventDefault()
237
+
238
+ chooseValue(activeValue)
239
+
240
+ return
241
+ }
242
+
243
+ // Type-ahead: a single printable character with no
244
+ // modifier is a search, not a shortcut.
245
+ if (
246
+ keyEvent.key.length === 1 &&
247
+ !keyEvent.altKey &&
248
+ !keyEvent.ctrlKey &&
249
+ !keyEvent.metaKey
250
+ ) {
251
+ runTypeahead(keyEvent.key)
252
+ }
253
+ }}
254
+ ref={setFloating}
255
+ // Duplicated from `getFloatingProps()` on purpose, and
256
+ // the two cannot differ — `useRole(context, { role:
257
+ // "listbox" })` in the hook is where it comes from.
258
+ // Stated here because a linter cannot see a role through
259
+ // a spread.
260
+ role="listbox"
261
+ style={floatingStyles}
262
+ >
263
+ {options.map((option) => (
264
+ <ListboxOption
265
+ isSelected={
266
+ picker.selectedValue === option.value
267
+ }
268
+ item={option}
269
+ key={option.value}
270
+ onSelect={chooseValue}
271
+ registerFocus={focus.register}
272
+ registerSelection={picker.register}
273
+ tabIndex={selectTabIndex(
274
+ focus,
275
+ option.value,
276
+ )}
277
+ trackElement={(value, element) => {
278
+ if (element) {
279
+ optionElements.current.set(
280
+ value,
281
+ element,
282
+ )
283
+ } else {
284
+ optionElements.current.delete(value)
285
+ }
286
+ }}
287
+ />
288
+ ))}
289
+ </div>
290
+ </FloatingFocusManager>
291
+ </FloatingPortal>
292
+ ) : null}
293
+ </>
294
+ )
295
+ }