@charcuterie/ui 2.2.0 → 2.4.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 (37) hide show
  1. package/dist/Combobox/Combobox.d.ts +22 -4
  2. package/dist/Combobox/Combobox.d.ts.map +1 -1
  3. package/dist/Combobox/Combobox.js +142 -24
  4. package/dist/Combobox/Combobox.js.map +1 -1
  5. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  6. package/dist/Combobox/ComboboxOption.js +29 -2
  7. package/dist/Combobox/ComboboxOption.js.map +1 -1
  8. package/dist/EmptyState/EmptyState.d.ts +6 -1
  9. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  10. package/dist/EmptyState/EmptyState.js.map +1 -1
  11. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  12. package/dist/Listbox/ListboxOption.js +22 -2
  13. package/dist/Listbox/ListboxOption.js.map +1 -1
  14. package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
  15. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  16. package/dist/Overlay/useAnchoredOverlay.js +23 -3
  17. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  18. package/dist/Select/Select.d.ts.map +1 -1
  19. package/dist/Select/Select.js +8 -2
  20. package/dist/Select/Select.js.map +1 -1
  21. package/dist/controlStyles.d.ts +8 -0
  22. package/dist/controlStyles.d.ts.map +1 -1
  23. package/dist/controlStyles.js +8 -0
  24. package/dist/controlStyles.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/Combobox/Combobox.mdx +30 -0
  27. package/src/Combobox/Combobox.stories.tsx +150 -5
  28. package/src/Combobox/Combobox.test.tsx +120 -1
  29. package/src/Combobox/Combobox.tsx +273 -72
  30. package/src/Combobox/ComboboxOption.tsx +30 -5
  31. package/src/EmptyState/EmptyState.stories.tsx +15 -0
  32. package/src/EmptyState/EmptyState.test.tsx +16 -1
  33. package/src/EmptyState/EmptyState.tsx +6 -1
  34. package/src/Listbox/ListboxOption.tsx +22 -4
  35. package/src/Overlay/useAnchoredOverlay.ts +49 -8
  36. package/src/Select/Select.tsx +6 -5
  37. package/src/controlStyles.ts +8 -0
@@ -3,7 +3,12 @@ import {
3
3
  FloatingPortal,
4
4
  } from "@floating-ui/react"
5
5
  import { useVirtualizer } from "@tanstack/react-virtual"
6
- import type { ReactElement, ReactNode } from "react"
6
+ import type {
7
+ ReactElement,
8
+ KeyboardEvent as ReactKeyboardEvent,
9
+ ReactNode,
10
+ RefObject,
11
+ } from "react"
7
12
  import { useEffect, useRef, useState } from "react"
8
13
 
