@spunto/design-system 0.22.0 → 0.23.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/README.md +20 -0
- package/package.json +1 -1
- package/src/components/combobox.tsx +706 -0
- package/src/index.ts +39 -0
package/README.md
CHANGED
|
@@ -73,6 +73,26 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
73
73
|
- _Forms_ — `Input`, `Textarea`, `Label`, `Switch`, `Checkbox`,
|
|
74
74
|
`RadioGroup` (+`RadioGroupItem`), `Select` (+`SelectTrigger`/`SelectValue`/
|
|
75
75
|
`SelectContent`/`SelectItem`/`SelectGroup`/`SelectGroupLabel`/`SelectSeparator`).
|
|
76
|
+
- _Combobox_ — `Combobox` (+`ComboboxInput`/`Clear`/`Trigger`/`Value`/`Search`/`Chips`/
|
|
77
|
+
`Chip`/`ChipsInput`/`Content`/`List`/`Group`/`GroupLabel`/`Separator`/`Item`/`Empty`/
|
|
78
|
+
`Loading`), the `Select` you can type into — for the list nobody wants to scroll
|
|
79
|
+
(branches, repos, model ids, members, tags). Base UI's `Combobox` underneath, and
|
|
80
|
+
compositional like `Select`: items are children. Three things it adds. (1) **Items
|
|
81
|
+
filter themselves**, with the same collator `CommandPalette` uses, so no caller
|
|
82
|
+
re-`.filter()`s its options and "deploi" finds "déploiement"; groups hide themselves
|
|
83
|
+
through `:has()` (no more `if (items.length === 0) return null`), and so does a
|
|
84
|
+
separator with nothing left after it. (2) **A query is only a query when someone
|
|
85
|
+
typed it** — Base UI writes the selected item's label into the input, and taken at
|
|
86
|
+
face value that text filters the list down to the row you just picked; the filter
|
|
87
|
+
keys off the `input-change` reason and ignores anything the component wrote itself.
|
|
88
|
+
(3) In multi-select **the chips field registers itself as the popup's anchor**, so
|
|
89
|
+
the list opens under the whole field instead of the caret-sized `<input>` — no ref
|
|
90
|
+
to remember, no `anchor` prop to pass. Two field shapes, two components rather than
|
|
91
|
+
a flag: `ComboboxInput` (the combobox *is* the field, same 32 px/border/ring as
|
|
92
|
+
`Input`, chevron out of the tab order) or `ComboboxTrigger` + `ComboboxValue` with a
|
|
93
|
+
`ComboboxSearch` at the top of the popup, for when the value is rendered rather than
|
|
94
|
+
typed. Presentational only: `loading` is a prop and a server-side search is
|
|
95
|
+
`onInputValueChange` + `filter={null}`.
|
|
76
96
|
- _Navigation & feedback_ — `Tabs` (+`TabsList`/`TabsIndicator`/`TabsTab`/`TabsPanel`),
|
|
77
97
|
`Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
|
|
78
98
|
declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,45 @@ export {
|
|
|
70
70
|
SelectSeparator,
|
|
71
71
|
} from "./components/select"
|
|
72
72
|
|
|
73
|
+
// Combobox — the Select you can type into, on Base UI. Compositional like
|
|
74
|
+
// Select (items are children), but the items **filter themselves** with the
|
|
75
|
+
// same collator the ⌘K palette uses, so no caller re-`.filter()`s its options;
|
|
76
|
+
// groups hide themselves when empty; and in multi-select the chips field
|
|
77
|
+
// registers itself as the popup's anchor. Two field shapes, two components:
|
|
78
|
+
// `ComboboxInput` (the combobox IS the field) or `ComboboxTrigger` + a
|
|
79
|
+
// `ComboboxSearch` inside the popup.
|
|
80
|
+
export {
|
|
81
|
+
Combobox,
|
|
82
|
+
ComboboxInput,
|
|
83
|
+
ComboboxClear,
|
|
84
|
+
ComboboxTrigger,
|
|
85
|
+
ComboboxValue,
|
|
86
|
+
ComboboxSearch,
|
|
87
|
+
ComboboxChips,
|
|
88
|
+
ComboboxChip,
|
|
89
|
+
ComboboxChipsInput,
|
|
90
|
+
ComboboxContent,
|
|
91
|
+
ComboboxList,
|
|
92
|
+
ComboboxGroup,
|
|
93
|
+
ComboboxGroupLabel,
|
|
94
|
+
ComboboxSeparator,
|
|
95
|
+
ComboboxItem,
|
|
96
|
+
ComboboxEmpty,
|
|
97
|
+
ComboboxLoading,
|
|
98
|
+
} from "./components/combobox"
|
|
99
|
+
export type {
|
|
100
|
+
ComboboxProps,
|
|
101
|
+
ComboboxInputProps,
|
|
102
|
+
ComboboxSearchProps,
|
|
103
|
+
ComboboxChipProps,
|
|
104
|
+
ComboboxContentProps,
|
|
105
|
+
ComboboxGroupProps,
|
|
106
|
+
ComboboxItemProps,
|
|
107
|
+
ComboboxLoadingProps,
|
|
108
|
+
ComboboxFilter,
|
|
109
|
+
ComboboxFilterItem,
|
|
110
|
+
} from "./components/combobox"
|
|
111
|
+
|
|
73
112
|
// Overlays that plug into the `SpuntoProvider` umbrella (portal container +
|
|
74
113
|
// tooltip delay group).
|
|
75
114
|
export { Tooltip } from "./components/tooltip"
|