@spunto/design-system 0.15.1 → 0.17.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,630 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useId,
9
+ useRef,
10
+ useState,
11
+ type ComponentProps,
12
+ type ReactElement,
13
+ type ReactNode,
14
+ type RefObject,
15
+ } from "react"
16
+ import { Autocomplete } from "@base-ui/react/autocomplete"
17
+ import { Popover } from "@base-ui/react/popover"
18
+ import { SearchIcon } from "lucide-react"
19
+
20
+ import { cn } from "../utils"
21
+ import { highlightMatch, type CommandFilter, type CommandFilterItem } from "./command-shared"
22
+ import { Kbd } from "./kbd"
23
+ import { Skeleton } from "./skeleton"
24
+ import { Spinner } from "./spinner"
25
+ import { useOverlayContainer } from "./spunto-provider"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Why this exists next to CommandPalette
29
+ //
30
+ // `CommandPalette` is a *dialog*: it traps focus, which is exactly right for ⌘K
31
+ // and exactly wrong for a "/" menu inside an editor. There, the caret must stay
32
+ // in the block the user is typing in — the moment focus moves, the insertion
33
+ // point is lost and the menu has nothing left to insert into.
34
+ //
35
+ // So this is a separate component rather than a `modal={false}` flag on the
36
+ // palette: the contract isn't "same thing, less dimming", it's the opposite one.
37
+ // Nothing here ever moves focus (Base UI `Popover` with `initialFocus={false}`,
38
+ // `finalFocus={false}`, no backdrop, non-modal), the menu is anchored to whatever
39
+ // the caller points at (an element, a ref, or a virtual element — a caret rect),
40
+ // and the keys it needs (↑ ↓ ↵ esc) are taken on `document` in the **capture**
41
+ // phase, so they never reach the field underneath. Everything else — every
42
+ // printable character, Home/End, the caret keys we don't claim — flows to the
43
+ // field as if no menu were open.
44
+ //
45
+ // Two ways to drive the query, and they're the two real shapes of this UI:
46
+ // - the caller owns the text (the "/" menu: `query={afterTheSlash}`), or
47
+ // - the menu owns it, by rendering a `CommandMenuInput` inside the popup
48
+ // (a filter dropdown hanging off a button; add `autoFocus` if that field
49
+ // should take focus — it's opt-in, and still not a trap).
50
+ //
51
+ // Presentational, like the palette: `loading` is a prop, results are children,
52
+ // a server-side search is `onQueryChange` + `filter={null}`.
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Same shape as the palette's — a filter written for one works on the other. */
56
+ export type CommandMenuFilterItem = CommandFilterItem
57
+ export type CommandMenuFilter = CommandFilter
58
+
59
+ /** How every part finds the rendered items: they're the only thing carrying this slot. */
60
+ const ITEM_SELECTOR = "[data-slot='command-menu-item']"
61
+
62
+ interface MenuContextValue {
63
+ query: string
64
+ loading: boolean
65
+ activeValue: string | null
66
+ setActiveValue: (value: string) => void
67
+ setQuery: (value: string) => void
68
+ matches: (item: CommandFilterItem) => boolean
69
+ close: () => void
70
+ listId: string
71
+ }
72
+
73
+ const MenuContext = createContext<MenuContextValue | null>(null)
74
+
75
+ function useMenu(part: string): MenuContextValue {
76
+ const ctx = useContext(MenuContext)
77
+ if (!ctx) throw new Error(`<${part}> must be rendered inside <CommandMenu>.`)
78
+ return ctx
79
+ }
80
+
81
+ // --- root -------------------------------------------------------------------
82
+
83
+ export interface CommandMenuProps {
84
+ /** Controlled open state — the caller decides (typing "/", clicking a button…). */
85
+ open: boolean
86
+ onOpenChange: (open: boolean) => void
87
+ /**
88
+ * What the menu hangs off: an element, a ref, a getter, or a **virtual element**
89
+ * (`{ getBoundingClientRect() }`) — which is how a caret becomes an anchor.
90
+ */
91
+ anchor?: Popover.Positioner.Props["anchor"]
92
+ /**
93
+ * The field that keeps the focus. Purely for accessibility: while the menu is
94
+ * open, it gets `aria-expanded`, `aria-controls` and a live `aria-activedescendant`
95
+ * pointing at the highlighted item, so a screen reader announces the highlight
96
+ * without focus ever moving. Keyboard interception doesn't need it.
97
+ */
98
+ inputRef?: RefObject<HTMLElement | null>
99
+ /** Controlled query. Leave it out to let a `CommandMenuInput` own it. */
100
+ query?: string
101
+ defaultQuery?: string
102
+ /** Fires on every keystroke of a `CommandMenuInput` — the hook for a server-side search. */
103
+ onQueryChange?: (query: string) => void
104
+ /**
105
+ * Local filtering. Default: accent- and case-insensitive "contains" over the
106
+ * item's label, keywords and value. `null` filters nothing (results already
107
+ * come filtered), or pass your own predicate.
108
+ */
109
+ filter?: CommandMenuFilter | null
110
+ /** Draws the input spinner, shows `CommandMenuLoading`, silences `CommandMenuEmpty`. */
111
+ loading?: boolean
112
+ /** Where the popup sits relative to the anchor. */
113
+ side?: Popover.Positioner.Props["side"]
114
+ align?: Popover.Positioner.Props["align"]
115
+ sideOffset?: number
116
+ alignOffset?: number
117
+ /** Accessible name of the listbox. */
118
+ label?: string
119
+ className?: string
120
+ children?: ReactNode
121
+ }
122
+
123
+ /**
124
+ * Inline command menu — the palette's parts, anchored, and without a focus trap.
125
+ *
126
+ * ```tsx
127
+ * <CommandMenu open={open} onOpenChange={setOpen} anchor={caretRect} inputRef={editorRef} query={query}>
128
+ * <CommandMenuList>
129
+ * <CommandMenuEmpty>Aucun bloc</CommandMenuEmpty>
130
+ * <CommandMenuGroup heading="Blocs">
131
+ * <CommandMenuItem icon={<Heading1 />} onSelect={() => insert("h1")}>Titre 1</CommandMenuItem>
132
+ * </CommandMenuGroup>
133
+ * </CommandMenuList>
134
+ * </CommandMenu>
135
+ * ```
136
+ */
137
+ function CommandMenu({
138
+ open,
139
+ onOpenChange,
140
+ anchor,
141
+ inputRef,
142
+ query: queryProp,
143
+ defaultQuery = "",
144
+ onQueryChange,
145
+ filter,
146
+ loading = false,
147
+ side = "bottom",
148
+ align = "start",
149
+ sideOffset = 6,
150
+ alignOffset = 0,
151
+ label = "Menu de commandes",
152
+ className,
153
+ children,
154
+ }: CommandMenuProps) {
155
+ const container = useOverlayContainer()
156
+ const listId = useId()
157
+ const popupRef = useRef<HTMLDivElement | null>(null)
158
+ const [uncontrolled, setUncontrolled] = useState(defaultQuery)
159
+ const [activeValue, setActiveValue] = useState<string | null>(null)
160
+ const query = queryProp ?? uncontrolled
161
+ const { contains } = Autocomplete.useFilter({ sensitivity: "base" })
162
+
163
+ const setQuery = useCallback(
164
+ (next: string) => {
165
+ if (queryProp === undefined) setUncontrolled(next)
166
+ onQueryChange?.(next)
167
+ },
168
+ [queryProp, onQueryChange]
169
+ )
170
+
171
+ const close = useCallback(() => onOpenChange(false), [onOpenChange])
172
+
173
+ // Every rendered item, in DOM order. The DOM is the source of truth here for
174
+ // the same reason it is in the palette: items filter themselves out by
175
+ // rendering nothing, so any list we kept in state would describe the previous
176
+ // keystroke.
177
+ const items = useCallback(
178
+ () => Array.from(popupRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []),
179
+ []
180
+ )
181
+
182
+ // Keep a highlight on something that still exists. Runs after *every* commit
183
+ // (no dep array) because what changed is the rendered list, not a value we
184
+ // hold — the functional update makes it a no-op once it has settled.
185
+ useEffect(() => {
186
+ if (!open) return
187
+ const values = items().map((el) => el.dataset.value ?? "")
188
+ setActiveValue((prev) => (prev && values.includes(prev) ? prev : (values[0] ?? null)))
189
+ })
190
+
191
+ useEffect(() => {
192
+ if (!open) setActiveValue(null)
193
+ }, [open])
194
+
195
+ // Follow the highlight when it moves out of view. `nearest` so an already
196
+ // visible item doesn't make the list jump.
197
+ useEffect(() => {
198
+ if (!open || !activeValue) return
199
+ popupRef.current
200
+ ?.querySelector<HTMLElement>(`${ITEM_SELECTOR}[data-active='true']`)
201
+ ?.scrollIntoView({ block: "nearest" })
202
+ }, [open, activeValue])
203
+
204
+ // The keys the menu claims, taken on `document` in the capture phase so they
205
+ // are consumed before the focused field (or React's delegated handlers) ever
206
+ // sees them. Deliberately short: ↵ is only claimed when there *is* something
207
+ // to activate, so "/" + a query with no result still inserts a line break.
208
+ useEffect(() => {
209
+ if (!open) return
210
+ function onKeyDown(event: KeyboardEvent) {
211
+ if (event.defaultPrevented) return
212
+ const claim = () => {
213
+ event.preventDefault()
214
+ event.stopPropagation()
215
+ }
216
+ if (event.key === "Escape") {
217
+ claim()
218
+ close()
219
+ return
220
+ }
221
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
222
+ const all = items()
223
+ if (all.length === 0) return
224
+ claim()
225
+ const values = all.map((el) => el.dataset.value ?? "")
226
+ const at = activeValue ? values.indexOf(activeValue) : -1
227
+ const step = event.key === "ArrowDown" ? 1 : -1
228
+ const next = (at + step + values.length) % values.length
229
+ setActiveValue(values[next])
230
+ return
231
+ }
232
+ if (event.key === "Enter") {
233
+ const active = popupRef.current?.querySelector<HTMLElement>(`${ITEM_SELECTOR}[data-active='true']`)
234
+ if (!active) return
235
+ claim()
236
+ // A click, not a stored callback: it also does the right thing when the
237
+ // item rendered itself as a link.
238
+ active.click()
239
+ }
240
+ }
241
+ document.addEventListener("keydown", onKeyDown, true)
242
+ return () => document.removeEventListener("keydown", onKeyDown, true)
243
+ }, [open, activeValue, items, close])
244
+
245
+ // Combobox semantics for the field that kept the focus. Written to the DOM
246
+ // rather than handed back as props: the caller's field is often a
247
+ // contenteditable deep in an editor, not something we can spread props onto.
248
+ useEffect(() => {
249
+ const el = inputRef?.current
250
+ if (!el) return
251
+ if (!open) {
252
+ el.removeAttribute("aria-expanded")
253
+ el.removeAttribute("aria-controls")
254
+ el.removeAttribute("aria-activedescendant")
255
+ return
256
+ }
257
+ el.setAttribute("aria-expanded", "true")
258
+ el.setAttribute("aria-controls", listId)
259
+ const active = popupRef.current?.querySelector<HTMLElement>(`${ITEM_SELECTOR}[data-active='true']`)
260
+ if (active?.id) el.setAttribute("aria-activedescendant", active.id)
261
+ else el.removeAttribute("aria-activedescendant")
262
+ return () => {
263
+ el.removeAttribute("aria-expanded")
264
+ el.removeAttribute("aria-controls")
265
+ el.removeAttribute("aria-activedescendant")
266
+ }
267
+ }, [open, activeValue, inputRef, listId])
268
+
269
+ const ctx: MenuContextValue = {
270
+ query,
271
+ loading,
272
+ activeValue,
273
+ setActiveValue,
274
+ setQuery,
275
+ close,
276
+ listId,
277
+ matches: (item) => {
278
+ if (!query.trim()) return true
279
+ if (filter === null) return true
280
+ if (filter) return filter(item, query)
281
+ return [item.label, item.value ?? "", ...item.keywords].some((text) => text && contains(text, query))
282
+ },
283
+ }
284
+
285
+ return (
286
+ <Popover.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
287
+ <Popover.Portal container={container ?? undefined}>
288
+ <Popover.Positioner
289
+ data-slot="command-menu-positioner"
290
+ className="z-50 outline-none"
291
+ anchor={anchor}
292
+ side={side}
293
+ align={align}
294
+ sideOffset={sideOffset}
295
+ alignOffset={alignOffset}
296
+ >
297
+ <Popover.Popup
298
+ ref={popupRef}
299
+ data-slot="command-menu"
300
+ // The whole point: opening and closing leave the focus alone.
301
+ initialFocus={false}
302
+ finalFocus={false}
303
+ // A pointer press inside the menu would blur the field the caret
304
+ // lives in before the click ever lands. Cancel the default, keep the
305
+ // caret, let `click` do the work.
306
+ onMouseDown={(event) => event.preventDefault()}
307
+ className={cn(
308
+ "flex max-h-[min(20rem,var(--available-height))] w-[min(20rem,var(--available-width))] flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg shadow-black/[0.08] outline-none",
309
+ "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",
310
+ className
311
+ )}
312
+ aria-label={label}
313
+ >
314
+ <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>
315
+ </Popover.Popup>
316
+ </Popover.Positioner>
317
+ </Popover.Portal>
318
+ </Popover.Root>
319
+ )
320
+ }
321
+
322
+ // --- input ------------------------------------------------------------------
323
+
324
+ export interface CommandMenuInputProps
325
+ extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size"> {
326
+ /** Left-hand icon. Defaults to a magnifier; pass `null` for none. */
327
+ icon?: ReactNode
328
+ }
329
+
330
+ /**
331
+ * Optional search row *inside* the popup, for a menu that owns its own query
332
+ * (a filter dropdown on a button). Leave it out when the caller's field is the
333
+ * query — the "/" menu case. It doesn't take focus unless you pass `autoFocus`.
334
+ */
335
+ function CommandMenuInput({ className, placeholder = "Rechercher…", icon, ...props }: CommandMenuInputProps) {
336
+ const { loading, query, setQuery } = useMenu("CommandMenuInput")
337
+ return (
338
+ <div
339
+ data-slot="command-menu-input"
340
+ className={cn("flex h-9 shrink-0 items-center gap-2 border-b border-border px-2.5", className)}
341
+ >
342
+ {icon !== null && (
343
+ <span className="shrink-0 text-muted-foreground [&_svg]:size-4" aria-hidden>
344
+ {icon ?? <SearchIcon className="size-4" />}
345
+ </span>
346
+ )}
347
+ <input
348
+ // `any-pointer-coarse:text-base` — below 16px, iOS zooms the page on focus.
349
+ className="h-full min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground any-pointer-coarse:text-base"
350
+ placeholder={placeholder}
351
+ aria-label={props["aria-label"] ?? placeholder}
352
+ value={query}
353
+ onChange={(event) => setQuery(event.target.value)}
354
+ {...props}
355
+ />
356
+ {loading && <Spinner size="xs" className="shrink-0 text-muted-foreground" />}
357
+ </div>
358
+ )
359
+ }
360
+
361
+ // --- list -------------------------------------------------------------------
362
+
363
+ /**
364
+ * The scrollable listbox. Wrap the groups, items, `CommandMenuEmpty` and
365
+ * `CommandMenuLoading` in it — the empty state keys off `:has()` on this
366
+ * element, so it only works from inside.
367
+ */
368
+ function CommandMenuList({ className, children, ...props }: ComponentProps<"div">) {
369
+ const { loading, listId } = useMenu("CommandMenuList")
370
+ const ref = useRef<HTMLDivElement>(null)
371
+ const [count, setCount] = useState(0)
372
+
373
+ // Counting the rendered items is the one thing CSS can't do for us, and the
374
+ // announcement below needs it. Reading the DOM after the commit is cheaper
375
+ // (and more accurate) than making every item report in.
376
+ useEffect(() => {
377
+ const rendered = ref.current?.querySelectorAll(ITEM_SELECTOR).length ?? 0
378
+ setCount((prev) => (prev === rendered ? prev : rendered))
379
+ })
380
+
381
+ return (
382
+ <div
383
+ ref={ref}
384
+ data-slot="command-menu-list"
385
+ className={cn("group/cmd-menu min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5", className)}
386
+ {...props}
387
+ >
388
+ <div id={listId} role="listbox" aria-label="Résultats">
389
+ {children}
390
+ </div>
391
+ {/* Must stay mounted to announce reliably — only its text changes. */}
392
+ <div role="status" aria-live="polite" className="sr-only">
393
+ {loading ? "Recherche en cours…" : count === 0 ? "Aucun résultat" : `${count} résultat${count > 1 ? "s" : ""}`}
394
+ </div>
395
+ </div>
396
+ )
397
+ }
398
+
399
+ // --- group / separator ------------------------------------------------------
400
+
401
+ export interface CommandMenuGroupProps extends ComponentProps<"div"> {
402
+ heading?: ReactNode
403
+ }
404
+
405
+ /**
406
+ * A titled block of items. It hides itself when none of its items survived the
407
+ * filter — through `:has()` rather than a JS count, so the heading never flashes
408
+ * for a frame above an empty group.
409
+ */
410
+ function CommandMenuGroup({ heading, className, children, ...props }: CommandMenuGroupProps) {
411
+ const headingId = useId()
412
+ return (
413
+ <div
414
+ data-slot="command-menu-group"
415
+ role="group"
416
+ aria-labelledby={heading != null ? headingId : undefined}
417
+ className={cn("hidden has-[[data-slot=command-menu-item]]:block", className)}
418
+ {...props}
419
+ >
420
+ {heading != null && (
421
+ <div
422
+ id={headingId}
423
+ className="px-2 pt-1.5 pb-1 text-[0.6875rem] font-medium tracking-wide text-muted-foreground uppercase select-none"
424
+ >
425
+ {heading}
426
+ </div>
427
+ )}
428
+ {children}
429
+ </div>
430
+ )
431
+ }
432
+
433
+ /** A thin rule between groups. Static — it doesn't disappear with its neighbours. */
434
+ function CommandMenuSeparator({ className, ...props }: ComponentProps<"div">) {
435
+ return (
436
+ <div
437
+ data-slot="command-menu-separator"
438
+ role="separator"
439
+ className={cn("-mx-1.5 my-1 h-px bg-border", className)}
440
+ {...props}
441
+ />
442
+ )
443
+ }
444
+
445
+ // --- item -------------------------------------------------------------------
446
+
447
+ /** Props handed to the app's link component. Spread them all. */
448
+ export type CommandMenuLinkProps = ComponentProps<"a"> & { href: string }
449
+ export type CommandMenuLinkRender = (props: CommandMenuLinkProps) => ReactElement
450
+
451
+ export interface CommandMenuItemProps extends Omit<ComponentProps<"div">, "onSelect"> {
452
+ /** Leading icon (a `lucide-react` glyph, an avatar, a status dot…). */
453
+ icon?: ReactNode
454
+ /** Secondary text, right-aligned. */
455
+ meta?: ReactNode
456
+ /** Second line under the label. */
457
+ description?: ReactNode
458
+ /** Shortcut cap on the right, e.g. `"mod+n"`. */
459
+ shortcut?: string
460
+ /** Extra search terms, never displayed. */
461
+ keywords?: string[]
462
+ /**
463
+ * Text the filter matches on. Defaults to the children when they're a plain
464
+ * string — pass it explicitly as soon as they aren't.
465
+ */
466
+ label?: string
467
+ /** Stable identity for the item. Also matched by the filter. */
468
+ value?: string
469
+ disabled?: boolean
470
+ /** Turns the item into a link. Enter and click both navigate. */
471
+ href?: string
472
+ /**
473
+ * Escape hatch for app-specific rendering. `link` gets every prop the item put
474
+ * on the node plus `href`, and must return an anchor-ish node (`next/link`, a
475
+ * router link…). The package never imports `next/link`.
476
+ */
477
+ render?: { link?: CommandMenuLinkRender }
478
+ /** Fires on click *and* on Enter while highlighted. */
479
+ onSelect?: () => void
480
+ /**
481
+ * Whether selecting closes the menu.
482
+ * @default true
483
+ */
484
+ closeOnSelect?: boolean
485
+ }
486
+
487
+ /**
488
+ * One entry. Renders nothing when it doesn't match the query — that's the whole
489
+ * filtering mechanism, and what keeps a long list cheap once the user types.
490
+ */
491
+ function CommandMenuItem({
492
+ icon,
493
+ meta,
494
+ description,
495
+ shortcut,
496
+ keywords = [],
497
+ label: labelProp,
498
+ value,
499
+ disabled,
500
+ href,
501
+ render,
502
+ onSelect,
503
+ closeOnSelect = true,
504
+ className,
505
+ children,
506
+ ...props
507
+ }: CommandMenuItemProps) {
508
+ const { matches, close, query, activeValue, setActiveValue } = useMenu("CommandMenuItem")
509
+ const autoId = useId()
510
+ const label = labelProp ?? (typeof children === "string" ? children : undefined)
511
+
512
+ if (!matches({ label: label ?? "", keywords, value })) return null
513
+
514
+ const itemValue = value ?? label ?? autoId
515
+ const active = activeValue === itemValue
516
+
517
+ const content = (
518
+ <>
519
+ {icon != null && (
520
+ <span
521
+ className={cn(
522
+ "flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4",
523
+ active && "text-foreground"
524
+ )}
525
+ aria-hidden
526
+ >
527
+ {icon}
528
+ </span>
529
+ )}
530
+ <span className="min-w-0 flex-1">
531
+ <span className="block truncate">{label != null ? highlightMatch(label, query) : children}</span>
532
+ {description != null && <span className="block truncate text-xs text-muted-foreground">{description}</span>}
533
+ </span>
534
+ {meta != null && <span className="shrink-0 truncate text-xs text-muted-foreground tabular-nums">{meta}</span>}
535
+ {shortcut && <Kbd keys={shortcut} className="shrink-0" />}
536
+ </>
537
+ )
538
+
539
+ const shared = {
540
+ id: autoId,
541
+ role: "option" as const,
542
+ "aria-selected": active,
543
+ "aria-disabled": disabled || undefined,
544
+ "data-slot": "command-menu-item",
545
+ "data-value": itemValue,
546
+ "data-active": active || undefined,
547
+ "data-disabled": disabled || undefined,
548
+ onMouseEnter: () => {
549
+ if (!disabled) setActiveValue(itemValue)
550
+ },
551
+ onClick: () => {
552
+ if (disabled) return
553
+ onSelect?.()
554
+ if (closeOnSelect) close()
555
+ },
556
+ className: cn(
557
+ "flex scroll-my-1.5 cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-foreground no-underline outline-none select-none",
558
+ active && "bg-accent text-accent-foreground",
559
+ disabled && "pointer-events-none opacity-50",
560
+ className
561
+ ),
562
+ }
563
+
564
+ if (href) {
565
+ const linkRender = render?.link ?? ((linkProps: CommandMenuLinkProps) => <a {...linkProps} />)
566
+ return linkRender({ ...shared, ...(props as ComponentProps<"a">), href, children: content })
567
+ }
568
+
569
+ return (
570
+ <div {...shared} {...props}>
571
+ {content}
572
+ </div>
573
+ )
574
+ }
575
+
576
+ // --- empty / loading --------------------------------------------------------
577
+
578
+ /**
579
+ * Shown only when no item survived the filter, and never while `loading` (a
580
+ * pending search isn't an empty result). Must live inside `CommandMenuList`.
581
+ */
582
+ function CommandMenuEmpty({ className, children = "Aucun résultat", ...props }: ComponentProps<"div">) {
583
+ const { loading } = useMenu("CommandMenuEmpty")
584
+ if (loading) return null
585
+ return (
586
+ <div
587
+ data-slot="command-menu-empty"
588
+ role="presentation"
589
+ className={cn(
590
+ "px-3 py-6 text-center text-sm text-muted-foreground group-has-[[data-slot=command-menu-item]]/cmd-menu:hidden",
591
+ className
592
+ )}
593
+ {...props}
594
+ >
595
+ {children}
596
+ </div>
597
+ )
598
+ }
599
+
600
+ export interface CommandMenuLoadingProps extends ComponentProps<"div"> {
601
+ /** Number of skeleton rows. @default 3 */
602
+ rows?: number
603
+ }
604
+
605
+ /** Skeleton rows while an async search is in flight. Renders nothing unless the root is `loading`. */
606
+ function CommandMenuLoading({ rows = 3, className, ...props }: CommandMenuLoadingProps) {
607
+ const { loading } = useMenu("CommandMenuLoading")
608
+ if (!loading) return null
609
+ return (
610
+ <div data-slot="command-menu-loading" role="presentation" className={cn("space-y-1", className)} {...props}>
611
+ {Array.from({ length: rows }, (_, i) => (
612
+ <div key={i} className="flex items-center gap-2 px-2 py-1.5">
613
+ <Skeleton className="size-4 shrink-0 rounded" />
614
+ <Skeleton className="h-3" style={{ width: `${45 - i * 8}%` }} />
615
+ </div>
616
+ ))}
617
+ </div>
618
+ )
619
+ }
620
+
621
+ export {
622
+ CommandMenu,
623
+ CommandMenuInput,
624
+ CommandMenuList,
625
+ CommandMenuGroup,
626
+ CommandMenuSeparator,
627
+ CommandMenuItem,
628
+ CommandMenuEmpty,
629
+ CommandMenuLoading,
630
+ }