@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.
- 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 +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.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 +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -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 +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- 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 +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -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 +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -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 +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- 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
|
+
}
|