@sableui/react 0.17.0 → 0.19.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 +10 -3
- package/CHANGELOG.md +235 -0
- package/README.md +11 -4
- package/bin/sable.mjs +266 -30
- package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
- package/dist/activity-DT8VK9fx.d.ts +25 -0
- package/dist/adapters/github.d.ts +41 -0
- package/dist/adapters/github.js +24 -0
- package/dist/blocks/index.d.ts +1 -1
- package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
- package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
- package/dist/components/activity-heatmap/index.js +9 -0
- package/dist/components/avatar/avatar.js +16 -7
- package/dist/components/avatar/avatar.meta.js +43 -10
- package/dist/components/avatar/avatar.variants.js +26 -2
- package/dist/components/code-block/code-block.js +5 -3
- package/dist/components/code-block/code-block.meta.js +6 -0
- package/dist/components/collapsible/collapsible.js +6 -5
- package/dist/components/collapsible/collapsible.meta.js +14 -5
- package/dist/components/data-table/data-table.js +132 -45
- package/dist/components/data-table/data-table.meta.js +63 -4
- package/dist/components/date-picker/date-picker.js +13 -6
- package/dist/components/date-picker/date-picker.meta.js +22 -0
- package/dist/components/dialog/dialog.js +25 -5
- package/dist/components/dialog/dialog.meta.js +26 -5
- package/dist/components/media-image/media-image.js +3 -1
- package/dist/components/media-image/media-image.meta.js +15 -1
- package/dist/components/media-tile/media-tile.js +13 -1
- package/dist/components/media-tile/media-tile.meta.js +10 -0
- package/dist/components/menu/menu.js +2 -0
- package/dist/components/menu/menu.meta.js +2 -2
- package/dist/components/page-header/page-header.js +9 -4
- package/dist/components/page-header/page-header.meta.js +26 -1
- package/dist/components/panel-group/panel-group.js +17 -1
- package/dist/components/panel-group/panel-group.meta.js +43 -3
- package/dist/components/pill/pill.meta.js +15 -6
- package/dist/components/pill/pill.variants.js +1 -0
- package/dist/components/search-input/search-input.js +30 -5
- package/dist/components/search-input/search-input.meta.js +29 -4
- package/dist/components/section-header/section-header.js +27 -17
- package/dist/components/section-header/section-header.meta.js +27 -5
- package/dist/components/sheet/sheet.js +15 -3
- package/dist/components/sheet/sheet.meta.js +2 -2
- package/dist/components/side-nav/side-nav.variants.js +8 -3
- package/dist/components/sidebar/index.js +4 -0
- package/dist/components/sidebar/sidebar.js +90 -11
- package/dist/components/sidebar/sidebar.meta.js +38 -4
- package/dist/components/table/table.js +7 -3
- package/dist/components/tabs/tabs.js +10 -2
- package/dist/components/tabs/tabs.meta.js +4 -0
- package/dist/components/workspace/workspace.js +13 -3
- package/dist/components/workspace/workspace.meta.js +109 -1
- package/dist/file-hashes.json +94 -80
- package/dist/foundations/typography.js +13 -0
- package/dist/icon/Icon.js +21 -2
- package/dist/icon/index.d.ts +36 -1
- package/dist/icon/index.js +5 -0
- package/dist/icon/names.js +240 -0
- package/dist/icon-sets/reicon.js +41 -1
- package/dist/index.d.ts +540 -75
- package/dist/index.js +18 -1
- package/dist/layout/AspectBox.js +2 -5
- package/dist/layout/Bleed.js +10 -6
- package/dist/layout/Center.js +11 -3
- package/dist/layout/Columns.js +6 -3
- package/dist/layout/Grid.js +14 -6
- package/dist/layout/GridOverlay.js +7 -7
- package/dist/layout/Region.js +18 -3
- package/dist/layout/Row.js +27 -20
- package/dist/layout/Spacer.js +13 -2
- package/dist/layout/SplitPane.js +1 -1
- package/dist/layout/Stack.js +25 -18
- package/dist/layout/index.d.ts +57 -6
- package/dist/lib/chart/activity.js +32 -0
- package/dist/lib/chart/calendar.js +67 -0
- package/dist/lib/chart/chart-tooltip.js +2 -2
- package/dist/lib/chart/heat.js +55 -0
- package/dist/lib/cn.js +1 -0
- package/dist/lib/focus-return.js +53 -0
- package/dist/lib/taxonomy.js +1 -0
- package/dist/lib/use-media-query.js +41 -0
- package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
- package/dist/patterns/dashboard-page.js +23 -17
- package/dist/patterns/index.d.ts +7 -2
- package/dist/patterns/index.js +5 -1
- package/dist/patterns/tracker-page.js +392 -0
- package/dist/patterns/tracker-page.meta.js +40 -0
- package/dist/presets/index.d.ts +1 -1
- package/dist/styles.css +301 -188
- package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
- package/dist/themes/_contract.js +9 -1
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/sable/index.d.ts +1 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/llms/components/activity-heatmap.md +133 -0
- package/llms/components/avatar.md +32 -9
- package/llms/components/code-block.md +1 -0
- package/llms/components/collapsible.md +12 -4
- package/llms/components/data-table.md +40 -3
- package/llms/components/date-picker.md +15 -1
- package/llms/components/dialog.md +34 -3
- package/llms/components/media-image.md +12 -0
- package/llms/components/media-tile.md +21 -0
- package/llms/components/menu.md +1 -1
- package/llms/components/page-header.md +16 -1
- package/llms/components/panel-group.md +34 -2
- package/llms/components/pill.md +24 -5
- package/llms/components/search-input.md +37 -4
- package/llms/components/section-header.md +28 -4
- package/llms/components/sheet.md +2 -2
- package/llms/components/sidebar.md +17 -4
- package/llms/components/tabs.md +1 -0
- package/llms/components/workspace.md +81 -1
- package/llms/foundations/typography.md +4 -1
- package/llms/layout/aspect-box.md +1 -1
- package/llms/layout/bleed.md +1 -1
- package/llms/layout/center.md +1 -1
- package/llms/layout/columns.md +1 -1
- package/llms/layout/grid.md +1 -1
- package/llms/layout/region.md +1 -1
- package/llms/layout/row.md +9 -1
- package/llms/layout/spacer.md +1 -1
- package/llms/layout/split-pane.md +1 -1
- package/llms/layout/stack.md +1 -1
- package/llms/patterns/dashboard-page.md +18 -15
- package/llms/patterns/tracker-page.md +450 -0
- package/llms/reference/token-intent.md +3 -1
- package/llms/reference/utilities.md +4 -1
- package/llms/tasks/auditing-an-app.md +35 -1
- package/llms/tasks/building-a-page.md +3 -2
- package/llms/tasks/building-a-tool.md +101 -5
- package/llms/tasks/building-with-sable.md +11 -3
- package/llms/tasks/creating-a-preset.md +9 -0
- package/llms/tasks/getting-started.md +10 -3
- package/llms/tasks/using-presets.md +1 -2
- package/llms-full.txt +1215 -89
- package/llms.txt +11 -9
- package/package.json +7 -1
- package/registry.json +1021 -70
- package/scripts/adoption.mjs +252 -0
- package/scripts/agent-setup.mjs +110 -18
- package/scripts/app-css.mjs +129 -0
- package/scripts/grade.mjs +178 -2
- package/scripts/lint-rules.mjs +126 -0
- package/scripts/wiring.mjs +2 -1
- package/skills/entry/SKILL.md +1 -1
- package/skills/sable/SKILL.md +64 -6
- package/skills/sable/agents/openai.yaml +1 -1
- package/targets/ios.json +9 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/data-table/data-table.meta.ts
|
|
2
2
|
var dataTableMeta = {
|
|
3
3
|
name: "DataTable",
|
|
4
|
-
summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 none), optional row click / selection, and an empty state. For server data or paging, sort/page the data and pair with Pagination.",
|
|
4
|
+
summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 none), optional row click / selection, rows grouped under collapsible header rows (groupBy), and an empty state. For server data or paging, sort/page the data and pair with Pagination.",
|
|
5
5
|
keywords: [
|
|
6
6
|
"data table",
|
|
7
7
|
"table",
|
|
@@ -10,7 +10,14 @@ var dataTableMeta = {
|
|
|
10
10
|
"rows",
|
|
11
11
|
"empty state",
|
|
12
12
|
"pagination",
|
|
13
|
-
"list"
|
|
13
|
+
"list",
|
|
14
|
+
"grouped",
|
|
15
|
+
"group by",
|
|
16
|
+
"sections",
|
|
17
|
+
"issue list",
|
|
18
|
+
"issue tracker",
|
|
19
|
+
"status groups",
|
|
20
|
+
"collapsible groups"
|
|
14
21
|
],
|
|
15
22
|
usage: {
|
|
16
23
|
description: "Use DataTable when a data array and column definition should render a sortable, semantic table with optional row actions and an empty state. Use the lower-level Table parts for hand-authored markup or highly custom row layouts.",
|
|
@@ -33,7 +40,11 @@ var dataTableMeta = {
|
|
|
33
40
|
},
|
|
34
41
|
{
|
|
35
42
|
guidance: "do",
|
|
36
|
-
description: "
|
|
43
|
+
description: "Group rows by status for an issue tracker (or by owner, by day) with groupBy + groups on ONE DataTable: every group shares the header and the column widths, each header row carries the group's count and an add action, and sorting applies within each group. Do not stack one DataTable per group inside Collapsibles; the header repeats and the columns stop lining up."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
guidance: "do",
|
|
47
|
+
description: "Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, nested or aggregated grouping, or virtualised rows. DataTable stays dependency-free and does not grow into a data grid; `sable docs tables-with-tanstack` maps one onto the other."
|
|
37
48
|
},
|
|
38
49
|
{
|
|
39
50
|
guidance: "dont",
|
|
@@ -60,6 +71,11 @@ var dataTableMeta = {
|
|
|
60
71
|
required: false,
|
|
61
72
|
description: "Content spanning all columns when the row array is empty."
|
|
62
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "Group header row",
|
|
76
|
+
required: false,
|
|
77
|
+
description: "With groupBy: one row per group heading its own tbody (a th with scope=rowgroup across every column) holding the disclosure toggle, the group's icon, label and count, and an optional trailing action."
|
|
78
|
+
},
|
|
63
79
|
{
|
|
64
80
|
name: "Caption",
|
|
65
81
|
required: false,
|
|
@@ -136,6 +152,32 @@ var dataTableMeta = {
|
|
|
136
152
|
name: "caption",
|
|
137
153
|
type: "ReactNode",
|
|
138
154
|
description: "Bottom table caption."
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "groupBy",
|
|
158
|
+
type: "(row) => string",
|
|
159
|
+
description: "Group the rows under header rows by the key this returns (a status, an owner, a day). One header serves every group, so the columns line up across them; sorting applies within each group."
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "groups",
|
|
163
|
+
type: "{ key: string; label?: ReactNode; icon?: ReactNode; action?: ReactNode; defaultExpanded?: boolean }[]",
|
|
164
|
+
description: "Order and presentation of the groups, by key: the header row's label (defaults to the key), a leading icon such as a status mark, a trailing action such as an icon Button that adds a row to the group, and whether it starts open (default true). Groups groupBy returns that are not listed follow, in the order their first row appears in data. A listed group with no rows is not shown."
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "collapsible",
|
|
168
|
+
type: "boolean",
|
|
169
|
+
default: "true",
|
|
170
|
+
description: "Whether each group's header row is a toggle that opens and closes the group. A closed group keeps its header row and count; its rows are hidden and select-all skips them."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "expandedGroups",
|
|
174
|
+
type: "ReadonlySet<string>",
|
|
175
|
+
description: "The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state."
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "onExpandedGroupsChange",
|
|
179
|
+
type: "(keys: Set<string>) => void",
|
|
180
|
+
description: "Called with the new set of open group keys when a group header is toggled."
|
|
139
181
|
}
|
|
140
182
|
],
|
|
141
183
|
tokensUsed: ["muted-foreground", "foreground", "ring"],
|
|
@@ -164,6 +206,18 @@ var dataTableMeta = {
|
|
|
164
206
|
part: "Row",
|
|
165
207
|
why: "A checked row's fill eases in across its cells, so selecting by checkbox reads as the row being picked rather than a band stamped over it."
|
|
166
208
|
},
|
|
209
|
+
{
|
|
210
|
+
slot: "transition",
|
|
211
|
+
recipe: "motion-interactive",
|
|
212
|
+
part: "Group chevron",
|
|
213
|
+
why: "A group's chevron turns from pointing along the reading direction (closed) to down (open), so the group reads as folding rather than a glyph being swapped."
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
slot: "arrival",
|
|
217
|
+
recipe: "none",
|
|
218
|
+
part: "Group rows",
|
|
219
|
+
why: "Opening a group shows its rows at once: table rows cannot animate their height without leaving the table layout, and the rows are the content the reader asked for."
|
|
220
|
+
},
|
|
167
221
|
{
|
|
168
222
|
slot: "activity",
|
|
169
223
|
recipe: "none",
|
|
@@ -174,7 +228,8 @@ var dataTableMeta = {
|
|
|
174
228
|
a11y: [
|
|
175
229
|
"Sorted columns set aria-sort=ascending/descending on the header cell.",
|
|
176
230
|
"Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
|
|
177
|
-
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
|
|
231
|
+
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.",
|
|
232
|
+
'Grouped, each group is its own <tbody> (a rowgroup) headed by a <th scope="rowgroup"> spanning the columns, so group headers are never read as data rows; the toggle is a real button with aria-expanded and aria-controls naming that tbody.'
|
|
178
233
|
],
|
|
179
234
|
examples: [
|
|
180
235
|
{
|
|
@@ -197,6 +252,10 @@ var dataTableMeta = {
|
|
|
197
252
|
label: "In a card, edge to edge",
|
|
198
253
|
code: '<Card><CardHeader><CardTitle>Deployments</CardTitle></CardHeader><CardContent flush><DataTable data={deployments} rowKey={(row) => row.id} columns={[{ key: "project", header: "Project", sortable: true }, { key: "duration", header: "Duration", align: "end" }]} /></CardContent></Card>'
|
|
199
254
|
},
|
|
255
|
+
{
|
|
256
|
+
label: "Issues grouped by status",
|
|
257
|
+
code: 'const STATUSES = [\n { key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },\n { key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },\n { key: "todo", label: "Todo", icon: <StatusDot /> },\n { key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },\n]\n\n<DataTable\n data={issues}\n rowKey={(t) => t.id}\n groupBy={(t) => t.status}\n groups={STATUSES.map((s) => ({ ...s, action: <Button variant="ghost" size="icon-xs" aria-label={`Add issue to ${s.label}`} onClick={() => create(s.key)}><Icon name="plus" /></Button> }))}\n onRowClick={(t) => setSelectedId(t.id)}\n isRowSelected={(t) => t.id === selectedId}\n columns={[\n { key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },\n { key: "title", header: "Issue", sortable: true },\n { key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },\n ]}\n/>'
|
|
258
|
+
},
|
|
200
259
|
{
|
|
201
260
|
label: "Empty state",
|
|
202
261
|
code: '<DataTable data={[]} columns={[{ key: "name", header: "Name" }, { key: "status", header: "Status" }]} empty={<EmptyState icon={<Icon name="search" />} title="No deployments" description="Try a different filter or create a deployment." action={<Button>Create deployment</Button>} />} />'
|
|
@@ -12,7 +12,7 @@ import { useId, useState } from "react";
|
|
|
12
12
|
import { Icon } from "../../icon/index.js";
|
|
13
13
|
import { cn } from "../../lib/cn.js";
|
|
14
14
|
import { useDensity, useTypeTier } from "../../lib/density.js";
|
|
15
|
-
import { FIELD_INVALID, FIELD_SHELL, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
|
|
15
|
+
import { FIELD_INVALID, FIELD_SHELL, FIELD_SHELL_XS, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
|
|
16
16
|
import { Calendar } from "../calendar/calendar.js";
|
|
17
17
|
import { Popover, PopoverContent, PopoverTrigger } from "../popover/popover.js";
|
|
18
18
|
import {
|
|
@@ -56,6 +56,8 @@ function DatePicker(props) {
|
|
|
56
56
|
disabled,
|
|
57
57
|
minDate,
|
|
58
58
|
maxDate,
|
|
59
|
+
size = "md",
|
|
60
|
+
formatOptions,
|
|
59
61
|
className,
|
|
60
62
|
id,
|
|
61
63
|
"aria-label": ariaLabel,
|
|
@@ -102,11 +104,12 @@ function DatePicker(props) {
|
|
|
102
104
|
if (next == null) return;
|
|
103
105
|
commit(setMinutes(timeBase(), Number(next)));
|
|
104
106
|
}
|
|
105
|
-
const label = value ? (
|
|
107
|
+
const label = value ? formatOptions ? new Intl.DateTimeFormat(void 0, formatOptions).format(value) : (
|
|
106
108
|
// 24-hour, matching the hour / minute Selects below — the trigger
|
|
107
109
|
// and the row that sets it must not show the same time two ways.
|
|
108
110
|
format(value, withTime ? "MMM d, yyyy 'at' HH:mm" : "PP")
|
|
109
111
|
) : placeholder ?? (withTime ? "Pick date & time" : "Pick a date");
|
|
112
|
+
const xs = size === "xs";
|
|
110
113
|
return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
|
|
111
114
|
/* @__PURE__ */ jsx(
|
|
112
115
|
PopoverTrigger,
|
|
@@ -130,17 +133,21 @@ function DatePicker(props) {
|
|
|
130
133
|
"aria-required": ariaRequired,
|
|
131
134
|
disabled,
|
|
132
135
|
className: cn(
|
|
133
|
-
"inline-flex
|
|
136
|
+
"inline-flex items-center text-start text-foreground",
|
|
137
|
+
xs ? "h-(--control-xs) gap-1 px-1.5" : "h-(--control-md) gap-2 ps-3 pe-3",
|
|
134
138
|
// Select's type: the density tier, plus the 16px phone tier
|
|
135
|
-
// its form-row neighbours take (lib/field-shell).
|
|
139
|
+
// its form-row neighbours take (lib/field-shell). The xs rung
|
|
140
|
+
// never sits in a page-register form row, so it skips it.
|
|
136
141
|
tier.body,
|
|
137
|
-
density === "comfortable" && MOBILE_FIELD_TIER,
|
|
142
|
+
density === "comfortable" && !xs && MOBILE_FIELD_TIER,
|
|
138
143
|
FIELD_SHELL,
|
|
144
|
+
// After FIELD_SHELL, so its well replaces the outlined field.
|
|
145
|
+
xs && FIELD_SHELL_XS,
|
|
139
146
|
FIELD_INVALID,
|
|
140
147
|
className
|
|
141
148
|
),
|
|
142
149
|
children: [
|
|
143
|
-
/* @__PURE__ */ jsx(Icon, { name: "calendar", className: "shrink-0 text-
|
|
150
|
+
/* @__PURE__ */ jsx(Icon, { name: "calendar", className: cn("shrink-0 text-muted-foreground", xs ? "text-xs" : "text-sm") }),
|
|
144
151
|
/* @__PURE__ */ jsx("span", { className: cn("truncate", !value && "text-muted-foreground"), children: label })
|
|
145
152
|
]
|
|
146
153
|
}
|
|
@@ -5,6 +5,9 @@ var datePickerMeta = {
|
|
|
5
5
|
status: "stable",
|
|
6
6
|
keywords: [
|
|
7
7
|
"date picker",
|
|
8
|
+
"due date",
|
|
9
|
+
"property row",
|
|
10
|
+
"inspector",
|
|
8
11
|
"calendar",
|
|
9
12
|
"date input",
|
|
10
13
|
"date time picker",
|
|
@@ -104,6 +107,17 @@ var datePickerMeta = {
|
|
|
104
107
|
type: "Date",
|
|
105
108
|
description: "Latest selectable day (inclusive). Later days are disabled and navigation is bounded."
|
|
106
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: "size",
|
|
112
|
+
type: '"md" | "xs"',
|
|
113
|
+
default: '"md"',
|
|
114
|
+
description: `The trigger's rung. md is the form field beside Input and Select. xs is the property-grid rung (24px in a workspace): an inset panel-field well with no border, the same shape as SelectTrigger size="xs" and ScrubField on the neighbouring rows of an inspector \u2014 use it for a due date in a PropertyRow instead of a native date input.`
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "formatOptions",
|
|
118
|
+
type: "Intl.DateTimeFormatOptions",
|
|
119
|
+
description: `How the trigger writes the chosen date, as Intl.DateTimeFormat options in the reader's locale \u2014 { month: "short", day: "numeric" } for "Oct 23" where the rest of a list writes dates that way. Omitted: "Oct 23, 2026" (with "at 14:30" under granularity="minute").`
|
|
120
|
+
},
|
|
107
121
|
{
|
|
108
122
|
name: "className",
|
|
109
123
|
type: "string",
|
|
@@ -137,6 +151,14 @@ var datePickerMeta = {
|
|
|
137
151
|
'With granularity="minute" the hour and minute Selects are full Base UI listboxes (typeahead, ARIA) labelled "Hour" and "Minute"; selecting a day keeps the chosen time-of-day, so day and time can be set in any order.'
|
|
138
152
|
],
|
|
139
153
|
examples: [
|
|
154
|
+
{
|
|
155
|
+
label: "In an inspector row",
|
|
156
|
+
code: `<PanelSection title="Properties">
|
|
157
|
+
<PropertyRow label="Due date">
|
|
158
|
+
<DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
|
|
159
|
+
</PropertyRow>
|
|
160
|
+
</PanelSection>`
|
|
161
|
+
},
|
|
140
162
|
{
|
|
141
163
|
label: "Controlled",
|
|
142
164
|
code: "const [date, setDate] = useState<Date>()\n<DatePicker value={date} onValueChange={setDate} />"
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// src/components/dialog/dialog.tsx
|
|
2
2
|
import { AlertDialog as AlertBase } from "@base-ui/react/alert-dialog";
|
|
3
3
|
import { Dialog as Base } from "@base-ui/react/dialog";
|
|
4
|
+
import { useMemo } from "react";
|
|
4
5
|
import { Icon } from "../../icon/index.js";
|
|
5
6
|
import { cn } from "../../lib/cn.js";
|
|
7
|
+
import { FocusReturnProvider, useFocusReturn, useFocusReturnDefault } from "../../lib/focus-return.js";
|
|
6
8
|
import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
|
|
7
9
|
import { PresentationProvider } from "../../lib/presentation.js";
|
|
10
|
+
import { mergeRefs } from "../../lib/refs.js";
|
|
8
11
|
import { Button } from "../button/button.js";
|
|
9
12
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
-
|
|
13
|
+
function Dialog({ open, onOpenChange, ...props }) {
|
|
14
|
+
const focus = useFocusReturn(open, onOpenChange);
|
|
15
|
+
return /* @__PURE__ */ jsx(FocusReturnProvider, { value: focus.focusReturn, children: /* @__PURE__ */ jsx(Base.Root, { ...props, open, onOpenChange: focus.onOpenChange }) });
|
|
16
|
+
}
|
|
11
17
|
var DialogTrigger = Base.Trigger;
|
|
12
18
|
var DialogClose = Base.Close;
|
|
13
19
|
var DialogPortal = withPortalThemeScope(Base.Portal);
|
|
@@ -29,13 +35,22 @@ function DialogContent({
|
|
|
29
35
|
size = "default",
|
|
30
36
|
showCloseButton,
|
|
31
37
|
scroll,
|
|
38
|
+
finalFocus,
|
|
39
|
+
ref,
|
|
32
40
|
...props
|
|
33
41
|
}) {
|
|
34
|
-
|
|
42
|
+
const focusReturn = useFocusReturnDefault();
|
|
43
|
+
const popupRef = useMemo(
|
|
44
|
+
() => focusReturn ? mergeRefs(focusReturn.popupRef, ref) : ref,
|
|
45
|
+
[focusReturn, ref]
|
|
46
|
+
);
|
|
47
|
+
const focus = { ref: popupRef, finalFocus: finalFocus === void 0 ? focusReturn?.finalFocus : finalFocus };
|
|
48
|
+
return size === "fullscreen" ? /* @__PURE__ */ jsx(FullscreenContent, { showCloseButton: showCloseButton ?? true, ...focus, ...props }) : /* @__PURE__ */ jsx(
|
|
35
49
|
CardContent,
|
|
36
50
|
{
|
|
37
51
|
showCloseButton: showCloseButton ?? false,
|
|
38
52
|
scroll: scroll ?? true,
|
|
53
|
+
...focus,
|
|
39
54
|
...props
|
|
40
55
|
}
|
|
41
56
|
);
|
|
@@ -44,9 +59,10 @@ function FullscreenContent({
|
|
|
44
59
|
className,
|
|
45
60
|
children,
|
|
46
61
|
showCloseButton,
|
|
62
|
+
keepMounted,
|
|
47
63
|
...props
|
|
48
64
|
}) {
|
|
49
|
-
return /* @__PURE__ */ jsxs(DialogPortal, { children: [
|
|
65
|
+
return /* @__PURE__ */ jsxs(DialogPortal, { keepMounted, children: [
|
|
50
66
|
/* @__PURE__ */ jsx(DialogOverlay, { size: "fullscreen" }),
|
|
51
67
|
/* @__PURE__ */ jsxs(
|
|
52
68
|
Base.Popup,
|
|
@@ -93,9 +109,10 @@ function CardContent({
|
|
|
93
109
|
children,
|
|
94
110
|
showCloseButton,
|
|
95
111
|
scroll,
|
|
112
|
+
keepMounted,
|
|
96
113
|
...props
|
|
97
114
|
}) {
|
|
98
|
-
return /* @__PURE__ */ jsxs(DialogPortal, { children: [
|
|
115
|
+
return /* @__PURE__ */ jsxs(DialogPortal, { keepMounted, children: [
|
|
99
116
|
/* @__PURE__ */ jsx(DialogOverlay, {}),
|
|
100
117
|
/* @__PURE__ */ jsxs(
|
|
101
118
|
Base.Popup,
|
|
@@ -175,7 +192,10 @@ function DialogFooter({
|
|
|
175
192
|
}
|
|
176
193
|
);
|
|
177
194
|
}
|
|
178
|
-
|
|
195
|
+
function AlertDialog({ open, onOpenChange, ...props }) {
|
|
196
|
+
const focus = useFocusReturn(open, onOpenChange);
|
|
197
|
+
return /* @__PURE__ */ jsx(FocusReturnProvider, { value: focus.focusReturn, children: /* @__PURE__ */ jsx(AlertBase.Root, { ...props, open, onOpenChange: focus.onOpenChange }) });
|
|
198
|
+
}
|
|
179
199
|
var AlertDialogTrigger = AlertBase.Trigger;
|
|
180
200
|
var AlertDialogClose = DialogClose;
|
|
181
201
|
var AlertDialogContent = DialogContent;
|
|
@@ -59,6 +59,18 @@ var dialogMeta = {
|
|
|
59
59
|
description: "Open a dialog for passive status updates. Use Toast, Banner, or InlineStatus instead.",
|
|
60
60
|
instead: ["Toast", "Banner", "InlineStatus"]
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
guidance: "do",
|
|
64
|
+
description: "Open a dialog from your own button with controlled open/onOpenChange when the trigger is not a DialogTrigger (a menu item, a row action, a keyboard shortcut). Focus returns on close to whatever held it when the dialog opened, with no finalFocus wiring; pass finalFocus only to send it somewhere else."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
guidance: "do",
|
|
68
|
+
description: "Set keepMounted on DialogContent for a draft the user may come back to (a compose form, a long comment, a half-filled request). Closing by Esc or a stray click then hides the dialog instead of unmounting it, so the fields are still filled when it reopens. Clear the draft yourself on submit or discard."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
guidance: "dont",
|
|
72
|
+
description: "Set keepMounted by default. A dialog that should open fresh (a confirmation, a create form after a successful save) would show stale input, and live content (polling, video) keeps running while hidden."
|
|
73
|
+
},
|
|
62
74
|
{
|
|
63
75
|
guidance: "do",
|
|
64
76
|
description: 'Use the AlertDialog family for a confirmation that must be answered (discard changes, leave without saving). It looks identical but is role="alertdialog" and a backdrop click does not dismiss it \u2014 only Cancel, Esc, or the action. For irreversible deletes, use DestructiveActionModal, which is built on it.'
|
|
@@ -101,8 +113,11 @@ var dialogMeta = {
|
|
|
101
113
|
{
|
|
102
114
|
name: "Dialog (Root)",
|
|
103
115
|
type: "Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal)",
|
|
104
|
-
description: "Controls open state. Uncontrolled by default."
|
|
105
|
-
}
|
|
116
|
+
description: "Controls open state. Uncontrolled by default. Records the element that holds focus when the dialog opens (through a DialogTrigger, or your own button flipping a controlled open) and returns focus there on close."
|
|
117
|
+
},
|
|
118
|
+
{ name: "open", type: "boolean", description: "Controlled open state. Pair with onOpenChange \u2014 for a dialog opened from your own button instead of a DialogTrigger." },
|
|
119
|
+
{ name: "defaultOpen", type: "boolean", default: "false", description: "Initial open state when uncontrolled." },
|
|
120
|
+
{ name: "onOpenChange", type: "(open: boolean, eventDetails) => void", description: "Called when the dialog asks to open or close (a trigger, Esc, an outside click, DialogClose)." }
|
|
106
121
|
],
|
|
107
122
|
variants: {
|
|
108
123
|
size: ["default", "fullscreen"]
|
|
@@ -110,8 +125,8 @@ var dialogMeta = {
|
|
|
110
125
|
subcomponents: [
|
|
111
126
|
{
|
|
112
127
|
name: "DialogContent",
|
|
113
|
-
type: 'Base UI Popup props + size?: "default" | "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className',
|
|
114
|
-
description: 'Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region.'
|
|
128
|
+
type: 'Base UI Popup props (initialFocus, finalFocus) + size?: "default" | "fullscreen" + showCloseButton?: boolean + scroll?: boolean + keepMounted?: boolean + className',
|
|
129
|
+
description: 'Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region. keepMounted (default false) keeps the content mounted and hidden while closed, so a draft survives close and reopen. finalFocus defaults to the element focused when the dialog opened, trigger or not; an explicit finalFocus (a ref, a function, or false) wins.'
|
|
115
130
|
},
|
|
116
131
|
{
|
|
117
132
|
name: "DialogOverlay",
|
|
@@ -151,7 +166,7 @@ var dialogMeta = {
|
|
|
151
166
|
{
|
|
152
167
|
name: "AlertDialog (Root)",
|
|
153
168
|
type: "Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange)",
|
|
154
|
-
description: 'Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss \u2014 only an AlertDialogClose, an action that sets open to false, or Esc.'
|
|
169
|
+
description: 'Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss \u2014 only an AlertDialogClose, an action that sets open to false, or Esc. Returns focus on close to the element focused when it opened, exactly as Dialog does.'
|
|
155
170
|
},
|
|
156
171
|
{
|
|
157
172
|
name: "AlertDialogTrigger",
|
|
@@ -210,6 +225,8 @@ var dialogMeta = {
|
|
|
210
225
|
a11y: [
|
|
211
226
|
"Base UI provides focus trapping, scroll lock, aria-modal, and labelled-by wiring (DialogTitle).",
|
|
212
227
|
"Content taller than the viewport scrolls inside the dialog (thin scrollbar, contained overscroll) rather than overflowing off screen \u2014 the page behind is scroll-locked, so anything past the edge would be unreachable.",
|
|
228
|
+
"On close, focus returns to the element that held it when the dialog opened, whether a DialogTrigger opened it or your own button set a controlled open. It is captured before the content mounts, so an autoFocus field inside cannot displace it. If that element has left the page, Base UI's default (the trigger, if any) applies; an explicit finalFocus on the content always wins. AlertDialog and Sheet behave the same.",
|
|
229
|
+
"keepMounted hides closed content with the hidden attribute, so a kept draft is out of the accessibility tree and the tab order until the dialog reopens.",
|
|
213
230
|
'Esc and click-outside dismiss by default. AlertDialog keeps Esc but ignores click-outside, and its popup is role="alertdialog".',
|
|
214
231
|
"Animate via data-[starting-style]/data-[ending-style] attributes.",
|
|
215
232
|
'size="fullscreen" has no title chrome, so name the popup yourself: aria-label on DialogContent (or a visually hidden DialogTitle), plus alt text on the media \u2014 the zoomed image is a separate image to assistive technology. Its built-in close button carries aria-label="Close" and clears the safe-area insets (notch, home indicator).'
|
|
@@ -231,6 +248,10 @@ var dialogMeta = {
|
|
|
231
248
|
label: "Settings form",
|
|
232
249
|
code: '<Dialog><DialogTrigger render={<Button variant="secondary">Rename collection</Button>} /><DialogContent showCloseButton><DialogHeader><DialogTitle>Rename collection</DialogTitle><DialogDescription>Update the display name shown to collectors.</DialogDescription></DialogHeader><Form><Field label="Collection name"><Input defaultValue="Midnight Archive" /></Field><DialogFooter><DialogClose render={<Button variant="secondary">Cancel</Button>} /><Button type="submit">Save changes</Button></DialogFooter></Form></DialogContent></Dialog>'
|
|
233
250
|
},
|
|
251
|
+
{
|
|
252
|
+
label: "Opened from your own button, keeping a draft",
|
|
253
|
+
code: 'const [open, setOpen] = useState(false)\n\n<Button variant="secondary" onClick={() => setOpen(true)}>New message</Button>\n<Dialog open={open} onOpenChange={setOpen}>\n <DialogContent keepMounted showCloseButton>\n <DialogHeader>\n <DialogTitle>New message</DialogTitle>\n <DialogDescription>Close it any time. Your draft is here when you come back.</DialogDescription>\n </DialogHeader>\n <Field label="Message"><Textarea /></Field>\n <DialogFooter>\n <DialogClose render={<Button variant="secondary">Close</Button>} />\n <Button onClick={() => setOpen(false)}>Send</Button>\n </DialogFooter>\n </DialogContent>\n</Dialog>'
|
|
254
|
+
},
|
|
234
255
|
{
|
|
235
256
|
label: "Destructive confirmation",
|
|
236
257
|
code: '<AlertDialog><AlertDialogTrigger render={<Button variant="destructive">Delete project</Button>} /><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Delete project?</AlertDialogTitle><AlertDialogDescription>This removes the project and cannot be undone.</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogClose render={<Button variant="secondary">Cancel</Button>} /><Button variant="destructive" onClick={deleteProject}>Delete project</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>'
|
|
@@ -12,6 +12,7 @@ function MediaImage({
|
|
|
12
12
|
seed,
|
|
13
13
|
fallback,
|
|
14
14
|
fit = "cover",
|
|
15
|
+
position,
|
|
15
16
|
srcSet,
|
|
16
17
|
sizes,
|
|
17
18
|
loading = "lazy",
|
|
@@ -42,7 +43,8 @@ function MediaImage({
|
|
|
42
43
|
loading,
|
|
43
44
|
decoding: "async",
|
|
44
45
|
onLoadingStatusChange: (next) => setTracked({ src, status: next }),
|
|
45
|
-
className: cn("size-full motion-fade", fit === "contain" ? "object-contain" : "object-cover")
|
|
46
|
+
className: cn("size-full motion-fade", fit === "contain" ? "object-contain" : "object-cover"),
|
|
47
|
+
style: position != null ? { objectPosition: position } : void 0
|
|
46
48
|
}
|
|
47
49
|
) : null,
|
|
48
50
|
showFallback ? /* @__PURE__ */ jsx("div", { "data-slot": "media-image-fallback", "aria-hidden": true, className: "absolute inset-0", children: fallback ?? /* @__PURE__ */ jsx(GradientArt, { seed: seed ?? (alt || void 0), className: "size-full" }) }) : null
|
|
@@ -30,6 +30,10 @@ var mediaImageMeta = {
|
|
|
30
30
|
guidance: "do",
|
|
31
31
|
description: "Leave `alt` empty when a visible title sits next to the image \u2014 a tile, a row \u2014 and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL."
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
guidance: "do",
|
|
35
|
+
description: 'Set `position` when a cover crop would cut off the subject \u2014 a face high in a portrait, a product at one edge. Store the focus with the image (`"35% 50%"`) rather than re-cropping the file.'
|
|
36
|
+
},
|
|
33
37
|
{
|
|
34
38
|
guidance: "dont",
|
|
35
39
|
description: "Do not hand-write `<img onError={() => setBroken(true)}>`. An image that fails before hydration never fires the handler on a prerendered page; MediaImage preloads off the DOM and catches it."
|
|
@@ -57,6 +61,12 @@ var mediaImageMeta = {
|
|
|
57
61
|
default: '"cover"',
|
|
58
62
|
description: "`cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped)."
|
|
59
63
|
},
|
|
64
|
+
{
|
|
65
|
+
name: "position",
|
|
66
|
+
type: 'CSSProperties["objectPosition"]',
|
|
67
|
+
default: '"center"',
|
|
68
|
+
description: 'Where the image sits in its frame \u2014 CSS `object-position`, the sibling of `fit`. Under `cover` it is the part the crop keeps (`"50% 30%"` for a face, `"bottom"` for a horizon); under `contain` it is the side the letterbox gives way to.'
|
|
69
|
+
},
|
|
60
70
|
{ name: "srcSet", type: "string", description: "Responsive sources, passed to the `<img>`." },
|
|
61
71
|
{ name: "sizes", type: "string", description: "The `sizes` attribute for `srcSet`." },
|
|
62
72
|
{ name: "loading", type: '"lazy" | "eager"', default: '"lazy"', description: "Native lazy loading. Use `eager` for the first row of a grid above the fold." },
|
|
@@ -84,7 +94,11 @@ var mediaImageMeta = {
|
|
|
84
94
|
{ label: "Cover with fallback", code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage src={album.cover} seed={album.title} />\n</AspectBox>' },
|
|
85
95
|
{ label: "No image yet", code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage seed="Low tide, high noon" />\n</AspectBox>' },
|
|
86
96
|
{ label: "Row thumbnail", code: '<MediaImage src={track.art} seed={track.title} className="size-12 shrink-0 rounded-sm" />' },
|
|
87
|
-
{ label: "Logo, never cropped", code: '<AspectBox ratio={16 / 9} className="rounded-lg">\n <MediaImage src={brand.logo} alt={brand.name} fit="contain" />\n</AspectBox>' }
|
|
97
|
+
{ label: "Logo, never cropped", code: '<AspectBox ratio={16 / 9} className="rounded-lg">\n <MediaImage src={brand.logo} alt={brand.name} fit="contain" />\n</AspectBox>' },
|
|
98
|
+
{
|
|
99
|
+
label: "Crop focus per image",
|
|
100
|
+
code: '<AspectBox ratio={1} className="rounded-md">\n <MediaImage src={project.thumbnail} seed={project.title} position={project.focus ?? "center"} />\n</AspectBox>'
|
|
101
|
+
}
|
|
88
102
|
]
|
|
89
103
|
};
|
|
90
104
|
export {
|
|
@@ -34,6 +34,7 @@ function MediaTile({
|
|
|
34
34
|
sizes,
|
|
35
35
|
alt,
|
|
36
36
|
seed,
|
|
37
|
+
position,
|
|
37
38
|
aspect = "square",
|
|
38
39
|
size = "md",
|
|
39
40
|
href,
|
|
@@ -107,7 +108,18 @@ function MediaTile({
|
|
|
107
108
|
"data-slot": "media-tile-cover",
|
|
108
109
|
className: "order-first rounded-(--media-tile-radius)",
|
|
109
110
|
children: [
|
|
110
|
-
/* @__PURE__ */ jsx(
|
|
111
|
+
/* @__PURE__ */ jsx(
|
|
112
|
+
MediaImage,
|
|
113
|
+
{
|
|
114
|
+
src,
|
|
115
|
+
srcSet,
|
|
116
|
+
sizes,
|
|
117
|
+
alt,
|
|
118
|
+
seed: seed ?? titleText,
|
|
119
|
+
position,
|
|
120
|
+
loading
|
|
121
|
+
}
|
|
122
|
+
),
|
|
111
123
|
linked || action != null ? (
|
|
112
124
|
// The tile answers the pointer and the keyboard alike: a light scrim
|
|
113
125
|
// deepens the cover so the action reads over any artwork. Hover only
|
|
@@ -70,6 +70,12 @@ var mediaTileMeta = {
|
|
|
70
70
|
{ name: "sizes", type: "string", description: 'The cover\'s layout width for `srcSet`, e.g. `"(min-width: 64rem) 25vw, 50vw"`.' },
|
|
71
71
|
{ name: "alt", type: "string", default: '""', description: "Cover alt text. Empty by default: the title below already names the item." },
|
|
72
72
|
{ name: "seed", type: "string | number", description: "Seeds the fallback art. Defaults to `title` when it is a string." },
|
|
73
|
+
{
|
|
74
|
+
name: "position",
|
|
75
|
+
type: 'CSSProperties["objectPosition"]',
|
|
76
|
+
default: '"center"',
|
|
77
|
+
description: 'Which part of the cover the crop keeps \u2014 CSS `object-position`, forwarded to the cover\'s MediaImage: `"50% 30%"` for a face, `"top"` for a poster\'s title. Store it with the image when each one frames differently.'
|
|
78
|
+
},
|
|
73
79
|
{
|
|
74
80
|
name: "aspect",
|
|
75
81
|
type: '"square" | "video" | "poster" | number',
|
|
@@ -130,6 +136,10 @@ var mediaTileMeta = {
|
|
|
130
136
|
label: "Gallery, no links",
|
|
131
137
|
code: '<Grid minItemWidth="8rem" gap={2}>\n {photos.map((photo) => (\n <MediaTile key={photo.id} size="sm" title={photo.caption} src={photo.url} alt={photo.description} />\n ))}\n</Grid>'
|
|
132
138
|
},
|
|
139
|
+
{
|
|
140
|
+
label: "Portfolio grid with crop focus",
|
|
141
|
+
code: '<Grid minItemWidth="16rem" gap={4}>\n {projects.map((project) => (\n <MediaTile\n key={project.slug}\n aspect="video"\n title={project.title}\n subtitle={project.client}\n src={project.thumbnail}\n position={project.focus}\n href={`/work/${project.slug}`}\n />\n ))}\n</Grid>'
|
|
142
|
+
},
|
|
133
143
|
{
|
|
134
144
|
label: "Poster shelf",
|
|
135
145
|
code: '<MediaTile aspect="poster" size="lg" titleAs="h3" title="The Long Dark" subtitle="2026 \xB7 Drama" href="/films/the-long-dark" />'
|
|
@@ -131,6 +131,7 @@ function MenuSubmenuTrigger({
|
|
|
131
131
|
className,
|
|
132
132
|
children,
|
|
133
133
|
value,
|
|
134
|
+
icon,
|
|
134
135
|
...props
|
|
135
136
|
}) {
|
|
136
137
|
const tier = useTypeTier();
|
|
@@ -143,6 +144,7 @@ function MenuSubmenuTrigger({
|
|
|
143
144
|
),
|
|
144
145
|
...props,
|
|
145
146
|
children: [
|
|
147
|
+
icon,
|
|
146
148
|
/* @__PURE__ */ jsx("span", { className: "flex-1 truncate text-start", children }),
|
|
147
149
|
value !== void 0 ? /* @__PURE__ */ jsx("span", { className: "truncate text-muted-foreground", children: value }) : null,
|
|
148
150
|
/* @__PURE__ */ jsx(Icon, { name: "chevron-right", className: "text-xs opacity-60" })
|
|
@@ -114,8 +114,8 @@ var menuMeta = {
|
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
name: "MenuSubmenuTrigger",
|
|
117
|
-
type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + className",
|
|
118
|
-
description: 'The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High \u203A" rather than making the reader open it to find out.'
|
|
117
|
+
type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + icon: ReactNode + className",
|
|
118
|
+
description: 'The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High \u203A" rather than making the reader open it to find out. `icon` leads the row as MenuItem\'s does; give it one whenever the items around it have icons, or its label starts a column to their left.'
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
name: "MenuRadioGroup",
|
|
@@ -2,30 +2,35 @@
|
|
|
2
2
|
import { cn } from "../../lib/cn.js";
|
|
3
3
|
import { HeadingRow } from "../../lib/heading-row.js";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
var SIZE = {
|
|
6
|
+
md: { root: "gap-2 pb-4", title: "text-heading-2", description: "text-body-sm" },
|
|
7
|
+
lg: { root: "gap-3 pb-6", title: "text-display-lg", description: "max-w-(--measure-prose) text-body-lg" }
|
|
8
|
+
};
|
|
5
9
|
function PageHeader({
|
|
6
10
|
title,
|
|
7
11
|
description,
|
|
8
12
|
back,
|
|
9
13
|
actions,
|
|
10
14
|
titleAs: Title = "h1",
|
|
15
|
+
size = "md",
|
|
11
16
|
className,
|
|
12
17
|
children,
|
|
13
18
|
...props
|
|
14
19
|
}) {
|
|
15
|
-
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col
|
|
20
|
+
return /* @__PURE__ */ jsxs("div", { "data-size": size, className: cn("flex flex-col", SIZE[size].root, className), ...props, children: [
|
|
16
21
|
/* @__PURE__ */ jsx(
|
|
17
22
|
HeadingRow,
|
|
18
23
|
{
|
|
19
24
|
as: Title,
|
|
20
25
|
title,
|
|
21
|
-
titleClassName:
|
|
22
|
-
band: "flex min-h-(--control-md) items-center",
|
|
26
|
+
titleClassName: SIZE[size].title,
|
|
27
|
+
band: "flex min-h-(--control-md) items-center mt-[min(0px,calc((var(--control-md)_-_1lh)/2))]",
|
|
23
28
|
titleBasis: "basis-[min(100%,20rem)]",
|
|
24
29
|
leading: back,
|
|
25
30
|
actions
|
|
26
31
|
}
|
|
27
32
|
),
|
|
28
|
-
description ? /* @__PURE__ */ jsx("p", { className: "text-
|
|
33
|
+
description ? /* @__PURE__ */ jsx("p", { className: cn(SIZE[size].description, "text-muted-foreground"), children: description }) : null,
|
|
29
34
|
children
|
|
30
35
|
] });
|
|
31
36
|
}
|
|
@@ -7,7 +7,11 @@ var pageHeaderMeta = {
|
|
|
7
7
|
"page header",
|
|
8
8
|
"title",
|
|
9
9
|
"lede",
|
|
10
|
-
"page actions"
|
|
10
|
+
"page actions",
|
|
11
|
+
"page title size",
|
|
12
|
+
"display title",
|
|
13
|
+
"editorial header",
|
|
14
|
+
"hero title"
|
|
11
15
|
],
|
|
12
16
|
usage: {
|
|
13
17
|
description: "Use PageHeader to establish a page's title, supporting description, and primary page actions.",
|
|
@@ -20,6 +24,14 @@ var pageHeaderMeta = {
|
|
|
20
24
|
guidance: "do",
|
|
21
25
|
description: "Keep the title literal and put explanation in the lede or nearby content."
|
|
22
26
|
},
|
|
27
|
+
{
|
|
28
|
+
guidance: "do",
|
|
29
|
+
description: 'Use size="lg" for an editorial page (a personal site, a marketing page, an article index): a display-tier title over a body-lg lede. Pair it with SectionHeader size="lg" for the sections below, so the steps stay display \u2192 heading-2 \u2192 body.'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
guidance: "dont",
|
|
33
|
+
description: 'Use size="lg" on product routes (settings, lists, dashboards). Display type is never product chrome: there it competes with the content it frames. Keep md, and do not restyle the title with a display class through className either.'
|
|
34
|
+
},
|
|
23
35
|
{
|
|
24
36
|
guidance: "dont",
|
|
25
37
|
description: "Do not use PageHeader inside repeated cards, dialogs, or compact panels."
|
|
@@ -64,8 +76,17 @@ var pageHeaderMeta = {
|
|
|
64
76
|
type: '"h1" | "h2"',
|
|
65
77
|
default: '"h1"',
|
|
66
78
|
description: "The title's heading element. Keep h1 \u2014 the route's one h1. Use h2 only when something above already owns the h1: a shell that renders the route title, or a page showing a PageHeader as an example."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "size",
|
|
82
|
+
type: '"md" | "lg"',
|
|
83
|
+
default: '"md"',
|
|
84
|
+
description: "The title's scale. md: text-heading-2 over a text-body-sm description, for product pages. lg: text-display-lg over a text-body-lg lede capped at the prose measure, with more space between and below, for an editorial page (personal site, marketing page, article index). Beside actions or a back control, a title line taller than the controls lifts by half the difference, so its first line stays centred on them."
|
|
67
85
|
}
|
|
68
86
|
],
|
|
87
|
+
variants: {
|
|
88
|
+
size: ["md", "lg"]
|
|
89
|
+
},
|
|
69
90
|
a11y: [
|
|
70
91
|
"Renders the title as an <h1>; use one PageHeader per page.",
|
|
71
92
|
"Its title renders the route's `h1`. Do not render another `h1` elsewhere on the page: heading navigation would report two documents, and the outline a screen-reader user builds from it stops matching the page."
|
|
@@ -76,6 +97,10 @@ var pageHeaderMeta = {
|
|
|
76
97
|
label: "With actions",
|
|
77
98
|
code: '<PageHeader title="Storage credits" description="Fund permanent storage." actions={<Button>Top up</Button>} />'
|
|
78
99
|
},
|
|
100
|
+
{
|
|
101
|
+
label: "Editorial page title (size lg)",
|
|
102
|
+
code: '<PageHeader size="lg" title="Field notes" description="Essays on making things slowly, from a studio by the sea." actions={<Button variant="outline">Subscribe</Button>} />'
|
|
103
|
+
},
|
|
79
104
|
{
|
|
80
105
|
label: "One per page, inside the region",
|
|
81
106
|
code: `// PageHeader introduces the current route: title, optional description, and the
|