@sableui/react 0.8.1 → 0.9.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 +2 -2
- package/CHANGELOG.md +97 -0
- package/bin/sable.mjs +26 -11
- package/dist/blocks/index.d.ts +1 -1
- package/dist/components/app-shell/app-shell.js +2 -12
- package/dist/components/app-shell/app-shell.meta.js +22 -0
- package/dist/components/app-shell/index.js +2 -0
- package/dist/components/app-shell/skip-link.js +28 -0
- package/dist/components/button/button.js +35 -8
- package/dist/components/button/button.meta.js +6 -1
- package/dist/components/card/card.meta.js +1 -1
- package/dist/components/command-menu/command-menu.js +27 -2
- package/dist/components/data-table/data-table.meta.js +4 -0
- package/dist/components/date-tile/date-tile.meta.js +1 -0
- package/dist/components/feedback/feedback.meta.js +1 -0
- package/dist/components/fieldset/fieldset.js +22 -13
- package/dist/components/fieldset/fieldset.meta.js +25 -1
- package/dist/components/form/form.js +45 -9
- package/dist/components/form/form.meta.js +12 -3
- package/dist/components/media-tile/media-tile.js +16 -2
- package/dist/components/media-tile/media-tile.meta.js +8 -0
- package/dist/components/radio-card/radio-card.js +6 -2
- package/dist/components/radio-card/radio-card.meta.js +5 -0
- package/dist/components/sheet/sheet.js +1 -0
- package/dist/components/stepper/stepper.js +7 -2
- package/dist/components/stepper/stepper.meta.js +1 -1
- package/dist/components/toast/toast.js +2 -0
- package/dist/file-hashes.json +29 -28
- package/dist/index.d.ts +109 -22
- package/dist/index.js +1 -1
- package/dist/{meta-CcE_SeFu.d.ts → meta-DVG8ceze.d.ts} +9 -0
- package/dist/patterns/index.d.ts +1 -1
- package/dist/styles.css +30 -0
- package/llms/components/app-shell.md +26 -0
- package/llms/components/button.md +2 -1
- package/llms/components/card.md +1 -1
- package/llms/components/data-table.md +1 -0
- package/llms/components/fieldset.md +21 -1
- package/llms/components/form.md +11 -2
- package/llms/components/media-tile.md +32 -0
- package/llms/components/radio-card.md +1 -0
- package/llms/components/stepper.md +1 -1
- package/llms/tasks/creating-a-preset.md +4 -1
- package/llms/tasks/getting-started.md +6 -0
- package/llms/tasks/tables-with-tanstack.md +157 -0
- package/llms-full.txt +266 -7
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/registry.json +94 -10
- package/scripts/grade.mjs +14 -3
- package/scripts/sources.mjs +10 -2
- package/skills/sable/SKILL.md +32 -7
package/AGENTS.md
CHANGED
|
@@ -32,7 +32,7 @@ library gap to report, not a reason for a native control.
|
|
|
32
32
|
|
|
33
33
|
These are short on purpose. Each links to a foundations doc with the full vocabulary, the reasoning, and before/after examples; `sable-lint` catches most violations.
|
|
34
34
|
|
|
35
|
-
0. **Compose from Sable components; never rebuild one.** Before writing any interactive or recognisable element — a button, a field, a toggle, tabs, a menu, a badge, a card, a search
|
|
35
|
+
0. **Compose from Sable components; never rebuild one.** Before writing any interactive or recognisable element — a button, a field, a toggle, tabs, a menu, a badge, a card, a search field, a dialog — run `npx sable search "<what you need>"` and use what it returns. This binds behaviour and parts, not layouts: results marked ◇ (patterns, blocks, compositions such as MediaTile) are references, used when their layout fits the brief. When one does not fit, compose your own from Sable's parts and behaviour. A storefront's search panel can look like nothing else in the catalog, as long as its field, list keyboard model and rows are Sable's. A raw `<button>`, `<input>` or `<div>` dressed in a component's classes is a reinvention, and it is a bug even when every class is a semantic token: it misses the component's states, density, focus and keyboard contract, and drifts the moment the component changes. A link that looks like a button is `<Button render={<a href=… />}>`. If nothing fits, compose from the nearest component's parts; if it genuinely does not exist, say so in your summary — that is a library gap to report, not a license to invent. The rules below are for styling what you compose *around* components, not for re-creating them. `sable-lint` flags the common cases (`hand-rolled-control`, `lookalike-control`), and the bigger assemblies built from utilities and tokens: a raw `grid` of mapped items is `<Grid cols minItemWidth>` (`lookalike-grid`), a link or button wearing a card's radius, surface and padding is `<Card render={<Link … />}>` (`lookalike-card`), a round fixed-size box holding a picture, GradientArt or initials is `<Avatar>` (`lookalike-avatar`), and an `aspect-*` + `overflow-hidden` frame around media is `<AspectBox ratio>` (`lookalike-media-frame`). `sable check` also lists every app-owned surface (a card fill, a bordered padded box, a media frame, a fixed-size circle) for your reuse inventory: each one composes the component named beside it, or the inventory says why the app owns it.
|
|
36
36
|
|
|
37
37
|
**The same goes for everything below a component. Resolve in this order, and stop at the first that fits:** (1) a **component** → (2) a Sable **utility or recipe** — relative surfaces (`sable-surface*`, `sable-well`, `sable-surface-interactive`), state variants (`sable-selected:`, `sable-highlighted:`…), geometry (`rounded-concentric-*`, `popup-inset`, `rounded-popup-item`, `popup-bleed`), scroll policies, `motion-*` recipes → (3) a **semantic token** → (4) hand-written CSS, last, and reported as a gap. `npx sable search "<what you need>"` searches all of these; `registry.json → utilities` lists the utilities with intent and what each replaces. Mixing ink into a surface by hand (`color-mix(… var(--foreground) …)`) is the tell that you skipped step 2 — that is what the relative-surface utilities compute, in every theme and mode.
|
|
38
38
|
|
|
@@ -55,7 +55,7 @@ Pick a theme as a starting point, then override tokens on top of it. Every token
|
|
|
55
55
|
|
|
56
56
|
The machine-readable version of this section is the `consume` block in `registry.json`.
|
|
57
57
|
|
|
58
|
-
1. **Install** from npm: `pnpm add @sableui/react` (or the project's package manager). No registry configuration or token is needed.
|
|
58
|
+
1. **Install** from npm: `pnpm add @sableui/react` (or the project's package manager). No registry configuration or token is needed. Sable depends on no router, data or form library, and keeps whatever the project already uses: links, menus and nav items take any router's link through `render` (`render={<Link to="/inbox" />}` is TanStack Router's or React Router's `Link` alike). For a blank-slate app, the suggested stack is Vite + TypeScript with TanStack Router, TanStack Query, TanStack Form for forms with validation logic, and TanStack Table once a table outgrows `DataTable` (`sable docs tables-with-tanstack`).
|
|
59
59
|
2. **Dedupe React** in the bundler (`resolve.dedupe: ["react","react-dom"]`) — Base UI must not resolve a second React, or you get an "invalid hook call" blank page.
|
|
60
60
|
3. **CSS, in order:** `@import "tailwindcss";` → `@import "@sableui/react/styles.css";` → `@source "<node_modules>/@sableui/react/dist/**/*.{js,mjs}";` so Tailwind scans the package's classes. That ships every component's classes (about 32 KB gzipped); before shipping, `sable sources --write src/sable-sources.css` lists just the modules the app imports, imported in place of that `@source`, and `sable check` fails when it goes stale.
|
|
61
61
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# What's new
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
A storefront built on 0.8.2 (and the same brief on shadcn) found the seams
|
|
6
|
+
below: focus lost after async actions, controls clipped in accordions, and
|
|
7
|
+
slots a shop needs that the parts did not have. It also built its own search
|
|
8
|
+
panel, and it was good. This release makes that the expected outcome rather
|
|
9
|
+
than a rule bent.
|
|
10
|
+
|
|
11
|
+
### Apps on Sable shouldn't all look alike
|
|
12
|
+
|
|
13
|
+
- **Behaviour is fixed; layout is yours.** The skill and `AGENTS.md` now sort
|
|
14
|
+
the catalog three ways. Behaviour (controls, fields, overlays, anything with
|
|
15
|
+
focus or keys) is always used. Parts (Card, Entity, AspectBox, Badge) are
|
|
16
|
+
composed freely. References (patterns, blocks, and compositions such as
|
|
17
|
+
MediaTile) are used when their layout fits the brief; otherwise the agent
|
|
18
|
+
composes its own from parts, and that is not a gap.
|
|
19
|
+
- **Signature surfaces.** The brief names the one to three surfaces where a
|
|
20
|
+
product's identity lives (a shop's product card and search). The agent
|
|
21
|
+
designs those from the brief's direction, and takes Sable's defaults
|
|
22
|
+
everywhere else.
|
|
23
|
+
- **`sable search` and `sable component` mark references** with ◇ and name
|
|
24
|
+
the parts to compose your own from (`reference` and `parts` in JSON).
|
|
25
|
+
- **`sable check`'s surface list reads as notes.** "→ Sable has Card"
|
|
26
|
+
replaces "→ consider Card", and the reuse inventory records "used it" or
|
|
27
|
+
"kept ours, because…" with neither treated as a failure. A new note flags
|
|
28
|
+
arrow keys handled by hand: list keyboard navigation is behaviour CommandMenu,
|
|
29
|
+
Combobox, Menu and Toolbar already own.
|
|
30
|
+
|
|
31
|
+
### Upgrading from 0.8
|
|
32
|
+
|
|
33
|
+
- **Button `loading` no longer sets `disabled`.** A busy native button gets
|
|
34
|
+
`aria-disabled` and refuses presses itself, so it keeps focus. Disabling the
|
|
35
|
+
focused button sent focus to `<body>`, and a sheet the action opened had
|
|
36
|
+
nowhere to return it. A test asserting `toBeDisabled()` on a loading button
|
|
37
|
+
checks `aria-disabled="true"` now.
|
|
38
|
+
- **A horizontal Field is a few pixels taller.** Its row is padded to a
|
|
39
|
+
Switch's height, so the control no longer overflows the Field's box.
|
|
40
|
+
|
|
41
|
+
### Fixes
|
|
42
|
+
|
|
43
|
+
- **Focus moves to the first invalid field after your own validation.** Form
|
|
44
|
+
focused it only for its own checks. Errors your handler sets on Fields now get
|
|
45
|
+
the same, once `onSubmit` (or the promise it returns) is done, with or
|
|
46
|
+
without `noValidate`.
|
|
47
|
+
- **Controls in an AccordionPanel are no longer clipped.** The first checkbox
|
|
48
|
+
or switch lost its top edge, and focus rings their left edge.
|
|
49
|
+
`motion-disclosure` clips with a small margin, and horizontal Fields contain
|
|
50
|
+
their control.
|
|
51
|
+
- **Toasts keep clear of an open Sheet.** A toast on the sheet's edge covered
|
|
52
|
+
its footer (Check out) or its close button. With room, the stack moves beside
|
|
53
|
+
the sheet; on a narrow screen, a bottom stack moves to the top.
|
|
54
|
+
- **Field forwards `data-*`, `id` and other props to its root.**
|
|
55
|
+
- **`sable grade` accepts native input attributes** (`inputMode`,
|
|
56
|
+
`enterKeyHint`, `autoCapitalize`, `autoCorrect`, `pattern`, `list`, `accept`,
|
|
57
|
+
`capture`). It flagged the ones the form contract asks for.
|
|
58
|
+
- **`sable check` warns when `sable sources` trims nothing.** With the
|
|
59
|
+
whole-package `@source` still beside the generated file, Tailwind scans all
|
|
60
|
+
of Sable anyway.
|
|
61
|
+
|
|
62
|
+
### New
|
|
63
|
+
|
|
64
|
+
- **Button `onClick` can return a promise.** The button shows `loading` until
|
|
65
|
+
it settles and ignores presses meanwhile, from the first click on. A `busy`
|
|
66
|
+
flag in state took effect a render late, so three fast clicks added three
|
|
67
|
+
items.
|
|
68
|
+
- **Field `trailing`.** Vertically, it sits beside the control: a promo code's
|
|
69
|
+
Apply button. Horizontally, it sits at the end of the label's line: a facet
|
|
70
|
+
count.
|
|
71
|
+
- **RadioCard `trailing`,** end-aligned on the title's line: a delivery
|
|
72
|
+
option's price.
|
|
73
|
+
- **MediaTile `meta`, `srcSet` and `sizes`.** `meta` holds the lines under the
|
|
74
|
+
subtitle (price, sale price, finishes, a Badge), so a product tile is a
|
|
75
|
+
MediaTile.
|
|
76
|
+
- **CommandMenu items take `leading`** (a thumbnail or Avatar in place of
|
|
77
|
+
`icon`), and the field has a clear button once there is a query.
|
|
78
|
+
- **`Fieldset variant="plain"`:** the group and its legend without the card.
|
|
79
|
+
It names a radio group inside a form that is already framed.
|
|
80
|
+
- **Stepper compacts on a phone.** A horizontal stepper narrower than 28rem
|
|
81
|
+
keeps every disc and only the current step's title; the other titles stay
|
|
82
|
+
for screen readers.
|
|
83
|
+
- **`SkipLink`,** AppShell's skip link on its own, for layouts that are not an
|
|
84
|
+
AppShell.
|
|
85
|
+
|
|
86
|
+
## 0.8.2
|
|
87
|
+
|
|
88
|
+
- **A suggested stack for blank-slate apps.** Sable still depends on no router,
|
|
89
|
+
data or form library, and an app keeps the ones it has. Starting from
|
|
90
|
+
nothing, the skill, `AGENTS.md`, `sable docs getting-started` and the install
|
|
91
|
+
recipe (`registry.json` → `consume.stack`) now suggest Vite + TypeScript with
|
|
92
|
+
TanStack Router, TanStack Query, TanStack Form for forms with validation
|
|
93
|
+
logic, and TanStack Table once a table outgrows `DataTable`.
|
|
94
|
+
- **`sable docs tables-with-tanstack`:** when to move from `DataTable` to
|
|
95
|
+
TanStack Table (server-side data, column visibility or resizing, grouping,
|
|
96
|
+
virtualised rows), and how to render it with Sable's `Table` parts,
|
|
97
|
+
`Checkbox`, `Button` and `Pagination`, with server data through TanStack
|
|
98
|
+
Query and bulk-action selection. `DataTable` stays dependency-free.
|
|
99
|
+
|
|
3
100
|
## 0.8.1
|
|
4
101
|
|
|
5
102
|
- **`sable check` shows doctor's notes.** Warnings and notes from `sable doctor`
|
package/bin/sable.mjs
CHANGED
|
@@ -608,7 +608,7 @@ function cmdSearch() {
|
|
|
608
608
|
const pool = []
|
|
609
609
|
if (!type || type === "component")
|
|
610
610
|
for (const c of COMPONENTS)
|
|
611
|
-
pool.push({ kind: "component", name: c.name, summary: c.summary, status: c.status, group: c.group, _s: score({ name: c.name, keywords: c.keywords, text: `${c.summary} ${c.usage?.description ?? ""}` }) })
|
|
611
|
+
pool.push({ kind: "component", name: c.name, summary: c.summary, status: c.status, group: c.group, ...(c.composition ? { reference: true, parts: c.composition } : {}), _s: score({ name: c.name, keywords: c.keywords, text: `${c.summary} ${c.usage?.description ?? ""}` }) })
|
|
612
612
|
if (!type || type === "provider")
|
|
613
613
|
for (const p of PROVIDERS)
|
|
614
614
|
pool.push({ kind: "provider", name: p.name, summary: p.summary, _s: score({ name: p.name, keywords: ["provider", ...(p.companions ?? [])], text: `${p.summary} ${p.placement ?? ""}` }) })
|
|
@@ -646,15 +646,26 @@ function cmdSearch() {
|
|
|
646
646
|
"Try a single word — search is term-wise, not phrase-wise",
|
|
647
647
|
])
|
|
648
648
|
|
|
649
|
-
|
|
649
|
+
// Patterns, blocks and composition components are designed arrangements,
|
|
650
|
+
// not behaviour: references to use when their layout fits the brief. Marked
|
|
651
|
+
// so an agent does not take the nearest one as the required answer.
|
|
652
|
+
const results = hits.map(({ _s, ...h }) => ({
|
|
653
|
+
...h,
|
|
654
|
+
...(h.kind === "pattern" || h.kind === "block" ? { reference: true } : {}),
|
|
655
|
+
retrieve: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
|
|
656
|
+
}))
|
|
657
|
+
const anyReference = results.some((h) => h.reference)
|
|
650
658
|
ok("search", { query, count: results.length, results }, () =>
|
|
651
|
-
|
|
652
|
-
.map((h) =>
|
|
659
|
+
[
|
|
660
|
+
...results.map((h) =>
|
|
653
661
|
DENSE
|
|
654
|
-
? `${h.kind}/${h.name}`
|
|
655
|
-
: `${h.kind.padEnd(9)} ${h.name.padEnd(24)} ${firstSentence(h.summary ?? "")}`,
|
|
656
|
-
)
|
|
657
|
-
|
|
662
|
+
? `${h.kind}/${h.name}${h.reference ? " (reference)" : ""}`
|
|
663
|
+
: `${h.kind.padEnd(9)} ${`${h.name}${h.reference ? " ◇" : ""}`.padEnd(24)} ${firstSentence(h.summary ?? "")}`,
|
|
664
|
+
),
|
|
665
|
+
...(anyReference && !DENSE
|
|
666
|
+
? ["", "◇ a reference layout: use it when it fits the brief, or compose your own from Sable's parts. Either is fine. Behaviour components (controls, overlays, fields) are always used, never rebuilt."]
|
|
667
|
+
: []),
|
|
668
|
+
].join("\n"),
|
|
658
669
|
)
|
|
659
670
|
}
|
|
660
671
|
|
|
@@ -726,6 +737,7 @@ function cmdComponent() {
|
|
|
726
737
|
register: c.register,
|
|
727
738
|
import: importLine(c.name),
|
|
728
739
|
summary: c.summary,
|
|
740
|
+
...(c.composition ? { composition: c.composition } : {}),
|
|
729
741
|
}
|
|
730
742
|
if (level !== "brief") {
|
|
731
743
|
data.props = c.props ?? []
|
|
@@ -744,6 +756,9 @@ function cmdComponent() {
|
|
|
744
756
|
[
|
|
745
757
|
`${c.name} [${c.status}] ${c.group} register:${c.register}`,
|
|
746
758
|
c.summary,
|
|
759
|
+
c.composition
|
|
760
|
+
? `Reference layout: use it when it fits the brief, or compose your own from ${c.composition}.`
|
|
761
|
+
: "",
|
|
747
762
|
`\n${data.import}`,
|
|
748
763
|
level === "brief" ? "" : renderProps(c),
|
|
749
764
|
level !== "full" || DENSE ? "" : renderUsage(c),
|
|
@@ -1442,7 +1457,7 @@ const MANUAL_REVIEW = [
|
|
|
1442
1457
|
{ step: "Walk the journey", how: "sable docs reviewing-a-journey — operate the primary task and its recovery path end to end; loading, empty, error and success states; keyboard and touch." },
|
|
1443
1458
|
{ step: "Review the motion", how: "sable docs reviewing-motion — observe playback and interruption for anything that moves." },
|
|
1444
1459
|
{ step: "Check reduced motion", how: "Turn on prefers-reduced-motion (or MotionProvider \"reduced\"): state still changes, nothing essential depends on animation, nothing keeps moving." },
|
|
1445
|
-
{ step: "Write the reuse inventory", how: "List the Sable components, parts, utilities and token groups used, and every app-owned UI or CSS addition with the catalog search that justified it. Start from the app-owned surfaces sable check lists (sable grade --json → inventory)
|
|
1460
|
+
{ step: "Write the reuse inventory", how: "List the Sable components, parts, utilities and token groups used, and every app-owned UI or CSS addition with the catalog search that justified it. Start from the app-owned surfaces sable check lists (sable grade --json → inventory) and note, for each, whether you used the Sable part named beside it or kept your own. Either is fine for layout and look. Behaviour (controls, overlays, keyboard handling) comes from Sable." },
|
|
1446
1461
|
{ step: "Record what you learned", how: "sable docs learning-from-builds — keep conditional lessons with evidence (sable learn --record); never record human approval you did not get." },
|
|
1447
1462
|
]
|
|
1448
1463
|
|
|
@@ -1557,8 +1572,8 @@ function cmdCheck() {
|
|
|
1557
1572
|
if (!list.length) return ""
|
|
1558
1573
|
return [
|
|
1559
1574
|
"",
|
|
1560
|
-
`App-owned surfaces for the reuse inventory
|
|
1561
|
-
...list.slice(0, MAX_DETAILS).map((s) => ` ${s.file}:${s.line} <${s.tag}> ${s.kind} (${s.classes}) → ${s.consider}`),
|
|
1575
|
+
`App-owned surfaces, for the reuse inventory — ${list.length}. Notes, not failures: use the Sable part named if it fits your design, or keep yours.`,
|
|
1576
|
+
...list.slice(0, MAX_DETAILS).map((s) => ` ${s.file}:${s.line} <${s.tag}> ${s.kind} (${s.classes}) → Sable has ${s.consider}`),
|
|
1562
1577
|
...(list.length > MAX_DETAILS ? [` … ${list.length - MAX_DETAILS} more: sable grade ${paths.join(" ")} --json → inventory.surfaces`] : []),
|
|
1563
1578
|
"",
|
|
1564
1579
|
].join("\n")
|
package/dist/blocks/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/app-shell/app-shell.tsx
|
|
2
2
|
import { cn } from "../../lib/cn.js";
|
|
3
|
-
import { buttonVariants } from "../button/button.variants.js";
|
|
4
3
|
import { AppShellDock } from "./app-shell-dock.js";
|
|
4
|
+
import { SkipLink } from "./skip-link.js";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
function AppShell({
|
|
7
7
|
banners,
|
|
@@ -26,17 +26,7 @@ function AppShell({
|
|
|
26
26
|
),
|
|
27
27
|
...rest,
|
|
28
28
|
children: [
|
|
29
|
-
/* @__PURE__ */ jsx(
|
|
30
|
-
"a",
|
|
31
|
-
{
|
|
32
|
-
href: "#main-content",
|
|
33
|
-
className: cn(
|
|
34
|
-
buttonVariants({ variant: "primary", size: "sm" }),
|
|
35
|
-
"text-label-md sr-only focus:not-sr-only focus:fixed focus:start-2 focus:top-2 focus:z-(--z-skip-link)"
|
|
36
|
-
),
|
|
37
|
-
children: "Skip to content"
|
|
38
|
-
}
|
|
39
|
-
),
|
|
29
|
+
/* @__PURE__ */ jsx(SkipLink, {}),
|
|
40
30
|
banners ? /* @__PURE__ */ jsx("div", { className: "relative z-(--z-nav)", children: banners }) : null,
|
|
41
31
|
header,
|
|
42
32
|
/* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1", children: [
|
|
@@ -115,6 +115,13 @@ var appShellMeta = {
|
|
|
115
115
|
description: "Floating overlays \u2014 a message launcher, FAB, or modal mount points."
|
|
116
116
|
}
|
|
117
117
|
],
|
|
118
|
+
subcomponents: [
|
|
119
|
+
{
|
|
120
|
+
name: "SkipLink",
|
|
121
|
+
type: 'ComponentProps<"a">',
|
|
122
|
+
description: 'AppShell\'s skip link on its own, for a layout that is not an AppShell (a storefront with a top header). Put it first in the body and give the page\'s <main> the matching id: `<SkipLink />` then `<main id="main-content" tabIndex={-1}>`. `href` defaults to #main-content, the label to "Skip to content".'
|
|
123
|
+
}
|
|
124
|
+
],
|
|
118
125
|
a11y: [
|
|
119
126
|
"Renders a skip link to #main-content as the first focusable element.",
|
|
120
127
|
'Wraps content in a <main id="main-content"> landmark.',
|
|
@@ -148,6 +155,21 @@ var appShellMeta = {
|
|
|
148
155
|
<PageHeader title="Home" description="Latest posts from artists you follow." />
|
|
149
156
|
</Region>
|
|
150
157
|
</AppShell>`
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
label: "SkipLink in a layout of your own",
|
|
161
|
+
code: `<>
|
|
162
|
+
<SkipLink />
|
|
163
|
+
<header className="flex items-center justify-between px-(--page-inset) py-4">
|
|
164
|
+
<Link to="/">Alder & Lune</Link>
|
|
165
|
+
<Button variant="ghost" size="icon" aria-label="Search"><Icon name="search" /></Button>
|
|
166
|
+
</header>
|
|
167
|
+
<main id="main-content" tabIndex={-1}>
|
|
168
|
+
<Region max="wide">
|
|
169
|
+
<PageHeader title="Lighting" />
|
|
170
|
+
</Region>
|
|
171
|
+
</main>
|
|
172
|
+
</>`
|
|
151
173
|
},
|
|
152
174
|
{
|
|
153
175
|
label: "Minimal (sidebar + content only)",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/components/app-shell/skip-link.tsx
|
|
2
|
+
import { cn } from "../../lib/cn.js";
|
|
3
|
+
import { buttonVariants } from "../button/button.variants.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
function SkipLink({
|
|
6
|
+
href = "#main-content",
|
|
7
|
+
children = "Skip to content",
|
|
8
|
+
className,
|
|
9
|
+
...props
|
|
10
|
+
}) {
|
|
11
|
+
return /* @__PURE__ */ jsx(
|
|
12
|
+
"a",
|
|
13
|
+
{
|
|
14
|
+
href,
|
|
15
|
+
className: cn(
|
|
16
|
+
buttonVariants({ variant: "primary", size: "sm" }),
|
|
17
|
+
"text-label-md sr-only focus:not-sr-only focus:fixed focus:start-2 focus:top-2 focus:z-(--z-skip-link)",
|
|
18
|
+
className
|
|
19
|
+
),
|
|
20
|
+
...props,
|
|
21
|
+
children
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
SkipLink.displayName = "SkipLink";
|
|
26
|
+
export {
|
|
27
|
+
SkipLink
|
|
28
|
+
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// src/components/button/button.tsx
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useState
|
|
6
|
+
} from "react";
|
|
3
7
|
import { useRender } from "@base-ui/react/use-render";
|
|
4
8
|
import { cn } from "../../lib/cn.js";
|
|
5
9
|
import { useTypeTier } from "../../lib/density.js";
|
|
@@ -31,19 +35,38 @@ function Button({
|
|
|
31
35
|
bleed,
|
|
32
36
|
disabled,
|
|
33
37
|
children,
|
|
38
|
+
onClick,
|
|
34
39
|
...props
|
|
35
40
|
}) {
|
|
36
41
|
const tier = useTypeTier();
|
|
37
42
|
const isNativeButton = render === void 0;
|
|
43
|
+
const [pending, setPending] = useState(false);
|
|
44
|
+
const pendingRef = useRef(false);
|
|
45
|
+
const busy = loading || pending;
|
|
46
|
+
const handleClick = (event) => {
|
|
47
|
+
if (busy || pendingRef.current || !isNativeButton && disabled) {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const result = onClick?.(event);
|
|
52
|
+
if (typeof result?.then === "function") {
|
|
53
|
+
pendingRef.current = true;
|
|
54
|
+
setPending(true);
|
|
55
|
+
result.finally(() => {
|
|
56
|
+
pendingRef.current = false;
|
|
57
|
+
setPending(false);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
38
61
|
const stacked = useContext(StackedActionsContext) && !String(size ?? "md").startsWith("icon");
|
|
39
62
|
const resolvedSize = stacked ? "cta" : size ?? "md";
|
|
40
63
|
const hasLabel = children != null && children !== false;
|
|
41
64
|
const spinner = /* @__PURE__ */ jsx(Spinner, { className: "size-(--button-icon)", thickness: 2 });
|
|
42
|
-
const start =
|
|
65
|
+
const start = busy ? spinner : iconStart;
|
|
43
66
|
const iconOnly = String(resolvedSize).startsWith("icon");
|
|
44
|
-
const inner =
|
|
67
|
+
const inner = busy && iconOnly ? spinner : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
45
68
|
start,
|
|
46
|
-
trimLabel(
|
|
69
|
+
trimLabel(busy ? loadingLabel ?? children : children),
|
|
47
70
|
iconEnd
|
|
48
71
|
] });
|
|
49
72
|
const content = badge != null ? /* @__PURE__ */ jsxs("span", { "data-slot": "button-badge-anchor", className: "relative inline-flex items-center gap-(--control-gap)", children: [
|
|
@@ -67,16 +90,20 @@ function Button({
|
|
|
67
90
|
tier.action,
|
|
68
91
|
optical,
|
|
69
92
|
resolvedBleed && resolvedBleed !== "auto" && resolvedBleed !== "none" ? BLEED_CLASS[resolvedBleed] : null,
|
|
70
|
-
// Busy is not disabled. Loading
|
|
93
|
+
// Busy is not disabled. Loading refuses presses so the action cannot
|
|
71
94
|
// double-fire, but fading it to 50% made a working button read as an
|
|
72
95
|
// unavailable one. It keeps full colour; the spinner says "working".
|
|
73
|
-
|
|
96
|
+
busy ? "cursor-progress disabled:opacity-100 aria-disabled:opacity-100" : null,
|
|
74
97
|
className
|
|
75
98
|
),
|
|
76
99
|
children: content,
|
|
100
|
+
onClick: handleClick,
|
|
77
101
|
...resolvedBleed ? { "data-bleed": resolvedBleed } : null,
|
|
78
|
-
...
|
|
79
|
-
|
|
102
|
+
...busy ? { "aria-busy": true } : null,
|
|
103
|
+
// A busy native button stays focusable: aria-disabled, with presses
|
|
104
|
+
// refused in handleClick. `disabled` would move focus to <body>.
|
|
105
|
+
...isNativeButton && disabled ? { disabled: true } : null,
|
|
106
|
+
...busy || !isNativeButton && disabled ? { "aria-disabled": true } : null
|
|
80
107
|
},
|
|
81
108
|
defaultTagName: "button"
|
|
82
109
|
});
|
|
@@ -126,7 +126,12 @@ var buttonMeta = {
|
|
|
126
126
|
name: "loading",
|
|
127
127
|
type: "boolean",
|
|
128
128
|
default: "false",
|
|
129
|
-
description: "
|
|
129
|
+
description: "Show a leading Spinner while an action is in flight and refuse presses. Sets aria-busy and aria-disabled, never the native disabled: disabling the focused button sent focus to <body>, so a sheet the action opened had nowhere to return it."
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "onClick",
|
|
133
|
+
type: "(event: MouseEvent<HTMLButtonElement>) => unknown",
|
|
134
|
+
description: "May return a promise: the button shows `loading` until it settles and ignores further presses meanwhile, from the first click on. Prefer it to a `busy` flag in state, which takes effect a render late (three fast clicks ran three times)."
|
|
130
135
|
},
|
|
131
136
|
{
|
|
132
137
|
name: "badge",
|
|
@@ -81,7 +81,7 @@ var cardMeta = {
|
|
|
81
81
|
name: "variant",
|
|
82
82
|
type: '"default" | "flat"',
|
|
83
83
|
default: '"default"',
|
|
84
|
-
description: '"default" raises with bg-card + border + soft shadow; "flat" is transparent.'
|
|
84
|
+
description: '"default" raises with bg-card + border + soft shadow; "flat" is transparent, for a card inside a section that already frames it. A flat card made a link or button shows its shape only on hover (the muted fill), so over a photo with square bottom corners it reads as a cropped image at rest: give a linked media card the default variant.'
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
name: "density",
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
DialogDescription,
|
|
21
21
|
DialogTitle
|
|
22
22
|
} from "../dialog/dialog.js";
|
|
23
|
+
import { mergeRefs } from "../../lib/refs.js";
|
|
24
|
+
import { Button } from "../button/index.js";
|
|
23
25
|
import { Scroller } from "../scroller/scroller.js";
|
|
24
26
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
25
27
|
function defaultFilter(items, query) {
|
|
@@ -55,6 +57,8 @@ function CommandMenuBody({
|
|
|
55
57
|
}) {
|
|
56
58
|
const [activeId, setActiveId] = useState(null);
|
|
57
59
|
const listRef = useRef(null);
|
|
60
|
+
const inputRef = useRef(null);
|
|
61
|
+
const mergedInputRef = useMemo(() => mergeRefs(inputRef, inputProps?.ref), [inputProps?.ref]);
|
|
58
62
|
const listboxId = useId();
|
|
59
63
|
const tier = useTypeTier();
|
|
60
64
|
const grouped = useMemo(() => {
|
|
@@ -163,6 +167,7 @@ function CommandMenuBody({
|
|
|
163
167
|
autoFocus: !inline,
|
|
164
168
|
type: "text",
|
|
165
169
|
...inputProps,
|
|
170
|
+
ref: mergedInputRef,
|
|
166
171
|
"data-command-menu-input": inline ? void 0 : "",
|
|
167
172
|
role: "combobox",
|
|
168
173
|
"aria-expanded": true,
|
|
@@ -179,7 +184,25 @@ function CommandMenuBody({
|
|
|
179
184
|
inputProps?.className
|
|
180
185
|
)
|
|
181
186
|
}
|
|
182
|
-
)
|
|
187
|
+
),
|
|
188
|
+
query ? (
|
|
189
|
+
// Empties the query and hands focus back to the field, which owns
|
|
190
|
+
// the keyboard (the list is driven from it).
|
|
191
|
+
/* @__PURE__ */ jsx(
|
|
192
|
+
Button,
|
|
193
|
+
{
|
|
194
|
+
variant: "ghost",
|
|
195
|
+
size: "icon-xs",
|
|
196
|
+
"aria-label": "Clear search",
|
|
197
|
+
className: "-my-1 text-muted-foreground hover:text-foreground",
|
|
198
|
+
onClick: () => {
|
|
199
|
+
setQuery("");
|
|
200
|
+
inputRef.current?.focus();
|
|
201
|
+
},
|
|
202
|
+
children: /* @__PURE__ */ jsx(Icon, { name: "xmark" })
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
) : null
|
|
183
206
|
] }),
|
|
184
207
|
/* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: resultCount === 0 ? "No results" : `${resultCount} result${resultCount === 1 ? "" : "s"}` }),
|
|
185
208
|
/* @__PURE__ */ jsxs(
|
|
@@ -200,6 +223,7 @@ function CommandMenuBody({
|
|
|
200
223
|
{
|
|
201
224
|
id: item.id ? `${listboxId}-${item.id}` : void 0,
|
|
202
225
|
icon: item.icon,
|
|
226
|
+
leading: item.leading,
|
|
203
227
|
detail: item.detail,
|
|
204
228
|
stacked: item.stacked,
|
|
205
229
|
hint: item.hint,
|
|
@@ -371,6 +395,7 @@ function CommandMenuGroup({
|
|
|
371
395
|
function CommandMenuItem({
|
|
372
396
|
onSelect,
|
|
373
397
|
icon,
|
|
398
|
+
leading,
|
|
374
399
|
detail,
|
|
375
400
|
stacked,
|
|
376
401
|
hint,
|
|
@@ -409,7 +434,7 @@ function CommandMenuItem({
|
|
|
409
434
|
),
|
|
410
435
|
...props,
|
|
411
436
|
children: [
|
|
412
|
-
icon && /* @__PURE__ */ jsx(Icon, { name: icon, className: "text-base text-muted-foreground" }),
|
|
437
|
+
leading != null ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "flex shrink-0 items-center", children: leading }) : icon && /* @__PURE__ */ jsx(Icon, { name: icon, className: "text-base text-muted-foreground" }),
|
|
413
438
|
stacked ? /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
414
439
|
/* @__PURE__ */ jsx("span", { className: "truncate", children }),
|
|
415
440
|
detail && /* @__PURE__ */ jsx("span", { className: "truncate text-body-sm text-muted-foreground", children: detail })
|
|
@@ -31,6 +31,10 @@ var dataTableMeta = {
|
|
|
31
31
|
guidance: "do",
|
|
32
32
|
description: "Pair server-side datasets with Pagination and pass already-sorted/paged rows."
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
guidance: "do",
|
|
36
|
+
description: "Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, grouping, or virtualised rows. DataTable stays dependency-free and does not grow into a data grid; `sable docs tables-with-tanstack` maps one onto the other."
|
|
37
|
+
},
|
|
34
38
|
{
|
|
35
39
|
guidance: "dont",
|
|
36
40
|
description: "Make every row clickable and also fill it with competing buttons. Choose a clear row interaction model."
|
|
@@ -3,6 +3,7 @@ var feedbackMeta = {
|
|
|
3
3
|
name: "Feedback",
|
|
4
4
|
summary: "A compact feedback popover. Composes Popover + Textarea + Button + Toggle into a rating row plus a message field; submitting reports the rating + message and closes the popover.",
|
|
5
5
|
status: "stable",
|
|
6
|
+
composition: "Popover, Textarea, ToggleGroup, Button",
|
|
6
7
|
keywords: [
|
|
7
8
|
"feedback",
|
|
8
9
|
"sentiment",
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
// src/components/fieldset/fieldset.tsx
|
|
2
2
|
import { Fieldset as Base } from "@base-ui/react/fieldset";
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
3
4
|
import { cn } from "../../lib/cn.js";
|
|
4
5
|
import { jsx } from "react/jsx-runtime";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
var VariantContext = createContext("card");
|
|
7
|
+
function Fieldset({ className, variant = "card", ...props }) {
|
|
8
|
+
return /* @__PURE__ */ jsx(VariantContext.Provider, { value: variant, children: /* @__PURE__ */ jsx(
|
|
7
9
|
Base.Root,
|
|
8
10
|
{
|
|
11
|
+
"data-variant": variant,
|
|
9
12
|
className: cn(
|
|
10
13
|
// min-w-0: a native <fieldset> defaults to min-inline-size: min-content
|
|
11
|
-
// and would refuse to shrink below its widest control.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// and would refuse to shrink below its widest control.
|
|
15
|
+
variant === "plain" ? "flex min-w-0 flex-col gap-2" : (
|
|
16
|
+
// overflow-hidden keeps the footer's wash inside the rounded corners.
|
|
17
|
+
// Publishes its surface like Card, so wells, stepper discs and
|
|
18
|
+
// interactive rows inside derive from the card, not the page.
|
|
19
|
+
"min-w-0 overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm [--sable-surface-bg:var(--card)]"
|
|
20
|
+
),
|
|
16
21
|
// No opacity of its own: a disabled fieldset disables every control
|
|
17
22
|
// in it, and each already dims to 50% — dimming here as well stacked
|
|
18
23
|
// to ~30%, against 50% everywhere else in the system.
|
|
@@ -20,14 +25,15 @@ function Fieldset({ className, ...props }) {
|
|
|
20
25
|
),
|
|
21
26
|
...props
|
|
22
27
|
}
|
|
23
|
-
);
|
|
28
|
+
) });
|
|
24
29
|
}
|
|
25
30
|
Fieldset.displayName = "Fieldset";
|
|
26
31
|
function FieldsetLegend({ className, ...props }) {
|
|
32
|
+
const plain = useContext(VariantContext) === "plain";
|
|
27
33
|
return /* @__PURE__ */ jsx(
|
|
28
34
|
Base.Legend,
|
|
29
35
|
{
|
|
30
|
-
className: cn("px-6 pt-6 text-title-lg text-foreground", className),
|
|
36
|
+
className: cn(plain ? "text-label-lg text-foreground" : "px-6 pt-6 text-title-lg text-foreground", className),
|
|
31
37
|
...props
|
|
32
38
|
}
|
|
33
39
|
);
|
|
@@ -37,11 +43,12 @@ function FieldsetDescription({
|
|
|
37
43
|
className,
|
|
38
44
|
...props
|
|
39
45
|
}) {
|
|
46
|
+
const plain = useContext(VariantContext) === "plain";
|
|
40
47
|
return /* @__PURE__ */ jsx(
|
|
41
48
|
"p",
|
|
42
49
|
{
|
|
43
50
|
"data-slot": "fieldset-description",
|
|
44
|
-
className: cn("px-6 text-body-sm text-muted-foreground", className),
|
|
51
|
+
className: cn(plain ? "text-body-sm text-muted-foreground" : "px-6 text-body-sm text-muted-foreground", className),
|
|
45
52
|
...props
|
|
46
53
|
}
|
|
47
54
|
);
|
|
@@ -51,19 +58,21 @@ function FieldsetContent({
|
|
|
51
58
|
className,
|
|
52
59
|
...props
|
|
53
60
|
}) {
|
|
54
|
-
|
|
61
|
+
const plain = useContext(VariantContext) === "plain";
|
|
62
|
+
return /* @__PURE__ */ jsx("div", { className: cn(plain ? "space-y-4" : "space-y-4 px-6 py-4 last:pb-6", className), ...props });
|
|
55
63
|
}
|
|
56
64
|
FieldsetContent.displayName = "FieldsetContent";
|
|
57
65
|
function FieldsetFooter({
|
|
58
66
|
className,
|
|
59
67
|
...props
|
|
60
68
|
}) {
|
|
69
|
+
const plain = useContext(VariantContext) === "plain";
|
|
61
70
|
return /* @__PURE__ */ jsx(
|
|
62
71
|
"div",
|
|
63
72
|
{
|
|
64
73
|
className: cn(
|
|
65
|
-
"flex justify-end gap-2 border-t border-border bg-muted/30 px-6 py-4",
|
|
66
|
-
"[[data-slot=fieldset-description]+&]:mt-6",
|
|
74
|
+
plain ? "flex justify-end gap-2 pt-2" : "flex justify-end gap-2 border-t border-border bg-muted/30 px-6 py-4",
|
|
75
|
+
!plain && "[[data-slot=fieldset-description]+&]:mt-6",
|
|
67
76
|
className
|
|
68
77
|
),
|
|
69
78
|
...props
|