@spunto/design-system 0.22.0 → 0.24.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.
@@ -0,0 +1,706 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ type ComponentProps,
10
+ type ReactNode,
11
+ } from "react"
12
+ import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
13
+ import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react"
14
+
15
+ import { cn } from "../utils"
16
+ import { highlightMatch, type CommandFilter, type CommandFilterItem } from "./command-shared"
17
+ import { Skeleton } from "./skeleton"
18
+ import { Spinner } from "./spinner"
19
+ import { useOverlayContainer } from "./spunto-provider"
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Why it's built this way
23
+ //
24
+ // A `Select` where you can type. Base UI's `Combobox` brings the hard parts —
25
+ // roles, `aria-activedescendant`, ↑↓ highlight, scroll-into-view, chips and
26
+ // their backspace behaviour — and this file brings three things it doesn't:
27
+ //
28
+ // 1. ITEMS FILTER THEMSELVES, with the same collator the ⌘K palette uses.
29
+ // Base UI filters from an `items` array; like `CommandPalette`, this
30
+ // component is compositional (the app writes `<ComboboxItem>` children, the
31
+ // way it writes `<SelectItem>`), so each item decides for itself whether it
32
+ // matches and renders nothing when it doesn't. Groups hide themselves
33
+ // through `:has()`. Nobody has to re-`.filter()` their options by hand, and
34
+ // "déploiement" is found by "deploi" here exactly like it is in the palette
35
+ // — one definition of "matching" for the whole package (`command-shared`).
36
+ //
37
+ // 2. A QUERY IS ONLY A QUERY WHEN SOMEONE TYPED IT. Base UI writes the selected
38
+ // item's label into the input on selection; taken at face value that text
39
+ // would filter the list down to the single row you just picked, which is how
40
+ // a combobox ends up looking broken on reopen. Every input change carries a
41
+ // `reason`, so the filter keys off `input-change` — a keystroke or a paste —
42
+ // and ignores everything the component wrote itself.
43
+ //
44
+ // 3. THE POPUP FINDS ITS OWN ANCHOR IN MULTI-SELECT. `ComboboxChips` registers
45
+ // itself, `ComboboxContent` reads it. Without that the popup anchors to the
46
+ // bare `<input>` — a caret-sized box that moves to the end of the last chip
47
+ // row — and the caller has to remember a ref and an `anchor` prop to avoid it.
48
+ //
49
+ // Purely presentational: nothing here calls an API. `loading` is a prop, items
50
+ // are children, and a server-side search sets `filter={null}` and feeds the
51
+ // already-filtered items in.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** What the filter gets to look at for one item. Shared with `CommandPalette`. */
55
+ export type ComboboxFilterItem = CommandFilterItem
56
+
57
+ export type ComboboxFilter = CommandFilter
58
+
59
+ interface ComboboxContextValue {
60
+ /** The *typed* query — empty whenever the text in the input isn't the user's. */
61
+ query: string
62
+ loading: boolean
63
+ matches: (item: ComboboxFilterItem) => boolean
64
+ /** The chips field, when there is one: the popup anchors to it, not to the input. */
65
+ chipsElement: HTMLElement | null
66
+ setChipsElement: (element: HTMLElement | null) => void
67
+ }
68
+
69
+ const ComboboxContext = createContext<ComboboxContextValue | null>(null)
70
+
71
+ /** How the empty state and the group find the rendered items. */
72
+ const ITEM_SELECTOR = "[data-slot='combobox-item']"
73
+
74
+ function useCombobox(part: string): ComboboxContextValue {
75
+ const context = useContext(ComboboxContext)
76
+ if (!context) throw new Error(`<${part}> must be rendered inside <Combobox>.`)
77
+ return context
78
+ }
79
+
80
+ // --- root -------------------------------------------------------------------
81
+
82
+ export type ComboboxProps<Value, Multiple extends boolean | undefined = false> = Omit<
83
+ ComboboxPrimitive.Root.Props<Value, Multiple>,
84
+ // Base UI's own filtering works off an `items` array. Here items are children
85
+ // and filter themselves (see the note at the top), so these three would only
86
+ // filter the same list a second time.
87
+ "filter" | "items" | "filteredItems"
88
+ > & {
89
+ /**
90
+ * Local filtering. Default: accent- and case-insensitive "contains" over the
91
+ * item's label, keywords and value. Pass `null` to filter nothing (results
92
+ * already come filtered from an API), or your own predicate.
93
+ */
94
+ filter?: ComboboxFilter | null
95
+ /** Draws a spinner in the field, shows `ComboboxLoading`, silences `ComboboxEmpty`. */
96
+ loading?: boolean
97
+ }
98
+
99
+ /**
100
+ * A listbox you can type into: the `Select` for lists nobody wants to scroll —
101
+ * branches, repositories, model ids, members. Domain-free, and compositional
102
+ * like `Select`: the parts you write are the list.
103
+ *
104
+ * ```tsx
105
+ * <Combobox value={branch} onValueChange={(v) => setBranch(v ?? "")}>
106
+ * <ComboboxInput placeholder="main" />
107
+ * <ComboboxContent>
108
+ * <ComboboxList>
109
+ * <ComboboxEmpty>Aucune branche</ComboboxEmpty>
110
+ * {branches.map((b) => (
111
+ * <ComboboxItem key={b} value={b} icon={<GitBranch />}>{b}</ComboboxItem>
112
+ * ))}
113
+ * </ComboboxList>
114
+ * </ComboboxContent>
115
+ * </Combobox>
116
+ * ```
117
+ *
118
+ * `multiple` swaps the field for `ComboboxChips`; everything below it is the same.
119
+ */
120
+ function Combobox<Value, Multiple extends boolean | undefined = false>({
121
+ filter,
122
+ loading = false,
123
+ inputValue,
124
+ onInputValueChange,
125
+ children,
126
+ ...props
127
+ }: ComboboxProps<Value, Multiple>) {
128
+ const { contains } = ComboboxPrimitive.useFilter({ sensitivity: "base" })
129
+ const [observed, setObserved] = useState("")
130
+ const [typed, setTyped] = useState(false)
131
+ const [chipsElement, setChipsElement] = useState<HTMLElement | null>(null)
132
+
133
+ // The input text is Base UI's to own — this only watches it go by, so no
134
+ // controlled/uncontrolled seam is introduced where there wasn't one. What
135
+ // matters is the `reason`: only `input-change` — a keystroke or a paste — makes
136
+ // the text a query.
137
+ // Anything the component wrote itself (the label of the item just selected,
138
+ // a sync with an external `value`) leaves the list whole.
139
+ function handleInputValueChange(next: string, details: ComboboxPrimitive.Root.ChangeEventDetails) {
140
+ setObserved(next)
141
+ setTyped(details.reason === "input-change")
142
+ onInputValueChange?.(next, details)
143
+ }
144
+
145
+ const text = inputValue !== undefined ? String(inputValue ?? "") : observed
146
+ const query = typed ? text : ""
147
+
148
+ const context: ComboboxContextValue = {
149
+ query,
150
+ loading,
151
+ chipsElement,
152
+ setChipsElement,
153
+ matches: (item) => {
154
+ if (!query.trim()) return true
155
+ if (filter === null) return true
156
+ if (filter) return filter(item, query)
157
+ return [item.label, item.value ?? "", ...item.keywords].some((text) => text && contains(text, query))
158
+ },
159
+ }
160
+
161
+ return (
162
+ <ComboboxPrimitive.Root
163
+ data-slot="combobox"
164
+ inputValue={inputValue}
165
+ onInputValueChange={handleInputValueChange}
166
+ {...(props as ComboboxPrimitive.Root.Props<Value, Multiple>)}
167
+ >
168
+ <ComboboxContext.Provider value={context}>{children}</ComboboxContext.Provider>
169
+ </ComboboxPrimitive.Root>
170
+ )
171
+ }
172
+
173
+ // --- field: the combobox *is* the input -------------------------------------
174
+
175
+ /** The shell every field shape shares — same 32 px, same border, same ring as `Input`. */
176
+ const fieldClassName =
177
+ "flex w-full items-center rounded-lg border border-input bg-transparent text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:opacity-50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40"
178
+
179
+ export interface ComboboxInputProps
180
+ extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "size"> {
181
+ /** Leading icon inside the field (a `lucide-react` glyph, a status dot…). */
182
+ icon?: ReactNode
183
+ /**
184
+ * Draws the ✕ that clears the selection. It appears only when there *is*
185
+ * something to clear — Base UI decides that, not the caller.
186
+ * @default true
187
+ */
188
+ clearable?: boolean
189
+ }
190
+
191
+ /**
192
+ * The field, for the shape where the combobox **is** the input: you read the
193
+ * current value by reading the text, and typing filters in place. The chevron
194
+ * isn't a tab stop — the input already opens the list with ↓, and a field that
195
+ * costs two tabs to leave is a field people complain about.
196
+ *
197
+ * For the other shape — a button that shows the value, with the search box
198
+ * inside the popup — use `ComboboxTrigger` + `ComboboxSearch`.
199
+ */
200
+ function ComboboxInput({ className, icon, clearable = true, ...props }: ComboboxInputProps) {
201
+ const { loading } = useCombobox("ComboboxInput")
202
+ return (
203
+ <div data-slot="combobox-field" className={cn("group/field h-8", fieldClassName, className)}>
204
+ {icon != null && (
205
+ <span className="ml-2.5 flex shrink-0 items-center text-muted-foreground [&_svg]:size-3.5" aria-hidden>
206
+ {icon}
207
+ </span>
208
+ )}
209
+ <ComboboxPrimitive.Input
210
+ data-slot="combobox-input"
211
+ // `md:text-sm` — below 16px, iOS zooms the page when the field takes focus.
212
+ className="h-full min-w-0 flex-1 bg-transparent px-2.5 text-base outline-none placeholder:text-muted-foreground disabled:pointer-events-none md:text-sm"
213
+ {...props}
214
+ />
215
+ <span className="flex shrink-0 items-center gap-0.5 pr-1.5">
216
+ {loading && <Spinner size="xs" className="mr-0.5 text-muted-foreground" />}
217
+ {clearable && <ComboboxClear />}
218
+ {/* Hidden while the ✕ is there: two buttons in a 32 px field is one too
219
+ many, and the input itself still opens the list. */}
220
+ <ComboboxPrimitive.Trigger
221
+ data-slot="combobox-chevron"
222
+ tabIndex={-1}
223
+ aria-label="Ouvrir la liste"
224
+ className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none group-has-[[data-slot=combobox-clear]]/field:hidden"
225
+ >
226
+ <ChevronDownIcon className="size-3.5" />
227
+ </ComboboxPrimitive.Trigger>
228
+ </span>
229
+ </div>
230
+ )
231
+ }
232
+
233
+ /**
234
+ * The ✕ that empties the field. Mounted by Base UI only when there's a value to
235
+ * clear, so it needs no `visible`/`showClear` prop — asking the caller for one
236
+ * is asking them to recompute what the component already knows.
237
+ */
238
+ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
239
+ return (
240
+ <ComboboxPrimitive.Clear
241
+ data-slot="combobox-clear"
242
+ aria-label="Effacer"
243
+ className={cn(
244
+ "flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50",
245
+ className
246
+ )}
247
+ {...props}
248
+ >
249
+ <XIcon className="size-3.5" />
250
+ </ComboboxPrimitive.Clear>
251
+ )
252
+ }
253
+
254
+ // --- field: a trigger, and the search inside the popup -----------------------
255
+
256
+ /**
257
+ * The other field shape: a button showing the selected value, exactly like
258
+ * `SelectTrigger`. Put a `ComboboxValue` inside it and a `ComboboxSearch` at the
259
+ * top of the popup — that's the shape to reach for when the value is long, or
260
+ * rendered (an avatar, a badge) rather than typed.
261
+ */
262
+ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
263
+ return (
264
+ <ComboboxPrimitive.Trigger
265
+ data-slot="combobox-trigger"
266
+ className={cn(
267
+ "flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm whitespace-nowrap transition-colors outline-none select-none data-[popup-open]:border-ring focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 [&>span]:truncate",
268
+ className
269
+ )}
270
+ {...props}
271
+ >
272
+ {children}
273
+ <ComboboxPrimitive.Icon className="shrink-0 text-muted-foreground">
274
+ <ChevronDownIcon className="size-3.5" />
275
+ </ComboboxPrimitive.Icon>
276
+ </ComboboxPrimitive.Trigger>
277
+ )
278
+ }
279
+
280
+ /**
281
+ * The selected value, or `placeholder` when there's none. Goes inside
282
+ * `ComboboxTrigger`. With no children it prints the raw value: the package never
283
+ * receives the list of options (they're children, and a closed popup hasn't
284
+ * rendered them), so anything prettier than the value itself — a label, an
285
+ * avatar, a badge — is the app's to render here.
286
+ */
287
+ function ComboboxValue({ placeholder, children, ...props }: ComboboxPrimitive.Value.Props) {
288
+ return (
289
+ <span data-slot="combobox-value" className="truncate">
290
+ <ComboboxPrimitive.Value
291
+ placeholder={
292
+ placeholder != null ? <span className="text-muted-foreground">{placeholder}</span> : undefined
293
+ }
294
+ {...props}
295
+ >
296
+ {children}
297
+ </ComboboxPrimitive.Value>
298
+ </span>
299
+ )
300
+ }
301
+
302
+ export interface ComboboxSearchProps
303
+ extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "size"> {
304
+ /** Leading icon. Defaults to a magnifier; pass `null` for none. */
305
+ icon?: ReactNode
306
+ }
307
+
308
+ /**
309
+ * The search row **inside** the popup, for the `ComboboxTrigger` shape. Flush
310
+ * against the popup's edges rather than a bordered box: it isn't the field, it's
311
+ * the top of the list — the same row `CommandPaletteInput` draws.
312
+ */
313
+ function ComboboxSearch({ className, placeholder = "Rechercher…", icon, ...props }: ComboboxSearchProps) {
314
+ const { loading } = useCombobox("ComboboxSearch")
315
+ return (
316
+ <div
317
+ data-slot="combobox-search"
318
+ className={cn("flex h-9 shrink-0 items-center gap-2 border-b border-border px-2.5", className)}
319
+ >
320
+ {icon !== null && (
321
+ <span className="shrink-0 text-muted-foreground [&_svg]:size-3.5" aria-hidden>
322
+ {icon ?? <SearchIcon className="size-3.5" />}
323
+ </span>
324
+ )}
325
+ <ComboboxPrimitive.Input
326
+ className="h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground md:text-sm"
327
+ placeholder={placeholder}
328
+ aria-label={props["aria-label"] ?? placeholder}
329
+ {...props}
330
+ />
331
+ {loading && <Spinner size="xs" className="shrink-0 text-muted-foreground" />}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ // --- field: chips (multi-select) --------------------------------------------
337
+
338
+ /**
339
+ * The multi-select field: the selected values as chips, with the input sharing
340
+ * the box. It registers itself as the popup's anchor, so `ComboboxContent` opens
341
+ * under the *whole* field and matches its width — without it, the popup hangs off
342
+ * the caret-sized `<input>`, which sits wherever the last chip left it.
343
+ */
344
+ function ComboboxChips({ className, ...props }: ComboboxPrimitive.Chips.Props) {
345
+ const { setChipsElement } = useCombobox("ComboboxChips")
346
+ return (
347
+ <ComboboxPrimitive.Chips
348
+ ref={setChipsElement}
349
+ data-slot="combobox-chips"
350
+ className={cn(
351
+ "min-h-8 flex-wrap gap-1 py-1 pr-1.5 pl-1 has-[[data-slot=combobox-chip]]:pl-1",
352
+ fieldClassName,
353
+ className
354
+ )}
355
+ {...props}
356
+ />
357
+ )
358
+ }
359
+
360
+ export interface ComboboxChipProps extends ComboboxPrimitive.Chip.Props {
361
+ /**
362
+ * Draws the ✕ on the chip.
363
+ * @default true
364
+ */
365
+ removable?: boolean
366
+ }
367
+
368
+ /** One selected value. Backspace from the input removes the last one — Base UI's doing. */
369
+ function ComboboxChip({ className, children, removable = true, ...props }: ComboboxChipProps) {
370
+ return (
371
+ <ComboboxPrimitive.Chip
372
+ data-slot="combobox-chip"
373
+ className={cn(
374
+ "flex h-5.5 items-center gap-1 rounded-md bg-muted pr-1 pl-1.5 text-xs font-medium whitespace-nowrap text-foreground outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground",
375
+ !removable && "pr-1.5",
376
+ className
377
+ )}
378
+ {...props}
379
+ >
380
+ {children}
381
+ {removable && (
382
+ <ComboboxPrimitive.ChipRemove
383
+ data-slot="combobox-chip-remove"
384
+ aria-label="Retirer"
385
+ className="flex size-3.5 items-center justify-center rounded-sm text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
386
+ >
387
+ <XIcon className="size-3" />
388
+ </ComboboxPrimitive.ChipRemove>
389
+ )}
390
+ </ComboboxPrimitive.Chip>
391
+ )
392
+ }
393
+
394
+ /** The text field that lives among the chips. Flush — `ComboboxChips` draws the box. */
395
+ function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
396
+ return (
397
+ <ComboboxPrimitive.Input
398
+ data-slot="combobox-chips-input"
399
+ className={cn(
400
+ "h-6 min-w-24 flex-1 bg-transparent px-1.5 text-base outline-none placeholder:text-muted-foreground md:text-sm",
401
+ className
402
+ )}
403
+ {...props}
404
+ />
405
+ )
406
+ }
407
+
408
+ // --- popup ------------------------------------------------------------------
409
+
410
+ export interface ComboboxContentProps extends ComboboxPrimitive.Popup.Props {
411
+ side?: ComboboxPrimitive.Positioner.Props["side"]
412
+ align?: ComboboxPrimitive.Positioner.Props["align"]
413
+ sideOffset?: ComboboxPrimitive.Positioner.Props["sideOffset"]
414
+ alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"]
415
+ /** Override the element the popup hangs off. Defaults to the field. */
416
+ anchor?: ComboboxPrimitive.Positioner.Props["anchor"]
417
+ }
418
+
419
+ /**
420
+ * Portal + positioned popup, rendered into the `SpuntoProvider`'s overlay
421
+ * container like every other overlay here — which is what keeps it above a
422
+ * `Dialog` instead of behind it.
423
+ */
424
+ function ComboboxContent({
425
+ className,
426
+ children,
427
+ side = "bottom",
428
+ align = "start",
429
+ sideOffset = 4,
430
+ alignOffset = 0,
431
+ anchor,
432
+ ...props
433
+ }: ComboboxContentProps) {
434
+ const container = useOverlayContainer()
435
+ const { chipsElement } = useCombobox("ComboboxContent")
436
+ return (
437
+ <ComboboxPrimitive.Portal container={container ?? undefined}>
438
+ <ComboboxPrimitive.Positioner
439
+ data-slot="combobox-positioner"
440
+ className="z-50 outline-none"
441
+ side={side}
442
+ align={align}
443
+ sideOffset={sideOffset}
444
+ alignOffset={alignOffset}
445
+ anchor={anchor ?? chipsElement ?? undefined}
446
+ >
447
+ <ComboboxPrimitive.Popup
448
+ data-slot="combobox-content"
449
+ className={cn(
450
+ "flex max-h-[min(20rem,var(--available-height))] w-[var(--anchor-width)] max-w-[var(--available-width)] min-w-[max(8rem,var(--anchor-width))] flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
451
+ "origin-[var(--transform-origin)] transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
452
+ className
453
+ )}
454
+ {...props}
455
+ >
456
+ {children}
457
+ </ComboboxPrimitive.Popup>
458
+ </ComboboxPrimitive.Positioner>
459
+ </ComboboxPrimitive.Portal>
460
+ )
461
+ }
462
+
463
+ /**
464
+ * The scrollable results area. Groups, items, `ComboboxEmpty` and
465
+ * `ComboboxLoading` all go in it — the empty state keys off `:has()` on this
466
+ * element, so it only works from inside.
467
+ */
468
+ function ComboboxList({ className, children, ...props }: ComboboxPrimitive.List.Props) {
469
+ const { loading } = useCombobox("ComboboxList")
470
+ const ref = useRef<HTMLDivElement>(null)
471
+ const [count, setCount] = useState(0)
472
+
473
+ // Counting the rendered items is the one thing CSS can't do for us, and the
474
+ // announcement below needs it. Reading the DOM after the commit is cheaper
475
+ // (and more accurate) than making every item report in.
476
+ useEffect(() => {
477
+ const rendered = ref.current?.querySelectorAll(ITEM_SELECTOR).length ?? 0
478
+ setCount((prev) => (prev === rendered ? prev : rendered))
479
+ })
480
+
481
+ return (
482
+ <>
483
+ <ComboboxPrimitive.List
484
+ ref={ref}
485
+ data-slot="combobox-list"
486
+ className={cn("group/combobox-list min-h-0 flex-1 overflow-y-auto overscroll-contain p-1", className)}
487
+ {...props}
488
+ >
489
+ {children}
490
+ </ComboboxPrimitive.List>
491
+ {/* Must stay mounted to announce reliably — only its text changes. */}
492
+ <ComboboxPrimitive.Status className="sr-only">
493
+ {loading ? "Recherche en cours…" : count === 0 ? "Aucun résultat" : `${count} résultat${count > 1 ? "s" : ""}`}
494
+ </ComboboxPrimitive.Status>
495
+ </>
496
+ )
497
+ }
498
+
499
+ // --- group / separator ------------------------------------------------------
500
+
501
+ export interface ComboboxGroupProps extends ComboboxPrimitive.Group.Props {
502
+ heading?: ReactNode
503
+ }
504
+
505
+ /**
506
+ * A titled block of items. It hides itself when none of its items survived the
507
+ * filter — through `:has()` rather than a JS count, so no caller ever writes
508
+ * `if (items.length === 0) return null` again, and the heading never flashes for
509
+ * a frame above an empty group.
510
+ */
511
+ function ComboboxGroup({ heading, className, children, ...props }: ComboboxGroupProps) {
512
+ return (
513
+ <ComboboxPrimitive.Group
514
+ data-slot="combobox-group"
515
+ className={cn("hidden has-[[data-slot=combobox-item]]:block", className)}
516
+ {...props}
517
+ >
518
+ {heading != null && <ComboboxGroupLabel>{heading}</ComboboxGroupLabel>}
519
+ {children}
520
+ </ComboboxPrimitive.Group>
521
+ )
522
+ }
523
+
524
+ /** The heading of a group. `ComboboxGroup`'s `heading` prop renders one for you. */
525
+ function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
526
+ return (
527
+ <ComboboxPrimitive.GroupLabel
528
+ data-slot="combobox-group-label"
529
+ className={cn("px-2 pt-1.5 pb-1 text-xs font-medium text-muted-foreground select-none", className)}
530
+ {...props}
531
+ />
532
+ )
533
+ }
534
+
535
+ /**
536
+ * A thin rule between blocks. It disappears when nothing survives *after* it —
537
+ * neither an item of its own nor a group still holding one — because the whole
538
+ * point of filtering the list is that the rule between two blocks stops making
539
+ * sense before the blocks do. A separator left alone at the bottom of a popup is
540
+ * the tell that someone wrote the condition by hand and forgot a case.
541
+ *
542
+ * What it can't see is what comes *before* it (CSS has no previous-sibling
543
+ * `:has()`), so a rule rendered first in the list is the app's to hold back.
544
+ */
545
+ function ComboboxSeparator({ className, ...props }: ComponentProps<"div">) {
546
+ return (
547
+ <div
548
+ data-slot="combobox-separator"
549
+ role="separator"
550
+ className={cn(
551
+ "-mx-1 my-1 hidden h-px bg-border",
552
+ "[&:has(~:is([data-slot=combobox-item],:has([data-slot=combobox-item])))]:block",
553
+ className
554
+ )}
555
+ {...props}
556
+ />
557
+ )
558
+ }
559
+
560
+ // --- item -------------------------------------------------------------------
561
+
562
+ export interface ComboboxItemProps extends Omit<ComboboxPrimitive.Item.Props, "value" | "children"> {
563
+ /** What gets committed. Objects work too — Base UI stringifies them for the input. */
564
+ value?: unknown
565
+ children?: ReactNode
566
+ /** Leading icon (a `lucide-react` glyph, an avatar, a status dot…). */
567
+ icon?: ReactNode
568
+ /** Secondary text, right-aligned: type, org, version… */
569
+ meta?: ReactNode
570
+ /** Second line under the label. */
571
+ description?: ReactNode
572
+ /** Extra search terms, never displayed. */
573
+ keywords?: string[]
574
+ /**
575
+ * Text the filter matches on. Defaults to the children when they're a plain
576
+ * string — pass it explicitly as soon as they aren't (an item that renders
577
+ * "Créer « foo »" still searches on `foo`).
578
+ */
579
+ label?: string
580
+ }
581
+
582
+ /**
583
+ * One option. Renders nothing when it doesn't match the query — that's the whole
584
+ * filtering mechanism, and what keeps a long list cheap once the user types.
585
+ *
586
+ * The ✓ sits on the **right**, unlike `SelectItem`: the left slot belongs to the
587
+ * item's own icon here (a repo, a branch, an avatar), and in multi-select a
588
+ * right-hand column of ticks reads as the checkbox column it is.
589
+ */
590
+ function ComboboxItem({
591
+ value,
592
+ icon,
593
+ meta,
594
+ description,
595
+ keywords = [],
596
+ label: labelProp,
597
+ className,
598
+ children,
599
+ ...props
600
+ }: ComboboxItemProps) {
601
+ const { matches, query } = useCombobox("ComboboxItem")
602
+ // Plain-string children are the label, and the label is what gets a `<mark>`.
603
+ // Anything else the caller wrote is rendered verbatim — `label` then only
604
+ // feeds the filter, so an item can match on "foo" while reading "Créer « foo »".
605
+ const text = typeof children === "string" ? children : undefined
606
+ const label = labelProp ?? text
607
+
608
+ if (!matches({ label: label ?? "", keywords, value: typeof value === "string" ? value : undefined })) {
609
+ return null
610
+ }
611
+
612
+ return (
613
+ <ComboboxPrimitive.Item
614
+ data-slot="combobox-item"
615
+ value={value}
616
+ className={cn(
617
+ "group/combobox-item flex scroll-my-1 cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-2 text-sm text-foreground outline-none select-none",
618
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
619
+ className
620
+ )}
621
+ {...props}
622
+ >
623
+ {icon != null && (
624
+ <span
625
+ className="flex size-4 shrink-0 items-center justify-center text-muted-foreground group-data-highlighted/combobox-item:text-foreground [&_svg]:size-3.5"
626
+ aria-hidden
627
+ >
628
+ {icon}
629
+ </span>
630
+ )}
631
+ <span className="min-w-0 flex-1">
632
+ <span className="block truncate">{text != null ? highlightMatch(text, query) : children}</span>
633
+ {description != null && <span className="block truncate text-xs text-muted-foreground">{description}</span>}
634
+ </span>
635
+ {meta != null && <span className="shrink-0 truncate text-xs text-muted-foreground tabular-nums">{meta}</span>}
636
+ <ComboboxPrimitive.ItemIndicator className="flex size-4 shrink-0 items-center justify-center text-primary">
637
+ <CheckIcon className="size-3.5" />
638
+ </ComboboxPrimitive.ItemIndicator>
639
+ </ComboboxPrimitive.Item>
640
+ )
641
+ }
642
+
643
+ // --- empty / loading --------------------------------------------------------
644
+
645
+ /**
646
+ * Shown only when no item survived the filter, and never while `loading` (a
647
+ * pending search isn't an empty result). Must live inside `ComboboxList`.
648
+ */
649
+ function ComboboxEmpty({ className, children = "Aucun résultat", ...props }: ComponentProps<"div">) {
650
+ const { loading } = useCombobox("ComboboxEmpty")
651
+ if (loading) return null
652
+ return (
653
+ <div
654
+ data-slot="combobox-empty"
655
+ role="presentation"
656
+ className={cn(
657
+ "px-3 py-6 text-center text-sm text-muted-foreground group-has-[[data-slot=combobox-item]]/combobox-list:hidden",
658
+ className
659
+ )}
660
+ {...props}
661
+ >
662
+ {children}
663
+ </div>
664
+ )
665
+ }
666
+
667
+ export interface ComboboxLoadingProps extends ComponentProps<"div"> {
668
+ /** Number of skeleton rows. @default 3 */
669
+ rows?: number
670
+ }
671
+
672
+ /** Skeleton rows while an async search is in flight. Renders nothing unless the root is `loading`. */
673
+ function ComboboxLoading({ rows = 3, className, ...props }: ComboboxLoadingProps) {
674
+ const { loading } = useCombobox("ComboboxLoading")
675
+ if (!loading) return null
676
+ return (
677
+ <div data-slot="combobox-loading" role="presentation" className={cn("space-y-1", className)} {...props}>
678
+ {Array.from({ length: rows }, (_, i) => (
679
+ <div key={i} className="flex items-center gap-2 px-2 py-1.5">
680
+ <Skeleton className="size-3.5 shrink-0 rounded" />
681
+ <Skeleton className="h-3" style={{ width: `${55 - i * 10}%` }} />
682
+ </div>
683
+ ))}
684
+ </div>
685
+ )
686
+ }
687
+
688
+ export {
689
+ Combobox,
690
+ ComboboxInput,
691
+ ComboboxClear,
692
+ ComboboxTrigger,
693
+ ComboboxValue,
694
+ ComboboxSearch,
695
+ ComboboxChips,
696
+ ComboboxChip,
697
+ ComboboxChipsInput,
698
+ ComboboxContent,
699
+ ComboboxList,
700
+ ComboboxGroup,
701
+ ComboboxGroupLabel,
702
+ ComboboxSeparator,
703
+ ComboboxItem,
704
+ ComboboxEmpty,
705
+ ComboboxLoading,
706
+ }