@sableui/react 0.2.0 → 0.3.1
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 +84 -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/blocks/index.d.ts +396 -0
- package/dist/blocks/index.js +1997 -0
- package/dist/chunk-6LCIBMMG.js +4908 -0
- package/dist/{chunk-AOWBYEQ6.js → chunk-CKNM7KSB.js} +1598 -6464
- package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
- package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
- package/dist/chunk-WCFLQVK5.js +5097 -0
- package/dist/icon/index.js +2 -2
- package/dist/icon-sets/reicon.js +1 -1
- package/dist/index.d.ts +471 -34
- package/dist/index.js +588 -2589
- package/dist/ios/index.d.ts +8 -1
- package/dist/ios/index.js +6 -5
- package/dist/ios/preview.d.ts +11 -1
- package/dist/ios/preview.js +13 -8
- package/dist/ios/styles.css +14 -0
- 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 +29 -30
- package/dist/presets/index.js +10 -1
- package/dist/themes/base/base.css +23 -0
- package/dist/themes/index.js +2 -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/button.md +1 -1
- package/llms/components/card.md +2 -2
- package/llms/components/context-menu.md +1 -1
- package/llms/components/date-tile.md +79 -0
- package/llms/components/entity.md +4 -2
- 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/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/sparkline.md +80 -0
- package/llms/components/table.md +1 -1
- package/llms/patterns/auth-page.md +2 -2
- package/llms/patterns/dashboard-page.md +6 -7
- package/llms/patterns/feed-page.md +7 -7
- package/llms/patterns/list-detail-page.md +7 -15
- package/llms/patterns/settings-page.md +10 -21
- package/llms/tasks/building-an-ios-prototype.md +4 -1
- package/llms/tasks/choosing-a-component.md +2 -2
- package/llms-full.txt +4296 -277
- package/llms.txt +55 -3
- package/package.json +5 -1
- package/registry.json +6383 -3741
- package/targets/ios.json +90 -9
- package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# MediaPlayerBlock
|
|
2
|
+
|
|
3
|
+
A now-playing card: cover, title and artist as an Entity with a Like action, a position Slider with elapsed and remaining time, and previous / play-pause / next.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for audio or video playback controls outside the media itself — a mini player, a podcast card, a preview in a feed. Playback over the video frame is the Video component's own chrome.
|
|
6
|
+
|
|
7
|
+
**Block:** Media & files · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Play/pause is the one filled control, centred between two ghost skips.
|
|
12
|
+
- Times are `text-mono-caption`, so the digits do not shift as they count.
|
|
13
|
+
- The cover is painted from the palette as a placeholder; swap it for the real artwork.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Every icon-only Button carries aria-label, and play/pause's label follows its state.
|
|
18
|
+
- The Slider carries aria-label="Position". Give it aria-valuetext in your player ("1:24 of 3:40") so the position is read as time, not a percentage.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { Button, Card, CardContent, cn, Entity, Icon, Slider } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const track = { title: "Low tide, high noon", artist: "Juno Avery", position: 38, elapsed: "1:24", remaining: "-2:16" }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Placeholder cover painted from the palette — the theme's decorative tones
|
|
30
|
+
* over the brand, so it follows every look. Replace with the cover image.
|
|
31
|
+
*/
|
|
32
|
+
const ART_TONES = ["info", "collectible", "edition"]
|
|
33
|
+
const ART_SPOTS = [[20, 20, 70], [85, 30, 60], [60, 90, 65]]
|
|
34
|
+
const cover = [
|
|
35
|
+
...ART_SPOTS.map(
|
|
36
|
+
([x, y, reach], i) =>
|
|
37
|
+
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
38
|
+
`radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES[i % ART_TONES.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`,
|
|
39
|
+
),
|
|
40
|
+
"var(--primary)",
|
|
41
|
+
].join(", ")
|
|
42
|
+
|
|
43
|
+
export interface MediaPlayerBlockProps {
|
|
44
|
+
className?: string
|
|
45
|
+
/** false drops the card's surface and border — for a Sheet or muted section that already frames it. */
|
|
46
|
+
framed?: boolean
|
|
47
|
+
/** Called with the new playing state. */
|
|
48
|
+
onPlayingChange?: (playing: boolean) => void
|
|
49
|
+
onPrevious?: () => void
|
|
50
|
+
onNext?: () => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }: MediaPlayerBlockProps) {
|
|
54
|
+
const [playing, setPlaying] = useState(true)
|
|
55
|
+
return (
|
|
56
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
57
|
+
<CardContent className="flex flex-col gap-4">
|
|
58
|
+
{/* Entity's own row padding is for lists; inside a card's content the
|
|
59
|
+
card already pads it. */}
|
|
60
|
+
<Entity
|
|
61
|
+
className="p-0"
|
|
62
|
+
leading={<div aria-hidden className="size-14 rounded-md" style={{ background: cover }} />}
|
|
63
|
+
title={track.title}
|
|
64
|
+
subtitle={track.artist}
|
|
65
|
+
action={
|
|
66
|
+
<Button variant="ghost" size="icon-sm" aria-label="Like">
|
|
67
|
+
<Icon name="heart" />
|
|
68
|
+
</Button>
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
<div className="flex flex-col gap-1.5">
|
|
72
|
+
<Slider defaultValue={track.position} aria-label="Position" />
|
|
73
|
+
<div className="flex justify-between text-mono-caption text-muted-foreground">
|
|
74
|
+
<span>{track.elapsed}</span>
|
|
75
|
+
<span>{track.remaining}</span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex items-center justify-center gap-3">
|
|
79
|
+
<Button variant="ghost" size="icon" aria-label="Previous" onClick={onPrevious}>
|
|
80
|
+
<Icon name="previous" variant="solid" />
|
|
81
|
+
</Button>
|
|
82
|
+
<Button
|
|
83
|
+
size="icon"
|
|
84
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
setPlaying(!playing)
|
|
87
|
+
onPlayingChange?.(!playing)
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<Icon name={playing ? "pause" : "play"} variant="solid" />
|
|
91
|
+
</Button>
|
|
92
|
+
<Button variant="ghost" size="icon" aria-label="Next" onClick={onNext}>
|
|
93
|
+
<Icon name="next" variant="solid" />
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</CardContent>
|
|
97
|
+
</Card>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# NewsletterBlock
|
|
2
|
+
|
|
3
|
+
A newsletter sign-up card: the list's name, what it sends and how often, and an email field with Subscribe.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this in a footer, at the end of an article, or beside a changelog. Creating an account is SignUpBlock; inviting a teammate is InviteTeamBlock.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · card · designed for 18rem–24rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the BrandLandingPage page shell's footer.
|
|
12
|
+
- The description says how often mail arrives — the one thing people want to know before they subscribe.
|
|
13
|
+
- The field and button sit side by side from sm up and stack on a phone, where the button goes full width.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The input has a real (visually hidden) <label>, not just a placeholder.
|
|
18
|
+
- The CardTitle renders an h2, so the sign-up is reachable by heading in a footer.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useId } from "react"
|
|
24
|
+
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Input } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const newsletter = { title: "The Cove digest", body: "One note a month on trusted workflows and product releases." }
|
|
27
|
+
|
|
28
|
+
export interface NewsletterBlockProps {
|
|
29
|
+
className?: string
|
|
30
|
+
/** false drops the card's surface and border — for a footer or muted section that already frames it. */
|
|
31
|
+
framed?: boolean
|
|
32
|
+
/** Called with the address on Subscribe or Enter. */
|
|
33
|
+
onSubscribe?: (email: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function NewsletterBlock({ className, framed = true, onSubscribe }: NewsletterBlockProps) {
|
|
37
|
+
const emailId = useId()
|
|
38
|
+
return (
|
|
39
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle render={<h2 />}>{newsletter.title}</CardTitle>
|
|
42
|
+
<CardDescription>{newsletter.body}</CardDescription>
|
|
43
|
+
</CardHeader>
|
|
44
|
+
<CardContent>
|
|
45
|
+
<form
|
|
46
|
+
className="grid gap-2 sm:grid-cols-[1fr_auto]"
|
|
47
|
+
onSubmit={(event) => {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
onSubscribe?.(String(new FormData(event.currentTarget).get("email") ?? ""))
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<label htmlFor={emailId} className="sr-only">
|
|
53
|
+
Work email
|
|
54
|
+
</label>
|
|
55
|
+
<Input id={emailId} name="email" type="email" autoComplete="email" placeholder="you@company.com" required />
|
|
56
|
+
<Button type="submit" className="w-full sm:w-auto">
|
|
57
|
+
Subscribe
|
|
58
|
+
</Button>
|
|
59
|
+
</form>
|
|
60
|
+
</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# NotFoundBlock
|
|
2
|
+
|
|
3
|
+
A not-found page body: the status code in mono, a plain-language h1, what probably happened, and two ways on — home and search.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the body of a 404 route. An empty list or a search with no results inside a working page is EmptyState, not this.
|
|
6
|
+
|
|
7
|
+
**Block:** System · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The heading says what happened in words; the code is a small mono label for people who look for it.
|
|
12
|
+
- Two ways on, one primary. Never a dead end with only the browser's back button.
|
|
13
|
+
- Capped at the `form` region width, so the short message stays a tight, centred column.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- It renders the page's h1 — use it as the route's main content, inside <main>.
|
|
18
|
+
- Both actions are links styled as Buttons, because they navigate.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Button, cn, Icon, Region, Row, Stack } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const notFound = {
|
|
26
|
+
code: "404",
|
|
27
|
+
title: "This page isn't here",
|
|
28
|
+
body: "It may have moved, or the link may be mistyped. Check the address, or head back to somewhere you know.",
|
|
29
|
+
home: { label: "Go home", href: "/" },
|
|
30
|
+
help: { label: "Search the docs", href: "/docs" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NotFoundBlockProps {
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function NotFoundBlock({ className }: NotFoundBlockProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Region as="section" max="form" className={cn("w-full py-16 sm:py-24", className)}>
|
|
40
|
+
<Stack gap={4} align="center" className="text-center">
|
|
41
|
+
<p className="text-mono-md text-muted-foreground">{notFound.code}</p>
|
|
42
|
+
<h1 className="text-balance text-heading-1">{notFound.title}</h1>
|
|
43
|
+
<p className="text-pretty text-body-md text-muted-foreground">{notFound.body}</p>
|
|
44
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
45
|
+
<Button iconStart={<Icon name="home" />} render={<a href={notFound.home.href} />}>
|
|
46
|
+
{notFound.home.label}
|
|
47
|
+
</Button>
|
|
48
|
+
<Button variant="outline" iconStart={<Icon name="search" />} render={<a href={notFound.help.href} />}>
|
|
49
|
+
{notFound.help.label}
|
|
50
|
+
</Button>
|
|
51
|
+
</Row>
|
|
52
|
+
</Stack>
|
|
53
|
+
</Region>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# NotificationListBlock
|
|
2
|
+
|
|
3
|
+
An activity list: Entity rows with the actor's Avatar, who did what, and a compact time with an unread StatusDot.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for a notifications popover, a recent-activity card or an inbox preview. A full inbox with selection and bulk actions is a ListDetailPage.
|
|
6
|
+
|
|
7
|
+
**Block:** Social & messaging · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Rows are Card + CardList + Entity, inset from the card's padding at any density.
|
|
12
|
+
- Unread is a quiet info dot after the time — the list stays calm when most items are unread.
|
|
13
|
+
- framed={false} drops the surface for a Popover, which already is one.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The unread dot is decorative; each unread row also carries a screen-reader-only "(unread)" after the name.
|
|
18
|
+
- Each Avatar's alt is the actor's name.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Avatar, Card, CardList, cn, Entity, StatusDot } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const notifications = [
|
|
26
|
+
{ initials: "MO", name: "Mara Okafor", what: "collected your edition", when: "2m", unread: true },
|
|
27
|
+
{ initials: "SR", name: "Sam Rivera", what: "left a comment on Dusk", when: "1h", unread: false },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface NotificationListBlockProps {
|
|
31
|
+
className?: string
|
|
32
|
+
/** false drops the card's surface and border — for a Popover or Sheet that already frames it. */
|
|
33
|
+
framed?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function NotificationListBlock({ className, framed = true }: NotificationListBlockProps) {
|
|
37
|
+
return (
|
|
38
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
|
|
39
|
+
<CardList>
|
|
40
|
+
{notifications.map((n) => (
|
|
41
|
+
<Entity
|
|
42
|
+
key={n.name}
|
|
43
|
+
leading={<Avatar size="sm" fallback={n.initials} alt={n.name} />}
|
|
44
|
+
title={
|
|
45
|
+
<>
|
|
46
|
+
{n.name}
|
|
47
|
+
{n.unread ? <span className="sr-only"> (unread)</span> : null}
|
|
48
|
+
</>
|
|
49
|
+
}
|
|
50
|
+
subtitle={n.what}
|
|
51
|
+
action={
|
|
52
|
+
<span className="flex items-center gap-1.5 text-caption text-muted-foreground">
|
|
53
|
+
{n.when}
|
|
54
|
+
{n.unread ? <StatusDot tone="info" /> : null}
|
|
55
|
+
</span>
|
|
56
|
+
}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</CardList>
|
|
60
|
+
</Card>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# NotificationSettingsBlock
|
|
2
|
+
|
|
3
|
+
A preferences card: Entity rows in a CardList, each with a title, a one-line detail and a Switch that applies instantly.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for any short list of on/off preferences — notifications, privacy, feature flags in a settings section. For a full settings route with a section rail, start from the SettingsPage page shell.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Switches apply the moment they change; never pair them with a Save button. `onChange` receives the row id and the new state.
|
|
12
|
+
- Rows are Card + CardList + Entity, so they inset with the card's padding at any density.
|
|
13
|
+
|
|
14
|
+
## Accessibility
|
|
15
|
+
|
|
16
|
+
- Each Switch takes its row title as aria-label, since the Entity title is not a <label>.
|
|
17
|
+
- Keep details to one line — Entity truncates its subtitle.
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { Card, CardList, cn, Entity, Switch } from "@sableui/react"
|
|
23
|
+
|
|
24
|
+
const preferences = [
|
|
25
|
+
{ id: "email", title: "Email notifications", detail: "Replies and mentions", on: true },
|
|
26
|
+
{ id: "push", title: "Push notifications", detail: "On this device", on: false },
|
|
27
|
+
{ id: "digest", title: "Weekly digest", detail: "Every Monday", on: true },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface NotificationSettingsBlockProps {
|
|
31
|
+
className?: string
|
|
32
|
+
/** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
|
|
33
|
+
framed?: boolean
|
|
34
|
+
/** Called the moment a switch changes. Toggles apply instantly; there is no Save. */
|
|
35
|
+
onChange?: (id: string, on: boolean) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function NotificationSettingsBlock({ className, framed = true, onChange }: NotificationSettingsBlockProps) {
|
|
39
|
+
return (
|
|
40
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
|
|
41
|
+
<CardList>
|
|
42
|
+
{preferences.map((p) => (
|
|
43
|
+
<Entity
|
|
44
|
+
key={p.id}
|
|
45
|
+
title={p.title}
|
|
46
|
+
subtitle={p.detail}
|
|
47
|
+
action={<Switch defaultChecked={p.on} aria-label={p.title} onCheckedChange={(on: boolean) => onChange?.(p.id, on)} />}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</CardList>
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# OrderTrackingBlock
|
|
2
|
+
|
|
3
|
+
An order status card: order number, item and a status Badge in the header over a vertical dot Stepper of delivery milestones.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for anything that moves through known stages — a delivery, an application, a review, a deployment. For an open-ended activity log, use a list of Entity rows with timestamps instead.
|
|
6
|
+
|
|
7
|
+
**Block:** Commerce · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The Stepper's `current` is the step under way; finished steps show their timestamp and future steps show none.
|
|
12
|
+
- The status Badge restates the current stage in one word so the card scans without reading the steps.
|
|
13
|
+
|
|
14
|
+
## Accessibility
|
|
15
|
+
|
|
16
|
+
- The Stepper is labelled "Delivery status" and marks the current step for assistive tech.
|
|
17
|
+
- The Badge's tone is backed by its text; colour alone never carries the status.
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { Badge, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, cn, Stepper, StepperItem } from "@sableui/react"
|
|
23
|
+
|
|
24
|
+
// design-lint-disable-next-line inline-color -- an order number, not a colour
|
|
25
|
+
const order = { number: "Order #2048", item: "Giclée print, 18 × 24 in", status: "In transit", current: 2 }
|
|
26
|
+
|
|
27
|
+
const steps = [
|
|
28
|
+
{ title: "Order placed", detail: "Oct 9, 10:14 AM" },
|
|
29
|
+
{ title: "Printed and packed", detail: "Oct 10, 4:02 PM" },
|
|
30
|
+
{ title: "Out for delivery", detail: "Arriving today by 6 PM" },
|
|
31
|
+
{ title: "Delivered", detail: undefined },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export interface OrderTrackingBlockProps {
|
|
35
|
+
className?: string
|
|
36
|
+
/** false drops the card's surface and border — for a Sheet or muted section that already frames it. */
|
|
37
|
+
framed?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function OrderTrackingBlock({ className, framed = true }: OrderTrackingBlockProps) {
|
|
41
|
+
return (
|
|
42
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle>{order.number}</CardTitle>
|
|
45
|
+
<CardDescription>{order.item}</CardDescription>
|
|
46
|
+
<CardAction>
|
|
47
|
+
<Badge tone="warning" size="sm">
|
|
48
|
+
{order.status}
|
|
49
|
+
</Badge>
|
|
50
|
+
</CardAction>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardContent>
|
|
53
|
+
<Stepper orientation="vertical" indicator="dot" current={order.current} aria-label="Delivery status">
|
|
54
|
+
{steps.map((step) => (
|
|
55
|
+
<StepperItem key={step.title} title={step.title} description={step.detail} />
|
|
56
|
+
))}
|
|
57
|
+
</Stepper>
|
|
58
|
+
</CardContent>
|
|
59
|
+
</Card>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|