@sableui/react 0.11.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.
Files changed (87) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +170 -0
  3. package/DESIGN.md +1 -1
  4. package/README.md +15 -1
  5. package/bin/sable.mjs +254 -10
  6. package/dist/components/app-header/app-header.meta.js +2 -1
  7. package/dist/components/button/button.js +3 -0
  8. package/dist/components/button/button.meta.js +1 -1
  9. package/dist/components/button/button.variants.js +1 -1
  10. package/dist/components/button-group/button-group.js +17 -0
  11. package/dist/components/button-group/button-group.meta.js +72 -0
  12. package/dist/components/button-group/index.js +7 -0
  13. package/dist/components/caret-menu/caret-menu.js +221 -0
  14. package/dist/components/caret-menu/caret-menu.meta.js +119 -0
  15. package/dist/components/caret-menu/index.js +8 -0
  16. package/dist/components/chat-composer/chat-composer.js +30 -7
  17. package/dist/components/chat-composer/chat-composer.meta.js +27 -8
  18. package/dist/components/combobox/combobox.meta.js +26 -0
  19. package/dist/components/input-group/index.js +7 -0
  20. package/dist/components/input-group/input-group.js +66 -0
  21. package/dist/components/input-group/input-group.meta.js +106 -0
  22. package/dist/components/menu/index.js +2 -0
  23. package/dist/components/menu/menu.js +51 -9
  24. package/dist/components/menu/menu.meta.js +11 -2
  25. package/dist/components/menubar/index.js +8 -0
  26. package/dist/components/menubar/menubar.js +42 -0
  27. package/dist/components/menubar/menubar.meta.js +99 -0
  28. package/dist/components/message-scroller/message-scroller.meta.js +4 -0
  29. package/dist/components/navigation-menu/index.js +19 -0
  30. package/dist/components/navigation-menu/navigation-menu.js +108 -0
  31. package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
  32. package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
  33. package/dist/components/pagination/pagination.js +11 -5
  34. package/dist/components/pagination/pagination.meta.js +21 -3
  35. package/dist/components/scroller/scroller.js +8 -4
  36. package/dist/components/scroller/scroller.meta.js +6 -0
  37. package/dist/components/sheet/sheet.js +82 -9
  38. package/dist/components/sheet/sheet.meta.js +41 -4
  39. package/dist/components/sheet/sheet.variants.js +8 -1
  40. package/dist/components/sidebar/sidebar.js +13 -2
  41. package/dist/components/sidebar/sidebar.meta.js +10 -0
  42. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  43. package/dist/file-hashes.json +47 -31
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +655 -162
  46. package/dist/index.js +12 -1
  47. package/dist/lib/taxonomy.js +5 -0
  48. package/dist/lib/use-caret-trigger.js +187 -0
  49. package/dist/lib/use-copy.js +6 -1
  50. package/dist/lib/use-smooth-wheel.js +164 -0
  51. package/dist/patterns/chat-panel.js +1 -1
  52. package/dist/patterns/chat-panel.meta.js +1 -0
  53. package/dist/styles.css +25 -0
  54. package/llms/components/app-header.md +1 -1
  55. package/llms/components/button-group.md +55 -0
  56. package/llms/components/button.md +1 -1
  57. package/llms/components/caret-menu.md +100 -0
  58. package/llms/components/chat-composer.md +24 -7
  59. package/llms/components/combobox.md +12 -0
  60. package/llms/components/input-group.md +78 -0
  61. package/llms/components/menu.md +16 -1
  62. package/llms/components/menubar.md +110 -0
  63. package/llms/components/message-scroller.md +1 -0
  64. package/llms/components/navigation-menu.md +113 -0
  65. package/llms/components/overflow-menu.md +26 -1
  66. package/llms/components/pagination.md +15 -3
  67. package/llms/components/scroller.md +1 -0
  68. package/llms/components/sheet.md +18 -3
  69. package/llms/components/sidebar.md +2 -0
  70. package/llms/components/streaming-text.md +2 -1
  71. package/llms/patterns/chat-panel.md +4 -1
  72. package/llms/tasks/building-a-chat.md +158 -0
  73. package/llms/tasks/building-with-sable.md +4 -1
  74. package/llms/tasks/converting-an-app.md +7 -0
  75. package/llms/tasks/extending-a-component.md +170 -0
  76. package/llms/tasks/getting-started.md +22 -4
  77. package/llms/tasks/migrating-from-shadcn.md +351 -0
  78. package/llms-full.txt +1321 -26
  79. package/llms.txt +13 -4
  80. package/package.json +3 -2
  81. package/registry.json +979 -23
  82. package/scripts/lint-rules.mjs +35 -0
  83. package/scripts/wiring.mjs +541 -0
  84. package/skills/entry/SKILL.md +3 -2
  85. package/skills/sable/SKILL.md +26 -5
  86. package/targets/ios.json +45 -0
  87. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -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