@charcuterie/ui 2.2.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.
- 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 +22 -1
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Listbox/ListboxOption.d.ts.map +1 -1
- package/dist/Listbox/ListboxOption.js +12 -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 +1 -1
- 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 +266 -72
- package/src/Combobox/ComboboxOption.tsx +22 -3
- package/src/Listbox/ListboxOption.tsx +11 -3
- 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,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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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-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
|
|
84
|
+
"cursor-not-allowed text-content-disabled",
|
|
77
85
|
FOCUS_RING_CLASS,
|
|
78
86
|
)}
|
|
79
87
|
disabled={isDisabled}
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
useInteractions,
|
|
15
15
|
useRole,
|
|
16
16
|
} from "@floating-ui/react"
|
|
17
|
-
import type { ReactElement } from "react"
|
|
17
|
+
import type { ReactElement, RefObject } from "react"
|
|
18
|
+
import { createElement, useEffect } from "react"
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* The floating-ui block every anchored overlay — `Popover`, `Menu`,
|
|
@@ -67,6 +68,15 @@ export type AnchoredOverlayRole =
|
|
|
67
68
|
| "menu"
|
|
68
69
|
|
|
69
70
|
export type UseAnchoredOverlayOptions = {
|
|
71
|
+
/**
|
|
72
|
+
* Anchor the panel to a **pre-existing** element the consumer already
|
|
73
|
+
* rendered, instead of cloning a `trigger`. For the attached-input
|
|
74
|
+
* `Combobox`, whose reference is the consumer's own `<input>` (both the
|
|
75
|
+
* value and the query) — there is nothing to clone. Supply exactly one
|
|
76
|
+
* of `anchorRef` / `trigger`; with `anchorRef`, `clonedTrigger` is
|
|
77
|
+
* `null`.
|
|
78
|
+
*/
|
|
79
|
+
anchorRef?: RefObject<HTMLElement | null>
|
|
70
80
|
/**
|
|
71
81
|
* Both default **on** — Escape and outside-press are what makes an
|
|
72
82
|
* overlay an overlay. Switch one off for the honest exception (a
|
|
@@ -86,11 +96,21 @@ export type UseAnchoredOverlayOptions = {
|
|
|
86
96
|
onDismiss: () => void
|
|
87
97
|
placement?: Placement
|
|
88
98
|
role: AnchoredOverlayRole
|
|
89
|
-
/**
|
|
90
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The control the panel hangs off. **Cloned, not wrapped.** Omit only
|
|
101
|
+
* when `anchorRef` is supplied instead.
|
|
102
|
+
*/
|
|
103
|
+
trigger?: ReactElement
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
// `useClonedChild` insists on exactly one element, and hooks cannot be
|
|
107
|
+
// called conditionally — so in `anchorRef` mode we still call it, on this
|
|
108
|
+
// throwaway, and discard the result. It is never rendered and never
|
|
109
|
+
// receives the floating reference (that goes to `anchorRef`).
|
|
110
|
+
const PLACEHOLDER_TRIGGER = createElement("span")
|
|
111
|
+
|
|
93
112
|
export const useAnchoredOverlay = ({
|
|
113
|
+
anchorRef,
|
|
94
114
|
isEscapeDismissable = true,
|
|
95
115
|
isOutsidePressDismissable = true,
|
|
96
116
|
isTriggerWidthMatched = false,
|
|
@@ -128,6 +148,8 @@ export const useAnchoredOverlay = ({
|
|
|
128
148
|
)
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
const isAnchored = anchorRef !== undefined
|
|
152
|
+
|
|
131
153
|
const { context, floatingStyles, refs } = useFloating({
|
|
132
154
|
// Read-only. Charcuterie remains the sole owner; floating-ui is
|
|
133
155
|
// told what is true and never decides it.
|
|
@@ -180,11 +202,30 @@ export const useAnchoredOverlay = ({
|
|
|
180
202
|
(referenceProps.id as string | undefined) ??
|
|
181
203
|
generatedTriggerId
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
// In anchor mode floating-ui's reference is the consumer's element, set
|
|
206
|
+
// imperatively — there is no cloned trigger to carry `refs.setReference`.
|
|
207
|
+
// `setReference` is stable; re-running on `isVisible` re-applies it when
|
|
208
|
+
// the panel opens (and after the anchor element mounts).
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!isAnchored) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
refs.setReference(anchorRef.current)
|
|
215
|
+
}, [isAnchored, anchorRef, refs])
|
|
216
|
+
|
|
217
|
+
const clonedChild = useClonedChild(
|
|
218
|
+
trigger ?? PLACEHOLDER_TRIGGER,
|
|
219
|
+
{
|
|
220
|
+
...referenceProps,
|
|
221
|
+
id: triggerId,
|
|
222
|
+
// The anchor element already exists; the clone (a discarded
|
|
223
|
+
// placeholder here) must not also claim the floating reference.
|
|
224
|
+
...(isAnchored ? {} : { ref: refs.setReference }),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const clonedTrigger = isAnchored ? null : clonedChild
|
|
188
229
|
|
|
189
230
|
return {
|
|
190
231
|
clonedTrigger,
|
package/src/Select/Select.tsx
CHANGED
|
@@ -5,10 +5,7 @@ import type {
|
|
|
5
5
|
ReactNode,
|
|
6
6
|
} from "react"
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
CONTROL_SIZE_CLASS,
|
|
10
|
-
MIN_TOUCH_TARGET_CLASS,
|
|
11
|
-
} from "../controlStyles.ts"
|
|
8
|
+
import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
|
|
12
9
|
import {
|
|
13
10
|
DISABLED_CLASS,
|
|
14
11
|
FOCUS_RING_CLASS,
|
|
@@ -139,8 +136,12 @@ export const Select = ({
|
|
|
139
136
|
// removes, and forgetting to put one back leaves a control
|
|
140
137
|
// that looks exactly like a text input.
|
|
141
138
|
"pe-9",
|
|
139
|
+
// Height comes from the shared control-size/density system ONLY —
|
|
140
|
+
// no per-component touch-target floor. Touch is the density axis's
|
|
141
|
+
// job (`[data-density]` scales `--control-height-*` past 44px on a
|
|
142
|
+
// kiosk), so forcing 44px here made Select taller than Button et al.
|
|
143
|
+
// at desktop density. See the controls-share-one-height decision.
|
|
142
144
|
CONTROL_SIZE_CLASS[size],
|
|
143
|
-
MIN_TOUCH_TARGET_CLASS,
|
|
144
145
|
"hover:border-border-strong",
|
|
145
146
|
FOCUS_RING_CLASS,
|
|
146
147
|
DISABLED_CLASS,
|
package/src/controlStyles.ts
CHANGED
|
@@ -46,6 +46,14 @@ export const ICON_CONTROL_SIZE_CLASS: Record<
|
|
|
46
46
|
* density — a finger does not get smaller because the list is
|
|
47
47
|
* dense — so this is opt-in per control rather than baked into the
|
|
48
48
|
* size classes, which would make `compact` a lie.
|
|
49
|
+
*
|
|
50
|
+
* **Do NOT add this to a control that shares a form row** (`Button`,
|
|
51
|
+
* `Select`, a `Listbox`/`Combobox` trigger, `SegmentedControl`, …). It
|
|
52
|
+
* overrides the shared `CONTROL_SIZE_CLASS` height and makes that one
|
|
53
|
+
* control taller than its neighbours at desktop density — touch sizing is
|
|
54
|
+
* the density axis's job. Reserve this for a genuinely standalone tap
|
|
55
|
+
* target. See
|
|
56
|
+
* `docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md`.
|
|
49
57
|
*/
|
|
50
58
|
export const MIN_TOUCH_TARGET_CLASS =
|
|
51
59
|
"min-h-(--control-min-touch-target) min-w-(--control-min-touch-target)"
|