@sableui/react 0.8.2 → 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.
Files changed (48) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +83 -0
  3. package/bin/sable.mjs +26 -11
  4. package/dist/blocks/index.d.ts +1 -1
  5. package/dist/components/app-shell/app-shell.js +2 -12
  6. package/dist/components/app-shell/app-shell.meta.js +22 -0
  7. package/dist/components/app-shell/index.js +2 -0
  8. package/dist/components/app-shell/skip-link.js +28 -0
  9. package/dist/components/button/button.js +35 -8
  10. package/dist/components/button/button.meta.js +6 -1
  11. package/dist/components/card/card.meta.js +1 -1
  12. package/dist/components/command-menu/command-menu.js +27 -2
  13. package/dist/components/date-tile/date-tile.meta.js +1 -0
  14. package/dist/components/feedback/feedback.meta.js +1 -0
  15. package/dist/components/fieldset/fieldset.js +22 -13
  16. package/dist/components/fieldset/fieldset.meta.js +25 -1
  17. package/dist/components/form/form.js +45 -9
  18. package/dist/components/form/form.meta.js +12 -3
  19. package/dist/components/media-tile/media-tile.js +16 -2
  20. package/dist/components/media-tile/media-tile.meta.js +8 -0
  21. package/dist/components/radio-card/radio-card.js +6 -2
  22. package/dist/components/radio-card/radio-card.meta.js +5 -0
  23. package/dist/components/sheet/sheet.js +1 -0
  24. package/dist/components/stepper/stepper.js +7 -2
  25. package/dist/components/stepper/stepper.meta.js +1 -1
  26. package/dist/components/toast/toast.js +2 -0
  27. package/dist/file-hashes.json +28 -27
  28. package/dist/index.d.ts +109 -22
  29. package/dist/index.js +1 -1
  30. package/dist/{meta-CcE_SeFu.d.ts → meta-DVG8ceze.d.ts} +9 -0
  31. package/dist/patterns/index.d.ts +1 -1
  32. package/dist/styles.css +30 -0
  33. package/llms/components/app-shell.md +26 -0
  34. package/llms/components/button.md +2 -1
  35. package/llms/components/card.md +1 -1
  36. package/llms/components/fieldset.md +21 -1
  37. package/llms/components/form.md +11 -2
  38. package/llms/components/media-tile.md +32 -0
  39. package/llms/components/radio-card.md +1 -0
  40. package/llms/components/stepper.md +1 -1
  41. package/llms/tasks/creating-a-preset.md +4 -1
  42. package/llms-full.txt +99 -7
  43. package/llms.txt +2 -2
  44. package/package.json +1 -1
  45. package/registry.json +89 -10
  46. package/scripts/grade.mjs +14 -3
  47. package/scripts/sources.mjs +10 -2
  48. package/skills/sable/SKILL.md +23 -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 box, a dialog — run `npx sable search "<what you need>"` and use what it returns. 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.
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
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
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
+
3
86
  ## 0.8.2
4
87
 
