@sableui/react 0.7.1 → 0.8.1

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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +2 -0
  4. package/bin/sable.mjs +45 -2
  5. package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
  6. package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
  7. package/dist/components/button/button.meta.js +4 -0
  8. package/dist/components/chat-composer/chat-composer.js +4 -2
  9. package/dist/components/chat-composer/chat-composer.meta.js +8 -2
  10. package/dist/components/command-menu/command-menu.js +9 -2
  11. package/dist/components/command-menu/command-menu.meta.js +3 -3
  12. package/dist/components/data-table/data-table.js +117 -64
  13. package/dist/components/data-table/data-table.meta.js +27 -1
  14. package/dist/components/form/form.js +30 -9
  15. package/dist/components/form/form.meta.js +17 -3
  16. package/dist/components/message-scroller/message-scroller.meta.js +1 -1
  17. package/dist/components/page-header/page-header.js +1 -0
  18. package/dist/components/page-header/page-header.meta.js +1 -1
  19. package/dist/components/settings-menu/settings-menu.js +10 -1
  20. package/dist/components/settings-menu/settings-menu.meta.js +1 -1
  21. package/dist/components/sidebar/sidebar.js +1 -1
  22. package/dist/components/sidebar/sidebar.meta.js +5 -0
  23. package/dist/components/switch/switch.js +1 -1
  24. package/dist/components/switch/switch.meta.js +2 -2
  25. package/dist/components/toggle/toggle.js +2 -1
  26. package/dist/file-hashes.json +603 -0
  27. package/dist/icon-sets/reicon.js +13 -1
  28. package/dist/index.d.ts +80 -13
  29. package/dist/index.js +1 -1
  30. package/dist/lib/heading-row.js +3 -2
  31. package/dist/lib/trim-label.js +17 -4
  32. package/dist/styles.css +27 -2
  33. package/llms/components/breadcrumbs.md +1 -1
  34. package/llms/components/button.md +1 -0
  35. package/llms/components/chat-composer.md +3 -2
  36. package/llms/components/command-menu.md +2 -2
  37. package/llms/components/data-table.md +33 -1
  38. package/llms/components/form.md +19 -2
  39. package/llms/components/message-scroller.md +1 -1
  40. package/llms/components/page-header.md +1 -1
  41. package/llms/components/settings-menu.md +1 -1
  42. package/llms/components/sidebar.md +1 -0
  43. package/llms/components/switch.md +2 -2
  44. package/llms/tasks/getting-started.md +5 -2
  45. package/llms/tasks/patching-a-component.md +48 -0
  46. package/llms/tasks/reviewing-a-design.md +5 -0
  47. package/llms-full.txt +126 -15
  48. package/llms.txt +1 -1
  49. package/package.json +4 -2
  50. package/registry.json +98 -20
  51. package/scripts/design-memory.mjs +7 -2
  52. package/scripts/lint-rules.mjs +32 -1
  53. package/scripts/patches.mjs +219 -0
  54. package/scripts/sources.mjs +247 -0
  55. package/skills/sable/SKILL.md +9 -4
package/AGENTS.md CHANGED
@@ -36,7 +36,7 @@ These are short on purpose. Each links to a foundations doc with the full vocabu
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
 
39
- **Never restyle a component with override classes to get a look it lacks.** `className` on a Sable component is for layout (width, margin, flex and grid placement, position); its look — fill, edge, corner, ring, shadow, ink — comes from a variant. Use the one that exists, and if none fits, that is a library gap to report, not a `className`. The common cases: a control floating over content is `<Button variant="floating">`, one on a coloured band `variant="on-color"`; a quiet chip is `<Pill appearance="outline" size="sm">`; a status label is `<Badge appearance="plain" tone="…" dot>`; a destructive menu row is `<MenuItem variant="destructive">` (never `className="text-destructive"`); a list of `Entity` rows in a card is `<CardList>`, and a table that runs edge to edge in a card is `<CardContent flush>`, which insets the outer columns to the card's padding (never `pl-6`/`pr-6` on cells). `sable-lint` flags these (`component-restyle`, `destructive-item-ink`, `table-edge-padding`, `popup-separator-bleed`).
39
+ **Never restyle a component with override classes to get a look it lacks.** `className` on a Sable component is for layout (width, margin, flex and grid placement, position); its look — fill, edge, corner, ring, shadow, ink — comes from a variant. Use the one that exists, and if none fits, that is a library gap to report, not a `className`. The common cases: a control floating over content is `<Button variant="floating">`, one on a coloured band `variant="on-color"`; a quiet chip is `<Pill appearance="outline" size="sm">`; a status label is `<Badge appearance="plain" tone="…" dot>`; a destructive menu row is `<MenuItem variant="destructive">` (never `className="text-destructive"`); a list of `Entity` rows in a card is `<CardList>`, and a table that runs edge to edge in a card is `<CardContent flush>`, which insets the outer columns to the card's padding (never `pl-6`/`pr-6` on cells). `sable-lint` flags these (`component-restyle`, `destructive-item-ink`, `table-edge-padding`, `popup-separator-bleed`). A component that is *broken*, not just missing a look, is patched in place with `sable patch <Component> --reason … --repro …` while the fix ships: the record is re-checked on every upgrade (`sable docs patching-a-component`).
40
40
  1. **Semantic tokens only.** `bg-card`, `text-muted-foreground`, `border-border`, `bg-primary` — never a palette step, never inline hex/`oklch()`. Tone fills take their paired ink (`bg-tone-success` + `text-tone-success-foreground`). → `sable docs token-intent`
