@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 +25 -3
- package/package.json +1 -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/sheet.tsx +173 -0
- package/src/components/table.tsx +98 -0
- package/src/index.ts +56 -0
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
|
|
67
|
-
the provider's overlay container; `Tooltip`'s shared delay group is mounted by
|
|
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
|
@@ -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
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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"
|