5
88
  - **A suggested stack for blank-slate apps.** Sable still depends on no router,
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
- const results = hits.map(({ _s, ...h }) => ({ ...h, retrieve: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}` }))
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
- results
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
- .join("\n"),
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): each composes the component named beside it, or the inventory says why the app owns it." },
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 (review, not failures) — ${list.length}:`,
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")
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { B as BlockMeta } from '../meta-CcE_SeFu.js';
2
+ import { B as BlockMeta } from '../meta-DVG8ceze.js';
3
3
 
4
4
  interface ApiKeysBlockProps {
5
5
  className?: string;
@@ -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)",
@@ -1,7 +1,9 @@
1
1
  // src/components/app-shell/index.ts
2
2
  import { AppShell } from "./app-shell.js";
3
+ import { SkipLink } from "./skip-link.js";
3
4
  import { appShellMeta } from "./app-shell.meta.js";
4
5
  export {
5
6
  AppShell,
7
+ SkipLink,
6
8
  appShellMeta
7
9
  };
@@ -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 { useContext } from "react";
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 = loading ? spinner : iconStart;
65
+ const start = busy ? spinner : iconStart;
43
66
  const iconOnly = String(resolvedSize).startsWith("icon");
44
- const inner = loading && iconOnly ? spinner : /* @__PURE__ */ jsxs(Fragment, { children: [
67
+ const inner = busy && iconOnly ? spinner : /* @__PURE__ */ jsxs(Fragment, { children: [
45
68
  start,
46
- trimLabel(loading ? loadingLabel ?? children : children),
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 sets `disabled` so the action cannot
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
- loading ? "cursor-progress disabled:opacity-100 aria-disabled:opacity-100" : null,
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
- ...loading ? { "aria-busy": true } : null,
79
- ...isNativeButton ? { disabled: disabled || loading } : loading || disabled ? { "aria-disabled": true } : null
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: "Disable the button and show a leading Spinner while an action is in flight. Sets aria-busy; a native button is disabled, a render element (e.g. an <a>) gets aria-disabled."
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 })
@@ -32,6 +32,7 @@ var dateTileMeta = {
32
32
  components: ["Entity", "Card", "RelativeTime", "Calendar"]
33
33
  },
34
34
  status: "stable",
35
+ composition: "a <time> element with the type tiers, on a surface utility",
35
36
  props: [
36
37
  {
37
38
  name: "date",
@@ -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
- function Fieldset({ className, ...props }) {
6
- return /* @__PURE__ */ jsx(
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. overflow-hidden
12
- // keeps the footer's wash inside the rounded corners.
13
- // Publishes its surface like Card, so wells, stepper discs and
14
- // interactive rows inside derive from the card, not the page.
15
- "min-w-0 overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm [--sable-surface-bg:var(--card)]",
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
- return /* @__PURE__ */ jsx("div", { className: cn("space-y-4 px-6 py-4 last:pb-6", className), ...props });
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
@@ -1,7 +1,7 @@
1
1
  // src/components/fieldset/fieldset.meta.ts
2
2
  var fieldsetMeta = {
3
3
  name: "Fieldset",
4
- summary: "Form section panel (Card-like bordered surface) over Base UI Fieldset, with Legend/Description/Content/Footer parts.",
4
+ summary: 'Form section panel (Card-like bordered surface) over Base UI Fieldset, with Legend/Description/Content/Footer parts; `variant="plain"` is the bare group, for a radio group\'s shared name.',
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "fieldset",
@@ -24,6 +24,10 @@ var fieldsetMeta = {
24
24
  guidance: "dont",
25
25
  description: "Do not use Fieldset as a generic spacing wrapper outside forms."
26
26
  },
27
+ {
28
+ guidance: "do",
29
+ description: 'Use `variant="plain"` to name a radio group or a run of checkboxes inside a form or card that is already framed: the legend reads as a field label and nothing draws a second box. Do not strip the card variant with classes instead.'
30
+ },
27
31
  {
28
32
  guidance: "dont",
29
33
  description: "Do not use a Legend that is really a heading. A legend names the group's question for assistive technology; using it as a section title puts a question mark where the outline should be."
@@ -64,6 +68,12 @@ var fieldsetMeta = {
64
68
  ]
65
69
  },
66
70
  props: [
71
+ {
72
+ name: "variant",
73
+ type: '"card" | "plain"',
74
+ default: '"card"',
75
+ description: "`card` is a bordered, raised section with padded parts. `plain` is the grouping alone (no border, surface or inset) with the legend in the field-label style: the shared name a radio group needs, inside a form that is already framed."
76
+ },
67
77
  {
68
78
  name: "disabled",
69
79
  type: "boolean",
@@ -152,6 +162,20 @@ var fieldsetMeta = {
152
162
  // \u2717 Not for visual grouping of unrelated fields. Use PanelSection on a panel or
153
163
  // a Card section on a page; a legend that is really a heading lies to
154
164
  // assistive technology about the form's structure.`
165
+ },
166
+ {
167
+ label: "A radio group's name, without a second box",
168
+ code: `<Card>
169
+ <CardContent>
170
+ <Fieldset variant="plain">
171
+ <FieldsetLegend>Delivery</FieldsetLegend>
172
+ <RadioGroup defaultValue="standard">
173
+ <RadioCard value="standard" title="Standard" description="3\u20135 working days" trailing="Free" />
174
+ <RadioCard value="express" title="Express" description="Next working day" trailing="$12.00" />
175
+ </RadioGroup>
176
+ </Fieldset>
177
+ </CardContent>
178
+ </Card>`
155
179
  }
156
180
  ]
157
181
  };