41
41
  2. **Type is a closed vocabulary.** One paired utility per text node (`text-heading-2`, `text-body-md`, `text-label-lg`, `text-mono-md`); never compose `text-* font-* tracking-*` by hand. Pick by **role**, not size. One `heading-1` per route. Two colors: `foreground` and `muted-foreground` — never opacity on text. → `sable docs typography`
42
42
  3. **Space on the 4px scale, by role.** Inset, stack, inline; related things closer than unrelated things. No off-scale `p-[15px]`. Padding, gaps and insets stay on the scale (a step, or `--spacing(n)` inside an arbitrary value) so the density control moves them; fixed `rem`/`px` only for what must not scale, like a hairline. Single-column content in a card is a flex column, not a grid. → `sable docs spacing`
@@ -57,7 +57,7 @@ The machine-readable version of this section is the `consume` block in `registry
57
57
 
58
58
  1. **Install** from npm: `pnpm add @sableui/react` (or the project's package manager). No registry configuration or token is needed.
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
- 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.
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
 
62
62
  `styles.css` carries **Sable only** — the default theme, and the layout floor every other theme inherits (`--page-inset`, `--region-*`, `--rhythm-unit` on a bare `:root`). The bundled **alternates are opt-in**, because they are `[data-sable-theme]`-scoped and an app that never sets the attribute ships CSS that can never match: 5.5 KB gzip of permanently-dead weight, which is exactly how a real app's build audit found this. Using one? `@import "@sableui/react/theme/prism.css";`. Offering the whole set through a `<ThemeSelector>`? `@import "@sableui/react/themes.css";`. **Symptom of forgetting:** `data-sable-theme` is set, nothing matches, and the page silently renders as the Sable theme — no error, just the wrong theme.
63
63
  4. **Fonts:** load Figtree (sans) + DM Mono (mono) — the theme declares the stacks, the host provides the files. Apply `antialiased` (`-webkit-font-smoothing: antialiased` / `-moz-osx-font-smoothing: grayscale`) **once on the app root**: Sable's type scale was drawn against smoothed text, and without it macOS renders every weight heavier than designed. The package deliberately doesn't set it — a library has no business styling the host's whole page — so it has to happen here.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # What's new
2
2
 
3
+ ## 0.8.1
4
+
5
+ - **`sable check` shows doctor's notes.** Warnings and notes from `sable doctor`
6
+ don't fail the check, so a passing check printed none of them. They now list
7
+ under the doctor line, and when the app still scans all of Sable, the finish
8
+ steps add one: trim the CSS with `sable sources` to the components the app
9
+ imports. The skill says to act on check's notes, and the install recipe
10
+ (`registry.json` → `consume.cssSources`) and `sable docs getting-started`
11
+ mention `sable sources` beside the whole-package `@source`.
12
+
13
+ ## 0.8.0
14
+
15
+ The same support-workspace app was built twice, on Sable and on shadcn, and this
16
+ release closes every gap the comparison found: two silent layout and form bugs,
17
+ the component defects, row selection, a sanctioned way to patch a component
18
+ while its fix ships, and CSS that only carries the components an app imports.
19
+
20
+ ### Upgrading from 0.7
21
+
22
+ - **`<Form noValidate>` now means your code owns validation.** `onSubmit` always
23
+ runs and each Field shows the `error` you set; nothing checks the Fields or
24
+ the browser first. It used to skip only Sable's extra browser check, while
25
+ Form still blocked an empty `required` Field. `validationMode`, `errors`,
26
+ `onFormSubmit` and `actionsRef` do nothing with it. Drop `noValidate` to keep
27
+ Sable's validation, and word its messages with Field's new `messages`.
28
+ - **A Switch's knob stays white when on.** It took the primary's label ink, so
29
+ a mid-light brand colour gave a dark knob. It now turns dark only on a track
30
+ lighter than L 0.9 (a dark mode's near-white primary). Set
31
+ `--theme-switch-thumb-on` to choose your own.
32
+ - **PageHeader holds 20rem for its title** before actions may share its row, so
33
+ on a phone the actions sit under the title instead of squeezing it to one
34
+ word a line.
35
+
36
+ ### Everything in this release
37
+
38
+ - **Nested `Grid`, `Col` and `SplitPane` no longer inherit their parent's
39
+ breakpoints.** A breakpoint a nested one left unset read its ancestor's value,
40
+ so a `cols={2}` Grid inside a 12-column `lg` Columns drew 12 tracks, and a
41
+ nested `span={6}` Col spanned 9. Each now resets its own.
42
+ - **`Field messages`** words a failed native constraint:
43
+ `messages={{ valueMissing: "Enter a subject." }}`, instead of the browser's
44
+ "Please fill out this field."
45
+ - **Text around an expression stays one label.** `All ({count})` rendered as
46
+ "All ( 12 )" in Button, Pill, Badge, NotificationBadge, Toolbar and pill Tabs:
47
+ each text child was its own flex item, spaced by the gap. Adjacent text now
48
+ joins into one.
49
+ - **Toggle spaces an icon from its label**, like Button.
50
+ - **SidebarHeader grows to hold a second row** (a search field under the brand)
51
+ instead of clipping it at 64px. Give that row `basis-full`.
52
+ - **`BreadcrumbLink` takes `render`**, so a router link navigates in the app
53
+ instead of reloading the page.
54
+ - **DataTable checkbox selection.** `selectedKeys` and `onSelectedKeysChange`
55
+ add a checkbox column, each box named by `rowSelectionLabel`, and a
56
+ select-all box that acts on the rows shown and goes mixed when some are
57
+ checked. Checked rows take the selected fill; a box never opens the row. The
58
+ docs show a bulk-action bar with a live status.
59
+ - **`sable patch`** fixes a component in place while the library fix ships.
60
+ `sable patch Card --reason … --repro …` records why and how to check it, and
61
+ prints your package manager's patch steps (`pnpm patch`, `yarn patch`, or
62
+ patch-package). Every build now ships `dist/file-hashes.json`, and `sable
63
+ doctor` re-checks each patch on upgrade: it fails when Sable changed a
64
+ patched file, quoting the changelog for that component, until the repro says
65
+ `--keep` or `--drop`. See `sable docs patching-a-component`.
66
+ - **`sable sources`** writes `@source` lines for just the Sable modules an app
67
+ imports. The whole-package `@source` ships every component's classes (about
68
+ 32 KB gzipped); an app on Card, Button and DataTable measured 12 KB. `sable
69
+ doctor` fails when the file goes stale, since an unlisted component renders
70
+ unstyled.
71
+ - **New lint rule `form-required-error`:** a `required` Field with an `error`
72
+ inside a validating Form, where that error can never show for an empty field.
73
+ - **Icons:** `tag`, `paperclip`, `chart`, `flag` and `at-sign`.
74
+ - **`CommandMenu` stacked rows:** `stacked` puts `detail` under the label, for
75
+ results only their detail tells apart.
76
+ - **A one-setting `SettingsMenu`** opens straight to its options under the
77
+ row's label: one click, not two.
78
+ - **`ChatComposer sendOn="mod-enter"`** for long-form replies: Enter breaks the
79
+ line, Cmd/Ctrl+Enter sends.
80
+ - **Docs:** `sable learn --template` states its evidence rules (inside the
81
+ project; different before and after files); MessageScroller names its scroll
82
+ options (`{ animate }`, not `behavior`); Button explains `useCopy(text)`; and
83
+ `reviewing-a-design` says to scroll through, or capture with reduced motion,
84
+ before calling a below-the-fold chart empty.
85
+
3
86
  ## 0.7.1
4
87
 
5
88
  Fixes from building a customer-support app on 0.7.0 with an app-owned preset,
package/README.md CHANGED
@@ -72,6 +72,8 @@ export default defineConfig({ resolve: { dedupe: ["react", "react-dom"] } })
72
72
  @source "../node_modules/@sableui/react/dist/**/*.{js,mjs}";
73
73
  ```
