@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,73 @@
|
|
|
1
|
+
# StepsBlock
|
|
2
|
+
|
|
3
|
+
A how-it-works section: the heading, lede and a guide link on the left; three numbered steps on the right, each under a strong rule.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this when the pitch is a sequence — onboarding, a workflow, a setup. Parallel benefits with no order are FeatureGridBlock; progress through a live process is a Stepper.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the BrandLandingPage page shell's how-it-works section.
|
|
12
|
+
- The steps are an <ol>, so the order is semantic and the 01 / 02 / 03 numbers are its visual echo, set in the mono tier.
|
|
13
|
+
- Three steps fit the 1 : 2 split on desktop; on a phone everything stacks with the intro first.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each step has an h3 under the section's h2.
|
|
18
|
+
- The guide link is an <a> styled as an outline Button, because it navigates.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useId } from "react"
|
|
24
|
+
import { Button, cn, Icon, Region, Stack } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const intro = {
|
|
27
|
+
eyebrow: "How it works",
|
|
28
|
+
title: "From request to sealed record.",
|
|
29
|
+
lede: "Teams are usually live within a week. Cove adapts to the routes you already run instead of asking you to start over.",
|
|
30
|
+
link: { label: "Read the rollout guide", href: "/guides/rollout" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const steps = [
|
|
34
|
+
{ title: "Connect your spaces", body: "Bring teams, vendors, and record stores into one workspace, with roles that mirror how you already work." },
|
|
35
|
+
{ title: "Route every approval", body: "Requests follow the path you define, with reviewers, deadlines, and escalation built in from the start." },
|
|
36
|
+
{ title: "Seal the record", body: "Approved work is archived with its full context and is ready for review the moment it closes." },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export interface StepsBlockProps {
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function StepsBlock({ className }: StepsBlockProps) {
|
|
44
|
+
const titleId = useId()
|
|
45
|
+
return (
|
|
46
|
+
<Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16 sm:py-24", className)}>
|
|
47
|
+
<div className="grid gap-12 lg:grid-cols-[1fr_2fr] lg:gap-16">
|
|
48
|
+
<Stack gap={2} className="max-w-md">
|
|
49
|
+
<p className="text-overline text-muted-foreground">{intro.eyebrow}</p>
|
|
50
|
+
<h2 id={titleId} className="text-balance text-heading-1">
|
|
51
|
+
{intro.title}
|
|
52
|
+
</h2>
|
|
53
|
+
<p className="text-body-md text-muted-foreground">{intro.lede}</p>
|
|
54
|
+
<div className="pt-3">
|
|
55
|
+
<Button variant="outline" iconEnd={<Icon name="arrow-right" />} render={<a href={intro.link.href} />}>
|
|
56
|
+
{intro.link.label}
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
</Stack>
|
|
60
|
+
<ol className="grid gap-10 sm:grid-cols-3 sm:gap-6">
|
|
61
|
+
{steps.map((step, index) => (
|
|
62
|
+
<li key={step.title} className="flex flex-col gap-2 border-t border-border-strong pt-6">
|
|
63
|
+
<span className="text-mono-sm text-muted-foreground">{String(index + 1).padStart(2, "0")}</span>
|
|
64
|
+
<h3 className="mt-3 text-title-lg sm:mt-6">{step.title}</h3>
|
|
65
|
+
<p className="text-body-sm text-muted-foreground">{step.body}</p>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ol>
|
|
69
|
+
</div>
|
|
70
|
+
</Region>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# TaskListBlock
|
|
2
|
+
|
|
3
|
+
A checklist card: a titled list of Checkbox rows with a category Badge each, a live count of what is left, and a quiet Add task action.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for a short, personal to-do list on a dashboard or in onboarding. A team's task board with assignees and status columns needs a Table or DataTable.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Finishing a task strikes it through and mutes it rather than removing it, so the list does not jump under the pointer.
|
|
12
|
+
- The count in CardAction updates with every check, so progress reads without counting rows.
|
|
13
|
+
- Add task is a ghost Button in the footer — present, but quieter than the list.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Every Checkbox has a real <Label htmlFor>, so the whole title is the click target and the accessible name.
|
|
18
|
+
- Category Badges carry text; the tone only groups them visually.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState, useId } from "react"
|
|
24
|
+
import { Badge, Button, Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle, Checkbox, cn, Icon, Label } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const tasks = [
|
|
27
|
+
{ title: "Pick the cover type", tag: "Design", tone: "collectible", done: true },
|
|
28
|
+
{ title: "Wire up checkout", tag: "Engineering", tone: "info", done: false },
|
|
29
|
+
{ title: "Draft launch post", tag: "Launch", tone: "edition", done: false },
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
export interface TaskListBlockProps {
|
|
33
|
+
className?: string
|
|
34
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
35
|
+
framed?: boolean
|
|
36
|
+
onAddTask?: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function TaskListBlock({ className, framed = true, onAddTask }: TaskListBlockProps) {
|
|
40
|
+
const [done, setDone] = useState<boolean[]>(tasks.map((t) => t.done))
|
|
41
|
+
const id = useId()
|
|
42
|
+
const left = done.filter((d) => !d).length
|
|
43
|
+
return (
|
|
44
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
45
|
+
<CardHeader>
|
|
46
|
+
<CardTitle>This week</CardTitle>
|
|
47
|
+
<CardAction>
|
|
48
|
+
<span className="text-caption text-muted-foreground">{left} left</span>
|
|
49
|
+
</CardAction>
|
|
50
|
+
</CardHeader>
|
|
51
|
+
<CardContent>
|
|
52
|
+
<ul className="flex flex-col gap-1">
|
|
53
|
+
{tasks.map((task, i) => (
|
|
54
|
+
<li key={task.title} className="flex items-center gap-3 py-1.5">
|
|
55
|
+
<Checkbox
|
|
56
|
+
id={`${id}-${i}`}
|
|
57
|
+
checked={done[i]}
|
|
58
|
+
onCheckedChange={(v: boolean) => setDone(done.map((d, j) => (j === i ? v : d)))}
|
|
59
|
+
/>
|
|
60
|
+
<Label
|
|
61
|
+
htmlFor={`${id}-${i}`}
|
|
62
|
+
className={cn("min-w-0 flex-1 motion-interactive", done[i] && "text-muted-foreground line-through")}
|
|
63
|
+
>
|
|
64
|
+
{task.title}
|
|
65
|
+
</Label>
|
|
66
|
+
<Badge tone={task.tone} size="sm">
|
|
67
|
+
{task.tag}
|
|
68
|
+
</Badge>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</CardContent>
|
|
73
|
+
<CardFooter>
|
|
74
|
+
<Button variant="ghost" size="sm" iconStart={<Icon name="plus" />} onClick={onAddTask}>
|
|
75
|
+
Add task
|
|
76
|
+
</Button>
|
|
77
|
+
</CardFooter>
|
|
78
|
+
</Card>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# TeamMembersBlock
|
|
2
|
+
|
|
3
|
+
A members card: Entity rows with avatar, name and email, a role Badge, and an OverflowMenu of role changes and removal; pending invites sit in the same list.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to manage who is in a workspace, project or team. For inviting people when the list is empty or short, InviteTeamBlock is the lighter shape; for hundreds of members with filters, use a DataTable.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 20rem–42rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Each row's menu offers only what applies to it: Make admin or Make member, Resend invite for pending rows, then the destructive action after a separator.
|
|
12
|
+
- The owner has no menu — ownership is transferred elsewhere — and a control-sized spacer keeps the role badges aligned.
|
|
13
|
+
- Pending invites are outline Badges in the same list, so the team's full size reads in one place.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each OverflowMenu is labelled with the member's name ("Manage Mara Okafor"), so the triggers are distinguishable in a list.
|
|
18
|
+
- Roles are text in a Badge; tone only distinguishes the owner.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Avatar, Badge, Button, Card, CardAction, CardDescription, CardHeader, CardList, CardTitle, cn, Entity, Icon, MenuItem, MenuSeparator, OverflowMenu } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const members = [
|
|
26
|
+
{ id: "u1", name: "Ada Lovelace", initials: "AL", email: "ada@studio.com", role: "Owner" },
|
|
27
|
+
{ id: "u2", name: "Mara Okafor", initials: "MO", email: "mara@studio.com", role: "Admin" },
|
|
28
|
+
{ id: "u3", name: "Sam Rivera", initials: "SR", email: "sam@studio.com", role: "Member" },
|
|
29
|
+
{ id: "u4", name: "jun@studio.com", initials: "J", email: "Invited 2 days ago", role: "Pending" },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export interface TeamMembersBlockProps {
|
|
33
|
+
className?: string
|
|
34
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
35
|
+
framed?: boolean
|
|
36
|
+
onInvite?: () => void
|
|
37
|
+
/** Called with the member's id and the action chosen from their menu. */
|
|
38
|
+
onMemberAction?: (id: string, action: "make-admin" | "make-member" | "resend" | "remove") => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function TeamMembersBlock({ className, framed = true, onInvite, onMemberAction }: TeamMembersBlockProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
|
|
44
|
+
<CardHeader>
|
|
45
|
+
<CardTitle render={<h2 />}>Members</CardTitle>
|
|
46
|
+
<CardDescription>{members.length} people in this workspace.</CardDescription>
|
|
47
|
+
<CardAction>
|
|
48
|
+
<Button size="sm" iconStart={<Icon name="plus" />} onClick={onInvite}>
|
|
49
|
+
Invite
|
|
50
|
+
</Button>
|
|
51
|
+
</CardAction>
|
|
52
|
+
</CardHeader>
|
|
53
|
+
<CardList>
|
|
54
|
+
{members.map((m) => (
|
|
55
|
+
<Entity
|
|
56
|
+
key={m.id}
|
|
57
|
+
leading={<Avatar size="sm" fallback={m.initials} alt={m.name} />}
|
|
58
|
+
title={m.name}
|
|
59
|
+
subtitle={m.email}
|
|
60
|
+
action={
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<Badge appearance={m.role === "Pending" ? "outline" : "plain"} tone={m.role === "Owner" ? "edition" : "neutral"} size="sm">
|
|
63
|
+
{m.role}
|
|
64
|
+
</Badge>
|
|
65
|
+
{/* The owner is not managed from here; a placeholder keeps the column aligned. */}
|
|
66
|
+
{m.role === "Owner" ? (
|
|
67
|
+
<span aria-hidden className="size-(--control-sm)" />
|
|
68
|
+
) : (
|
|
69
|
+
<OverflowMenu label={`Manage ${m.name}`} size="icon-sm">
|
|
70
|
+
{m.role === "Pending" ? (
|
|
71
|
+
<MenuItem onClick={() => onMemberAction?.(m.id, "resend")}>Resend invite</MenuItem>
|
|
72
|
+
) : m.role === "Admin" ? (
|
|
73
|
+
<MenuItem onClick={() => onMemberAction?.(m.id, "make-member")}>Make member</MenuItem>
|
|
74
|
+
) : (
|
|
75
|
+
<MenuItem onClick={() => onMemberAction?.(m.id, "make-admin")}>Make admin</MenuItem>
|
|
76
|
+
)}
|
|
77
|
+
<MenuSeparator />
|
|
78
|
+
<MenuItem variant="destructive" onClick={() => onMemberAction?.(m.id, "remove")}>
|
|
79
|
+
{m.role === "Pending" ? "Cancel invite" : "Remove from workspace"}
|
|
80
|
+
</MenuItem>
|
|
81
|
+
</OverflowMenu>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</CardList>
|
|
88
|
+
</Card>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# TestimonialBlock
|
|
2
|
+
|
|
3
|
+
A customer story: one large blockquote with the person's avatar, name and role, beside three outcome figures separated by rules.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for one strong, attributable quote backed by numbers. A wall of short quotes is a different section; a logo row is Marquee.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the BrandLandingPage page shell's customer story.
|
|
12
|
+
- The quote and the outcomes share one Card as a <figure>; the outcomes move under the quote below lg.
|
|
13
|
+
- Outcome values use display sizes because they are the proof; their labels say what each number counts.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- <figure> + <blockquote> + <figcaption> tie the quote to the person who said it.
|
|
18
|
+
- The section is labelled "Customer story" so it is reachable as a landmark.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Avatar, Card, CardContent, cn, Region, Separator, Stack } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const story = {
|
|
26
|
+
quote: "We used to chase approvals across four tools. Now every request has an owner, a deadline, and a record, and our auditors stopped asking for screenshots.",
|
|
27
|
+
name: "Maya Okafor",
|
|
28
|
+
initials: "MO",
|
|
29
|
+
role: "Head of operations, Fielddesk",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const outcomes = [
|
|
33
|
+
{ value: "3.2×", label: "faster approvals in the first quarter" },
|
|
34
|
+
{ value: "68%", label: "fewer follow-ups on handoffs" },
|
|
35
|
+
{ value: "1 day", label: "to prepare a quarterly audit" },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export interface TestimonialBlockProps {
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function TestimonialBlock({ className }: TestimonialBlockProps) {
|
|
43
|
+
return (
|
|
44
|
+
<Region as="section" aria-label="Customer story" max="wide" className={cn("w-full py-16", className)}>
|
|
45
|
+
<Card render={<figure />}>
|
|
46
|
+
<div className="grid lg:grid-cols-[1.5fr_1fr]">
|
|
47
|
+
<CardContent className="flex flex-col justify-between gap-10 sm:p-10">
|
|
48
|
+
<Stack gap={3}>
|
|
49
|
+
<p className="text-overline text-muted-foreground">Customer story</p>
|
|
50
|
+
<blockquote className="text-balance text-heading-3 sm:text-heading-2">“{story.quote}”</blockquote>
|
|
51
|
+
</Stack>
|
|
52
|
+
<figcaption className="flex items-center gap-3">
|
|
53
|
+
<Avatar size="lg" fallback={story.initials} alt={story.name} />
|
|
54
|
+
<div>
|
|
55
|
+
<p className="text-title-sm">{story.name}</p>
|
|
56
|
+
<p className="text-body-sm text-muted-foreground">{story.role}</p>
|
|
57
|
+
</div>
|
|
58
|
+
</figcaption>
|
|
59
|
+
</CardContent>
|
|
60
|
+
<div className="border-border max-lg:border-t lg:border-s">
|
|
61
|
+
<CardContent className="flex h-full flex-col justify-center gap-6 sm:p-10">
|
|
62
|
+
{outcomes.map((outcome, index) => (
|
|
63
|
+
<Stack key={outcome.label} gap={3}>
|
|
64
|
+
{index > 0 && <Separator />}
|
|
65
|
+
<div>
|
|
66
|
+
<p className="text-heading-1 sm:text-display-lg">{outcome.value}</p>
|
|
67
|
+
<p className="mt-1 text-body-sm text-muted-foreground">{outcome.label}</p>
|
|
68
|
+
</div>
|
|
69
|
+
</Stack>
|
|
70
|
+
))}
|
|
71
|
+
</CardContent>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</Card>
|
|
75
|
+
</Region>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# TrafficSourcesBlock
|
|
2
|
+
|
|
3
|
+
A share card: a donut PieChart of where a total comes from, the total in the hole, a legend, and the headline share in words in the footer.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for a split of one total into four or fewer parts — traffic sources, revenue by plan, tickets by channel. More parts, or values to compare exactly, belong in a horizontal BarChart.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 18rem–24rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The total sits in the donut's hole, so the card answers "how many" and "from where" at once.
|
|
12
|
+
- The footer puts the biggest share in a sentence — the takeaway does not depend on reading the slices.
|
|
13
|
+
- The slices sweep in clockwise as the card scrolls into view.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The chart's data table lists every source and its count; the legend repeats it visually.
|
|
18
|
+
- Hovering or arrowing through the slices pulls each out and announces it.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, PieChart } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const sources = [
|
|
26
|
+
{ source: "Search", visitors: 4280 },
|
|
27
|
+
{ source: "Direct", visitors: 2910 },
|
|
28
|
+
{ source: "Social", visitors: 1840 },
|
|
29
|
+
{ source: "Email", visitors: 960 },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const total = sources.reduce((sum, s) => sum + s.visitors, 0)
|
|
33
|
+
|
|
34
|
+
export interface TrafficSourcesBlockProps {
|
|
35
|
+
className?: string
|
|
36
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
37
|
+
framed?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {
|
|
41
|
+
return (
|
|
42
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle render={<h2 />}>Traffic sources</CardTitle>
|
|
45
|
+
<CardDescription>Where this month's visitors came from.</CardDescription>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent>
|
|
48
|
+
<PieChart
|
|
49
|
+
label="Visitors by source, this month"
|
|
50
|
+
data={sources}
|
|
51
|
+
index="source"
|
|
52
|
+
valueKey="visitors"
|
|
53
|
+
donut
|
|
54
|
+
centerLabel={{ value: total.toLocaleString("en-US"), label: "Visitors" }}
|
|
55
|
+
/>
|
|
56
|
+
</CardContent>
|
|
57
|
+
<CardFooter className="text-body-sm text-muted-foreground">
|
|
58
|
+
Search brings {Math.round((sources[0].visitors / total) * 100)}% of visitors.
|
|
59
|
+
</CardFooter>
|
|
60
|
+
</Card>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# TrafficTrendBlock
|
|
2
|
+
|
|
3
|
+
A trend card: a stacked gradient AreaChart of two series by day, a time-range ToggleGroup in the header, and a one-line takeaway with its direction in the footer.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the headline chart of a dashboard or an analytics section on a landing page. For a single number with a small trend beside it, KpiSparklineBlock is lighter.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 20rem–48rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Switching the range eases the chart from one shape to the next instead of redrawing it.
|
|
12
|
+
- The footer states the takeaway in words, so the card says something even before the chart is read.
|
|
13
|
+
- The tooltip heading spells out the weekday; the axis keeps short dates so they fit.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The chart's accessible name includes the range showing, and its data table updates with it.
|
|
18
|
+
- The range control is a labelled ToggleGroup; the chart is one tab stop with arrow-key navigation.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { AreaChart, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Icon, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */
|
|
27
|
+
const days = Array.from({ length: 90 }, (_, i) => {
|
|
28
|
+
const wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25
|
|
29
|
+
return {
|
|
30
|
+
date: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),
|
|
31
|
+
desktop: Math.round(240 + wave + (i % 7) * 9),
|
|
32
|
+
mobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const series = [
|
|
37
|
+
{ key: "desktop", label: "Desktop" },
|
|
38
|
+
{ key: "mobile", label: "Mobile" },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const ranges = [
|
|
42
|
+
{ id: "90d", label: "3 months", days: 90 },
|
|
43
|
+
{ id: "30d", label: "30 days", days: 30 },
|
|
44
|
+
{ id: "7d", label: "7 days", days: 7 },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
48
|
+
|
|
49
|
+
export interface TrafficTrendBlockProps {
|
|
50
|
+
className?: string
|
|
51
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
52
|
+
framed?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {
|
|
56
|
+
const [range, setRange] = useState("90d")
|
|
57
|
+
const shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))
|
|
58
|
+
return (
|
|
59
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl", className)}>
|
|
60
|
+
<CardHeader>
|
|
61
|
+
<CardTitle render={<h2 />}>Visitors</CardTitle>
|
|
62
|
+
<CardDescription>Desktop and mobile, by day.</CardDescription>
|
|
63
|
+
<CardAction>
|
|
64
|
+
<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label="Time range">
|
|
65
|
+
{ranges.map((r) => (
|
|
66
|
+
<Toggle key={r.id} value={r.id}>
|
|
67
|
+
{r.label}
|
|
68
|
+
</Toggle>
|
|
69
|
+
))}
|
|
70
|
+
</ToggleGroup>
|
|
71
|
+
</CardAction>
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent>
|
|
74
|
+
<AreaChart
|
|
75
|
+
label={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}
|
|
76
|
+
data={shown}
|
|
77
|
+
index="date"
|
|
78
|
+
series={series}
|
|
79
|
+
indexFormatter={shortDate}
|
|
80
|
+
tooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }) }}
|
|
81
|
+
stacked
|
|
82
|
+
gradient
|
|
83
|
+
className="h-64"
|
|
84
|
+
/>
|
|
85
|
+
</CardContent>
|
|
86
|
+
<CardFooter className="gap-1.5 text-body-sm">
|
|
87
|
+
<Icon name="arrow-up" className="text-success" />
|
|
88
|
+
<span>
|
|
89
|
+
Up 5.2% on the previous period <span className="text-muted-foreground">· desktop leads every week</span>
|
|
90
|
+
</span>
|
|
91
|
+
</CardFooter>
|
|
92
|
+
</Card>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# UploadListBlock
|
|
2
|
+
|
|
3
|
+
An upload queue: full-width Attachment rows — one uploading with progress and a cancel button, one finished with a success check.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this under a FileDropzone, or anywhere files are in flight — a composer, an import step, a document form. For files attached to a message, Attachment chips in the composer fit better.
|
|
6
|
+
|
|
7
|
+
**Block:** Media & files · card · designed for 16rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Rows, not chips: each Attachment drops its chip cap (`max-w-none`) to fill the block's width.
|
|
12
|
+
- The success check sits in a control-sized box so it lines up with the cancel button on the row above.
|
|
13
|
+
- No card: Attachment rows are their own surfaces. Wrap the block in a Card if it needs a header.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Attachment announces upload progress; the cancel Button carries aria-label.
|
|
18
|
+
- The finished row's check carries aria-label="Uploaded", so the state is spoken, not only coloured.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Attachment, Button, cn, Icon } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const uploading = { name: "brand-guidelines.pdf", meta: "4.2 MB of 6.8 MB", progress: 62 }
|
|
26
|
+
const uploaded = { name: "cover-final.png", meta: "Uploaded · 2.1 MB" }
|
|
27
|
+
|
|
28
|
+
export interface UploadListBlockProps {
|
|
29
|
+
className?: string
|
|
30
|
+
/** Called when the in-progress upload is cancelled. */
|
|
31
|
+
onCancel?: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function UploadListBlock({ className, onCancel }: UploadListBlockProps) {
|
|
35
|
+
return (
|
|
36
|
+
// An upload list, not chips: rows fill the block's width.
|
|
37
|
+
<div className={cn("flex w-full max-w-xs flex-col gap-2", className)}>
|
|
38
|
+
<Attachment
|
|
39
|
+
className="max-w-none"
|
|
40
|
+
name={uploading.name}
|
|
41
|
+
meta={uploading.meta}
|
|
42
|
+
status="uploading"
|
|
43
|
+
progress={uploading.progress}
|
|
44
|
+
actions={
|
|
45
|
+
<Button variant="ghost" size="icon-sm" aria-label="Cancel upload" onClick={onCancel}>
|
|
46
|
+
<Icon name="xmark" />
|
|
47
|
+
</Button>
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
<Attachment
|
|
51
|
+
className="max-w-none"
|
|
52
|
+
name={uploaded.name}
|
|
53
|
+
icon="image-stack"
|
|
54
|
+
meta={uploaded.meta}
|
|
55
|
+
// The same box as the icon-sm button above, so the check centres on
|
|
56
|
+
// the cancel button's line instead of hugging the edge.
|
|
57
|
+
actions={
|
|
58
|
+
<span className="grid size-(--control-sm) place-items-center">
|
|
59
|
+
<Icon name="circle-check" className="text-success" aria-label="Uploaded" />
|
|
60
|
+
</span>
|
|
61
|
+
}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# VerifyCodeBlock
|
|
2
|
+
|
|
3
|
+
A one-time-code card: where the code went, a six-slot OtpField grouped 3 + 3 under a visible label, and a quiet Resend.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for email or SMS verification, two-factor sign-in, or confirming a sensitive change. For a code someone copies out (a recovery key), show it with Code and a copy button instead.
|
|
6
|
+
|
|
7
|
+
**Block:** Auth · card · designed for 18rem–24rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- No submit button: the code verifies the moment the last slot fills, which is what people expect from a code field.
|
|
12
|
+
- The destination is named in the description, so a mistyped address is caught before the wait.
|
|
13
|
+
- Resend is a ghost Button — present for the stuck case, never competing with the field.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- OtpField names each slot ("Character 1 of 6") and accepts a pasted code into all six at once.
|
|
18
|
+
- Field labels the group and reads the expiry as its description.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Field, OtpField } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const sentTo = "ada@studio.com"
|
|
26
|
+
|
|
27
|
+
export interface VerifyCodeBlockProps {
|
|
28
|
+
className?: string
|
|
29
|
+
/** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
|
|
30
|
+
framed?: boolean
|
|
31
|
+
/** Called with the code as soon as the last digit is entered. */
|
|
32
|
+
onVerify?: (code: string) => void
|
|
33
|
+
onResend?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function VerifyCodeBlock({ className, framed = true, onVerify, onResend }: VerifyCodeBlockProps) {
|
|
37
|
+
return (
|
|
38
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
|
|
39
|
+
<CardHeader>
|
|
40
|
+
<CardTitle>Check your email</CardTitle>
|
|
41
|
+
<CardDescription>
|
|
42
|
+
We sent a 6-digit code to <span className="text-foreground">{sentTo}</span>.
|
|
43
|
+
</CardDescription>
|
|
44
|
+
</CardHeader>
|
|
45
|
+
<CardContent>
|
|
46
|
+
<Field label="Verification code" description="The code expires in 10 minutes.">
|
|
47
|
+
<OtpField length={6} groupSize={3} onValueComplete={(code: string) => onVerify?.(code)} />
|
|
48
|
+
</Field>
|
|
49
|
+
</CardContent>
|
|
50
|
+
<CardFooter className="justify-between gap-3">
|
|
51
|
+
<span className="text-caption text-muted-foreground">Didn’t get it?</span>
|
|
52
|
+
<Button variant="ghost" size="sm" onClick={onResend}>
|
|
53
|
+
Resend code
|
|
54
|
+
</Button>
|
|
55
|
+
</CardFooter>
|
|
56
|
+
</Card>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
```
|