@sableui/react 0.7.0 → 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 (77) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +115 -0
  3. package/README.md +2 -0
  4. package/bin/sable.mjs +34 -0
  5. package/dist/blocks/traffic-sources-block.js +1 -1
  6. package/dist/blocks/traffic-trend-block.js +3 -3
  7. package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
  8. package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
  9. package/dist/components/button/button.meta.js +4 -0
  10. package/dist/components/card/card.js +7 -4
  11. package/dist/components/chat-composer/chat-composer.js +4 -2
  12. package/dist/components/chat-composer/chat-composer.meta.js +8 -2
  13. package/dist/components/command-menu/command-menu.js +9 -2
  14. package/dist/components/command-menu/command-menu.meta.js +3 -3
  15. package/dist/components/data-table/data-table.js +117 -64
  16. package/dist/components/data-table/data-table.meta.js +27 -1
  17. package/dist/components/form/form.js +30 -9
  18. package/dist/components/form/form.meta.js +17 -3
  19. package/dist/components/message-scroller/message-scroller.meta.js +1 -1
  20. package/dist/components/page-header/page-header.js +1 -0
  21. package/dist/components/page-header/page-header.meta.js +1 -1
  22. package/dist/components/pie-chart/pie-chart.js +3 -3
  23. package/dist/components/pie-chart/pie-chart.meta.js +3 -3
  24. package/dist/components/radial-chart/radial-chart.js +3 -3
  25. package/dist/components/radial-chart/radial-chart.meta.js +2 -2
  26. package/dist/components/settings-menu/settings-menu.js +10 -1
  27. package/dist/components/settings-menu/settings-menu.meta.js +1 -1
  28. package/dist/components/side-nav/side-nav.variants.js +3 -2
  29. package/dist/components/sidebar/sidebar.js +1 -1
  30. package/dist/components/sidebar/sidebar.meta.js +5 -0
  31. package/dist/components/switch/switch.js +1 -1
  32. package/dist/components/switch/switch.meta.js +2 -2
  33. package/dist/components/table/table.js +1 -1
  34. package/dist/components/toggle/toggle.js +2 -1
  35. package/dist/file-hashes.json +603 -0
  36. package/dist/icon-sets/reicon.js +13 -1
  37. package/dist/index.d.ts +88 -15
  38. package/dist/index.js +1 -1
  39. package/dist/layout/index.d.ts +5 -2
  40. package/dist/lib/chart/format.js +5 -0
  41. package/dist/lib/component-guidance.js +7 -2
  42. package/dist/lib/heading-row.js +3 -2
  43. package/dist/lib/trim-label.js +17 -4
  44. package/dist/presets/index.d.ts +7 -1
  45. package/dist/presets/index.js +10 -0
  46. package/dist/styles.css +27 -2
  47. package/llms/blocks/traffic-sources-block.md +1 -1
  48. package/llms/blocks/traffic-trend-block.md +5 -3
  49. package/llms/components/breadcrumbs.md +1 -1
  50. package/llms/components/button.md +1 -0
  51. package/llms/components/chat-composer.md +3 -2
  52. package/llms/components/command-menu.md +2 -2
  53. package/llms/components/data-table.md +33 -1
  54. package/llms/components/form.md +19 -2
  55. package/llms/components/message-scroller.md +1 -1
  56. package/llms/components/page-header.md +1 -1
  57. package/llms/components/pie-chart.md +2 -2
  58. package/llms/components/radial-chart.md +1 -1
  59. package/llms/components/settings-menu.md +1 -1
  60. package/llms/components/sidebar.md +1 -0
  61. package/llms/components/switch.md +2 -2
  62. package/llms/layout/region.md +16 -3
  63. package/llms/tasks/building-a-page.md +34 -3
  64. package/llms/tasks/creating-a-preset.md +4 -1
  65. package/llms/tasks/patching-a-component.md +48 -0
  66. package/llms/tasks/reviewing-a-design.md +9 -0
  67. package/llms/tasks/using-presets.md +19 -0
  68. package/llms-full.txt +207 -27
  69. package/llms.txt +1 -1
  70. package/package.json +4 -2
  71. package/registry.json +132 -30
  72. package/scripts/design-memory.mjs +7 -2
  73. package/scripts/design-presets.mjs +15 -4
  74. package/scripts/lint-rules.mjs +32 -1
  75. package/scripts/patches.mjs +219 -0
  76. package/scripts/sources.mjs +247 -0
  77. package/skills/sable/SKILL.md +16 -4
