@charcuterie/ui 3.21.0 → 3.23.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 (49) hide show
  1. package/README.md +22 -0
  2. package/dist/Board/useBoardDrag.d.ts +15 -1
  3. package/dist/Board/useBoardDrag.d.ts.map +1 -1
  4. package/dist/Board/useBoardDrag.js +3 -3
  5. package/dist/Board/useBoardDrag.js.map +1 -1
  6. package/dist/DropRail/DropRail.d.ts +109 -0
  7. package/dist/DropRail/DropRail.d.ts.map +1 -0
  8. package/dist/DropRail/DropRail.js +277 -0
  9. package/dist/DropRail/DropRail.js.map +1 -0
  10. package/dist/NavBar/NavBar.d.ts +130 -0
  11. package/dist/NavBar/NavBar.d.ts.map +1 -0
  12. package/dist/NavBar/NavBar.js +114 -0
  13. package/dist/NavBar/NavBar.js.map +1 -0
  14. package/dist/NavBar/NavBarLink.d.ts +43 -0
  15. package/dist/NavBar/NavBarLink.d.ts.map +1 -0
  16. package/dist/NavBar/NavBarLink.js +67 -0
  17. package/dist/NavBar/NavBarLink.js.map +1 -0
  18. package/dist/NavBar/navBarCurrent.d.ts +37 -0
  19. package/dist/NavBar/navBarCurrent.d.ts.map +1 -0
  20. package/dist/NavBar/navBarCurrent.js +48 -0
  21. package/dist/NavBar/navBarCurrent.js.map +1 -0
  22. package/dist/ReorderList/ReorderList.d.ts +131 -0
  23. package/dist/ReorderList/ReorderList.d.ts.map +1 -0
  24. package/dist/ReorderList/ReorderList.js +154 -0
  25. package/dist/ReorderList/ReorderList.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/Board/useBoardDrag.ts +17 -2
  32. package/src/DropRail/DropRail.mdx +90 -0
  33. package/src/DropRail/DropRail.stories.tsx +328 -0
  34. package/src/DropRail/DropRail.test.tsx +154 -0
  35. package/src/DropRail/DropRail.tsx +496 -0
  36. package/src/NavBar/NavBar.mdx +157 -0
  37. package/src/NavBar/NavBar.stories.tsx +350 -0
  38. package/src/NavBar/NavBar.test.tsx +344 -0
  39. package/src/NavBar/NavBar.tsx +271 -0
  40. package/src/NavBar/NavBarLink.tsx +139 -0
  41. package/src/NavBar/navBarCurrent.test.ts +81 -0
  42. package/src/NavBar/navBarCurrent.ts +64 -0
  43. package/src/ReorderList/ReorderList.mdx +83 -0
  44. package/src/ReorderList/ReorderList.stories.tsx +149 -0
  45. package/src/ReorderList/ReorderList.test.tsx +190 -0
  46. package/src/ReorderList/ReorderList.tsx +331 -0
  47. package/src/icons.storyHelpers.tsx +12 -0
  48. package/src/index.ts +17 -0
  49. package/src/sourceRules.test.ts +37 -1