74
74
 
75
+ That `@source` generates the classes of every Sable component, about 32 KB of CSS gzipped. Once the app is built, `npx sable sources --write src/sable-sources.css` writes `@source` lines for just the modules it imports (12 KB for an app using Card, Button and DataTable); `@import "./sable-sources.css";` in place of the line above. Re-run it after importing something new: `sable check` fails while the file is stale, since an unlisted component renders unstyled.
76
+
75
77
  **4. Fonts** — the default theme expects **Figtree** (sans) + **DM Mono** (mono). Each alternate preset declares its own families in its manifest (`registry.json → themes.available[].fonts`). Load whichever stacks you plan to use via `<link>` or `@font-face`; the theme manifests declare the stacks but ship no font files.
76
78
 
77
79
  **5. Providers + color mode / theme selection:**
package/bin/sable.mjs CHANGED
@@ -37,6 +37,8 @@ import { dirname, join, relative, resolve } from "node:path"
37
37
  import { fileURLToPath } from "node:url"
38
38
  import { skeletonize } from "../scripts/skeleton.mjs"
39
39
  import { claudeStopHook, installAgentSkill, installClaudeStopHook } from "../scripts/agent-setup.mjs"
40
+ import { patchChecks } from "../scripts/patches.mjs"
41
+ import { sourcesCheck } from "../scripts/sources.mjs"
40
42
 
41
43
  const HERE = dirname(fileURLToPath(import.meta.url))
42
44
  const PKG_ROOT = join(HERE, "..")
@@ -89,6 +91,28 @@ const COMMANDS = [
89
91
  ],
90
92
  returns: ["preset.template", "preset.check", "preset.css"],
91
93
  },
