@sableui/react 0.10.0 → 0.12.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/AGENTS.md +10 -4
- package/CHANGELOG.md +184 -0
- package/DESIGN.md +1 -1
- package/README.md +15 -1
- package/bin/sable.mjs +254 -10
- package/dist/components/app-header/app-header.meta.js +2 -1
- package/dist/components/button/button.js +3 -0
- package/dist/components/button/button.meta.js +1 -1
- package/dist/components/button/button.variants.js +1 -1
- package/dist/components/button-group/button-group.js +17 -0
- package/dist/components/button-group/button-group.meta.js +72 -0
- package/dist/components/button-group/index.js +7 -0
- package/dist/components/caret-menu/caret-menu.js +221 -0
- package/dist/components/caret-menu/caret-menu.meta.js +119 -0
- package/dist/components/caret-menu/index.js +8 -0
- package/dist/components/chat-composer/chat-composer.js +30 -7
- package/dist/components/chat-composer/chat-composer.meta.js +27 -8
- package/dist/components/combobox/combobox.meta.js +26 -0
- package/dist/components/input-group/index.js +7 -0
- package/dist/components/input-group/input-group.js +66 -0
- package/dist/components/input-group/input-group.meta.js +106 -0
- package/dist/components/menu/index.js +2 -0
- package/dist/components/menu/menu.js +51 -9
- package/dist/components/menu/menu.meta.js +11 -2
- package/dist/components/menubar/index.js +8 -0
- package/dist/components/menubar/menubar.js +42 -0
- package/dist/components/menubar/menubar.meta.js +99 -0
- package/dist/components/message-scroller/message-scroller.meta.js +4 -0
- package/dist/components/navigation-menu/index.js +19 -0
- package/dist/components/navigation-menu/navigation-menu.js +108 -0
- package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
- package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
- package/dist/components/pagination/pagination.js +11 -5
- package/dist/components/pagination/pagination.meta.js +21 -3
- package/dist/components/panel-section/index.js +2 -1
- package/dist/components/panel-section/panel-section.js +15 -0
- package/dist/components/panel-section/panel-section.meta.js +32 -0
- package/dist/components/scroller/scroller.js +8 -4
- package/dist/components/scroller/scroller.meta.js +6 -0
- package/dist/components/sheet/sheet.js +82 -9
- package/dist/components/sheet/sheet.meta.js +41 -4
- package/dist/components/sheet/sheet.variants.js +8 -1
- package/dist/components/sidebar/sidebar.js +13 -2
- package/dist/components/sidebar/sidebar.meta.js +10 -0
- package/dist/components/streaming-text/streaming-text.meta.js +5 -1
- package/dist/file-hashes.json +50 -34
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +687 -164
- package/dist/index.js +12 -1
- package/dist/lib/taxonomy.js +5 -0
- package/dist/lib/use-caret-trigger.js +187 -0
- package/dist/lib/use-copy.js +6 -1
- package/dist/lib/use-smooth-wheel.js +164 -0
- package/dist/patterns/chat-panel.js +1 -1
- package/dist/patterns/chat-panel.meta.js +1 -0
- package/dist/styles.css +25 -0
- package/llms/components/app-header.md +1 -1
- package/llms/components/button-group.md +55 -0
- package/llms/components/button.md +1 -1
- package/llms/components/caret-menu.md +100 -0
- package/llms/components/chat-composer.md +24 -7
- package/llms/components/combobox.md +12 -0
- package/llms/components/input-group.md +78 -0
- package/llms/components/menu.md +16 -1
- package/llms/components/menubar.md +110 -0
- package/llms/components/message-scroller.md +1 -0
- package/llms/components/navigation-menu.md +113 -0
- package/llms/components/overflow-menu.md +26 -1
- package/llms/components/pagination.md +15 -3
- package/llms/components/panel-section.md +28 -0
- package/llms/components/scroller.md +1 -0
- package/llms/components/sheet.md +18 -3
- package/llms/components/sidebar.md +2 -0
- package/llms/components/streaming-text.md +2 -1
- package/llms/patterns/chat-panel.md +4 -1
- package/llms/tasks/building-a-chat.md +158 -0
- package/llms/tasks/building-with-sable.md +4 -1
- package/llms/tasks/converting-an-app.md +7 -0
- package/llms/tasks/extending-a-component.md +170 -0
- package/llms/tasks/getting-started.md +22 -4
- package/llms/tasks/migrating-from-shadcn.md +351 -0
- package/llms-full.txt +1349 -26
- package/llms.txt +14 -5
- package/package.json +3 -2
- package/registry.json +1037 -53
- package/scripts/lint-rules.mjs +35 -0
- package/scripts/wiring.mjs +541 -0
- package/skills/entry/SKILL.md +3 -2
- package/skills/sable/SKILL.md +26 -5
- package/targets/ios.json +45 -0
- package/dist/lib/use-wheel-to-horizontal.js +0 -65
|
@@ -10,13 +10,27 @@ TypeScript on Vite, TanStack Router, TanStack Query, TanStack Form for forms wit
|
|
|
10
10
|
validation logic, and TanStack Table once a table outgrows `DataTable`
|
|
11
11
|
(`sable docs tables-with-tanstack`). An existing app keeps its own.
|
|
12
12
|
|
|
13
|
-
## Install
|
|
13
|
+
## Install and wire it
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
pnpm add @sableui/react
|
|
17
|
+
pnpm exec sable init --dry-run # every change it would make
|
|
18
|
+
pnpm exec sable init
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
`sable init` reads the package manager from your lockfile and the framework
|
|
22
|
+
from the project. It installs what is missing (React, Tailwind and its build
|
|
23
|
+
plugin), writes the CSS below into your global stylesheet in order, adds React
|
|
24
|
+
dedupe and the Tailwind plugin to a Vite config (a PostCSS config for Next.js),
|
|
25
|
+
loads Figtree and DM Mono, wraps a Vite starter's `<App />` in `IconProvider`,
|
|
26
|
+
installs the agent skill, and finishes with `sable doctor`. Edits it cannot make
|
|
27
|
+
safely, such as a Next.js root layout, it prints with the file they belong in.
|
|
28
|
+
Vite and Next.js get every step; other setups get the packages and stylesheet.
|
|
29
|
+
Once an app is wired, `sable doctor --fix` applies the same fixes to whatever
|
|
30
|
+
doctor flags. Run `sable init` with the package installed: before that, `npx
|
|
31
|
+
sable` names an unrelated package.
|
|
32
|
+
|
|
33
|
+
## Or wire it by hand
|
|
20
34
|
|
|
21
35
|
For `src/styles.css` with `node_modules` at the project root:
|
|
22
36
|
|
|
@@ -43,11 +57,15 @@ export default function App() {
|
|
|
43
57
|
}
|
|
44
58
|
```
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
Mount `<IconProvider set={reiconIconSet}>` (from `@sableui/react/icons/reicon`)
|
|
61
|
+
once at the root: without it every icon, including those inside Sable's
|
|
62
|
+
components, renders nothing, and `sable doctor` fails. Optional theme and motion
|
|
63
|
+
providers are explained in the package README.
|
|
48
64
|
|
|
49
65
|
## Connect your agent and verify
|
|
50
66
|
|
|
67
|
+
`sable init` already ran these; by hand:
|
|
68
|
+
|
|
51
69
|
```bash
|
|
52
70
|
pnpm exec sable setup --agent --dry-run
|
|
53
71
|
pnpm exec sable setup --agent
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Task: migrating from shadcn/ui (and Radix) to Sable
|
|
2
|
+
|
|
3
|
+
An app built on shadcn/ui owns a `components/ui/` folder of copied Radix
|
|
4
|
+
wrappers. Sable replaces that folder with one installed package. The visible
|
|
5
|
+
API is close: Sable's semantic tokens share shadcn's names, and most
|
|
6
|
+
components keep their part names. The behaviour layer is different. Sable is
|
|
7
|
+
built on **Base UI**, not Radix, so composition, values and events follow Base
|
|
8
|
+
UI. This guide maps one to the other. Every Sable name below is in the
|
|
9
|
+
installed contract: confirm any of them with `sable component <Name>`.
|
|
10
|
+
|
|
11
|
+
Do the app-level work in [converting-an-app.md](./converting-an-app.md) first
|
|
12
|
+
(registers, wiring, deleting your tokens). This guide covers the component
|
|
13
|
+
swap that follows.
|
|
14
|
+
|
|
15
|
+
## 0. What goes away
|
|
16
|
+
|
|
17
|
+
- **`components/ui/*` and `components.json`.** Delete the folder once nothing
|
|
18
|
+
imports it. Every component comes from `@sableui/react` (layout primitives
|
|
19
|
+
too; blocks and patterns from `@sableui/react/blocks` and `/patterns`).
|
|
20
|
+
Don't copy a Sable component into the app to edit it. Use a variant, a
|
|
21
|
+
preset, or [patching-a-component.md](./patching-a-component.md).
|
|
22
|
+
- **`@radix-ui/*`, `cmdk`, `vaul`, `sonner`, `input-otp`, `next-themes`,
|
|
23
|
+
`tw-animate-css`, `class-variance-authority` (unless the app's own code uses
|
|
24
|
+
it) and `lucide-react`.** Each has a Sable counterpart below.
|
|
25
|
+
- **Your `globals.css` token block.** Sable's tokens carry the same names
|
|
26
|
+
(`--background`, `--card`, `--primary`…) and are defined by the theme. A
|
|
27
|
+
copied shadcn `:root { … }` block overrides them with values the theme
|
|
28
|
+
never tested. Delete it, and move brand colour into a preset
|
|
29
|
+
(`sable docs using-presets`).
|
|
30
|
+
|
|
31
|
+
`cn()` survives: Sable exports it (`import { cn } from "@sableui/react"`), with
|
|
32
|
+
the paired typography utilities registered in its merge groups.
|
|
33
|
+
|
|
34
|
+
## 1. The patterns that change everywhere
|
|
35
|
+
|
|
36
|
+
### `asChild` → `render`
|
|
37
|
+
|
|
38
|
+
Radix composes by cloning a child. Base UI takes the element as a prop:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// shadcn
|
|
42
|
+
<DialogTrigger asChild><Button variant="outline">Edit</Button></DialogTrigger>
|
|
43
|
+
|
|
44
|
+
// Sable
|
|
45
|
+
<DialogTrigger render={<Button variant="outline">Edit</Button>} />
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It applies to every trigger and close part (`DialogTrigger`, `DialogClose`,
|
|
49
|
+
`SheetTrigger`, `MenuTrigger`, `PopoverTrigger`, `TooltipTrigger`,
|
|
50
|
+
`CollapsibleTrigger`, `ContextCardTrigger`…). The same goes for links: a
|
|
51
|
+
`Button` that navigates is `<Button render={<Link to="/x" />}>Open</Button>`.
|
|
52
|
+
`SidebarItem`, `SidebarBrand`, `Card` and `Separator` take `render` too.
|
|
53
|
+
|
|
54
|
+
### Part names
|
|
55
|
+
|
|
56
|
+
| shadcn / Radix | Sable |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `DropdownMenu*` | `Menu*`: `Menu`, `MenuTrigger`, `MenuContent`, `MenuItem` |
|
|
59
|
+
| `DropdownMenuLabel` | `MenuGroupLabel`, **inside a `MenuGroup`** (Base UI requires the group) |
|
|
60
|
+
| `DropdownMenuSub` / `SubTrigger` / `SubContent` | `MenuSubmenu` / `MenuSubmenuTrigger` / a nested `MenuContent side="inline-end"` |
|
|
61
|
+
| `*Content` on Accordion, Collapsible | `AccordionPanel`, `CollapsiblePanel` |
|
|
62
|
+
| `TabsContent` | `TabsPanel` (`TabsContent` is exported as an alias) |
|
|
63
|
+
| `HoverCard*` | `ContextCard*` |
|
|
64
|
+
| `Drawer*` (vaul) | `Sheet*` with `side="bottom"` |
|
|
65
|
+
|
|
66
|
+
Dialog, AlertDialog, Popover, Tooltip, Select, ContextMenu, Sheet and Combobox
|
|
67
|
+
keep `*Content`.
|
|
68
|
+
|
|
69
|
+
### Values are arrays where Base UI allows several
|
|
70
|
+
|
|
71
|
+
| shadcn / Radix | Sable |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `<Accordion type="single" collapsible defaultValue="a">` | `<Accordion defaultValue={["a"]}>`. Single is the default, and an open item always closes on its trigger, so there is no `collapsible` |
|
|
74
|
+
| `<Accordion type="multiple">` | `<Accordion multiple>` |
|
|
75
|
+
| `<ToggleGroup type="single" value="a">` | `<ToggleGroup value={["a"]}>`. Always an array; `multiple` allows several |
|
|
76
|
+
| `<Slider defaultValue={[50]}>` | `<Slider defaultValue={50}>`. A single thumb is a number, a range is `[20, 80]` |
|
|
77
|
+
| `<Checkbox checked="indeterminate">` | `<Checkbox indeterminate>`. `checked` is only ever a boolean |
|
|
78
|
+
|
|
79
|
+
### Events
|
|
80
|
+
|
|
81
|
+
- **Menu rows:** `onSelect` → `onClick`. To keep the menu open after a click
|
|
82
|
+
(Radix: `event.preventDefault()` in `onSelect`), set `closeOnClick={false}`.
|
|
83
|
+
- **Change handlers take a second argument.** `onValueChange`,
|
|
84
|
+
`onCheckedChange` and `onOpenChange` receive `(value, eventDetails)`. The
|
|
85
|
+
first argument is what Radix passed, so most handlers work unchanged.
|
|
86
|
+
- **Dismissal hooks:** `onEscapeKeyDown` / `onPointerDownOutside` /
|
|
87
|
+
`onInteractOutside` → one `onOpenChange` on the root. Read
|
|
88
|
+
`eventDetails.reason` (`"escape-key"`, `"outside-press"`…) and call
|
|
89
|
+
`eventDetails.cancel()` to keep it open.
|
|
90
|
+
- **`forceMount`** → `keepMounted` (on `AccordionPanel`, `CollapsiblePanel` and
|
|
91
|
+
portals).
|
|
92
|
+
- **State styling:** `data-[state=open]:` → Sable's state variants:
|
|
93
|
+
`sable-open:`, `sable-selected:`, `sable-highlighted:`, `sable-disabled:`
|
|
94
|
+
and `sable-invalid:` (`sable utility sable-open:`). Base UI writes
|
|
95
|
+
`data-open`, `data-checked`, `data-highlighted` and `data-pressed`, not
|
|
96
|
+
`data-state`.
|
|
97
|
+
|
|
98
|
+
### Variants, not overrides
|
|
99
|
+
|
|
100
|
+
shadcn invites restyling through `className`. Sable does not: a
|
|
101
|
+
`bg-*`/`border*`/`rounded*`/`ring-*`/`shadow-*` class on `Button`, `Pill` or
|
|
102
|
+
`Badge` fails `component-restyle`. Layout classes (`w-full`, `ms-auto`, grid
|
|
103
|
+
placement) are fine. If no variant gives you the look, the component
|
|
104
|
+
needs one, and that is a library change; `extending-a-component.md` has the
|
|
105
|
+
ways that don't need one (`render`, slots, refs, tokens). Map the variants instead:
|
|
106
|
+
|
|
107
|
+
| shadcn `Button` | Sable `Button` |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `variant="default"` | `variant="primary"` (the default) |
|
|
110
|
+
| `secondary` / `outline` / `ghost` / `destructive` / `link` | same names |
|
|
111
|
+
| `size="default"` / `sm` / `lg` | `size="md"` (default) / `sm` / `cta` |
|
|
112
|
+
| `size="icon"` / `icon-sm` / `icon-lg` | `icon` / `icon-sm` / `icon` (add `icon-xs` in tool chrome) |
|
|
113
|
+
| a hand-placed `<Loader2 className="animate-spin" />` | `loading` (keeps the label; `loadingLabel` to change it) |
|
|
114
|
+
| `<Icon /> Label` children | `iconStart={<Icon name="plus" />}` / `iconEnd` |
|
|
115
|
+
|
|
116
|
+
`buttonVariants` is exported, as in shadcn, for the rare non-`Button` element
|
|
117
|
+
that must look like one. Prefer `render`.
|
|
118
|
+
|
|
119
|
+
### Classes shadcn's source teaches that Sable's lint rejects
|
|
120
|
+
|
|
121
|
+
Copied shadcn markup carries these idioms. `sable-lint` names each one:
|
|
122
|
+
|
|
123
|
+
| shadcn idiom | Sable | rule |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `text-sm font-medium` | a paired tier: `text-label-md`, `text-body-sm`, `text-heading-3`… (`sable token typography`) | `decomposed-type` |
|
|
126
|
+
| `hover:bg-accent hover:text-accent-foreground` | a component, or `sable-surface-interactive` | `surface-hover` |
|
|
127
|
+
| `transition-colors`, `transition-all`, `transition-[color,box-shadow]` | `motion-interactive` | `transition-colors`, `transition-all` |
|
|
128
|
+
| `animate-in fade-in-0 zoom-in-95 data-[state=open]:…` | `motion-pop` / `motion-fade` / `motion-slide-*` | n/a (tw-animate is gone) |
|
|
129
|
+
| `animate-spin`, `animate-pulse` | `motion-spin`, `motion-pulse`, or `Spinner` / `Skeleton` | `builtin-animate` |
|
|
130
|
+
| `ring-ring/50`, `focus-visible:ring-[3px]` | the component's own focus ring; `ring-ring` at full strength if you must | `faded-focus-ring` |
|
|
131
|
+
| `bg-black/50` scrim | `bg-overlay` (Dialog and Sheet draw their own) | `raw-black-white` |
|
|
132
|
+
| `z-50` | `z-(--z-overlay)` and siblings | `raw-z-index` |
|
|
133
|
+
| `text-muted-foreground/70` | `text-muted-foreground` | `text-opacity` |
|
|
134
|
+
| `overflow-y-auto` alone | add `scrollbar-quiet`, or use `Scroller` | `unmanaged-overflow` |
|
|
135
|
+
| `bg-zinc-900`, `text-slate-500` | a semantic token, or a generated family (`bg-blue-100`) for categorical colour | `palette-ref` |
|
|
136
|
+
| `<div className="border-input rounded-md …">` dressed as a field | `Input` / `Textarea` / `SearchInput` | `lookalike-control` |
|
|
137
|
+
|
|
138
|
+
## 2. Tokens
|
|
139
|
+
|
|
140
|
+
The names carry over. Most shadcn utilities (`bg-background`, `text-foreground`,
|
|
141
|
+
`bg-card`, `bg-popover`, `bg-muted`, `text-muted-foreground`, `bg-primary`,
|
|
142
|
+
`bg-secondary`, `bg-accent`, `bg-destructive`, `border-border`, `border-input`,
|
|
143
|
+
`ring-ring`, `bg-sidebar*`) compile unchanged against Sable. Sable is stricter
|
|
144
|
+
about **role**, so check each use against its intent (`sable token <name>`):
|
|
145
|
+
|
|
146
|
+
| shadcn var | Sable | note |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `--background`, `--foreground` | same | the page ground and ink |
|
|
149
|
+
| `--card`, `--popover` | same | `popover` is for anything floating, including persistent inspectors |
|
|
150
|
+
| `--muted` | same | recessed and **inert**; not a hover fill |
|
|
151
|
+
| `--secondary` | same | a pressable low-emphasis fill; not a surface |
|
|
152
|
+
| `--accent` | same | the transient **state** layer (hover, current), applied by components; don't paint it by hand |
|
|
153
|
+
| `--border` | `border` or `border-strong` | `border-strong` when the line carries structure (a table rule, a section divider) |
|
|
154
|
+
| `--input` | `input` (fields), `control-border` (checkbox, radio, switch) | `control-border` is held to 3:1 |
|
|
155
|
+
| `--ring` | same | focus only, never selection |
|
|
156
|
+
| `--destructive` | `destructive` (fill), `destructive-text` (ink on a surface) | likewise `success`/`success-text`, `tone-warning`/`tone-warning-text`, `tone-info`/`tone-info-text` |
|
|
157
|
+
| `--chart-1`…`--chart-5` | `--chart-1`…`--chart-8`, set by the theme | address series by `tone` on the chart, not by variable |
|
|
158
|
+
| `--sidebar-*` | same names | |
|
|
159
|
+
| `--radius` (one value, derived) | a ladder: `rounded-xs`…`rounded-xl`, `rounded-full`; `--button-radius` for buttons | a theme sets the ladder; pick by role (`sable token radii`) |
|
|
160
|
+
|
|
161
|
+
New to a shadcn app: `tone-1`…`tone-3` (category colour), `highlight` (marked
|
|
162
|
+
content), `overlay` (scrims), and the generated palette families. Fonts:
|
|
163
|
+
Sable's `--font-sans` / `--font-mono` default to Figtree and DM Mono, loaded
|
|
164
|
+
by the host (see **Consuming** in `llms.txt`).
|
|
165
|
+
|
|
166
|
+
Dark mode: `next-themes` → `SableThemeProvider` (`defaultPreference`,
|
|
167
|
+
`useSableTheme()`). It writes the same `.dark` class on `<html>`.
|
|
168
|
+
|
|
169
|
+
## 3. Icons
|
|
170
|
+
|
|
171
|
+
`lucide-react` imports → `<Icon name="…" />`, rendered by the set in
|
|
172
|
+
`<IconProvider set={reiconIconSet}>` (`@sableui/react/icons/reicon`).
|
|
173
|
+
Without a provider, every icon renders nothing, including the ones inside
|
|
174
|
+
Sable's components. Names are semantic:
|
|
175
|
+
|
|
176
|
+
| lucide | Sable | lucide | Sable |
|
|
177
|
+
|---|---|---|---|
|
|
178
|
+
| `X` | `xmark` | `MoreHorizontal` / `Ellipsis` | `ellipsis` |
|
|
179
|
+
| `Check` | `check` | `ChevronDown` / `Up` / `Left` / `Right` | `chevron-down` … |
|
|
180
|
+
| `Search` | `search` | `Plus` / `Minus` | `plus` / `minus` |
|
|
181
|
+
| `Settings` | `gear` | `Trash2` | `trash` |
|
|
182
|
+
| `Pencil` | `edit` | `Copy` | `copy` |
|
|
183
|
+
| `Loader2` | `spinner` (or `Spinner`) | `AlertTriangle` | `warning` |
|
|
184
|
+
| `Info` | `info` | `CircleCheck` | `circle-check` |
|
|
185
|
+
| `Mail` | `mail` | `Menu` | `bars` |
|
|
186
|
+
| `ExternalLink` | `external` | `LogOut` | `logout` |
|
|
187
|
+
|
|
188
|
+
The full contract is 84 names (`iconContract.names` in `registry.json`). An app
|
|
189
|
+
icon outside it goes in a set of your own composed over Reicon
|
|
190
|
+
(`composeIconSets(appIcons, reiconIconSet)`) and is listed in
|
|
191
|
+
`.sable/icons.json`. Keeping lucide as that set is allowed; use `<Icon>` at the
|
|
192
|
+
call sites either way.
|
|
193
|
+
|
|
194
|
+
## 4. Component by component
|
|
195
|
+
|
|
196
|
+
`—` means Sable has no counterpart. The note says what to compose instead.
|
|
197
|
+
|
|
198
|
+
| shadcn | Sable | what changes |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| accordion | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionPanel` | array values; no `type` or `collapsible` (§1) |
|
|
201
|
+
| alert | `Note` (`tone`, `title`, `icon`, `action`) | `variant="destructive"` → `tone="destructive"`. A page-wide bar is `Banner`; a one-line status is `InlineStatus` |
|
|
202
|
+
| alert-dialog | `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogClose` | `AlertDialogAction` / `AlertDialogCancel` → `AlertDialogClose render={<Button …/>}`. For a delete confirmation use `DestructiveActionModal` (`onConfirm`, `pending`, `confirmText`) |
|
|
203
|
+
| aspect-ratio | `AspectBox` (`ratio`) | layout primitive |
|
|
204
|
+
| avatar | `Avatar` (`src`, `fallback`, `size`, `status`), or `AvatarRoot` / `AvatarImage` / `AvatarFallback` | the one-element form covers most uses; `AvatarGroup` for stacks |
|
|
205
|
+
| badge | `Badge` (`appearance` × `tone`) | `default` → `appearance="solid"`; `secondary` → `soft`; `outline` → `outline`; `destructive` → `tone="destructive"` |
|
|
206
|
+
| breadcrumb | `Breadcrumbs`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` | no `BreadcrumbList` (the root renders the `<ol>`); no `BreadcrumbEllipsis` |
|
|
207
|
+
| button | `Button` | §1 table |
|
|
208
|
+
| calendar | `Calendar` | same react-day-picker v9 (`mode`, `selected`, `onSelect`); Sable styles it, so drop `classNames` overrides |
|
|
209
|
+
| card | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` | same parts, plus `CardList` for rows and `density="compact"` |
|
|
210
|
+
| carousel | `Carousel` (`slidesToShow`, `loop`, `autoplay`, `index`) | one component, no Embla parts |
|
|
211
|
+
| checkbox | `Checkbox` | `indeterminate` prop (§1); wrap in `Field label orientation="horizontal"` for a label |
|
|
212
|
+
| collapsible | `Collapsible`, `CollapsibleTrigger`, `CollapsiblePanel` | `CollapsibleIndicator` for the chevron |
|
|
213
|
+
| combobox (Popover + Command) | `Combobox`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, `ComboboxEmpty` | one real combobox instead of a popover holding a command list; `items` on the root, a render function in `ComboboxList`; `multiple` + `ComboboxChips` for several |
|
|
214
|
+
| command | `CommandMenu` (`items`, `open`, `onOpenChange`), `CommandMenuPanel` inline | data-driven: `{ group, label, onSelect, icon, hint }` items instead of `CommandGroup`/`CommandItem` children (declarative `CommandMenuGroup` / `CommandMenuItem` exist too). `useCommandMenuShortcut` binds ⌘K |
|
|
215
|
+
| context-menu | `ContextMenu`, `ContextMenuTrigger`, `ContextMenuContent`, `ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuGroup`, `ContextMenuGroupLabel` | `onSelect` → `onClick`; destructive rows use `variant="destructive"` |
|
|
216
|
+
| data-table | `DataTable` (`columns`, `data`, `rowKey`, `defaultSort`, selection) | built-in sorting and selection; for TanStack features see `sable docs tables-with-tanstack` |
|
|
217
|
+
| dialog | `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose` | **`showCloseButton` defaults to false** (true in shadcn); `size="fullscreen"` for a media viewer |
|
|
218
|
+
| drawer | `Sheet`, `SheetTrigger`, `SheetContent side="bottom"`, `SheetHeader`, `SheetTitle`, `SheetDescription`, `SheetFooter`, `SheetClose` | swipe-to-dismiss included. vaul's `snapPoints` / `activeSnapPoint` / `setActiveSnapPoint` → `Sheet` `snapPoints` / `snapPoint` / `onSnapPointChange` (bottom only; fractions, px or rem); `fadeFromIndex` has no equivalent |
|
|
219
|
+
| dropdown-menu | `Menu`, `MenuTrigger`, `MenuContent`, `MenuItem`, `MenuGroup`, `MenuGroupLabel`, `MenuSeparator`, `MenuCheckboxItem`, `MenuRadioGroup`, `MenuRadioItem`, `MenuSubmenu`, `MenuSubmenuTrigger` | §1; `DropdownMenuShortcut` → a `<KbdGroup className="ms-auto">` in the row; an ellipsis trigger is `OverflowMenu` |
|
|
220
|
+
| empty | `EmptyState` (`icon`, `title`, `description`, `action`) | |
|
|
221
|
+
| field / form (react-hook-form) | `Form`, `Field` (`label`, `description`, `error`, `required`, `name`, `trailing`) | §5 |
|
|
222
|
+
| hover-card | `ContextCard`, `ContextCardTrigger`, `ContextCardContent` | |
|
|
223
|
+
| input | `Input` | `aria-invalid` drives the error state; `SearchInput` for search; `NumberField` for numbers |
|
|
224
|
+
| input-group | `InputGroup` (`leading`, `trailing`) around one `Input` | addons are props, not `InputGroupAddon` / `InputGroupInput` / `InputGroupButton` parts: `<InputGroup leading="https://" trailing={<Button variant="ghost" size="icon-sm" aria-label="Copy">…</Button>}><Input /></InputGroup>`. A button beside the field (not inside it) is `Field trailing` |
|
|
225
|
+
| input-otp | `OtpField` (`length`, `groupSize`, `onValueComplete`) | one component: no `InputOTPGroup` / `InputOTPSlot` / `InputOTPSeparator`; `groupSize={3}` draws the split |
|
|
226
|
+
| item | `Entity` (`leading`, `title`, `subtitle`, `action`, `interactive`) | |
|
|
227
|
+
| kbd | `Kbd`, `KbdGroup` | `mod` renders ⌘ or Ctrl per platform |
|
|
228
|
+
| label | `Label`, or better `Field label` | `Field` wires `htmlFor`, `aria-describedby` and `aria-invalid` for you |
|
|
229
|
+
| menubar | `Menubar` | one `Menu` per title: `MenubarMenu` → `Menu`, `MenubarTrigger` → `MenubarTrigger`, `MenubarContent` → `MenuContent`, `MenubarItem` → `MenuItem`, `MenubarSub` / `MenubarSubTrigger` / `MenubarSubContent` → `MenuSubmenu` / `MenuSubmenuTrigger` / `MenuContent side="inline-end"`, checkbox and radio rows → `MenuCheckboxItem` / `MenuRadioGroup` + `MenuRadioItem`. No `MenubarShortcut` |
|
|
230
|
+
| navigation-menu | `NavigationMenu` | `NavigationMenuList` / `Item` / `Trigger` / `Content` / `Link` keep their names. `NavigationMenuViewport` and `NavigationMenuIndicator` go: the root renders the one shared panel, which glides between triggers and resizes itself. `asChild` on a link → `render={<Link to="…" />}`; mark the current page with `active` (`aria-current="page"`). shadcn's `navigationMenuTriggerStyle()` on a plain link → a `NavigationMenuLink` in its own item, which is styled as a bar entry. Panel rows take `description` for the second line. For app navigation, use `Sidebar` / `SideNav` / `AppHeader` / `BottomNav` in an `AppShell` instead |
|
|
231
|
+
| pagination | `Pagination` (`page`, `total`, `onPageChange`, `siblingCount`, `pageLink`) | one component, not `PaginationContent` / `PaginationItem` / `PaginationLink` parts. Links: `` pageLink={(page) => <Link to={`?page=${page}`} />} `` makes every control a real link |
|
|
232
|
+
| popover | `Popover`, `PopoverTrigger`, `PopoverContent` (`side`, `align`, `sideOffset`), `PopoverTitle`, `PopoverDescription`, `PopoverClose` | `PopoverAnchor` → `anchor` on `PopoverContent` (an element or ref) |
|
|
233
|
+
| progress | `Progress` (`value`, `null` for indeterminate) | a level within a range (storage, quota) is `Meter`, not Progress |
|
|
234
|
+
| radio-group | `RadioGroup`, `Radio` | `RadioGroupItem` → `Radio`; `RadioCard` for card options |
|
|
235
|
+
| resizable | `PanelGroup`, `Panel`, `PanelResizer`, `usePanelWidth` | pixel widths with persisted bounds, for tool chrome; no percentage layout. A non-resizable two-pane split is `SplitPane` |
|
|
236
|
+
| scroll-area | `Scroller` (edge fades), or `scrollbar-quiet` on any overflow element | |
|
|
237
|
+
| select | `Select`, `SelectTrigger`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectGroupLabel`, `SelectSeparator` | `<SelectValue placeholder>` → `<SelectTrigger placeholder>`; the trigger shows the item's label, derived from `SelectItem`s or `items` |
|
|
238
|
+
| separator | `Separator` (`orientation`, `label`) | |
|
|
239
|
+
| sheet | `Sheet` parts as above, `side` on `SheetContent` (default `"right"`) | `SheetList` for row content |
|
|
240
|
+
| sidebar | `Sidebar` (`collapsed`, `variant`), `SidebarHeader`, `SidebarBrand`, `SidebarNav`, `SidebarItem` (`icon`, `label`, `active`, `badge`, `render`), `SidebarFooter`; mounted in `AppShell sidebar={…}` | no `SidebarProvider` / `SidebarMenu*` / `SidebarGroup`; the rail is desktop-only, and mobile uses `AppHeader` + `BottomNav` (`sable pattern` shells show the wiring) |
|
|
241
|
+
| skeleton | `Skeleton` | same; shape with utilities |
|
|
242
|
+
| slider | `Slider` | number vs array (§1) |
|
|
243
|
+
| sonner | `ToastProvider` at the root + `toast` | sonner's API: `toast.success/error/warning/info/loading/promise/dismiss`. `<Toaster />` → `<ToastProvider position="bottom-right">` around the app |
|
|
244
|
+
| spinner | `Spinner` (`size`, `appearance="ring" \| "dots"`, `label`) | |
|
|
245
|
+
| switch | `Switch` | same `checked` / `onCheckedChange` |
|
|
246
|
+
| table | `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption` | same parts; don't pad edge cells by hand (`table-edge-padding`); `CardContent flush` sets the inset |
|
|
247
|
+
| tabs | `Tabs`, `TabsList` (`variant="underline" \| "pill"`), `TabsTrigger`, `TabsPanel` | `TabsContent` still works as an alias |
|
|
248
|
+
| textarea | `Textarea` (`autoResize`) | |
|
|
249
|
+
| toggle | `Toggle` (`pressed`, `onPressedChange`) | |
|
|
250
|
+
| toggle-group | `ToggleGroup` (`appearance="segmented"` for a segmented control), `Toggle` / `ToggleGroupItem` | array value (§1) |
|
|
251
|
+
| tooltip | `TooltipProvider`, `Tooltip`, `TooltipTrigger`, `TooltipContent` (`side`), or `SimpleTooltip` | `delayDuration` → `delay` on `TooltipProvider` |
|
|
252
|
+
| chart | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `RadialChart`, `Sparkline` | no Recharts and no `ChartContainer`/`ChartConfig`: pass `data`, `index` (the x key) and `series: [{ key, label, tone }]` |
|
|
253
|
+
| button-group | `ButtonGroup` | children stay `Button`s; the group squares inner corners and draws the seam. A split button is `ButtonGroup` > `Button` + `Menu` with the chevron `Button` as `MenuTrigger`. No `ButtonGroupSeparator`/`ButtonGroupText` |
|
|
254
|
+
| native-select | — | use `Select` in place of a native one |
|
|
255
|
+
|
|
256
|
+
Before and after for the non-obvious cases:
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
// dropdown-menu
|
|
260
|
+
<DropdownMenu>
|
|
261
|
+
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal /></Button></DropdownMenuTrigger>
|
|
262
|
+
<DropdownMenuContent align="end">
|
|
263
|
+
<DropdownMenuLabel>Post</DropdownMenuLabel>
|
|
264
|
+
<DropdownMenuItem onSelect={edit}>Edit</DropdownMenuItem>
|
|
265
|
+
<DropdownMenuItem className="text-destructive" onSelect={remove}>Delete</DropdownMenuItem>
|
|
266
|
+
</DropdownMenuContent>
|
|
267
|
+
</DropdownMenu>
|
|
268
|
+
|
|
269
|
+
<OverflowMenu label="Post actions">
|
|
270
|
+
<MenuGroup>
|
|
271
|
+
<MenuGroupLabel>Post</MenuGroupLabel>
|
|
272
|
+
<MenuItem onClick={edit}>Edit</MenuItem>
|
|
273
|
+
<MenuItem variant="destructive" onClick={remove}>Delete</MenuItem>
|
|
274
|
+
</MenuGroup>
|
|
275
|
+
</OverflowMenu>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// chart
|
|
280
|
+
<ChartContainer config={{ revenue: { label: "Revenue", color: "var(--chart-1)" } }}>
|
|
281
|
+
<AreaChart data={data}><XAxis dataKey="month" /><Area dataKey="revenue" /></AreaChart>
|
|
282
|
+
</ChartContainer>
|
|
283
|
+
|
|
284
|
+
<AreaChart label="Revenue" data={data} index="month" series={[{ key: "revenue", label: "Revenue", tone: "1" }]} />
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## 5. Forms
|
|
288
|
+
|
|
289
|
+
shadcn's `Form` wraps react-hook-form with `FormField` / `FormItem` /
|
|
290
|
+
`FormLabel` / `FormControl` / `FormMessage`. Sable's `Form` + `Field` is
|
|
291
|
+
library-agnostic and does that wiring itself:
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
<Form onFormSubmit={save} errors={serverErrors}>
|
|
295
|
+
<Field name="email" label="Email" required messages={{ valueMissing: "Enter your email." }}>
|
|
296
|
+
<Input type="email" autoComplete="email" spellCheck={false} />
|
|
297
|
+
</Field>
|
|
298
|
+
<Button type="submit" loading={saving}>Save</Button>
|
|
299
|
+
</Form>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- `FormLabel` + `FormControl` + `FormDescription` + `FormMessage` → one
|
|
303
|
+
`Field` (`label`, `description`, `error`). It wires `htmlFor`,
|
|
304
|
+
`aria-describedby` and `aria-invalid`.
|
|
305
|
+
- **Keeping react-hook-form** is fine: register the control as usual and pass
|
|
306
|
+
`error={errors.email?.message}` to its `Field`. Use `<Form noValidate>` so
|
|
307
|
+
your resolver owns validation. Don't also mark that `Field` `required`
|
|
308
|
+
with an `error` inside a validating `Form` (`form-required-error`).
|
|
309
|
+
- Server errors go in `Form errors={{ email: "Taken" }}`, keyed by `Field name`.
|
|
310
|
+
Each clears when the user edits that field.
|
|
311
|
+
- Follow the form submit contract in `AGENTS.md`: submit stays enabled until
|
|
312
|
+
the request starts; focus the first invalid field (`Form` does);
|
|
313
|
+
`loading` keeps the label.
|
|
314
|
+
|
|
315
|
+
## 6. What Sable doesn't have yet
|
|
316
|
+
|
|
317
|
+
Every shadcn component in the map above now has a Sable counterpart. For
|
|
318
|
+
anything else shadcn's ecosystem offers, compose it from Sable's parts rather
|
|
319
|
+
than porting the shadcn file back in. If a missing piece blocks the app, say so
|
|
320
|
+
upstream. A local workaround is a
|
|
321
|
+
`sable patch`, not a copy.
|
|
322
|
+
|
|
323
|
+
## 7. Verify
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
pnpm exec sable doctor # wiring: styles order, @source, IconProvider, dedupe
|
|
327
|
+
pnpm exec sable lint src # the idioms in §1, by rule
|
|
328
|
+
pnpm exec sable check src # doctor + lint + grade before calling it done
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`shadcn-leftover` flags what is still imported from the old stack (`@radix-ui/*`,
|
|
332
|
+
`@/components/ui`, `lucide-react`, `sonner`, `vaul`, `cmdk`, `input-otp`,
|
|
333
|
+
`next-themes`) and every `asChild` prop. A deliberate keeper, such as a Radix
|
|
334
|
+
part Sable does not ship yet, takes a reasoned disable. The rest no rule can
|
|
335
|
+
judge, so grep for it; each of these should return nothing:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
grep -rnE 'data-\[state=|animate-in|tw-animate|type="(single|multiple)"' src
|
|
339
|
+
grep -rn 'onSelect=' src # Menu rows: should be onClick (Calendar, CommandMenu items and Entity keep onSelect)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Then render it: open each overlay, menu and form in light and dark. Lint
|
|
343
|
+
passes on markup that never opens.
|
|
344
|
+
|
|
345
|
+
## Read next
|
|
346
|
+
|
|
347
|
+
- [converting-an-app.md](./converting-an-app.md): registers, wiring, tokens, verification by measurement
|
|
348
|
+
- [patching-a-component.md](./patching-a-component.md): when a Sable component is wrong and the fix hasn't shipped
|
|
349
|
+
- [extending-a-component.md](./extending-a-component.md): owning the source is gone, so this is how you add what a component lacks
|
|
350
|
+
- [tables-with-tanstack.md](./tables-with-tanstack.md): data tables beyond `DataTable`
|
|
351
|
+
- [auditing-an-app.md](./auditing-an-app.md): staying converted across upgrades
|