@spunto/design-system 0.21.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 CHANGED
@@ -56,9 +56,43 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
56
56
  `Separator`, `Spinner`, `Skeleton`, `Avatar` (+`AvatarImage`/`AvatarFallback`),
57
57
  `Kbd` — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K, Ctrl K off Apple),
58
58
  spelled out for screen readers.
59
+ - _AvatarGroup_ — the stack of overlapping avatars. `size` (`xs`…`xl`) sizes the whole
60
+ stack at once — on `Avatar` it sets the circle **and** its initials, because a 64 px
61
+ avatar with 14 px initials looks broken. **Compositional**: the group never reads a
62
+ `{ name, src }` shape, it wraps whatever you hand it (an `Avatar`, one inside a
63
+ `Tooltip`, a round `Skeleton` while the list loads) in its own span, so a loading
64
+ stack has exactly the geometry of the loaded one. It owns only what a caller can't
65
+ get right by hand: the overlap (a ratio of the diameter, `spacing` = ~40/29/20%), the
66
+ `ringClassName` that separates two faces (match the surface *behind* the stack —
67
+ `ring-card` inside a `Card`), the paint order, and the `+N`. Counting has two rules:
68
+ the counter adds the children `max` cut **and** the people never rendered
69
+ (`total` − children), so a paginated list shows `+38` without fabricating 38 ghost
70
+ avatars; and a `+1` never appears — one hidden child takes exactly the room its
71
+ counter would, saying less, so the face is drawn instead. `onOverflowClick` makes
72
+ the counter a real button.
59
73
  - _Forms_ — `Input`, `Textarea`, `Label`, `Switch`, `Checkbox`,
60
74
  `RadioGroup` (+`RadioGroupItem`), `Select` (+`SelectTrigger`/`SelectValue`/
61
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}`.
62
96
  - _Navigation & feedback_ — `Tabs` (+`TabsList`/`TabsIndicator`/`TabsTab`/`TabsPanel`),
63
97
  `Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
64
98
  declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,18 +1,54 @@
1
1
  "use client"
2
2
 
3
+ import { Children, createContext, useContext, type ComponentProps, type ReactNode } from "react"
3
4
  import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
5
+ import { cva, type VariantProps } from "class-variance-authority"
4
6
 
5
7
  import { cn } from "../utils"
6
8
 
9
+ const avatarVariants = cva("relative flex shrink-0 overflow-hidden rounded-full bg-muted select-none", {
10
+ variants: {
11
+ // The font-size of each step lives here rather than on the fallback: the
12
+ // initials then *inherit* it, so one `size` sets the circle and its text
13
+ // together (a 64 px avatar with 14 px initials looks broken).
14
+ size: {
15
+ xs: "size-5 text-[0.5625rem]",
16
+ sm: "size-7 text-[0.6875rem]",
17
+ default: "size-9 text-sm",
18
+ lg: "size-12 text-base",
19
+ xl: "size-16 text-xl",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ size: "default",
24
+ },
25
+ })
26
+
27
+ export type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>
28
+
29
+ /**
30
+ * The size an `AvatarGroup` imposes on the avatars inside it, so a stack is
31
+ * sized once instead of on every child. `null` = no group above.
32
+ */
33
+ const AvatarGroupSizeContext = createContext<AvatarSize | null>(null)
34
+
35
+ export interface AvatarProps extends AvatarPrimitive.Root.Props {
36
+ /**
37
+ * Diameter of the circle (20 / 28 / 36 / 48 / 64 px) — and the size of the
38
+ * fallback initials with it. Inside an `AvatarGroup`, defaults to the group's.
39
+ * @default "default"
40
+ */
41
+ size?: AvatarSize
42
+ }
43
+
7
44
  /** Circular avatar container. Compose `AvatarImage` + `AvatarFallback` inside. */
8
- function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
45
+ function Avatar({ className, size, ...props }: AvatarProps) {
46
+ const groupSize = useContext(AvatarGroupSizeContext)
47
+
9
48
  return (
10
49
  <AvatarPrimitive.Root
11
50
  data-slot="avatar"
12
- className={cn(
13
- "relative flex size-9 shrink-0 overflow-hidden rounded-full bg-muted select-none",
14
- className
15
- )}
51
+ className={cn(avatarVariants({ size: size ?? groupSize ?? undefined }), className)}
16
52
  {...props}
17
53
  />
18
54
  )
@@ -29,7 +65,8 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
29
65
  <AvatarPrimitive.Fallback
30
66
  data-slot="avatar-fallback"
31
67
  className={cn(
32
- "flex size-full items-center justify-center bg-muted text-sm font-medium text-muted-foreground",
68
+ // No text-size here on purpose — it comes from the root's `size`.
69
+ "flex size-full items-center justify-center bg-muted font-medium text-muted-foreground",
33
70
  className
34
71
  )}
35
72
  {...props}
@@ -37,4 +74,169 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
37
74
  )
38
75
  }
