@charcuterie/ui 2.1.0 → 2.3.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 (45) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +1 -1
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -1
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -1
  4. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +11 -2
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -1
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +2 -2
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -1
  8. package/dist/Combobox/Combobox.d.ts +22 -4
  9. package/dist/Combobox/Combobox.d.ts.map +1 -1
  10. package/dist/Combobox/Combobox.js +142 -24
  11. package/dist/Combobox/Combobox.js.map +1 -1
  12. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  13. package/dist/Combobox/ComboboxOption.js +22 -1
  14. package/dist/Combobox/ComboboxOption.js.map +1 -1
  15. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  16. package/dist/Listbox/ListboxOption.js +12 -2
  17. package/dist/Listbox/ListboxOption.js.map +1 -1
  18. package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
  19. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  20. package/dist/Overlay/useAnchoredOverlay.js +23 -3
  21. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  22. package/dist/Select/Select.d.ts.map +1 -1
  23. package/dist/Select/Select.js +8 -2
  24. package/dist/Select/Select.js.map +1 -1
  25. package/dist/controlStyles.d.ts +8 -0
  26. package/dist/controlStyles.d.ts.map +1 -1
  27. package/dist/controlStyles.js +8 -0
  28. package/dist/controlStyles.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +6 -0
  31. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +12 -0
  32. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +1 -0
  33. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +12 -0
  34. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +39 -1
  35. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +39 -3
  36. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +15 -1
  37. package/src/Combobox/Combobox.mdx +30 -0
  38. package/src/Combobox/Combobox.stories.tsx +150 -5
  39. package/src/Combobox/Combobox.test.tsx +120 -1
  40. package/src/Combobox/Combobox.tsx +266 -72
  41. package/src/Combobox/ComboboxOption.tsx +22 -3
  42. package/src/Listbox/ListboxOption.tsx +11 -3
  43. package/src/Overlay/useAnchoredOverlay.ts +49 -8
  44. package/src/Select/Select.tsx +6 -5
  45. package/src/controlStyles.ts +8 -0
@@ -9,6 +9,7 @@ import * as stories from "./Combobox.stories.tsx"
9
9
 
10
10
  const {
11
11
  AllVariants,
12
+ AttachedInputDrillDown,
12
13
  Default,
13
14
  DisabledFirstOption,
14
15
  Interactive,
@@ -224,8 +225,10 @@ test("removing a chip reports the removal through onSelect", async () => {
224
225
  ).toBeInTheDocument()
225
226
  })
226
227
 
228
+ // The chip's face and accessible name are the option's human label,
229
+ // not its `value` — "English", not "eng".
227
230
  await userEvent.click(
228
- body.getByRole("button", { name: "Remove eng" }),
231
+ body.getByRole("button", { name: "Remove English" }),
229
232
  )
230
233
 
231
234
  // The removal flowed back through `onSelect`, so the parent cleared it
@@ -269,6 +272,122 @@ test("a disabled first option is skipped, never active or committed", async () =
269
272
  ).toBeInTheDocument()
270
273
  })
271
274
 
275
+ test("attached mode: the consumer input is the combobox and options are shown", async () => {
276
+ const { body, canvas } = await mountStory(
277
+ AttachedInputDrillDown,
278
+ )
279
+
280
+ const input = canvas.getByLabelText("Path")
281
+
282
+ // The a11y is mirrored onto the consumer's own input.
283
+ await waitFor(() => {
284
+ expect(input).toHaveAttribute("role", "combobox")
285
+ })
286
+
287
+ await expect(input).toHaveAttribute(
288
+ "aria-autocomplete",
289
+ "list",
290
+ )
291
+
292
+ const listbox = body.getByRole("listbox")
293
+
294
+ await expect(input).toHaveAttribute(
295
+ "aria-controls",
296
+ listbox.id,
297
+ )
298
+
299
+ // The three top-level folders, from the pre-filtered options.
300
+ await expect(body.getAllByRole("option")).toHaveLength(3)
301
+
302
+ await expectNoAxeViolations(listbox)
303
+ })
304
+
305
+ test("attached mode: selecting a folder drills in and keeps the popup open", async () => {
306
+ const { body, canvas } = await mountStory(
307
+ AttachedInputDrillDown,
308
+ )
309
+
310
+ const input = canvas.getByLabelText("Path")
311
+
312
+ await userEvent.click(input)
313
+
314
+ await waitFor(() => {
315
+ expect(input).toHaveFocus()
316
+ })
317
+
318
+ // Enter commits the active option ("apps") — but does not close.
319
+ await userEvent.keyboard("{Enter}")
320
+
321
+ await waitFor(() => {
322
+ expect(
323
+ body.getByRole("option", { name: /mux-magic/ }),
324
+ ).toBeInTheDocument()
325
+ })
326
+
327
+ // The field drilled in, the popup is still open, and focus never left.
328
+ await expect(input).toHaveValue("/apps/")
329
+
330
+ await expect(
331
+ body.getByRole("listbox"),
332
+ ).toBeInTheDocument()
333
+
334
+ await expect(input).toHaveFocus()
335
+ })
336
+
337
+ test("attached mode: Tab also accepts the active option and drills", async () => {
338
+ const { body, canvas } = await mountStory(
339
+ AttachedInputDrillDown,
340
+ )
341
+
342
+ const input = canvas.getByLabelText("Path")
343
+
344
+ await userEvent.click(input)
345
+
346
+ await waitFor(() => {
347
+ expect(input).toHaveFocus()
348
+ })
349
+
350
+ await userEvent.keyboard("{Tab}")
351
+
352
+ await waitFor(() => {
353
+ expect(input).toHaveValue("/apps/")
354
+ })
355
+
356
+ // Tab drilled rather than moving focus out; the popup stays open.
357
+ await expect(
358
+ body.getByRole("listbox"),
359
+ ).toBeInTheDocument()
360
+
361
+ await expect(input).toHaveFocus()
362
+ })
363
+
364
+ test("attached mode: Escape closes the popup outright", async () => {
365
+ const { body, canvas } = await mountStory(
366
+ AttachedInputDrillDown,
367
+ )
368
+
369
+ const input = canvas.getByLabelText("Path")
370
+
371
+ await userEvent.click(input)
372
+
373
+ await waitFor(() => {
374
+ expect(input).toHaveFocus()
375
+ })
376
+
377
+ await userEvent.keyboard("{Escape}")
378
+
379
+ await waitFor(() => {
380
+ expect(body.queryByRole("listbox")).toBeNull()
381
+ })
382
+
383
+ // The consumer's input persists (Combobox never rendered it); it just
384
+ // reports itself collapsed.
385
+ await expect(input).toHaveAttribute(
386
+ "aria-expanded",
387
+ "false",
388
+ )
389
+ })
390
+
272
391
  test("a windowed list carries aria-setsize and aria-posinset", async () => {
273
392
  const { body } = await mountStory(Virtualized)
274
393
 
@@ -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,47 @@ 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
+ <span
560
+ className="inline-flex items-center gap-1 rounded-sm bg-intent-neutral-surface-hover px-1.5 py-0.5 text-content-primary text-xs"
561
+ key={value}
562
+ >
563
+ {chipLabel(value)}
564
+
565
+ <button
566
+ aria-label={`Remove ${chipLabel(value)}`}
567
+ className="cursor-pointer text-content-secondary hover:text-content-primary"
568
+ onClick={() => {
569
+ removeValue(value)
570
+ }}
571
+ type="button"
572
+ >
573
+ ✕
574
+ </button>
575
+ </span>
576
+ ))}
577
+ </div>
578
+ ) : null}
579
+
362
580
  {clonedTrigger}
