@sableui/react 0.11.0 → 0.13.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 +199 -0
- package/DESIGN.md +3 -3
- 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/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 +49 -33
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +660 -164
- package/dist/index.js +12 -1
- package/dist/lib/palette.js +46 -6
- 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/palette.css +243 -165
- 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/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/reference/token-intent.md +1 -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 +1322 -27
- package/llms.txt +13 -4
- package/package.json +3 -2
- package/registry.json +979 -23
- 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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Task: extending a Sable component
|
|
2
|
+
|
|
3
|
+
You need a component to do something it doesn't do out of the box: be a link,
|
|
4
|
+
carry an extra control, react to a key, suggest names as someone types. Before
|
|
5
|
+
restyling it from the outside or copying it into the app, go through the
|
|
6
|
+
extension points below, roughly in this order. Most needs are one of them, and
|
|
7
|
+
each keeps the component's states, density, focus and keyboard contract, which a
|
|
8
|
+
copy loses the day it is made.
|
|
9
|
+
|
|
10
|
+
The split to keep in mind: **behaviour** comes from Base UI and Sable, **look**
|
|
11
|
+
comes from variants and tokens, and **what is yours** (content, data, what an
|
|
12
|
+
action does) goes in through props and slots.
|
|
13
|
+
|
|
14
|
+
## 1. Compose an element: `render`
|
|
15
|
+
|
|
16
|
+
Every component that renders an element takes `render`, and keeps its props,
|
|
17
|
+
ref and state on the element you give it. This is how a Sable control becomes a
|
|
18
|
+
link, or any router's link:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
<Button render={<a href="/pricing" />}>See pricing</Button>
|
|
22
|
+
<Card render={<Link to="/projects/42" />}>…</Card>
|
|
23
|
+
<Button variant="ghost" render={<Link to="/inbox" />} iconStart={<Icon name="inbox" />}>Inbox</Button>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A link that looks like a button is `<Button render={<a … />}>`, never an `<a>`
|
|
27
|
+
wearing button classes (`sable-lint` flags that as `lookalike-control`).
|
|
28
|
+
|
|
29
|
+
## 2. Fill a slot
|
|
30
|
+
|
|
31
|
+
Slots take content you own. The component lays it out and keeps it aligned with
|
|
32
|
+
its own parts. Check `sable component <Name> --props` for the full list.
|
|
33
|
+
|
|
34
|
+
| Component | Slots |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Button | `iconStart`, `iconEnd`, `badge` |
|
|
37
|
+
| ChatComposer | `leading`, `actions`, `attachments`, `footer` (a node, or a function of the send key in effect) |
|
|
38
|
+
| Message | `avatar`, `header`, `footer` |
|
|
39
|
+
| PageHeader | `actions`, `back` |
|
|
40
|
+
| MenuItem, MenuLinkItem, CaretMenuItem | `icon`, `description` |
|
|
41
|
+
|
|
42
|
+
`className` on a component is for layout (margins, width, alignment). It is not
|
|
43
|
+
a slot for a new look; see section 5.
|
|
44
|
+
|
|
45
|
+
## 3. Hold the state, reach the element
|
|
46
|
+
|
|
47
|
+
Controlled props (`value` + `onValueChange`, `open` + `onOpenChange`) let the
|
|
48
|
+
app own the state and change it from anywhere: clear a draft after a server
|
|
49
|
+
confirms the send, open a menu from a shortcut.
|
|
50
|
+
|
|
51
|
+
Refs reach the element itself. ChatComposer's `fieldRef` is the textarea, which
|
|
52
|
+
is enough to focus it, read the caret, or **listen to it**. That last one is an
|
|
53
|
+
extension point in its own right: a key handler on the field that handles a key
|
|
54
|
+
and calls `event.preventDefault()` owns that key, and ChatComposer does not also
|
|
55
|
+
act on it. That is how the suggestion list below takes Enter without the message
|
|
56
|
+
being sent.
|
|
57
|
+
|
|
58
|
+
## 4. Add behaviour on top: mentions and slash commands
|
|
59
|
+
|
|
60
|
+
The worked example. Mentions and slash commands are behaviour that sits on a
|
|
61
|
+
text field, not a new kind of field, so Sable ships the parts every app gets
|
|
62
|
+
wrong and leaves the rest to the app:
|
|
63
|
+
|
|
64
|
+
- **Sable owns:** noticing a trigger character at the caret (`@`, `/`, `#`), on
|
|
65
|
+
a word boundary and after IME composition; placing the list at the caret;
|
|
66
|
+
↑/↓/Enter/Tab/Escape while focus stays in the field; the ARIA that lets a
|
|
67
|
+
screen reader follow along.
|
|
68
|
+
- **The app owns:** the items (filter or fetch them, debounced, from the query),
|
|
69
|
+
the loading and empty states, and what choosing one does.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
const trigger = useCaretTrigger({
|
|
73
|
+
triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }],
|
|
74
|
+
})
|
|
75
|
+
const q = trigger.match?.query.toLowerCase() ?? ""
|
|
76
|
+
const commands = trigger.match?.trigger.char === "/"
|
|
77
|
+
|
|
78
|
+
<ChatComposer fieldRef={trigger.fieldRef} onSend={send} />
|
|
79
|
+
<CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={loading ? "Searching…" : undefined}>
|
|
80
|
+
{commands
|
|
81
|
+
? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (
|
|
82
|
+
<CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>
|
|
83
|
+
/{c.name}
|
|
84
|
+
</CaretMenuItem>
|
|
85
|
+
))
|
|
86
|
+
: people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (
|
|
87
|
+
<CaretMenuItem key={p.id} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>
|
|
88
|
+
))}
|
|
89
|
+
</CaretMenu>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A mention is inserted as text with a trailing space; a command runs and
|
|
93
|
+
`replace("")` clears its `/query`. The same hook works on `Textarea` and
|
|
94
|
+
`Input` through `ref={trigger.fieldRef}`. `sable component CaretMenu` has the
|
|
95
|
+
props and the accessibility notes.
|
|
96
|
+
|
|
97
|
+
The pattern generalises. To add your own behaviour to a Sable field, attach to
|
|
98
|
+
it through its ref, read and write through the field's own input path (the hook's
|
|
99
|
+
`replace()` does, so controlled values and undo keep working), and take a key by
|
|
100
|
+
handling it first and calling `preventDefault()`.
|
|
101
|
+
|
|
102
|
+
### Mentions as chips are a different job
|
|
103
|
+
|
|
104
|
+
`useCaretTrigger` keeps the value a plain string, because that is what a
|
|
105
|
+
textarea holds. Showing a mention as a token *inside the field* needs a
|
|
106
|
+
rich-text (contenteditable) editor such as TipTap or Lexical, with its own value
|
|
107
|
+
model. Sable does not own an editor. Two ways to get chips today:
|
|
108
|
+
|
|
109
|
+
- Keep text mentions in the field and render them as chips where the message is
|
|
110
|
+
shown: parse `@Name` against your people list when you render the Message.
|
|
111
|
+
- Use an editor in place of the composer's field and keep Sable's controls around
|
|
112
|
+
it. A ChatComposer slot for a custom editor is a possible later addition; it
|
|
113
|
+
doesn't exist yet, so don't write code that assumes it.
|
|
114
|
+
|
|
115
|
+
## 5. Change the look: tokens and presets, not overrides
|
|
116
|
+
|
|
117
|
+
A component's fill, edge, corners, ink and interaction states come from its
|
|
118
|
+
variants and the theme's tokens. To change how it looks:
|
|
119
|
+
|
|
120
|
+
- **Everywhere:** override tokens on `:root` (or any subtree) after Sable's CSS:
|
|
121
|
+
radius, density, accent, type scale, motion. `sable token --list` lists them.
|
|
122
|
+
- **As a reusable direction:** a preset (`sable docs creating-a-preset`), compiled
|
|
123
|
+
to scoped CSS with `sable preset --css <path>`.
|
|
124
|
+
- **One component, a look it lacks:** that is a missing variant. Report it (see
|
|
125
|
+
section 8); don't paint it on with `className`. `component-restyle`,
|
|
126
|
+
`destructive-item-ink` and `table-edge-padding` flag the common overrides,
|
|
127
|
+
because a class that repaints a part fights the part's own hover, focus and
|
|
128
|
+
disabled states.
|
|
129
|
+
|
|
130
|
+
Parts carry `data-slot` names (`chat-composer-attachments`,
|
|
131
|
+
`menu-item-description`, `button-group`). They are good test selectors and fine
|
|
132
|
+
for layout from a parent. They are not a way around the rule above, and a slot
|
|
133
|
+
name a component's docs don't mention may change between versions.
|
|
134
|
+
|
|
135
|
+
## 6. Add icons: `composeIconSets`
|
|
136
|
+
|
|
137
|
+
`<IconProvider set={composeIconSets(appExtras, reiconIconSet)}>` adds your own
|
|
138
|
+
glyphs alongside Reicon's (list the added names in `.sable/icons.json` so
|
|
139
|
+
`sable grade` accepts them). Components keep using semantic names.
|
|
140
|
+
|
|
141
|
+
## 7. Fix a defect: `sable patch`
|
|
142
|
+
|
|
143
|
+
When a component is wrong, not merely missing something, patch the installed
|
|
144
|
+
module and record why: `sable patch <Component> --reason … --repro …`. Doctor
|
|
145
|
+
re-checks it on every upgrade. `sable docs patching-a-component` has the steps.
|
|
146
|
+
|
|
147
|
+
## 8. App-owned, or a library gap?
|
|
148
|
+
|
|
149
|
+
It belongs in the app when it is about **your** product: domain data, copy,
|
|
150
|
+
business rules, which items a list offers, what an action does, the layout of a
|
|
151
|
+
signature surface.
|
|
152
|
+
|
|
153
|
+
It is a library gap when **another app would need the same thing** and none of
|
|
154
|
+
the sections above gets there without fighting the component: a missing
|
|
155
|
+
variant, slot, prop, or piece of behaviour. Then:
|
|
156
|
+
|
|
157
|
+
1. Show the search: `sable search "<need>"` and what came back.
|
|
158
|
+
2. Name the missing capability and the smallest API that would cover it.
|
|
159
|
+
3. Meanwhile, compose the nearest thing from Sable's parts in the app (not a
|
|
160
|
+
copy of the component), and say in your summary that it is a stand-in for a
|
|
161
|
+
reported gap.
|
|
162
|
+
|
|
163
|
+
In the Sable repository itself, close the gap in the package and then use it
|
|
164
|
+
(`sable docs adding-a-component`).
|
|
165
|
+
|
|
166
|
+
## Read next
|
|
167
|
+
|
|
168
|
+
- [patching-a-component.md](./patching-a-component.md): the patch workflow
|
|
169
|
+
- [creating-a-preset.md](./creating-a-preset.md): a reusable look
|
|
170
|
+
- [building-a-chat.md](./building-a-chat.md): the chat parts and who owns what
|
|
@@ -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
|