@sableui/react 0.7.1 → 0.8.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 (54) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +73 -0
  3. package/README.md +2 -0
  4. package/bin/sable.mjs +34 -0
  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/patching-a-component.md +48 -0
  45. package/llms/tasks/reviewing-a-design.md +5 -0
  46. package/llms-full.txt +121 -13
  47. package/llms.txt +1 -1
  48. package/package.json +4 -2
  49. package/registry.json +97 -20
  50. package/scripts/design-memory.mjs +7 -2
  51. package/scripts/lint-rules.mjs +32 -1
  52. package/scripts/patches.mjs +219 -0
  53. package/scripts/sources.mjs +247 -0
  54. package/skills/sable/SKILL.md +5 -2
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,78 @@
1
1
  # What's new
2
2
 
3
+ ## 0.8.0
4
+
5
+ The same support-workspace app was built twice, on Sable and on shadcn, and this
6
+ release closes every gap the comparison found: two silent layout and form bugs,
7
+ the component defects, row selection, a sanctioned way to patch a component
8
+ while its fix ships, and CSS that only carries the components an app imports.
9
+
10
+ ### Upgrading from 0.7
11
+
12
+ - **`<Form noValidate>` now means your code owns validation.** `onSubmit` always
13
+ runs and each Field shows the `error` you set; nothing checks the Fields or
14
+ the browser first. It used to skip only Sable's extra browser check, while
15
+ Form still blocked an empty `required` Field. `validationMode`, `errors`,
16
+ `onFormSubmit` and `actionsRef` do nothing with it. Drop `noValidate` to keep
17
+ Sable's validation, and word its messages with Field's new `messages`.
18
+ - **A Switch's knob stays white when on.** It took the primary's label ink, so
19
+ a mid-light brand colour gave a dark knob. It now turns dark only on a track
20
+ lighter than L 0.9 (a dark mode's near-white primary). Set
21
+ `--theme-switch-thumb-on` to choose your own.
22
+ - **PageHeader holds 20rem for its title** before actions may share its row, so
23
+ on a phone the actions sit under the title instead of squeezing it to one
24
+ word a line.
25
+
26
+ ### Everything in this release
27
+
28
+ - **Nested `Grid`, `Col` and `SplitPane` no longer inherit their parent's
29
+ breakpoints.** A breakpoint a nested one left unset read its ancestor's value,
30
+ so a `cols={2}` Grid inside a 12-column `lg` Columns drew 12 tracks, and a
31
+ nested `span={6}` Col spanned 9. Each now resets its own.
32
+ - **`Field messages`** words a failed native constraint:
33
+ `messages={{ valueMissing: "Enter a subject." }}`, instead of the browser's
34
+ "Please fill out this field."
35
+ - **Text around an expression stays one label.** `All ({count})` rendered as
36
+ "All ( 12 )" in Button, Pill, Badge, NotificationBadge, Toolbar and pill Tabs:
37
+ each text child was its own flex item, spaced by the gap. Adjacent text now
38
+ joins into one.
39
+ - **Toggle spaces an icon from its label**, like Button.
40
+ - **SidebarHeader grows to hold a second row** (a search field under the brand)
41
+ instead of clipping it at 64px. Give that row `basis-full`.
42
+ - **`BreadcrumbLink` takes `render`**, so a router link navigates in the app
43
+ instead of reloading the page.
44
+ - **DataTable checkbox selection.** `selectedKeys` and `onSelectedKeysChange`
45
+ add a checkbox column, each box named by `rowSelectionLabel`, and a
46
+ select-all box that acts on the rows shown and goes mixed when some are
47
+ checked. Checked rows take the selected fill; a box never opens the row. The
48
+ docs show a bulk-action bar with a live status.
49
+ - **`sable patch`** fixes a component in place while the library fix ships.
50
+ `sable patch Card --reason … --repro …` records why and how to check it, and
51
+ prints your package manager's patch steps (`pnpm patch`, `yarn patch`, or
52
+ patch-package). Every build now ships `dist/file-hashes.json`, and `sable
53
+ doctor` re-checks each patch on upgrade: it fails when Sable changed a
54
+ patched file, quoting the changelog for that component, until the repro says
55
+ `--keep` or `--drop`. See `sable docs patching-a-component`.
56
+ - **`sable sources`** writes `@source` lines for just the Sable modules an app
57
+ imports. The whole-package `@source` ships every component's classes (about
58
+ 32 KB gzipped); an app on Card, Button and DataTable measured 12 KB. `sable
59
+ doctor` fails when the file goes stale, since an unlisted component renders
60
+ unstyled.
61
+ - **New lint rule `form-required-error`:** a `required` Field with an `error`
62
+ inside a validating Form, where that error can never show for an empty field.
63
+ - **Icons:** `tag`, `paperclip`, `chart`, `flag` and `at-sign`.
64
+ - **`CommandMenu` stacked rows:** `stacked` puts `detail` under the label, for
65
+ results only their detail tells apart.
66
+ - **A one-setting `SettingsMenu`** opens straight to its options under the
67
+ row's label: one click, not two.
68
+ - **`ChatComposer sendOn="mod-enter"`** for long-form replies: Enter breaks the
69
+ line, Cmd/Ctrl+Enter sends.
70
+ - **Docs:** `sable learn --template` states its evidence rules (inside the
71
+ project; different before and after files); MessageScroller names its scroll
72
+ options (`{ animate }`, not `behavior`); Button explains `useCopy(text)`; and
73
+ `reviewing-a-design` says to scroll through, or capture with reduced motion,
74
+ before calling a below-the-fold chart empty.
75
+
3
76
  ## 0.7.1
