@spunto/design-system 0.9.3 → 0.11.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.
Files changed (31) hide show
  1. package/README.md +102 -4
  2. package/package.json +9 -1
  3. package/src/components/command-palette.tsx +702 -0
  4. package/src/components/devcontainer/catalog-card.tsx +269 -0
  5. package/src/components/devcontainer/catalog-tile.tsx +133 -0
  6. package/src/components/devcontainer/extension-card.tsx +146 -0
  7. package/src/components/devcontainer/feature-card.tsx +157 -0
  8. package/src/components/devcontainer/feature-tile.tsx +63 -0
  9. package/src/components/devcontainer/image-card.tsx +107 -0
  10. package/src/components/devcontainer/image-tile.tsx +56 -0
  11. package/src/components/devcontainer/index.ts +68 -0
  12. package/src/components/devcontainer/publisher-badge.tsx +91 -0
  13. package/src/components/devcontainer/registry-ref.tsx +89 -0
  14. package/src/components/devcontainer/registry.ts +154 -0
  15. package/src/components/devcontainer/stack-mark-paths.ts +60 -0
  16. package/src/components/devcontainer/stack-mark.tsx +92 -0
  17. package/src/components/devcontainer/types.ts +84 -0
  18. package/src/components/kbd.tsx +117 -0
  19. package/src/components/projects/advanced-sections.tsx +136 -0
  20. package/src/components/projects/build-manifest.tsx +116 -0
  21. package/src/components/projects/extension-picker.tsx +169 -0
  22. package/src/components/projects/feature-picker.tsx +193 -0
  23. package/src/components/projects/form-section.tsx +97 -0
  24. package/src/components/projects/image-picker.tsx +206 -0
  25. package/src/components/projects/index.ts +58 -0
  26. package/src/components/projects/project-form.tsx +488 -0
  27. package/src/components/projects/repo-list.tsx +302 -0
  28. package/src/components/projects/secret-list.tsx +159 -0
  29. package/src/components/projects/types.ts +136 -0
  30. package/src/index.ts +35 -0
  31. package/styles.css +13 -0
