@spunto/design-system 0.9.2 → 0.10.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 +51 -1
- package/package.json +6 -1
- package/src/components/command-palette.tsx +702 -0
- package/src/components/kbd.tsx +117 -0
- package/src/components/workers/creator-avatar.tsx +38 -0
- package/src/components/workers/git-branch-chips.tsx +128 -0
- package/src/components/workers/index.ts +35 -0
- package/src/components/workers/resource-bars.tsx +104 -0
- package/src/components/workers/setup-progress.tsx +130 -0
- package/src/components/workers/tag-chips.tsx +139 -0
- package/src/components/workers/tag-color.ts +28 -0
- package/src/components/workers/types.ts +44 -0
- package/src/components/workers/worker-card.tsx +345 -0
- package/src/components/workers/worker-status.tsx +181 -0
- package/src/index.ts +40 -1
- package/src/utils.ts +23 -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
|
+
}
|