4
77
 
5
78
  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
 
@@ -1627,6 +1659,8 @@ const HANDLERS = {
1627
1659
  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
1660
  },
1629
1661
  preset: () => cmdDesignTool("design-presets.mjs"),
1662
+ patch: () => cmdDesignTool("patches.mjs"),
1663
+ sources: () => cmdDesignTool("sources.mjs"),
1630
1664
  setup: cmdSetup,
1631
1665
  check: cmdCheck,
1632
1666
  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";
@@ -93,7 +93,29 @@ var dataTableMeta = {
93
93
  {
94
94
  name: "isRowSelected",
95
95
  type: "(row) => boolean",
96
- description: "Mark a row visually selected (data-selected). Derive it from a stable id (row.id === selectedId), not the row index, so selection survives re-sorts."
96
+ description: "Mark a row visually selected (data-selected), e.g. the one open in a detail pane. Derive it from a stable id (row.id === selectedId), not the row index, so selection survives re-sorts. For checking several rows, use selectedKeys."
97
+ },
98
+ {
99
+ name: "selectedKeys",
100
+ type: "ReadonlySet<string | number>",
101
+ description: "The checked rows, by rowKey, for bulk actions. With onSelectedKeysChange it adds a checkbox column (each box named by rowSelectionLabel) and a select-all box in the header that acts on the rows shown and goes mixed when some are checked. Checked rows get the selected fill. Controlled: when the rows shown change (a filter, another view), set the keys to keep, usually none. Needs rowKey."
102
+ },
103
+ {
104
+ name: "onSelectedKeysChange",
105
+ type: "(keys: Set<string | number>) => void",
106
+ description: "Called with the new set of checked keys. Its presence turns selection on. A click on a box never fires onRowClick."
107
+ },
108
+ {
109
+ name: "rowSelectionLabel",
110
+ type: "(row) => string",
111
+ default: '() => "Select row"',
112
+ description: "A row checkbox's accessible name. Name the row, e.g. (t) => `Select ${t.subject}`, so a screen reader can tell the boxes apart."
113
+ },
114
+ {
115
+ name: "selectAllLabel",
116
+ type: "(count: number) => string",
117
+ default: "(count) => `Select all ${count} shown`",
118
+ description: `The select-all checkbox's accessible name; say what it acts on ("Select all 7 tickets shown").`
97
119
  },
98
120
  {
99
121
  name: "defaultSort",
@@ -119,6 +141,10 @@ var dataTableMeta = {
119
141
  "Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
120
142
  ],
121
143
  examples: [
144
+ {
145
+ label: "Checkbox selection with a bulk-action bar",
146
+ code: 'const [selected, setSelected] = useState<Set<string | number>>(new Set())\n\n<Stack gap={2}>\n {/* Mounted from the start, so a screen reader hears each change. */}\n <span role="status" className="sr-only">{selected.size ? `${selected.size} of ${tickets.length} selected` : ""}</span>\n {selected.size ? (\n <Toolbar aria-label="Bulk actions">\n <span className="text-label-md">{selected.size} of {tickets.length} selected</span>\n <Button size="sm" variant="secondary" onClick={() => resolve(selected)}>Resolve</Button>\n </Toolbar>\n ) : null}\n <DataTable\n data={tickets}\n rowKey={(t) => t.id}\n columns={columns}\n selectedKeys={selected}\n onSelectedKeysChange={setSelected}\n rowSelectionLabel={(t) => `Select ${t.subject}`}\n selectAllLabel={(n) => `Select all ${n} tickets shown`}\n />\n</Stack>'
147
+ },
122
148
  {
123
149
  label: "Sortable columns",
124
150
  code: '<DataTable data={users} columns={[{ key: "name", header: "Name", sortable: true }, { key: "role", header: "Role" }, { key: "joined", header: "Joined", sortable: true, sortValue: (u) => u.joinedAt, cell: (u) => formatDate(u.joinedAt) }]} />'