@spunto/design-system 0.16.0 → 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 CHANGED
@@ -63,9 +63,20 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
63
63
  `Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
64
64
  declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
65
65
  - _Overlays_ (plug into the `SpuntoProvider` umbrella) — `Dialog`, `AlertDialog`
66
- (a confirmation variant, non-dismissible), `Tooltip`. Their portals render into
67
- the provider's overlay container; `Tooltip`'s shared delay group is mounted by the
68
- provider too.
66
+ (a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
67
+ into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
68
+ the provider too.
69
+ - _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
70
+ `SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
71
+ an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
72
+ props rather than one bundle of `data-[side=right]:`-prefixed classes, precisely so
73
+ `className` stays the layout escape hatch (an app widens a panel or lays it out in two
74
+ columns without `!important`).
75
+ - _Table_ — `Table` (+`TableHeader`/`TableBody`/`TableFooter`/`TableRow`/`TableHead`/
76
+ `TableCell`/`TableCaption`). **Presentational only**: no sorting, no pagination, no
77
+ column model — apps already own that logic and disagree on it; what they were
78
+ duplicating is the markup and the token choices. `containerClassName` reaches the
79
+ scroll container (max-height, border, sticky-header context).
69
80
  - _CommandPalette_ — `CommandPalette` (+`CommandPaletteInput`/`List`/`Group`/`Item`/
70
81
  `Empty`/`Loading`/`Separator`/`Footer`/`Trigger`), the ⌘K palette: an input, groups,
71
82
  items, and a jump. **Domain-free**, hence its place in the root entry — it knows
@@ -80,6 +91,17 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
80
91
  `value`/`onValueChange` + `filter={null}`. Navigation is `href` + `render.link`, never
81
92
  `next/link` — same rule as `WorkerCard`. Not virtualized: a few hundred items stay
82
93
  fluid (a non-matching item renders nothing), beyond that filter server-side.
94
+ - _CommandMenu_ — `CommandMenu` (+`CommandMenuInput`/`List`/`Group`/`Item`/`Empty`/
95
+ `Loading`/`Separator`), the palette's vocabulary **inline**: a Base UI `Popover`
96
+ anchored to an element or a **virtual element** (a caret rect), which never moves
97
+ focus (`initialFocus`/`finalFocus` off, no backdrop, non-modal) and claims ↑ ↓ ↵ Esc
98
+ on `document` in the **capture** phase, before the field underneath. That's what a
99
+ "/" menu inside a block editor needs and what the dialog-based palette can't do
100
+ without losing the caret — hence a separate component rather than a `modal={false}`
101
+ flag: the contract is the opposite one. Enter is only claimed when an item is
102
+ highlighted, so a query with no result still inserts a line break. The query either
103
+ comes from the caller (`query`, the "/" case) or from a `CommandMenuInput` rendered
104
+ inside the popup (a filter dropdown on a button; `autoFocus` is opt-in).
83
105
  - _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
84
106
  xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
85
107
  bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -15,6 +15,7 @@ import { Dialog } from "@base-ui/react/dialog"
15
15
  import { SearchIcon } from "lucide-react"
16
16
 
17
17
  import { cn } from "../utils"
18
+ import { highlightMatch, type CommandFilter, type CommandFilterItem } from "./command-shared"
18
19
  import { Kbd } from "./kbd"
19
20
  import { Skeleton } from "./skeleton"
20
21
  import { Spinner } from "./spinner"
@@ -41,17 +42,13 @@ import { useOverlayContainer } from "./spunto-provider"
41
42
  // already-filtered items in.
42
43
  // ---------------------------------------------------------------------------
43
44
 
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
- }
45
+ /**
46
+ * What the filter gets to look at for one item. Shared with `CommandMenu` — the
47
+ * two components filter identically, only their shell differs.
48
+ */
49
+ export type CommandPaletteFilterItem = CommandFilterItem
53
50
 
