@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.
- package/AGENTS.md +10 -4
- package/CHANGELOG.md +170 -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/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 +47 -31
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +655 -162
- 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/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 +1321 -26
- 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
package/llms-full.txt
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Sable (@sableui/react)
|
|
2
2
|
|
|
3
|
-
> An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter.
|
|
3
|
+
> An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 116 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
|
|
4
4
|
|
|
5
5
|
## Start here — brief, composition, evidence
|
|
6
6
|
|
|
7
7
|
Read `sable docs building-with-sable` for the connected workflow: build a product
|
|
8
|
-
idea, inspire a reusable preset, audit existing work,
|
|
8
|
+
idea, inspire a reusable preset, audit existing work, polish a selected design, or
|
|
9
|
+
fix one scoped defect on a short path that reads only what the change touches.
|
|
9
10
|
Run `sable setup --agent` to connect Codex, Claude Code, or Cursor to the maintained
|
|
10
11
|
`skills/sable/SKILL.md` in the installed package. Package upgrades update the skill.
|
|
11
12
|
Canvas and the skill exchange portable presets; `sable preset --check <path>`
|
|
@@ -119,7 +120,10 @@ These are organised by **task**, and they are the fastest route in:
|
|
|
119
120
|
- [llms/tasks/learning-from-builds.md](./llms/tasks/learning-from-builds.md) — retrieve and preserve conditional lessons with `sable learn`
|
|
120
121
|
- [llms/tasks/building-a-page.md](./llms/tasks/building-a-page.md) — page register: patterns, regions, type tiers, colour by role
|
|
121
122
|
- [llms/tasks/building-a-tool.md](./llms/tasks/building-a-tool.md) — workspace register: the shell, what to use instead of what, the traps
|
|
123
|
+
- [llms/tasks/building-a-chat.md](./llms/tasks/building-a-chat.md) — a real chat on any streaming source: who owns what, the turn lifecycle mapped to props, every failure state, a11y, safe Markdown
|
|
122
124
|
- [llms/tasks/converting-an-app.md](./llms/tasks/converting-an-app.md) — migrating an existing app, in the order that avoids rework
|
|
125
|
+
- [llms/tasks/migrating-from-shadcn.md](./llms/tasks/migrating-from-shadcn.md) — shadcn/ui + Radix → Sable: `asChild` → `render`, array values, events, tokens, icons, every component mapped
|
|
126
|
+
- [llms/tasks/extending-a-component.md](./llms/tasks/extending-a-component.md) — when a component doesn't do something: `render`, slots, refs and key handling, mentions and slash commands (`useCaretTrigger` + CaretMenu), tokens not overrides, gap or app-owned
|
|
123
127
|
- [llms/tasks/auditing-an-app.md](./llms/tasks/auditing-an-app.md) — an app already on Sable, after an upgrade: what changed, what to re-check
|
|
124
128
|
- [llms/tasks/adding-a-component.md](./llms/tasks/adding-a-component.md) — contributing to Sable: the meta contract and the enforced minima
|
|
125
129
|
|
|
@@ -598,6 +602,167 @@ being a thing anyone has to say.
|
|
|
598
602
|
|
|
599
603
|
---
|
|
600
604
|
|
|
605
|
+
# Task: building a chat
|
|
606
|
+
|
|
607
|
+
Sable ships the parts a conversation is made of, and the [`ChatPanel`
|
|
608
|
+
pattern](../patterns/chat-panel.md) assembles them into a working screen. It
|
|
609
|
+
ships no transport, no message store and no model client. This guide is the
|
|
610
|
+
seam between the two: which part owns what, how a request's lifecycle maps
|
|
611
|
+
onto the parts' props, what each failure looks like, and what is left for the
|
|
612
|
+
app.
|
|
613
|
+
|
|
614
|
+
Check every name here against the installed package with
|
|
615
|
+
`sable component <Name> --detail full` before relying on it.
|
|
616
|
+
|
|
617
|
+
## Who owns what
|
|
618
|
+
|
|
619
|
+
| Sable part | Owns | Does not own |
|
|
620
|
+
|---|---|---|
|
|
621
|
+
| [`MessageScroller`](../components/message-scroller.md) | The scroll region: following the newest message, letting go when the reader scrolls up, the jump-to-latest button, holding still when history is prepended, `scrollToMessage`, `onVisibleMessagesChange` | Your messages, your loading of history |
|
|
622
|
+
| [`Message`](../components/message.md) | One turn's row: side (`align`), avatar gutter, `header` and `footer`, `grouped` runs, `messageId` | How the content looks |
|
|
623
|
+
| [`Bubble`](../components/bubble.md) | The surface: `muted` (them), `primary` (me), `plain` (long-form answers), the tail, reactions | Timestamps and names (those go in Message's `header`/`footer`) |
|
|
624
|
+
| [`StreamingText`](../components/streaming-text.md) | Text arriving a token at a time: `text` (append-only), `streaming`, the caret, `aria-busy` | Markdown, and announcing the finished answer |
|
|
625
|
+
| [`ThinkingOrb`](../components/thinking-orb.md), [`Marker`](../components/marker.md) | The wait before the first token (orb or `Marker icon="spinner" spin`), and every non-message row: status, tool notes, date breaks, bordered notices | — |
|
|
626
|
+
| [`Attachment`](../components/attachment.md) | A file's four states: queued, `uploading` with `progress`, `error` with the reason in `meta`, delivered | The upload itself |
|
|
627
|
+
| [`ChatComposer`](../components/chat-composer.md) | The input: Enter to send (never mid-IME; Return breaks the line on touch), `busy` + `onStop`, `allowEmpty` for attachment-only sends, `disabled`, `invalid`, `footer` as a function of the send key in effect | Whether the send succeeded |
|
|
628
|
+
| [`Toast`](../components/toast.md) | Notices about the app, not the conversation | — |
|
|
629
|
+
|
|
630
|
+
**The app owns** the transport (HTTP, SSE, WebSocket), the streaming state, message ids, persistence and history, retries and backoff, auth, rate limits, moderation, uploads, and what counts as a failed turn. Sable renders whatever state you hand it; it never infers one.
|
|
631
|
+
|
|
632
|
+
## The lifecycle, mapped to props
|
|
633
|
+
|
|
634
|
+
A turn moves through five states. Keep **one** status value in the app and derive every prop from it; two flags (`sending` here, `streaming` there) drift, and the composer ends up saying "sending" under an answer that already finished.
|
|
635
|
+
|
|
636
|
+
| State | Transcript | Composer |
|
|
637
|
+
|---|---|---|
|
|
638
|
+
| **idle** | Settled messages. The last answer's `footer` carries its actions (copy, regenerate) as a `Toolbar` of icon `ToolbarButton`s | Normal; `busy={false}` |
|
|
639
|
+
| **submitted** (sent, no token yet) | The reader's message, optimistically, with the id you generated. Below it, the wait: `<Marker icon="spinner" spin shimmer>Thinking…</Marker>` or a `ThinkingOrb` | `busy` and `onStop` |
|
|
640
|
+
| **streaming** | The wait is **replaced** by the answer: `StreamingText` with `streaming` in a `plain` Bubble. No answer actions yet | `busy` and `onStop` |
|
|
641
|
+
| **done** | `streaming={false}`, which removes the caret: the only signal the reader gets that the answer is complete. Actions appear in the `footer` | `busy={false}` |
|
|
642
|
+
| **stopped / error** | See [Failures](#failures) | `busy={false}`; the draft is still the reader's |
|
|
643
|
+
|
|
644
|
+
Rules that follow from it:
|
|
645
|
+
|
|
646
|
+
- **One indicator per wait.** The spinner Marker (or orb) shows before the first token and is removed when tokens land. While the answer streams, its own caret is the indicator; pass `caret={false}` only when something adjacent already reports the wait.
|
|
647
|
+
- **`busy` is not `disabled`.** `busy` is temporary and comes with a way out (`onStop` turns send into stop). `disabled` is for a composer that cannot send at all: offline, over a quota, a read-only thread.
|
|
648
|
+
- **Controlled composers are cleared by you, after the send is accepted.** An uncontrolled composer clears itself; a controlled one leaves the text so a failed send loses nothing.
|
|
649
|
+
- **Regenerate replaces the answer, not appends to it.** `StreamingText` assumes `text` only grows. When regenerating, give the new answer a new `key` (a new message id does it) so the old spans unmount instead of being diffed against.
|
|
650
|
+
- **Editing a sent message** is the app's feature: put the text back in a controlled composer, or swap the Bubble's content for a `Textarea` in place, then truncate the turns after it before re-sending. Sable has no edit mode on Message.
|
|
651
|
+
|
|
652
|
+
## Scrolling
|
|
653
|
+
|
|
654
|
+
`MessageScroller` needs no configuration for streaming. It follows the newest message as messages arrive **and** as the last one grows, and stops following the moment the reader scrolls up past `followThreshold` (64px). The jump-to-latest button appears while they are away; returning to the bottom resumes following.
|
|
655
|
+
|
|
656
|
+
- Bound it: `flex-1 min-h-0` inside a flex column. A transcript that grows the page pushes the composer off the screen.
|
|
657
|
+
- Never call `scrollToBottom` per token. It fights a reader who scrolled up to read. Call it once when **they** send, so their own message is in view.
|
|
658
|
+
- Give every `Message` a `messageId`; it is the handle for `scrollToMessage`, `initialScroll={{ messageId }}` (restore a read position) and `onVisibleMessagesChange` (read receipts).
|
|
659
|
+
- Load older history by prepending to `children`. The scroller anchors on the first rendered message, so the view does not jump.
|
|
660
|
+
|
|
661
|
+
## Attachments
|
|
662
|
+
|
|
663
|
+
- **Pending** files are `Attachment` chips in the composer's `attachments`, each with a remove action. Upload when picked, not when sent, so `status="uploading"` and `progress` can show in the box the file belongs to.
|
|
664
|
+
- **Failed** uploads keep their card: `status="error"`, the reason in `meta` ("Over the 50 MB limit"), a Retry action. Colour alone is not a status.
|
|
665
|
+
- **Attachment-only sends:** `allowEmpty={files.length > 0}`. `onSend` then receives `""`. Tie it to the pending files; a constant `allowEmpty` leaves send enabled with nothing to send. Disable sending while an upload is still in flight if your backend needs the file first.
|
|
666
|
+
- **Sent** files move into the turn: one `Message` holding the files and the text together, `variant="media"` for images, `variant="card"` for documents, `onOpen` to preview.
|
|
667
|
+
|
|
668
|
+
## Failures
|
|
669
|
+
|
|
670
|
+
Put each failure where the reader is looking when it happens. A failure that belongs to a turn stays in the transcript, next to that turn; a toast is for things that are not about the conversation.
|
|
671
|
+
|
|
672
|
+
| Failure | UI |
|
|
673
|
+
|---|---|
|
|
674
|
+
| **Send rejected** (network down, 4xx before streaming) | The optimistic message stays, with a Message `footer` of `<InlineStatus tone="destructive">Not sent</InlineStatus>` and a Retry button. The composer keeps the draft if it was controlled |
|
|
675
|
+
| **Error mid-stream** | Keep what streamed, set `streaming={false}`, and add `<Marker variant="bordered" icon="warning">The answer was cut off.</Marker>` with Retry in the answer's `footer`. Never delete partial text the reader may be reading |
|
|
676
|
+
| **Stopped by the reader** | Keep the partial answer, `streaming={false}`, and a quiet `<Marker variant="note">Stopped</Marker>`. Not an error, so no warning colour |
|
|
677
|
+
| **Rate limited / quota** | `ChatComposer disabled`, with the reason and the wait in its `footer` ("Limit reached · try again in 2 min"). Re-enable it when the window passes |
|
|
678
|
+
| **Empty response** | Treat as an error: a bordered Marker ("No answer came back") and Retry. An empty plain Bubble reads as a rendering bug |
|
|
679
|
+
| **Tool call failed** | A `Marker variant="note" icon="warning"` in that turn's `header` ("Search failed: timeout"). The answer may continue without it; say so rather than hiding the failure |
|
|
680
|
+
| **Disconnected** (socket dropped) | A `Banner` above the transcript, or a toast if the panel is small, and `disabled` on the composer until reconnected |
|
|
681
|
+
| **Moderation / blocked** | A bordered Marker in place of the answer, naming what happened. Don't render the blocked content and then hide it |
|
|
682
|
+
|
|
683
|
+
## Accessibility
|
|
684
|
+
|
|
685
|
+
- **Don't put streamed text in a live region.** Every token would be announced. `StreamingText` sets `aria-busy` while streaming instead.
|
|
686
|
+
- **Announce the turn once, when it settles.** Keep an empty `<p role="status" className="sr-only">` mounted from the start, and set its text when a turn ends ("Answer ready", "Answer stopped", "Couldn't answer"). A region inserted together with its text is announced unreliably (`sable docs AGENTS` → Announcing and focusing).
|
|
687
|
+
- **A status Marker is already live** (`role="status"`), so replacing its text as the work moves on ("Searching the docs" → "Writing the answer") is announced. Remove it when the work ends; a permanent "Thinking…" is a region that never resolves.
|
|
688
|
+
- **ThinkingOrb's `label` must stay constant** while it is mounted. Put the changing phase in adjacent text.
|
|
689
|
+
- **Focus:** after sending, keep focus in the composer (`fieldRef`) so the reader can keep typing. After Stop, ChatComposer moves focus to the field itself (the stop button turns back into send in place, and a disabled send would drop focus to the page); on touch it leaves focus alone rather than raise the keyboard.
|
|
690
|
+
- **Reduced motion:** StreamingText, ThinkingOrb, the Marker shimmer and the scroller's smooth scrolling all fall back under `prefers-reduced-motion` or `MotionProvider` off. Don't add your own animation that ignores it.
|
|
691
|
+
|
|
692
|
+
## Rendering the model's output
|
|
693
|
+
|
|
694
|
+
Sable has no Markdown renderer; use one (react-markdown, or your own parser) and map its elements to Sable parts:
|
|
695
|
+
|
|
696
|
+
- the answer sits in `<Bubble variant="plain">`, which drops the surface and the measure cap; give it `max-w-full` so a wide code line scrolls inside its block rather than pushing the transcript sideways on a phone;
|
|
697
|
+
- fenced code → `CodeBlock` (copy button included; wire highlighting once with `CodeHighlightProvider`);
|
|
698
|
+
- inline code → `Code`; tables → `Table` parts; links → your router's link or a plain `<a>` with `rel="noopener noreferrer"` for outside URLs;
|
|
699
|
+
- paragraphs and lists take the prose tier (`useTypeTier().prose`) rather than a hand-picked size.
|
|
700
|
+
|
|
701
|
+
**Never render model output as HTML** (`dangerouslySetInnerHTML`, or a Markdown renderer with raw HTML on). Model output is untrusted input. Strip or escape HTML, and allow only `http(s)` and `mailto` link targets.
|
|
702
|
+
|
|
703
|
+
While an answer streams, render it with `StreamingText` (plain text); switch to the Markdown renderer when it settles, or render Markdown progressively if your renderer tolerates half-finished syntax (an unclosed fence). Either way the switch must not move the text the reader is on.
|
|
704
|
+
|
|
705
|
+
## Wiring a chat hook
|
|
706
|
+
|
|
707
|
+
The shape below fits most streaming chat hooks. The hook's names (`messages`, `status`, `send`, `stop`, `regenerate`, `error`) belong to **your library**, not Sable. The AI SDK's `useChat`, for example, exposes a `status` of `"submitted" | "streaming" | "ready" | "error"`; check the version you have installed.
|
|
708
|
+
|
|
709
|
+
```tsx
|
|
710
|
+
import {
|
|
711
|
+
Bubble, ChatComposer, Marker, Message, MessageScroller, StreamingText,
|
|
712
|
+
} from "@sableui/react"
|
|
713
|
+
|
|
714
|
+
function Chat() {
|
|
715
|
+
const { messages, status, send, stop } = useYourChat() // your library
|
|
716
|
+
const busy = status === "submitted" || status === "streaming"
|
|
717
|
+
const last = messages.at(-1)
|
|
718
|
+
return (
|
|
719
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
720
|
+
<MessageScroller className="flex-1 min-h-0 px-4 py-6">
|
|
721
|
+
{messages.map((m) => {
|
|
722
|
+
const mine = m.role === "user"
|
|
723
|
+
const live = status === "streaming" && m.id === last?.id && !mine
|
|
724
|
+
return (
|
|
725
|
+
<Message key={m.id} messageId={m.id} align={mine ? "end" : "start"}>
|
|
726
|
+
<Bubble variant={mine ? "primary" : "plain"} align={mine ? "end" : "start"} className={mine ? undefined : "max-w-full"}>
|
|
727
|
+
{mine ? m.text : <StreamingText text={m.text} streaming={live} />}
|
|
728
|
+
</Bubble>
|
|
729
|
+
</Message>
|
|
730
|
+
)
|
|
731
|
+
})}
|
|
732
|
+
{status === "submitted" ? <Marker icon="spinner" spin shimmer>Thinking…</Marker> : null}
|
|
733
|
+
</MessageScroller>
|
|
734
|
+
<ChatComposer
|
|
735
|
+
onSend={(text) => send(text)}
|
|
736
|
+
busy={busy}
|
|
737
|
+
onStop={stop}
|
|
738
|
+
footer={({ sendOn }) => (sendOn === "enter" ? "Enter to send · Shift+Enter for a new line" : null)}
|
|
739
|
+
/>
|
|
740
|
+
</div>
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
For the full screen (header, grouping, avatars, answer actions, the empty state), start from `sable pattern ChatPanel --skeleton` and replace its sample data with your hook. For a chat docked beside other work, compose the same three parts inside a `Sheet` or a panel and wrap them in `<Density value="compact">`.
|
|
746
|
+
|
|
747
|
+
## What Sable does not do
|
|
748
|
+
|
|
749
|
+
- No transport, retries or reconnection.
|
|
750
|
+
- No message store, ids, history paging or persistence; `MessageScroller` only renders what you pass.
|
|
751
|
+
- No Markdown parsing or sanitising.
|
|
752
|
+
- No mentions or slash commands in `ChatComposer` (it holds a plain string).
|
|
753
|
+
- No typing indicators or presence from other people; compose them from `Marker` and `Avatar` status if your product has them.
|
|
754
|
+
|
|
755
|
+
## Before you ship
|
|
756
|
+
|
|
757
|
+
1. `sable check src` passes.
|
|
758
|
+
2. Walk the journey (`sable docs reviewing-a-journey`) through every row of the lifecycle and failure tables: send, wait, stream, stop mid-answer, regenerate, a failed send, an error mid-stream, attachment-only send, an upload failure, and a reload that restores the thread.
|
|
759
|
+
3. Scroll up mid-stream: the view must stay put, and jump-to-latest must appear and work.
|
|
760
|
+
4. Keyboard only, then a screen reader: the finished answer is announced once; Stop is reachable from the composer; nothing announces per token.
|
|
761
|
+
5. On a phone: Return breaks the line, the send button sends, the composer stays above the on-screen keyboard (`dvh`, not `vh`), and wide code scrolls inside its block.
|
|
762
|
+
6. Reduced motion on: text still arrives, nothing keeps moving after the turn settles (`sable docs reviewing-motion`).
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
601
766
|
# Task: building a page (page register)
|
|
602
767
|
|
|
603
768
|
Marketing, docs, settings, a feed, a dashboard — anything whose primary
|
|
@@ -1065,6 +1230,7 @@ $sable inspire these references into a warm, compact shop preset
|
|
|
1065
1230
|
$sable build a ceramics shop using .sable/presets/clay.json
|
|
1066
1231
|
$sable audit our checkout and report prioritized findings
|
|
1067
1232
|
$sable polish our checkout and repair the reported issues
|
|
1233
|
+
$sable fix the settings dialog's footer buttons
|
|
1068
1234
|
```
|
|
1069
1235
|
|
|
1070
1236
|
Claude Code and Cursor use `/sable` with the same modes. `create` is a `build`
|
|
@@ -1072,7 +1238,9 @@ alias. These are agent instructions; the shell CLI serves catalog resources and
|
|
|
1072
1238
|
validates records. Other agents can read the package's `skills/sable/SKILL.md`.
|
|
1073
1239
|
|
|
1074
1240
|
`inspire` produces a preset and component preview. `audit` evaluates an existing
|
|
1075
|
-
interface and reports by default.
|
|
1241
|
+
interface and reports by default. `fix` repairs one scoped defect: it reads only the
|
|
1242
|
+
contracts the change touches and finishes with `sable check` on the changed files
|
|
1243
|
+
and a look at the affected state, escalating to `polish` when the change grows. A reference URL does not request a cloned site.
|
|
1076
1244
|
Develop alternative rendered directions when requested or when a consequential
|
|
1077
1245
|
choice needs comparison. A three-design comparison is not a mandatory build step.
|
|
1078
1246
|
|
|
@@ -1235,6 +1403,11 @@ use instead.
|
|
|
1235
1403
|
Migrating a React app that has its own styles. The order below front-loads the
|
|
1236
1404
|
decisions that invalidate later work if you get them wrong.
|
|
1237
1405
|
|
|
1406
|
+
Coming from **shadcn/ui or Radix**? Do this order, then use
|
|
1407
|
+
[migrating-from-shadcn.md](./migrating-from-shadcn.md) for step 3: it maps
|
|
1408
|
+
`asChild` → `render`, array values, renamed parts and events, the tokens and
|
|
1409
|
+
icons, and every shadcn component to its Sable counterpart.
|
|
1410
|
+
|
|
1238
1411
|
## 0. Classify every surface first
|
|
1239
1412
|
|
|
1240
1413
|
Before touching a component, **list the routes and assign a register to each** —
|
|
@@ -1379,6 +1552,8 @@ layers panel lost 174 lines by adopting `Tree`.
|
|
|
1379
1552
|
|
|
1380
1553
|
- [building-a-tool.md](./building-a-tool.md) or
|
|
1381
1554
|
[building-a-page.md](./building-a-page.md)
|
|
1555
|
+
- [migrating-from-shadcn.md](./migrating-from-shadcn.md) — the per-component map from shadcn/ui and Radix
|
|
1556
|
+
- [extending-a-component.md](./extending-a-component.md) — what to do where the app used to edit a component's source
|
|
1382
1557
|
- `DESIGN.md → Registers`
|
|
1383
1558
|
- `llms/reference/token-intent.md`
|
|
1384
1559
|
|
|
@@ -1837,6 +2012,179 @@ fine-tune the agent's underlying model or guarantee universal aesthetic quality.
|
|
|
1837
2012
|
|
|
1838
2013
|
---
|
|
1839
2014
|
|
|
2015
|
+
# Task: extending a Sable component
|
|
2016
|
+
|
|
2017
|
+
You need a component to do something it doesn't do out of the box: be a link,
|
|
2018
|
+
carry an extra control, react to a key, suggest names as someone types. Before
|
|
2019
|
+
restyling it from the outside or copying it into the app, go through the
|
|
2020
|
+
extension points below, roughly in this order. Most needs are one of them, and
|
|
2021
|
+
each keeps the component's states, density, focus and keyboard contract, which a
|
|
2022
|
+
copy loses the day it is made.
|
|
2023
|
+
|
|
2024
|
+
The split to keep in mind: **behaviour** comes from Base UI and Sable, **look**
|
|
2025
|
+
comes from variants and tokens, and **what is yours** (content, data, what an
|
|
2026
|
+
action does) goes in through props and slots.
|
|
2027
|
+
|
|
2028
|
+
## 1. Compose an element: `render`
|
|
2029
|
+
|
|
2030
|
+
Every component that renders an element takes `render`, and keeps its props,
|
|
2031
|
+
ref and state on the element you give it. This is how a Sable control becomes a
|
|
2032
|
+
link, or any router's link:
|
|
2033
|
+
|
|
2034
|
+
```tsx
|
|
2035
|
+
<Button render={<a href="/pricing" />}>See pricing</Button>
|
|
2036
|
+
<Card render={<Link to="/projects/42" />}>…</Card>
|
|
2037
|
+
<Button variant="ghost" render={<Link to="/inbox" />} iconStart={<Icon name="inbox" />}>Inbox</Button>
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
A link that looks like a button is `<Button render={<a … />}>`, never an `<a>`
|
|
2041
|
+
wearing button classes (`sable-lint` flags that as `lookalike-control`).
|
|
2042
|
+
|
|
2043
|
+
## 2. Fill a slot
|
|
2044
|
+
|
|
2045
|
+
Slots take content you own. The component lays it out and keeps it aligned with
|
|
2046
|
+
its own parts. Check `sable component <Name> --props` for the full list.
|
|
2047
|
+
|
|
2048
|
+
| Component | Slots |
|
|
2049
|
+
|---|---|
|
|
2050
|
+
| Button | `iconStart`, `iconEnd`, `badge` |
|
|
2051
|
+
| ChatComposer | `leading`, `actions`, `attachments`, `footer` (a node, or a function of the send key in effect) |
|
|
2052
|
+
| Message | `avatar`, `header`, `footer` |
|
|
2053
|
+
| PageHeader | `actions`, `back` |
|
|
2054
|
+
| MenuItem, MenuLinkItem, CaretMenuItem | `icon`, `description` |
|
|
2055
|
+
|
|
2056
|
+
`className` on a component is for layout (margins, width, alignment). It is not
|
|
2057
|
+
a slot for a new look; see section 5.
|
|
2058
|
+
|
|
2059
|
+
## 3. Hold the state, reach the element
|
|
2060
|
+
|
|
2061
|
+
Controlled props (`value` + `onValueChange`, `open` + `onOpenChange`) let the
|
|
2062
|
+
app own the state and change it from anywhere: clear a draft after a server
|
|
2063
|
+
confirms the send, open a menu from a shortcut.
|
|
2064
|
+
|
|
2065
|
+
Refs reach the element itself. ChatComposer's `fieldRef` is the textarea, which
|
|
2066
|
+
is enough to focus it, read the caret, or **listen to it**. That last one is an
|
|
2067
|
+
extension point in its own right: a key handler on the field that handles a key
|
|
2068
|
+
and calls `event.preventDefault()` owns that key, and ChatComposer does not also
|
|
2069
|
+
act on it. That is how the suggestion list below takes Enter without the message
|
|
2070
|
+
being sent.
|
|
2071
|
+
|
|
2072
|
+
## 4. Add behaviour on top: mentions and slash commands
|
|
2073
|
+
|
|
2074
|
+
The worked example. Mentions and slash commands are behaviour that sits on a
|
|
2075
|
+
text field, not a new kind of field, so Sable ships the parts every app gets
|
|
2076
|
+
wrong and leaves the rest to the app:
|
|
2077
|
+
|
|
2078
|
+
- **Sable owns:** noticing a trigger character at the caret (`@`, `/`, `#`), on
|
|
2079
|
+
a word boundary and after IME composition; placing the list at the caret;
|
|
2080
|
+
↑/↓/Enter/Tab/Escape while focus stays in the field; the ARIA that lets a
|
|
2081
|
+
screen reader follow along.
|
|
2082
|
+
- **The app owns:** the items (filter or fetch them, debounced, from the query),
|
|
2083
|
+
the loading and empty states, and what choosing one does.
|
|
2084
|
+
|
|
2085
|
+
```tsx
|
|
2086
|
+
const trigger = useCaretTrigger({
|
|
2087
|
+
triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }],
|
|
2088
|
+
})
|
|
2089
|
+
const q = trigger.match?.query.toLowerCase() ?? ""
|
|
2090
|
+
const commands = trigger.match?.trigger.char === "/"
|
|
2091
|
+
|
|
2092
|
+
<ChatComposer fieldRef={trigger.fieldRef} onSend={send} />
|
|
2093
|
+
<CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={loading ? "Searching…" : undefined}>
|
|
2094
|
+
{commands
|
|
2095
|
+
? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (
|
|
2096
|
+
<CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>
|
|
2097
|
+
/{c.name}
|
|
2098
|
+
</CaretMenuItem>
|
|
2099
|
+
))
|
|
2100
|
+
: people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (
|
|
2101
|
+
<CaretMenuItem key={p.id} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>
|
|
2102
|
+
))}
|
|
2103
|
+
</CaretMenu>
|
|
2104
|
+
```
|
|
2105
|
+
|
|
2106
|
+
A mention is inserted as text with a trailing space; a command runs and
|
|
2107
|
+
`replace("")` clears its `/query`. The same hook works on `Textarea` and
|
|
2108
|
+
`Input` through `ref={trigger.fieldRef}`. `sable component CaretMenu` has the
|
|
2109
|
+
props and the accessibility notes.
|
|
2110
|
+
|
|
2111
|
+
The pattern generalises. To add your own behaviour to a Sable field, attach to
|
|
2112
|
+
it through its ref, read and write through the field's own input path (the hook's
|
|
2113
|
+
`replace()` does, so controlled values and undo keep working), and take a key by
|
|
2114
|
+
handling it first and calling `preventDefault()`.
|
|
2115
|
+
|
|
2116
|
+
### Mentions as chips are a different job
|
|
2117
|
+
|
|
2118
|
+
`useCaretTrigger` keeps the value a plain string, because that is what a
|
|
2119
|
+
textarea holds. Showing a mention as a token *inside the field* needs a
|
|
2120
|
+
rich-text (contenteditable) editor such as TipTap or Lexical, with its own value
|
|
2121
|
+
model. Sable does not own an editor. Two ways to get chips today:
|
|
2122
|
+
|
|
2123
|
+
- Keep text mentions in the field and render them as chips where the message is
|
|
2124
|
+
shown: parse `@Name` against your people list when you render the Message.
|
|
2125
|
+
- Use an editor in place of the composer's field and keep Sable's controls around
|
|
2126
|
+
it. A ChatComposer slot for a custom editor is a possible later addition; it
|
|
2127
|
+
doesn't exist yet, so don't write code that assumes it.
|
|
2128
|
+
|
|
2129
|
+
## 5. Change the look: tokens and presets, not overrides
|
|
2130
|
+
|
|
2131
|
+
A component's fill, edge, corners, ink and interaction states come from its
|
|
2132
|
+
variants and the theme's tokens. To change how it looks:
|
|
2133
|
+
|
|
2134
|
+
- **Everywhere:** override tokens on `:root` (or any subtree) after Sable's CSS:
|
|
2135
|
+
radius, density, accent, type scale, motion. `sable token --list` lists them.
|
|
2136
|
+
- **As a reusable direction:** a preset (`sable docs creating-a-preset`), compiled
|
|
2137
|
+
to scoped CSS with `sable preset --css <path>`.
|
|
2138
|
+
- **One component, a look it lacks:** that is a missing variant. Report it (see
|
|
2139
|
+
section 8); don't paint it on with `className`. `component-restyle`,
|
|
2140
|
+
`destructive-item-ink` and `table-edge-padding` flag the common overrides,
|
|
2141
|
+
because a class that repaints a part fights the part's own hover, focus and
|
|
2142
|
+
disabled states.
|
|
2143
|
+
|
|
2144
|
+
Parts carry `data-slot` names (`chat-composer-attachments`,
|
|
2145
|
+
`menu-item-description`, `button-group`). They are good test selectors and fine
|
|
2146
|
+
for layout from a parent. They are not a way around the rule above, and a slot
|
|
2147
|
+
name a component's docs don't mention may change between versions.
|
|
2148
|
+
|
|
2149
|
+
## 6. Add icons: `composeIconSets`
|
|
2150
|
+
|
|
2151
|
+
`<IconProvider set={composeIconSets(appExtras, reiconIconSet)}>` adds your own
|
|
2152
|
+
glyphs alongside Reicon's (list the added names in `.sable/icons.json` so
|
|
2153
|
+
`sable grade` accepts them). Components keep using semantic names.
|
|
2154
|
+
|
|
2155
|
+
## 7. Fix a defect: `sable patch`
|
|
2156
|
+
|
|
2157
|
+
When a component is wrong, not merely missing something, patch the installed
|
|
2158
|
+
module and record why: `sable patch <Component> --reason … --repro …`. Doctor
|
|
2159
|
+
re-checks it on every upgrade. `sable docs patching-a-component` has the steps.
|
|
2160
|
+
|
|
2161
|
+
## 8. App-owned, or a library gap?
|
|
2162
|
+
|
|
2163
|
+
It belongs in the app when it is about **your** product: domain data, copy,
|
|
2164
|
+
business rules, which items a list offers, what an action does, the layout of a
|
|
2165
|
+
signature surface.
|
|
2166
|
+
|
|
2167
|
+
It is a library gap when **another app would need the same thing** and none of
|
|
2168
|
+
the sections above gets there without fighting the component: a missing
|
|
2169
|
+
variant, slot, prop, or piece of behaviour. Then:
|
|
2170
|
+
|
|
2171
|
+
1. Show the search: `sable search "<need>"` and what came back.
|
|
2172
|
+
2. Name the missing capability and the smallest API that would cover it.
|
|
2173
|
+
3. Meanwhile, compose the nearest thing from Sable's parts in the app (not a
|
|
2174
|
+
copy of the component), and say in your summary that it is a stand-in for a
|
|
2175
|
+
reported gap.
|
|
2176
|
+
|
|
2177
|
+
In the Sable repository itself, close the gap in the package and then use it
|
|
2178
|
+
(`sable docs adding-a-component`).
|
|
2179
|
+
|
|
2180
|
+
## Read next
|
|
2181
|
+
|
|
2182
|
+
- [patching-a-component.md](./patching-a-component.md): the patch workflow
|
|
2183
|
+
- [creating-a-preset.md](./creating-a-preset.md): a reusable look
|
|
2184
|
+
- [building-a-chat.md](./building-a-chat.md): the chat parts and who owns what
|
|
2185
|
+
|
|
2186
|
+
---
|
|
2187
|
+
|
|
1840
2188
|
# Get started with Sable
|
|
1841
2189
|
|
|
1842
2190
|
Sable is on npm as `@sableui/react`. The installed version's `registry.json`
|
|
@@ -1849,13 +2197,27 @@ TypeScript on Vite, TanStack Router, TanStack Query, TanStack Form for forms wit
|
|
|
1849
2197
|
validation logic, and TanStack Table once a table outgrows `DataTable`
|
|
1850
2198
|
(`sable docs tables-with-tanstack`). An existing app keeps its own.
|
|
1851
2199
|
|
|
1852
|
-
## Install
|
|
2200
|
+
## Install and wire it
|
|
1853
2201
|
|
|
1854
2202
|
```bash
|
|
1855
2203
|
pnpm add @sableui/react
|
|
2204
|
+
pnpm exec sable init --dry-run # every change it would make
|
|
2205
|
+
pnpm exec sable init
|
|
1856
2206
|
```
|
|
1857
2207
|
|
|
1858
|
-
|
|
2208
|
+
`sable init` reads the package manager from your lockfile and the framework
|
|
2209
|
+
from the project. It installs what is missing (React, Tailwind and its build
|
|
2210
|
+
plugin), writes the CSS below into your global stylesheet in order, adds React
|
|
2211
|
+
dedupe and the Tailwind plugin to a Vite config (a PostCSS config for Next.js),
|
|
2212
|
+
loads Figtree and DM Mono, wraps a Vite starter's `<App />` in `IconProvider`,
|
|
2213
|
+
installs the agent skill, and finishes with `sable doctor`. Edits it cannot make
|
|
2214
|
+
safely, such as a Next.js root layout, it prints with the file they belong in.
|
|
2215
|
+
Vite and Next.js get every step; other setups get the packages and stylesheet.
|
|
2216
|
+
Once an app is wired, `sable doctor --fix` applies the same fixes to whatever
|
|
2217
|
+
doctor flags. Run `sable init` with the package installed: before that, `npx
|
|
2218
|
+
sable` names an unrelated package.
|
|
2219
|
+
|
|
2220
|
+
## Or wire it by hand
|
|
1859
2221
|
|
|
1860
2222
|
For `src/styles.css` with `node_modules` at the project root:
|
|
1861
2223
|
|
|
@@ -1882,11 +2244,15 @@ export default function App() {
|
|
|
1882
2244
|
}
|
|
1883
2245
|
```
|
|
1884
2246
|
|
|
1885
|
-
|
|
1886
|
-
|
|
2247
|
+
Mount `<IconProvider set={reiconIconSet}>` (from `@sableui/react/icons/reicon`)
|
|
2248
|
+
once at the root: without it every icon, including those inside Sable's
|
|
2249
|
+
components, renders nothing, and `sable doctor` fails. Optional theme and motion
|
|
2250
|
+
providers are explained in the package README.
|
|
1887
2251
|
|
|
1888
2252
|
## Connect your agent and verify
|
|
1889
2253
|
|
|
2254
|
+
`sable init` already ran these; by hand:
|
|
2255
|
+
|
|
1890
2256
|
```bash
|
|
1891
2257
|
pnpm exec sable setup --agent --dry-run
|
|
1892
2258
|
pnpm exec sable setup --agent
|
|
@@ -1993,6 +2359,360 @@ Only label a record human when it reflects an actual human's stated judgment.
|
|
|
1993
2359
|
|
|
1994
2360
|
---
|
|
1995
2361
|
|
|
2362
|
+
# Task: migrating from shadcn/ui (and Radix) to Sable
|
|
2363
|
+
|
|
2364
|
+
An app built on shadcn/ui owns a `components/ui/` folder of copied Radix
|
|
2365
|
+
wrappers. Sable replaces that folder with one installed package. The visible
|
|
2366
|
+
API is close: Sable's semantic tokens share shadcn's names, and most
|
|
2367
|
+
components keep their part names. The behaviour layer is different. Sable is
|
|
2368
|
+
built on **Base UI**, not Radix, so composition, values and events follow Base
|
|
2369
|
+
UI. This guide maps one to the other. Every Sable name below is in the
|
|
2370
|
+
installed contract: confirm any of them with `sable component <Name>`.
|
|
2371
|
+
|
|
2372
|
+
Do the app-level work in [converting-an-app.md](./converting-an-app.md) first
|
|
2373
|
+
(registers, wiring, deleting your tokens). This guide covers the component
|
|
2374
|
+
swap that follows.
|
|
2375
|
+
|
|
2376
|
+
## 0. What goes away
|
|
2377
|
+
|
|
2378
|
+
- **`components/ui/*` and `components.json`.** Delete the folder once nothing
|
|
2379
|
+
imports it. Every component comes from `@sableui/react` (layout primitives
|
|
2380
|
+
too; blocks and patterns from `@sableui/react/blocks` and `/patterns`).
|
|
2381
|
+
Don't copy a Sable component into the app to edit it. Use a variant, a
|
|
2382
|
+
preset, or [patching-a-component.md](./patching-a-component.md).
|
|
2383
|
+
- **`@radix-ui/*`, `cmdk`, `vaul`, `sonner`, `input-otp`, `next-themes`,
|
|
2384
|
+
`tw-animate-css`, `class-variance-authority` (unless the app's own code uses
|
|
2385
|
+
it) and `lucide-react`.** Each has a Sable counterpart below.
|
|
2386
|
+
- **Your `globals.css` token block.** Sable's tokens carry the same names
|
|
2387
|
+
(`--background`, `--card`, `--primary`…) and are defined by the theme. A
|
|
2388
|
+
copied shadcn `:root { … }` block overrides them with values the theme
|
|
2389
|
+
never tested. Delete it, and move brand colour into a preset
|
|
2390
|
+
(`sable docs using-presets`).
|
|
2391
|
+
|
|
2392
|
+
`cn()` survives: Sable exports it (`import { cn } from "@sableui/react"`), with
|
|
2393
|
+
the paired typography utilities registered in its merge groups.
|
|
2394
|
+
|
|
2395
|
+
## 1. The patterns that change everywhere
|
|
2396
|
+
|
|
2397
|
+
### `asChild` → `render`
|
|
2398
|
+
|
|
2399
|
+
Radix composes by cloning a child. Base UI takes the element as a prop:
|
|
2400
|
+
|
|
2401
|
+
```tsx
|
|
2402
|
+
// shadcn
|
|
2403
|
+
<DialogTrigger asChild><Button variant="outline">Edit</Button></DialogTrigger>
|
|
2404
|
+
|
|
2405
|
+
// Sable
|
|
2406
|
+
<DialogTrigger render={<Button variant="outline">Edit</Button>} />
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
It applies to every trigger and close part (`DialogTrigger`, `DialogClose`,
|
|
2410
|
+
`SheetTrigger`, `MenuTrigger`, `PopoverTrigger`, `TooltipTrigger`,
|
|
2411
|
+
`CollapsibleTrigger`, `ContextCardTrigger`…). The same goes for links: a
|
|
2412
|
+
`Button` that navigates is `<Button render={<Link to="/x" />}>Open</Button>`.
|
|
2413
|
+
`SidebarItem`, `SidebarBrand`, `Card` and `Separator` take `render` too.
|
|
2414
|
+
|
|
2415
|
+
### Part names
|
|
2416
|
+
|
|
2417
|
+
| shadcn / Radix | Sable |
|
|
2418
|
+
|---|---|
|
|
2419
|
+
| `DropdownMenu*` | `Menu*`: `Menu`, `MenuTrigger`, `MenuContent`, `MenuItem` |
|
|
2420
|
+
| `DropdownMenuLabel` | `MenuGroupLabel`, **inside a `MenuGroup`** (Base UI requires the group) |
|
|
2421
|
+
| `DropdownMenuSub` / `SubTrigger` / `SubContent` | `MenuSubmenu` / `MenuSubmenuTrigger` / a nested `MenuContent side="inline-end"` |
|
|
2422
|
+
| `*Content` on Accordion, Collapsible | `AccordionPanel`, `CollapsiblePanel` |
|
|
2423
|
+
| `TabsContent` | `TabsPanel` (`TabsContent` is exported as an alias) |
|
|
2424
|
+
| `HoverCard*` | `ContextCard*` |
|
|
2425
|
+
| `Drawer*` (vaul) | `Sheet*` with `side="bottom"` |
|
|
2426
|
+
|
|
2427
|
+
Dialog, AlertDialog, Popover, Tooltip, Select, ContextMenu, Sheet and Combobox
|
|
2428
|
+
keep `*Content`.
|
|
2429
|
+
|
|
2430
|
+
### Values are arrays where Base UI allows several
|
|
2431
|
+
|
|
2432
|
+
| shadcn / Radix | Sable |
|
|
2433
|
+
|---|---|
|
|
2434
|
+
| `<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` |
|
|
2435
|
+
| `<Accordion type="multiple">` | `<Accordion multiple>` |
|
|
2436
|
+
| `<ToggleGroup type="single" value="a">` | `<ToggleGroup value={["a"]}>`. Always an array; `multiple` allows several |
|
|
2437
|
+
| `<Slider defaultValue={[50]}>` | `<Slider defaultValue={50}>`. A single thumb is a number, a range is `[20, 80]` |
|
|
2438
|
+
| `<Checkbox checked="indeterminate">` | `<Checkbox indeterminate>`. `checked` is only ever a boolean |
|
|
2439
|
+
|
|
2440
|
+
### Events
|
|
2441
|
+
|
|
2442
|
+
- **Menu rows:** `onSelect` → `onClick`. To keep the menu open after a click
|
|
2443
|
+
(Radix: `event.preventDefault()` in `onSelect`), set `closeOnClick={false}`.
|
|
2444
|
+
- **Change handlers take a second argument.** `onValueChange`,
|
|
2445
|
+
`onCheckedChange` and `onOpenChange` receive `(value, eventDetails)`. The
|
|
2446
|
+
first argument is what Radix passed, so most handlers work unchanged.
|
|
2447
|
+
- **Dismissal hooks:** `onEscapeKeyDown` / `onPointerDownOutside` /
|
|
2448
|
+
`onInteractOutside` → one `onOpenChange` on the root. Read
|
|
2449
|
+
`eventDetails.reason` (`"escape-key"`, `"outside-press"`…) and call
|
|
2450
|
+
`eventDetails.cancel()` to keep it open.
|
|
2451
|
+
- **`forceMount`** → `keepMounted` (on `AccordionPanel`, `CollapsiblePanel` and
|
|
2452
|
+
portals).
|
|
2453
|
+
- **State styling:** `data-[state=open]:` → Sable's state variants:
|
|
2454
|
+
`sable-open:`, `sable-selected:`, `sable-highlighted:`, `sable-disabled:`
|
|
2455
|
+
and `sable-invalid:` (`sable utility sable-open:`). Base UI writes
|
|
2456
|
+
`data-open`, `data-checked`, `data-highlighted` and `data-pressed`, not
|
|
2457
|
+
`data-state`.
|
|
2458
|
+
|
|
2459
|
+
### Variants, not overrides
|
|
2460
|
+
|
|
2461
|
+
shadcn invites restyling through `className`. Sable does not: a
|
|
2462
|
+
`bg-*`/`border*`/`rounded*`/`ring-*`/`shadow-*` class on `Button`, `Pill` or
|
|
2463
|
+
`Badge` fails `component-restyle`. Layout classes (`w-full`, `ms-auto`, grid
|
|
2464
|
+
placement) are fine. If no variant gives you the look, the component
|
|
2465
|
+
needs one, and that is a library change; `extending-a-component.md` has the
|
|
2466
|
+
ways that don't need one (`render`, slots, refs, tokens). Map the variants instead:
|
|
2467
|
+
|
|
2468
|
+
| shadcn `Button` | Sable `Button` |
|
|
2469
|
+
|---|---|
|
|
2470
|
+
| `variant="default"` | `variant="primary"` (the default) |
|
|
2471
|
+
| `secondary` / `outline` / `ghost` / `destructive` / `link` | same names |
|
|
2472
|
+
| `size="default"` / `sm` / `lg` | `size="md"` (default) / `sm` / `cta` |
|
|
2473
|
+
| `size="icon"` / `icon-sm` / `icon-lg` | `icon` / `icon-sm` / `icon` (add `icon-xs` in tool chrome) |
|
|
2474
|
+
| a hand-placed `<Loader2 className="animate-spin" />` | `loading` (keeps the label; `loadingLabel` to change it) |
|
|
2475
|
+
| `<Icon /> Label` children | `iconStart={<Icon name="plus" />}` / `iconEnd` |
|
|
2476
|
+
|
|
2477
|
+
`buttonVariants` is exported, as in shadcn, for the rare non-`Button` element
|
|
2478
|
+
that must look like one. Prefer `render`.
|
|
2479
|
+
|
|
2480
|
+
### Classes shadcn's source teaches that Sable's lint rejects
|
|
2481
|
+
|
|
2482
|
+
Copied shadcn markup carries these idioms. `sable-lint` names each one:
|
|
2483
|
+
|
|
2484
|
+
| shadcn idiom | Sable | rule |
|
|
2485
|
+
|---|---|---|
|
|
2486
|
+
| `text-sm font-medium` | a paired tier: `text-label-md`, `text-body-sm`, `text-heading-3`… (`sable token typography`) | `decomposed-type` |
|
|
2487
|
+
| `hover:bg-accent hover:text-accent-foreground` | a component, or `sable-surface-interactive` | `surface-hover` |
|
|
2488
|
+
| `transition-colors`, `transition-all`, `transition-[color,box-shadow]` | `motion-interactive` | `transition-colors`, `transition-all` |
|
|
2489
|
+
| `animate-in fade-in-0 zoom-in-95 data-[state=open]:…` | `motion-pop` / `motion-fade` / `motion-slide-*` | n/a (tw-animate is gone) |
|
|
2490
|
+
| `animate-spin`, `animate-pulse` | `motion-spin`, `motion-pulse`, or `Spinner` / `Skeleton` | `builtin-animate` |
|
|
2491
|
+
| `ring-ring/50`, `focus-visible:ring-[3px]` | the component's own focus ring; `ring-ring` at full strength if you must | `faded-focus-ring` |
|
|
2492
|
+
| `bg-black/50` scrim | `bg-overlay` (Dialog and Sheet draw their own) | `raw-black-white` |
|
|
2493
|
+
| `z-50` | `z-(--z-overlay)` and siblings | `raw-z-index` |
|
|
2494
|
+
| `text-muted-foreground/70` | `text-muted-foreground` | `text-opacity` |
|
|
2495
|
+
| `overflow-y-auto` alone | add `scrollbar-quiet`, or use `Scroller` | `unmanaged-overflow` |
|
|
2496
|
+
| `bg-zinc-900`, `text-slate-500` | a semantic token, or a generated family (`bg-blue-100`) for categorical colour | `palette-ref` |
|
|
2497
|
+
| `<div className="border-input rounded-md …">` dressed as a field | `Input` / `Textarea` / `SearchInput` | `lookalike-control` |
|
|
2498
|
+
|
|
2499
|
+
## 2. Tokens
|
|
2500
|
+
|
|
2501
|
+
The names carry over. Most shadcn utilities (`bg-background`, `text-foreground`,
|
|
2502
|
+
`bg-card`, `bg-popover`, `bg-muted`, `text-muted-foreground`, `bg-primary`,
|
|
2503
|
+
`bg-secondary`, `bg-accent`, `bg-destructive`, `border-border`, `border-input`,
|
|
2504
|
+
`ring-ring`, `bg-sidebar*`) compile unchanged against Sable. Sable is stricter
|
|
2505
|
+
about **role**, so check each use against its intent (`sable token <name>`):
|
|
2506
|
+
|
|
2507
|
+
| shadcn var | Sable | note |
|
|
2508
|
+
|---|---|---|
|
|
2509
|
+
| `--background`, `--foreground` | same | the page ground and ink |
|
|
2510
|
+
| `--card`, `--popover` | same | `popover` is for anything floating, including persistent inspectors |
|
|
2511
|
+
| `--muted` | same | recessed and **inert**; not a hover fill |
|
|
2512
|
+
| `--secondary` | same | a pressable low-emphasis fill; not a surface |
|
|
2513
|
+
| `--accent` | same | the transient **state** layer (hover, current), applied by components; don't paint it by hand |
|
|
2514
|
+
| `--border` | `border` or `border-strong` | `border-strong` when the line carries structure (a table rule, a section divider) |
|
|
2515
|
+
| `--input` | `input` (fields), `control-border` (checkbox, radio, switch) | `control-border` is held to 3:1 |
|
|
2516
|
+
| `--ring` | same | focus only, never selection |
|
|
2517
|
+
| `--destructive` | `destructive` (fill), `destructive-text` (ink on a surface) | likewise `success`/`success-text`, `tone-warning`/`tone-warning-text`, `tone-info`/`tone-info-text` |
|
|
2518
|
+
| `--chart-1`…`--chart-5` | `--chart-1`…`--chart-8`, set by the theme | address series by `tone` on the chart, not by variable |
|
|
2519
|
+
| `--sidebar-*` | same names | |
|
|
2520
|
+
| `--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`) |
|
|
2521
|
+
|
|
2522
|
+
New to a shadcn app: `tone-1`…`tone-3` (category colour), `highlight` (marked
|
|
2523
|
+
content), `overlay` (scrims), and the generated palette families. Fonts:
|
|
2524
|
+
Sable's `--font-sans` / `--font-mono` default to Figtree and DM Mono, loaded
|
|
2525
|
+
by the host (see **Consuming** in `llms.txt`).
|
|
2526
|
+
|
|
2527
|
+
Dark mode: `next-themes` → `SableThemeProvider` (`defaultPreference`,
|
|
2528
|
+
`useSableTheme()`). It writes the same `.dark` class on `<html>`.
|
|
2529
|
+
|
|
2530
|
+
## 3. Icons
|
|
2531
|
+
|
|
2532
|
+
`lucide-react` imports → `<Icon name="…" />`, rendered by the set in
|
|
2533
|
+
`<IconProvider set={reiconIconSet}>` (`@sableui/react/icons/reicon`).
|
|
2534
|
+
Without a provider, every icon renders nothing, including the ones inside
|
|
2535
|
+
Sable's components. Names are semantic:
|
|
2536
|
+
|
|
2537
|
+
| lucide | Sable | lucide | Sable |
|
|
2538
|
+
|---|---|---|---|
|
|
2539
|
+
| `X` | `xmark` | `MoreHorizontal` / `Ellipsis` | `ellipsis` |
|
|
2540
|
+
| `Check` | `check` | `ChevronDown` / `Up` / `Left` / `Right` | `chevron-down` … |
|
|
2541
|
+
| `Search` | `search` | `Plus` / `Minus` | `plus` / `minus` |
|
|
2542
|
+
| `Settings` | `gear` | `Trash2` | `trash` |
|
|
2543
|
+
| `Pencil` | `edit` | `Copy` | `copy` |
|
|
2544
|
+
| `Loader2` | `spinner` (or `Spinner`) | `AlertTriangle` | `warning` |
|
|
2545
|
+
| `Info` | `info` | `CircleCheck` | `circle-check` |
|
|
2546
|
+
| `Mail` | `mail` | `Menu` | `bars` |
|
|
2547
|
+
| `ExternalLink` | `external` | `LogOut` | `logout` |
|
|
2548
|
+
|
|
2549
|
+
The full contract is 84 names (`iconContract.names` in `registry.json`). An app
|
|
2550
|
+
icon outside it goes in a set of your own composed over Reicon
|
|
2551
|
+
(`composeIconSets(appIcons, reiconIconSet)`) and is listed in
|
|
2552
|
+
`.sable/icons.json`. Keeping lucide as that set is allowed; use `<Icon>` at the
|
|
2553
|
+
call sites either way.
|
|
2554
|
+
|
|
2555
|
+
## 4. Component by component
|
|
2556
|
+
|
|
2557
|
+
`—` means Sable has no counterpart. The note says what to compose instead.
|
|
2558
|
+
|
|
2559
|
+
| shadcn | Sable | what changes |
|
|
2560
|
+
|---|---|---|
|
|
2561
|
+
| accordion | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionPanel` | array values; no `type` or `collapsible` (§1) |
|
|
2562
|
+
| alert | `Note` (`tone`, `title`, `icon`, `action`) | `variant="destructive"` → `tone="destructive"`. A page-wide bar is `Banner`; a one-line status is `InlineStatus` |
|
|
2563
|
+
| alert-dialog | `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogClose` | `AlertDialogAction` / `AlertDialogCancel` → `AlertDialogClose render={<Button …/>}`. For a delete confirmation use `DestructiveActionModal` (`onConfirm`, `pending`, `confirmText`) |
|
|
2564
|
+
| aspect-ratio | `AspectBox` (`ratio`) | layout primitive |
|
|
2565
|
+
| avatar | `Avatar` (`src`, `fallback`, `size`, `status`), or `AvatarRoot` / `AvatarImage` / `AvatarFallback` | the one-element form covers most uses; `AvatarGroup` for stacks |
|
|
2566
|
+
| badge | `Badge` (`appearance` × `tone`) | `default` → `appearance="solid"`; `secondary` → `soft`; `outline` → `outline`; `destructive` → `tone="destructive"` |
|
|
2567
|
+
| breadcrumb | `Breadcrumbs`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` | no `BreadcrumbList` (the root renders the `<ol>`); no `BreadcrumbEllipsis` |
|
|
2568
|
+
| button | `Button` | §1 table |
|
|
2569
|
+
| calendar | `Calendar` | same react-day-picker v9 (`mode`, `selected`, `onSelect`); Sable styles it, so drop `classNames` overrides |
|
|
2570
|
+
| card | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` | same parts, plus `CardList` for rows and `density="compact"` |
|
|
2571
|
+
| carousel | `Carousel` (`slidesToShow`, `loop`, `autoplay`, `index`) | one component, no Embla parts |
|
|
2572
|
+
| checkbox | `Checkbox` | `indeterminate` prop (§1); wrap in `Field label orientation="horizontal"` for a label |
|
|
2573
|
+
| collapsible | `Collapsible`, `CollapsibleTrigger`, `CollapsiblePanel` | `CollapsibleIndicator` for the chevron |
|
|
2574
|
+
| 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 |
|
|
2575
|
+
| 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 |
|
|
2576
|
+
| context-menu | `ContextMenu`, `ContextMenuTrigger`, `ContextMenuContent`, `ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuGroup`, `ContextMenuGroupLabel` | `onSelect` → `onClick`; destructive rows use `variant="destructive"` |
|
|
2577
|
+
| data-table | `DataTable` (`columns`, `data`, `rowKey`, `defaultSort`, selection) | built-in sorting and selection; for TanStack features see `sable docs tables-with-tanstack` |
|
|
2578
|
+
| dialog | `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose` | **`showCloseButton` defaults to false** (true in shadcn); `size="fullscreen"` for a media viewer |
|
|
2579
|
+
| 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 |
|
|
2580
|
+
| 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` |
|
|
2581
|
+
| empty | `EmptyState` (`icon`, `title`, `description`, `action`) | |
|
|
2582
|
+
| field / form (react-hook-form) | `Form`, `Field` (`label`, `description`, `error`, `required`, `name`, `trailing`) | §5 |
|
|
2583
|
+
| hover-card | `ContextCard`, `ContextCardTrigger`, `ContextCardContent` | |
|
|
2584
|
+
| input | `Input` | `aria-invalid` drives the error state; `SearchInput` for search; `NumberField` for numbers |
|
|
2585
|
+
| 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` |
|
|
2586
|
+
| input-otp | `OtpField` (`length`, `groupSize`, `onValueComplete`) | one component: no `InputOTPGroup` / `InputOTPSlot` / `InputOTPSeparator`; `groupSize={3}` draws the split |
|
|
2587
|
+
| item | `Entity` (`leading`, `title`, `subtitle`, `action`, `interactive`) | |
|
|
2588
|
+
| kbd | `Kbd`, `KbdGroup` | `mod` renders ⌘ or Ctrl per platform |
|
|
2589
|
+
| label | `Label`, or better `Field label` | `Field` wires `htmlFor`, `aria-describedby` and `aria-invalid` for you |
|
|
2590
|
+
| 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` |
|
|
2591
|
+
| 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 |
|
|
2592
|
+
| 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 |
|
|
2593
|
+
| popover | `Popover`, `PopoverTrigger`, `PopoverContent` (`side`, `align`, `sideOffset`), `PopoverTitle`, `PopoverDescription`, `PopoverClose` | `PopoverAnchor` → `anchor` on `PopoverContent` (an element or ref) |
|
|
2594
|
+
| progress | `Progress` (`value`, `null` for indeterminate) | a level within a range (storage, quota) is `Meter`, not Progress |
|
|
2595
|
+
| radio-group | `RadioGroup`, `Radio` | `RadioGroupItem` → `Radio`; `RadioCard` for card options |
|
|
2596
|
+
| resizable | `PanelGroup`, `Panel`, `PanelResizer`, `usePanelWidth` | pixel widths with persisted bounds, for tool chrome; no percentage layout. A non-resizable two-pane split is `SplitPane` |
|
|
2597
|
+
| scroll-area | `Scroller` (edge fades), or `scrollbar-quiet` on any overflow element | |
|
|
2598
|
+
| 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` |
|
|
2599
|
+
| separator | `Separator` (`orientation`, `label`) | |
|
|
2600
|
+
| sheet | `Sheet` parts as above, `side` on `SheetContent` (default `"right"`) | `SheetList` for row content |
|
|
2601
|
+
| 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) |
|
|
2602
|
+
| skeleton | `Skeleton` | same; shape with utilities |
|
|
2603
|
+
| slider | `Slider` | number vs array (§1) |
|
|
2604
|
+
| 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 |
|
|
2605
|
+
| spinner | `Spinner` (`size`, `appearance="ring" \| "dots"`, `label`) | |
|
|
2606
|
+
| switch | `Switch` | same `checked` / `onCheckedChange` |
|
|
2607
|
+
| 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 |
|
|
2608
|
+
| tabs | `Tabs`, `TabsList` (`variant="underline" \| "pill"`), `TabsTrigger`, `TabsPanel` | `TabsContent` still works as an alias |
|
|
2609
|
+
| textarea | `Textarea` (`autoResize`) | |
|
|
2610
|
+
| toggle | `Toggle` (`pressed`, `onPressedChange`) | |
|
|
2611
|
+
| toggle-group | `ToggleGroup` (`appearance="segmented"` for a segmented control), `Toggle` / `ToggleGroupItem` | array value (§1) |
|
|
2612
|
+
| tooltip | `TooltipProvider`, `Tooltip`, `TooltipTrigger`, `TooltipContent` (`side`), or `SimpleTooltip` | `delayDuration` → `delay` on `TooltipProvider` |
|
|
2613
|
+
| 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 }]` |
|
|
2614
|
+
| 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` |
|
|
2615
|
+
| native-select | — | use `Select` in place of a native one |
|
|
2616
|
+
|
|
2617
|
+
Before and after for the non-obvious cases:
|
|
2618
|
+
|
|
2619
|
+
```tsx
|
|
2620
|
+
// dropdown-menu
|
|
2621
|
+
<DropdownMenu>
|
|
2622
|
+
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal /></Button></DropdownMenuTrigger>
|
|
2623
|
+
<DropdownMenuContent align="end">
|
|
2624
|
+
<DropdownMenuLabel>Post</DropdownMenuLabel>
|
|
2625
|
+
<DropdownMenuItem onSelect={edit}>Edit</DropdownMenuItem>
|
|
2626
|
+
<DropdownMenuItem className="text-destructive" onSelect={remove}>Delete</DropdownMenuItem>
|
|
2627
|
+
</DropdownMenuContent>
|
|
2628
|
+
</DropdownMenu>
|
|
2629
|
+
|
|
2630
|
+
<OverflowMenu label="Post actions">
|
|
2631
|
+
<MenuGroup>
|
|
2632
|
+
<MenuGroupLabel>Post</MenuGroupLabel>
|
|
2633
|
+
<MenuItem onClick={edit}>Edit</MenuItem>
|
|
2634
|
+
<MenuItem variant="destructive" onClick={remove}>Delete</MenuItem>
|
|
2635
|
+
</MenuGroup>
|
|
2636
|
+
</OverflowMenu>
|
|
2637
|
+
```
|
|
2638
|
+
|
|
2639
|
+
```tsx
|
|
2640
|
+
// chart
|
|
2641
|
+
<ChartContainer config={{ revenue: { label: "Revenue", color: "var(--chart-1)" } }}>
|
|
2642
|
+
<AreaChart data={data}><XAxis dataKey="month" /><Area dataKey="revenue" /></AreaChart>
|
|
2643
|
+
</ChartContainer>
|
|
2644
|
+
|
|
2645
|
+
<AreaChart label="Revenue" data={data} index="month" series={[{ key: "revenue", label: "Revenue", tone: "1" }]} />
|
|
2646
|
+
```
|
|
2647
|
+
|
|
2648
|
+
## 5. Forms
|
|
2649
|
+
|
|
2650
|
+
shadcn's `Form` wraps react-hook-form with `FormField` / `FormItem` /
|
|
2651
|
+
`FormLabel` / `FormControl` / `FormMessage`. Sable's `Form` + `Field` is
|
|
2652
|
+
library-agnostic and does that wiring itself:
|
|
2653
|
+
|
|
2654
|
+
```tsx
|
|
2655
|
+
<Form onFormSubmit={save} errors={serverErrors}>
|
|
2656
|
+
<Field name="email" label="Email" required messages={{ valueMissing: "Enter your email." }}>
|
|
2657
|
+
<Input type="email" autoComplete="email" spellCheck={false} />
|
|
2658
|
+
</Field>
|
|
2659
|
+
<Button type="submit" loading={saving}>Save</Button>
|
|
2660
|
+
</Form>
|
|
2661
|
+
```
|
|
2662
|
+
|
|
2663
|
+
- `FormLabel` + `FormControl` + `FormDescription` + `FormMessage` → one
|
|
2664
|
+
`Field` (`label`, `description`, `error`). It wires `htmlFor`,
|
|
2665
|
+
`aria-describedby` and `aria-invalid`.
|
|
2666
|
+
- **Keeping react-hook-form** is fine: register the control as usual and pass
|
|
2667
|
+
`error={errors.email?.message}` to its `Field`. Use `<Form noValidate>` so
|
|
2668
|
+
your resolver owns validation. Don't also mark that `Field` `required`
|
|
2669
|
+
with an `error` inside a validating `Form` (`form-required-error`).
|
|
2670
|
+
- Server errors go in `Form errors={{ email: "Taken" }}`, keyed by `Field name`.
|
|
2671
|
+
Each clears when the user edits that field.
|
|
2672
|
+
- Follow the form submit contract in `AGENTS.md`: submit stays enabled until
|
|
2673
|
+
the request starts; focus the first invalid field (`Form` does);
|
|
2674
|
+
`loading` keeps the label.
|
|
2675
|
+
|
|
2676
|
+
## 6. What Sable doesn't have yet
|
|
2677
|
+
|
|
2678
|
+
Every shadcn component in the map above now has a Sable counterpart. For
|
|
2679
|
+
anything else shadcn's ecosystem offers, compose it from Sable's parts rather
|
|
2680
|
+
than porting the shadcn file back in. If a missing piece blocks the app, say so
|
|
2681
|
+
upstream. A local workaround is a
|
|
2682
|
+
`sable patch`, not a copy.
|
|
2683
|
+
|
|
2684
|
+
## 7. Verify
|
|
2685
|
+
|
|
2686
|
+
```bash
|
|
2687
|
+
pnpm exec sable doctor # wiring: styles order, @source, IconProvider, dedupe
|
|
2688
|
+
pnpm exec sable lint src # the idioms in §1, by rule
|
|
2689
|
+
pnpm exec sable check src # doctor + lint + grade before calling it done
|
|
2690
|
+
```
|
|
2691
|
+
|
|
2692
|
+
`shadcn-leftover` flags what is still imported from the old stack (`@radix-ui/*`,
|
|
2693
|
+
`@/components/ui`, `lucide-react`, `sonner`, `vaul`, `cmdk`, `input-otp`,
|
|
2694
|
+
`next-themes`) and every `asChild` prop. A deliberate keeper, such as a Radix
|
|
2695
|
+
part Sable does not ship yet, takes a reasoned disable. The rest no rule can
|
|
2696
|
+
judge, so grep for it; each of these should return nothing:
|
|
2697
|
+
|
|
2698
|
+
```bash
|
|
2699
|
+
grep -rnE 'data-\[state=|animate-in|tw-animate|type="(single|multiple)"' src
|
|
2700
|
+
grep -rn 'onSelect=' src # Menu rows: should be onClick (Calendar, CommandMenu items and Entity keep onSelect)
|
|
2701
|
+
```
|
|
2702
|
+
|
|
2703
|
+
Then render it: open each overlay, menu and form in light and dark. Lint
|
|
2704
|
+
passes on markup that never opens.
|
|
2705
|
+
|
|
2706
|
+
## Read next
|
|
2707
|
+
|
|
2708
|
+
- [converting-an-app.md](./converting-an-app.md): registers, wiring, tokens, verification by measurement
|
|
2709
|
+
- [patching-a-component.md](./patching-a-component.md): when a Sable component is wrong and the fix hasn't shipped
|
|
2710
|
+
- [extending-a-component.md](./extending-a-component.md): owning the source is gone, so this is how you add what a component lacks
|
|
2711
|
+
- [tables-with-tanstack.md](./tables-with-tanstack.md): data tables beyond `DataTable`
|
|
2712
|
+
- [auditing-an-app.md](./auditing-an-app.md): staying converted across upgrades
|
|
2713
|
+
|
|
2714
|
+
---
|
|
2715
|
+
|
|
1996
2716
|
# Task: patching a Sable component
|
|
1997
2717
|
|
|
1998
2718
|
A Sable component has a defect that blocks your app, and the fix has not shipped.
|
|
@@ -3989,6 +4709,7 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
|
|
|
3989
4709
|
|
|
3990
4710
|
## Notes
|
|
3991
4711
|
|
|
4712
|
+
- This is the assembled screen with sample data. To wire it to a real streaming source (a hook such as useChat, or your own transport), read `sable docs building-a-chat`: who owns what, the turn lifecycle mapped to props, and every failure state.
|
|
3992
4713
|
- The panel is a fixed-height column, not a growing page. `h-[calc(100dvh_-_--spacing(12))]` on the Card (the viewport minus the Page's py-6, in spacing units so density moves both together — a fixed `3rem` drifts the moment density moves the padding) and `flex-1 min-h-0` on the MessageScroller are what make the transcript scroll instead of the document — the single most common thing to get wrong here, and it fails by pushing the composer off the bottom of the screen.
|
|
3993
4714
|
- The panel is a Card, not a hand-built bordered section: CardHeader (title, description, CardAction), the MessageScroller and a CardFooter all inset by the card's own `--card-p`, so the three regions share one edge at any card density.
|
|
3994
4715
|
- `dvh` rather than `vh`: on mobile the URL bar collapses, and a `vh`-sized panel puts the composer under it exactly when someone is trying to type.
|
|
@@ -4228,7 +4949,9 @@ export function ChatPanel() {
|
|
|
4228
4949
|
onStop={() => undefined}
|
|
4229
4950
|
stopLabel="Stop answering"
|
|
4230
4951
|
onAttach={() => undefined}
|
|
4231
|
-
|
|
4952
|
+
// A keyboard hint, so only where Enter sends: on touch Return
|
|
4953
|
+
// breaks the line and the button sends, and the row goes away.
|
|
4954
|
+
footer={({ sendOn }) => (sendOn === "enter" ? "Enter to send · Shift+Enter for a new line" : null)}
|
|
4232
4955
|
// Model and effort sit in `actions`, at the send end of the row:
|
|
4233
4956
|
// they qualify the message about to be sent, so they belong beside
|
|
4234
4957
|
// the button that sends it rather than next to the attach control.
|
|
@@ -10004,7 +10727,7 @@ Use AppHeader for compact application chrome at the top of a screen, usually wit
|
|
|
10004
10727
|
|
|
10005
10728
|
## Related
|
|
10006
10729
|
|
|
10007
|
-
- Components: AppShell, PageHeader, BottomNav
|
|
10730
|
+
- Components: AppShell, PageHeader, BottomNav, NavigationMenu
|
|
10008
10731
|
|
|
10009
10732
|
## Examples
|
|
10010
10733
|
|
|
@@ -11341,7 +12064,7 @@ Use Button when the user commits an action, submits a form, opens UI, or confirm
|
|
|
11341
12064
|
### Best practices
|
|
11342
12065
|
|
|
11343
12066
|
- Do: Use a single primary button in a view or dense panel so the action hierarchy stays obvious.
|
|
11344
|
-
- Do: For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`.
|
|
12067
|
+
- Do: For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click.
|
|
11345
12068
|
- Do: Write labels as actions, such as "Save changes" or "Delete draft", instead of vague labels like "OK".
|
|
11346
12069
|
- Do: Use loading with loadingLabel for async work so the action cannot double-fire and the state is announced.
|
|
11347
12070
|
- Do: Put icons in iconStart / iconEnd rather than as children, so the button applies its optical padding. Start icons say what the action is (plus, share); end icons say where it goes (chevron-right, external).
|
|
@@ -11561,6 +12284,64 @@ import { Banner, Button } from "@sableui/react"
|
|
|
11561
12284
|
|
|
11562
12285
|
---
|
|
11563
12286
|
|
|
12287
|
+
# ButtonGroup
|
|
12288
|
+
|
|
12289
|
+
**Status:** stable
|
|
12290
|
+
|
|
12291
|
+
Buttons joined into one control: a split button (an action beside the chevron that opens its menu) or a short run of related actions. The children stay ordinary Buttons; the group squares their inner corners, collapses bordered seams into one hairline, and draws a seam between filled ones.
|
|
12292
|
+
|
|
12293
|
+
**Keywords:** split button, button group, segmented actions, dropdown button, joined buttons, toolbar
|
|
12294
|
+
|
|
12295
|
+
## Usage
|
|
12296
|
+
|
|
12297
|
+
Use ButtonGroup when one action has close alternatives (Copy page, with Open in… behind a chevron) or a few actions belong together as one object (zoom out, reset, zoom in). Use ToggleGroup for a choice that stays selected, and Tabs to switch views.
|
|
12298
|
+
|
|
12299
|
+
### Best practices
|
|
12300
|
+
|
|
12301
|
+
- Do: Give the children one height: `size="sm"` with `size="icon-sm"`, `md` with `icon`. The group aligns them but cannot reconcile two heights into one edge.
|
|
12302
|
+
- Do: For a split button, put the main action first and a Menu whose trigger is the chevron Button second, with an aria-label on the trigger ("More page actions").
|
|
12303
|
+
- Do: Name the group with aria-label when the buttons alone do not say what they act on.
|
|
12304
|
+
- Don't: Do not round or border the children yourself to make them meet; that is the group's job, and an override breaks under a theme's button shape.
|
|
12305
|
+
- Don't: Do not use it for a selection that stays on. That is ToggleGroup, which carries the pressed state.
|
|
12306
|
+
|
|
12307
|
+
## Props
|
|
12308
|
+
|
|
12309
|
+
| Name | Type | Default | Description |
|
|
12310
|
+
| --- | --- | --- | --- |
|
|
12311
|
+
| role | string | "group" | Group semantics, so a screen reader announces the buttons as one set. Pass `toolbar` when it carries arrow-key navigation of your own. |
|
|
12312
|
+
| className | string | — | Layout classes for the group (margins, alignment). The children's look comes from their own variants. |
|
|
12313
|
+
| ...props | ComponentPropsWithRef<"div"> | — | All native <div> attributes, including aria-label. |
|
|
12314
|
+
|
|
12315
|
+
## Accessibility
|
|
12316
|
+
|
|
12317
|
+
- Renders role="group", so assistive tech announces the buttons as one set; name it with aria-label.
|
|
12318
|
+
- Each child keeps its own focus stop and label; a focused child is raised above its neighbour so its ring is drawn whole.
|
|
12319
|
+
- An icon-only chevron that opens a menu needs its own aria-label, such as "More page actions".
|
|
12320
|
+
|
|
12321
|
+
## Related
|
|
12322
|
+
|
|
12323
|
+
- Components: Button, Menu, Toggle
|
|
12324
|
+
|
|
12325
|
+
## Examples
|
|
12326
|
+
|
|
12327
|
+
### Split button with a menu
|
|
12328
|
+
|
|
12329
|
+
```tsx
|
|
12330
|
+
import { Button, ButtonGroup, Icon, Menu, MenuContent, MenuItem, MenuTrigger } from "@sableui/react"
|
|
12331
|
+
|
|
12332
|
+
<ButtonGroup aria-label="Page actions"><Button variant="outline" size="sm" iconStart={<Icon name="copy" />} onClick={copy}>Copy page</Button><Menu><MenuTrigger render={<Button variant="outline" size="icon-sm" aria-label="More page actions" />}><Icon name="chevron-down" /></MenuTrigger><MenuContent align="end"><MenuItem icon={<Icon name="file" />} description="View this page as plain text">View as Markdown</MenuItem></MenuContent></Menu></ButtonGroup>
|
|
12333
|
+
```
|
|
12334
|
+
|
|
12335
|
+
### Related actions
|
|
12336
|
+
|
|
12337
|
+
```tsx
|
|
12338
|
+
import { Button, ButtonGroup, Icon } from "@sableui/react"
|
|
12339
|
+
|
|
12340
|
+
<ButtonGroup aria-label="Zoom"><Button variant="secondary" size="icon-sm" aria-label="Zoom out"><Icon name="minus" /></Button><Button variant="secondary" size="sm">100%</Button><Button variant="secondary" size="icon-sm" aria-label="Zoom in"><Icon name="plus" /></Button></ButtonGroup>
|
|
12341
|
+
```
|
|
12342
|
+
|
|
12343
|
+
---
|
|
12344
|
+
|
|
11564
12345
|
# Calendar
|
|
11565
12346
|
|
|
11566
12347
|
**Status:** stable
|
|
@@ -11777,6 +12558,109 @@ import { Avatar, Card, CardDescription, CardHeader, CardList, CardTitle, Entity
|
|
|
11777
12558
|
|
|
11778
12559
|
---
|
|
11779
12560
|
|
|
12561
|
+
# CaretMenu
|
|
12562
|
+
|
|
12563
|
+
**Status:** beta
|
|
12564
|
+
|
|
12565
|
+
Suggestions at the caret of a text field, for @mentions and /slash commands. `useCaretTrigger` watches a Textarea, Input or ChatComposer (through `fieldRef`) for a trigger character and reports the query; CaretMenu draws the list beside the trigger and drives it from the field's keys, so focus never leaves the field. The value stays plain text: a mention is inserted as `@Sam Rivera`, a command runs and clears its `/query`.
|
|
12566
|
+
|
|
12567
|
+
**Keywords:** mention, mentions, at mention, slash command, slash commands, autocomplete, typeahead, suggestions, emoji picker, hashtag, trigger
|
|
12568
|
+
|
|
12569
|
+
## Usage
|
|
12570
|
+
|
|
12571
|
+
Use CaretMenu with `useCaretTrigger` when typing a character in running text should offer choices: `@` for people, `/` for commands, `#` for channels, `:` for emoji. The app owns the items (filter or fetch them from `trigger.match.query`) and what choosing one does; Sable owns detecting the trigger, placing the list at the caret, the keys and the accessibility. Use Combobox when the whole field is a search for one value, and CommandMenu for a global palette.
|
|
12572
|
+
|
|
12573
|
+
### Best practices
|
|
12574
|
+
|
|
12575
|
+
- Do: Attach the hook's `fieldRef` to the field: `ref` on Textarea or Input, `fieldRef` on ChatComposer. On ChatComposer, Enter picks the highlighted suggestion instead of sending, because CaretMenu takes the Enter first.
|
|
12576
|
+
- Do: Insert a mention with a trailing space, `trigger.replace(`@${person.name} `)`, so the reader keeps typing; run a slash command and then `trigger.replace("")` to clear `/query` from the field.
|
|
12577
|
+
- Do: For `@` names with spaces set `allowSpaces: true` on that trigger; for `/` commands set `atStart: true` so `and/or` mid-sentence stays text.
|
|
12578
|
+
- Do: Fetch remote suggestions from `trigger.match?.query` yourself, debounced, and pass `status` while loading or when nothing matches ("Searching…", "No one matches"). The status row is also what the list announces.
|
|
12579
|
+
- Don't: Do not expect chips inside the field. A textarea holds text, so a mention is text. Rendering tokens in the field needs a rich-text editor, which Sable does not own (`sable docs extending-a-component`).
|
|
12580
|
+
- Don't: Do not move focus into the list or wrap the field in a Popover. Focus stays in the field; the list is pointed at with aria-activedescendant.
|
|
12581
|
+
|
|
12582
|
+
### Anatomy
|
|
12583
|
+
|
|
12584
|
+
| Part | Required | Description |
|
|
12585
|
+
| --- | --- | --- |
|
|
12586
|
+
| useCaretTrigger | yes | The hook on the field: trigger detection, the query, the caret anchor, replace() and dismiss(). |
|
|
12587
|
+
| CaretMenu | yes | The list at the caret, its keys, and its announcements. |
|
|
12588
|
+
| CaretMenuItem | yes | One suggestion row, with optional icon and description. |
|
|
12589
|
+
|
|
12590
|
+
## Subcomponents
|
|
12591
|
+
|
|
12592
|
+
| Name | Type | Description |
|
|
12593
|
+
| --- | --- | --- |
|
|
12594
|
+
| CaretMenuItem | div props + onSelect: () => void + disabled?: boolean + icon?: ReactNode + description?: ReactNode | One suggestion, styled as a Menu row. `onSelect` runs on Enter, Tab or click; `icon` and `description` work as on MenuItem. A disabled row shows but the arrow keys skip it. |
|
|
12595
|
+
|
|
12596
|
+
## Props
|
|
12597
|
+
|
|
12598
|
+
| Name | Type | Default | Description |
|
|
12599
|
+
| --- | --- | --- | --- |
|
|
12600
|
+
| trigger | UseCaretTriggerResult | — | The result of `useCaretTrigger({ triggers })`, whose `fieldRef` is attached to the field. Each trigger is `{ char, atStart?, allowSpaces?, maxQueryLength? }`; the result carries `match` (`{ trigger, query, start, end }` or null), `replace(text)`, `dismiss()` and `getAnchorRect()`. |
|
|
12601
|
+
| label | string | — | The list's accessible name, e.g. "People" or "Commands". |
|
|
12602
|
+
| status | ReactNode | — | A row under the items for what the list is doing: loading, nothing found, an error. Announced politely in place of the suggestion count. With no items and no status the list stays shut. |
|
|
12603
|
+
| children | ReactNode | — | CaretMenuItems, already filtered for the query. |
|
|
12604
|
+
| className | string | — | Layout classes for the popup, such as a width. |
|
|
12605
|
+
|
|
12606
|
+
## Tokens used
|
|
12607
|
+
|
|
12608
|
+
`popover`, `popover-foreground`, `border-floating`, `accent`, `accent-foreground`, `muted-foreground`
|
|
12609
|
+
|
|
12610
|
+
## Motion
|
|
12611
|
+
|
|
12612
|
+
- **arrival** · root · `motion-pop` — Scales in from the caret's side, so the list reads as coming from the character just typed. It leaves instantly: it answers a keystroke and should be gone by the next one.
|
|
12613
|
+
|
|
12614
|
+
## Accessibility
|
|
12615
|
+
|
|
12616
|
+
- Focus stays in the field. The field carries aria-autocomplete="list", aria-controls (the listbox) and aria-activedescendant (the highlighted option), so a screen reader follows the arrow keys without focus moving.
|
|
12617
|
+
- An <input> becomes role="combobox" with aria-expanded. A <textarea> keeps its textbox role (combobox would drop multiline), and since a textbox cannot carry aria-expanded, opening is announced through a polite status, mounted from the start: "3 suggestions", or the `status` text.
|
|
12618
|
+
- ↑/↓ move the highlight and wrap; Enter or Tab choose it; Escape closes the list without reaching an enclosing Dialog or Sheet. Shift+Enter is left alone, so a line break still breaks the line.
|
|
12619
|
+
- Rows keep focus in the field on pointer down, so a click does not blur it and close the list first.
|
|
12620
|
+
- Triggers follow a word boundary (`ana@example.com` and `and/or` stay text), wait for IME composition to end, and stay closed after Escape until the caret leaves that trigger.
|
|
12621
|
+
|
|
12622
|
+
## Related
|
|
12623
|
+
|
|
12624
|
+
- Components: ChatComposer, Textarea, Input, Combobox, CommandMenu, Menu
|
|
12625
|
+
|
|
12626
|
+
## Examples
|
|
12627
|
+
|
|
12628
|
+
### Mentions and slash commands in a ChatComposer
|
|
12629
|
+
|
|
12630
|
+
```tsx
|
|
12631
|
+
import { Avatar, CaretMenu, CaretMenuItem, ChatComposer, useCaretTrigger } from "@sableui/react"
|
|
12632
|
+
|
|
12633
|
+
const trigger = useCaretTrigger({ triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }] })
|
|
12634
|
+
const q = trigger.match?.query.toLowerCase() ?? ""
|
|
12635
|
+
const commands = trigger.match?.trigger.char === "/"
|
|
12636
|
+
|
|
12637
|
+
<ChatComposer fieldRef={trigger.fieldRef} onSend={send} />
|
|
12638
|
+
<CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={!commands && people.length === 0 ? "No one matches" : undefined}>
|
|
12639
|
+
{commands
|
|
12640
|
+
? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (
|
|
12641
|
+
<CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>/{c.name}</CaretMenuItem>
|
|
12642
|
+
))
|
|
12643
|
+
: people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (
|
|
12644
|
+
<CaretMenuItem key={p.id} icon={<Avatar size="sm" src={p.avatar} fallback={p.initials} />} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>
|
|
12645
|
+
))}
|
|
12646
|
+
</CaretMenu>
|
|
12647
|
+
```
|
|
12648
|
+
|
|
12649
|
+
### On a Textarea, with remote results
|
|
12650
|
+
|
|
12651
|
+
```tsx
|
|
12652
|
+
import { CaretMenu, CaretMenuItem, Textarea, useCaretTrigger } from "@sableui/react"
|
|
12653
|
+
|
|
12654
|
+
const trigger = useCaretTrigger({ triggers: [{ char: "#" }] })
|
|
12655
|
+
// Fetch from trigger.match?.query yourself, debounced; pass the state as status.
|
|
12656
|
+
<Textarea ref={trigger.fieldRef} aria-label="Note" />
|
|
12657
|
+
<CaretMenu trigger={trigger} label="Channels" status={loading ? "Searching…" : channels.length === 0 ? "No channels match" : undefined}>
|
|
12658
|
+
{channels.map((c) => <CaretMenuItem key={c.id} onSelect={() => trigger.replace(`#${c.name} `)}>#{c.name}</CaretMenuItem>)}
|
|
12659
|
+
</CaretMenu>
|
|
12660
|
+
```
|
|
12661
|
+
|
|
12662
|
+
---
|
|
12663
|
+
|
|
11780
12664
|
# Carousel
|
|
11781
12665
|
|
|
11782
12666
|
**Status:** stable
|
|
@@ -11871,7 +12755,7 @@ import { Carousel } from "@sableui/react"
|
|
|
11871
12755
|
|
|
11872
12756
|
**Status:** stable
|
|
11873
12757
|
|
|
11874
|
-
The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn="mod-enter" swaps them for long-form replies), and send becomes stop while a reply streams.
|
|
12758
|
+
The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn="mod-enter" swaps them for long-form replies; on touch, Return always breaks the line), and send becomes stop while a reply streams. allowEmpty sends attachments without text.
|
|
11875
12759
|
|
|
11876
12760
|
**Keywords:** chat input, composer, message input, prompt input, send message, ai chat, support chat, textarea
|
|
11877
12761
|
|
|
@@ -11881,13 +12765,15 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
11881
12765
|
|
|
11882
12766
|
### Best practices
|
|
11883
12767
|
|
|
12768
|
+
- Do: Wiring it to a streaming source? `sable docs building-a-chat` maps a turn lifecycle (submitted, streaming, done, stopped, error) onto `busy`, `onStop` and `disabled`, and says where each failure goes.
|
|
11884
12769
|
- Do: Pass `busy` while a reply streams, and `onStop` alongside it. Interrupting a long generation is the most common thing a reader wants during one, and the control belongs where their cursor already is.
|
|
11885
12770
|
- Do: Leave it uncontrolled unless you need the text elsewhere — a slash-command menu, a draft you persist. Uncontrolled clears itself on send; controlled does not.
|
|
11886
12771
|
- Do: Render pending uploads as `<Attachment>` chips in `attachments`, so the file and the message it belongs to stay in one box.
|
|
12772
|
+
- Do: Pass `allowEmpty={files.length > 0}` when a file can go without a caption. Send enables on the attachment alone and `onSend` receives `""`. Tie it to the pending files: a constant `allowEmpty` leaves send enabled with nothing to send.
|
|
11887
12773
|
- Do: Put model pickers and tool toggles in `leading`, beside the attach button — they are composer state, not message content, and belong on the chrome row rather than in the field.
|
|
11888
12774
|
- Do: Building a docked agent panel inside a tool? Wrap the whole conversation — scroller, messages and composer — in `<Density value="compact">`. The entire set follows the workspace register from there: prose drops a tier, the rhythm tightens, and the controls come off the compact ladder. Do not hand-size it at the call site.
|
|
11889
12775
|
- Don't: Do not clear a controlled composer for the user on send. Only the caller knows whether the send succeeded, and clearing on their behalf loses the message when it did not.
|
|
11890
|
-
- Don't: Do not add your own Enter-to-send handler on top. The built-in one already guards against firing mid-IME-composition,
|
|
12776
|
+
- Don't: Do not add your own Enter-to-send handler on top. The built-in one already guards against firing mid-IME-composition, and on a touch device lets Return break the line (a soft keyboard has no Shift+Return), which are the lines hand-rolled chat inputs miss.
|
|
11891
12777
|
- Don't: Do not use it as a general multi-line input inside a larger form. That is `Textarea`. A standalone reply box that sends multi-paragraph messages (email, support replies) is this one with sendOn="mod-enter".
|
|
11892
12778
|
- Don't: Do not override the field's type utility to make it match your transcript. The field and `Bubble` both read the same tier already, which is what stops a message changing size the moment it is sent — overriding one breaks that pairing.
|
|
11893
12779
|
- Don't: Do not remove the height cap to let the field grow without limit. A pasted essay would push the conversation off the screen; past the cap it scrolls.
|
|
@@ -11908,9 +12794,10 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
11908
12794
|
| value | string | — | Controlled value. Omit for an uncontrolled composer that owns its own text. |
|
|
11909
12795
|
| defaultValue | string | "" | Initial value when uncontrolled. |
|
|
11910
12796
|
| onValueChange | (value: string) => void | — | Notified on every keystroke. Required when `value` is supplied. |
|
|
11911
|
-
| onSend | (value: string) => void | — | Called with the trimmed message. An uncontrolled composer clears itself afterwards; a controlled one is the caller's to clear. |
|
|
12797
|
+
| onSend | (value: string) => void | — | Called with the trimmed message. An uncontrolled composer clears itself afterwards; a controlled one is the caller's to clear. With `allowEmpty` the message can be `""`. |
|
|
12798
|
+
| allowEmpty | boolean | false | Let the reader send with no text, because the app has something else to send (pending attachments). Pass it only while that is true, `allowEmpty={files.length > 0}`; `onSend` then receives `""`. |
|
|
11912
12799
|
| placeholder | string | "Send a message…" | Placeholder for the field. |
|
|
11913
|
-
| sendOn | "enter" \| "mod-enter" | "enter" | Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer ("Cmd+Enter to send"). |
|
|
12800
|
+
| sendOn | "enter" \| "mod-enter" | "enter" | Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer ("Cmd+Enter to send"). On a touch device (pointer: coarse) enter behaves as mod-enter: a soft keyboard has no Shift+Return, so Return breaks the line and the send button sends. |
|
|
11914
12801
|
| busy | boolean | false | A reply is streaming. Disables send, and swaps it for a stop control when `onStop` is supplied. |
|
|
11915
12802
|
| onStop | () => void | — | Interrupt the in-flight reply. Supplying it turns send into stop while busy. |
|
|
11916
12803
|
| disabled | boolean | false | Block sending entirely — over a limit, disconnected, read-only thread. Distinct from `busy`, which is temporary and offers a way out (stop). |
|
|
@@ -11922,8 +12809,8 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
11922
12809
|
| label | string | "Message" | Accessible label for the field. |
|
|
11923
12810
|
| sendLabel | string | "Send message" | Accessible label for the send button. |
|
|
11924
12811
|
| stopLabel | string | "Stop generating" | Accessible label for the stop button. |
|
|
11925
|
-
| footer | ReactNode | — | Helper text or counter below the controls. |
|
|
11926
|
-
| fieldRef | Ref<HTMLTextAreaElement> | — | Ref to the underlying textarea — for focusing after a send, or on mount. |
|
|
12812
|
+
| footer | ReactNode \| ((state: { sendOn: "enter" \| "mod-enter" }) => ReactNode) | — | Helper text or counter below the controls. For a keyboard hint pass a function: it receives the send key in effect (touch turns `enter` into `mod-enter`), and returning null removes the row, e.g. `footer={({ sendOn }) => sendOn === "enter" ? "Enter to send" : null}`. |
|
|
12813
|
+
| fieldRef | Ref<HTMLTextAreaElement> | — | Ref to the underlying textarea — for focusing after a send, or on mount, and the extension point for anything that listens to the field. Pass `useCaretTrigger`'s `fieldRef` here for mentions and slash commands (CaretMenu). An Enter another listener already handled (`preventDefault()`) does not send. |
|
|
11927
12814
|
| ...form props | Omit<ComponentPropsWithRef<"form">, "onSubmit" \| "defaultValue"> | — | Spread onto the form element. Use `onSend` rather than `onSubmit`. |
|
|
11928
12815
|
|
|
11929
12816
|
## Variants
|
|
@@ -11939,7 +12826,8 @@ Pairs with MessageScroller to make a working chat. A single surface rather than
|
|
|
11939
12826
|
- A real `<form>`, so the send button is a submit button and the whole thing behaves as one control to assistive tech and to password managers alike.
|
|
11940
12827
|
- Enter never submits while an IME composition is open (`isComposing`), so a CJK reader confirming a candidate word does not mail a fragment of their first sentence.
|
|
11941
12828
|
- The focus ring is drawn on the surface via `focus-within`, not on the textarea, so the visible focus indicator matches the box a sighted user perceives as the input.
|
|
11942
|
-
- Send is disabled — not merely styled down — until there is non-whitespace to send, so the affordance and the behaviour agree for keyboard users.
|
|
12829
|
+
- Send is disabled — not merely styled down — until there is non-whitespace to send (or `allowEmpty` says an attachment is enough), so the affordance and the behaviour agree for keyboard users.
|
|
12830
|
+
- On a touch device Return breaks the line and the send button sends; `aria-keyshortcuts` reports the shortcut actually in effect (⌘/Ctrl+Enter there, Enter on desktop).
|
|
11943
12831
|
- Icon-only attach, send and stop buttons all carry accessible labels, each overridable for localisation.
|
|
11944
12832
|
|
|
11945
12833
|
## Related
|
|
@@ -11981,6 +12869,19 @@ import { Attachment, Button, ChatComposer, Icon } from "@sableui/react"
|
|
|
11981
12869
|
/>
|
|
11982
12870
|
```
|
|
11983
12871
|
|
|
12872
|
+
### Attachments sent without text
|
|
12873
|
+
|
|
12874
|
+
```tsx
|
|
12875
|
+
import { Attachment, ChatComposer } from "@sableui/react"
|
|
12876
|
+
|
|
12877
|
+
<ChatComposer
|
|
12878
|
+
onSend={(text) => post(text, files)}
|
|
12879
|
+
allowEmpty={files.length > 0}
|
|
12880
|
+
onAttach={pickFile}
|
|
12881
|
+
attachments={files.map((f) => <Attachment key={f.id} name={f.name} meta={f.size} />)}
|
|
12882
|
+
/>
|
|
12883
|
+
```
|
|
12884
|
+
|
|
11984
12885
|
### A working chat, end to end
|
|
11985
12886
|
|
|
11986
12887
|
```tsx
|
|
@@ -12545,6 +13446,7 @@ Use Combobox when users choose from a list that benefits from typing, filtering,
|
|
|
12545
13446
|
|
|
12546
13447
|
- Do: Pass items so internal filtering, empty state, and accessibility wiring have the full option set.
|
|
12547
13448
|
- Do: Use ComboboxEmpty with helpful copy for zero matches.
|
|
13449
|
+
- Do: For results from a server, pass `filter={null}` so Base UI shows the items as the server returned them, fetch from `onInputValueChange`, and make ComboboxEmpty say which state it is in (searching, no results, type to search). Object items take `itemToStringLabel`.
|
|
12548
13450
|
- Do: Wrap in Field when the combobox is part of a form.
|
|
12549
13451
|
- Do: Reach for ComboboxVirtualList past roughly a thousand rows, and leave filtering to the root. It reads the filtered list through useFilteredItems(), so the rendered rows and the rows Base UI navigates cannot drift apart.
|
|
12550
13452
|
- Do: Use `multiple` with ComboboxChips for several values from a filterable set, and keep chip labels short so the field does not turn into a paragraph.
|
|
@@ -12585,6 +13487,9 @@ Use Combobox when users choose from a list that benefits from typing, filtering,
|
|
|
12585
13487
|
| Combobox (Root) | Base UI Combobox.Root props (items, value, defaultValue, onValueChange, inputValue, onInputValueChange, open, onOpenChange, multiple, autoHighlight, filter, disabled) | — | Groups all parts and owns selection + filter state. Pass `items` (flat array or groups) to enable internal filtering and ComboboxEmpty. |
|
|
12586
13488
|
| items | readonly any[] \| readonly ComboboxItemGroup[] | — | The pool of options, flat or grouped ({ value, items }). Drives type-to-filter and is required for ComboboxEmpty to render its fallback. |
|
|
12587
13489
|
| multiple | boolean | false | Select several items instead of one. `value` / `defaultValue` / `onValueChange` become arrays, choosing an item toggles its membership, and the popup stays open between picks. Pair it with ComboboxChips in place of ComboboxInput; every other part is unchanged. |
|
|
13490
|
+
| filter | null \| ((item, query, itemToString?) => boolean) | Base UI's locale-aware contains | How items match the query. `null` turns matching off, for items a server already filtered: pair it with `onInputValueChange` to fetch. |
|
|
13491
|
+
| onInputValueChange | (inputValue: string, eventDetails) => void | — | Fires as the reader types. The place to fetch results for a server-backed combobox. |
|
|
13492
|
+
| itemToStringLabel | (item) => string | item.label for { value, label } items | The text an object item shows in the input once chosen, and is matched by. Needed for object items without a `label` field. |
|
|
12588
13493
|
| virtualized | boolean | false | Tells Base UI the DOM holds only part of the list. Required when using ComboboxVirtualList. |
|
|
12589
13494
|
|
|
12590
13495
|
## Tokens used
|
|
@@ -12633,6 +13538,14 @@ import { Combobox, ComboboxChips, ComboboxContent, ComboboxEmpty, ComboboxItem,
|
|
|
12633
13538
|
<Combobox multiple items={tags} value={value} onValueChange={setValue}><ComboboxChips placeholder="Add tags…" /><ComboboxContent><ComboboxEmpty>No matching tags.</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item} value={item}>{item}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>
|
|
12634
13539
|
```
|
|
12635
13540
|
|
|
13541
|
+
### Async search (server results)
|
|
13542
|
+
|
|
13543
|
+
```tsx
|
|
13544
|
+
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@sableui/react"
|
|
13545
|
+
|
|
13546
|
+
<Combobox items={results} filter={null} itemToStringLabel={(person) => person.name} value={person} onValueChange={setPerson} onInputValueChange={setQuery}><ComboboxInput placeholder="Search people…" /><ComboboxContent><ComboboxEmpty>{searching ? "Searching…" : query ? "No one matches." : "Type a name to search."}</ComboboxEmpty><ComboboxList>{(item) => (<ComboboxItem key={item.id} value={item}>{item.name}</ComboboxItem>)}</ComboboxList></ComboboxContent></Combobox>
|
|
13547
|
+
```
|
|
13548
|
+
|
|
12636
13549
|
### Virtualized (long list)
|
|
12637
13550
|
|
|
12638
13551
|
```tsx
|
|
@@ -14769,6 +15682,87 @@ import { Input } from "@sableui/react"
|
|
|
14769
15682
|
|
|
14770
15683
|
---
|
|
14771
15684
|
|
|
15685
|
+
# InputGroup
|
|
15686
|
+
|
|
15687
|
+
**Status:** stable
|
|
15688
|
+
|
|
15689
|
+
An Input with addons inside the same field: a prefix or suffix ("https://", ".com", "$"), an icon, or a borderless Button such as copy or reveal. One shell, one focus ring, one error state; a Field's label and error wiring reaches the Input inside.
|
|
15690
|
+
|
|
15691
|
+
**Keywords:** input group, input addon, prefix, suffix, adornment, url input, currency input, password reveal, copy field, icon in input
|
|
15692
|
+
|
|
15693
|
+
## Usage
|
|
15694
|
+
|
|
15695
|
+
Use InputGroup when something belongs inside the field with the text: a unit, a fixed prefix, an icon, or a small action on the value itself (copy, reveal, clear). Use Field's `trailing` for an action beside the field (Apply next to a promo code), SearchInput for search, and NumberField for numbers with steppers.
|
|
15696
|
+
|
|
15697
|
+
### Best practices
|
|
15698
|
+
|
|
15699
|
+
- Do: Keep text addons short and fixed ("https://", "kg", "USD"). They are part of the field, not its label.
|
|
15700
|
+
- Do: For a button addon, use a borderless Button (`variant="ghost"`, `size="icon-sm"`) with an aria-label. The addon tightens around it, so it sits as far from the edge as from the top and bottom.
|
|
15701
|
+
- Do: Wrap the group in Field for a label and errors: `<Field label="Website"><InputGroup leading="https://"><Input /></InputGroup></Field>`.
|
|
15702
|
+
- Don't: Do not put the value's meaning only in an addon. A screen reader hears the label and the text, so "Price (USD)" belongs in the label too.
|
|
15703
|
+
- Don't: Do not restyle an Input to fake an addon; the group owns the border, the fill and the ring.
|
|
15704
|
+
|
|
15705
|
+
## Props
|
|
15706
|
+
|
|
15707
|
+
| Name | Type | Default | Description |
|
|
15708
|
+
| --- | --- | --- | --- |
|
|
15709
|
+
| leading | ReactNode | — | Inside the field, before the text: a prefix ("https://", "$"), an icon, or a borderless Button. |
|
|
15710
|
+
| trailing | ReactNode | — | Inside the field, after the text: a suffix (".com", "kg"), an icon, or a borderless Button (copy, reveal, clear). |
|
|
15711
|
+
| children | ReactElement | — | The single `<Input />`. It keeps its own props and type; the group takes over its border, fill and focus ring. |
|
|
15712
|
+
| id / required / disabled / aria-describedby / aria-invalid | control props | — | Forwarded to the Input, not set on the group. This is how a Field's wiring reaches the text box when the group is its child. |
|
|
15713
|
+
| className | string | — | Layout classes for the group (width, margins). |
|
|
15714
|
+
|
|
15715
|
+
## Tokens used
|
|
15716
|
+
|
|
15717
|
+
`input`, `card`, `ring`, `destructive`, `muted-foreground`, `foreground`
|
|
15718
|
+
|
|
15719
|
+
## Accessibility
|
|
15720
|
+
|
|
15721
|
+
- The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.
|
|
15722
|
+
- Inside a Field, the label, description and error are wired to the Input itself, not the group.
|
|
15723
|
+
- Text addons are plain text beside the input, read in order; put a unit or currency in the label as well so it is not only visual.
|
|
15724
|
+
- A button addon is a real Button with its own focus stop and needs an aria-label.
|
|
15725
|
+
|
|
15726
|
+
## Related
|
|
15727
|
+
|
|
15728
|
+
- Components: Input, Field, SearchInput, NumberField, Button
|
|
15729
|
+
|
|
15730
|
+
## Examples
|
|
15731
|
+
|
|
15732
|
+
### Prefix and suffix
|
|
15733
|
+
|
|
15734
|
+
```tsx
|
|
15735
|
+
import { Field, Input, InputGroup } from "@sableui/react"
|
|
15736
|
+
|
|
15737
|
+
<Field label="Website"><InputGroup leading="https://" trailing=".com"><Input placeholder="example" /></InputGroup></Field>
|
|
15738
|
+
```
|
|
15739
|
+
|
|
15740
|
+
### Leading icon
|
|
15741
|
+
|
|
15742
|
+
```tsx
|
|
15743
|
+
import { Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
15744
|
+
|
|
15745
|
+
<Field label="Email"><InputGroup leading={<Icon name="mail" />}><Input type="email" placeholder="you@example.com" /></InputGroup></Field>
|
|
15746
|
+
```
|
|
15747
|
+
|
|
15748
|
+
### Copy button
|
|
15749
|
+
|
|
15750
|
+
```tsx
|
|
15751
|
+
import { Button, Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
15752
|
+
|
|
15753
|
+
<Field label="API key"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label="Copy key" onClick={copy}><Icon name={copied ? "check" : "copy"} /></Button>}><Input readOnly value={key} /></InputGroup></Field>
|
|
15754
|
+
```
|
|
15755
|
+
|
|
15756
|
+
### Password with reveal
|
|
15757
|
+
|
|
15758
|
+
```tsx
|
|
15759
|
+
import { Button, Field, Icon, Input, InputGroup } from "@sableui/react"
|
|
15760
|
+
|
|
15761
|
+
<Field label="Password"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label={shown ? "Hide password" : "Show password"} aria-pressed={shown} onClick={() => setShown(!shown)}><Icon name={shown ? "eye-off" : "eye"} /></Button>}><Input type={shown ? "text" : "password"} autoComplete="current-password" /></InputGroup></Field>
|
|
15762
|
+
```
|
|
15763
|
+
|
|
15764
|
+
---
|
|
15765
|
+
|
|
14772
15766
|
# Kbd
|
|
14773
15767
|
|
|
14774
15768
|
**Status:** stable
|
|
@@ -15508,7 +16502,8 @@ Use Menu for a compact list of actions opened from a visible trigger.
|
|
|
15508
16502
|
| --- | --- | --- |
|
|
15509
16503
|
| MenuTrigger | Base UI Menu.Trigger props + render | The button that opens the menu. Compose with render={<Button … />}. |
|
|
15510
16504
|
| MenuContent | Base UI Popup props + side: 'top' \| 'right' \| 'bottom' \| 'left' + align: 'start' \| 'center' \| 'end' + sideOffset: number + className | Portal + Positioner + Popup. side picks which side of the trigger the popup renders against (auto-flips on collision; defaults to 'bottom'); align sets alignment along that side (defaults to 'start'); sideOffset is the gap in px between trigger and popup (defaults to 6). |
|
|
15511
|
-
| MenuItem | Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" \| "destructive" | An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints). |
|
|
16505
|
+
| MenuItem | Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" \| "destructive" + icon?: ReactNode + description?: ReactNode | An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints). `icon` leads the row; `description` adds a quieter second line saying what the action does, and the icon then sits in a small tile spanning both lines. |
|
|
16506
|
+
| MenuLinkItem | Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode | A row that navigates: a real <a>, so it opens in a new tab, middle-clicks and copies as a link. With target="_blank" it adds rel="noopener noreferrer" and a trailing ↗. Takes icon and description like MenuItem. |
|
|
15512
16507
|
| MenuSeparator | Base UI Separator props + className | Hairline rule between item runs. |
|
|
15513
16508
|
| MenuSubmenu | Base UI Menu.SubmenuRoot props | A nested menu. Wraps a MenuSubmenuTrigger and its own MenuContent — pass side="inline-end" to that content so the submenu opens away from its parent in RTL as well as LTR. |
|
|
15514
16509
|
| MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + className | The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High ›" rather than making the reader open it to find out. |
|
|
@@ -15580,6 +16575,20 @@ import { Button, Menu, MenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, Men
|
|
|
15580
16575
|
</Menu>
|
|
15581
16576
|
```
|
|
15582
16577
|
|
|
16578
|
+
### Rows with icons and descriptions, and links
|
|
16579
|
+
|
|
16580
|
+
```tsx
|
|
16581
|
+
import { Button, Icon, Menu, MenuContent, MenuItem, MenuLinkItem, MenuTrigger } from "@sableui/react"
|
|
16582
|
+
|
|
16583
|
+
<Menu>
|
|
16584
|
+
<MenuTrigger render={<Button variant="outline" size="sm">Share</Button>} />
|
|
16585
|
+
<MenuContent align="end">
|
|
16586
|
+
<MenuItem icon={<Icon name="copy" />} description="Copy page as Markdown for LLMs" onClick={copy}>Copy page</MenuItem>
|
|
16587
|
+
<MenuLinkItem icon={<Icon name="file" />} description="View this page as plain text" href={markdownUrl} target="_blank">View as Markdown</MenuLinkItem>
|
|
16588
|
+
</MenuContent>
|
|
16589
|
+
</Menu>
|
|
16590
|
+
```
|
|
16591
|
+
|
|
15583
16592
|
### Toggling rows
|
|
15584
16593
|
|
|
15585
16594
|
```tsx
|
|
@@ -15596,6 +16605,119 @@ import { Button, Menu, MenuCheckboxItem, MenuContent, MenuTrigger } from "@sable
|
|
|
15596
16605
|
|
|
15597
16606
|
---
|
|
15598
16607
|
|
|
16608
|
+
# Menubar
|
|
16609
|
+
|
|
16610
|
+
**Status:** stable
|
|
16611
|
+
|
|
16612
|
+
An application's row of menus — File, Edit, View — over Base UI's Menubar. Each menu is an ordinary Sable Menu with a MenubarTrigger as its title; the bar joins them into one tab stop with ←/→ between titles, and once one menu is open, pointing at a neighbour opens it.
|
|
16613
|
+
|
|
16614
|
+
**Keywords:** menubar, menu bar, app menu, file menu, edit menu, application menu, desktop menu
|
|
16615
|
+
|
|
16616
|
+
## Usage
|
|
16617
|
+
|
|
16618
|
+
Use Menubar for a tool or editor whose commands are too many for a toolbar and belong under named menus (File, Edit, View, Help). Use Toolbar for a row of direct actions, a single Menu or OverflowMenu for one set of options, and NavigationMenu-style links for site navigation.
|
|
16619
|
+
|
|
16620
|
+
### Best practices
|
|
16621
|
+
|
|
16622
|
+
- Do: Compose each menu from the Menu parts: `<Menu><MenubarTrigger>File</MenubarTrigger><MenuContent>…</MenuContent></Menu>`. Rows, groups, submenus, checkbox and radio items are the same parts a lone Menu uses.
|
|
16623
|
+
- Do: Name the bar with aria-label ("Editor") so it is announced as the application's menus, not a list of buttons.
|
|
16624
|
+
- Do: Keep titles to one short word each. A reader scans a menubar left to right; a phrase in the bar is a toolbar button.
|
|
16625
|
+
- Don't: Do not use MenubarTrigger outside a Menubar, or a plain MenuTrigger inside one. The bar's arrow keys and hover-to-switch work on the triggers it owns; a Button trigger in the bar looks like a menu title and behaves like a stray button.
|
|
16626
|
+
- Don't: Do not use a menubar for site navigation. Its items are commands in menus (role=menuitem), not links between pages.
|
|
16627
|
+
|
|
16628
|
+
### Anatomy
|
|
16629
|
+
|
|
16630
|
+
| Part | Required | Description |
|
|
16631
|
+
| --- | --- | --- |
|
|
16632
|
+
| Menubar | yes | The bar: role=menubar, one tab stop, arrow keys between titles. |
|
|
16633
|
+
| Menu | yes | One per title; the ordinary Menu root. |
|
|
16634
|
+
| MenubarTrigger | yes | The title in the bar that opens its menu. |
|
|
16635
|
+
| MenuContent | yes | The menu's popup, with MenuItem / MenuSubmenu / MenuCheckboxItem rows inside. |
|
|
16636
|
+
|
|
16637
|
+
## Subcomponents
|
|
16638
|
+
|
|
16639
|
+
| Name | Type | Description |
|
|
16640
|
+
| --- | --- | --- |
|
|
16641
|
+
| MenubarTrigger | Base UI Menu.Trigger props + className | A menu's title in the bar. A small ghost Button in the control type tier, held in the accent wash while its menu is open (`sable-open:`), so the reader sees which title the popup belongs to. |
|
|
16642
|
+
|
|
16643
|
+
## Props
|
|
16644
|
+
|
|
16645
|
+
| Name | Type | Default | Description |
|
|
16646
|
+
| --- | --- | --- | --- |
|
|
16647
|
+
| orientation | "horizontal" \| "vertical" | "horizontal" | The bar's axis. Arrow keys follow it: ←/→ along a horizontal bar, ↑/↓ down a vertical one. |
|
|
16648
|
+
| loopFocus | boolean | true | Wrap from the last title back to the first with the arrow keys. |
|
|
16649
|
+
| modal | boolean | true | While a menu is open, block pointer interaction outside it and lock page scroll, like a lone Menu. |
|
|
16650
|
+
| disabled | boolean | false | Disable every menu in the bar. |
|
|
16651
|
+
| className | string | — | Layout classes for the bar. Titles take their look from MenubarTrigger. |
|
|
16652
|
+
|
|
16653
|
+
## Tokens used
|
|
16654
|
+
|
|
16655
|
+
`accent`, `accent-foreground`, `foreground`, `ring`
|
|
16656
|
+
|
|
16657
|
+
## Accessibility
|
|
16658
|
+
|
|
16659
|
+
- Base UI renders role="menubar" with aria-orientation; each title is a menuitem with aria-haspopup and aria-expanded.
|
|
16660
|
+
- The bar is one tab stop. ←/→ move between titles (↑/↓ when vertical), ↓ or Enter opens a menu and moves into it, and Esc closes it and returns focus to its title.
|
|
16661
|
+
- With one menu open, pointing at another title opens that menu instead, so a reader can sweep across the bar, as in a desktop application.
|
|
16662
|
+
- Name the bar with aria-label; titles are their own text.
|
|
16663
|
+
|
|
16664
|
+
## Related
|
|
16665
|
+
|
|
16666
|
+
- Components: Menu, Toolbar, OverflowMenu, ContextMenu
|
|
16667
|
+
|
|
16668
|
+
## Examples
|
|
16669
|
+
|
|
16670
|
+
### Editor menus
|
|
16671
|
+
|
|
16672
|
+
```tsx
|
|
16673
|
+
import { Menu, Menubar, MenubarTrigger, MenuContent, MenuItem, MenuSeparator } from "@sableui/react"
|
|
16674
|
+
|
|
16675
|
+
<Menubar aria-label="Editor">
|
|
16676
|
+
<Menu>
|
|
16677
|
+
<MenubarTrigger>File</MenubarTrigger>
|
|
16678
|
+
<MenuContent>
|
|
16679
|
+
<MenuItem>New</MenuItem>
|
|
16680
|
+
<MenuItem>Open…</MenuItem>
|
|
16681
|
+
<MenuSeparator />
|
|
16682
|
+
<MenuItem>Save</MenuItem>
|
|
16683
|
+
</MenuContent>
|
|
16684
|
+
</Menu>
|
|
16685
|
+
<Menu>
|
|
16686
|
+
<MenubarTrigger>Edit</MenubarTrigger>
|
|
16687
|
+
<MenuContent>
|
|
16688
|
+
<MenuItem>Undo</MenuItem>
|
|
16689
|
+
<MenuItem>Redo</MenuItem>
|
|
16690
|
+
</MenuContent>
|
|
16691
|
+
</Menu>
|
|
16692
|
+
</Menubar>
|
|
16693
|
+
```
|
|
16694
|
+
|
|
16695
|
+
### With a submenu and toggling rows
|
|
16696
|
+
|
|
16697
|
+
```tsx
|
|
16698
|
+
import { Menu, Menubar, MenubarTrigger, MenuCheckboxItem, MenuContent, MenuRadioGroup, MenuRadioItem, MenuSubmenu, MenuSubmenuTrigger } from "@sableui/react"
|
|
16699
|
+
|
|
16700
|
+
<Menubar aria-label="Editor">
|
|
16701
|
+
<Menu>
|
|
16702
|
+
<MenubarTrigger>View</MenubarTrigger>
|
|
16703
|
+
<MenuContent>
|
|
16704
|
+
<MenuCheckboxItem checked={ruler} onCheckedChange={setRuler}>Show ruler</MenuCheckboxItem>
|
|
16705
|
+
<MenuSubmenu>
|
|
16706
|
+
<MenuSubmenuTrigger value={zoom}>Zoom</MenuSubmenuTrigger>
|
|
16707
|
+
<MenuContent side="inline-end" align="start" sideOffset={4}>
|
|
16708
|
+
<MenuRadioGroup value={zoom} onValueChange={setZoom}>
|
|
16709
|
+
<MenuRadioItem value="100%">100%</MenuRadioItem>
|
|
16710
|
+
<MenuRadioItem value="Fit">Fit</MenuRadioItem>
|
|
16711
|
+
</MenuRadioGroup>
|
|
16712
|
+
</MenuContent>
|
|
16713
|
+
</MenuSubmenu>
|
|
16714
|
+
</MenuContent>
|
|
16715
|
+
</Menu>
|
|
16716
|
+
</Menubar>
|
|
16717
|
+
```
|
|
16718
|
+
|
|
16719
|
+
---
|
|
16720
|
+
|
|
15599
16721
|
# Message
|
|
15600
16722
|
|
|
15601
16723
|
**Status:** stable
|
|
@@ -15727,6 +16849,7 @@ The scroll region a conversation lives in. It owns the scrolling and nothing els
|
|
|
15727
16849
|
|
|
15728
16850
|
### Best practices
|
|
15729
16851
|
|
|
16852
|
+
- Do: Building a whole chat? `sable docs building-a-chat` covers what the scroller does during a stream, what the app owns, and the failure states around it.
|
|
15730
16853
|
- Do: Bound it the way you would bound any scroll box — `flex-1 min-h-0` inside a flex column, or `max-h-96`. `className` lands on the element that actually scrolls, so the obvious bound is the correct one (same contract as Scroller).
|
|
15731
16854
|
- Do: Give every Message a `messageId`. It is the handle for `scrollToMessage`, `initialScroll`, and `onVisibleMessagesChange` — a message without one is invisible to all three.
|
|
15732
16855
|
- Do: Prepend older history directly into `children` when the reader reaches the top. The scroller anchors on the first rendered message and re-adds the height that appeared above it, so the view does not jump.
|
|
@@ -16018,6 +17141,122 @@ const { copy, copied } = useCopy(commitHash)
|
|
|
16018
17141
|
|
|
16019
17142
|
---
|
|
16020
17143
|
|
|
17144
|
+
# NavigationMenu
|
|
17145
|
+
|
|
17146
|
+
**Status:** stable
|
|
17147
|
+
|
|
17148
|
+
A site header's primary nav over Base UI NavigationMenu: a bar of links and triggers, where a trigger opens a panel of links on hover, click or Enter. One shared panel glides between triggers and resizes to each one's content. Links take any router's link through `render` and mark the current page with `active`.
|
|
17149
|
+
|
|
17150
|
+
**Keywords:** navigation, nav, header nav, site nav, mega menu, top nav, navbar, dropdown nav
|
|
17151
|
+
|
|
17152
|
+
## Usage
|
|
17153
|
+
|
|
17154
|
+
Use NavigationMenu for a marketing or docs site's header: a few top-level destinations, some of which open a panel of related pages. Use Menu for actions rather than destinations, Tabs to switch views inside a page, Sidebar or SideNav for an app's persistent navigation, and AppHeader for an app's mobile top bar.
|
|
17155
|
+
|
|
17156
|
+
### Best practices
|
|
17157
|
+
|
|
17158
|
+
- Do: Keep the bar to a handful of entries. A trigger groups related pages; a destination with nothing beneath it is a plain NavigationMenuLink in its own item.
|
|
17159
|
+
- Do: Give panel links a `description` when titles alone are ambiguous ("Sync" → "Offline first, conflict free"), and lay the Content out with layout classes (`grid w-[30rem] grid-cols-2`); the shared panel sizes itself to it.
|
|
17160
|
+
- Do: Pass your router's link through `render` (`<NavigationMenuLink render={<Link to="/pricing" />}>`) and set `active` on the current page's link, so the bar shows where the reader is and announces it as `aria-current="page"`.
|
|
17161
|
+
- Do: Below `md`, hide the bar and offer the same destinations in a Sheet opened from a menu Button: SheetContent side="left" holding a SideNav, its groups mirroring the panels. A hover panel has nothing to hover on a phone, and a bar of five entries does not fit one.
|
|
17162
|
+
- Don't: Do not put actions in it (Sign in, New project). Those are Buttons beside the nav; the nav holds places to go.
|
|
17163
|
+
- Don't: Do not assemble the panel yourself. The root renders the one Portal, Positioner, Popup and Viewport every trigger shares; a second one breaks the glide between triggers.
|
|
17164
|
+
|
|
17165
|
+
### Anatomy
|
|
17166
|
+
|
|
17167
|
+
| Part | Required | Description |
|
|
17168
|
+
| --- | --- | --- |
|
|
17169
|
+
| NavigationMenu | yes | The `<nav>` root; also renders the shared floating panel. |
|
|
17170
|
+
| NavigationMenuList | yes | The bar: a `<ul>` the arrow keys rove across. |
|
|
17171
|
+
| NavigationMenuItem | yes | One `<li>`: a link, or a trigger with its content. |
|
|
17172
|
+
| NavigationMenuTrigger | no | Opens its item's content; carries a chevron. |
|
|
17173
|
+
| NavigationMenuContent | no | The links a trigger reveals, rendered into the shared panel. |
|
|
17174
|
+
| NavigationMenuLink | yes | A destination: a bar entry in an item, a two-line row in content. |
|
|
17175
|
+
|
|
17176
|
+
## Subcomponents
|
|
17177
|
+
|
|
17178
|
+
| Name | Type | Description |
|
|
17179
|
+
| --- | --- | --- |
|
|
17180
|
+
| NavigationMenuList | Base UI NavigationMenu.List props + className | The bar of items, a `<ul>`. Arrow keys move between entries. |
|
|
17181
|
+
| NavigationMenuItem | Base UI NavigationMenu.Item props + className + value?: string | One `<li>` holding either a NavigationMenuLink or a NavigationMenuTrigger with its NavigationMenuContent. |
|
|
17182
|
+
| NavigationMenuTrigger | Base UI NavigationMenu.Trigger props + className | Opens its item's content on hover, click or Enter, with a chevron that turns while open. Styled like the bar's links: muted at rest, full ink when hovered or open. |
|
|
17183
|
+
| NavigationMenuContent | Base UI NavigationMenu.Content props + className + keepMounted?: boolean | The item's panel content. Size and lay it out with layout classes; the shared panel follows its size and slides it in from the side it was reached from. |
|
|
17184
|
+
| NavigationMenuLink | Base UI NavigationMenu.Link props + className + href?: string + active?: boolean + closeOnClick?: boolean + render?: ReactElement + description?: ReactNode | A destination. In an item it is a bar entry; in content it is a row with an optional `description` line. `active` sets `aria-current="page"`; `render={<Link to="…" />}` keeps the roving focus on your router's link. |
|
|
17185
|
+
|
|
17186
|
+
## Props
|
|
17187
|
+
|
|
17188
|
+
| Name | Type | Default | Description |
|
|
17189
|
+
| --- | --- | --- | --- |
|
|
17190
|
+
| NavigationMenu (Root) | Base UI NavigationMenu.Root props (value, defaultValue, onValueChange, delay, closeDelay, orientation) + className | — | The `<nav>`. `value` is the open item's value (null when closed); `delay` / `closeDelay` tune hover-to-open. |
|
|
17191
|
+
| align | "start" \| "center" \| "end" | "start" | Alignment of the shared panel along its trigger. |
|
|
17192
|
+
| sideOffset | number | 8 | Gap between the bar and the panel, in px. The pointer crosses it on a bridge, so the panel does not close on the way in. |
|
|
17193
|
+
|
|
17194
|
+
## Tokens used
|
|
17195
|
+
|
|
17196
|
+
`popover`, `popover-foreground`, `border-floating`, `accent`, `accent-foreground`, `muted-foreground`, `foreground`, `ring`
|
|
17197
|
+
|
|
17198
|
+
## Motion
|
|
17199
|
+
|
|
17200
|
+
- **arrival** · Panel · _custom (outside the recipe layer)_ — The panel fades and scales in from its trigger like a popover, but it also resizes as the reader moves between triggers, which motion-pop cannot express: Base UI measures each content into --popup-width/--popup-height and the popup transitions to them on the motion tokens.
|
|
17201
|
+
- **response** · Trigger, Link · `motion-interactive` — Bar entries and panel rows fill on hover and focus like the other interactive surfaces, so the bar reads as live before anything opens.
|
|
17202
|
+
- **transition** · Panel · _custom (outside the recipe layer)_ — Moving from one trigger to the next keeps one panel open: it glides along the bar and the new content slides in from the direction of travel, so the reader sees one surface changing rather than two popups swapping. Snaps on first open and on resize (data-instant), where a glide would mean nothing.
|
|
17203
|
+
- **activity** · root · _no motion_ — Nothing in a nav moves on its own.
|
|
17204
|
+
|
|
17205
|
+
## Accessibility
|
|
17206
|
+
|
|
17207
|
+
- The root is a <nav>; give it an aria-label when the page has more than one nav ("Main").
|
|
17208
|
+
- Arrow keys move across the bar; Enter, Space or ArrowDown opens a trigger's content and moves focus into it; Esc closes and returns focus to the trigger.
|
|
17209
|
+
- A trigger is a button with aria-expanded; its content is reachable by Tab in reading order once open.
|
|
17210
|
+
- `active` on a link sets aria-current="page" and gives it full ink, so the current page is announced as well as shown.
|
|
17211
|
+
- Panel motion runs on the motion tokens, so reduced motion (or MotionProvider "reduced") collapses the glide and resize to instant.
|
|
17212
|
+
|
|
17213
|
+
## Related
|
|
17214
|
+
|
|
17215
|
+
- Components: Menu, SideNav, Sheet, AppHeader, Tabs
|
|
17216
|
+
|
|
17217
|
+
## Examples
|
|
17218
|
+
|
|
17219
|
+
### Bar with a panel
|
|
17220
|
+
|
|
17221
|
+
```tsx
|
|
17222
|
+
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@sableui/react"
|
|
17223
|
+
|
|
17224
|
+
<NavigationMenu aria-label="Main">
|
|
17225
|
+
<NavigationMenuList>
|
|
17226
|
+
<NavigationMenuItem>
|
|
17227
|
+
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
|
|
17228
|
+
<NavigationMenuContent className="grid w-[30rem] grid-cols-2">
|
|
17229
|
+
<NavigationMenuLink href="/editor" description="Write and review together.">Editor</NavigationMenuLink>
|
|
17230
|
+
<NavigationMenuLink href="/sync" description="Offline first, conflict free.">Sync</NavigationMenuLink>
|
|
17231
|
+
</NavigationMenuContent>
|
|
17232
|
+
</NavigationMenuItem>
|
|
17233
|
+
<NavigationMenuItem>
|
|
17234
|
+
<NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>
|
|
17235
|
+
</NavigationMenuItem>
|
|
17236
|
+
</NavigationMenuList>
|
|
17237
|
+
</NavigationMenu>
|
|
17238
|
+
```
|
|
17239
|
+
|
|
17240
|
+
### With a router, marking the current page
|
|
17241
|
+
|
|
17242
|
+
```tsx
|
|
17243
|
+
import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList } from "@sableui/react"
|
|
17244
|
+
// <Link> = your router's link (react-router, next/link, @tanstack/router, …)
|
|
17245
|
+
|
|
17246
|
+
<NavigationMenu aria-label="Main">
|
|
17247
|
+
<NavigationMenuList>
|
|
17248
|
+
<NavigationMenuItem>
|
|
17249
|
+
<NavigationMenuLink active={path === "/docs"} render={<Link to="/docs" />}>Docs</NavigationMenuLink>
|
|
17250
|
+
</NavigationMenuItem>
|
|
17251
|
+
<NavigationMenuItem>
|
|
17252
|
+
<NavigationMenuLink active={path === "/blog"} render={<Link to="/blog" />}>Blog</NavigationMenuLink>
|
|
17253
|
+
</NavigationMenuItem>
|
|
17254
|
+
</NavigationMenuList>
|
|
17255
|
+
</NavigationMenu>
|
|
17256
|
+
```
|
|
17257
|
+
|
|
17258
|
+
---
|
|
17259
|
+
|
|
16021
17260
|
# Note
|
|
16022
17261
|
|
|
16023
17262
|
**Status:** stable
|
|
@@ -16355,7 +17594,7 @@ import { Field, OtpField } from "@sableui/react"
|
|
|
16355
17594
|
|
|
16356
17595
|
An overflow ellipsis icon button that opens a Menu. Composes our Menu + Button + Icon — pass MenuItems as children and it wires the ghost icon trigger, accessible label, and portal-rendered content. All keyboard, focus, and dismissal behavior comes through Menu.
|
|
16357
17596
|
|
|
16358
|
-
**Keywords:** dots menu, more actions, ellipsis, kebab
|
|
17597
|
+
**Keywords:** dots menu, more actions, ellipsis, kebab, responsive actions, responsive action menu, collapse actions, actions overflow on mobile
|
|
16359
17598
|
|
|
16360
17599
|
## Usage
|
|
16361
17600
|
|
|
@@ -16365,6 +17604,7 @@ Use OverflowMenu for compact overflow actions when the primary action is already
|
|
|
16365
17604
|
|
|
16366
17605
|
- Do: Use it for secondary commands that belong to a row, card, or toolbar item.
|
|
16367
17606
|
- Do: Keep labels explicit so the icon-only trigger does not obscure the command meaning.
|
|
17607
|
+
- Do: For actions that fit inline on a wide screen but not a narrow one, render them twice: inline (a Row inside a wrapper shown from a container width; Row sets its own display, so the wrapper does the hiding), and as MenuItems in an OverflowMenu hidden from that width (`@container` + `@md:`). display:none takes the hidden copy out of the accessibility tree, so only one set is ever announced. Keep the primary action inline at every width.
|
|
16368
17608
|
- Don't: Do not hide the most important or most frequent action inside OverflowMenu.
|
|
16369
17609
|
- Don't: Do not use it as the only affordance on a touch surface. It is revealed on hover in most layouts, and hover does not exist on touch — pair it with a visible action or make it always-visible there.
|
|
16370
17610
|
|
|
@@ -16414,6 +17654,30 @@ import { Icon, MenuItem, MenuSeparator, OverflowMenu } from "@sableui/react"
|
|
|
16414
17654
|
<OverflowMenu><MenuItem><Icon name="check" className="text-sm" />Mark as read</MenuItem><MenuSeparator /><MenuItem variant="destructive"><Icon name="xmark" className="text-sm" />Delete</MenuItem></OverflowMenu>
|
|
16415
17655
|
```
|
|
16416
17656
|
|
|
17657
|
+
### Responsive actions: inline when wide, in a menu when narrow
|
|
17658
|
+
|
|
17659
|
+
```tsx
|
|
17660
|
+
import { Button, Icon, MenuItem, OverflowMenu, Row } from "@sableui/react"
|
|
17661
|
+
|
|
17662
|
+
<div className="@container/actions flex w-full justify-end">
|
|
17663
|
+
<Row gap={1} align="center">
|
|
17664
|
+
<div className="hidden @md/actions:block">
|
|
17665
|
+
<Row gap={1}>
|
|
17666
|
+
<Button variant="ghost" size="sm" iconStart={<Icon name="share" />}>Share</Button>
|
|
17667
|
+
<Button variant="ghost" size="sm" iconStart={<Icon name="download" />}>Export</Button>
|
|
17668
|
+
</Row>
|
|
17669
|
+
</div>
|
|
17670
|
+
<div className="@md/actions:hidden">
|
|
17671
|
+
<OverflowMenu label="Report actions" size="icon-sm">
|
|
17672
|
+
<MenuItem icon={<Icon name="share" />}>Share</MenuItem>
|
|
17673
|
+
<MenuItem icon={<Icon name="download" />}>Export</MenuItem>
|
|
17674
|
+
</OverflowMenu>
|
|
17675
|
+
</div>
|
|
17676
|
+
<Button size="sm" iconStart={<Icon name="edit" />}>Edit report</Button>
|
|
17677
|
+
</Row>
|
|
17678
|
+
</div>
|
|
17679
|
+
```
|
|
17680
|
+
|
|
16417
17681
|
---
|
|
16418
17682
|
|
|
16419
17683
|
# PageHeader
|
|
@@ -16570,9 +17834,9 @@ import { PageLoader } from "@sableui/react"
|
|
|
16570
17834
|
|
|
16571
17835
|
**Status:** stable
|
|
16572
17836
|
|
|
16573
|
-
Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.
|
|
17837
|
+
Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. With pageLink every control is a real link (crawlable, opens in a new tab, any router through render). Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.
|
|
16574
17838
|
|
|
16575
|
-
**Keywords:** pagination, pager, pages, table, results, navigation, next, previous
|
|
17839
|
+
**Keywords:** pagination, pager, pages, table, results, navigation, next, previous, page links, seo, url
|
|
16576
17840
|
|
|
16577
17841
|
## Usage
|
|
16578
17842
|
|
|
@@ -16582,6 +17846,7 @@ Use Pagination when a result set is split into discrete pages and the user needs
|
|
|
16582
17846
|
|
|
16583
17847
|
- Do: Keep page state controlled by the parent route or data layer so URL, filters, and result fetches stay in sync.
|
|
16584
17848
|
- Do: Use a specific label when multiple pagers appear on a page, such as "Customer pages".
|
|
17849
|
+
- Do: Pass pageLink when the page lives in the URL (search results, a blog, a catalogue): each page becomes a link a crawler can follow and a reader can open in a new tab. Use a router's Link there so navigation stays in the app.
|
|
16585
17850
|
- Do: Reset to page 1 when filters or search terms materially change the result set.
|
|
16586
17851
|
- Don't: Use Pagination for tiny lists where all items fit comfortably on one page.
|
|
16587
17852
|
- Don't: Hide disabled previous/next buttons at the bounds; disabled controls make the current position clearer.
|
|
@@ -16601,7 +17866,8 @@ Use Pagination when a result set is split into discrete pages and the user needs
|
|
|
16601
17866
|
| --- | --- | --- | --- |
|
|
16602
17867
|
| page | number | — | The active page (1-based). Clamped into the valid range. |
|
|
16603
17868
|
| total | number | — | Total number of pages. |
|
|
16604
|
-
| onPageChange | (page: number) => void | — | Called with the next page when a control is activated. No-op for out-of-range or unchanged values. |
|
|
17869
|
+
| onPageChange | (page: number) => void | — | Called with the next page when a control is activated. No-op for out-of-range or unchanged values. Optional with pageLink, where the URL is the page state. |
|
|
17870
|
+
| pageLink | (page: number) => ReactElement | — | Render each page control as a link: return the element for a page, (page) => <a href={`?page=${page}`} /> or a router's <Link to={`?page=${page}`} />. The current page keeps aria-current; previous/next at the bounds stay disabled buttons. onPageChange becomes optional and, if set, still fires on click. |
|
|
16605
17871
|
| siblingCount | number | 1 | Page buttons shown on each side of the current page before the list collapses into ellipses. First and last pages are always shown. |
|
|
16606
17872
|
| label | string | Pagination | Accessible label applied to the surrounding nav element. |
|
|
16607
17873
|
| className | string | — | Extra classes merged onto the nav. |
|
|
@@ -16615,6 +17881,7 @@ Use Pagination when a result set is split into discrete pages and the user needs
|
|
|
16615
17881
|
- Renders a landmark <nav> with aria-label so assistive tech can jump to the pager.
|
|
16616
17882
|
- The current page button carries aria-current='page'.
|
|
16617
17883
|
- Prev/next buttons are real <button>s with aria-label and become disabled at the bounds.
|
|
17884
|
+
- With pageLink the controls are real <a> links (or the router's), so they are crawlable and open in a new tab; a bound arrow stays a disabled button rather than a link to nowhere.
|
|
16618
17885
|
- Ellipsis slots are aria-hidden — they convey no navigable target.
|
|
16619
17886
|
|
|
16620
17887
|
## Related
|
|
@@ -16650,6 +17917,15 @@ import { DataTable, Pagination, Stack } from "@sableui/react"
|
|
|
16650
17917
|
<Stack gap={2}><DataTable data={rows} columns={columns} rowKey={(row) => row.id} /><Pagination label="Customer pages" page={page} total={12} onPageChange={setPage} /></Stack>
|
|
16651
17918
|
```
|
|
16652
17919
|
|
|
17920
|
+
### Page links (URL is the state)
|
|
17921
|
+
|
|
17922
|
+
```tsx
|
|
17923
|
+
import { Pagination } from "@sableui/react"
|
|
17924
|
+
// <Link> = your router's link (react-router, next/link, @tanstack/router, …)
|
|
17925
|
+
|
|
17926
|
+
<Pagination page={page} total={pageCount} pageLink={(page) => <Link to={`/search?q=${query}&page=${page}`} />} />
|
|
17927
|
+
```
|
|
17928
|
+
|
|
16653
17929
|
### Server query state
|
|
16654
17930
|
|
|
16655
17931
|
```tsx
|
|
@@ -17874,6 +19150,7 @@ Use Scroller to create a contained overflow region for content that naturally ex
|
|
|
17874
19150
|
| --- | --- | --- | --- |
|
|
17875
19151
|
| orientation | "vertical" \| "horizontal" | "vertical" | "vertical" scrolls and fades top/bottom; "horizontal" scrolls and fades left/right. |
|
|
17876
19152
|
| wheelScroll | boolean | true | On a horizontal Scroller, let a vertical mouse wheel scroll it sideways. Ignored when vertical. Turn it off only if the region is inside something that owns the wheel itself (a map, a canvas). |
|
|
19153
|
+
| smoothWheel | boolean | true | Ease mouse-wheel steps toward their target, on either axis: the inertia a smooth-scroll library such as Lenis adds, scoped to this region. Only a mouse wheel is eased; trackpads and touch keep their own momentum, keys and scrollbar drags stay native, and reduced motion (the OS preference or MotionProvider) turns it off. Anything else that moves the region (scrollIntoView, a stick-to-bottom chat) stops the easing at once. For a scroll container of your own, `useSmoothWheel()` returns the same behaviour as a ref callback. |
|
|
17877
19154
|
| label | string | — | Accessible name for the scroll region. The region takes focus, so it should say what it contains; without one a screen reader announces an unnamed group. |
|
|
17878
19155
|
| viewportClassName | string | — | Classes for the inner element that actually scrolls. Narrow on purpose — `className` is what you want almost always. This is for the few utilities that only mean something on the scrolling box: `snap-x`, `scroll-smooth`, `scroll-p-*`. |
|
|
17879
19156
|
| ...div props | ComponentPropsWithRef<'div'> | — | Everything except `className`/`style` spreads onto the SCROLLING element — `role`, `id`, `aria-*`, `data-*` and event handlers all describe the thing that scrolls, and CommandMenu relies on it to make this component its listbox. `ref` and `onScroll` likewise address the scroller, and compose with the component's own. |
|
|
@@ -18705,7 +19982,7 @@ import { MenuRadioGroup, MenuRadioItem, MenuSeparator, SettingsMenu, SettingsRow
|
|
|
18705
19982
|
|
|
18706
19983
|
Edge-anchored drawer over Base UI Drawer — focus trap, scroll lock, Esc/outside dismissal, portal backdrop, plus swipe-to-dismiss on touch. Slides in from a viewport edge via side and swipes back out toward the same edge.
|
|
18707
19984
|
|
|
18708
|
-
**Keywords:** sheet, drawer, side panel, overlay, swipe to dismiss
|
|
19985
|
+
**Keywords:** sheet, drawer, side panel, overlay, swipe to dismiss, bottom sheet, snap points, detents
|
|
18709
19986
|
|
|
18710
19987
|
## Usage
|
|
18711
19988
|
|
|
@@ -18715,6 +19992,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
18715
19992
|
|
|
18716
19993
|
- Do: Use it for inspect, edit, filter, or create flows that relate to the current page.
|
|
18717
19994
|
- Do: Include clear title, close path, and footer actions for task-oriented sheets.
|
|
19995
|
+
- Do: Give a bottom sheet snap points when it shows a summary first and detail on demand (a map result, a now-playing panel): `snapPoints={[0.4, 1]}`. The first stop is where it opens; put what matters at that height, because the rest is a drag away.
|
|
18718
19996
|
- Don't: Do not use Sheet for simple confirmations or deeply nested workflows.
|
|
18719
19997
|
- Don't: Do not use it on desktop where a Dialog fits. A sheet's affordance is edge-anchored travel for thumb reach; on a wide viewport it becomes a dialog that arrives from the wrong place.
|
|
18720
19998
|
|
|
@@ -18735,7 +20013,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
18735
20013
|
|
|
18736
20014
|
| Name | Type | Description |
|
|
18737
20015
|
| --- | --- | --- |
|
|
18738
|
-
| SheetContent | Base UI Drawer.Popup props (initialFocus, finalFocus, …) + side: "right" \| "left" \| "top" \| "bottom" + className | Backdrop + edge-anchored popup; portals to <body>. side picks which viewport edge the sheet anchors to and slides in from (defaults to "right"). Left/right are full-height side panels; top/bottom span the width up to 80vh. On touch it swipes closed toward that same edge (right swipes right, left swipes left, top swipes up, bottom swipes down). |
|
|
20016
|
+
| SheetContent | Base UI Drawer.Popup props (initialFocus, finalFocus, …) + side: "right" \| "left" \| "top" \| "bottom" + className | Backdrop + edge-anchored popup; portals to <body>. side picks which viewport edge the sheet anchors to and slides in from (defaults to "right"). Left/right are full-height side panels; top/bottom span the width up to 80vh. A bottom sheet with snapPoints is as tall as its highest stop allows (the viewport less a top gap), shows a drag handle, and translates down to the active stop; the part below the screen becomes bottom padding, so its scroll area and SheetFooter end at the visible edge. A bottom sheet rides above the on-screen keyboard (measured from visualViewport). On touch it swipes closed toward that same edge (right swipes right, left swipes left, top swipes up, bottom swipes down). |
|
|
18739
20017
|
| SheetTrigger | Base UI Drawer.Trigger props (render) | Element that opens the sheet. Compose via render prop. |
|
|
18740
20018
|
| SheetClose | Base UI Drawer.Close props (render) | Element that dismisses the sheet. Compose via render prop. |
|
|
18741
20019
|
| SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
|
|
@@ -18748,7 +20026,11 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
18748
20026
|
|
|
18749
20027
|
| Name | Type | Default | Description |
|
|
18750
20028
|
| --- | --- | --- | --- |
|
|
18751
|
-
| Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle) — minus swipeDirection
|
|
20029
|
+
| Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. |
|
|
20030
|
+
| snapPoints | (number \| string)[] | — | Stops a bottom sheet rests at, smallest first: a fraction of the viewport (0.4), pixels (320), or a px/rem string ("20rem"). The panel is the full stop's height and opens at the first stop; a swipe moves between stops and a swipe below the first closes it. side="bottom" only: other sides ignore them, with a development warning. |
|
|
20031
|
+
| snapPoint | number \| string \| null | — | The active stop, controlled. Pair with onSnapPointChange; use defaultSnapPoint to open at a different stop uncontrolled. |
|
|
20032
|
+
| onSnapPointChange | (snapPoint: number \| string \| null, eventDetails) => void | — | Fires when a swipe (or your code) moves the sheet to another stop. |
|
|
20033
|
+
| snapToSequentialPoints | boolean | false | Move one stop per swipe, by distance. Off, a fast flick can skip past a stop. |
|
|
18752
20034
|
|
|
18753
20035
|
## Variants
|
|
18754
20036
|
|
|
@@ -18768,6 +20050,8 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
18768
20050
|
- Render a SheetClose inside SheetContent so screen-reader users can escape the popup.
|
|
18769
20051
|
- Slide animation runs via data-[starting-style]/data-[ending-style] attributes on the motion-duration token; while swiping the panel tracks the finger directly, and a released swipe finishes on the same token (scaled by flick speed), so reduced motion makes it instant.
|
|
18770
20052
|
- A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge.
|
|
20053
|
+
- Snap points add stops, not a gate: at any stop the content scrolls within the visible part and every control stays reachable by keyboard, so nothing needs the drag. The handle is decorative (aria-hidden).
|
|
20054
|
+
- A bottom sheet stays above the on-screen keyboard: its bottom edge is lifted by the height the keyboard takes from the visual viewport, so a focused field is not covered. An app that sets interactive-widget=resizes-content in its viewport meta gets the same result from the browser, and the inset reads 0.
|
|
18771
20055
|
|
|
18772
20056
|
## Related
|
|
18773
20057
|
|
|
@@ -18799,6 +20083,14 @@ import { Button, Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, Shee
|
|
|
18799
20083
|
<Sheet><SheetTrigger render={<Button variant="secondary">Menu</Button>} /><SheetContent side="left"><SheetHeader><SheetTitle>Navigation</SheetTitle></SheetHeader><SheetFooter><SheetClose render={<Button variant="secondary">Close</Button>} /></SheetFooter></SheetContent></Sheet>
|
|
18800
20084
|
```
|
|
18801
20085
|
|
|
20086
|
+
### Bottom sheet with snap points
|
|
20087
|
+
|
|
20088
|
+
```tsx
|
|
20089
|
+
import { Button, Entity, Sheet, SheetContent, SheetDescription, SheetHeader, SheetList, SheetTitle, SheetTrigger } from "@sableui/react"
|
|
20090
|
+
|
|
20091
|
+
<Sheet snapPoints={[0.4, 1]}><SheetTrigger render={<Button variant="secondary">Nearby</Button>} /><SheetContent side="bottom"><SheetHeader><SheetTitle>12 places nearby</SheetTitle><SheetDescription>Drag up for the full list.</SheetDescription></SheetHeader><SheetList>{places.map((place) => <Entity key={place.id} title={place.name} subtitle={place.distance} />)}</SheetList></SheetContent></Sheet>
|
|
20092
|
+
```
|
|
20093
|
+
|
|
18802
20094
|
### Bottom side
|
|
18803
20095
|
|
|
18804
20096
|
```tsx
|
|
@@ -18887,6 +20179,7 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
|
|
|
18887
20179
|
|
|
18888
20180
|
- Do: Use it for stable app destinations that should remain visible while content changes.
|
|
18889
20181
|
- Do: Keep item labels predictable and active state obvious.
|
|
20182
|
+
- Do: Set surface="page" when a full-width top bar sits above the rail. A theme's tinted or dark rail is for a full-height rail that frames the app.
|
|
18890
20183
|
- Don't: Do not use Sidebar for page-local settings, filters, or temporary action groups.
|
|
18891
20184
|
- Don't: Do not put page content in it. It is fixed-width chrome OUTSIDE the content grid, so anything in it is exempt from the page's measure and rhythm.
|
|
18892
20185
|
|
|
@@ -18912,6 +20205,7 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
|
|
|
18912
20205
|
| collapsed | boolean | false | Collapse the rail to an icon-only width (--sidebar-width-collapsed). Items hide their label + badge and reveal the label in a hover tooltip; the content area reflows. Controlled — wire a toggle from app state. Read the state in custom header/footer content via useSidebar(). |
|
|
18913
20206
|
| icons | boolean | true | Render the leading icon on each SidebarItem. Set false for a text-only rail. Ignored while collapsed (icon-only by definition). |
|
|
18914
20207
|
| variant | "app" \| "inline" | "app" | Where the rail lives. "app" is the page's chrome column — sticky, full viewport height, hidden below lg where AppHeader + BottomNav take over. "inline" is always visible and fills its parent's height, for a rail inside a bounded frame (a nested shell, an embedded preview). Use it instead of overriding the layout with !important classes. |
|
|
20208
|
+
| surface | "rail" \| "page" | "rail" | Which surface the rail is painted on. "rail" is its own --sidebar-* surface with a border — right for a full-height rail that frames the app, which a theme may tint or paint dark. "page" uses the page's surface and ink with no border — use it when a full-width header sits above the rail, where a tinted column starting under a page-coloured bar reads as pasted on. Use it instead of overriding bg-/text- classes, which the rail's re-pointed tokens defeat. |
|
|
18915
20209
|
|
|
18916
20210
|
## Variants
|
|
18917
20211
|
|
|
@@ -19748,6 +21042,7 @@ Use StreamingText for text whose final length is unknown while it renders — a
|
|
|
19748
21042
|
|
|
19749
21043
|
### Best practices
|
|
19750
21044
|
|
|
21045
|
+
- Do: In a chat, read `sable docs building-a-chat`: one status drives `streaming` and the composer, a regenerated answer takes a new key (the text is append-only), and the finished turn is announced once.
|
|
19751
21046
|
- Do: Grow `text` and let the component do the rest. It assumes an append-only string and keys units by index, so React mounts only the new spans — re-rendering a longer string animates what was added and leaves what the reader has already read alone.
|
|
19752
21047
|
- Do: Drop `streaming` to false the moment the source closes. That is what removes the caret and the softened tail, and their disappearance is the only signal the reader gets that the answer is complete.
|
|
19753
21048
|
- Do: Turn `caret` off when something adjacent already reports the wait — a spinner in the message header, a status row beneath. Two indicators for one wait read as two things happening.
|
|
@@ -19810,7 +21105,7 @@ import { StreamingText } from "@sableui/react"
|
|
|
19810
21105
|
```tsx
|
|
19811
21106
|
import { Message, StreamingText } from "@sableui/react"
|
|
19812
21107
|
|
|
19813
|
-
<Message
|
|
21108
|
+
<Message align="start"><StreamingText text={answer} streaming={pending} caret={false} /></Message>
|
|
19814
21109
|
```
|
|
19815
21110
|
|
|
19816
21111
|
### A headline resolving character by character
|