94
+ {
95
+ name: "patch",
96
+ summary: "Fix a Sable component in place while the library fix ships: record why and how to check it, patch with your package manager, and have doctor re-check it on every upgrade.",
97
+ arg: { name: "Component", required: false },
98
+ flags: [
99
+ { name: "--reason", value: "text", describe: "What is wrong. Required to record a patch." },
100
+ { name: "--repro", value: "text", describe: "How to see it, so a later upgrade can be checked against it. Required to record a patch." },
101
+ { name: "--issue", value: "id", describe: "Where it was reported upstream." },
102
+ { name: "--file", value: "dist/lib/<module>.js", describe: "Also cover a shared module the component uses, or patch one on its own." },
103
+ { name: "--list", value: null, describe: "Each recorded patch and its state against the installed Sable." },
104
+ { name: "--keep", value: null, describe: "After an upgrade: the repro still fails on Sable's own files, so keep the patch against this version." },
105
+ { name: "--drop", value: null, describe: "The repro passes on Sable's own files: drop the record (and the patch)." },
106
+ ],
107
+ returns: ["patch.recorded", "patch.list", "patch.kept", "patch.dropped"],
108
+ },
109
+ {
110
+ name: "sources",
111
+ summary: "Write the @source lines for just the Sable modules this app imports, so Tailwind generates their classes and no others (about 32 KB gzipped for all of Sable, a fraction for a typical app). doctor fails while the file is stale.",
112
+ arg: null,
113
+ flags: [{ name: "--write", value: "file.css", describe: "Write the stylesheet there (e.g. src/sable-sources.css) instead of printing it." }],
114
+ returns: ["sources"],
115
+ },
92
116
  {
93
117
  name: "setup",
94
118
  summary: "Install package-backed skill entry files for coding agents, preserving local edits.",
@@ -1265,6 +1289,9 @@ function doctorReport() {
1265
1289
  '@source "<path-to>/@sableui/react/dist/**/*.{js,mjs}";',
1266
1290
  )
1267
1291
  else if (hasSable) add("pass", "css:source", "@source covers the package.")
1292
+ // A generated per-module list (sable sources) must cover every module the
1293
+ // app imports, or those components render unstyled.
1294
+ if (hasSable) checks.push(...sourcesCheck(PKG_ROOT, CWD, expandedCss.map(({ path }) => cssByPath.get(path))))
1268
1295
  }
1269
1296
 
1270
1297
  /* The theme trap: opting into theme switching without importing theme CSS. */
@@ -1300,6 +1327,11 @@ function doctorReport() {
1300
1327
  )
1301
1328
  }
1302
1329
 
1330
+ /* Local patches (sable patch): each one is re-checked against the installed
1331
+ * Sable, and fails when Sable changed a patched file, so an upgrade tells the
1332
+ * agent to re-run the repro instead of keeping or losing a fix silently. */
1333
+ checks.push(...patchChecks(PKG_ROOT, CWD, R.version))
1334
+
1303
1335
  if (pkg && !/resolve\s*:\s*\{[^}]*dedupe|dedupe/.test(readConfigs()))
1304
1336
  add("info", "dedupe", "Could not confirm React dedupe in a bundler config.", 'resolve.dedupe: ["react","react-dom"] — a second React is an invalid-hook blank page.')
1305
1337
 
@@ -1440,6 +1472,9 @@ function cmdCheck() {
1440
1472
  status: doctorFailures.length ? "fail" : "pass",
1441
1473
  summary: doctorFailures.length ? `${doctorFailures.length} wiring failure(s): ${doctorFailures.map((c) => c.name).join(", ")}` : "wiring holds",
1442
1474
  details: doctorFailures.map((c) => `${c.name}: ${c.detail}${c.fix ? ` → ${c.fix}` : ""}`),
1475
+ // Warnings and notes don't fail the check, but they are things to act on,
1476
+ // and a passing check that printed nothing else hid them from the agent.
1477
+ notes: doctor.checks.filter((c) => c.status === "warn" || c.status === "info").map((c) => `${c.name}: ${c.detail}${c.fix ? ` → ${c.fix}` : ""}`),
1443
1478
  data: doctor,
1444
1479
  })
1445
1480
 
@@ -1497,7 +1532,12 @@ function cmdCheck() {
1497
1532
  const inventory = grade && grade.type !== "error" ? (grade.inventory ?? null) : null
1498
1533
 
1499
1534
  const failed = checks.filter((c) => c.status !== "pass").map((c) => c.name)
1500
- const data = { paths, passed: !failed.length, failed, checks, inventory, manualReview: MANUAL_REVIEW }
1535
+ // A build that still scans all of Sable ships every component's classes;
1536
+ // trimming it is a finish step, because sources are only final once the app is.
1537
+ const manual = doctor.checks.some((c) => c.name === "css:size")
1538
+ ? [...MANUAL_REVIEW, { step: "Ship only the CSS you use", how: "sable sources --write src/sable-sources.css, then @import it in place of the whole-package @source (about 32 KB of CSS gzipped down to what the app imports). Re-run it whenever you import something new from Sable; sable check fails while it is stale." }]
1539
+ : MANUAL_REVIEW
1540
+ const data = { paths, passed: !failed.length, failed, checks, inventory, manualReview: manual }
1501
1541
  const MARK = { pass: "✓", fail: "✗", error: "✗" }
1502
1542
  const MAX_DETAILS = 12
1503
1543
  const summaryText = () =>
@@ -1507,6 +1547,7 @@ function cmdCheck() {
1507
1547
  ` ${MARK[c.status]} ${c.name.padEnd(7)}${c.summary}`,
1508
1548
  ...c.details.slice(0, MAX_DETAILS).map((d) => ` ${d}`),
1509
1549
  ...(c.details.length > MAX_DETAILS ? [` … ${c.details.length - MAX_DETAILS} more: sable ${c.name}${c.name === "doctor" ? "" : ` ${paths.join(" ")}`}`] : []),
1550
+ ...(c.notes ?? []).map((n) => ` note — ${n}`),
1510
1551
  ]),
