@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,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
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# PricingCardBlock
|
|
2
|
+
|
|
3
|
+
A single-plan pricing card: a Monthly/Yearly ToggleGroup, the price and billing note, a checked feature list, and one full-width call to action.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this when one plan is on offer — an upgrade prompt, a paywall, a plan summary in billing settings. Comparing several tiers side by side is a pricing table, not three of these.
|
|
6
|
+
|
|
7
|
+
**Block:** Commerce · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The period switch is a ToggleGroup (a segmented control), not Tabs: it changes a value in place rather than showing another panel.
|
|
12
|
+
- The price is a heading tier because it is the card's headline; its unit and billing note sit beside and below it in muted body text.
|
|
13
|
+
- `onStart` receives the period showing, so checkout opens on the plan the person was looking at.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The ToggleGroup carries aria-label="Billing period"; each Toggle's text is its name.
|
|
18
|
+
- The check icons are decorative beside each feature's text.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { Button, Card, CardContent, cn, Icon, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const plan = {
|
|
27
|
+
monthly: { price: "$20", note: "Billed monthly. Cancel anytime." },
|
|
28
|
+
yearly: { price: "$16", note: "Billed $192 yearly. You save 20%." },
|
|
29
|
+
features: ["Unlimited projects", "Version history", "Priority support"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Period = "monthly" | "yearly"
|
|
33
|
+
|
|
34
|
+
export interface PricingCardBlockProps {
|
|
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 billing period showing when the trial starts. */
|
|
39
|
+
onStart?: (period: Period) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function PricingCardBlock({ className, framed = true, onStart }: PricingCardBlockProps) {
|
|
43
|
+
const [period, setPeriod] = useState<Period>("yearly")
|
|
44
|
+
return (
|
|
45
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
46
|
+
<CardContent className="flex flex-col gap-5">
|
|
47
|
+
<ToggleGroup
|
|
48
|
+
value={[period]}
|
|
49
|
+
onValueChange={(v: unknown[]) => v[0] && setPeriod(v[0] as Period)}
|
|
50
|
+
aria-label="Billing period"
|
|
51
|
+
className="w-full"
|
|
52
|
+
>
|
|
53
|
+
<Toggle value="monthly" className="flex-1">
|
|
54
|
+
Monthly
|
|
55
|
+
</Toggle>
|
|
56
|
+
<Toggle value="yearly" className="flex-1">
|
|
57
|
+
Yearly
|
|
58
|
+
</Toggle>
|
|
59
|
+
</ToggleGroup>
|
|
60
|
+
<div className="flex flex-col gap-1">
|
|
61
|
+
<div className="flex items-baseline gap-1.5">
|
|
62
|
+
<span className="text-heading-2">{plan[period].price}</span>
|
|
63
|
+
<span className="text-body-sm text-muted-foreground">per seat / month</span>
|
|
64
|
+
</div>
|
|
65
|
+
<span className="text-body-sm text-muted-foreground">{plan[period].note}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<ul className="flex flex-col gap-2 text-body-sm">
|
|
68
|
+
{plan.features.map((feature) => (
|
|
69
|
+
<li key={feature} className="flex items-center gap-2">
|
|
70
|
+
<Icon name="check" className="text-success" />
|
|
71
|
+
{feature}
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
<Button className="w-full" onClick={() => onStart?.(period)}>
|
|
76
|
+
Start free trial
|
|
77
|
+
</Button>
|
|
78
|
+
</CardContent>
|
|
79
|
+
</Card>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
```
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# PricingTableBlock
|
|
2
|
+
|
|
3
|
+
A three-tier pricing section: heading, a Monthly/Yearly ToggleGroup, and three equal-height plan cards with the recommended one outlined in primary and badged "Most popular".
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this on a pricing page or the pricing band of a landing page, to compare plans side by side. A single plan on offer (an upgrade prompt, a paywall) is PricingCardBlock.
|
|
6
|
+
|
|
7
|
+
**Block:** Commerce · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- One featured tier at most: it takes the primary button and a primary outline; the others take outline buttons so the recommendation is unmistakable.
|
|
12
|
+
- Cards stretch to the tallest one and push their buttons to the bottom, so the calls to action line up across the row.
|
|
13
|
+
- The yearly saving is stated in the toggle itself, where the choice is made. `onChoose` receives the tier id and the period showing.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each plan is an <article> with an h3 under the section's h2, so the tiers are navigable by heading.
|
|
18
|
+
- The ToggleGroup is labelled "Billing period"; prices update in place and stay in the reading order of each card.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState, useId } from "react"
|
|
24
|
+
import { Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Grid, Icon, Region, Stack, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const tiers = [
|
|
27
|
+
{
|
|
28
|
+
id: "starter",
|
|
29
|
+
name: "Starter",
|
|
30
|
+
blurb: "For one person shipping their first editions.",
|
|
31
|
+
monthly: 0,
|
|
32
|
+
yearly: 0,
|
|
33
|
+
cta: "Start for free",
|
|
34
|
+
featured: false,
|
|
35
|
+
features: ["3 live editions", "Collector profiles", "Community support"],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "studio",
|
|
39
|
+
name: "Studio",
|
|
40
|
+
blurb: "For working artists with a steady release schedule.",
|
|
41
|
+
monthly: 20,
|
|
42
|
+
yearly: 16,
|
|
43
|
+
cta: "Start free trial",
|
|
44
|
+
featured: true,
|
|
45
|
+
features: ["Unlimited editions", "Scheduled drops", "Sales analytics", "Priority support"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "collective",
|
|
49
|
+
name: "Collective",
|
|
50
|
+
blurb: "For galleries and teams releasing together.",
|
|
51
|
+
monthly: 48,
|
|
52
|
+
yearly: 40,
|
|
53
|
+
cta: "Talk to sales",
|
|
54
|
+
featured: false,
|
|
55
|
+
features: ["Everything in Studio", "Up to 10 seats", "Shared payouts", "Roles and approvals"],
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
type Period = "monthly" | "yearly"
|
|
60
|
+
|
|
61
|
+
export interface PricingTableBlockProps {
|
|
62
|
+
className?: string
|
|
63
|
+
/** Called with the chosen tier and the billing period showing. */
|
|
64
|
+
onChoose?: (tier: string, period: Period) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function PricingTableBlock({ className, onChoose }: PricingTableBlockProps) {
|
|
68
|
+
const [period, setPeriod] = useState<Period>("yearly")
|
|
69
|
+
const titleId = useId()
|
|
70
|
+
return (
|
|
71
|
+
<Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16", className)}>
|
|
72
|
+
<Stack gap={10}>
|
|
73
|
+
<Stack gap={4} align="center" className="text-center">
|
|
74
|
+
<h2 id={titleId} className="text-balance text-heading-1">
|
|
75
|
+
Pricing that grows with your practice
|
|
76
|
+
</h2>
|
|
77
|
+
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
|
|
78
|
+
Every plan includes unlimited collectors and no fees on your first $1,000 in sales.
|
|
79
|
+
</p>
|
|
80
|
+
<ToggleGroup value={[period]} onValueChange={(v: unknown[]) => v[0] && setPeriod(v[0] as Period)} aria-label="Billing period">
|
|
81
|
+
<Toggle value="monthly">Monthly</Toggle>
|
|
82
|
+
<Toggle value="yearly">Yearly · save 20%</Toggle>
|
|
83
|
+
</ToggleGroup>
|
|
84
|
+
</Stack>
|
|
85
|
+
<Grid cols={{ base: 1, md: 3 }} gap={4} className="items-stretch">
|
|
86
|
+
{tiers.map((tier) => (
|
|
87
|
+
<Card
|
|
88
|
+
key={tier.id}
|
|
89
|
+
render={<article />}
|
|
90
|
+
className={cn("flex flex-col", tier.featured && "border-primary ring-1 ring-primary")}
|
|
91
|
+
>
|
|
92
|
+
<CardHeader>
|
|
93
|
+
<CardTitle render={<h3 />}>{tier.name}</CardTitle>
|
|
94
|
+
<CardDescription>{tier.blurb}</CardDescription>
|
|
95
|
+
{tier.featured ? (
|
|
96
|
+
<CardAction>
|
|
97
|
+
<Badge tone="edition" size="sm">
|
|
98
|
+
Most popular
|
|
99
|
+
</Badge>
|
|
100
|
+
</CardAction>
|
|
101
|
+
) : null}
|
|
102
|
+
</CardHeader>
|
|
103
|
+
<CardContent className="flex flex-1 flex-col gap-5">
|
|
104
|
+
<div className="flex items-baseline gap-1.5">
|
|
105
|
+
<span className="text-heading-2">${tier[period]}</span>
|
|
106
|
+
<span className="text-body-sm text-muted-foreground">per seat / month</span>
|
|
107
|
+
</div>
|
|
108
|
+
<ul className="flex flex-col gap-2 text-body-sm">
|
|
109
|
+
{tier.features.map((feature) => (
|
|
110
|
+
<li key={feature} className="flex items-center gap-2">
|
|
111
|
+
<Icon name="check" className="text-success" />
|
|
112
|
+
{feature}
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ul>
|
|
116
|
+
</CardContent>
|
|
117
|
+
<CardFooter>
|
|
118
|
+
<Button className="w-full" variant={tier.featured ? "primary" : "outline"} onClick={() => onChoose?.(tier.id, period)}>
|
|
119
|
+
{tier.cta}
|
|
120
|
+
</Button>
|
|
121
|
+
</CardFooter>
|
|
122
|
+
</Card>
|
|
123
|
+
))}
|
|
124
|
+
</Grid>
|
|
125
|
+
</Stack>
|
|
126
|
+
</Region>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
```
|