@sableui/react 0.1.1 → 0.3.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 +3 -1
- package/CHANGELOG.md +120 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +1 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +68 -26
- package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
- package/dist/blocks/index.d.ts +396 -0
- package/dist/blocks/index.js +1997 -0
- package/dist/chunk-6LCIBMMG.js +4908 -0
- package/dist/{chunk-J23ELQQZ.js → chunk-CKNM7KSB.js} +1664 -4270
- package/dist/chunk-K6LII3OE.js +5093 -0
- package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
- package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
- package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
- package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
- package/dist/foundations/index.js +1 -1
- package/dist/icon/index.js +2 -2
- package/dist/icon-sets/reicon.js +1 -1
- package/dist/index.d.ts +624 -133
- package/dist/index.js +767 -4964
- package/dist/ios/index.d.ts +14 -2
- package/dist/ios/index.js +10 -9
- package/dist/ios/preview.d.ts +11 -1
- package/dist/ios/preview.js +14 -9
- package/dist/ios/styles.css +26 -6
- package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
- package/dist/patterns/index.d.ts +1 -1
- package/dist/patterns/index.js +1684 -471
- package/dist/presets/index.d.ts +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/styles.css +238 -40
- package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
- package/dist/themes/atelier/atelier.css +3 -0
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/base/base.css +34 -4
- package/dist/themes/base/index.d.ts +1 -1
- package/dist/themes/ember/ember.css +3 -0
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/index.js +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/meridian/meridian.css +10 -5
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/poppy/poppy.css +16 -0
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/prism/prism.css +4 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/dist/themes/verdant/verdant.css +7 -2
- package/llms/blocks/api-keys-block.md +79 -0
- package/llms/blocks/chat-thread-block.md +51 -0
- package/llms/blocks/command-palette-block.md +48 -0
- package/llms/blocks/cta-band-block.md +62 -0
- package/llms/blocks/danger-zone-block.md +69 -0
- package/llms/blocks/device-comparison-block.md +91 -0
- package/llms/blocks/event-card-block.md +84 -0
- package/llms/blocks/faq-block.md +72 -0
- package/llms/blocks/feature-grid-block.md +75 -0
- package/llms/blocks/feedback-form-block.md +66 -0
- package/llms/blocks/goal-progress-block.md +54 -0
- package/llms/blocks/hero-block.md +65 -0
- package/llms/blocks/invite-team-block.md +61 -0
- package/llms/blocks/invoice-history-block.md +84 -0
- package/llms/blocks/kpi-sparkline-block.md +57 -0
- package/llms/blocks/media-player-block.md +100 -0
- package/llms/blocks/newsletter-block.md +64 -0
- package/llms/blocks/not-found-block.md +56 -0
- package/llms/blocks/notification-list-block.md +63 -0
- package/llms/blocks/notification-settings-block.md +54 -0
- package/llms/blocks/order-tracking-block.md +62 -0
- package/llms/blocks/pricing-card-block.md +82 -0
- package/llms/blocks/pricing-table-block.md +129 -0
- package/llms/blocks/product-card-block.md +69 -0
- package/llms/blocks/profile-card-block.md +103 -0
- package/llms/blocks/profile-form-block.md +114 -0
- package/llms/blocks/revenue-breakdown-block.md +70 -0
- package/llms/blocks/sign-in-block.md +64 -0
- package/llms/blocks/sign-up-block.md +78 -0
- package/llms/blocks/site-footer-block.md +96 -0
- package/llms/blocks/stat-row-block.md +59 -0
- package/llms/blocks/steps-block.md +73 -0
- package/llms/blocks/task-list-block.md +81 -0
- package/llms/blocks/team-members-block.md +91 -0
- package/llms/blocks/testimonial-block.md +78 -0
- package/llms/blocks/traffic-sources-block.md +63 -0
- package/llms/blocks/traffic-trend-block.md +95 -0
- package/llms/blocks/upload-list-block.md +66 -0
- package/llms/blocks/verify-code-block.md +59 -0
- package/llms/blocks/wallet-block.md +84 -0
- package/llms/components/area-chart.md +112 -0
- package/llms/components/bar-chart.md +131 -0
- package/llms/components/bubble.md +5 -5
- package/llms/components/button.md +1 -1
- package/llms/components/card.md +2 -2
- package/llms/components/checkbox-card.md +1 -1
- package/llms/components/context-card.md +2 -2
- package/llms/components/context-menu.md +1 -1
- package/llms/components/date-tile.md +79 -0
- package/llms/components/dialog.md +1 -1
- package/llms/components/empty-state.md +4 -4
- package/llms/components/entity.md +5 -3
- package/llms/components/fieldset.md +1 -1
- package/llms/components/kbd.md +18 -6
- package/llms/components/line-chart.md +101 -0
- package/llms/components/menu.md +1 -1
- package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
- package/llms/components/pie-chart.md +103 -0
- package/llms/components/pill.md +1 -1
- package/llms/components/radar-chart.md +106 -0
- package/llms/components/radial-chart.md +102 -0
- package/llms/components/segmented-bar.md +102 -0
- package/llms/components/settings-menu.md +2 -2
- package/llms/components/side-nav.md +1 -1
- package/llms/components/skeleton.md +1 -1
- package/llms/components/sparkline.md +80 -0
- package/llms/components/table.md +1 -1
- package/llms/components/theme-selector.md +1 -1
- package/llms/foundations/shape.md +1 -1
- package/llms/patterns/auth-page.md +132 -42
- package/llms/patterns/brand-landing-page.md +271 -175
- package/llms/patterns/chat-panel.md +120 -39
- package/llms/patterns/dashboard-page.md +276 -65
- package/llms/patterns/feed-page.md +245 -64
- package/llms/patterns/landing-page.md +473 -75
- package/llms/patterns/list-detail-page.md +377 -68
- package/llms/patterns/settings-page.md +268 -67
- package/llms/reference/token-intent.md +4 -2
- package/llms/reference/utilities.md +20 -5
- package/llms/tasks/building-an-ios-prototype.md +4 -1
- package/llms/tasks/choosing-a-component.md +2 -2
- package/llms-full.txt +6428 -892
- package/llms.txt +67 -16
- package/package.json +5 -1
- package/registry.json +3217 -552
- package/targets/ios.json +88 -18
- package/llms/components/description.md +0 -96
- package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# EventCardBlock
|
|
2
|
+
|
|
3
|
+
An upcoming-event card: a DateTile beside the title and time, a "starts in" Badge, attendees as an AvatarGroup, and Decline / Join.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for the next meeting, booking or live session on a dashboard or home screen. For a list of many upcoming events, use Entity rows with a DateTile as `leading`.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The DateTile names the day; the full weekday and time range sit in text beside it.
|
|
12
|
+
- The Badge is one title line tall and wraps under the title rather than squeezing it onto two lines.
|
|
13
|
+
- AvatarGroup `fit` trades avatars for the +N count before it pushes the actions to their own line.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- DateTile is a <time> with a machine-readable date; the time range is plain text read in order after the title.
|
|
18
|
+
- The AvatarGroup's aria-label states the total attendee count.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Avatar, AvatarGroup, Badge, Button, Card, CardContent, CardDescription, CardFooter, CardTitle, cn, DateTile } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const event = {
|
|
26
|
+
title: "Design review",
|
|
27
|
+
date: new Date(2026, 9, 14),
|
|
28
|
+
when: "Tuesday, 2:00 – 2:45 PM",
|
|
29
|
+
starts: "In 10 min",
|
|
30
|
+
attendees: ["MO", "SR", "JL"],
|
|
31
|
+
total: 7,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EventCardBlockProps {
|
|
35
|
+
className?: string
|
|
36
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
37
|
+
framed?: boolean
|
|
38
|
+
/** Called with the person's answer. */
|
|
39
|
+
onRespond?: (answer: "join" | "decline") => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function EventCardBlock({ className, framed = true, onRespond }: EventCardBlockProps) {
|
|
43
|
+
return (
|
|
44
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
45
|
+
<CardContent className="flex gap-4">
|
|
46
|
+
<DateTile date={event.date} />
|
|
47
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
48
|
+
{/* The row carries the title's type, so the badge's box is exactly
|
|
49
|
+
one title line (`h-[1lh]`): it centres on the title and never
|
|
50
|
+
makes the row taller than the title alone. When a bolder or
|
|
51
|
+
roomier look leaves no space beside the title, the badge wraps
|
|
52
|
+
to its own line instead of squeezing the title onto two. */}
|
|
53
|
+
<div className="flex flex-wrap items-start justify-between gap-x-2 text-title-lg">
|
|
54
|
+
<CardTitle>{event.title}</CardTitle>
|
|
55
|
+
<span className="flex h-[1lh] shrink-0 items-center">
|
|
56
|
+
<Badge tone="info" size="sm">
|
|
57
|
+
{event.starts}
|
|
58
|
+
</Badge>
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
<CardDescription>{event.when}</CardDescription>
|
|
62
|
+
</div>
|
|
63
|
+
</CardContent>
|
|
64
|
+
{/* `fit`: when a roomy look tightens the row, the stack gives up
|
|
65
|
+
avatars to the count instead of pushing the actions to a new line. */}
|
|
66
|
+
<CardFooter className="flex-wrap justify-between gap-3">
|
|
67
|
+
<AvatarGroup aria-label={`${event.total} attendees`} total={event.total} size="sm" surface={framed ? "card" : undefined} fit>
|
|
68
|
+
{event.attendees.map((initials) => (
|
|
69
|
+
<Avatar key={initials} fallback={initials} alt={initials} />
|
|
70
|
+
))}
|
|
71
|
+
</AvatarGroup>
|
|
72
|
+
<div className="flex gap-2">
|
|
73
|
+
<Button variant="outline" size="sm" onClick={() => onRespond?.("decline")}>
|
|
74
|
+
Decline
|
|
75
|
+
</Button>
|
|
76
|
+
<Button size="sm" onClick={() => onRespond?.("join")}>
|
|
77
|
+
Join
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
</CardFooter>
|
|
81
|
+
</Card>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# FaqBlock
|
|
2
|
+
|
|
3
|
+
A FAQ section: the heading and a way to ask beside an Accordion of questions, each answer one or two sentences.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this near pricing or at the end of a landing page, for the questions that block a decision. Long help content belongs in docs, not an accordion.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Every question starts closed: the list of questions is itself the useful summary.
|
|
12
|
+
- The way to ask a human sits beside the heading, so it is found before the reader scrolls the list.
|
|
13
|
+
- Capped at the `content` region width; questions wider than that are hard to scan.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Accordion triggers are buttons with aria-expanded, inside headings, so the questions are navigable by heading and operable by keyboard.
|
|
18
|
+
- The contact link is a real <a> inside the lede sentence.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useId } from "react"
|
|
24
|
+
import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, cn, Region, Stack } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const intro = {
|
|
27
|
+
title: "Questions, answered",
|
|
28
|
+
lede: "Can't find what you need?",
|
|
29
|
+
contact: { label: "Write to us", href: "/contact" },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const questions = [
|
|
33
|
+
{ q: "Is there a free plan?", a: "Yes. Starter is free forever for three live editions, with no fees on your first $1,000 in sales." },
|
|
34
|
+
{ q: "Can I change plans later?", a: "Any time. Upgrades apply immediately and are prorated; downgrades take effect at the end of the billing period." },
|
|
35
|
+
{ q: "Who owns the work I sell?", a: "You do. Collectors own the editions they buy, and you keep the rights to the work itself." },
|
|
36
|
+
{ q: "How do payouts work?", a: "Sales clear after seven days and are paid out every Friday to the account you connect in settings." },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export interface FaqBlockProps {
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FaqBlock({ className }: FaqBlockProps) {
|
|
44
|
+
const titleId = useId()
|
|
45
|
+
return (
|
|
46
|
+
<Region as="section" aria-labelledby={titleId} max="content" className={cn("w-full py-16", className)}>
|
|
47
|
+
<div className="grid gap-10 md:grid-cols-[1fr_2fr]">
|
|
48
|
+
<Stack gap={2}>
|
|
49
|
+
<h2 id={titleId} className="text-balance text-heading-2">
|
|
50
|
+
{intro.title}
|
|
51
|
+
</h2>
|
|
52
|
+
<p className="text-body-md text-muted-foreground">
|
|
53
|
+
{intro.lede}{" "}
|
|
54
|
+
<a href={intro.contact.href} className="text-foreground underline underline-offset-4 motion-interactive hover:text-muted-foreground">
|
|
55
|
+
{intro.contact.label}
|
|
56
|
+
</a>
|
|
57
|
+
.
|
|
58
|
+
</p>
|
|
59
|
+
</Stack>
|
|
60
|
+
<Accordion>
|
|
61
|
+
{questions.map((item) => (
|
|
62
|
+
<AccordionItem key={item.q} value={item.q}>
|
|
63
|
+
<AccordionTrigger>{item.q}</AccordionTrigger>
|
|
64
|
+
<AccordionPanel>{item.a}</AccordionPanel>
|
|
65
|
+
</AccordionItem>
|
|
66
|
+
))}
|
|
67
|
+
</Accordion>
|
|
68
|
+
</div>
|
|
69
|
+
</Region>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# FeatureGridBlock
|
|
2
|
+
|
|
3
|
+
A feature section: an overline, an h2 and a lede over a grid of article cards, each with an icon in a well, a title and one sentence — 1, 2 then 3 columns.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to explain what a product does in six or fewer points. For a sequence (first this, then that), use StepsBlock; for one proof point with numbers, TestimonialBlock.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the LandingPage page shell's features section.
|
|
12
|
+
- Six features fill 3 × 2 evenly; keep the count a multiple of three, or drop to cols={{ lg: 2 }}.
|
|
13
|
+
- Each body is one sentence. A feature that needs a paragraph needs its own section.
|
|
14
|
+
- Icons sit in a `sable-well`, which derives its fill from the card surface it is on.
|
|
15
|
+
|
|
16
|
+
## Accessibility
|
|
17
|
+
|
|
18
|
+
- Each feature is an <article> with an h3 under the section's h2.
|
|
19
|
+
- The icons are decorative; each title says what the feature is.
|
|
20
|
+
|
|
21
|
+
## Source
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { useId } from "react"
|
|
25
|
+
import { Card, CardContent, cn, Grid, Icon, Region, Stack } from "@sableui/react"
|
|
26
|
+
|
|
27
|
+
const intro = {
|
|
28
|
+
eyebrow: "Why Sable",
|
|
29
|
+
title: "A design system with the rules written down",
|
|
30
|
+
lede: "The decisions a senior designer would make are encoded once, so every screen your team or your agent builds starts from them.",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const features = [
|
|
34
|
+
{ icon: "component", title: "Behavior built in", body: "Focus management, keyboard navigation, and ARIA come from Base UI primitives under every control." },
|
|
35
|
+
{ icon: "eyedropper", title: "Semantic tokens end to end", body: "Re-theme the whole product, light and dark, from one preset without touching a component." },
|
|
36
|
+
{ icon: "layout", title: "Pages, not just parts", body: "Landing, dashboard, settings, auth, and list-detail shells to copy and adapt." },
|
|
37
|
+
{ icon: "book", title: "A contract agents can read", body: "registry.json, llms.txt, and a CLI hand agents the exact props, examples, and rules." },
|
|
38
|
+
{ icon: "circle-check", title: "Lint that knows the design", body: "sable-lint flags raw colors, hand-built controls, and motion that ignores reduced motion." },
|
|
39
|
+
{ icon: "play", title: "Motion with an off switch", body: "Named recipes run on one scale variable, so reduced motion stops every transition at once." },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export interface FeatureGridBlockProps {
|
|
43
|
+
className?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function FeatureGridBlock({ className }: FeatureGridBlockProps) {
|
|
47
|
+
const titleId = useId()
|
|
48
|
+
return (
|
|
49
|
+
<Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16 sm:py-24", className)}>
|
|
50
|
+
<Stack gap={10}>
|
|
51
|
+
<Stack gap={3} className="max-w-2xl">
|
|
52
|
+
<p className="text-overline text-muted-foreground">{intro.eyebrow}</p>
|
|
53
|
+
<h2 id={titleId} className="text-balance text-heading-1">
|
|
54
|
+
{intro.title}
|
|
55
|
+
</h2>
|
|
56
|
+
<p className="text-body-lg text-muted-foreground">{intro.lede}</p>
|
|
57
|
+
</Stack>
|
|
58
|
+
<Grid cols={{ base: 1, sm: 2, lg: 3 }} gap={4}>
|
|
59
|
+
{features.map((feature) => (
|
|
60
|
+
<Card key={feature.title} render={<article />} className="h-full">
|
|
61
|
+
<CardContent className="flex flex-col gap-3">
|
|
62
|
+
<span className="grid size-10 place-items-center rounded-md sable-well">
|
|
63
|
+
<Icon name={feature.icon} className="text-xl" />
|
|
64
|
+
</span>
|
|
65
|
+
<h3 className="pt-2 text-title-lg">{feature.title}</h3>
|
|
66
|
+
<p className="text-body-sm text-muted-foreground">{feature.body}</p>
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
))}
|
|
70
|
+
</Grid>
|
|
71
|
+
</Stack>
|
|
72
|
+
</Region>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# FeedbackFormBlock
|
|
2
|
+
|
|
3
|
+
A feedback card: a one-to-five rating as a full-width ToggleGroup, an optional notes field, and Skip / Send feedback.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for a post-session or post-purchase survey in a Sheet, Dialog or end-of-flow screen. For a feedback control anchored to a button in the app chrome, use the Feedback component (a popover).
|
|
6
|
+
|
|
7
|
+
**Block:** Social & messaging · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The rating is preselected, so one tap on Send is a complete answer; the notes are optional and say so.
|
|
12
|
+
- Skip is a ghost Button before the primary, so dismissing is always available and never competes with sending.
|
|
13
|
+
- `onDone` reports whether feedback was sent and, if so, the score and notes.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The ToggleGroup carries aria-label="Rating"; each Toggle's number is its name.
|
|
18
|
+
- The Textarea carries aria-label="Notes"; its placeholder is a prompt, not the label.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Textarea, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const scale = ["1", "2", "3", "4", "5"]
|
|
27
|
+
|
|
28
|
+
export interface FeedbackFormBlockProps {
|
|
29
|
+
className?: string
|
|
30
|
+
/** false drops the card's surface and border — for a Sheet or Dialog that already frames it. */
|
|
31
|
+
framed?: boolean
|
|
32
|
+
/** Skip calls it with `sent: false`; Send with `sent: true` and the answers. */
|
|
33
|
+
onDone?: (sent: boolean, feedback?: { score: number; notes: string }) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FeedbackFormBlock({ className, framed = true, onDone }: FeedbackFormBlockProps) {
|
|
37
|
+
const [score, setScore] = useState("4")
|
|
38
|
+
const [notes, setNotes] = useState("")
|
|
39
|
+
return (
|
|
40
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle>How was the session?</CardTitle>
|
|
43
|
+
<CardDescription>One tap is enough. Notes help us more.</CardDescription>
|
|
44
|
+
</CardHeader>
|
|
45
|
+
<CardContent className="flex flex-col gap-4">
|
|
46
|
+
<ToggleGroup value={[score]} onValueChange={(v: unknown[]) => v[0] && setScore(String(v[0]))} aria-label="Rating" className="w-full">
|
|
47
|
+
{scale.map((n) => (
|
|
48
|
+
<Toggle key={n} value={n} className="flex-1">
|
|
49
|
+
{n}
|
|
50
|
+
</Toggle>
|
|
51
|
+
))}
|
|
52
|
+
</ToggleGroup>
|
|
53
|
+
<Textarea placeholder="What worked, what didn’t…" aria-label="Notes" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
54
|
+
</CardContent>
|
|
55
|
+
<CardFooter className="justify-end">
|
|
56
|
+
<Button variant="ghost" size="sm" onClick={() => onDone?.(false)}>
|
|
57
|
+
Skip
|
|
58
|
+
</Button>
|
|
59
|
+
<Button size="sm" onClick={() => onDone?.(true, { score: Number(score), notes })}>
|
|
60
|
+
Send feedback
|
|
61
|
+
</Button>
|
|
62
|
+
</CardFooter>
|
|
63
|
+
</Card>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# GoalProgressBlock
|
|
2
|
+
|
|
3
|
+
A goals card: a ring per goal in a RadialChart against its track (a full ring is 100%), a legend, and which goals are behind in the footer.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for progress toward two to four targets — a quarter's goals, a plan's limits, onboarding steps. A single reading in a settings row is a Meter; many goals are a list of Progress bars.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 18rem–24rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- max={100} makes a full ring mean done; each ring draws round from 12 o'clock as the card scrolls into view.
|
|
12
|
+
- The footer names what is behind, so the card is actionable without reading the rings.
|
|
13
|
+
- Values are formatted as percents in the tooltip and data table.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The rings' data table lists each goal and its percent.
|
|
18
|
+
- Arrowing through the rings announces each goal; the legend names every ring's colour.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, RadialChart } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const goals = [
|
|
26
|
+
{ goal: "Releases", progress: 82 },
|
|
27
|
+
{ goal: "Collectors", progress: 64 },
|
|
28
|
+
{ goal: "Revenue", progress: 91 },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export interface GoalProgressBlockProps {
|
|
32
|
+
className?: string
|
|
33
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
34
|
+
framed?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function GoalProgressBlock({ className, framed = true }: GoalProgressBlockProps) {
|
|
38
|
+
const behind = goals.filter((g) => g.progress < 70)
|
|
39
|
+
return (
|
|
40
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle render={<h2 />}>Quarter goals</CardTitle>
|
|
43
|
+
<CardDescription>Progress toward each target, in percent.</CardDescription>
|
|
44
|
+
</CardHeader>
|
|
45
|
+
<CardContent>
|
|
46
|
+
<RadialChart label="Progress toward quarter goals" data={goals} index="goal" valueKey="progress" max={100} className="h-64" valueFormatter={(v) => `${v}%`} />
|
|
47
|
+
</CardContent>
|
|
48
|
+
<CardFooter className="text-body-sm text-muted-foreground">
|
|
49
|
+
{behind.length ? `${behind.map((g) => g.goal).join(" and ")} behind pace.` : "Every goal on pace."}
|
|
50
|
+
</CardFooter>
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# HeroBlock
|
|
2
|
+
|
|
3
|
+
A centred hero: an outline Badge eyebrow, the page's h1 at display size, a lede, a primary and an outline call to action, and an install command in a CodeBlock.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to open a product or developer-tool landing page. A brand page that leads with a product scene beside the headline is the BrandLandingPage page shell's hero.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the LandingPage page shell's hero.
|
|
12
|
+
- The headline is the page's only h1 — place one HeroBlock per page.
|
|
13
|
+
- Two actions, one primary. The install command is the third, quieter way in for developers who already decided.
|
|
14
|
+
- The lede is capped at the prose measure and the headline at max-w-4xl, so both stay readable on wide screens.
|
|
15
|
+
|
|
16
|
+
## Accessibility
|
|
17
|
+
|
|
18
|
+
- Calls to action are links styled as Buttons (render={<a />}), because they navigate.
|
|
19
|
+
- The CodeBlock's copy button is labelled and announces when it has copied.
|
|
20
|
+
|
|
21
|
+
## Source
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { Badge, Button, cn, CodeBlock, Icon, Region, Row, Stack } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const hero = {
|
|
27
|
+
eyebrow: "Open source · MIT licensed",
|
|
28
|
+
title: "Product UI your agents can't get wrong",
|
|
29
|
+
lede: "Sable is a themeable React component library with enforced tokens, typography, and motion, documented for coding agents as carefully as for people.",
|
|
30
|
+
primary: { label: "Get started", href: "/docs/getting-started" },
|
|
31
|
+
secondary: { label: "Browse components", href: "/components" },
|
|
32
|
+
install: "pnpm add @sableui/react",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HeroBlockProps {
|
|
36
|
+
className?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function HeroBlock({ className }: HeroBlockProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Region as="section" max="wide" className={cn("w-full py-16 sm:py-24", className)}>
|
|
42
|
+
<Stack gap={8} align="center">
|
|
43
|
+
<Stack gap={4} align="center" className="text-center">
|
|
44
|
+
<Badge appearance="outline" size="sm" icon={<Icon name="sparkles" />}>
|
|
45
|
+
{hero.eyebrow}
|
|
46
|
+
</Badge>
|
|
47
|
+
<h1 className="max-w-4xl text-balance text-display-lg sm:text-display-2xl">{hero.title}</h1>
|
|
48
|
+
<p className="max-w-(--measure-prose) text-pretty text-body-lg text-muted-foreground">{hero.lede}</p>
|
|
49
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
50
|
+
<Button size="cta" render={<a href={hero.primary.href} />}>
|
|
51
|
+
{hero.primary.label}
|
|
52
|
+
</Button>
|
|
53
|
+
<Button size="cta" variant="outline" render={<a href={hero.secondary.href} />}>
|
|
54
|
+
{hero.secondary.label}
|
|
55
|
+
</Button>
|
|
56
|
+
</Row>
|
|
57
|
+
</Stack>
|
|
58
|
+
<div className="w-full max-w-sm">
|
|
59
|
+
<CodeBlock prompt="$" code={hero.install} />
|
|
60
|
+
</div>
|
|
61
|
+
</Stack>
|
|
62
|
+
</Region>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# InviteTeamBlock
|
|
2
|
+
|
|
3
|
+
An invite card: who is already on the team as an AvatarGroup, and an email field with Send.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for team or project invites in onboarding, an empty workspace, or a settings section. For managing an existing member list with roles, use a CardList of Entity rows instead.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The field and Send are a <form>, so Enter sends; `onSend` receives the typed address.
|
|
12
|
+
- The AvatarGroup ring matches the card surface when framed and inherits the host surface when not.
|
|
13
|
+
|
|
14
|
+
## Accessibility
|
|
15
|
+
|
|
16
|
+
- The AvatarGroup carries an aria-label with the count, so the stack reads as one item, not four initials.
|
|
17
|
+
- The email Input carries aria-label; there is no visible label in this compact layout.
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { Avatar, AvatarGroup, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Input } from "@sableui/react"
|
|
23
|
+
|
|
24
|
+
const collaborators = ["MO", "SR", "JL", "AK"]
|
|
25
|
+
|
|
26
|
+
export interface InviteTeamBlockProps {
|
|
27
|
+
className?: string
|
|
28
|
+
/** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
|
|
29
|
+
framed?: boolean
|
|
30
|
+
/** Called with the address typed, on Send or Enter. */
|
|
31
|
+
onSend?: (email: string) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function InviteTeamBlock({ className, framed = true, onSend }: InviteTeamBlockProps) {
|
|
35
|
+
return (
|
|
36
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
37
|
+
<CardHeader>
|
|
38
|
+
<CardTitle>Invite your team</CardTitle>
|
|
39
|
+
<CardDescription>Collaborators can edit every project.</CardDescription>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent className="flex flex-col gap-4">
|
|
42
|
+
<AvatarGroup aria-label={`${collaborators.length} collaborators`} size="sm" surface={framed ? "card" : undefined}>
|
|
43
|
+
{collaborators.map((initials) => (
|
|
44
|
+
<Avatar key={initials} fallback={initials} alt={initials} />
|
|
45
|
+
))}
|
|
46
|
+
</AvatarGroup>
|
|
47
|
+
<form
|
|
48
|
+
className="flex gap-2"
|
|
49
|
+
onSubmit={(event) => {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
onSend?.(String(new FormData(event.currentTarget).get("email") ?? ""))
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<Input name="email" type="email" placeholder="name@company.com" aria-label="Email address" />
|
|
55
|
+
<Button type="submit">Send</Button>
|
|
56
|
+
</form>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# InvoiceHistoryBlock
|
|
2
|
+
|
|
3
|
+
A billing history card: a flush Table of date, plan, amount and status per invoice, with a download button on each row.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this in billing settings, under the current plan. For a long, filterable history (hundreds of rows, date ranges, export), use DataTable on its own page.
|
|
6
|
+
|
|
7
|
+
**Block:** Commerce · card · designed for 20rem–42rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The table runs edge to edge (CardContent flush); its first and last columns still line up with the card header's padding.
|
|
12
|
+
- Amounts are a numeric column — right-aligned with tabular figures — so they compare by eye.
|
|
13
|
+
- On a narrow screen the table scrolls sideways inside the card (with a quiet scrollbar) rather than squeezing its columns.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The download column has a screen-reader-only header, and each button names its invoice ("Download INV-0042").
|
|
18
|
+
- Status is text in a Badge; the dot's colour only reinforces it.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Icon, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const invoices = [
|
|
26
|
+
{ id: "INV-0042", date: "Oct 1, 2026", plan: "Studio · yearly", amount: "$192.00", status: "Paid" },
|
|
27
|
+
{ id: "INV-0031", date: "Sep 1, 2026", plan: "Studio · monthly", amount: "$20.00", status: "Paid" },
|
|
28
|
+
{ id: "INV-0027", date: "Aug 1, 2026", plan: "Studio · monthly", amount: "$20.00", status: "Refunded" },
|
|
29
|
+
{ id: "INV-0019", date: "Jul 1, 2026", plan: "Starter", amount: "$0.00", status: "Paid" },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export interface InvoiceHistoryBlockProps {
|
|
33
|
+
className?: string
|
|
34
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
35
|
+
framed?: boolean
|
|
36
|
+
/** Called with the invoice id to download. */
|
|
37
|
+
onDownload?: (id: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function InvoiceHistoryBlock({ className, framed = true, onDownload }: InvoiceHistoryBlockProps) {
|
|
41
|
+
return (
|
|
42
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle render={<h2 />}>Invoices</CardTitle>
|
|
45
|
+
<CardDescription>Sent to billing@studio.com on the 1st of each month.</CardDescription>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent flush className="overflow-x-auto scrollbar-quiet">
|
|
48
|
+
<Table>
|
|
49
|
+
<TableHeader>
|
|
50
|
+
<TableRow>
|
|
51
|
+
<TableHead>Date</TableHead>
|
|
52
|
+
<TableHead>Plan</TableHead>
|
|
53
|
+
<TableHead numeric>Amount</TableHead>
|
|
54
|
+
<TableHead>Status</TableHead>
|
|
55
|
+
<TableHead className="w-0">
|
|
56
|
+
<span className="sr-only">Download</span>
|
|
57
|
+
</TableHead>
|
|
58
|
+
</TableRow>
|
|
59
|
+
</TableHeader>
|
|
60
|
+
<TableBody>
|
|
61
|
+
{invoices.map((invoice) => (
|
|
62
|
+
<TableRow key={invoice.id}>
|
|
63
|
+
<TableCell className="whitespace-nowrap">{invoice.date}</TableCell>
|
|
64
|
+
<TableCell className="text-muted-foreground">{invoice.plan}</TableCell>
|
|
65
|
+
<TableCell numeric>{invoice.amount}</TableCell>
|
|
66
|
+
<TableCell>
|
|
67
|
+
<Badge appearance="plain" tone={invoice.status === "Paid" ? "success" : "neutral"} dot>
|
|
68
|
+
{invoice.status}
|
|
69
|
+
</Badge>
|
|
70
|
+
</TableCell>
|
|
71
|
+
<TableCell>
|
|
72
|
+
<Button variant="ghost" size="icon-sm" aria-label={`Download ${invoice.id}`} onClick={() => onDownload?.(invoice.id)}>
|
|
73
|
+
<Icon name="download" />
|
|
74
|
+
</Button>
|
|
75
|
+
</TableCell>
|
|
76
|
+
</TableRow>
|
|
77
|
+
))}
|
|
78
|
+
</TableBody>
|
|
79
|
+
</Table>
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# KpiSparklineBlock
|
|
2
|
+
|
|
3
|
+
A KPI row with trends: three stat cards, each the value in the metric mono tier, a Sparkline of its last twelve weeks, and the change as a Badge.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this at the top of a dashboard or a landing page's proof section, where each number needs its recent shape. Without the trend, StatRowBlock is calmer; for one headline trend, TrafficTrendBlock.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Each sparkline is coloured by what its measure is, and the change Badge by whether it is good: churn falling is good news in a warning-toned trend.
|
|
12
|
+
- The sparklines draw on as the row scrolls into view.
|
|
13
|
+
- One column on a phone, three from sm.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each Sparkline is an image named by the measure and the period, with its range spoken.
|
|
18
|
+
- The change is stated in text (+8.2% vs last quarter) with an arrow icon; colour only reinforces it.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Badge, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, cn, Grid, Icon, Sparkline } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const kpis = [
|
|
26
|
+
{ label: "Revenue", value: "$18,240", delta: "+8.2%", up: true, trend: [12, 14, 13, 17, 16, 19, 18, 21, 20, 24, 23, 26], tone: "success" },
|
|
27
|
+
{ label: "Active users", value: "3,912", delta: "+3.1%", up: true, trend: [30, 31, 29, 33, 34, 33, 36, 35, 37, 38, 37, 39], tone: "info" },
|
|
28
|
+
{ label: "Churn", value: "4.1%", delta: "−0.6%", up: false, trend: [9, 8, 9, 7, 8, 6, 7, 5, 6, 4, 5, 4], tone: "warning" },
|
|
29
|
+
] as const
|
|
30
|
+
|
|
31
|
+
export interface KpiSparklineBlockProps {
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function KpiSparklineBlock({ className }: KpiSparklineBlockProps) {
|
|
36
|
+
return (
|
|
37
|
+
<Grid cols={{ base: 1, sm: 3 }} gap={3} className={cn("w-full", className)}>
|
|
38
|
+
{kpis.map((kpi) => (
|
|
39
|
+
<Card key={kpi.label}>
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardDescription>{kpi.label}</CardDescription>
|
|
42
|
+
<CardTitle className="text-mono-xl">{kpi.value}</CardTitle>
|
|
43
|
+
<CardAction>
|
|
44
|
+
<Sparkline label={`${kpi.label}, last 12 weeks`} data={[...kpi.trend]} variant="area" tone={kpi.tone} />
|
|
45
|
+
</CardAction>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent>
|
|
48
|
+
<Badge tone={kpi.up ? "success" : "neutral"} size="sm" icon={<Icon name={kpi.up ? "arrow-up" : "arrow-down"} />}>
|
|
49
|
+
{kpi.delta} vs last quarter
|
|
50
|
+
</Badge>
|
|
51
|
+
</CardContent>
|
|
52
|
+
</Card>
|
|
53
|
+
))}
|
|
54
|
+
</Grid>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
```
|