@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +115 -0
- package/README.md +2 -0
- package/bin/sable.mjs +34 -0
- package/dist/blocks/traffic-sources-block.js +1 -1
- package/dist/blocks/traffic-trend-block.js +3 -3
- package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
- package/dist/components/button/button.meta.js +4 -0
- package/dist/components/card/card.js +7 -4
- package/dist/components/chat-composer/chat-composer.js +4 -2
- package/dist/components/chat-composer/chat-composer.meta.js +8 -2
- package/dist/components/command-menu/command-menu.js +9 -2
- package/dist/components/command-menu/command-menu.meta.js +3 -3
- package/dist/components/data-table/data-table.js +117 -64
- package/dist/components/data-table/data-table.meta.js +27 -1
- package/dist/components/form/form.js +30 -9
- package/dist/components/form/form.meta.js +17 -3
- package/dist/components/message-scroller/message-scroller.meta.js +1 -1
- package/dist/components/page-header/page-header.js +1 -0
- package/dist/components/page-header/page-header.meta.js +1 -1
- package/dist/components/pie-chart/pie-chart.js +3 -3
- package/dist/components/pie-chart/pie-chart.meta.js +3 -3
- package/dist/components/radial-chart/radial-chart.js +3 -3
- package/dist/components/radial-chart/radial-chart.meta.js +2 -2
- package/dist/components/settings-menu/settings-menu.js +10 -1
- package/dist/components/settings-menu/settings-menu.meta.js +1 -1
- package/dist/components/side-nav/side-nav.variants.js +3 -2
- package/dist/components/sidebar/sidebar.js +1 -1
- package/dist/components/sidebar/sidebar.meta.js +5 -0
- package/dist/components/switch/switch.js +1 -1
- package/dist/components/switch/switch.meta.js +2 -2
- package/dist/components/table/table.js +1 -1
- package/dist/components/toggle/toggle.js +2 -1
- package/dist/file-hashes.json +603 -0
- package/dist/icon-sets/reicon.js +13 -1
- package/dist/index.d.ts +88 -15
- package/dist/index.js +1 -1
- package/dist/layout/index.d.ts +5 -2
- package/dist/lib/chart/format.js +5 -0
- package/dist/lib/component-guidance.js +7 -2
- package/dist/lib/heading-row.js +3 -2
- package/dist/lib/trim-label.js +17 -4
- package/dist/presets/index.d.ts +7 -1
- package/dist/presets/index.js +10 -0
- package/dist/styles.css +27 -2
- package/llms/blocks/traffic-sources-block.md +1 -1
- package/llms/blocks/traffic-trend-block.md +5 -3
- package/llms/components/breadcrumbs.md +1 -1
- package/llms/components/button.md +1 -0
- package/llms/components/chat-composer.md +3 -2
- package/llms/components/command-menu.md +2 -2
- package/llms/components/data-table.md +33 -1
- package/llms/components/form.md +19 -2
- package/llms/components/message-scroller.md +1 -1
- package/llms/components/page-header.md +1 -1
- package/llms/components/pie-chart.md +2 -2
- package/llms/components/radial-chart.md +1 -1
- package/llms/components/settings-menu.md +1 -1
- package/llms/components/sidebar.md +1 -0
- package/llms/components/switch.md +2 -2
- package/llms/layout/region.md +16 -3
- package/llms/tasks/building-a-page.md +34 -3
- package/llms/tasks/creating-a-preset.md +4 -1
- package/llms/tasks/patching-a-component.md +48 -0
- package/llms/tasks/reviewing-a-design.md +9 -0
- package/llms/tasks/using-presets.md +19 -0
- package/llms-full.txt +207 -27
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/registry.json +132 -30
- package/scripts/design-memory.mjs +7 -2
- package/scripts/design-presets.mjs +15 -4
- package/scripts/lint-rules.mjs +32 -1
- package/scripts/patches.mjs +219 -0
- package/scripts/sources.mjs +247 -0
- package/skills/sable/SKILL.md +16 -4
package/registry.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://sable.design/registry.schema.json",
|
|
3
3
|
"name": "@sableui/react",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Themeable React components, semantic tokens, layout patterns, presets, and package-owned agent skills. Default theme: Sable.",
|
|
6
6
|
"engine": {
|
|
7
7
|
"behavior": "@base-ui/react",
|
|
@@ -1585,6 +1585,10 @@
|
|
|
1585
1585
|
{
|
|
1586
1586
|
"id": "lookalike-media-frame",
|
|
1587
1587
|
"fails-on": "A cover or media frame built by hand — an `aspect-*` ratio + `overflow-hidden` wrapped around an <img>, <video>, <picture> or GradientArt. That is `AspectBox`: `ratio` as a number reserves the box before the media arrives (no layout shift while a cover loads or a shader starts), it clips and positions what it holds (`relative w-full overflow-hidden`), and it is the one frame the media components share, so a change to how frames behave reaches every cover. Put the radius on it as a className. For a cover with its title beneath (an album, an episode, a product), `MediaTile` composes this frame with fallback art for a missing or broken image and an action revealed on hover. Not flagged: a ratio box with no media in it (a pad, a canvas well), a frame without overflow clipping, an <img> sized by its own aspect class, or a round fixed-size frame (lookalike-avatar's)."
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
"id": "form-required-error",
|
|
1591
|
+
"fails-on": "A `required` Field with an `error` prop, inside a `Form` that validates for you. On submit Form checks every Field first, so an empty required one stops the submit with the browser's message (\"Please fill out this field.\") and your onSubmit, which would set `error`, never runs. Either let your code own validation, `<Form noValidate>` (onSubmit always runs and the Field shows your `error`), or let Form validate and word the message on the Field: `messages={{ valueMissing: \"Enter a subject.\" }}`."
|
|
1588
1592
|
}
|
|
1589
1593
|
],
|
|
1590
1594
|
"escapeHatch": {
|
|
@@ -1678,7 +1682,12 @@
|
|
|
1678
1682
|
"volume-low",
|
|
1679
1683
|
"volume-off",
|
|
1680
1684
|
"queue",
|
|
1681
|
-
"music"
|
|
1685
|
+
"music",
|
|
1686
|
+
"tag",
|
|
1687
|
+
"paperclip",
|
|
1688
|
+
"chart",
|
|
1689
|
+
"flag",
|
|
1690
|
+
"at-sign"
|
|
1682
1691
|
]
|
|
1683
1692
|
},
|
|
1684
1693
|
"layoutPrimitives": {
|
|
@@ -1706,7 +1715,19 @@
|
|
|
1706
1715
|
"bestPractices": [
|
|
1707
1716
|
{
|
|
1708
1717
|
"guidance": "do",
|
|
1709
|
-
"description": "Choose the max value by
|
|
1718
|
+
"description": "Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide)."
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
"guidance": "do",
|
|
1722
|
+
"description": "Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose."
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
"guidance": "do",
|
|
1726
|
+
"description": "Give sibling routes in one nav the same width so the header does not jump between them."
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
"guidance": "do",
|
|
1730
|
+
"description": "Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same."
|
|
1710
1731
|
},
|
|
1711
1732
|
{
|
|
1712
1733
|
"guidance": "do",
|
|
@@ -1715,6 +1736,14 @@
|
|
|
1715
1736
|
{
|
|
1716
1737
|
"guidance": "dont",
|
|
1717
1738
|
"description": "Do not use Region as a global wrapper that forces every page into the same max width."
|
|
1739
|
+
},
|
|
1740
|
+
{
|
|
1741
|
+
"guidance": "dont",
|
|
1742
|
+
"description": "Do not go fluid when the extra width only widens the gaps between fixed columns; cap it."
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
"guidance": "dont",
|
|
1746
|
+
"description": "Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge."
|
|
1718
1747
|
}
|
|
1719
1748
|
]
|
|
1720
1749
|
},
|
|
@@ -1741,7 +1770,7 @@
|
|
|
1741
1770
|
{
|
|
1742
1771
|
"name": "max",
|
|
1743
1772
|
"type": "\"prose\" | \"form\" | \"feed\" | \"content\" | \"wide\" | \"full\" | CSS length",
|
|
1744
|
-
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (
|
|
1773
|
+
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up."
|
|
1745
1774
|
},
|
|
1746
1775
|
{
|
|
1747
1776
|
"name": "bleed",
|
|
@@ -1764,9 +1793,14 @@
|
|
|
1764
1793
|
"imports": "import { PageHeader, Region } from \"@sableui/react\""
|
|
1765
1794
|
},
|
|
1766
1795
|
{
|
|
1767
|
-
"label": "
|
|
1796
|
+
"label": "Dashboard region (capped at wide)",
|
|
1768
1797
|
"code": "<Region max=\"wide\"><Grid minItemWidth=\"16rem\"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>",
|
|
1769
1798
|
"imports": "import { Card, CardHeader, CardTitle, Grid, Region } from \"@sableui/react\""
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
"label": "Fluid workspace: the queue's table takes the extra width",
|
|
1802
|
+
"code": "<Region as=\"main\"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label=\"Views\"><SideNavItem label=\"Open\" active /><SideNavItem label=\"Pending\" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>",
|
|
1803
|
+
"imports": "import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from \"@sableui/react\""
|
|
1770
1804
|
}
|
|
1771
1805
|
]
|
|
1772
1806
|
},
|
|
@@ -4457,8 +4491,8 @@
|
|
|
4457
4491
|
},
|
|
4458
4492
|
{
|
|
4459
4493
|
"name": "BreadcrumbLink",
|
|
4460
|
-
"type": "ComponentPropsWithRef<'a'>",
|
|
4461
|
-
"description": "An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus."
|
|
4494
|
+
"type": "ComponentPropsWithRef<'a'> & { render?: useRender.RenderProp }",
|
|
4495
|
+
"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."
|
|
4462
4496
|
},
|
|
4463
4497
|
{
|
|
4464
4498
|
"name": "BreadcrumbPage",
|
|
@@ -4783,6 +4817,10 @@
|
|
|
4783
4817
|
"guidance": "do",
|
|
4784
4818
|
"description": "Use a single primary button in a view or dense panel so the action hierarchy stays obvious."
|
|
4785
4819
|
},
|
|
4820
|
+
{
|
|
4821
|
+
"guidance": "do",
|
|
4822
|
+
"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`."
|
|
4823
|
+
},
|
|
4786
4824
|
{
|
|
4787
4825
|
"guidance": "do",
|
|
4788
4826
|
"description": "Write labels as actions, such as \"Save changes\" or \"Delete draft\", instead of vague labels like \"OK\"."
|
|
@@ -5622,7 +5660,7 @@
|
|
|
5622
5660
|
"group": "Chat & AI",
|
|
5623
5661
|
"register": "either",
|
|
5624
5662
|
"path": "components/chat-composer",
|
|
5625
|
-
"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.",
|
|
5663
|
+
"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.",
|
|
5626
5664
|
"keywords": [
|
|
5627
5665
|
"chat input",
|
|
5628
5666
|
"composer",
|
|
@@ -5666,7 +5704,7 @@
|
|
|
5666
5704
|
},
|
|
5667
5705
|
{
|
|
5668
5706
|
"guidance": "dont",
|
|
5669
|
-
"description": "Do not use it as a general multi-line input. That is `Textarea
|
|
5707
|
+
"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\"."
|
|
5670
5708
|
},
|
|
5671
5709
|
{
|
|
5672
5710
|
"guidance": "dont",
|
|
@@ -5740,6 +5778,12 @@
|
|
|
5740
5778
|
"default": "\"Send a message…\"",
|
|
5741
5779
|
"description": "Placeholder for the field."
|
|
5742
5780
|
},
|
|
5781
|
+
{
|
|
5782
|
+
"name": "sendOn",
|
|
5783
|
+
"type": "\"enter\" | \"mod-enter\"",
|
|
5784
|
+
"default": "\"enter\"",
|
|
5785
|
+
"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\")."
|
|
5786
|
+
},
|
|
5743
5787
|
{
|
|
5744
5788
|
"name": "busy",
|
|
5745
5789
|
"type": "boolean",
|
|
@@ -6994,7 +7038,7 @@
|
|
|
6994
7038
|
{
|
|
6995
7039
|
"name": "items",
|
|
6996
7040
|
"type": "CommandItem[]",
|
|
6997
|
-
"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 — and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
|
|
7041
|
+
"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 — and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
|
|
6998
7042
|
},
|
|
6999
7043
|
{
|
|
7000
7044
|
"name": "query",
|
|
@@ -7075,8 +7119,8 @@
|
|
|
7075
7119
|
},
|
|
7076
7120
|
{
|
|
7077
7121
|
"name": "CommandMenuItem",
|
|
7078
|
-
"type": "{ onSelect?: () => void; icon?: string; detail?: ReactNode; hint?: ReactNode; disabled?: boolean; id?: string; children }",
|
|
7079
|
-
"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 — 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."
|
|
7122
|
+
"type": "{ onSelect?: () => void; icon?: string; detail?: ReactNode; stacked?: boolean; hint?: ReactNode; disabled?: boolean; id?: string; children }",
|
|
7123
|
+
"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 — 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."
|
|
7080
7124
|
}
|
|
7081
7125
|
],
|
|
7082
7126
|
"tokensUsed": [
|
|
@@ -7495,7 +7539,29 @@
|
|
|
7495
7539
|
{
|
|
7496
7540
|
"name": "isRowSelected",
|
|
7497
7541
|
"type": "(row) => boolean",
|
|
7498
|
-
"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."
|
|
7542
|
+
"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."
|
|
7543
|
+
},
|
|
7544
|
+
{
|
|
7545
|
+
"name": "selectedKeys",
|
|
7546
|
+
"type": "ReadonlySet<string | number>",
|
|
7547
|
+
"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."
|
|
7548
|
+
},
|
|
7549
|
+
{
|
|
7550
|
+
"name": "onSelectedKeysChange",
|
|
7551
|
+
"type": "(keys: Set<string | number>) => void",
|
|
7552
|
+
"description": "Called with the new set of checked keys. Its presence turns selection on. A click on a box never fires onRowClick."
|
|
7553
|
+
},
|
|
7554
|
+
{
|
|
7555
|
+
"name": "rowSelectionLabel",
|
|
7556
|
+
"type": "(row) => string",
|
|
7557
|
+
"default": "() => \"Select row\"",
|
|
7558
|
+
"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."
|
|
7559
|
+
},
|
|
7560
|
+
{
|
|
7561
|
+
"name": "selectAllLabel",
|
|
7562
|
+
"type": "(count: number) => string",
|
|
7563
|
+
"default": "(count) => `Select all ${count} shown`",
|
|
7564
|
+
"description": "The select-all checkbox's accessible name; say what it acts on (\"Select all 7 tickets shown\")."
|
|
7499
7565
|
},
|
|
7500
7566
|
{
|
|
7501
7567
|
"name": "defaultSort",
|
|
@@ -7525,6 +7591,11 @@
|
|
|
7525
7591
|
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
|
|
7526
7592
|
],
|
|
7527
7593
|
"examples": [
|
|
7594
|
+
{
|
|
7595
|
+
"label": "Checkbox selection with a bulk-action bar",
|
|
7596
|
+
"code": "const [selected, setSelected] = useState<Set<string | number>>(new Set())\n\n<Stack gap={2}>\n\t{/* Mounted from the start, so a screen reader hears each change. */}\n\t<span role=\"status\" className=\"sr-only\">{selected.size ? `${selected.size} of ${tickets.length} selected` : \"\"}</span>\n\t{selected.size ? (\n\t\t<Toolbar aria-label=\"Bulk actions\">\n\t\t\t<span className=\"text-label-md\">{selected.size} of {tickets.length} selected</span>\n\t\t\t<Button size=\"sm\" variant=\"secondary\" onClick={() => resolve(selected)}>Resolve</Button>\n\t\t</Toolbar>\n\t) : null}\n\t<DataTable\n\t\tdata={tickets}\n\t\trowKey={(t) => t.id}\n\t\tcolumns={columns}\n\t\tselectedKeys={selected}\n\t\tonSelectedKeysChange={setSelected}\n\t\trowSelectionLabel={(t) => `Select ${t.subject}`}\n\t\tselectAllLabel={(n) => `Select all ${n} tickets shown`}\n\t/>\n</Stack>",
|
|
7597
|
+
"imports": "import { Button, DataTable, Stack, Toolbar } from \"@sableui/react\""
|
|
7598
|
+
},
|
|
7528
7599
|
{
|
|
7529
7600
|
"label": "Sortable columns",
|
|
7530
7601
|
"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) }]} />",
|
|
@@ -9498,7 +9569,7 @@
|
|
|
9498
9569
|
{
|
|
9499
9570
|
"name": "Form",
|
|
9500
9571
|
"type": "{ validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; errors?: Record<string, string | string[]>; onFormSubmit?: (values, details) => void; actionsRef?: RefObject<Form.Actions> } + ComponentPropsWithRef<\"form\">",
|
|
9501
|
-
"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
|
|
9572
|
+
"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."
|
|
9502
9573
|
},
|
|
9503
9574
|
{
|
|
9504
9575
|
"name": "validationMode",
|
|
@@ -9516,6 +9587,12 @@
|
|
|
9516
9587
|
"type": "(values: Record<string, unknown>, details) => void",
|
|
9517
9588
|
"description": "Called with the named Field values after validation passes; prevents the native submit."
|
|
9518
9589
|
},
|
|
9590
|
+
{
|
|
9591
|
+
"name": "noValidate",
|
|
9592
|
+
"type": "boolean",
|
|
9593
|
+
"default": "false",
|
|
9594
|
+
"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."
|
|
9595
|
+
},
|
|
9519
9596
|
{
|
|
9520
9597
|
"name": "actionsRef",
|
|
9521
9598
|
"type": "RefObject<{ validate(name?: string): void }>",
|
|
@@ -9525,8 +9602,8 @@
|
|
|
9525
9602
|
"subcomponents": [
|
|
9526
9603
|
{
|
|
9527
9604
|
"name": "Field",
|
|
9528
|
-
"type": "{ label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; disabled?: boolean; name?: string; validate?: (value, formValues) => string | string[] | null | Promise<…>; validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; validationDebounceTime?: number; orientation?: \"vertical\" | \"horizontal\"; className?: string; children: ReactElement }",
|
|
9529
|
-
"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…) 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
|
|
9605
|
+
"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<…>; validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; validationDebounceTime?: number; orientation?: \"vertical\" | \"horizontal\"; className?: string; children: ReactElement }",
|
|
9606
|
+
"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…) 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 → control); \"horizontal\" places control then label inline for a single checkbox / switch / radio — 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."
|
|
9530
9607
|
}
|
|
9531
9608
|
],
|
|
9532
9609
|
"tokensUsed": [
|
|
@@ -9550,6 +9627,16 @@
|
|
|
9550
9627
|
"code": "<Field label=\"Password\" error=\"Must be at least 8 characters.\"><Input type=\"password\" /></Field>",
|
|
9551
9628
|
"imports": "import { Field, Input } from \"@sableui/react\""
|
|
9552
9629
|
},
|
|
9630
|
+
{
|
|
9631
|
+
"label": "Your own wording for a missing value",
|
|
9632
|
+
"code": "<Form onSubmit={handleSubmit}><Field label=\"Subject\" required messages={{ valueMissing: \"Enter a subject.\" }}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9633
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9634
|
+
},
|
|
9635
|
+
{
|
|
9636
|
+
"label": "Validation in your own code",
|
|
9637
|
+
"code": "<Form noValidate onSubmit={handleSubmit}><Field label=\"Subject\" required error={errors.subject}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9638
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9639
|
+
},
|
|
9553
9640
|
{
|
|
9554
9641
|
"label": "Validated on submit",
|
|
9555
9642
|
"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>",
|
|
@@ -11711,7 +11798,7 @@
|
|
|
11711
11798
|
{
|
|
11712
11799
|
"name": "ref",
|
|
11713
11800
|
"type": "Ref<MessageScrollerHandle>",
|
|
11714
|
-
"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."
|
|
11801
|
+
"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`."
|
|
11715
11802
|
},
|
|
11716
11803
|
{
|
|
11717
11804
|
"name": "autoFollow",
|
|
@@ -12860,7 +12947,7 @@
|
|
|
12860
12947
|
{
|
|
12861
12948
|
"name": "actions",
|
|
12862
12949
|
"type": "ReactNode",
|
|
12863
|
-
"description": "Trailing actions aligned to the title row's end. They drop under the title when the
|
|
12950
|
+
"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."
|
|
12864
12951
|
},
|
|
12865
12952
|
{
|
|
12866
12953
|
"name": "titleAs",
|
|
@@ -13561,8 +13648,8 @@
|
|
|
13561
13648
|
},
|
|
13562
13649
|
{
|
|
13563
13650
|
"name": "centerLabel",
|
|
13564
|
-
"type": "{ value: string; label?: string }",
|
|
13565
|
-
"description": "A value and caption in the hole of a donut."
|
|
13651
|
+
"type": "{ value: string | number; label?: string }",
|
|
13652
|
+
"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."
|
|
13566
13653
|
},
|
|
13567
13654
|
{
|
|
13568
13655
|
"name": "separator",
|
|
@@ -13675,7 +13762,7 @@
|
|
|
13675
13762
|
"examples": [
|
|
13676
13763
|
{
|
|
13677
13764
|
"label": "Donut with a total",
|
|
13678
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value:
|
|
13765
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value: 752, label: \"Visitors\" }}\n/>",
|
|
13679
13766
|
"imports": "import { PieChart } from \"@sableui/react\""
|
|
13680
13767
|
},
|
|
13681
13768
|
{
|
|
@@ -14439,8 +14526,8 @@
|
|
|
14439
14526
|
},
|
|
14440
14527
|
{
|
|
14441
14528
|
"name": "centerLabel",
|
|
14442
|
-
"type": "{ value: string; label?: string }",
|
|
14443
|
-
"description": "A value and caption in the middle."
|
|
14529
|
+
"type": "{ value: string | number; label?: string }",
|
|
14530
|
+
"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%\")."
|
|
14444
14531
|
},
|
|
14445
14532
|
{
|
|
14446
14533
|
"name": "legend",
|
|
@@ -15942,7 +16029,7 @@
|
|
|
15942
16029
|
"group": "Overlays",
|
|
15943
16030
|
"register": "either",
|
|
15944
16031
|
"path": "components/settings-menu",
|
|
15945
|
-
"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.",
|
|
16032
|
+
"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.",
|
|
15946
16033
|
"keywords": [
|
|
15947
16034
|
"settings",
|
|
15948
16035
|
"model picker",
|
|
@@ -16467,6 +16554,11 @@
|
|
|
16467
16554
|
}
|
|
16468
16555
|
],
|
|
16469
16556
|
"subcomponents": [
|
|
16557
|
+
{
|
|
16558
|
+
"name": "SidebarHeader",
|
|
16559
|
+
"type": "div props",
|
|
16560
|
+
"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."
|
|
16561
|
+
},
|
|
16470
16562
|
{
|
|
16471
16563
|
"name": "SidebarBrand",
|
|
16472
16564
|
"type": "{ mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props",
|
|
@@ -17858,7 +17950,7 @@
|
|
|
17858
17950
|
"group": "Selection",
|
|
17859
17951
|
"register": "either",
|
|
17860
17952
|
"path": "components/switch",
|
|
17861
|
-
"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).",
|
|
17953
|
+
"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.",
|
|
17862
17954
|
"keywords": [
|
|
17863
17955
|
"switch",
|
|
17864
17956
|
"toggle setting",
|
|
@@ -17941,7 +18033,6 @@
|
|
|
17941
18033
|
"tokensUsed": [
|
|
17942
18034
|
"input",
|
|
17943
18035
|
"primary",
|
|
17944
|
-
"primary-foreground",
|
|
17945
18036
|
"ring"
|
|
17946
18037
|
],
|
|
17947
18038
|
"motion": [
|
|
@@ -21174,7 +21265,7 @@
|
|
|
21174
21265
|
"Hovering or arrowing through the slices pulls each out and announces it."
|
|
21175
21266
|
],
|
|
21176
21267
|
"imports": "import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, PieChart } from \"@sableui/react\"",
|
|
21177
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total
|
|
21268
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total, label: \"Visitors\" }}\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"text-body-sm text-muted-foreground\">\n\t\t\t\tSearch brings {Math.round((sources[0].visitors / total) * 100)}% of visitors.\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21178
21269
|
},
|
|
21179
21270
|
{
|
|
21180
21271
|
"name": "TrafficTrendBlock",
|
|
@@ -21197,7 +21288,7 @@
|
|
|
21197
21288
|
"The range control is a labelled ToggleGroup; the chart is one tab stop with arrow-key navigation."
|
|
21198
21289
|
],
|
|
21199
21290
|
"imports": "import { useState } from \"react\"\nimport { AreaChart, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Icon, Toggle, ToggleGroup } from \"@sableui/react\"",
|
|
21200
|
-
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21291
|
+
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"@container/trend w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader className=\"@max-sm/trend:has-data-[slot=card-action]:grid-cols-1\">\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t{/* Beside the title on a wide card; under it on a narrow one, where three\n\t\t\t\t ranges would squeeze the title and description into a sliver. */}\n\t\t\t\t<CardAction className=\"@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start\">\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21201
21292
|
},
|
|
21202
21293
|
{
|
|
21203
21294
|
"name": "UploadListBlock",
|
|
@@ -23748,6 +23839,9 @@
|
|
|
23748
23839
|
"render",
|
|
23749
23840
|
"showLabel"
|
|
23750
23841
|
],
|
|
23842
|
+
"BreadcrumbLink": [
|
|
23843
|
+
"render"
|
|
23844
|
+
],
|
|
23751
23845
|
"BreadcrumbSeparator": [
|
|
23752
23846
|
"children"
|
|
23753
23847
|
],
|
|
@@ -23917,6 +24011,7 @@
|
|
|
23917
24011
|
"onValueChange",
|
|
23918
24012
|
"placeholder",
|
|
23919
24013
|
"sendLabel",
|
|
24014
|
+
"sendOn",
|
|
23920
24015
|
"stopLabel",
|
|
23921
24016
|
"value"
|
|
23922
24017
|
],
|
|
@@ -24188,7 +24283,8 @@
|
|
|
24188
24283
|
"disabled",
|
|
24189
24284
|
"hint",
|
|
24190
24285
|
"icon",
|
|
24191
|
-
"onSelect"
|
|
24286
|
+
"onSelect",
|
|
24287
|
+
"stacked"
|
|
24192
24288
|
],
|
|
24193
24289
|
"CommandMenuPanel": [
|
|
24194
24290
|
"children",
|
|
@@ -24326,7 +24422,11 @@
|
|
|
24326
24422
|
"empty",
|
|
24327
24423
|
"isRowSelected",
|
|
24328
24424
|
"onRowClick",
|
|
24329
|
-
"
|
|
24425
|
+
"onSelectedKeysChange",
|
|
24426
|
+
"rowKey",
|
|
24427
|
+
"rowSelectionLabel",
|
|
24428
|
+
"selectAllLabel",
|
|
24429
|
+
"selectedKeys"
|
|
24330
24430
|
],
|
|
24331
24431
|
"DatePicker": [
|
|
24332
24432
|
"className",
|
|
@@ -24511,6 +24611,7 @@
|
|
|
24511
24611
|
"disabled",
|
|
24512
24612
|
"error",
|
|
24513
24613
|
"label",
|
|
24614
|
+
"messages",
|
|
24514
24615
|
"name",
|
|
24515
24616
|
"orientation",
|
|
24516
24617
|
"required",
|
|
@@ -24567,6 +24668,7 @@
|
|
|
24567
24668
|
"Form": [
|
|
24568
24669
|
"actionsRef",
|
|
24569
24670
|
"errors",
|
|
24671
|
+
"noValidate",
|
|
24570
24672
|
"onFormSubmit",
|
|
24571
24673
|
"validationMode"
|
|
24572
24674
|
],
|
|
@@ -95,7 +95,12 @@ const template = {
|
|
|
95
95
|
avoidWhen: "",
|
|
96
96
|
change: { before: "", after: "" },
|
|
97
97
|
provenance: { kind: "build-review", source: "", date: "", reviewer: "", sableVersion: "" },
|
|
98
|
-
|
|
98
|
+
// The paths say their own rules, which recording enforces: a dogfood run met
|
|
99
|
+
// both only as ERR_LESSON_INVALID after filling the template in.
|
|
100
|
+
evidence: [
|
|
101
|
+
{ kind: "before", path: "<a non-empty file inside this project, relative to its root, e.g. design/evidence/before.png>", observation: "" },
|
|
102
|
+
{ kind: "after", path: "<a different file from the before one, also inside the project>", observation: "" },
|
|
103
|
+
],
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
function main() {
|
|
@@ -118,7 +123,7 @@ function main() {
|
|
|
118
123
|
const actions = ["--list", "--search", "--show", "--record", "--template", "--check"].filter((flag) => flags.has(flag))
|
|
119
124
|
if (actions.length > 1) return fail("ERR_BAD_FLAG", "Choose one of --list, --search, --show, --record, --template, --check.")
|
|
120
125
|
if (flags.has("--help")) {
|
|
121
|
-
process.stdout.write("sable learn — retrieve precedents and record project-owned lessons\n\n --list | --search <words> | --show <id> | --template | --record <lesson.json> | --check\n --json structured { type, data } output\n\nRun from the project root. Evidence paths are relative to that root. Records are provisional reviewer observations, never automatic proof of taste.\n")
|
|
126
|
+
process.stdout.write("sable learn — retrieve precedents and record project-owned lessons\n\n --list | --search <words> | --show <id> | --template | --record <lesson.json> | --check\n --json structured { type, data } output\n\nRun from the project root. Evidence paths are relative to that root, must stay inside it, and before and after must be different files. Records are provisional reviewer observations, never automatic proof of taste.\n")
|
|
122
127
|
return
|
|
123
128
|
}
|
|
124
129
|
if (flags.has("--template")) {
|
|
@@ -55,7 +55,7 @@ try {
|
|
|
55
55
|
const target = match[1].includes(".dark") ? groups.dark : groups.light
|
|
56
56
|
for (const declaration of match[2].matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) target[declaration[1]] = declaration[2].trim()
|
|
57
57
|
}
|
|
58
|
-
const { PRESET_TOKEN_NAMES } = await import("../dist/presets/index.js")
|
|
58
|
+
const { PAGE_WIDTH_TOKENS, PRESET_TOKEN_NAMES } = await import("../dist/presets/index.js")
|
|
59
59
|
function tokens(mode) {
|
|
60
60
|
const values = { ...groups.light, ...(mode === "dark" ? groups.dark : {}) }
|
|
61
61
|
function resolveValue(value, chain = []) {
|
|
@@ -65,7 +65,9 @@ try {
|
|
|
65
65
|
return resolveValue(values[name], [...chain, name])
|
|
66
66
|
})
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
// Page widths are opt-in: copying the defaults would override any widths
|
|
69
|
+
// an app already sets in its own CSS, since preset CSS loads after it.
|
|
70
|
+
return Object.fromEntries(PRESET_TOKEN_NAMES.filter(name => values[name] && !PAGE_WIDTH_TOKENS.includes(name)).map(name => [name, resolveValue(values[name])]))
|
|
69
71
|
}
|
|
70
72
|
const preset = parsePreset({ schemaVersion: 1, id: "my-preset", label: "My preset", revision: 1, sableVersion: manifest.version, defaultMode: "dark", fonts: { sans: groups.light["font-sans"], mono: groups.light["font-mono"] }, direction: "Content-first composition with a clear primary action. Replace this guidance with the direction for your product.", tokens: { shared: {}, light: tokens("light"), dark: tokens("dark") } })
|
|
71
73
|
emit("preset.template", preset, JSON.stringify(preset, null, 2))
|
|
@@ -95,10 +97,19 @@ try {
|
|
|
95
97
|
}
|
|
96
98
|
const accepted = labels.accepted.map(f => `\n accepted: ${pair(f)} — ${f.reason}`).join("")
|
|
97
99
|
const unmeasured = labels.unmeasured.length ? `, ${labels.unmeasured.length} unmeasured (${labels.unmeasured.join(", ")})` : ""
|
|
100
|
+
// A passing check is the start of applying a preset, not the end: say what
|
|
101
|
+
// makes the app use it, so a check does not read as "done".
|
|
102
|
+
const fonts = [...new Set(Object.values(preset.fonts).filter(Boolean).map(stack => stack.split(",")[0].trim().replace(/^["']|["']$/g, "")))]
|
|
103
|
+
const next = [
|
|
104
|
+
`sable preset --css ${file} > <stylesheet>, imported after Sable's`,
|
|
105
|
+
`load fonts: ${fonts.join(", ")}`,
|
|
106
|
+
`SableThemeProvider defaultThemeId="${preset.id}" defaultPreference="${preset.defaultMode}" with the preset in customThemes`,
|
|
107
|
+
"build the app, run sable check, review both modes (sable docs using-presets)",
|
|
108
|
+
]
|
|
98
109
|
emit(
|
|
99
110
|
"preset.check",
|
|
100
|
-
{ valid: true, id: preset.id, revision: preset.revision, authoredWith: preset.sableVersion, installed, warnings, labelContrast: { measured: labels.measured, accepted: labels.accepted, unmeasured: labels.unmeasured } },
|
|
101
|
-
`Valid preset ${preset.id}, revision ${preset.revision}. Labels on filled roles: ${labels.measured} pairs measured${unmeasured}.${accepted}${warningText}\nReview rendered contrast and brief fit separately
|
|
111
|
+
{ valid: true, id: preset.id, revision: preset.revision, authoredWith: preset.sableVersion, installed, warnings, labelContrast: { measured: labels.measured, accepted: labels.accepted, unmeasured: labels.unmeasured }, fonts, next },
|
|
112
|
+
`Valid preset ${preset.id}, revision ${preset.revision}. Labels on filled roles: ${labels.measured} pairs measured${unmeasured}.${accepted}${warningText}\nReview rendered contrast and brief fit separately.\nTo apply it to an app:\n${next.map(step => ` - ${step}`).join("\n")}`,
|
|
102
113
|
)
|
|
103
114
|
} else {
|
|
104
115
|
const css = presetCss(preset)
|
package/scripts/lint-rules.mjs
CHANGED
|
@@ -1038,6 +1038,37 @@ export function makeRules({ semanticTokens, motionTokens, labelContrast = null,
|
|
|
1038
1038
|
return ctx?.isStylesheet && statusText ? statusText(text) : []
|
|
1039
1039
|
},
|
|
1040
1040
|
},
|
|
1041
|
+
{
|
|
1042
|
+
id: "form-required-error",
|
|
1043
|
+
/* CONSUMER-scoped. A support app set `error` on a required Field and
|
|
1044
|
+
* never saw it: Form validates every Field before it calls onSubmit, so
|
|
1045
|
+
* an empty one stopped the submit with the browser's wording and the
|
|
1046
|
+
* handler that sets `error` never ran. */
|
|
1047
|
+
scope: "consumer",
|
|
1048
|
+
since: "0.8.0",
|
|
1049
|
+
file: true,
|
|
1050
|
+
code: true,
|
|
1051
|
+
why:
|
|
1052
|
+
"A `required` Field with an `error` prop, inside a `Form` that validates for you. On submit Form checks every Field first, so an empty required one stops the submit with the browser's message (\"Please fill out this field.\") and your onSubmit, which would set `error`, never runs. Either let your code own validation, `<Form noValidate>` (onSubmit always runs and the Field shows your `error`), or let Form validate and word the message on the Field: `messages={{ valueMissing: \"Enter a subject.\" }}`.",
|
|
1053
|
+
scan(text) {
|
|
1054
|
+
const src = maskMarkupTemplates(text)
|
|
1055
|
+
const out = []
|
|
1056
|
+
// A boolean prop is on when written bare or as anything but `={false}`.
|
|
1057
|
+
const flag = (attrs, name) => {
|
|
1058
|
+
const m = new RegExp(`(?:^|\\s)${name}(?![\\w-])(\\s*=\\s*\\{\\s*false\\s*\\})?`).exec(attrs)
|
|
1059
|
+
return Boolean(m) && !m[1]
|
|
1060
|
+
}
|
|
1061
|
+
for (const form of openingTags(src, /^Form$/)) {
|
|
1062
|
+
if (flag(form.attrs, "noValidate")) continue
|
|
1063
|
+
const { inner } = elementContent(src, form)
|
|
1064
|
+
for (const field of openingTags(inner, /^Field$/)) {
|
|
1065
|
+
if (!flag(field.attrs, "required") || !/(?:^|\s)error\s*=/.test(field.attrs)) continue
|
|
1066
|
+
out.push({ index: form.end + 1 + field.index, match: "required Field with error inside a validating Form → <Form noValidate> or messages={{ valueMissing }}" })
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return out
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1041
1072
|
{
|
|
1042
1073
|
id: "hand-derived-surface",
|
|
1043
1074
|
/* CONSUMER-scoped. Mixing ink into a surface by hand is what the
|
|
@@ -1229,7 +1260,7 @@ export function makeRules({ semanticTokens, motionTokens, labelContrast = null,
|
|
|
1229
1260
|
file: true,
|
|
1230
1261
|
code: true,
|
|
1231
1262
|
why:
|
|
1232
|
-
"A look-changing class on a Sable component (border*, bg-*, rounded*, ring-*, shadow-* on Button, Pill or Badge). Their appearance is set by variants — Button `variant` (floating for a control over content), Pill `appearance`/`size`, Badge `appearance`/`tone` — and an override fights the variant's own hover, selected and focus states. Use a real variant, or add one to the component. Layout classes (width, margin, flex, position) are fine.",
|
|
1263
|
+
"A look-changing class on a Sable component (border*, bg-*, rounded*, ring-*, shadow-* on Button, Pill or Badge). Their appearance is set by variants — Button `variant` (floating for a control over content), Pill `appearance`/`size`, Badge `appearance`/`tone` — and an override fights the variant's own hover, selected and focus states. Use a real variant, or add one to the component. Layout classes (width, margin, flex, position) are fine. If the component is defective rather than missing a look, patch it with `sable patch <Component>` (recorded, and re-checked on every upgrade) instead of restyling it.",
|
|
1233
1264
|
scan(text) {
|
|
1234
1265
|
const out = []
|
|
1235
1266
|
for (const m of openingTags(text, /^(?:Button|Pill|Badge)$/)) {
|