@@ -0,0 +1,702 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ type ComponentProps,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react"
13
+ import { Autocomplete } from "@base-ui/react/autocomplete"
14
+ import { Dialog } from "@base-ui/react/dialog"
15
+ import { SearchIcon } from "lucide-react"
16
+
17
+ import { cn } from "../utils"
18
+ import { Kbd } from "./kbd"
19
+ import { Skeleton } from "./skeleton"
20
+ import { Spinner } from "./spinner"
21
+ import { useOverlayContainer } from "./spunto-provider"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Why it's built this way
25
+ //
26
+ // The palette is a Base UI `Autocomplete` (roles, `aria-activedescendant`,
27
+ // ↑↓ highlight, scroll-into-view, Enter-activates-the-highlighted-item) rendered
28
+ // *inline* inside a Base UI `Dialog` (focus trap, focus restore, Escape, backdrop).
29
+ // That's the composition Base UI itself recommends for a command palette.
30
+ //
31
+ // One deviation: Base UI filters from an `items` array, and this component is
32
+ // compositional — the app writes `<CommandPaletteItem>` children, like Dialog or
33
+ // Select parts. So the root runs with `mode="none"` (Base UI keeps its hands off
34
+ // the list) and each item decides *for itself* whether it matches the query, with
35
+ // the same `Autocomplete.useFilter()` collator Base UI would have used. An item
36
+ // that doesn't match renders nothing, which is also what keeps a few hundred
37
+ // items fluid: the DOM shrinks as soon as the user types.
38
+ //
39
+ // Purely presentational: nothing here calls an API. `loading` is a prop, results
40
+ // are children, and a server-side search just sets `filter={null}` and feeds the
41
+ // already-filtered items in.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** What the filter gets to look at for one item. */
45
+ export interface CommandPaletteFilterItem {
46
+ /** `label` prop, or the item's children when they're a plain string. */
47
+ label: string
48
+ /** Extra terms that match but are never displayed. */
49
+ keywords: string[]
50
+ /** The item's `value`, when it has one. */
51
+ value?: string
52
+ }
53
+
54
+ export type CommandPaletteFilter = (item: CommandPaletteFilterItem, query: string) => boolean
55
+
56
+ interface PaletteContextValue {
57
+ query: string
58
+ loading: boolean
59
+ matches: (item: CommandPaletteFilterItem) => boolean
60
+ close: () => void
61
+ }
62
+
63
+ const PaletteContext = createContext<PaletteContextValue | null>(null)
64
+
65
+ /** How every part finds the rendered items: they're the only thing carrying this slot. */
66
+ const ITEM_SELECTOR = "[data-slot='command-palette-item']"
67
+
68
+ function usePalette(part: string): PaletteContextValue {
69
+ const ctx = useContext(PaletteContext)
70
+ if (!ctx) throw new Error(`<${part}> must be rendered inside <CommandPalette>.`)
71
+ return ctx
72
+ }
73
+
74
+ // --- keyboard shortcut ------------------------------------------------------
75
+
76
+ interface Combo {
77
+ key: string
78
+ mod: boolean
79
+ shift: boolean
80
+ alt: boolean
81
+ }
82
+
83
+ /** `"mod+k"` → the combo to match. `mod` = ⌘ on Apple, Ctrl elsewhere. */
84
+ function parseShortcut(shortcut: string): Combo | null {
85
+ const parts = shortcut
86
+ .toLowerCase()
87
+ .split("+")
88
+ .map((p) => p.trim())
89
+ .filter(Boolean)
90
+ const key = parts.pop()
91
+ if (!key) return null
92
+ return {
93
+ key,
94
+ mod: parts.some((p) => p === "mod" || p === "meta" || p === "cmd" || p === "command" || p === "ctrl" || p === "control"),
95
+ shift: parts.includes("shift"),
96
+ alt: parts.some((p) => p === "alt" || p === "option"),
97
+ }
98
+ }
99
+
100
+ function comboMatches(event: KeyboardEvent, combo: Combo): boolean {
101
+ if (event.key.toLowerCase() !== combo.key) return false
102
+ // ⌘ and Ctrl are interchangeable on purpose — a Mac user on a PC keyboard, or
103
+ // a Linux user, should not have to know which one this app decided on.
104
+ if (combo.mod !== (event.metaKey || event.ctrlKey)) return false
105
+ if (combo.shift !== event.shiftKey) return false
106
+ if (combo.alt !== event.altKey) return false
107
+ return true
108
+ }
109
+
110
+ // --- match highlighting -----------------------------------------------------
111
+
112
+ /**
113
+ * Folds a string to a comparable form (lowercase, no diacritics) while keeping,
114
+ * for every folded character, the index it came from — so a match found on the
115
+ * folded text can be highlighted on the original one ("déploiement" matched by
116
+ * "deploi" highlights the right six characters, accent included).
117
+ */
118
+ function fold(text: string): { folded: string; map: number[] } {
119
+ let folded = ""
120
+ const map: number[] = []
121
+ for (let i = 0; i < text.length; i++) {
122
+ const chunk = text[i].normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
123
+ for (const char of chunk) {
124
+ folded += char
125
+ map.push(i)
126
+ }
127
+ }
128
+ return { folded, map }
129
+ }
130
+
131
+ /** Wraps the matched slice of `text` in a `<mark>`. Returns the plain text when there's no match. */
132
+ function highlightMatch(text: string, query: string): ReactNode {
133
+ if (!query.trim()) return text
134
+ const haystack = fold(text)
135
+ const needle = fold(query.trim()).folded
136
+ if (!needle) return text
137
+ const at = haystack.folded.indexOf(needle)
138
+ if (at === -1) return text
139
+ const start = haystack.map[at]
140
+ const end = haystack.map[at + needle.length - 1] + 1
141
+ return (
142
+ <>
143
+ {text.slice(0, start)}
144
+ <mark className="rounded-xs bg-primary/15 text-inherit">{text.slice(start, end)}</mark>
145
+ {text.slice(end)}
146
+ </>
147
+ )
148
+ }
149
+
150
+ // --- root -------------------------------------------------------------------
151
+
152
+ export interface CommandPaletteProps {
153
+ /** Controlled open state. */
154
+ open: boolean
155
+ onOpenChange: (open: boolean) => void
156
+ /**
157
+ * Global shortcut that toggles the palette, bound on `document` while mounted.
158
+ * `mod` = ⌘ on Apple, Ctrl elsewhere. Pass `null` to bind nothing (the app owns
159
+ * the shortcut, e.g. through its own hotkey layer).
160
+ * @default "mod+k"
161
+ */
162
+ shortcut?: string | null
163
+ /** Controlled query. Leave it out for an uncontrolled input. */
164
+ value?: string
165
+ defaultValue?: string
166
+ /** Fires on every keystroke — this is the hook for a server-side search. */
167
+ onValueChange?: (value: string) => void
168
+ /**
169
+ * Local filtering. Default: accent- and case-insensitive "contains" over the
170
+ * item's label, keywords and value. Pass `null` to filter nothing (results
171
+ * already come filtered from an API), or your own predicate.
172
+ */
173
+ filter?: CommandPaletteFilter | null
174
+ /** Draws the input spinner, shows `CommandPaletteLoading`, silences `CommandPaletteEmpty`. */
175
+ loading?: boolean
176
+ /**
177
+ * Whether the rest of the page is inert while the palette is open.
178
+ * @default true
179
+ */
180
+ modal?: boolean
181
+ /** Accessible name of the dialog (no visible title in a palette). */
182
+ label?: string
183
+ className?: string
184
+ children?: ReactNode
185
+ }
186
+
187
+ /**
188
+ * ⌘K palette: an input, groups of items, and a jump. Domain-free on purpose —
189
+ * it knows nothing of projects, workers or deployments; those are items the app
190
+ * hands it.
191
+ *
192
+ * ```tsx
193
+ * <CommandPalette open={open} onOpenChange={setOpen}>
194
+ * <CommandPaletteInput placeholder="Rechercher…" />
195
+ * <CommandPaletteList>
196
+ * <CommandPaletteEmpty>Aucun résultat</CommandPaletteEmpty>
197
+ * <CommandPaletteGroup heading="Projets">
198
+ * <CommandPaletteItem icon={<Folder />} href="/projects/abc">spunto</CommandPaletteItem>
199
+ * </CommandPaletteGroup>
200
+ * </CommandPaletteList>
201
+ * <CommandPaletteFooter />
202
+ * </CommandPalette>
203
+ * ```
204
+ */
205
+ function CommandPalette({
206
+ open,
207
+ onOpenChange,
208
+ shortcut = "mod+k",
209
+ value,
210
+ defaultValue = "",
211
+ onValueChange,
212
+ filter,
213
+ loading = false,
214
+ modal = true,
215
+ label = "Palette de commandes",
216
+ className,
217
+ children,
218
+ }: CommandPaletteProps) {
219
+ const container = useOverlayContainer()
220
+ const [uncontrolled, setUncontrolled] = useState(defaultValue)
221
+ const query = value ?? uncontrolled
222
+ const { contains } = Autocomplete.useFilter({ sensitivity: "base" })
223
+
224
+ // The shortcut listener outlives the popup (it's what opens it), so it reads
225
+ // the current open state through refs instead of re-binding on every change.
226
+ const openRef = useRef(open)
227
+ openRef.current = open
228
+ const onOpenChangeRef = useRef(onOpenChange)
229
+ onOpenChangeRef.current = onOpenChange
230
+
231
+ function setQuery(next: string) {
232
+ if (value === undefined) setUncontrolled(next)
233
+ onValueChange?.(next)
234
+ }
235
+
236
+ function handleOpenChange(next: boolean) {
237
+ onOpenChange(next)
238
+ // A palette always reopens empty — and an app doing a server search needs to
239
+ // hear about it so it drops the previous results.
240
+ if (!next && query !== "") setQuery("")
241
+ }
242
+
243
+ useEffect(() => {
244
+ if (!shortcut) return
245
+ const combo = parseShortcut(shortcut)
246
+ if (!combo) return
247
+ function onKeyDown(event: KeyboardEvent) {
248
+ if (!comboMatches(event, combo!)) return
249
+ event.preventDefault()
250
+ onOpenChangeRef.current(!openRef.current)
251
+ }
252
+ document.addEventListener("keydown", onKeyDown)
253
+ return () => document.removeEventListener("keydown", onKeyDown)
254
+ }, [shortcut])
255
+
256
+ const ctx: PaletteContextValue = {
257
+ query,
258
+ loading,
259
+ close: () => handleOpenChange(false),
260
+ matches: (item) => {
261
+ if (!query.trim()) return true
262
+ if (filter === null) return true
263
+ if (filter) return filter(item, query)
264
+ return [item.label, item.value ?? "", ...item.keywords].some((text) => text && contains(text, query))
265
+ },
266
+ }
267
+
268
+ return (
269
+ <Dialog.Root open={open} onOpenChange={handleOpenChange} modal={modal}>
270
+ <Dialog.Portal container={container ?? undefined}>
271
+ <Dialog.Backdrop
272
+ data-slot="command-palette-backdrop"
273
+ className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:bg-black/60"
274
+ />
275
+ {/* Top-aligned rather than centered: the list grows downwards, and the
276
+ popup doesn't jump when a virtual keyboard opens. `dvh` keeps that
277
+ true on mobile browsers with a collapsing URL bar. */}
278
+ <Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center overflow-hidden px-3 pt-[12vh] pb-3 sm:px-4 sm:pt-[14vh]">
279
+ <Dialog.Popup
280
+ data-slot="command-palette"
281
+ aria-label={label}
282
+ className={cn(
283
+ "flex max-h-[min(30rem,calc(100dvh-12vh-1.5rem))] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl shadow-black/[0.12] outline-none",
284
+ "transition-all duration-200 data-ending-style:-translate-y-2 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:-translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0",
285
+ className
286
+ )}
287
+ >
288
+ {/* `inline` = the list lives in this popup, not in a floating one.
289
+ `mode="none"` = Base UI doesn't filter; items filter themselves. */}
290
+ <Autocomplete.Root
291
+ open
292
+ inline
293
+ mode="none"
294
+ autoHighlight="always"
295
+ keepHighlight
296
+ value={query}
297
+ onValueChange={setQuery}
298
+ >
299
+ <PaletteContext.Provider value={ctx}>{children}</PaletteContext.Provider>
300
+ </Autocomplete.Root>
301
+ </Dialog.Popup>
302
+ </Dialog.Viewport>
303
+ </Dialog.Portal>
304
+ </Dialog.Root>
305
+ )
306
+ }
307
+
308
+ // --- input ------------------------------------------------------------------
309
+
310
+ export interface CommandPaletteInputProps
311
+ extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size"> {
312
+ /** Left-hand icon. Defaults to a magnifier; pass `null` for none. */
313
+ icon?: ReactNode
314
+ /** Draws a clickable `esc` cap on the right that closes the palette. */
315
+ escHint?: boolean
316
+ }
317
+
318
+ /**
319
+ * The search row. Owns the query (through the palette root), the magnifier, the
320
+ * spinner while `loading`, and the optional `esc` affordance — the palette's only
321
+ * pointer-reachable close button, since there's no title bar.
322
+ */
323
+ function CommandPaletteInput({
324
+ className,
325
+ placeholder = "Rechercher…",
326
+ icon,
327
+ escHint = false,
328
+ ...props
329
+ }: CommandPaletteInputProps) {
330
+ const { loading } = usePalette("CommandPaletteInput")
331
+ return (
332
+ <div
333
+ data-slot="command-palette-input"
334
+ className={cn("flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-3.5", className)}
335
+ >
336
+ {icon !== null && (
337
+ <span className="shrink-0 text-muted-foreground [&_svg]:size-4" aria-hidden>
338
+ {icon ?? <SearchIcon className="size-4" />}
339
+ </span>
340
+ )}
341
+ <Autocomplete.Input
342
+ // `any-pointer-coarse:text-base` — below 16px, iOS zooms the page on focus.
343
+ 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"
344
+ placeholder={placeholder}
345
+ aria-label={props["aria-label"] ?? placeholder}
346
+ {...props}
347
+ />
348
+ {loading && <Spinner size="xs" className="shrink-0 text-muted-foreground" />}
349
+ {escHint && (
350
+ <Dialog.Close
351
+ aria-label="Fermer"
352
+ className="shrink-0 rounded transition-opacity outline-none hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring/50"
353
+ >
354
+ <Kbd aria-hidden>esc</Kbd>
355
+ </Dialog.Close>
356
+ )}
357
+ </div>
358
+ )
359
+ }
360
+
361
+ // --- list -------------------------------------------------------------------
362
+
363
+ /**
364
+ * The scrollable results area. Wrap the groups, items, `CommandPaletteEmpty` and
365
+ * `CommandPaletteLoading` in it — the empty state keys off `:has()` on this
366
+ * element, so it only works from inside.
367
+ */
368
+ function CommandPaletteList({ className, children, ...props }: ComponentProps<"div">) {
369
+ const { loading } = usePalette("CommandPaletteList")
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 it's
374
+ // needed for the screen-reader announcement below. Reading the DOM after the
375
+ // commit is cheaper (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
+ // Base UI auto-highlights the first item when the *query* changes. Results
382
+ // that land later — a server search coming back — miss that pass, and the list
383
+ // would sit there with nothing highlighted and ↵ doing nothing. So when the
384
+ // list goes from empty to populated with still nothing highlighted, nudge it
385
+ // exactly the way a user would. Reading `count` (set one commit after the
386
+ // items rendered) is what makes the check reliable: by then the highlight, if
387
+ // Base UI set one, is on the DOM.
388
+ const wasEmpty = useRef(true)
389
+ useEffect(() => {
390
+ const empty = wasEmpty.current
391
+ wasEmpty.current = count === 0
392
+ const list = ref.current
393
+ if (!empty || count === 0 || !list) return
394
+ if (list.querySelector(`${ITEM_SELECTOR}[data-highlighted]`)) return
395
+ const input = list.closest("[data-slot='command-palette']")?.querySelector("input")
396
+ input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))
397
+ }, [count])
398
+
399
+ return (
400
+ <div
401
+ ref={ref}
402
+ data-slot="command-palette-list"
403
+ className={cn("group/cmdk min-h-0 flex-1 overflow-y-auto overscroll-contain", className)}
404
+ {...props}
405
+ >
406
+ <Autocomplete.List className="p-2">{children}</Autocomplete.List>
407
+ {/* Must stay mounted to announce reliably — only its text changes. */}
408
+ <Autocomplete.Status className="sr-only">
409
+ {loading ? "Recherche en cours…" : count === 0 ? "Aucun résultat" : `${count} résultat${count > 1 ? "s" : ""}`}
410
+ </Autocomplete.Status>
411
+ </div>
412
+ )
413
+ }
414
+
415
+ // --- group / separator ------------------------------------------------------
416
+
417
+ export interface CommandPaletteGroupProps extends ComponentProps<"div"> {
418
+ heading?: ReactNode
419
+ }
420
+
421
+ /**
422
+ * A titled block of items. It hides itself when none of its items survived the
423
+ * filter — through `:has()` rather than a JS count, so the heading never flashes
424
+ * for a frame above an empty group.
425
+ */
426
+ function CommandPaletteGroup({ heading, className, children, ...props }: CommandPaletteGroupProps) {
427
+ return (
428
+ <Autocomplete.Group
429
+ data-slot="command-palette-group"
430
+ className={cn("hidden has-[[data-slot=command-palette-item]]:block", className)}
431
+ {...props}
432
+ >
433
+ {heading != null && (
434
+ <Autocomplete.GroupLabel className="px-2 pt-2 pb-1.5 text-[0.6875rem] font-medium tracking-wide text-muted-foreground uppercase select-none">
435
+ {heading}
436
+ </Autocomplete.GroupLabel>
437
+ )}
438
+ {children}
439
+ </Autocomplete.Group>
440
+ )
441
+ }
442
+
443
+ /** A thin rule between groups. Static — it doesn't disappear with its neighbours. */
444
+ function CommandPaletteSeparator({ className, ...props }: ComponentProps<"div">) {
445
+ return (
446
+ <div
447
+ data-slot="command-palette-separator"
448
+ role="separator"
449
+ className={cn("-mx-2 my-1.5 h-px bg-border", className)}
450
+ {...props}
451
+ />
452
+ )
453
+ }
454
+
455
+ // --- item -------------------------------------------------------------------
456
+
457
+ /** Props handed to the app's link component. Spread them all — Base UI puts the roles and handlers in there. */
458
+ export type CommandPaletteLinkProps = ComponentProps<"a"> & { href: string }
459
+ export type CommandPaletteLinkRender = (props: CommandPaletteLinkProps) => ReactElement
460
+
461
+ export interface CommandPaletteItemProps extends Omit<ComponentProps<"div">, "onSelect"> {
462
+ /** Leading icon (a `lucide-react` glyph, an avatar, a status dot…). */
463
+ icon?: ReactNode
464
+ /** Secondary text, right-aligned: type, org, status… */
465
+ meta?: ReactNode
466
+ /** Second line under the label. */
467
+ description?: ReactNode
468
+ /** Shortcut cap on the right, e.g. `"mod+n"`. */
469
+ shortcut?: string
470
+ /** Extra search terms, never displayed. */
471
+ keywords?: string[]
472
+ /**
473
+ * Text the filter matches on. Defaults to the children when they're a plain
474
+ * string — pass it explicitly as soon as they aren't.
475
+ */
476
+ label?: string
477
+ /** Stable identity for the item. Also matched by the filter. */
478
+ value?: string
479
+ disabled?: boolean
480
+ /** Turns the item into a link. Enter and click both navigate. */
481
+ href?: string
482
+ /**
483
+ * Escape hatch for app-specific rendering. `link` gets every prop Base UI put
484
+ * on the item plus `href`, and must return an anchor-ish node (`next/link`, a
485
+ * router link…). Without it, an `href` falls back to a plain `<a>`. The package
486
+ * never imports `next/link` — same rule as `WorkerCard`.
487
+ */
488
+ render?: { link?: CommandPaletteLinkRender }
489
+ /** Fires on click *and* on Enter while highlighted. */
490
+ onSelect?: () => void
491
+ /**
492
+ * Whether selecting closes the palette.
493
+ * @default true
494
+ */
495
+ closeOnSelect?: boolean
496
+ }
497
+
498
+ /**
499
+ * One result. Renders nothing when it doesn't match the query — that's the whole
500
+ * filtering mechanism, and what keeps a long list cheap once the user types.
501
+ */
502
+ function CommandPaletteItem({
503
+ icon,
504
+ meta,
505
+ description,
506
+ shortcut,
507
+ keywords = [],
508
+ label: labelProp,
509
+ value,
510
+ disabled,
511
+ href,
512
+ render,
513
+ onSelect,
514
+ closeOnSelect = true,
515
+ className,
516
+ children,
517
+ ...props
518
+ }: CommandPaletteItemProps) {
519
+ const { matches, close, query } = usePalette("CommandPaletteItem")
520
+ const label = labelProp ?? (typeof children === "string" ? children : undefined)
521
+
522
+ if (!matches({ label: label ?? "", keywords, value })) return null
523
+
524
+ const linkRender = href ? (render?.link ?? ((linkProps) => <a {...linkProps} />)) : undefined
525
+
526
+ return (
527
+ <Autocomplete.Item
528
+ data-slot="command-palette-item"
529
+ value={value ?? label ?? null}
530
+ disabled={disabled}
531
+ onClick={() => {
532
+ if (disabled) return
533
+ onSelect?.()
534
+ if (closeOnSelect) close()
535
+ }}
536
+ render={
537
+ linkRender
538
+ ? (itemProps) => linkRender({ ...(itemProps as ComponentProps<"a">), href: href! })
539
+ : undefined
540
+ }
541
+ className={cn(
542
+ "group/cmd-item flex scroll-my-2 cursor-default items-center gap-2.5 rounded-lg px-2 py-2 text-sm text-foreground no-underline outline-none select-none",
543
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
544
+ className
545
+ )}
546
+ {...props}
547
+ >
548
+ {icon != null && (
549
+ <span
550
+ className="flex size-5 shrink-0 items-center justify-center text-muted-foreground group-data-highlighted/cmd-item:text-foreground [&_svg]:size-4"
551
+ aria-hidden
552
+ >
553
+ {icon}
554
+ </span>
555
+ )}
556
+ <span className="min-w-0 flex-1">
557
+ <span className="block truncate">{label != null ? highlightMatch(label, query) : children}</span>
558
+ {description != null && <span className="block truncate text-xs text-muted-foreground">{description}</span>}
559
+ </span>
560
+ {meta != null && (
561
+ <span className="shrink-0 truncate text-xs text-muted-foreground tabular-nums">{meta}</span>
562
+ )}
563
+ {shortcut && <Kbd keys={shortcut} className="shrink-0" />}
564
+ </Autocomplete.Item>
565
+ )
566
+ }
567
+
568
+ // --- empty / loading --------------------------------------------------------
569
+
570
+ /**
571
+ * Shown only when no item survived the filter, and never while `loading` (a
572
+ * pending search isn't an empty result). Must live inside `CommandPaletteList`.
573
+ */
574
+ function CommandPaletteEmpty({ className, children = "Aucun résultat", ...props }: ComponentProps<"div">) {
575
+ const { loading } = usePalette("CommandPaletteEmpty")
576
+ if (loading) return null
577
+ return (
578
+ <div
579
+ data-slot="command-palette-empty"
580
+ role="presentation"
581
+ className={cn(
582
+ "px-3 py-12 text-center text-sm text-muted-foreground group-has-[[data-slot=command-palette-item]]/cmdk:hidden",
583
+ className
584
+ )}
585
+ {...props}
586
+ >
587
+ {children}
588
+ </div>
589
+ )
590
+ }
591
+
592
+ export interface CommandPaletteLoadingProps extends ComponentProps<"div"> {
593
+ /** Number of skeleton rows. @default 3 */
594
+ rows?: number
595
+ }
596
+
597
+ /** Skeleton rows while an async search is in flight. Renders nothing unless the root is `loading`. */
598
+ function CommandPaletteLoading({ rows = 3, className, ...props }: CommandPaletteLoadingProps) {
599
+ const { loading } = usePalette("CommandPaletteLoading")
600
+ if (!loading) return null
601
+ return (
602
+ <div data-slot="command-palette-loading" role="presentation" className={cn("space-y-1", className)} {...props}>
603
+ {Array.from({ length: rows }, (_, i) => (
604
+ <div key={i} className="flex items-center gap-2.5 px-2 py-2">
605
+ <Skeleton className="size-4 shrink-0 rounded" />
606
+ <Skeleton className="h-3" style={{ width: `${45 - i * 8}%` }} />
607
+ </div>
608
+ ))}
609
+ </div>
610
+ )
611
+ }
612
+
613
+ // --- footer / trigger -------------------------------------------------------
614
+
615
+ export interface CommandPaletteFooterProps extends ComponentProps<"div"> {
616
+ /**
617
+ * Draws the ↑↓ / ↵ / esc reminders on the left.
618
+ * @default true
619
+ */
620
+ hints?: boolean
621
+ }
622
+
623
+ /** The bar under the list: keyboard reminders on the left, a free slot (children) on the right. */
624
+ function CommandPaletteFooter({ hints = true, className, children, ...props }: CommandPaletteFooterProps) {
625
+ return (
626
+ <div
627
+ data-slot="command-palette-footer"
628
+ className={cn(
629
+ "flex shrink-0 items-center justify-between gap-3 border-t border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
630
+ className
631
+ )}
632
+ {...props}
633
+ >
634
+ {hints ? (
635
+ <div className="flex items-center gap-3" aria-hidden>
636
+ <span className="flex items-center gap-1">
637
+ <Kbd>↑</Kbd>
638
+ <Kbd>↓</Kbd>
639
+ <span className="hidden sm:inline">naviguer</span>
640
+ </span>
641
+ <span className="flex items-center gap-1">
642
+ <Kbd>↵</Kbd> ouvrir
643
+ </span>
644
+ <span className="hidden items-center gap-1 sm:flex">
645
+ <Kbd>esc</Kbd> fermer
646
+ </span>
647
+ </div>
648
+ ) : (
649
+ <span />
650
+ )}
651
+ <div className="flex min-w-0 items-center gap-2">{children}</div>
652
+ </div>
653
+ )
654
+ }
655
+
656
+ export interface CommandPaletteTriggerProps extends ComponentProps<"button"> {
657
+ /** Shortcut displayed on the right. Pass `null` to show none. @default "mod+k" */
658
+ shortcut?: string | null
659
+ /** Left-hand icon. Defaults to a magnifier; pass `null` for none. */
660
+ icon?: ReactNode
661
+ }
662
+
663
+ /**
664
+ * The "Rechercher… ⌘K" field you drop in a nav bar. It's a plain button — it
665
+ * doesn't own the palette, it just calls `onClick`; the app keeps the open state.
666
+ */
667
+ function CommandPaletteTrigger({
668
+ shortcut = "mod+k",
669
+ icon,
670
+ className,
671
+ children = "Rechercher…",
672
+ ...props
673
+ }: CommandPaletteTriggerProps) {
674
+ return (
675
+ <button
676
+ type="button"
677
+ data-slot="command-palette-trigger"
678
+ className={cn(
679
+ "flex h-8 w-full items-center gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm text-muted-foreground transition-colors outline-none select-none hover:border-ring/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
680
+ className
681
+ )}
682
+ {...props}
683
+ >
684
+ {icon !== null && <span className="shrink-0 [&_svg]:size-4">{icon ?? <SearchIcon className="size-4" />}</span>}
685
+ <span className="min-w-0 flex-1 truncate text-left">{children}</span>
686
+ {shortcut && <Kbd keys={shortcut} className="shrink-0" />}
687
+ </button>
688
+ )
689
+ }
690
+
691
+ export {
692
+ CommandPalette,
693
+ CommandPaletteInput,
694
+ CommandPaletteList,
695
+ CommandPaletteGroup,
696
+ CommandPaletteSeparator,
697
+ CommandPaletteItem,
698
+ CommandPaletteEmpty,
699
+ CommandPaletteLoading,
700
+ CommandPaletteFooter,
701
+ CommandPaletteTrigger,
702
+ }