363
581
 
364
582
  {isVisible ? (
365
583
  <FloatingPortal>
366
584
  <FloatingFocusManager
367
585
  context={context}
586
+ // Attached mode keeps focus in the consumer's input — the
587
+ // panel must never grab it.
588
+ disabled={isAttached}
368
589
  initialFocus={inputElement}
369
590
  modal={false}
370
591
  >
@@ -378,69 +599,42 @@ export const Combobox = ({
378
599
  ref={setFloating}
379
600
  style={floatingStyles}
380
601
  >
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>
602
+ {isAttached ? null : (
603
+ <div className="flex items-center gap-1 border-border-subtle border-b p-2">
604
+ <input
605
+ aria-activedescendant={
606
+ activeValue === undefined
607
+ ? undefined
608
+ : optionDomId(activeValue)
609
+ }
610
+ aria-autocomplete="list"
611
+ aria-controls={listboxId}
612
+ aria-expanded="true"
613
+ aria-labelledby={triggerId}
614
+ className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
615
+ onChange={(changeEvent) => {
616
+ setQuery(changeEvent.target.value)
617
+ }}
618
+ onKeyDown={handleKeyDown}
619
+ placeholder={placeholder}
620
+ ref={internalInputElement}
621
+ role="combobox"
622
+ type="text"
623
+ value={currentQuery}
624
+ />
625
+ </div>
626
+ )}
441
627
 
442
628
  <div
443
- aria-labelledby={triggerId}
629
+ // Classic mode names the listbox after its trigger;
630
+ // attached mode has no rendered trigger to point at, so it
631
+ // carries its own label.
632
+ aria-label={
633
+ isAttached ? "Suggestions" : undefined
634
+ }
635
+ aria-labelledby={
636
+ isAttached ? undefined : triggerId
637
+ }
444
638
  className="min-h-0 flex-1 overflow-auto p-1"
445
639
  id={listboxId}
446
640
  ref={listElement}
@@ -53,9 +53,28 @@ 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",
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-content-primary text-sm",
64
+ // The row highlight sits on `surface-overlay`, and on that panel
65
+ // `intent-neutral-surface` is *darker* than the surface in every
66
+ // dark scheme (it is a base-surface tint) — so the visible token
67
+ // is `-hover`. Active is the keyboard cursor; hover is the
68
+ // pointer's; both use it.
69
+ !isDisabled &&
70
+ "hover:bg-intent-neutral-surface-hover",
71
+ isActive && "bg-intent-neutral-surface-hover",
72
+ // The accent tint marks selection when the cursor is elsewhere;
73
+ // the active row keeps its own highlight (the ✓ still marks it as
74
+ // selected), so the two are never fighting for one row's fill.
75
+ isSelected &&
76
+ !isActive &&
77
+ "bg-intent-accent-surface",
59
78
  isDisabled &&
60
79
  "cursor-not-allowed text-content-disabled",
61
80
  )}
@@ -69,11 +69,19 @@ 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-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
77
+ // `-hover`, not plain `intent-neutral-surface`: on the
78
+ // `surface-overlay` panel the plain tint is darker than the
79
+ // surface in dark schemes and the hover reads as no change.
80
+ !isDisabled &&
81
+ "hover:bg-intent-neutral-surface-hover",
74
82
  isSelected && "bg-intent-accent-surface",
75
83
  isDisabled &&
76
- "cursor-not-allowed text-content-disabled hover:bg-transparent",
84
+ "cursor-not-allowed text-content-disabled",
77
85
  FOCUS_RING_CLASS,
78
86
  )}
79
87
  disabled={isDisabled}