@charcuterie/ui 0.1.1 → 1.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 (115) 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 +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -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
+ }
@@ -0,0 +1,95 @@
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 { MenuItem } from "./Menu.tsx"
7
+
8
+ export type MenuActionProps = {
9
+ item: MenuItem
10
+ onDismiss: () => void
11
+ /** `RovingFocus.register` — membership of the arrow-key group. */
12
+ register: (value: string) => () => void
13
+ tabIndex: number
14
+ trackElement: (
15
+ key: string,
16
+ element: HTMLButtonElement | null,
17
+ ) => void
18
+ }
19
+
20
+ /**
21
+ * Its own file because **registration is an effect**, and an effect
22
+ * cannot run in a loop — the same reason `TabTrigger` and
23
+ * `AccordionSection` are separate. Any member of a registering group
24
+ * ends up shaped like this.
25
+ *
26
+ * A disabled item is simply not registered, so the arrow keys skip
27
+ * it without any command in `RovingFocus` knowing what "disabled"
28
+ * means. It stays in the DOM and stays announced, which is the
29
+ * difference between "you cannot do this right now" and "this does
30
+ * not exist".
31
+ */
32
+ export const MenuAction = ({
33
+ item,
34
+ onDismiss,
35
+ register,
36
+ tabIndex,
37
+ trackElement,
38
+ }: MenuActionProps): ReactNode => {
39
+ const {
40
+ icon,
41
+ isDisabled = false,
42
+ key,
43
+ label,
44
+ onSelect,
45
+ } = item
46
+
47
+ useEffect(() => {
48
+ if (isDisabled) {
49
+ return
50
+ }
51
+
52
+ return register(key)
53
+ }, [isDisabled, key, register])
54
+
55
+ return (
56
+ <button
57
+ className={toClassName(
58
+ "flex w-full cursor-pointer items-center 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",
59
+ "hover:bg-intent-neutral-surface",
60
+ isDisabled &&
61
+ "cursor-not-allowed text-content-disabled hover:bg-transparent",
62
+ FOCUS_RING_CLASS,
63
+ )}
64
+ disabled={isDisabled}
65
+ onClick={() => {
66
+ onSelect()
67
+
68
+ // Choosing dismisses. A menu that stays open after an action
69
+ // has fired is the one interaction users read as "it didn't
70
+ // work", and every one of the fleet's menus closes here
71
+ // today by unmounting its whole parent.
72
+ onDismiss()
73
+ }}
74
+ ref={(element) => {
75
+ trackElement(key, element)
76
+ }}
77
+ role="menuitem"
78
+ tabIndex={tabIndex}
79
+ type="button"
80
+ >
81
+ {icon === undefined ? null : (
82
+ <span
83
+ // Decoration beside the item's own text. An icon that
84
+ // announces itself turns "Delete" into "trash Delete".
85
+ aria-hidden="true"
86
+ className="shrink-0 text-content-secondary"
87
+ >
88
+ {icon}
89
+ </span>
90
+ )}
91
+
92
+ {label}
93
+ </button>
94
+ )
95
+ }
@@ -0,0 +1,74 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SelectStories from "./Select.stories";
4
+
5
+ <Meta of={SelectStories} />
6
+
7
+ # Select
8
+
9
+ Eighteen sites in the fleet, and they split cleanly in two: **twelve native `<select>`s**
10
+ that differ only in their Tailwind classes, and **six hand-rolled `role="listbox"`
11
+ controls**. This component is the first twelve. The other six are a different pattern —
12
+ see below.
13
+
14
+ <Canvas of={SelectStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## There is no state kind here, and that is the finding
21
+
22
+ Every other interactive component in this package owns its state through
23
+ `@charcuterie/logic`. This one owns **none**, because the platform already does — a
24
+ `<select>` holds the chosen option in the DOM, and putting a `useSinglePicker` beside it
25
+ would create the exact conflict the state layer exists to prevent:
26
+
27
+ > Two things believing they hold `value`.
28
+
29
+ That is the Radix argument from `Popover`, pointed at ourselves. So `value` seeds
30
+ `defaultValue` and changes are reported outward.
31
+
32
+ The consequence worth knowing: **`Select` is uncontrolled and `Tabs` is not**, and a reader
33
+ should be able to tell why without asking. A tab bar's selection has no DOM representation,
34
+ so something has to hold it. A select's does.
35
+
36
+ <Canvas of={SelectStories.AllVariants} />
37
+
38
+ ## Native, and what that rules out
39
+
40
+ | Native gives you | A hand-rolled listbox rebuilds |
41
+ | --- | --- |
42
+ | Type-ahead | a keystroke buffer and a timer |
43
+ | Home / End / PageUp / PageDown | four more key handlers |
44
+ | The mobile wheel picker | nothing that feels native |
45
+ | Form submission and `:invalid` | a hidden input |
46
+ | Autofill | — |
47
+
48
+ What it cannot do is render a rich option — an icon, two lines, a badge — or filter.
49
+ mux-magic has six controls that need one of those (`CommandPicker`, `PathPicker`,
50
+ `LinkPicker`, `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and **every one of them
51
+ is a `Combobox`**: a text input that filters a listbox. Different pattern, different ARIA
52
+ contract, P2 in the plan.
53
+
54
+ Building a bare `Listbox` here that none of the six could adopt would have shipped a
55
+ component with no caller — the mistake this milestone was explicitly checking for.
56
+
57
+ ## The placeholder is a disabled option
58
+
59
+ Not an empty-valued one. An enabled `<option value="">` looks identical, is selectable, and
60
+ silently posts `""`. Disabled, it reads as a prompt and can never be submitted.
61
+
62
+ <Canvas of={SelectStories.AllStates} />
63
+
64
+ ## `appearance-none` takes the arrow away
65
+
66
+ Removing the UA's own chevron and forgetting to put one back leaves a control that looks
67
+ exactly like a text input. The replacement is `aria-hidden` decoration in the same grid
68
+ cell, with `pe-9` reserving room for it.
69
+
70
+ ## Naming, once
71
+
72
+ Either `label` (which becomes `aria-label`) **or** a surrounding `Field` — never both. Two
73
+ naming mechanisms firing at once means the accessible name is whichever won, which is not a
74
+ thing a reader can predict.
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import { Field } from "../Field/Field.tsx"
9
+ import type { SelectItem } from "./Select.tsx"
10
+ import { Select } from "./Select.tsx"
11
+
12
+ const PROFILES: SelectItem[] = [
13
+ { label: "Lossless remux", value: "lossless" },
14
+ { label: "Compressed 1080p", value: "compressed" },
15
+ { isDisabled: true, label: "Dolby Vision", value: "dv" },
16
+ ]
17
+
18
+ const GROUPED: SelectItem[] = [
19
+ {
20
+ label: "Video",
21
+ options: [
22
+ { label: "Remux", value: "remux" },
23
+ { label: "Transcode", value: "transcode" },
24
+ ],
25
+ },
26
+ {
27
+ label: "Audio",
28
+ options: [
29
+ { label: "Passthrough", value: "passthrough" },
30
+ { label: "Downmix", value: "downmix" },
31
+ ],
32
+ },
33
+ ]
34
+
35
+ const meta = {
36
+ title: "Components/Select",
37
+ component: Select,
38
+ parameters: { layout: "padded" },
39
+ argTypes: { size: controlSizeArgType },
40
+ args: {
41
+ isDisabled: false,
42
+ options: PROFILES,
43
+ size: "md",
44
+ },
45
+ } satisfies Meta<typeof Select>
46
+
47
+ export default meta
48
+
49
+ type Story = StoryObj<typeof meta>
50
+
51
+ export const Default: Story = {
52
+ args: { label: "Rip profile", options: PROFILES },
53
+ }
54
+
55
+ export const AllVariants: Story = {
56
+ args: { label: "Rip profile", options: PROFILES },
57
+ render: () => (
58
+ <StoryGrid columns={2}>
59
+ <StoryCell label="sm">
60
+ <Select
61
+ label="Profile sm"
62
+ options={PROFILES}
63
+ size="sm"
64
+ />
65
+ </StoryCell>
66
+
67
+ <StoryCell label="lg">
68
+ <Select
69
+ label="Profile lg"
70
+ options={PROFILES}
71
+ size="lg"
72
+ />
73
+ </StoryCell>
74
+
75
+ <StoryCell label="grouped">
76
+ <Select label="Stream kind" options={GROUPED} />
77
+ </StoryCell>
78
+
79
+ <StoryCell label="placeholder">
80
+ <Select
81
+ label="Profile with prompt"
82
+ options={PROFILES}
83
+ placeholder="Choose a profile…"
84
+ />
85
+ </StoryCell>
86
+ </StoryGrid>
87
+ ),
88
+ }
89
+
90
+ /**
91
+ * The disabled option is still announced and still unselectable —
92
+ * "you cannot do this right now", rather than the option not
93
+ * existing. A `placeholder` is the same trick at the top of the
94
+ * list: disabled, so it reads as a prompt and can never be
95
+ * submitted.
96
+ */
97
+ export const AllStates: Story = {
98
+ args: { label: "Rip profile", options: PROFILES },
99
+ render: () => (
100
+ <StoryGrid columns={3}>
101
+ <StoryCell label="default">
102
+ <Select
103
+ label="Profile default"
104
+ options={PROFILES}
105
+ />
106
+ </StoryCell>
107
+
108
+ <StoryCell label="disabled">
109
+ <Select
110
+ isDisabled
111
+ label="Profile disabled"
112
+ options={PROFILES}
113
+ />
114
+ </StoryCell>
115
+
116
+ <StoryCell label="in a Field">
117
+ <Field
118
+ description="Applied to every title on the disc."
119
+ label="Profile in a field"
120
+ >
121
+ <Select options={PROFILES} />
122
+ </Field>
123
+ </StoryCell>
124
+ </StoryGrid>
125
+ ),
126
+ }