39
76
 
40
- export { Avatar, AvatarImage, AvatarFallback }
77
+ export type AvatarGroupSpacing = "tight" | "default" | "loose"
78
+
79
+ // How far each avatar slides under the previous one. Expressed per size because
80
+ // an overlap is a *ratio* of the circle (~29% at default), and Tailwind needs
81
+ // whole class names — hence a table rather than a computed `-ml-${n}`.
82
+ const OVERLAP: Record<AvatarSize, Record<AvatarGroupSpacing, string>> = {
83
+ xs: { tight: "-ml-2", default: "-ml-1.5", loose: "-ml-1" },
84
+ sm: { tight: "-ml-3", default: "-ml-2", loose: "-ml-1.5" },
85
+ default: { tight: "-ml-3.5", default: "-ml-2.5", loose: "-ml-2" },
86
+ lg: { tight: "-ml-5", default: "-ml-3.5", loose: "-ml-2.5" },
87
+ xl: { tight: "-ml-6", default: "-ml-5", loose: "-ml-3.5" },
88
+ }
89
+
90
+ // The ring is what separates two overlapping circles — it has to grow with them.
91
+ const RING: Record<AvatarSize, string> = {
92
+ xs: "ring-[1.5px]",
93
+ sm: "ring-2",
94
+ default: "ring-2",
95
+ lg: "ring-2",
96
+ xl: "ring-[3px]",
97
+ }
98
+
99
+ export interface AvatarGroupProps extends Omit<ComponentProps<"div">, "children"> {
100
+ /** The avatars, in reading order. Each one paints over its left neighbour. */
101
+ children?: ReactNode
102
+ /**
103
+ * Size applied to every avatar in the stack (a child's own `size` still wins).
104
+ * @default "default"
105
+ */
106
+ size?: AvatarSize
107
+ /**
108
+ * Show at most this many avatars; the rest collapse into a `+N` counter.
109
+ * Left unset, everything is drawn.
110
+ */
111
+ max?: number
112
+ /**
113
+ * Real number of people, when the caller only renders a sample of them (a
114
+ * members list that fetched 5 avatars out of 40 → `total={40}` counts the 35
115
+ * it never received). Defaults to the number of children.
116
+ */
117
+ total?: number
118
+ /**
119
+ * How much of each avatar the next one covers — ~40%, ~29%, ~20%.
120
+ * @default "default"
121
+ */
122
+ spacing?: AvatarGroupSpacing
123
+ /**
124
+ * Ring color punched between overlapping avatars: it must match the surface
125
+ * *behind* the stack, so inside a `Card` it's `ring-card`.
126
+ * @default "ring-background"
127
+ */
128
+ ringClassName?: string
129
+ /**
130
+ * Hovering an avatar brings it to the front (and lifts it a hair), which is
131
+ * how a covered face becomes readable without a click.
132
+ * @default true
133
+ */
134
+ revealOnHover?: boolean
135
+ /** Makes the `+N` counter a real button — "see all members". */
136
+ onOverflowClick?: () => void
137
+ /** Accessible name of that button. @default "Voir tout" */
138
+ overflowLabel?: string
139
+ }
140
+
141
+ /**
142
+ * A stack of overlapping avatars — the compact way to say "these people".
143
+ *
144
+ * ```tsx
145
+ * <AvatarGroup size="sm" max={4} total={members.length}>
146
+ * {members.map((m) => (
147
+ * <Avatar key={m.id}>
148
+ * <AvatarImage src={m.avatarUrl} alt={m.name} />
149
+ * <AvatarFallback>{initials(m.name)}</AvatarFallback>
150
+ * </Avatar>
151
+ * ))}
152
+ * </AvatarGroup>
153
+ * ```
154
+ *
155
+ * Purely presentational, and deliberately **compositional**: the group never
156
+ * reads a `{ name, src }` shape, it wraps whatever you put in it — an `Avatar`,
157
+ * one inside a `Tooltip`, a `Skeleton` while the list loads. It only owns what
158
+ * a caller can't get right by hand: the overlap, the ring that separates two
159
+ * faces, the paint order, and the `+N`.
160
+ */
161
+ function AvatarGroup({
162
+ className,
163
+ children,
164
+ size = "default",
165
+ max,
166
+ total,
167
+ spacing = "default",
168
+ ringClassName = "ring-background",
169
+ revealOnHover = true,
170
+ onOverflowClick,
171
+ overflowLabel = "Voir tout",
172
+ ...props
173
+ }: AvatarGroupProps) {
174
+ const items = Children.toArray(children)
175
+ const limit = max != null && max > 0 ? max : items.length
176
+
177
+ let shown = items.slice(0, limit)
178
+ // People the caller knows about but didn't render at all.
179
+ const unrendered = Math.max(0, (total ?? items.length) - items.length)
180
+ let overflow = items.length - shown.length + unrendered
181
+
182
+ // A single hidden child costs exactly the room a "+1" would take, and says
183
+ // strictly less. Draw the face instead.
184
+ if (overflow === 1 && unrendered === 0) {
185
+ shown = items
186
+ overflow = 0
187
+ }
188
+
189
+ const item = (node: ReactNode, index: number) => (
190
+ <span
191
+ key={index}
192
+ data-slot="avatar-group-item"
193
+ className={cn(
194
+ "relative inline-flex rounded-full",
195
+ RING[size],
196
+ ringClassName,
197
+ index > 0 && OVERLAP[size][spacing],
198
+ revealOnHover && "transition-transform duration-150 hover:z-10 hover:-translate-y-0.5"
199
+ )}
200
+ >
201
+ {node}
202
+ </span>
203
+ )
204
+
205
+ const counter = `+${overflow}`
206
+ const counterClass = cn(
207
+ avatarVariants({ size }),
208
+ "items-center justify-center font-medium text-muted-foreground"
209
+ )
210
+
211
+ return (
212
+ <AvatarGroupSizeContext.Provider value={size}>
213
+ <div data-slot="avatar-group" className={cn("flex items-center", className)} {...props}>
214
+ {shown.map(item)}
215
+ {overflow > 0 &&
216
+ item(
217
+ onOverflowClick ? (
218
+ <button
219
+ type="button"
220
+ data-slot="avatar-group-overflow"
221
+ aria-label={overflowLabel}
222
+ onClick={onOverflowClick}
223
+ className={cn(
224
+ counterClass,
225
+ "cursor-pointer transition-colors hover:bg-primary/15 hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
226
+ )}
227
+ >
228
+ {counter}
229
+ </button>
230
+ ) : (
231
+ <span data-slot="avatar-group-overflow" className={counterClass}>
232
+ {counter}
233
+ </span>
234
+ ),
235
+ shown.length
236
+ )}
237
+ </div>
238
+ </AvatarGroupSizeContext.Provider>
239
+ )
240
+ }
241
+
242
+ export { Avatar, AvatarImage, AvatarFallback, AvatarGroup }
@@ -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
@@ -23,7 +23,11 @@ export { RadioGroup, RadioGroupItem } from "./components/radio-group"
23
23
  export { Separator } from "./components/separator"
24
24
  export { Skeleton } from "./components/skeleton"
25
25
  export { Spinner, spinnerVariants } from "./components/spinner"
26
- export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
26
+ // Avatar + the stack of overlapping avatars (`AvatarGroup`) — compositional:
27
+ // the group wraps whatever you put in it and owns the overlap, the ring, the
28
+ // paint order and the `+N`.
29
+ export { Avatar, AvatarImage, AvatarFallback, AvatarGroup } from "./components/avatar"
30
+ export type { AvatarProps, AvatarSize, AvatarGroupProps, AvatarGroupSpacing } from "./components/avatar"
27
31
 
28
32
  // xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
29
33
  // the write/writeln ref handle).
@@ -66,6 +70,45 @@ export {
66
70
  SelectSeparator,
67
71
  } from "./components/select"
68
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
+
69
112
  // Overlays that plug into the `SpuntoProvider` umbrella (portal container +
70
113
  // tooltip delay group).
71
114
  export { Tooltip } from "./components/tooltip"