9
14
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
@@ -20,6 +25,20 @@ export type ComboboxProps = {
20
25
  error?: ReactNode
21
26
  /** A sticky footer hint (`LinkPicker`'s "type a path directly"). */
22
27
  footer?: ReactNode
28
+ /**
29
+ * Attached-input mode. Supply the consumer's own `<input>` and
30
+ * Combobox stops rendering its internal input/chip bar: it anchors the
31
+ * popup to this element, drives arrow/Enter/Tab/Escape off it, and
32
+ * mirrors the active option onto it for a screen reader. The consumer
33
+ * owns the input's `value`/`onChange` (wire them to the controlled
34
+ * `query`, which is then required) **and** `isVisible` — so a select
35
+ * does NOT auto-dismiss in this mode; staying open (a folder-picker
36
+ * drilling in) or closing (`onSelect` calls the consumer's own close)
37
+ * is the consumer's call. Options arrive pre-filtered (as with
38
+ * `onQueryChange`). Single-select only; `isMultiple` is not supported
39
+ * here.
40
+ */
41
+ inputRef?: RefObject<HTMLInputElement | null>
23
42
  /** Enter commits the raw query as a value (`AssFieldPicker`). */
24
43
  isCreatable?: boolean
25
44
  /** The loading panel state. */
@@ -42,8 +61,12 @@ export type ComboboxProps = {
42
61
  /** Controlled query; uncontrolled when absent. */
43
62
  query?: string
44
63
  selectedValue?: readonly string[] | string
45
- /** The control the popup hangs off. **Cloned, not wrapped.** */
46
- trigger: ReactElement
64
+ /**
65
+ * The control the popup hangs off. **Cloned, not wrapped.** Omit only
66
+ * in attached-input mode (`inputRef`), where the consumer's `<input>`
67
+ * is the anchor.
68
+ */
69
+ trigger?: ReactElement
47
70
  }
48
71
 
49
72
  const AUTO_VIRTUALIZE_THRESHOLD = 100
@@ -94,6 +117,7 @@ export const Combobox = ({
94
117
  emptyLabel = "No matches",
95
118
  error,
96
119
  footer,
120
+ inputRef,
97
121
  isCreatable = false,
98
122
  isLoading = false,
99
123
  isMultiple = false,
@@ -108,7 +132,19 @@ export const Combobox = ({
108
132
  selectedValue,
109
133
  trigger,
110
134
  }: ComboboxProps): ReactNode => {
111
- const inputElement = useRef<HTMLInputElement>(null)
135
+ // Attached-input mode: the consumer's `<input>` is the reference,
136
+ // query source, focus holder and keyboard target; Combobox renders the
137
+ // list-only panel.
138
+ const isAttached = inputRef !== undefined
139
+
140
+ const internalInputElement =
141
+ useRef<HTMLInputElement>(null)
142
+
143
+ // The input Combobox drives — the consumer's in attached mode, its own
144
+ // otherwise.
145
+ const inputElement = isAttached
146
+ ? inputRef
147
+ : internalInputElement
112
148
 
113
149
  const listElement = useRef<HTMLDivElement>(null)
114
150
 
@@ -128,10 +164,11 @@ export const Combobox = ({
128
164
  : [...selectedValue],
129
165
  )
130
166
 
131
- // The consumer owns filtering when it owns the query; otherwise it is
132
- // a plain `textValue` contains-match.
167
+ // The consumer owns filtering when it owns the query or drives an
168
+ // attached input (the query is a full path, not an option-label match);
169
+ // otherwise it is a plain `textValue` contains-match.
133
170
  const filtered =
134
- onQueryChange === undefined
171
+ onQueryChange === undefined && !isAttached
135
172
  ? options.filter((option) =>
136
173
  getOptionText(option).includes(
137
174
  currentQuery.toLowerCase(),
@@ -151,6 +188,9 @@ export const Combobox = ({
151
188
  setFloating,
152
189
  triggerId,
153
190
  } = useAnchoredOverlay({
191
+ // Attached mode anchors to the consumer's input; classic mode clones
192
+ // the trigger.
193
+ anchorRef: isAttached ? inputRef : undefined,
154
194
  // Escape is handled here so it can clear the query before closing.
155
195
  isEscapeDismissable: false,
156
196
  isVisible,
@@ -158,7 +198,7 @@ export const Combobox = ({
158
198
  offsetValue: 4,
159
199
  onDismiss,
160
200
  role: "listbox",
161
- trigger,
201
+ trigger: isAttached ? undefined : trigger,
162
202
  })
163
203
 
164
204
  // The panel wraps an input *and* a listbox, so the wrapper is a
@@ -185,10 +225,21 @@ export const Combobox = ({
185
225
  }
186
226
 
187
227
  useEffect(() => {
188
- if (isVisible) {
228
+ // In attached mode focus stays where the user is typing (the
229
+ // consumer's input); Combobox never grabs it.
230
+ if (isVisible && !isAttached) {
189
231
  inputElement.current?.focus()
190
232
  }
191
- }, [isVisible])
233
+ }, [isVisible, isAttached, inputElement])
234
+
235
+ // Attached mode's query lives on the consumer's input, so `setQuery`
236
+ // (which reseeds the highlight) never runs on a keystroke — reseed here
237
+ // when the controlled query changes.
238
+ useEffect(() => {
239
+ if (isAttached) {
240
+ setActiveIndex(0)
241
+ }
242
+ }, [isAttached])
192
243
 
193
244
  const rowVirtualizer = useVirtualizer({
194
245
  count: filtered.length,
@@ -236,6 +287,22 @@ export const Combobox = ({
236
287
  return
237
288
  }
238
289
 
290
+ if (isAttached) {
291
+ // The consumer owns the value, the query and `isVisible`: report the
292
+ // pick and let it update the controlled query (→ refetch) and decide
293
+ // whether to stay open (drill-down) or close. No `onDismiss`, no
294
+ // `setQuery` (that would double-fire the consumer's query), no
295
+ // `setSelected` (the picked row leaves the list on refetch).
296
+ onSelect(value)
297
+
298
+ // Reseed the highlight for the list that is about to arrive.
299
+ setActiveIndex(0)
300
+
301
+ inputElement.current?.focus()
302
+
303
+ return
304
+ }
305
+
239
306
  setSelected([value])
240
307
 
241
308
  onSelect(value)
@@ -243,8 +310,42 @@ export const Combobox = ({
243
310
  onDismiss()
244
311
  }
245
312
 
313
+ // A chip's face is the option's human label, not its `value` — a
314
+ // language chip reads "English", not "eng". `textValue` (the rich-label
315
+ // fallback) wins, then a plain-string `label`, then the raw value only
316
+ // when the option has since left `options`.
317
+ const chipLabel = (value: string) => {
318
+ const item = options.find(
319
+ (option) => option.value === value,
320
+ )
321
+
322
+ if (item === undefined) {
323
+ return value
324
+ }
325
+
326
+ return (
327
+ item.textValue ??
328
+ (typeof item.label === "string" ? item.label : value)
329
+ )
330
+ }
331
+
332
+ const removeValue = (value: string) => {
333
+ setSelected((previous) =>
334
+ previous.filter((one) => one !== value),
335
+ )
336
+
337
+ // The parent tracks selection through `onSelect`, so removing a chip
338
+ // reports the same value the list toggle would — a symmetric toggle.
339
+ onSelect(value)
340
+ }
341
+
342
+ // Typed as either event so the same logic serves the classic mode's
343
+ // React `onKeyDown` and attached mode's native `keydown` listener —
344
+ // both carry `key`/`shiftKey`/`preventDefault`.
246
345
  const handleKeyDown = (
247
- keyEvent: React.KeyboardEvent<HTMLInputElement>,
346
+ keyEvent:
347
+ | ReactKeyboardEvent<HTMLInputElement>
348
+ | KeyboardEvent,
248
349
  ) => {
249
350
  if (keyEvent.key === "ArrowDown") {
250
351
  keyEvent.preventDefault()
@@ -299,6 +400,17 @@ export const Combobox = ({
299
400
  }
300
401
 
301
402
  if (keyEvent.key === "Escape") {
403
+ // Attached mode's query lives on the consumer's input, which
404
+ // `setQuery` cannot clear — so Escape closes outright (the
405
+ // `PathPicker` contract).
406
+ if (isAttached) {
407
+ keyEvent.preventDefault()
408
+
409
+ onDismiss()
410
+
411
+ return
412
+ }
413
+
302
414
  // Clears the query first, closes only when it is already empty.
303
415
  if (currentQuery.length > 0) {
304
416
  keyEvent.preventDefault()
@@ -341,6 +453,80 @@ export const Combobox = ({
341
453
  rowVirtualizer.scrollToIndex,
342
454
  ])
343
455
 
456
+ // ─── Attached mode: drive the consumer's input ──────────────────────
457
+ //
458
+ // Combobox does not render the input, so its keyboard handler and ARIA
459
+ // are applied to the consumer's element. A ref-to-latest keeps the
460
+ // native listener stable across keystrokes (the `PathPicker` pattern).
461
+ const handleKeyDownRef = useRef(handleKeyDown)
462
+ handleKeyDownRef.current = handleKeyDown
463
+
464
+ useEffect(() => {
465
+ if (!isAttached || !isVisible) {
466
+ return
467
+ }
468
+
469
+ const element = inputRef.current
470
+
471
+ if (!element) {
472
+ return
473
+ }
474
+
475
+ const listener = (keyEvent: KeyboardEvent) => {
476
+ handleKeyDownRef.current(keyEvent)
477
+ }
478
+
479
+ element.addEventListener("keydown", listener)
480
+
481
+ return () => {
482
+ element.removeEventListener("keydown", listener)
483
+ }
484
+ }, [isAttached, isVisible, inputRef])
485
+
486
+ // Mirror the combobox ARIA onto the consumer's input. It owns
487
+ // `value`/`onChange`; these attributes are React-unmanaged, so setting
488
+ // them imperatively is stable across the consumer's re-renders.
489
+ useEffect(() => {
490
+ if (!isAttached) {
491
+ return
492
+ }
493
+
494
+ const element = inputRef.current
495
+
496
+ if (!element) {
497
+ return
498
+ }
499
+
500
+ element.setAttribute("role", "combobox")
501
+ element.setAttribute("aria-autocomplete", "list")
502
+
503
+ if (isVisible) {
504
+ element.setAttribute("aria-expanded", "true")
505
+ element.setAttribute("aria-controls", listboxId)
506
+
507
+ if (activeValue === undefined) {
508
+ element.removeAttribute("aria-activedescendant")
509
+ } else {
510
+ // Inlined rather than via `optionDomId` so the effect depends on
511
+ // the stable `listboxId`, not a per-render closure.
512
+ element.setAttribute(
513
+ "aria-activedescendant",
514
+ `${listboxId}-opt-${activeValue}`,
515
+ )
516
+ }
517
+ } else {
518
+ element.setAttribute("aria-expanded", "false")
519
+ element.removeAttribute("aria-controls")
520
+ element.removeAttribute("aria-activedescendant")
521
+ }
522
+ }, [
523
+ isAttached,
524
+ isVisible,
525
+ activeValue,
526
+ listboxId,
527
+ inputRef,
528
+ ])
529
+
344
530
  const renderOption = (
345
531
  option: ListboxItem,
346
532
  index: number,
@@ -359,12 +545,54 @@ export const Combobox = ({
359
545
 
360
546
  return (
361
547
  <>
548
+ {/*
549
+ Multi-select chips live *here*, above the trigger and outside the
550
+ `isVisible` gate — so a picked value stays on screen as a removable
551
+ tag after the popup closes. The previous build rendered them inside
552
+ the panel, where they vanished with it and the field read as empty
553
+ (the "no way to see or remove what I picked" report). Attached mode
554
+ is single-select, so it never grows a chip row.
555
+ */}
556
+ {isMultiple && !isAttached && selected.length > 0 ? (
557
+ <div className="mb-1 flex flex-wrap items-center gap-1">
558
+ {selected.map((value) => (
559
+ // The whole chip is the remove control, not just the ✕ — a
560
+ // bigger target, and removal is the chip's only action. One
561
+ // `<button>` (the ✕ is decorative) rather than a chip wrapping
562
+ // a button, so there is no nested interactive element and the
563
+ // accessible name is just "Remove <label>". Hover tints danger
564
+ // to signal the click removes.
565
+ <button
566
+ aria-label={`Remove ${chipLabel(value)}`}
567
+ className="inline-flex cursor-pointer items-center gap-1 rounded-sm bg-intent-neutral-surface-hover px-1.5 py-0.5 text-content-primary text-xs transition-colors duration-(--duration-fast) ease-standard hover:bg-intent-danger-surface hover:text-intent-danger-content"
568
+ key={value}
569
+ onClick={() => {
570
+ removeValue(value)
571
+ }}
572
+ type="button"
573
+ >
574
+ {chipLabel(value)}
575
+
576
+ <span
577
+ aria-hidden="true"
578
+ className="text-content-secondary"
579
+ >
580
+ ✕
581
+ </span>
582
+ </button>
583
+ ))}
584
+ </div>
585
+ ) : null}
586
+
362
587
  {clonedTrigger}
363
588
 
364
589
  {isVisible ? (
365
590
  <FloatingPortal>
366
591
  <FloatingFocusManager
367
592
  context={context}
593
+ // Attached mode keeps focus in the consumer's input — the
594
+ // panel must never grab it.
595
+ disabled={isAttached}
368
596
  initialFocus={inputElement}
369
597
  modal={false}
370
598
  >
@@ -378,69 +606,42 @@ export const Combobox = ({
378
606
  ref={setFloating}
379
607
  style={floatingStyles}
380
608
  >
381
- <div className="flex flex-wrap items-center gap-1 border-border-subtle border-b p-2">
382
- {isMultiple
383
- ? selected.map((value) => {
384
- const chip = options.find(
385
- (option) => option.value === value,
386
- )
387
-
388
- return (
389
- <span
390
- className="inline-flex items-center gap-1 rounded-sm bg-intent-neutral-surface px-1.5 py-0.5 text-content-primary text-xs"
391
- key={value}
392
- >
393
- {chip?.textValue ?? value}
394
-
395
- <button
396
- aria-label={`Remove ${chip?.textValue ?? value}`}
397
- className="cursor-pointer text-content-secondary hover:text-content-primary"
398
- onClick={() => {
399
- setSelected((previous) =>
400
- previous.filter(
401
- (one) => one !== value,
402
- ),
403
- )
404
-
405
- // Mirror the list toggle-off: the parent
406
- // tracks selection through `onSelect`, so a
407
- // chip removal must report the same value.
408
- onSelect(value)
409
- }}
410
- type="button"
411
- >
412
- ✕
413
- </button>
414
- </span>
415
- )
416
- })
417
- : null}
418
-
419
- <input
420
- aria-activedescendant={
421
- activeValue === undefined
422
- ? undefined
423
- : optionDomId(activeValue)
424
- }
425
- aria-autocomplete="list"
426
- aria-controls={listboxId}
427
- aria-expanded="true"
428
- aria-labelledby={triggerId}
429
- className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
430
- onChange={(changeEvent) => {
431
- setQuery(changeEvent.target.value)
432
- }}
433
- onKeyDown={handleKeyDown}
434
- placeholder={placeholder}
435
- ref={inputElement}
436
- role="combobox"
437
- type="text"
438
- value={currentQuery}
439
- />
440
- </div>
609
+ {isAttached ? null : (
610
+ <div className="flex items-center gap-1 border-border-subtle border-b p-2">
611
+ <input
612
+ aria-activedescendant={
613
+ activeValue === undefined
614
+ ? undefined
615
+ : optionDomId(activeValue)
616
+ }
617
+ aria-autocomplete="list"
618
+ aria-controls={listboxId}
619
+ aria-expanded="true"
620
+ aria-labelledby={triggerId}
621
+ className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
622
+ onChange={(changeEvent) => {
623
+ setQuery(changeEvent.target.value)
624
+ }}
625
+ onKeyDown={handleKeyDown}
626
+ placeholder={placeholder}
627
+ ref={internalInputElement}
628
+ role="combobox"
629
+ type="text"
630
+ value={currentQuery}
631
+ />
632
+ </div>
633
+ )}
441
634
 
442
635
  <div
443
- aria-labelledby={triggerId}
636
+ // Classic mode names the listbox after its trigger;
637
+ // attached mode has no rendered trigger to point at, so it
638
+ // carries its own label.
639
+ aria-label={
640
+ isAttached ? "Suggestions" : undefined
641
+ }
642
+ aria-labelledby={
643
+ isAttached ? undefined : triggerId
644
+ }
444
645
  className="min-h-0 flex-1 overflow-auto p-1"
445
646
  id={listboxId}
446
647
  ref={listElement}
@@ -53,11 +53,36 @@ export const ComboboxOption = ({
53
53
  aria-selected={isSelected}
54
54
  aria-setsize={setSize}
55
55
  className={toClassName(
56
- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm",
57
- isActive && "bg-intent-neutral-surface",
58
- isSelected && "bg-intent-accent-surface",
59
- isDisabled &&
60
- "cursor-not-allowed text-content-disabled",
56
+ // No base `bg-transparent`: it is a plain `background-color`
57
+ // utility at the same specificity as the state tints below, and
58
+ // Tailwind emits it *after* them, so it silently won every row —
59
+ // the active option and the selected option both rendered with no
60
+ // fill and the keyboard cursor was invisible (it read as "arrows
61
+ // do nothing"). A button's background is transparent by default;
62
+ // the tint classes only ever add one.
63
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-sm",
64
+ // The base sets no text colour: a plain `text-content-primary`
65
+ // clobbers a conditional `text-content-disabled` at equal
66
+ // specificity (Tailwind emits the base last), so a disabled option
67
+ // rendered full-strength instead of greyed. Pick one colour.
68
+ isDisabled
69
+ ? "text-content-disabled"
70
+ : "text-content-primary",
71
+ // The row highlight sits on `surface-overlay`, and on that panel
72
+ // `intent-neutral-surface` is *darker* than the surface in every
73
+ // dark scheme (it is a base-surface tint) — so the visible token
74
+ // is `-hover`. Active is the keyboard cursor; hover is the
75
+ // pointer's; both use it.
76
+ !isDisabled &&
77
+ "hover:bg-intent-neutral-surface-hover",
78
+ isActive && "bg-intent-neutral-surface-hover",
79
+ // The accent tint marks selection when the cursor is elsewhere;
80
+ // the active row keeps its own highlight (the ✓ still marks it as
81
+ // selected), so the two are never fighting for one row's fill.
82
+ isSelected &&
83
+ !isActive &&
84
+ "bg-intent-accent-surface",
85
+ isDisabled && "cursor-not-allowed",
61
86
  )}
62
87
  disabled={isDisabled}
63
88
  id={id}
@@ -181,3 +181,18 @@ export const Interactive: Story = {
181
181
  />
182
182
  ),
183
183
  }
184
+
185
+ /**
186
+ * Nested inside an already-deep section, an empty state is not an
187
+ * `<h2>`. The level goes to `6` so the outline never has to skip —
188
+ * the cap was a guess about document depth the consumer knows
189
+ * better than the component does.
190
+ */
191
+ export const DeeplyNested: Story = {
192
+ args: {
193
+ description:
194
+ "Insert a disc and rip-deck will pick it up automatically.",
195
+ heading: "No discs queued",
196
+ headingLevel: 6,
197
+ },
198
+ }
@@ -6,7 +6,8 @@ import { mountStory } from "../mountStory.testHelpers.ts"
6
6
  import { expectAgentDrivable } from "../testing/index.ts"
7
7
  import * as stories from "./EmptyState.stories.tsx"
8
8
 
9
- const { Default, Interactive } = composeStories(stories)
9
+ const { Default, DeeplyNested, Interactive } =
10
+ composeStories(stories)
10
11
 
11
12
  test("the heading is the handle", async () => {
12
13
  const { canvas } = await mountStory(Default)
@@ -39,3 +40,17 @@ test("the heading level is the caller's, and Tab reaches the way out", async ()
39
40
 
40
41
  await expect(action).toHaveFocus()
41
42
  })
43
+
44
+ test("the heading level reaches h6 for a deeply nested section", async () => {
45
+ const { canvas } = await mountStory(DeeplyNested)
46
+
47
+ // An empty state nested past four sections needs 5 or 6; the old
48
+ // `2 | 3 | 4` cap was a guess about depth the caller knows, and
49
+ // `` `h${headingLevel}` `` never had an upper bound to begin with.
50
+ await expect(
51
+ canvas.getByRole("heading", {
52
+ level: 6,
53
+ name: "No discs queued",
54
+ }),
55
+ ).toBeVisible()
56
+ })
@@ -19,8 +19,13 @@ export type EmptyStateProps = Omit<
19
19
  * only the caller knows what it sits under. Getting this wrong
20
20
  * breaks a screen reader's document outline, which is how the
21
21
  * fleet's `<h2>`-with-a-`<button>`-inside pattern happened.
22
+ *
23
+ * The full `2–6` range because the cap is a guess about document
24
+ * structure the consumer knows better than we do — an empty state
25
+ * nested inside an already-deep section needs `5` or `6`, and
26
+ * `` `h${headingLevel}` `` never cared about the upper bound.
22
27
  */
23
- headingLevel?: 2 | 3 | 4
28
+ headingLevel?: 2 | 3 | 4 | 5 | 6
24
29
  heading: string
25
30
  icon?: ReactNode
26
31
  size?: EmptyStateSize
@@ -69,11 +69,29 @@ export const ListboxOption = ({
69
69
  <button
70
70
  aria-selected={isSelected}
71
71
  className={toClassName(
72
- "flex w-full cursor-pointer items-center justify-between 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",
73
- "hover:bg-intent-neutral-surface",
72
+ // No base `bg-transparent`: it is emitted after the state tints
73
+ // and silently won at equal specificity, so the selected option's
74
+ // accent fill never showed (only its ✓ did). Default is
75
+ // transparent anyway; the tints below add a fill.
76
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-sm transition-colors duration-(--duration-fast) ease-standard",
77
+ // The base sets no text colour: a plain `text-content-primary` in
78
+ // it clobbers a conditional `text-content-disabled` at equal
79
+ // specificity (Tailwind emits the base last), so a disabled option
80
+ // rendered full-strength and did not look disabled — it read as a
81
+ // normal row the arrow keys "wrongly" skipped. Pick one colour.
82
+ isDisabled
83
+ ? "text-content-disabled"
84
+ : "text-content-primary",
85
+ // The active (roving-focused) row gets a fill on **`:focus`**, not
86
+ // `:focus-visible` — so it is visible even when the listbox was
87
+ // opened by mouse. The ring is keyboard-only, so on a mouse-open
88
+ // the initial option had no indicator and the first ArrowDown
89
+ // looked like it skipped it. `-hover`, not the plain tint, because
90
+ // that is darker than `surface-overlay` in dark schemes.
91
+ !isDisabled &&
92
+ "hover:bg-intent-neutral-surface-hover focus:bg-intent-neutral-surface-hover",
74
93
  isSelected && "bg-intent-accent-surface",
75
- isDisabled &&
76
- "cursor-not-allowed text-content-disabled hover:bg-transparent",
94
+ isDisabled && "cursor-not-allowed",
77
95
  FOCUS_RING_CLASS,
78
96
  )}
79
97
  disabled={isDisabled}