1511
1552
  "",
1512
1553
  failed.length ? `[sable check] ✗ ${failed.join(", ")} failed. Fix these and run sable check again.` : "[sable check] ✓ doctor, lint and grade pass.",
@@ -1526,7 +1567,7 @@ function cmdCheck() {
1526
1567
  [
1527
1568
  "",
1528
1569
  "Automated checks cannot judge the design. Before calling it done:",
1529
- ...MANUAL_REVIEW.map((m, i) => ` ${i + 1}. ${m.step}: ${m.how}`),
1570
+ ...manual.map((m, i) => ` ${i + 1}. ${m.step}: ${m.how}`),
1530
1571
  ].join("\n")
1531
1572
 
1532
1573
  /* Stop-hook mode (Claude Code): exit 2 blocks the agent from ending its turn
@@ -1627,6 +1668,8 @@ const HANDLERS = {
1627
1668
  ok("target", target, `iOS ${target.platformVersion}${target.status ? ` (${target.status})` : ""}: ${target.designPolicy.principle}\n${target.statusNote ? `${target.statusNote}\n` : ""}${target.designPolicy.rule}\n${(target.designPolicy.layout ?? []).map((l) => `Layout: ${l}\n`).join("")}` + Object.values(target.components).map(c => `${c.source}: ${c.strategy} → ${c.strategy === "contextual" ? "choose by role" : c.components.join(" + ") || "redesign for phone"}`).join("\n"))
1628
1669
  },
1629
1670
  preset: () => cmdDesignTool("design-presets.mjs"),
1671
+ patch: () => cmdDesignTool("patches.mjs"),
1672
+ sources: () => cmdDesignTool("sources.mjs"),
1630
1673
  setup: cmdSetup,
1631
1674
  check: cmdCheck,
1632
1675
  provider: cmdProvider,
@@ -1,4 +1,5 @@
1
1
  // src/components/breadcrumbs/breadcrumbs.tsx
2
+ import { useRender } from "@base-ui/react/use-render";
2
3
  import { Icon } from "../../icon/index.js";
3
4
  import { cn } from "../../lib/cn.js";
4
5
  import { jsx } from "react/jsx-runtime";
@@ -28,20 +29,20 @@ function BreadcrumbItem({
28
29
  BreadcrumbItem.displayName = "BreadcrumbItem";
29
30
  function BreadcrumbLink({
30
31
  className,
31
- ref,
32
+ render,
32
33
  ...props
33
34
  }) {
34
- return /* @__PURE__ */ jsx(
35
- "a",
36
- {
37
- ref,
35
+ return useRender({
36
+ render: render ?? /* @__PURE__ */ jsx("a", {}),
37
+ props: {
38
+ ...props,
38
39
  className: cn(
39
40
  "rounded-sm text-muted-foreground motion-interactive hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
40
41
  className
41
- ),
42
- ...props
43
- }
44
- );
42
+ )
43
+ },
44
+ defaultTagName: "a"
45
+ });
45
46
  }
46
47
  BreadcrumbLink.displayName = "BreadcrumbLink";