@@ -0,0 +1,154 @@
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 * as stories from "./DropRail.stories.tsx"
8
+
9
+ const { AllStates, Default, InAList, ManyTargets } =
10
+ composeStories(stories)
11
+
12
+ /**
13
+ * The pointer half of this component cannot be driven convincingly
14
+ * here, and `boardMove.ts` next door documents why: `userEvent` can
15
+ * dispatch `pointermove`, but the hit test is
16
+ * `document.elementFromPoint`, which needs a real layout at real
17
+ * coordinates. So these tests prove the parts a browser test can
18
+ * actually prove — the roles, the three chip states, the keyboard
19
+ * path, and that a commit reports the destination out loud — and
20
+ * the pointer path is left to the story a person drives.
21
+ *
22
+ * That split is deliberate rather than a gap being excused. The
23
+ * keyboard path is the one WCAG 2.5.7 requires, so it is the one
24
+ * that has to be red/green.
25
+ */
26
+
27
+ test("the rail is a listbox of destinations", async () => {
28
+ const { canvas } = await mountStory(Default)
29
+
30
+ const rail = canvas.getByRole("listbox", {
31
+ name: "Move to which project",
32
+ })
33
+
34
+ expect(rail).toBeInTheDocument()
35
+
36
+ expect(
37
+ canvas.getAllByRole("option").length,
38
+ ).toBeGreaterThan(1)
39
+ })
40
+
41
+ test("a closed rail renders nothing at all", async () => {
42
+ // `InAList` starts with no move in flight, which is the closed
43
+ // case as a host actually produces it. A rail that left an empty
44
+ // listbox behind would still be announced as one, and would still
45
+ // be a Tab stop on a page where nothing is being moved.
46
+ const { canvas } = await mountStory(InAList)
47
+
48
+ expect(canvas.queryByRole("listbox")).toBeNull()
49
+
50
+ expect(canvas.queryAllByRole("option")).toHaveLength(0)
51
+ })
52
+
53
+ test("the group a card is already in is shown but not offerable", async () => {
54
+ const { canvas } = await mountStory(Default)
55
+
56
+ const current = canvas.getByRole("option", {
57
+ name: /Atlas Ingest/,
58
+ })
59
+
60
+ expect(current).toHaveAttribute("aria-disabled", "true")
61
+
62
+ // Said out loud, not left to the dimmer colour. WCAG 1.4.1.
63
+ expect(current).toHaveTextContent("where it is now")
64
+ })
65
+
66
+ test("an archived destination is refused", async () => {
67
+ const { canvas } = await mountStory(Default)
68
+
69
+ expect(
70
+ canvas.getByRole("option", {
71
+ name: /Signal Kitchen/,
72
+ }),
73
+ ).toHaveAttribute("aria-disabled", "true")
74
+ })
75
+
76
+ test("the arrow keys walk the rail and Enter commits", async () => {
77
+ const { canvas } = await mountStory(InAList)
78
+
79
+ await userEvent.click(
80
+ canvas.getByRole("button", {
81
+ name: /Move Re-point the garden wall/,
82
+ }),
83
+ )
84
+
85
+ const rail = await waitFor(() =>
86
+ canvas.getByRole("listbox", {
87
+ name: "Move to which project",
88
+ }),
89
+ )
90
+
91
+ expect(rail).toBeInTheDocument()
92
+
93
+ // The first offerable chip takes focus when a pointer is not
94
+ // driving, so the keyboard path starts somewhere real.
95
+ await waitFor(() => {
96
+ expect(
97
+ canvas.getByRole("option", { name: /Loom Fleet/ }),
98
+ ).toHaveAttribute("tabindex", "0")
99
+ })
100
+
101
+ await userEvent.keyboard("{ArrowRight}")
102
+
103
+ await userEvent.keyboard("{Enter}")
104
+
105
+ await waitFor(() => {
106
+ expect(
107
+ canvas.getByText(/Last move:/),
108
+ ).toBeInTheDocument()
109
+ })
110
+ })
111
+
112
+ test("Escape abandons the move and nothing is committed", async () => {
113
+ const { canvas } = await mountStory(InAList)
114
+
115
+ await userEvent.click(
116
+ canvas.getByRole("button", {
117
+ name: /Move Label the spare keys/,
118
+ }),
119
+ )
120
+
121
+ await waitFor(() =>
122
+ canvas.getByRole("listbox", {
123
+ name: "Move to which project",
124
+ }),
125
+ )
126
+
127
+ await userEvent.keyboard("{Escape}")
128
+
129
+ await waitFor(() => {
130
+ expect(
131
+ canvas.queryByRole("listbox"),
132
+ ).not.toBeInTheDocument()
133
+ })
134
+
135
+ expect(
136
+ canvas.getByText("Nothing moved yet."),
137
+ ).toBeInTheDocument()
138
+ })
139
+
140
+ test("every destination stays reachable at the size that made a menu wrong", async () => {
141
+ const { canvas } = await mountStory(ManyTargets)
142
+
143
+ // Fifteen here rather than the Backlog's thirty-four, but the
144
+ // property under test is the one that matters: no destination is
145
+ // behind a scroll, so every chip is in the accessibility tree at
146
+ // once.
147
+ expect(canvas.getAllByRole("option")).toHaveLength(15)
148
+ })
149
+
150
+ test("no axe violations in any state", async () => {
151
+ const { canvasElement } = await mountStory(AllStates)
152
+
153
+ await expectNoAxeViolations(canvasElement)
154
+ })
@@ -0,0 +1,496 @@
1
+ import {
2
+ useLatestRef,
3
+ useRovingFocus,
4
+ useUniqueId,
5
+ } from "@charcuterie/logic"
6
+ import type { ReactNode } from "react"
7
+ import { useEffect, useRef, useState } from "react"
8
+
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
11
+
12
+ export type DropRailTarget = {
13
+ /**
14
+ * A number after the label — a lane's size, a project's open
15
+ * count. Optional, and absent is a real answer: a rail of
16
+ * destinations that have no size should not print a zero on every
17
+ * chip.
18
+ */
19
+ count?: number
20
+ /**
21
+ * Where the thing being moved already is. It stays **visible and
22
+ * unofferable** rather than being filtered out, because a rail
23
+ * whose contents change depending on what you grabbed is a rail
24
+ * whose chips move under the pointer between one move and the
25
+ * next — and muscle memory is the only reason this control is
26
+ * faster than a menu.
27
+ */
28
+ isCurrent?: boolean
29
+ isDisabled?: boolean
30
+ key: string
31
+ label: string
32
+ /** A leading glyph the app owns — an emoji, a dot, an icon. */
33
+ mark?: ReactNode
34
+ }
35
+
36
+ export type DropRailProps = {
37
+ className?: string
38
+ /**
39
+ * Whether the rail is showing. The host owns this, because only
40
+ * the host knows a move started — and it is the same boolean
41
+ * whether the move began as a pointer drag or as a tap on a
42
+ * handle.
43
+ */
44
+ isOpen: boolean
45
+ /** The rail's accessible name. Required — it is a listbox. */
46
+ label: string
47
+ onDismiss?: () => void
48
+ onPick: (targetKey: string) => void
49
+ targets: readonly DropRailTarget[]
50
+ }
51
+
52
+ /**
53
+ * The attribute the pointer hit test reads. A `data-` attribute
54
+ * rather than a class, for the same reason `Board` marks its cards
55
+ * with one: a class is a style and may be renamed by a consumer's
56
+ * tooling, and this is a contract with a hit test.
57
+ */
58
+ const TARGET_ATTRIBUTE = "data-drop-rail-target"
59
+
60
+ const TARGET_SELECTOR = "[data-drop-rail-target]"
61
+
62
+ /**
63
+ * Every destination at once, brought to the pointer instead of the
64
+ * pointer being carried to it.
65
+ *
66
+ * ### The measurement this exists for
67
+ *
68
+ * `Board` moves a card with a `Menu` on the card, and that is the
69
+ * right control for a board: three or four lanes, all on screen, and
70
+ * the menu opens where your hand already is. It stops being the
71
+ * right control at the size the fleet's real lists reach. Docket's
72
+ * Backlog draws **34 project groups down one page**, of which about
73
+ * four fit on a display. Dragging a card from the first to the
74
+ * thirty-fourth means holding a pointer down while the page scrolls,
75
+ * and a menu of 34 items is a scrolling list inside a popup — the
76
+ * same problem in a smaller box.
77
+ *
78
+ * A rail answers both. It pins to the top for the length of the
79
+ * move, so nothing is ever off screen, and there is no travel: every
80
+ * destination is the same short distance away.
81
+ *
82
+ * ### One control, three drivers, and that is the whole design
83
+ *
84
+ * WCAG 2.5.7 requires a single-pointer alternative to any dragging
85
+ * movement, and `FileDropZone` next door says the same thing about
86
+ * its own gesture: build the accessible path first and let the drag
87
+ * be the enhancement. So the rail is **not** a drag affordance with
88
+ * a fallback bolted on. It is a listbox that three things drive:
89
+ *
90
+ * 1. **Pointer.** While a button is held, the chip under the
91
+ * pointer is active and releasing over it commits.
92
+ * 2. **Tap.** Opened with no button held, it is a list you touch.
93
+ * Identical markup, identical commit.
94
+ * 3. **Keyboard.** Arrow keys walk it, Enter commits, Escape
95
+ * abandons. `useRovingFocus` owns which chip is tabbable, so
96
+ * there is no second keyboard model in this file to drift out of
97
+ * step with `Listbox`.
98
+ *
99
+ * The rail does not know which of the three opened it, and the host
100
+ * does not have to say. It reads `event.buttons` on the first
101
+ * pointer event it sees: a button already down means a drag is in
102
+ * flight, and nothing else needs threading through.
103
+ *
104
+ * ### It takes no drag-and-drop dependency, and it hit-tests chips
105
+ *
106
+ * The board's decision priced four libraries and took none of them,
107
+ * because a published library's dependency list is inherited by
108
+ * every consumer — including one with 60 KB gzipped to spend in
109
+ * total. Nothing here changes that arithmetic, and this needs far
110
+ * less than `Board` does: a chip is a box under the pointer, so
111
+ * `elementFromPoint` answers the only geometric question there is.
112
+ * No snapshot, no collision strategy, no sortable transform.
113
+ *
114
+ * ### It is not `Rail`, and the two must not be confused
115
+ *
116
+ * `Rail` is the app shell's side rail — a landmark, an `<aside>` or
117
+ * a `<nav>`, permanent furniture down one edge of a page. This is a
118
+ * horizontal strip of drop targets that exists only for the length
119
+ * of a move, and it is a `listbox`. They share four letters and
120
+ * nothing else. `FileDropZone` sets the precedent for the prefix
121
+ * carrying the distinction.
122
+ *
123
+ * ### What it deliberately does not do
124
+ *
125
+ * It does not render the thing being dragged, own the handle, or
126
+ * know what a move means. The host keeps all three, which is what
127
+ * lets the same rail sit above a board, a card grid, or a list of
128
+ * accordions without any of them being modelled here.
129
+ */
130
+ export const DropRail = ({
131
+ className,
132
+ isOpen,
133
+ label,
134
+ onDismiss,
135
+ onPick,
136
+ targets,
137
+ }: DropRailProps): ReactNode => {
138
+ const baseId = useUniqueId()
139
+
140
+ const railRef = useRef<HTMLDivElement>(null)
141
+
142
+ /**
143
+ * A pointer button was already down when the rail opened, so this
144
+ * move is a drag and it commits on release. A tap-opened rail
145
+ * commits on click instead, and the two must not both fire — a
146
+ * released drag also produces a `click`.
147
+ */
148
+ const isPointerDriven = useRef(false)
149
+
150
+ /**
151
+ * Focus is moved into the rail once per open, not once per render.
152
+ * Without the latch the effect below re-runs whenever the host
153
+ * re-renders and drags focus back to the first chip, which
154
+ * undoes every arrow key the moment anything above updates.
155
+ */
156
+ const hasTakenFocus = useRef(false)
157
+
158
+ const [announcement, setAnnouncement] = useState("")
159
+
160
+ const offerableKeys = targets
161
+ .filter(
162
+ (target) => !target.isCurrent && !target.isDisabled,
163
+ )
164
+ .map((target) => target.key)
165
+
166
+ /**
167
+ * The offerable keys as one string, because the effects below
168
+ * depend on *which* destinations are offerable and none of them
169
+ * may depend on the array's identity.
170
+ *
171
+ * `targets` is almost always a literal in the host's render, so
172
+ * the array is new on every render. An effect keyed on it
173
+ * re-registers every chip each time, and re-registering loses the
174
+ * active one — which during a keyboard walk means the arrow keys
175
+ * stop moving for no reason a consumer could ever debug.
176
+ */
177
+ const offerableKeysText = offerableKeys.join(" ")
178
+
179
+ const firstOfferableKey = offerableKeys[0]
180
+
181
+ const {
182
+ activeValue,
183
+ next,
184
+ previous,
185
+ register,
186
+ setActiveValue,
187
+ } = useRovingFocus<string>({ isWrapping: true })
188
+
189
+ /**
190
+ * Read by the global listeners, which are registered once per
191
+ * open rather than once per render. Without them the pointer
192
+ * handler closes over a stale `activeValue`, and Enter commits
193
+ * whichever chip was active when the rail opened.
194
+ */
195
+ const activeValueRef = useLatestRef(activeValue)
196
+
197
+ const offerableKeysRef = useLatestRef(offerableKeys)
198
+
199
+ const onDismissRef = useLatestRef(onDismiss)
200
+
201
+ const onPickRef = useLatestRef(onPick)
202
+
203
+ const targetsRef = useLatestRef(targets)
204
+
205
+ useEffect(() => {
206
+ const releases = offerableKeysText
207
+ .split(" ")
208
+ .filter((key) => key !== "")
209
+ .map((key) => register(key))
210
+
211
+ return () => {
212
+ for (const release of releases) {
213
+ release()
214
+ }
215
+ }
216
+ }, [offerableKeysText, register])
217
+
218
+ /**
219
+ * Closing resets everything a reopen must not inherit. Without it
220
+ * a rail reopened for a different card starts on whatever the last
221
+ * move chose, and the first arrow key moves from a place the
222
+ * person never went.
223
+ */
224
+ useEffect(() => {
225
+ if (isOpen) {
226
+ return
227
+ }
228
+
229
+ setActiveValue(null)
230
+
231
+ isPointerDriven.current = false
232
+
233
+ hasTakenFocus.current = false
234
+
235
+ setAnnouncement("")
236
+ }, [isOpen, setActiveValue])
237
+
238
+ useEffect(() => {
239
+ if (!isOpen) {
240
+ return undefined
241
+ }
242
+
243
+ const commit = (targetKey: string) => {
244
+ const target = targetsRef.current.find(
245
+ (one) => one.key === targetKey,
246
+ )
247
+
248
+ if (target) {
249
+ setAnnouncement(`Moved to ${target.label}.`)
250
+ }
251
+
252
+ onPickRef.current(targetKey)
253
+ }
254
+
255
+ const getKeyAt = (clientX: number, clientY: number) => {
256
+ const chip = globalThis.document
257
+ .elementFromPoint(clientX, clientY)
258
+ ?.closest(TARGET_SELECTOR)
259
+
260
+ return chip?.getAttribute(TARGET_ATTRIBUTE) ?? null
261
+ }
262
+
263
+ const getIsOfferable = (key: string | null) =>
264
+ key !== null && offerableKeysRef.current.includes(key)
265
+
266
+ const onPointerMove = (pointerEvent: PointerEvent) => {
267
+ if (pointerEvent.buttons === 0) {
268
+ return
269
+ }
270
+
271
+ isPointerDriven.current = true
272
+
273
+ const key = getKeyAt(
274
+ pointerEvent.clientX,
275
+ pointerEvent.clientY,
276
+ )
277
+
278
+ // `null` when the pointer is between chips or off the rail
279
+ // entirely, and that has to clear the highlight. A rail that
280
+ // keeps the last chip lit is telling you it will land
281
+ // somewhere it will not.
282
+ setActiveValue(getIsOfferable(key) ? key : null)
283
+ }
284
+
285
+ const onPointerUp = (pointerEvent: PointerEvent) => {
286
+ if (!isPointerDriven.current) {
287
+ return
288
+ }
289
+
290
+ const key = getKeyAt(
291
+ pointerEvent.clientX,
292
+ pointerEvent.clientY,
293
+ )
294
+
295
+ if (getIsOfferable(key) && key !== null) {
296
+ commit(key)
297
+
298
+ return
299
+ }
300
+
301
+ onDismissRef.current?.()
302
+ }
303
+
304
+ const onKeyDown = (keyEvent: KeyboardEvent) => {
305
+ if (keyEvent.key === "Escape") {
306
+ keyEvent.preventDefault()
307
+
308
+ onDismissRef.current?.()
309
+
310
+ return
311
+ }
312
+
313
+ if (
314
+ keyEvent.key === "ArrowRight" ||
315
+ keyEvent.key === "ArrowDown"
316
+ ) {
317
+ keyEvent.preventDefault()
318
+
319
+ next()
320
+
321
+ return
322
+ }
323
+
324
+ if (
325
+ keyEvent.key === "ArrowLeft" ||
326
+ keyEvent.key === "ArrowUp"
327
+ ) {
328
+ keyEvent.preventDefault()
329
+
330
+ previous()
331
+
332
+ return
333
+ }
334
+
335
+ const isCommitKey =
336
+ keyEvent.key === "Enter" || keyEvent.key === " "
337
+
338
+ if (isCommitKey && activeValueRef.current !== null) {
339
+ keyEvent.preventDefault()
340
+
341
+ commit(activeValueRef.current)
342
+ }
343
+ }
344
+
345
+ globalThis.addEventListener(
346
+ "pointermove",
347
+ onPointerMove,
348
+ )
349
+
350
+ globalThis.addEventListener("pointerup", onPointerUp)
351
+
352
+ globalThis.addEventListener("keydown", onKeyDown)
353
+
354
+ return () => {
355
+ globalThis.removeEventListener(
356
+ "pointermove",
357
+ onPointerMove,
358
+ )
359
+
360
+ globalThis.removeEventListener(
361
+ "pointerup",
362
+ onPointerUp,
363
+ )
364
+
365
+ globalThis.removeEventListener("keydown", onKeyDown)
366
+ }
367
+ }, [
368
+ activeValueRef,
369
+ isOpen,
370
+ next,
371
+ offerableKeysRef,
372
+ onDismissRef,
373
+ onPickRef,
374
+ previous,
375
+ setActiveValue,
376
+ targetsRef,
377
+ ])
378
+
379
+ /**
380
+ * Focus enters the rail only when a pointer is NOT driving it.
381
+ * Stealing focus mid-drag scrolls the page to the rail on some
382
+ * browsers, which is the one thing this control exists to avoid.
383
+ */
384
+ useEffect(() => {
385
+ if (
386
+ !isOpen ||
387
+ isPointerDriven.current ||
388
+ hasTakenFocus.current ||
389
+ firstOfferableKey === undefined
390
+ ) {
391
+ return
392
+ }
393
+
394
+ hasTakenFocus.current = true
395
+
396
+ setActiveValue(firstOfferableKey)
397
+
398
+ railRef.current
399
+ ?.querySelector<HTMLElement>(
400
+ "[data-drop-rail-target]:not([aria-disabled='true'])",
401
+ )
402
+ ?.focus()
403
+ }, [firstOfferableKey, isOpen, setActiveValue])
404
+
405
+ if (!isOpen) {
406
+ return null
407
+ }
408
+
409
+ return (
410
+ <>
411
+ <div
412
+ aria-label={label}
413
+ className={toClassName(
414
+ "sticky top-0 z-20 flex flex-wrap items-center gap-2 rounded-md border border-border-default bg-surface-overlay p-2 shadow-(--elevation-medium)",
415
+ className,
416
+ )}
417
+ id={baseId}
418
+ ref={railRef}
419
+ role="listbox"
420
+ >
421
+ {targets.map((target) => {
422
+ const isOfferable =
423
+ !target.isCurrent && !target.isDisabled
424
+
425
+ const isActive =
426
+ isOfferable && activeValue === target.key
427
+
428
+ return (
429
+ <button
430
+ aria-disabled={isOfferable ? undefined : true}
431
+ aria-selected={isActive}
432
+ className={toClassName(
433
+ "flex max-w-80 items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors duration-(--duration-fast) ease-standard",
434
+ isOfferable
435
+ ? "cursor-pointer border-border-default bg-surface-raised text-content-primary hover:bg-surface-sunken"
436
+ : "cursor-not-allowed border-border-subtle bg-surface-sunken text-content-disabled",
437
+ isActive &&
438
+ "border-intent-accent-border bg-intent-accent-surface text-intent-accent-content outline-solid outline-(length:--focus-ring-width) outline-focus-ring",
439
+ )}
440
+ data-drop-rail-target={target.key}
441
+ key={target.key}
442
+ onClick={() => {
443
+ // A released drag also fires a click. The pointer path
444
+ // has already committed by then, so this guard is what
445
+ // stops a drag committing twice.
446
+ if (
447
+ isPointerDriven.current ||
448
+ !isOfferable
449
+ ) {
450
+ return
451
+ }
452
+
453
+ onPick(target.key)
454
+ }}
455
+ role="option"
456
+ tabIndex={isActive ? 0 : -1}
457
+ type="button"
458
+ >
459
+ {target.mark === undefined ? null : (
460
+ <span aria-hidden>{target.mark}</span>
461
+ )}
462
+
463
+ <span className="truncate">
464
+ {target.label}
465
+ </span>
466
+
467
+ {target.count === undefined ? null : (
468
+ <span className="text-content-secondary text-xs">
469
+ {target.count}
470
+ </span>
471
+ )}
472
+
473
+ {target.isCurrent ? (
474
+ // Said out loud, not shown by dimming alone. WCAG
475
+ // 1.4.1: the one chip a person must not aim for is
476
+ // exactly the one that must not be marked by colour.
477
+ <VisuallyHidden>
478
+ — where it is now
479
+ </VisuallyHidden>
480
+ ) : null}
481
+ </button>
482
+ )
483
+ })}
484
+ </div>
485
+
486
+ {/* OUTSIDE the listbox, and that is not a style choice. A
487
+ `role="listbox"` may contain only `option` children, so a
488
+ live region inside it is an axe `aria-required-children`
489
+ violation — which is how this was found. It is `sr-only`,
490
+ so being a sibling costs no layout. */}
491
+ <output aria-live="polite" className="sr-only">
492
+ {announcement}
493
+ </output>
494
+ </>
495
+ )
496
+ }