@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.
- package/README.md +99 -3
- package/package.json +5 -1
- package/src/components/command-menu.tsx +630 -0
- package/src/components/command-palette.tsx +7 -50
- package/src/components/command-shared.tsx +59 -0
- package/src/components/marketing/code-block.tsx +89 -0
- package/src/components/marketing/copy-command.tsx +139 -0
- package/src/components/marketing/index.ts +55 -0
- package/src/components/marketing/motion.ts +92 -0
- package/src/components/marketing/night-band.tsx +75 -0
- package/src/components/marketing/product-header.tsx +115 -0
- package/src/components/marketing/reveal.tsx +71 -0
- package/src/components/marketing/section.tsx +161 -0
- package/src/components/sheet.tsx +173 -0
- package/src/components/table.tsx +98 -0
- package/src/index.ts +56 -0
- package/styles.css +67 -0
|
@@ -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
|
+
}
|