47
48
  function BreadcrumbPage({
@@ -74,8 +74,8 @@ var breadcrumbsMeta = {
74
74
  },
75
75
  {
76
76
  name: "BreadcrumbLink",
77
- type: "ComponentPropsWithRef<'a'>",
78
- description: "An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus."
77
+ type: "ComponentPropsWithRef<'a'> & { render?: useRender.RenderProp }",
78
+ description: `An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus. render composes another element with the crumb's styling, e.g. a router link: render={<Link to="/library" />}, so navigating stays in the app.`
79
79
  },
80
80
  {
81
81
  name: "BreadcrumbPage",
@@ -31,6 +31,10 @@ var buttonMeta = {
31
31
  guidance: "do",
32
32
  description: "Use a single primary button in a view or dense panel so the action hierarchy stays obvious."
33
33
  },
34
+ {
35
+ guidance: "do",
36
+ description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`."
37
+ },
34
38
  {
35
39
  guidance: "do",
36
40
  description: 'Write labels as actions, such as "Save changes" or "Delete draft", instead of vague labels like "OK".'
@@ -16,6 +16,7 @@ function ChatComposer({
16
16
  onValueChange,
17
17
  onSend,
18
18
  placeholder = "Send a message\u2026",
19
+ sendOn = "enter",
19
20
  busy = false,
20
21
  onStop,
21
22
  disabled = false,
@@ -69,11 +70,11 @@ function ChatComposer({
69
70
  (event) => {
70
71
  if (event.key !== "Enter") return;
71
72
  if (event.nativeEvent.isComposing) return;
72
- if (event.shiftKey) return;
73
+ if (sendOn === "mod-enter" ? !(event.metaKey || event.ctrlKey) : event.shiftKey) return;
73
74
  event.preventDefault();
74
75
  send();
75
76
  },
76
- [send]
77
+ [send, sendOn]
77
78
  );
78
79
  const showStop = busy && Boolean(onStop);
79
80
  return /* @__PURE__ */ jsxs(
@@ -103,6 +104,7 @@ function ChatComposer({
103
104
  autoResize: true,
104
105
  rows: 1,
105
106
  "aria-label": label,
107
+ "aria-keyshortcuts": sendOn === "mod-enter" ? "Meta+Enter Control+Enter" : "Enter",
106
108
  "aria-invalid": invalid || void 0,
107
109
  disabled,
108
110
  placeholder,
@@ -1,7 +1,7 @@
1
1
  // src/components/chat-composer/chat-composer.meta.ts
2
2
  var chatComposerMeta = {
3
3
  name: "ChatComposer",
4
- summary: "The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line, and send becomes stop while a reply streams.",
4
+ summary: 'The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn="mod-enter" swaps them for long-form replies), and send becomes stop while a reply streams.',
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "chat input",
@@ -46,7 +46,7 @@ var chatComposerMeta = {
46
46
  },
47
47
  {
48
48
  guidance: "dont",
49
- description: "Do not use it as a general multi-line input. That is `Textarea` \u2014 this one submits on Enter, which is wrong for any field inside a larger form."
49
+ description: 'Do not use it as a general multi-line input inside a larger form. That is `Textarea`. A standalone reply box that sends multi-paragraph messages (email, support replies) is this one with sendOn="mod-enter".'
50
50
  },
51
51
  {
52
52
  guidance: "dont",
@@ -118,6 +118,12 @@ var chatComposerMeta = {
118
118
  default: '"Send a message\u2026"',
119
119
  description: "Placeholder for the field."
120
120
  },
121
+ {
122
+ name: "sendOn",
123
+ type: '"enter" | "mod-enter"',
124
+ default: '"enter"',
125
+ description: 'Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer ("Cmd+Enter to send").'
126
+ },
121
127
  {
122
128
  name: "busy",
123
129
  type: "boolean",
@@ -201,6 +201,7 @@ function CommandMenuBody({
201
201
  id: item.id ? `${listboxId}-${item.id}` : void 0,
202
202
  icon: item.icon,
203
203
  detail: item.detail,
204
+ stacked: item.stacked,
204
205
  hint: item.hint,
205
206
  disabled: item.disabled,
206
207
  onSelect: () => {
@@ -371,6 +372,7 @@ function CommandMenuItem({
371
372
  onSelect,
372
373
  icon,
373
374
  detail,
375
+ stacked,
374
376
  hint,
375
377
  id: idProp,
376
378
  className,
@@ -408,8 +410,13 @@ function CommandMenuItem({
408
410
  ...props,
409
411
  children: [
410
412
  icon && /* @__PURE__ */ jsx(Icon, { name: icon, className: "text-base text-muted-foreground" }),
411
- /* @__PURE__ */ jsx("span", { className: "truncate", children }),
412
- detail && /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-body-sm text-muted-foreground", children: detail }),
413
+ stacked ? /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
414
+ /* @__PURE__ */ jsx("span", { className: "truncate", children }),
415
+ detail && /* @__PURE__ */ jsx("span", { className: "truncate text-body-sm text-muted-foreground", children: detail })
416
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
417
+ /* @__PURE__ */ jsx("span", { className: "truncate", children }),
418
+ detail && /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-body-sm text-muted-foreground", children: detail })
419
+ ] }),
413
420
  hint && /* @__PURE__ */ jsx("span", { className: "ms-auto flex min-w-0 max-w-1/2 items-center ps-4 text-body-sm text-muted-foreground", children: /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: hint }) })
414
421
  ]
415
422
  }
@@ -132,7 +132,7 @@ var commandMenuMeta = {
132
132
  {
133
133
  name: "items",
134
134
  type: "CommandItem[]",
135
- description: "Data-driven commands ({ group, label, onSelect, id?, icon?, detail?, hint?, disabled? }). Ignored when children are provided. id is optional identity: filter is handed these same objects, so it is how a caller that ranks over its own richer model maps the array back onto it \u2014 and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
135
+ description: "Data-driven commands ({ group, label, onSelect, id?, icon?, detail?, stacked?, hint?, disabled? }). stacked puts detail on its own line under the label, for results only their detail tells apart (two tickets with one subject); beside a long label an inline detail truncates first. Ignored when children are provided. id is optional identity: filter is handed these same objects, so it is how a caller that ranks over its own richer model maps the array back onto it \u2014 and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
136
136
  },
137
137
  {
138
138
  name: "query",
@@ -213,8 +213,8 @@ var commandMenuMeta = {
213
213
  },
214
214
  {
215
215
  name: "CommandMenuItem",
216
- type: "{ onSelect?: () => void; icon?: string; detail?: ReactNode; hint?: ReactNode; disabled?: boolean; id?: string; children }",
217
- description: "A selectable row. Self-filters by its text (unless the root was given a filter), runs onSelect, then closes the palette. detail is secondary text after the label; hint is the end-aligned trailing slot \u2014 a shortcut, a count, or a disabled row's reason. A disabled row stays listed, stays announced, and stays reachable by arrow key so its reason can be read, but is inert to Enter and to a click."
216
+ type: "{ onSelect?: () => void; icon?: string; detail?: ReactNode; stacked?: boolean; hint?: ReactNode; disabled?: boolean; id?: string; children }",
217
+ description: "A selectable row. Self-filters by its text (unless the root was given a filter), runs onSelect, then closes the palette. detail is secondary text after the label, or under it with stacked; hint is the end-aligned trailing slot \u2014 a shortcut, a count, or a disabled row's reason. A disabled row stays listed, stays announced, and stays reachable by arrow key so its reason can be read, but is inert to Enter and to a click."
218
218
  }
219
219
  ],
220
220
  tokensUsed: [
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
5
5
  import { Icon } from "../../icon/index.js";
6
6
  import { cn } from "../../lib/cn.js";
7
7
  import { Button } from "../button/button.js";
8
+ import { Checkbox } from "../checkbox/index.js";
8
9
  import {
9
10
  Table,
10
11
  TableBody,
@@ -28,6 +29,10 @@ function DataTable({
28
29
  rowKey,
29
30
  onRowClick,
30
31
  isRowSelected,
32
+ selectedKeys,
33
+ onSelectedKeysChange,
34
+ rowSelectionLabel = () => "Select row",
35
+ selectAllLabel = (count) => `Select all ${count} shown`,
31
36
  defaultSort,
32
37
  empty = "No results.",
33
38
  caption,
@@ -58,82 +63,130 @@ function DataTable({
58
63
  return sort.direction === "asc" ? c : -c;
59
64
  });
60
65
  }, [data, sort, columns]);
66
+ const selectable = Boolean(onSelectedKeysChange);
67
+ const dataIndex = selectable ? new Map(data.map((row, i) => [row, i])) : null;
68
+ const keyOf = (row) => {
69
+ const i = dataIndex?.get(row) ?? 0;
70
+ return rowKey ? rowKey(row, i) : i;
71
+ };
72
+ const shownKeys = selectable ? rows.map(keyOf) : [];
73
+ const checkedCount = selectable ? shownKeys.filter((k) => selectedKeys?.has(k)).length : 0;
74
+ const allChecked = shownKeys.length > 0 && checkedCount === shownKeys.length;
75
+ function setChecked(keys, checked) {
76
+ const next = new Set(selectedKeys);
77
+ for (const k of keys) {
78
+ if (checked) next.add(k);
79
+ else next.delete(k);
80
+ }
81
+ onSelectedKeysChange?.(next);
82
+ }
61
83
  return /* @__PURE__ */ jsxs(Table, { className, children: [
62
84
  caption ? /* @__PURE__ */ jsx(TableCaption, { children: caption }) : null,
63
- /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsx(TableRow, { children: columns.map((col) => {
64
- const active = sort?.key === col.key;
65
- return /* @__PURE__ */ jsx(
66
- TableHead,
85
+ /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
86
+ selectable ? /* @__PURE__ */ jsx(TableHead, { className: "w-0", children: /* @__PURE__ */ jsx(
87
+ Checkbox,
67
88
  {
68
- "aria-sort": active ? sort.direction === "asc" ? "ascending" : "descending" : void 0,
69
- className: cn(col.align && ALIGN[col.align], col.className),
70
- children: col.sortable ? (
71
- // The negative inline margin cancels Button's padding so
72
- // the label stays on the column's text edge; the header's
73
- // own type and ink win over Button's action tier. In an
74
- // end-aligned column the chevron leads, so the LABEL is
75
- // what sits on the numbers' edge.
76
- /* @__PURE__ */ jsxs(
77
- Button,
78
- {
79
- variant: "ghost",
80
- size: "xs",
81
- onClick: () => toggleSort(col.key),
82
- className: cn(
83
- "-mx-2 gap-1 text-label-md text-muted-foreground hover:text-foreground",
84
- col.align === "end" && "flex-row-reverse"
85
- ),
86
- children: [
87
- col.header,
88
- /* @__PURE__ */ jsx(
89
- Icon,
90
- {
91
- name: active && sort.direction === "desc" ? "chevron-down" : "chevron-up",
92
- className: cn("text-xs", !active && "opacity-60")
93
- }
94
- )
95
- ]
96
- }
97
- )
98
- ) : col.header
99
- },
100
- col.key
101
- );
102
- }) }) }),
89
+ "aria-label": selectAllLabel(shownKeys.length),
90
+ checked: allChecked,
91
+ indeterminate: checkedCount > 0 && !allChecked,
92
+ disabled: shownKeys.length === 0,
93
+ onCheckedChange: (checked) => setChecked(shownKeys, checked),
94
+ className: "align-middle"
95
+ }
96
+ ) }) : null,
97
+ columns.map((col) => {
98
+ const active = sort?.key === col.key;
99
+ return /* @__PURE__ */ jsx(
100
+ TableHead,
101
+ {
102
+ "aria-sort": active ? sort.direction === "asc" ? "ascending" : "descending" : void 0,
103
+ className: cn(col.align && ALIGN[col.align], col.className),
104
+ children: col.sortable ? (
105
+ // The negative inline margin cancels Button's padding so
106
+ // the label stays on the column's text edge; the header's
107
+ // own type and ink win over Button's action tier. In an
108
+ // end-aligned column the chevron leads, so the LABEL is
109
+ // what sits on the numbers' edge.
110
+ /* @__PURE__ */ jsxs(
111
+ Button,
112
+ {
113
+ variant: "ghost",
114
+ size: "xs",
115
+ onClick: () => toggleSort(col.key),
116
+ className: cn(
117
+ "-mx-2 gap-1 text-label-md text-muted-foreground hover:text-foreground",
118
+ col.align === "end" && "flex-row-reverse"
119
+ ),
120
+ children: [
121
+ col.header,
122
+ /* @__PURE__ */ jsx(
123
+ Icon,
124
+ {
125
+ name: active && sort.direction === "desc" ? "chevron-down" : "chevron-up",
126
+ className: cn("text-xs", !active && "opacity-60")
127
+ }
128
+ )
129
+ ]
130
+ }
131
+ )
132
+ ) : col.header
133
+ },
134
+ col.key
135
+ );
136
+ })
137
+ ] }) }),
103
138
  /* @__PURE__ */ jsx(TableBody, { children: rows.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
104
139
  TableCell,
105
140
  {
106
- colSpan: columns.length,
141
+ colSpan: columns.length + (selectable ? 1 : 0),
107
142
  className: cn(
108
143
  "text-center text-muted-foreground",
109
144
  typeof empty === "string" && "py-8"
110
145
  ),
111
146
  children: empty
112
147
  }
113
- ) }) : rows.map((row, i) => /* @__PURE__ */ jsx(
114
- TableRow,
115
- {
116
- "data-selected": isRowSelected?.(row) ? "" : void 0,
117
- onClick: onRowClick ? () => onRowClick(row) : void 0,
118
- tabIndex: onRowClick ? 0 : void 0,
119
- onKeyDown: onRowClick ? (e) => {
120
- if (e.target !== e.currentTarget) return;
121
- if (e.key !== "Enter" && e.key !== " ") return;
122
- e.preventDefault();
123
- onRowClick(row);
124
- } : void 0,
125
- className: onRowClick ? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" : void 0,
126
- children: columns.map((col) => /* @__PURE__ */ jsx(
127
- TableCell,
128
- {
129
- className: cn(col.align && ALIGN[col.align], col.className),
130
- children: col.cell ? col.cell(row) : row[col.key]
131
- },
132
- col.key
133
- ))
134
- },
135
- rowKey ? rowKey(row, i) : i
136
- )) })
148
+ ) }) : rows.map((row, i) => {
149
+ const key = selectable ? keyOf(row) : i;
150
+ const checked = selectable && Boolean(selectedKeys?.has(key));
151
+ return /* @__PURE__ */ jsxs(
152
+ TableRow,
153
+ {
154
+ "data-selected": checked || isRowSelected?.(row) ? "" : void 0,
155
+ onClick: onRowClick ? () => onRowClick(row) : void 0,
156
+ tabIndex: onRowClick ? 0 : void 0,
157
+ onKeyDown: onRowClick ? (e) => {
158
+ if (e.target !== e.currentTarget) return;
159
+ if (e.key !== "Enter" && e.key !== " ") return;
160
+ e.preventDefault();
161
+ onRowClick(row);
162
+ } : void 0,
163
+ className: onRowClick ? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" : void 0,
164
+ children: [
165
+ selectable ? (
166
+ // A click on the box checks it; it never opens the row.
167
+ /* @__PURE__ */ jsx(TableCell, { className: "w-0", onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
168
+ Checkbox,
169
+ {
170
+ "aria-label": rowSelectionLabel(row),
171
+ checked,
172
+ onCheckedChange: (next) => setChecked([key], next),
173
+ className: "align-middle"
174
+ }
175
+ ) })
176
+ ) : null,
177
+ columns.map((col) => /* @__PURE__ */ jsx(
178
+ TableCell,
179
+ {
180
+ className: cn(col.align && ALIGN[col.align], col.className),
181
+ children: col.cell ? col.cell(row) : row[col.key]
182
+ },
183
+ col.key
184
+ ))
185
+ ]
186
+ },
187
+ rowKey ? rowKey(row, i) : i
188
+ );
189
+ }) })
137
190
  ] });
138
191
  }
139
192
  DataTable.displayName = "DataTable";