@sableui/react 0.2.0 → 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 +59 -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-K6LII3OE.js +5093 -0
- package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
- package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
- 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 +6 -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/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,48 @@
|
|
|
1
|
+
# CommandPaletteBlock
|
|
2
|
+
|
|
3
|
+
An inline command palette: CommandMenuPanel with a filter field and grouped actions, each with an icon and its keyboard shortcut.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to show a command list in place — an empty state that teaches shortcuts, an onboarding step, a quick-actions card. The ⌘K palette that opens over the app is CommandMenu; this is its panel without the dialog.
|
|
6
|
+
|
|
7
|
+
**Block:** Navigation · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Each command shows its shortcut as a Kbd hint, so the palette also teaches the keys.
|
|
12
|
+
- `onCommand` receives the command's id; ids also key the rows, so two commands may share a label.
|
|
13
|
+
- No card around it: the panel is its own surface, lifted with `shadow-sm` when it sits on a muted section.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- CommandMenuPanel wires the filter input to the list as a combobox, with arrow-key navigation and the active row announced.
|
|
18
|
+
- Shortcut hints are Kbd elements, read as keys.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { cn, CommandMenuPanel, Kbd, type CommandItem } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const commands = [
|
|
26
|
+
{ id: "new-project", icon: "plus", label: "New project", key: "N" },
|
|
27
|
+
{ id: "invite", icon: "user", label: "Invite teammate", key: "I" },
|
|
28
|
+
{ id: "settings", icon: "gear", label: "Open settings", key: "," },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export interface CommandPaletteBlockProps {
|
|
32
|
+
className?: string
|
|
33
|
+
/** Called with the chosen command's id. */
|
|
34
|
+
onCommand?: (id: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CommandPaletteBlock({ className, onCommand }: CommandPaletteBlockProps) {
|
|
38
|
+
const items: CommandItem[] = commands.map((c) => ({
|
|
39
|
+
id: c.id,
|
|
40
|
+
group: "Actions",
|
|
41
|
+
icon: c.icon,
|
|
42
|
+
label: c.label,
|
|
43
|
+
hint: <Kbd>{c.key}</Kbd>,
|
|
44
|
+
onSelect: () => onCommand?.(c.id),
|
|
45
|
+
}))
|
|
46
|
+
return <CommandMenuPanel className={cn("w-full max-w-xs shadow-sm", className)} placeholder="Type a command…" items={items} />
|
|
47
|
+
}
|
|
48
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# CtaBandBlock
|
|
2
|
+
|
|
3
|
+
A closing call to action: a centred h2 and one-line lede on a card, with a primary and an outline action.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the last section before the footer, repeating the hero's primary action once the reader has seen the case. Mid-page prompts to sign up belong inline, not in a band.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the LandingPage page shell's closing call to action.
|
|
12
|
+
- The primary action matches the hero's, so the page asks for one thing twice rather than two things once.
|
|
13
|
+
- Sitting on a Card lifts it from the page without a coloured band, so it follows any preset.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The section is labelled by its h2.
|
|
18
|
+
- Both actions are links styled as Buttons, because they navigate.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useId } from "react"
|
|
24
|
+
import { Button, Card, CardContent, cn, Region, Row, Stack } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const cta = {
|
|
27
|
+
title: "Build the product, not another component library",
|
|
28
|
+
lede: "Install Sable, pick a preset, and ship the first screen today.",
|
|
29
|
+
primary: { label: "Get started", href: "/docs/getting-started" },
|
|
30
|
+
secondary: { label: "Star on GitHub", href: "/github" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CtaBandBlockProps {
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CtaBandBlock({ className }: CtaBandBlockProps) {
|
|
38
|
+
const titleId = useId()
|
|
39
|
+
return (
|
|
40
|
+
<Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16", className)}>
|
|
41
|
+
<Card>
|
|
42
|
+
<CardContent>
|
|
43
|
+
<Stack gap={4} align="center" className="py-8 text-center sm:py-12">
|
|
44
|
+
<h2 id={titleId} className="max-w-2xl text-balance text-heading-1">
|
|
45
|
+
{cta.title}
|
|
46
|
+
</h2>
|
|
47
|
+
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">{cta.lede}</p>
|
|
48
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
49
|
+
<Button size="cta" render={<a href={cta.primary.href} />}>
|
|
50
|
+
{cta.primary.label}
|
|
51
|
+
</Button>
|
|
52
|
+
<Button size="cta" variant="outline" render={<a href={cta.secondary.href} />}>
|
|
53
|
+
{cta.secondary.label}
|
|
54
|
+
</Button>
|
|
55
|
+
</Row>
|
|
56
|
+
</Stack>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
</Region>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# DangerZoneBlock
|
|
2
|
+
|
|
3
|
+
A delete-account card: what deleting removes and what survives, one destructive Button, and a DestructiveActionModal that asks for the handle to be typed before it acts.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the last section of account, workspace or project settings. Deleting a single record from a list is an OverflowMenu item that opens DestructiveActionModal directly, not a card.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 20rem–42rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the SettingsPage page shell's Delete account section.
|
|
12
|
+
- The button never acts directly: it opens the modal, whose confirm stays disabled until the handle is typed.
|
|
13
|
+
- The description says what is lost AND what is kept ("Collectors keep the editions they own"), which is what people want to know before they decide.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- DestructiveActionModal is an alertdialog: focus opens on Cancel and the backdrop does not dismiss it.
|
|
18
|
+
- The CardTitle is an h2, so the section is reachable by heading.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { Button, Card, CardDescription, CardFooter, CardHeader, CardTitle, cn, DestructiveActionModal } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const account = { handle: "ada", editions: 12 }
|
|
27
|
+
|
|
28
|
+
export interface DangerZoneBlockProps {
|
|
29
|
+
className?: string
|
|
30
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
31
|
+
framed?: boolean
|
|
32
|
+
/** Called once the person has typed the handle and confirmed. */
|
|
33
|
+
onDelete?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DangerZoneBlock({ className, framed = true, onDelete }: DangerZoneBlockProps) {
|
|
37
|
+
const [open, setOpen] = useState(false)
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle render={<h2 />}>Delete account</CardTitle>
|
|
43
|
+
<CardDescription>
|
|
44
|
+
Permanently remove your profile, editions, and sales history. Collectors keep the editions they own.
|
|
45
|
+
</CardDescription>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardFooter className="justify-end">
|
|
48
|
+
<Button variant="destructive" onClick={() => setOpen(true)}>
|
|
49
|
+
Delete account
|
|
50
|
+
</Button>
|
|
51
|
+
</CardFooter>
|
|
52
|
+
</Card>
|
|
53
|
+
<DestructiveActionModal
|
|
54
|
+
open={open}
|
|
55
|
+
onOpenChange={setOpen}
|
|
56
|
+
title="Delete your account?"
|
|
57
|
+
description={`This permanently removes your profile, ${account.editions} editions, and sales history. Pending payouts are still sent. This can't be undone.`}
|
|
58
|
+
confirmLabel="Delete account"
|
|
59
|
+
confirmingLabel="Deleting…"
|
|
60
|
+
confirmText={account.handle}
|
|
61
|
+
onConfirm={() => {
|
|
62
|
+
setOpen(false)
|
|
63
|
+
onDelete?.()
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# DeviceComparisonBlock
|
|
2
|
+
|
|
3
|
+
A comparison card: each series' total sits in the header as a pressable tile, and the one pressed is plotted below as a daily BarChart.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this when two or three measures share a page and the reader looks at one at a time — devices, regions, plans. To see them together, plot both in one BarChart or LineChart instead.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 20rem–48rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The totals are the switch: Toggles in a labelled ToggleGroup, so the number and the control are one thing, with the group's own pressed and hover states.
|
|
12
|
+
- Switching eases every bar to its new height rather than redrawing.
|
|
13
|
+
- Below sm the tiles stack under the title and divide with a top rule instead of a side rule.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each total is a Toggle (aria-pressed) named by its label and value, in a ToggleGroup labelled "Device" with arrow-key movement between tiles.
|
|
18
|
+
- The chart's name says which series is plotted, so the data table is unambiguous.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { BarChart, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
/** 30 days of visitors — replace with your own rows ({ date, desktop, mobile }). */
|
|
27
|
+
const days = Array.from({ length: 30 }, (_, i) => ({
|
|
28
|
+
date: new Date(2026, 8, 1 + i).toISOString().slice(0, 10),
|
|
29
|
+
desktop: Math.round(320 + Math.sin(i / 3) * 90 + (i % 5) * 14),
|
|
30
|
+
mobile: Math.round(260 + Math.cos(i / 4) * 80 + (i % 4) * 18),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const series = [
|
|
34
|
+
{ key: "desktop", label: "Desktop" },
|
|
35
|
+
{ key: "mobile", label: "Mobile" },
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
const shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
39
|
+
|
|
40
|
+
export interface DeviceComparisonBlockProps {
|
|
41
|
+
className?: string
|
|
42
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
43
|
+
framed?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function DeviceComparisonBlock({ className, framed = true }: DeviceComparisonBlockProps) {
|
|
47
|
+
const [active, setActive] = useState<string>("desktop")
|
|
48
|
+
const totals = Object.fromEntries(series.map((s) => [s.key, days.reduce((sum, d) => sum + d[s.key], 0)]))
|
|
49
|
+
const current = series.find((s) => s.key === active) ?? series[0]
|
|
50
|
+
return (
|
|
51
|
+
// overflow-hidden: the square tiles meet the card's rounded corner.
|
|
52
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl overflow-hidden", className)}>
|
|
53
|
+
<div className="flex flex-col border-b border-border sm:flex-row">
|
|
54
|
+
<CardHeader className="flex-1 pb-[var(--card-p,--spacing(6))]">
|
|
55
|
+
<CardTitle render={<h2 />}>Visitors by device</CardTitle>
|
|
56
|
+
<CardDescription>The last 30 days. Pick a device to plot it.</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
{/* The totals ARE the switch: one pressed tile per series. */}
|
|
59
|
+
<ToggleGroup
|
|
60
|
+
value={[active]}
|
|
61
|
+
onValueChange={(v: unknown[]) => v[0] && setActive(String(v[0]))}
|
|
62
|
+
aria-label="Device"
|
|
63
|
+
className="items-stretch gap-0"
|
|
64
|
+
>
|
|
65
|
+
{series.map((s) => (
|
|
66
|
+
<Toggle
|
|
67
|
+
key={s.key}
|
|
68
|
+
value={s.key}
|
|
69
|
+
className="h-auto flex-1 flex-col items-start justify-center gap-1 rounded-none border-border px-6 py-4 text-start text-foreground max-sm:border-t sm:border-s"
|
|
70
|
+
>
|
|
71
|
+
<span className="text-caption text-muted-foreground">{s.label}</span>
|
|
72
|
+
<span className="text-mono-lg">{totals[s.key].toLocaleString("en-US")}</span>
|
|
73
|
+
</Toggle>
|
|
74
|
+
))}
|
|
75
|
+
</ToggleGroup>
|
|
76
|
+
</div>
|
|
77
|
+
<CardContent>
|
|
78
|
+
<BarChart
|
|
79
|
+
label={`${current.label} visitors per day, last 30 days`}
|
|
80
|
+
data={days}
|
|
81
|
+
index="date"
|
|
82
|
+
series={[current]}
|
|
83
|
+
indexFormatter={shortDate}
|
|
84
|
+
radius={3}
|
|
85
|
+
className="h-64"
|
|
86
|
+
/>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
@@ -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
|
+
```
|