@spunto/design-system 0.16.0 → 0.18.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.
@@ -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,64 @@ 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
+
180
+ // Auth — the login/signup screen in bricks (layout, brand panel, form, OAuth
181
+ // button, separator). Domain-free like the palette, hence its place in the root:
182
+ // every word of it is a prop, and it neither fetches nor redirects. Assembling
183
+ // them: the showcase's "Créer sa page de login" guide.
184
+ export {
185
+ AuthLayout,
186
+ AuthBrandPanel,
187
+ AuthBrandMark,
188
+ AuthForm,
189
+ authFormCopy,
190
+ OAuthButton,
191
+ AuthSeparator,
192
+ GoogleIcon,
193
+ GitHubIcon,
194
+ } from "./components/auth"
195
+ export type {
196
+ AuthLayoutProps,
197
+ AuthBrandPanelProps,
198
+ AuthBrandMarkProps,
199
+ AuthFormProps,
200
+ AuthFormCopy,
201
+ AuthModeCopy,
202
+ AuthFieldCopy,
203
+ AuthMode,
204
+ AuthSubmitValues,
205
+ AuthLinkProps,
206
+ AuthLinkRender,
207
+ OAuthButtonProps,
208
+ OAuthProvider,
209
+ } from "./components/auth"
210
+
124
211
  // Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
125
212
  // palette, exported on its own for anywhere else a shortcut needs drawing.
126
213
  export { Kbd } from "./components/kbd"