@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.
- package/dist/Combobox/Combobox.d.ts +22 -4
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +142 -24
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +29 -2
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/EmptyState/EmptyState.d.ts +6 -1
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js.map +1 -1
- package/dist/Listbox/ListboxOption.d.ts.map +1 -1
- package/dist/Listbox/ListboxOption.js +22 -2
- package/dist/Listbox/ListboxOption.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +23 -3
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +8 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/controlStyles.d.ts +8 -0
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +8 -0
- package/dist/controlStyles.js.map +1 -1
- package/package.json +2 -2
- package/src/Combobox/Combobox.mdx +30 -0
- package/src/Combobox/Combobox.stories.tsx +150 -5
- package/src/Combobox/Combobox.test.tsx +120 -1
- package/src/Combobox/Combobox.tsx +273 -72
- package/src/Combobox/ComboboxOption.tsx +30 -5
- package/src/EmptyState/EmptyState.stories.tsx +15 -0
- package/src/EmptyState/EmptyState.test.tsx +16 -1
- package/src/EmptyState/EmptyState.tsx +6 -1
- package/src/Listbox/ListboxOption.tsx +22 -4
- package/src/Overlay/useAnchoredOverlay.ts +49 -8
- package/src/Select/Select.tsx +6 -5
- 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 {
|
|
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
|
-
/**
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
// a
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 } =
|
|
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
|
-
|
|
73
|
-
|
|
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}
|