54
- export type CommandPaletteFilter = (item: CommandPaletteFilterItem, query: string) => boolean
51
+ export type CommandPaletteFilter = CommandFilter
55
52
 
56
53
  interface PaletteContextValue {
57
54
  query: string
@@ -107,46 +104,6 @@ function comboMatches(event: KeyboardEvent, combo: Combo): boolean {
107
104
  return true
108
105
  }
109
106
 
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
107
  // --- root -------------------------------------------------------------------
151
108
 
152
109
  export interface CommandPaletteProps {
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // What `CommandPalette` (modal, ⌘K) and `CommandMenu` (inline, anchored) share:
5
+ // how an item describes itself to the filter, and how a match is highlighted.
6
+ // The two components differ in their shell — a focus-trapping Dialog vs. a
7
+ // Popover that never takes focus — not in what "matching" means, so a filter
8
+ // written for one works verbatim on the other.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** What the filter gets to look at for one item. */
12
+ export interface CommandFilterItem {
13
+ /** `label` prop, or the item's children when they're a plain string. */
14
+ label: string
15
+ /** Extra terms that match but are never displayed. */
16
+ keywords: string[]
17
+ /** The item's `value`, when it has one. */
18
+ value?: string
19
+ }
20
+
21
+ export type CommandFilter = (item: CommandFilterItem, query: string) => boolean
22
+
23
+ /**
24
+ * Folds a string to a comparable form (lowercase, no diacritics) while keeping,
25
+ * for every folded character, the index it came from — so a match found on the
26
+ * folded text can be highlighted on the original one ("déploiement" matched by
27
+ * "deploi" highlights the right six characters, accent included).
28
+ */
29
+ export function fold(text: string): { folded: string; map: number[] } {
30
+ let folded = ""
31
+ const map: number[] = []
32
+ for (let i = 0; i < text.length; i++) {
33
+ const chunk = text[i].normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
34
+ for (const char of chunk) {
35
+ folded += char
36
+ map.push(i)
37
+ }
38
+ }
39
+ return { folded, map }
40
+ }
41
+
42
+ /** Wraps the matched slice of `text` in a `<mark>`. Returns the plain text when there's no match. */
43
+ export function highlightMatch(text: string, query: string): ReactNode {
44
+ if (!query.trim()) return text
45
+ const haystack = fold(text)
46
+ const needle = fold(query.trim()).folded
47
+ if (!needle) return text
48
+ const at = haystack.folded.indexOf(needle)
49
+ if (at === -1) return text
50
+ const start = haystack.map[at]
51
+ const end = haystack.map[at + needle.length - 1] + 1
52
+ return (
53
+ <>
54
+ {text.slice(0, start)}
55
+ <mark className="rounded-xs bg-primary/15 text-inherit">{text.slice(start, end)}</mark>
56
+ {text.slice(end)}
57
+ </>
58
+ )
59
+ }
@@ -0,0 +1,173 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // A sheet is a Dialog that enters from an edge. Same primitive (`@base-ui/react/
11
+ // dialog` — focus trap, Escape, backdrop, focus restore), same portal target as
12
+ // every other overlay here (the `SpuntoProvider` container), only the geometry
13
+ // changes.
14
+ //
15
+ // Position and size are two separate props on purpose. `side` decides which edge
16
+ // the panel is glued to; `size` decides how far it comes in. Both resolve to
17
+ // plain, unprefixed utilities computed in JS — *not* `data-[side=right]:`-style
18
+ // selectors — so a `className` on `SheetContent` still wins on specificity, and
19
+ // an app can go from a 32rem panel to full-bleed with `size="full"` or with its
20
+ // own `sm:max-w-*` without reaching for `!important`.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type SheetSide = "right" | "left" | "top" | "bottom"
24
+
25
+ /** How far the panel comes in. Widths for left/right, heights for top/bottom. */
26
+ export type SheetSize = "sm" | "md" | "lg" | "xl" | "full"
27
+
28
+ const SIDE_CLASSES: Record<SheetSide, string> = {
29
+ right:
30
+ "inset-y-0 right-0 h-full w-full border-l data-ending-style:translate-x-full data-starting-style:translate-x-full",
31
+ left:
32
+ "inset-y-0 left-0 h-full w-full border-r data-ending-style:-translate-x-full data-starting-style:-translate-x-full",
33
+ top:
34
+ "inset-x-0 top-0 w-full border-b data-ending-style:-translate-y-full data-starting-style:-translate-y-full",
35
+ bottom:
36
+ "inset-x-0 bottom-0 w-full border-t data-ending-style:translate-y-full data-starting-style:translate-y-full",
37
+ }
38
+
39
+ // Below `sm` a side panel always takes the full width — a 24rem panel on a phone
40
+ // is a modal with a useless 3rem gutter.
41
+ const HORIZONTAL_SIZE_CLASSES: Record<SheetSize, string> = {
42
+ sm: "sm:max-w-sm",
43
+ md: "sm:max-w-lg",
44
+ lg: "sm:max-w-3xl",
45
+ xl: "sm:max-w-5xl",
46
+ full: "sm:max-w-none",
47
+ }
48
+
49
+ const VERTICAL_SIZE_CLASSES: Record<SheetSize, string> = {
50
+ sm: "h-auto max-h-[16rem]",
51
+ md: "h-auto max-h-[24rem]",
52
+ lg: "h-auto max-h-[32rem]",
53
+ xl: "h-auto max-h-[80dvh]",
54
+ full: "h-dvh",
55
+ }
56
+
57
+ /** Groups the sheet parts. Controlled via `open`/`onOpenChange`, or uncontrolled. */
58
+ function Sheet(props: SheetPrimitive.Root.Props) {
59
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
60
+ }
61
+
62
+ /** Element that opens the sheet. Pass `render` to reuse an existing button. */
63
+ function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
64
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
65
+ }
66
+
67
+ /** Imperatively closes the sheet. Wrap a `Button` via `render` for a footer action. */
68
+ function SheetClose(props: SheetPrimitive.Close.Props) {
69
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
70
+ }
71
+
72
+ export interface SheetContentProps extends SheetPrimitive.Popup.Props {
73
+ /** Which edge the panel slides in from. @default "right" */
74
+ side?: SheetSide
75
+ /** Width (left/right) or height (top/bottom) of the panel. @default "md" */
76
+ size?: SheetSize
77
+ /** Draws the ✕ in the top corner. @default true */
78
+ showClose?: boolean
79
+ }
80
+
81
+ /**
82
+ * Portal + backdrop + the sliding panel, in one styled wrapper. Renders into the
83
+ * `SpuntoProvider` overlay container (falls back to `document.body`).
84
+ *
85
+ * The panel is a `flex flex-col` by default; `className` is the layout escape
86
+ * hatch and is merged last — pass `p-0` to lay out your own padding, `grid
87
+ * grid-cols-2` to split it in two columns, `overflow-y-auto` to scroll the whole
88
+ * panel rather than an inner region.
89
+ */
90
+ function SheetContent({
91
+ className,
92
+ children,
93
+ side = "right",
94
+ size = "md",
95
+ showClose = true,
96
+ ...props
97
+ }: SheetContentProps) {
98
+ const container = useOverlayContainer()
99
+ const horizontal = side === "left" || side === "right"
100
+ return (
101
+ <SheetPrimitive.Portal container={container ?? undefined}>
102
+ <SheetPrimitive.Backdrop
103
+ data-slot="sheet-backdrop"
104
+ 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"
105
+ />
106
+ <SheetPrimitive.Popup
107
+ data-slot="sheet-content"
108
+ data-side={side}
109
+ className={cn(
110
+ "fixed z-50 flex flex-col gap-4 border-border bg-background bg-clip-padding text-sm text-foreground shadow-xl shadow-black/[0.08] outline-none",
111
+ "transition-transform duration-200 ease-out",
112
+ SIDE_CLASSES[side],
113
+ horizontal ? HORIZONTAL_SIZE_CLASSES[size] : VERTICAL_SIZE_CLASSES[size],
114
+ className
115
+ )}
116
+ {...props}
117
+ >
118
+ {children}
119
+ {showClose && (
120
+ <SheetPrimitive.Close
121
+ aria-label="Fermer"
122
+ className="absolute top-3.5 right-3.5 flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
123
+ >
124
+ <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
125
+ <path d="M18 6 6 18M6 6l12 12" />
126
+ </svg>
127
+ </SheetPrimitive.Close>
128
+ )}
129
+ </SheetPrimitive.Popup>
130
+ </SheetPrimitive.Portal>
131
+ )
132
+ }
133
+
134
+ /** Title + description stack at the top of the panel. */
135
+ function SheetHeader({ className, ...props }: ComponentProps<"div">) {
136
+ return <div data-slot="sheet-header" className={cn("flex flex-col gap-0.5 p-4", className)} {...props} />
137
+ }
138
+
139
+ /** Action row pinned to the bottom of the panel. */
140
+ function SheetFooter({ className, ...props }: ComponentProps<"div">) {
141
+ return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
142
+ }
143
+
144
+ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
145
+ return (
146
+ <SheetPrimitive.Title
147
+ data-slot="sheet-title"
148
+ className={cn("text-base leading-none font-semibold text-foreground", className)}
149
+ {...props}
150
+ />
151
+ )
152
+ }
153
+
154
+ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
155
+ return (
156
+ <SheetPrimitive.Description
157
+ data-slot="sheet-description"
158
+ className={cn("text-sm text-muted-foreground", className)}
159
+ {...props}
160
+ />
161
+ )
162
+ }
163
+
164
+ export {
165
+ Sheet,
166
+ SheetTrigger,
167
+ SheetClose,
168
+ SheetContent,
169
+ SheetHeader,
170
+ SheetFooter,
171
+ SheetTitle,
172
+ SheetDescription,
173
+ }
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Presentational only, on purpose: no sorting, no pagination, no column model.
9
+ // Apps that show tables already own that logic (and disagree on it) — what they
10
+ // were re-writing every time is the markup and the token choices, so that's all
11
+ // this ships. Every part is the plain HTML element with a `data-slot` and a
12
+ // class string, `className` merged last.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface TableProps extends ComponentProps<"table"> {
16
+ /**
17
+ * Class for the scroll container wrapping the `<table>` — the only place a
18
+ * `max-height`, a sticky-header context or a border/rounding can live, since
19
+ * it's the element that scrolls.
20
+ */
21
+ containerClassName?: string
22
+ }
23
+
24
+ /** The `<table>` plus its horizontal scroll container. */
25
+ function Table({ className, containerClassName, ...props }: TableProps) {
26
+ return (
27
+ <div data-slot="table-container" className={cn("relative w-full overflow-x-auto", containerClassName)}>
28
+ <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
29
+ </div>
30
+ )
31
+ }
32
+
33
+ function TableHeader({ className, ...props }: ComponentProps<"thead">) {
34
+ return <thead data-slot="table-header" className={cn("[&_tr]:border-b [&_tr]:border-border", className)} {...props} />
35
+ }
36
+
37
+ function TableBody({ className, ...props }: ComponentProps<"tbody">) {
38
+ return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />
39
+ }
40
+
41
+ /** Totals row. Sits under the body with its own tinted background. */
42
+ function TableFooter({ className, ...props }: ComponentProps<"tfoot">) {
43
+ return (
44
+ <tfoot
45
+ data-slot="table-footer"
46
+ className={cn("border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ /** A row. Hover tint by default; `data-[state=selected]` keeps it tinted. */
53
+ function TableRow({ className, ...props }: ComponentProps<"tr">) {
54
+ return (
55
+ <tr
56
+ data-slot="table-row"
57
+ className={cn(
58
+ "border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ /** A header cell (`<th>`). */
67
+ function TableHead({ className, ...props }: ComponentProps<"th">) {
68
+ return (
69
+ <th
70
+ data-slot="table-head"
71
+ className={cn(
72
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
73
+ className
74
+ )}
75
+ {...props}
76
+ />
77
+ )
78
+ }
79
+
80
+ /** A body cell (`<td>`). */
81
+ function TableCell({ className, ...props }: ComponentProps<"td">) {
82
+ return (
83
+ <td
84
+ data-slot="table-cell"
85
+ className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
86
+ {...props}
87
+ />
88
+ )
89
+ }
90
+
91
+ /** Caption under the table — its accessible description. */
92
+ function TableCaption({ className, ...props }: ComponentProps<"caption">) {
93
+ return (
94
+ <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
95
+ )
96
+ }
97
+
98
+ export { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption }
package/src/index.ts CHANGED
@@ -91,6 +91,35 @@ export {
91
91
  AlertDialogDescription,
92
92
  } from "./components/alert-dialog"
93
93
 
94
+ // Sheet — the same Base UI dialog, entering from an edge. `side` picks the edge,
95
+ // `size` how far it comes in; both resolve to plain utilities so `className`
96
+ // stays the layout escape hatch (no `!important` needed to widen a panel).
97
+ export {
98
+ Sheet,
99
+ SheetTrigger,
100
+ SheetClose,
101
+ SheetContent,
102
+ SheetHeader,
103
+ SheetFooter,
104
+ SheetTitle,
105
+ SheetDescription,
106
+ } from "./components/sheet"
107
+ export type { SheetContentProps, SheetSide, SheetSize } from "./components/sheet"
108
+
109
+ // Table — presentational only (no sorting, no pagination): apps own that logic,
110
+ // what they were duplicating is the markup and the tokens.
111
+ export {
112
+ Table,
113
+ TableHeader,
114
+ TableBody,
115
+ TableFooter,
116
+ TableRow,
117
+ TableHead,
118
+ TableCell,
119
+ TableCaption,
120
+ } from "./components/table"
121
+ export type { TableProps } from "./components/table"
122
+
94
123
  // CommandPalette — the ⌘K palette, domain-free: an input, groups, items, and a
95
124
  // jump. Base UI `Autocomplete` (roles + ↑↓ navigation) rendered inline inside a
96
125
  // Base UI `Dialog` (focus trap + Escape), portalled into the provider's overlay
@@ -121,6 +150,33 @@ export type {
121
150
  CommandPaletteLinkRender,
122
151
  } from "./components/command-palette"
123
152
 
153
+ // CommandMenu — the same parts, inline: a Base UI `Popover` anchored to whatever
154
+ // the caller points at (an element or a caret rect), which **never moves focus**
155
+ // (`initialFocus`/`finalFocus` off, no backdrop, non-modal) and takes ↑ ↓ ↵ esc on
156
+ // `document` in the capture phase. That's what a "/" menu inside an editor needs
157
+ // and what the dialog-based palette can't do without losing the caret.
158
+ export {
159
+ CommandMenu,
160
+ CommandMenuInput,
161
+ CommandMenuList,
162
+ CommandMenuGroup,
163
+ CommandMenuSeparator,
164
+ CommandMenuItem,
165
+ CommandMenuEmpty,
166
+ CommandMenuLoading,
167
+ } from "./components/command-menu"
168
+ export type {
169
+ CommandMenuProps,
170
+ CommandMenuInputProps,
171
+ CommandMenuGroupProps,
172
+ CommandMenuItemProps,
173
+ CommandMenuLoadingProps,
174
+ CommandMenuFilter,
175
+ CommandMenuFilterItem,
176
+ CommandMenuLinkProps,
177
+ CommandMenuLinkRender,
178
+ } from "./components/command-menu"
179
+
124
180
  // Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
125
181
  // palette, exported on its own for anywhere else a shortcut needs drawing.
126
182
  export { Kbd } from "./components/kbd"