@@ -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) }]} />'
@@ -17,13 +17,16 @@ function Form({
17
17
  onSubmit,
18
18
  onFormSubmit,
19
19
  noValidate,
20
+ validationMode,
21
+ errors,
22
+ actionsRef,
20
23
  ...props
21
24
  }) {
22
25
  const blockedRef = useRef(false);
23
26
  const handleSubmit = (event) => {
24
27
  blockedRef.current = false;
25
28
  const form = event.currentTarget;
26
- if (!noValidate && !form.checkValidity()) {
29
+ if (!form.checkValidity()) {
27
30
  event.preventDefault();
28
31
  blockedRef.current = true;
29
32
  form.reportValidity();
@@ -31,10 +34,24 @@ function Form({
31
34
  }
32
35
  onSubmit?.(event);
33
36
  };
37
+ if (noValidate) {
38
+ return /* @__PURE__ */ jsx(
39
+ "form",
40
+ {
41
+ noValidate: true,
42
+ className: cn("flex flex-col gap-6", className),
43
+ onSubmit,
44
+ ...props
45
+ }
46
+ );
47
+ }
34
48
  return /* @__PURE__ */ jsx(
35
49
  BaseForm,
36
50
  {
37
51
  className: cn("flex flex-col gap-6", className),
52
+ validationMode,
53
+ errors,
54
+ actionsRef,
38
55
  onSubmit: handleSubmit,
39
56
  onFormSubmit: onFormSubmit ? (values, details) => {
40
57
  if (!blockedRef.current) onFormSubmit(values, details);
@@ -49,6 +66,7 @@ function Field({
49
66
  description,
50
67
  error,
51
68
  required,
69
+ messages,
52
70
  disabled,
53
71
  name,
54
72
  validate,
@@ -98,14 +116,17 @@ function Field({
98
116
  children: description
99
117
  }
100
118
  ) : null;
101
- const errorNode = /* @__PURE__ */ jsx(
102
- BaseField.Error,
103
- {
104
- id: errId,
105
- className: "text-body-sm text-destructive",
106
- ...hasError ? { match: true, children: error } : null
107
- }
108
- );
119
+ const errorNode = /* @__PURE__ */ jsx(BaseField.Validity, { children: ({ validity }) => {
120
+ const flag = messages ? Object.keys(messages).find((key) => validity[key]) : void 0;
121
+ return /* @__PURE__ */ jsx(
122
+ BaseField.Error,
123
+ {
124
+ id: errId,
125
+ className: "text-body-sm text-destructive",
126
+ ...hasError ? { match: true, children: error } : flag ? { children: messages?.[flag] } : null
127
+ }
128
+ );
129
+ } });
109
130
  return /* @__PURE__ */ jsx(
110
131
  BaseField.Root,
111
132
  {
@@ -72,7 +72,7 @@ var formMeta = {
72
72
  {
73
73
  name: "Form",
74
74
  type: '{ validationMode?: "onSubmit" | "onBlur" | "onChange"; errors?: Record<string, string | string[]>; onFormSubmit?: (values, details) => void; actionsRef?: RefObject<Form.Actions> } + ComponentPropsWithRef<"form">',
75
- description: 'A <form> laid out as a vertical column with comfortable field spacing (override with className, e.g. gap-4). On submit it validates every Field (native constraints plus Field validate), shows each message under its field, focuses the first invalid control, and only then calls onSubmit. validationMode defaults to "onSubmit" (then live re-validation on change). errors shows external (e.g. server) messages keyed by Field name and clears one when its field is edited. onFormSubmit receives the named Field values and prevents the native submit. A control outside any Field is still checked by the browser before onSubmit runs; pass noValidate to skip that.'
75
+ description: 'A <form> laid out as a vertical column with comfortable field spacing (override with className, e.g. gap-4). On submit it validates every Field (native constraints plus Field validate), shows each message under its field, focuses the first invalid control, and only then calls onSubmit. validationMode defaults to "onSubmit" (then live re-validation on change). errors shows external (e.g. server) messages keyed by Field name and clears one when its field is edited. onFormSubmit receives the named Field values and prevents the native submit. A control outside any Field is still checked by the browser before onSubmit runs. Validating in your own code (react-hook-form, a schema, state)? Pass noValidate: onSubmit always runs and each Field shows the error you set; without it an empty required Field stops the submit before your handler can set that error.'
76
76
  },
77
77
  {
78
78
  name: "validationMode",
@@ -90,6 +90,12 @@ var formMeta = {
90
90
  type: "(values: Record<string, unknown>, details) => void",
91
91
  description: "Called with the named Field values after validation passes; prevents the native submit."
92
92
  },
93
+ {
94
+ name: "noValidate",
95
+ type: "boolean",
96
+ default: "false",
97
+ description: "Your code owns validation: nothing is checked before onSubmit (neither the Fields nor the browser), so it always runs and each Field shows the error you pass. required Fields keep their asterisk and aria-required. validationMode, errors, onFormSubmit and actionsRef do nothing with it."
98
+ },
93
99
  {
94
100
  name: "actionsRef",
95
101
  type: "RefObject<{ validate(name?: string): void }>",
@@ -99,8 +105,8 @@ var formMeta = {
99
105
  subcomponents: [
100
106
  {
101
107
  name: "Field",
102
- type: '{ label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; disabled?: boolean; name?: string; validate?: (value, formValues) => string | string[] | null | Promise<\u2026>; validationMode?: "onSubmit" | "onBlur" | "onChange"; validationDebounceTime?: number; orientation?: "vertical" | "horizontal"; className?: string; children: ReactElement }',
103
- description: `Wraps a single control element (e.g. <Input />) on Base UI Field and wires label (htmlFor + aria-labelledby), helper text and error (aria-describedby), aria-invalid, and required onto it automatically. Base UI-backed controls (Input, Textarea, Select, Combobox, NumberField, Checkbox, Switch, Slider, OtpField\u2026) register through context; the child is also cloned so plain elements keep the same wiring. error renders a destructive message and takes precedence; without it the slot shows Base UI's validation message when the field fails (native constraint, validate, or Form errors). required adds the native attribute plus a visual asterisk (defaults to false). disabled disables the control and dims the label. name keys the field in Form errors / onFormSubmit. validationMode overrides the Form's. orientation defaults to "vertical" (label \u2192 control); "horizontal" places control then label inline for a single checkbox / switch / radio \u2014 the control centres on the label's first line (it stays there when the label wraps) and description/error sit in the label's column. The root, label, description, error and control carry data-invalid / data-valid, data-touched, data-dirty, data-filled, data-focused and data-disabled for state styling.`
108
+ type: '{ label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; messages?: Partial<Record<"valueMissing" | "typeMismatch" | "patternMismatch" | "tooShort" | "tooLong" | "rangeUnderflow" | "rangeOverflow" | "stepMismatch" | "badInput", ReactNode>>; disabled?: boolean; name?: string; validate?: (value, formValues) => string | string[] | null | Promise<\u2026>; validationMode?: "onSubmit" | "onBlur" | "onChange"; validationDebounceTime?: number; orientation?: "vertical" | "horizontal"; className?: string; children: ReactElement }',
109
+ description: `Wraps a single control element (e.g. <Input />) on Base UI Field and wires label (htmlFor + aria-labelledby), helper text and error (aria-describedby), aria-invalid, and required onto it automatically. Base UI-backed controls (Input, Textarea, Select, Combobox, NumberField, Checkbox, Switch, Slider, OtpField\u2026) register through context; the child is also cloned so plain elements keep the same wiring. error renders a destructive message and takes precedence; without it the slot shows the field's messages entry for the native constraint that failed (messages={{ valueMissing: "Enter a subject." }}), else Base UI's validation message (the browser's wording for a native constraint, validate, or Form errors). required adds the native attribute plus a visual asterisk (defaults to false). disabled disables the control and dims the label. name keys the field in Form errors / onFormSubmit. validationMode overrides the Form's. orientation defaults to "vertical" (label \u2192 control); "horizontal" places control then label inline for a single checkbox / switch / radio \u2014 the control centres on the label's first line (it stays there when the label wraps) and description/error sit in the label's column. The root, label, description, error and control carry data-invalid / data-valid, data-touched, data-dirty, data-filled, data-focused and data-disabled for state styling.`
104
110
  }
105
111
  ],
106
112
  tokensUsed: ["muted-foreground", "destructive"],
@@ -119,6 +125,14 @@ var formMeta = {
119
125
  label: "Field with an error",
120
126
  code: '<Field label="Password" error="Must be at least 8 characters."><Input type="password" /></Field>'
121
127
  },
128
+ {
129
+ label: "Your own wording for a missing value",
130
+ code: '<Form onSubmit={handleSubmit}><Field label="Subject" required messages={{ valueMissing: "Enter a subject." }}><Input name="subject" /></Field><Button type="submit">Send</Button></Form>'
131
+ },
132
+ {
133
+ label: "Validation in your own code",
134
+ code: '<Form noValidate onSubmit={handleSubmit}><Field label="Subject" required error={errors.subject}><Input name="subject" /></Field><Button type="submit">Send</Button></Form>'
135
+ },
122
136
  {
123
137
  label: "Validated on submit",
124
138
  code: '<Form onSubmit={handleSubmit}><Field label="Handle" required validate={(value) => String(value).length < 3 ? "At least 3 characters." : null}><Input name="handle" /></Field><Button type="submit">Claim</Button></Form>'
@@ -86,7 +86,7 @@ var messageScrollerMeta = {
86
86
  {
87
87
  name: "ref",
88
88
  type: "Ref<MessageScrollerHandle>",
89
- description: "Imperative commands, not the DOM node: `scrollToBottom(opts)`, `scrollToTop(opts)`, `scrollToMessage(id, opts)` (returns false when the message is not rendered), `isFollowing()`, and `element` for the scrolling node itself."
89
+ description: "Imperative commands, not the DOM node: `scrollToBottom(opts)`, `scrollToTop(opts)`, `scrollToMessage(id, opts)` (returns false when the message is not rendered), `isFollowing()`, and `element` for the scrolling node itself. `opts` is `ScrollCommandOptions`: `{ animate?: boolean }` (default true, and off whenever motion is), not the DOM's `behavior`."
90
90
  },
91
91
  {
92
92
  name: "autoFollow",
@@ -20,6 +20,7 @@ function PageHeader({
20
20
  title,
21
21
  titleClassName: "text-heading-2",
22
22
  band: "flex min-h-(--control-md) items-center",
23
+ titleBasis: "basis-[min(100%,20rem)]",
23
24
  leading: back,
24
25
  actions
25
26
  }
@@ -57,7 +57,7 @@ var pageHeaderMeta = {
57
57
  {
58
58
  name: "actions",
59
59
  type: "ReactNode",
60
- description: "Trailing actions aligned to the title row's end. They drop under the title when the row runs out of room, and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only."
60
+ description: "Trailing actions aligned to the title row's end. They drop under the title when the title would have less than 20rem beside them (on a phone, nearly always), and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only."
61
61
  },
62
62
  {
63
63
  name: "titleAs",
@@ -5,7 +5,7 @@ import { useId } from "react";
5
5
  import { ChartFrame } from "../../lib/chart/chart-frame.js";
6
6
  import { ChartLegend } from "../../lib/chart/chart-legend.js";
7
7
  import { ChartTooltipContent } from "../../lib/chart/chart-tooltip.js";
8
- import { categoryText, chartFormatters } from "../../lib/chart/format.js";
8
+ import { categoryText, centerValueText, chartFormatters } from "../../lib/chart/format.js";
9
9
  import { arcPath, polar } from "../../lib/chart/geometry.js";
10
10
  import { DRAW_DURATION, morphTransition, pathStyle } from "../../lib/chart/motion.js";
11
11
  import {
@@ -177,8 +177,8 @@ function PieChart({
177
177
  );
178
178
  }) : null,
179
179
  centerLabel && g.inner > 0 ? /* @__PURE__ */ jsxs("text", { x: g.cx, y: g.cy, textAnchor: "middle", className: "[[data-reveal=out]_&]:opacity-0", style: { transition: morphTransition() }, children: [
180
- /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: centerLabel.label ? "-0.1em" : "0.32em", className: "fill-foreground text-heading-2 tabular-nums", children: centerLabel.value }),
181
- centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.6em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
180
+ /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "0.35em", className: "fill-foreground text-heading-2 tabular-nums", children: centerValueText(centerLabel.value, locale) }),
181
+ centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.9em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
182
182
  ] }) : null
183
183
  ] });
184
184
  }
@@ -83,8 +83,8 @@ var pieChartMeta = {
83
83
  },
84
84
  {
85
85
  name: "centerLabel",
86
- type: "{ value: string; label?: string }",
87
- description: "A value and caption in the hole of a donut."
86
+ type: "{ value: string | number; label?: string }",
87
+ description: "A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units."
88
88
  },
89
89
  {
90
90
  name: "separator",
@@ -197,7 +197,7 @@ var pieChartMeta = {
197
197
  examples: [
198
198
  {
199
199
  label: "Donut with a total",
200
- code: 'const sources = [\n { source: "Search", visitors: 275 },\n { source: "Direct", visitors: 200 },\n { source: "Social", visitors: 187 },\n { source: "Email", visitors: 90 },\n]\n\n<PieChart\n label="Visitors by source"\n data={sources}\n index="source"\n valueKey="visitors"\n donut\n centerLabel={{ value: "752", label: "Visitors" }}\n/>'
200
+ code: 'const sources = [\n { source: "Search", visitors: 275 },\n { source: "Direct", visitors: 200 },\n { source: "Social", visitors: 187 },\n { source: "Email", visitors: 90 },\n]\n\n<PieChart\n label="Visitors by source"\n data={sources}\n index="source"\n valueKey="visitors"\n donut\n centerLabel={{ value: 752, label: "Visitors" }}\n/>'
201
201
  },
202
202
  {
203
203
  label: "Labelled slices",
@@ -4,7 +4,7 @@
4
4
  import { ChartFrame } from "../../lib/chart/chart-frame.js";
5
5
  import { ChartLegend } from "../../lib/chart/chart-legend.js";
6
6
  import { ChartTooltipContent } from "../../lib/chart/chart-tooltip.js";
7
- import { categoryText, chartFormatters } from "../../lib/chart/format.js";
7
+ import { categoryText, centerValueText, chartFormatters } from "../../lib/chart/format.js";
8
8
  import { polar, strokeArcPath } from "../../lib/chart/geometry.js";
9
9
  import { drawTransition, morphTransition, pathStyle, stagger } from "../../lib/chart/motion.js";
10
10
  import {
@@ -159,8 +159,8 @@ function RadialChart({
159
159
  className: "[[data-reveal=out]_&]:opacity-0",
160
160
  style: { transition: morphTransition() },
161
161
  children: [
162
- /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? centerLabel.label ? "-1.4em" : "0" : centerLabel.label ? "-0.1em" : "0.32em", className: "fill-foreground text-heading-2 tabular-nums", children: centerLabel.value }),
163
- centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.6em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
162
+ /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? centerLabel.label ? "-1.4em" : "0" : "0.35em", className: "fill-foreground text-heading-2 tabular-nums", children: centerValueText(centerLabel.value, locale) }),
163
+ centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? "1.6em" : "1.9em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
164
164
  ]
165
165
  }
166
166
  ) : null
@@ -107,8 +107,8 @@ var radialChartMeta = {
107
107
  },
108
108
  {
109
109
  name: "centerLabel",
110
- type: "{ value: string; label?: string }",
111
- description: "A value and caption in the middle."
110
+ type: "{ value: string | number; label?: string }",
111
+ description: 'A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units ("7.2 GB", "72%").'
112
112
  },
113
113
  {
114
114
  name: "legend",
@@ -1,10 +1,13 @@
1
1
  // src/components/settings-menu/settings-menu.tsx
2
+ import { Children, isValidElement } from "react";
2
3
  import { Icon } from "../../icon/index.js";
3
4
  import { cn } from "../../lib/cn.js";
4
5
  import { useTypeTier } from "../../lib/density.js";
5
6
  import {
6
7
  Menu,
7
8
  MenuContent,
9
+ MenuGroup,
10
+ MenuGroupLabel,
8
11
  MenuSubmenu,
9
12
  MenuSubmenuTrigger,
10
13
  MenuTrigger
@@ -29,6 +32,12 @@ function SettingsMenu({
29
32
  ...props
30
33
  }) {
31
34
  const tier = useTypeTier();
35
+ const rows = Children.toArray(children);
36
+ const only = rows.length === 1 && isValidElement(rows[0]) && rows[0].type === SettingsRow ? rows[0] : null;
37
+ const content = only?.props.children && !only.props.disabled ? /* @__PURE__ */ jsxs(MenuGroup, { children: [
38
+ /* @__PURE__ */ jsx(MenuGroupLabel, { children: only.props.label }),
39
+ only.props.children
40
+ ] }) : children;
32
41
  return /* @__PURE__ */ jsxs(Menu, { ...props, children: [
33
42
  /* @__PURE__ */ jsxs(
34
43
  MenuTrigger,
@@ -49,7 +58,7 @@ function SettingsMenu({
49
58
  ]
50
59
  }
51
60
  ),
52
- /* @__PURE__ */ jsx(MenuContent, { side, align, className: "min-w-56", children })
61
+ /* @__PURE__ */ jsx(MenuContent, { side, align, className: "min-w-56", children: content })
53
62
  ] });
54
63
  }
55
64
  SettingsMenu.displayName = "SettingsMenu";
@@ -1,7 +1,7 @@
1
1
  // src/components/settings-menu/settings-menu.meta.ts
2
2
  var settingsMenuMeta = {
3
3
  name: "SettingsMenu",
4
- summary: "A compact trigger that states the current settings, over a menu of rows that change them. Each row shows its own value and opens a submenu.",
4
+ summary: "A compact trigger that states the current settings, over a menu of rows that change them. Each row shows its own value and opens a submenu; with one setting, the menu opens straight to its options under the row's label.",
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "settings",
@@ -20,9 +20,10 @@ var sideNavItemVariants = cva(
20
20
  }
21
21
  );
22
22
  var sideNavItemIconVariants = cva(
23
- "grid shrink-0 place-items-center",
23
+ "flex shrink-0 items-center justify-center",
24
24
  {
25
- variants: { size: { md: "size-5 text-xl", lg: "size-6 text-xl", rail: "size-6 text-2xl" } },
25
+ // md 20px icon in a 20px box; lg 20px in 24px; rail 24px in 24px.
26
+ variants: { size: { md: "size-[1em] text-xl", lg: "size-[1.2em] text-xl", rail: "size-[1em] text-2xl" } },
26
27
  defaultVariants: { size: "md" }
27
28
  }
28
29
  );
@@ -72,7 +72,7 @@ function SidebarHeader({
72
72
  "div",
73
73
  {
74
74
  className: cn(
75
- "flex h-16 items-center px-3",
75
+ "flex min-h-16 flex-wrap content-center items-center gap-y-2 px-3 has-[>:nth-child(2)]:py-3",
76
76
  // Collapsed, the brand mark centres on the same axis as the item icons.
77
77
  collapsed && "justify-center px-0",
78
78
  className
@@ -70,6 +70,11 @@ var sidebarMeta = {
70
70
  }
71
71
  ],
72
72
  subcomponents: [
73
+ {
74
+ name: "SidebarHeader",
75
+ type: "div props",
76
+ description: "The top of the rail, at least 64px tall. It holds SidebarBrand and may take a second row under it, such as a search field or a notifications button: give that row basis-full and the header grows to hold it instead of clipping."
77
+ },
73
78
  {
74
79
  name: "SidebarBrand",
75
80
  type: "{ mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props",
@@ -16,7 +16,7 @@ function Switch({ className, ...props }) {
16
16
  className
17
17
  ),
18
18
  ...props,
19
- children: /* @__PURE__ */ jsx(Base.Thumb, { className: "block h-5 w-5 rounded-full bg-(--switch-thumb) shadow-(--knob-shadow) motion-interactive ease-spring data-[checked]:translate-x-4 rtl:data-[checked]:-translate-x-4 data-[checked]:bg-primary-foreground" })
19
+ children: /* @__PURE__ */ jsx(Base.Thumb, { className: "block h-5 w-5 rounded-full bg-(--switch-thumb) shadow-(--knob-shadow) motion-interactive ease-spring data-[checked]:translate-x-4 rtl:data-[checked]:-translate-x-4 data-[checked]:bg-(--switch-thumb-on)" })
20
20
  }
21
21
  );
22
22
  }
@@ -1,7 +1,7 @@
1
1
  // src/components/switch/switch.meta.ts
2
2
  var switchMeta = {
3
3
  name: "Switch",
4
- summary: "Binary on/off toggle for immediate-effect settings. Track fills with the primary tone when checked; the thumb slides on a spring-eased transform (mirrored in RTL).",
4
+ summary: "Binary on/off toggle for immediate-effect settings. Track fills with the primary tone when checked; the thumb slides on a spring-eased transform (mirrored in RTL). The thumb is white in both states, like every physical toggle, and turns dark only on a near-white track (a dark mode's light primary). Themes can set --theme-switch-thumb-on.",
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "switch",
@@ -82,7 +82,7 @@ var switchMeta = {
82
82
  description: "Identifies the field when the enclosing form is submitted."
83
83
  }
84
84
  ],
85
- tokensUsed: ["input", "primary", "primary-foreground", "ring"],
85
+ tokensUsed: ["input", "primary", "ring"],
86
86
  motion: [
87
87
  {
88
88
  slot: "arrival",
@@ -51,7 +51,7 @@ function TableRow({ className, ...props }) {
51
51
  "tr",
52
52
  {
53
53
  className: cn(
54
- "border-b border-border motion-interactive hover:bg-muted/50",
54
+ "border-b border-border motion-interactive [tbody>&:hover]:bg-muted/50",
55
55
  "*:motion-interactive data-[selected]:*:bg-muted data-[selected]:*:shadow-[1px_0_0_var(--muted)]",
56
56
  className
57
57
  ),
@@ -22,7 +22,8 @@ function Toggle({ className, ...props }) {
22
22
  ),
23
23
  "data-[pressed]:bg-accent data-[pressed]:text-accent-foreground",
24
24
  "disabled:pointer-events-none disabled:opacity-50",
25
- "[&_svg]:shrink-0",
25
+ // An icon beside a label is spaced like Button's; without it they touched.
26
+ "gap-(--control-gap) [&_svg]:shrink-0",
26
27
  className
27
28
  ),
28
29
  ...props