@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.
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +1 -1
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -1
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -1
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +11 -2
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -1
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +2 -2
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -1
- 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/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +6 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +12 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +1 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +12 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +39 -1
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +39 -3
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +15 -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
|
@@ -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
|
|
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 {
|
|
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}
|