@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
package/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sable (@sableui/react)
|
|
2
2
|
|
|
3
|
-
> An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter.
|
|
3
|
+
> An owned, themeable React component system. Default theme: **Sable** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 123 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
|
|
4
4
|
|
|
5
5
|
## Start here — brief, composition, evidence
|
|
6
6
|
|
|
@@ -436,7 +436,10 @@ keyboard/focus behavior, semantic token roles and motion policy. Explain which
|
|
|
436
436
|
behavior a replacement gains and what must remain. An unused page pattern is not
|
|
437
437
|
drift; repeated overrides can indicate a missing system hook rather than app misuse.
|
|
438
438
|
Run relevant `sable doctor`, `sable lint <paths> --json` and `sable grade <paths>
|
|
439
|
-
--json` checks.
|
|
439
|
+
--json` checks. Grade's `rebuiltParts` lists elements whose class names stand in
|
|
440
|
+
for a part (`.crumb` for Breadcrumbs, `.property` for PropertyRow) and app CSS
|
|
441
|
+
that re-lays a component from outside; `modes` lists colours set for light with no
|
|
442
|
+
dark value, and whether anything lets a user switch modes. Report each. Lint is lexical; source grades and pattern similarity do not judge
|
|
440
443
|
taste. Deliberately static motion slots remain valid with appropriate feedback.
|
|
441
444
|
Lint's `label-contrast` measures an app's overrides of the brand fills (`--primary`,
|
|
442
445
|
`--sidebar-primary`, `--highlight`) and status fills (`--destructive`, `--success`,
|
|
@@ -445,6 +448,37 @@ names the nearest pair that reads. Report it with that pair as a recommendation.
|
|
|
445
448
|
A reasoned `design-lint-disable-next-line label-contrast` is the app's recorded
|
|
446
449
|
choice: list it as intentional, not as a defect.
|
|
447
450
|
|
|
451
|
+
## Adoption: an app wired for Sable that barely uses it
|
|
452
|
+
|
|
453
|
+
The commonest drift is not a wrong prop. It is an app where an agent never
|
|
454
|
+
loaded this skill: the stylesheet and tokens are wired, and every control is the
|
|
455
|
+
app's own (a `Modal`, a `PrimaryButton`, a `Tabs` written from scratch). Wiring
|
|
456
|
+
checks pass, and lint only sees the raw elements. `sable check` (and `sable
|
|
457
|
+
grade --json` → `adoption`) measures it: how many UI files compose Sable, which
|
|
458
|
+
app-defined components are named for something Sable ships and do the behaviour
|
|
459
|
+
by hand, and what the app's foundation is. `sable doctor` warns when adoption is
|
|
460
|
+
`none` or `low`. It is never a failure: whether working screens move is the
|
|
461
|
+
user's call.
|
|
462
|
+
|
|
463
|
+
Put the choice to the user before changing anything. Ask, don't migrate silently
|
|
464
|
+
and don't leave it unmentioned. Show the swaps, behaviour first (overlays,
|
|
465
|
+
controls, tabs, menus, anything with focus or keyboard handling), with what each
|
|
466
|
+
one gains ("your Modal has no focus trap or Esc; Sable's Dialog has both").
|
|
467
|
+
Layout and look stay the app's. How strongly to recommend it depends on the
|
|
468
|
+
`foundation` the report names:
|
|
469
|
+
|
|
470
|
+
| Foundation | What to say |
|
|
471
|
+
|---|---|
|
|
472
|
+
| `ad-hoc`: no system besides Sable | Recommend the move plainly. There is nothing to preserve but one-off components, and every one is a behaviour bug waiting. Offer to do it a screen at a time. |
|
|
473
|
+
| `own`: a folder of the app's own primitives | Offer the swaps and say what each gains. Keep the app's own where the user prefers them; suggest building them on Sable's behaviour (a `Button` wrapper around Sable's `Button`) instead. |
|
|
474
|
+
| `library`: another component library (MUI, Radix, shadcn-style `components/ui`) | Ask whether Sable should replace it or only cover new work. Don't mix two control libraries on one screen. |
|
|
475
|
+
| `sable`: Sable already carries the UI | Mention the few swaps listed when those screens are next touched. |
|
|
476
|
+
|
|
477
|
+
Use the question tool the host offers when there is one. Record the answer in
|
|
478
|
+
the project's instructions, so the next task doesn't ask again. On a yes, move
|
|
479
|
+
one screen or component at a time with `fix`/`polish`, running `sable check` and
|
|
480
|
+
looking at the rendered states after each.
|
|
481
|
+
|
|
448
482
|
## Deliver evidence-backed findings
|
|
449
483
|
|
|
450
484
|
Save the audit under `design/<surface>/audit/` or the existing review location.
|
|
@@ -775,8 +809,9 @@ For the full screen (header, grouping, avatars, answer actions, the empty state)
|
|
|
775
809
|
|
|
776
810
|
Marketing, docs, settings, a feed, a dashboard — anything whose primary
|
|
777
811
|
interaction is reading and navigating. This is the default register; if you are
|
|
778
|
-
building an editor
|
|
779
|
-
|
|
812
|
+
building an editor, a canvas app, or a records workspace where people work
|
|
813
|
+
through items with the selected one open beside the list (an issue tracker, a
|
|
814
|
+
support queue), see [building-a-tool.md](./building-a-tool.md).
|
|
780
815
|
|
|
781
816
|
## Start from the brief, then choose composition
|
|
782
817
|
|
|
@@ -956,12 +991,16 @@ source checks alone says nothing about hierarchy, story, or the resulting polish
|
|
|
956
991
|
|
|
957
992
|
# Task: building a tool (workspace register)
|
|
958
993
|
|
|
959
|
-
An editor, a canvas app, a console
|
|
960
|
-
|
|
994
|
+
An editor, a canvas app, a console, or a records workspace (an issue tracker, a
|
|
995
|
+
support queue, a pipeline): anything whose primary interaction is *operating on
|
|
996
|
+
something* rather than reading and navigating.
|
|
961
997
|
|
|
962
998
|
If the primary interaction is reading down a page, stop: you want
|
|
963
999
|
[building-a-page.md](./building-a-page.md). A dashboard that scrolls is a page
|
|
964
|
-
with dense content, not a workspace.
|
|
1000
|
+
with dense content, not a workspace. A tracker or inbox where people work
|
|
1001
|
+
through records one at a time, with the selected one open beside the list, is a
|
|
1002
|
+
workspace. Start that one from the `TrackerPage` pattern
|
|
1003
|
+
(`sable pattern TrackerPage --skeleton`) and read "A records workspace" below.
|
|
965
1004
|
|
|
966
1005
|
## The shell
|
|
967
1006
|
|
|
@@ -985,12 +1024,88 @@ hierarchy do not automatically transfer to dense operating surfaces.
|
|
|
985
1024
|
</Workspace>
|
|
986
1025
|
```
|
|
987
1026
|
|
|
1027
|
+
Every slot is optional; the body is always the one growing row. A bar that
|
|
1028
|
+
belongs to one column (a list's breadcrumbs and view switch, with the inspector
|
|
1029
|
+
running the full height beside it, as in Linear) is a `WorkspaceBar` placed first
|
|
1030
|
+
inside that column, not the `toolbar` slot.
|
|
1031
|
+
|
|
988
1032
|
`Workspace` sets the compact density scope for its whole subtree. Nothing
|
|
989
1033
|
hardcodes a height: every control reads `--control-*`, so the same
|
|
990
1034
|
`<Button size="md">` is 40px on a page and 32px here. A `Dialog` opened from
|
|
991
1035
|
inside resets to comfortable, because a modal is a page-register surface even
|
|
992
1036
|
when a tool launched it.
|
|
993
1037
|
|
|
1038
|
+
## A records workspace
|
|
1039
|
+
|
|
1040
|
+
The shape of an issue tracker, a support queue or a CRM pipeline. Every piece
|
|
1041
|
+
below exists, so the build is composition; `sable pattern TrackerPage
|
|
1042
|
+
--skeleton` has the whole tree.
|
|
1043
|
+
|
|
1044
|
+
```tsx
|
|
1045
|
+
<Workspace statusBar={<StatusBar>…</StatusBar>} bottomNav={<BottomNav>…</BottomNav>}>
|
|
1046
|
+
<PanelGroup>
|
|
1047
|
+
<Panel label="Navigation" width={220} narrow="sheet" side="left"
|
|
1048
|
+
open={narrow ? navOpen : true} onOpenChange={setNavOpen} scroll={false}>
|
|
1049
|
+
<Sidebar variant="inline">
|
|
1050
|
+
<SidebarHeader><SidebarBrand mark={…} label="Studio" />{search}</SidebarHeader>
|
|
1051
|
+
<SidebarNav>
|
|
1052
|
+
<SidebarItem icon={…} label="Inbox" badge={…} />
|
|
1053
|
+
<SidebarGroup label="Workspace">{items}</SidebarGroup>
|
|
1054
|
+
</SidebarNav>
|
|
1055
|
+
<SidebarFooter>
|
|
1056
|
+
<Menu>
|
|
1057
|
+
<MenuTrigger render={<SidebarAccount avatar={<Avatar size="2xs" … />} label="Emma Lee" detail="Studio" />} />
|
|
1058
|
+
<MenuContent side="top" align="start">{settings, appearance, sign out}</MenuContent>
|
|
1059
|
+
</Menu>
|
|
1060
|
+
</SidebarFooter>
|
|
1061
|
+
</Sidebar>
|
|
1062
|
+
</Panel>
|
|
1063
|
+
<WorkspaceCanvas className="flex flex-col">
|
|
1064
|
+
<WorkspaceBar><Breadcrumbs>…</Breadcrumbs></WorkspaceBar>
|
|
1065
|
+
<WorkspaceBar><Tabs><TabsList variant="pill">…</TabsList></Tabs></WorkspaceBar>
|
|
1066
|
+
{filters}
|
|
1067
|
+
<DataTable groupBy={(r) => r.status} groups={…} isRowSelected={…} onRowClick={…} />
|
|
1068
|
+
</WorkspaceCanvas>
|
|
1069
|
+
<Panel label="Details" width={320} narrow="sheet" open={selected != null}
|
|
1070
|
+
onOpenChange={(o) => !o && setSelected(null)} scroll={false}>
|
|
1071
|
+
<PanelSection title="Properties"><PropertyRow label="Status">…</PropertyRow></PanelSection>
|
|
1072
|
+
</Panel>
|
|
1073
|
+
</PanelGroup>
|
|
1074
|
+
</Workspace>
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
The decisions that keep it one system:
|
|
1078
|
+
|
|
1079
|
+
- **One grid down the rail.** SidebarBrand, SidebarItem, SidebarGroup and
|
|
1080
|
+
SidebarAccount share the icon and label columns, and inside the compact scope
|
|
1081
|
+
they take 32px rows on their own. Do not drop SideNav, an Entity or a stack of
|
|
1082
|
+
ghost Buttons into the rail: each brings its own insets.
|
|
1083
|
+
- **The account is one row and a menu.** Settings, appearance (light, dark,
|
|
1084
|
+
system) and sign-out go in the SidebarAccount's menu, not in a Settings button
|
|
1085
|
+
above it or a badge beside it. Its detail line ("Studio · Demo") carries what a
|
|
1086
|
+
badge would.
|
|
1087
|
+
- **One table, grouped.** Rows grouped by state are one `DataTable` with
|
|
1088
|
+
`groupBy`: one header, columns that line up, a count and an add action per
|
|
1089
|
+
group. Not one table per group inside Collapsibles.
|
|
1090
|
+
- **The inspector renders once.** `Panel narrow="sheet"` docks it on a desktop
|
|
1091
|
+
and makes it a Sheet below `lg`. Not a media-query hook plus a second copy in a
|
|
1092
|
+
Sheet. The nav rail does the same from the left, and fills its sheet.
|
|
1093
|
+
- **Reading inside the tool is page-sized.** An overview, a description, notes or
|
|
1094
|
+
a docs tab goes in `<Density value="comfortable">`, or its prose renders at the
|
|
1095
|
+
11px chrome tier.
|
|
1096
|
+
- **A view switch in chrome is a pill.** `TabsList variant="pill"`: its fill takes
|
|
1097
|
+
the compact `--button-radius`, about 5px, so it reads as a segment. Underline
|
|
1098
|
+
tabs are the page-section idiom.
|
|
1099
|
+
- **Ship both modes, and a way to switch.** The look is a preset with light and
|
|
1100
|
+
dark; SableThemeProvider applies it, and the account menu or a Settings dialog
|
|
1101
|
+
holds the mode switch. A tool asked to be compact and technical takes
|
|
1102
|
+
`type-base` (13 or 14) in the preset, which rebuilds the whole type scale,
|
|
1103
|
+
instead of per-tier overrides.
|
|
1104
|
+
- **State has a shape.** A status with more than on and off (partly done, in
|
|
1105
|
+
review) can be an app-owned glyph, but give it a label (`role="img"`,
|
|
1106
|
+
`aria-label`) and colour it from the palette (`var(--yellow-500)`) so it
|
|
1107
|
+
follows the mode.
|
|
1108
|
+
|
|
994
1109
|
## What you will need, and what to avoid reaching for
|
|
995
1110
|
|
|
996
1111
|
| you want | use | NOT |
|
|
@@ -1002,6 +1117,17 @@ when a tool launched it.
|
|
|
1002
1117
|
| a colour property | `ColorField` | `<input type="color">` — OS dialog, ignores the theme, wrong size |
|
|
1003
1118
|
| ambient document facts | `StatusBar` | a toolbar — status is glanced at, not operated |
|
|
1004
1119
|
| chrome typography | `text-ui-md` / `-sm` / `-body` / `-section` / `-micro` / `-num` | `text-body-*` — page tiers, ~1/3 too large here |
|
|
1120
|
+
| a title in a panel | `text-title-lg` (14px `text-ui-title` in compact) | a heading tier, or a page-tier override on `:root` |
|
|
1121
|
+
| a top bar over one column | `WorkspaceBar` first in the column, with `Breadcrumbs` | a hand-built `<header>` with `/` spans |
|
|
1122
|
+
| nav groups in a rail | `SidebarGroup` | `SideNav` + `SideNavGroup` — another set of columns |
|
|
1123
|
+
| the account at the foot of a rail | `SidebarAccount` as a `MenuTrigger` | an Entity + a Settings Button + a Badge |
|
|
1124
|
+
| a view switch in a bar | `TabsList variant="pill"` | underline tabs (page idiom), a hand-built segmented row |
|
|
1125
|
+
| rows grouped by state | `DataTable groupBy` + `groups` | a DataTable per group in Collapsibles |
|
|
1126
|
+
| a date property | `DatePicker size="xs"` | `<input type="date">` — OS format, wrong size |
|
|
1127
|
+
| a person in a row | `Avatar size="2xs"` / `"xs"`, `AvatarGroup` for several | a class on Avatar to shrink it |
|
|
1128
|
+
| a project or workspace mark | `Avatar shape="square" tone="…"` | a tinted `<span>` |
|
|
1129
|
+
| an inspector on a phone | `Panel narrow="sheet"` | a media-query hook and a second render in a Sheet |
|
|
1130
|
+
| a reading tab | `<Density value="comfortable">` | prose in chrome tiers |
|
|
1005
1131
|
|
|
1006
1132
|
## Menus, selects and popovers adapt on their own
|
|
1007
1133
|
|
|
@@ -1060,9 +1186,12 @@ a modal is its own surface rather than part of the control that opened it.
|
|
|
1060
1186
|
the nesting reads inverted. Use `treeIndent(depth)`; never hand-write an inset.
|
|
1061
1187
|
- **No pills in chrome.** The compact scope flattens `--button-radius` to ~5px —
|
|
1062
1188
|
one of two page-register rules the workspace register deliberately reverses.
|
|
1189
|
+
`TabsList variant="pill"` takes that radius too, so in a bar it is a segment.
|
|
1063
1190
|
- **Drag with window listeners, not `setPointerCapture`.** Capture throws for an
|
|
1064
1191
|
inactive pointer id and the throw aborts the handler mid-gesture, silently.
|
|
1065
|
-
- **An unmapped `<Icon name>` renders nothing.**
|
|
1192
|
+
- **An unmapped `<Icon name>` renders nothing.** A development build warns and
|
|
1193
|
+
names the nearest names, and Lucide or Heroicons spellings resolve, but check
|
|
1194
|
+
with `sable icon <name>` before you write one, through a wrapper too.
|
|
1066
1195
|
|
|
1067
1196
|
## Verify
|
|
1068
1197
|
|
|
@@ -1092,8 +1221,10 @@ and distinguish observed behavior from untested claims.
|
|
|
1092
1221
|
## Read next
|
|
1093
1222
|
|
|
1094
1223
|
- `DESIGN.md → Registers` — doctrine, measured numbers, the two reversals
|
|
1224
|
+
- `sable pattern TrackerPage --skeleton` — the records workspace, whole
|
|
1095
1225
|
- `llms/components/workspace.md`, `panel-group.md`, `panel-section.md`,
|
|
1096
|
-
`
|
|
1226
|
+
`sidebar.md`, `data-table.md`, `tree.md`, `scrub-field.md`, `status-bar.md`,
|
|
1227
|
+
`color-field.md`
|
|
1097
1228
|
- `llms/reference/token-intent.md` — which token to reach for
|
|
1098
1229
|
|
|
1099
1230
|
---
|
|
@@ -1259,7 +1390,13 @@ for each recognizable element with `sable search "<need>" --json`; inspect the
|
|
|
1259
1390
|
matching `component <Name> --props` and `--examples` before implementation.
|
|
1260
1391
|
Use Sable's composed anatomy and supported variants. A hand-built button, field,
|
|
1261
1392
|
menu, dialog, table, or card that imitates a shipped component violates the contract.
|
|
1262
|
-
That binds behaviour and parts, not layouts.
|
|
1393
|
+
That binds behaviour and parts, not layouts. Search a part by its plain name and
|
|
1394
|
+
its role before writing a class or component for it (`sable search breadcrumb`,
|
|
1395
|
+
`sable search "top bar"`); exported parts such as `PropertyRow` and `WorkspaceBar`
|
|
1396
|
+
are listed by name, and `sable component <Part>` answers with their entry. When a
|
|
1397
|
+
part's look or size disagrees with the design, use its variant or prop, or report
|
|
1398
|
+
the missing variant; never rebuild it. `sable icon <name>` says whether an icon
|
|
1399
|
+
exists. Results marked ◇ (patterns, blocks,
|
|
1263
1400
|
and compositions such as MediaTile) are references: use one when its layout fits
|
|
1264
1401
|
the brief, otherwise compose your own from Sable's parts. The brief's signature
|
|
1265
1402
|
surfaces, where the product's identity lives, are designed from its direction.
|
|
@@ -1278,8 +1415,10 @@ implement genuinely app-specific structure without creating parallel controls.
|
|
|
1278
1415
|
|
|
1279
1416
|
## Finish the working product
|
|
1280
1417
|
|
|
1281
|
-
Run `sable
|
|
1282
|
-
checks.
|
|
1418
|
+
Run `sable check <source paths>` (doctor, lint and grade) and the project's
|
|
1419
|
+
build/type checks. Answer each Rebuilt parts note in the reuse inventory, and fix
|
|
1420
|
+
a colour-modes warning: app colours live in a preset with both modes, and a
|
|
1421
|
+
`ThemeSwitcher` under `SableThemeProvider` lets the user review each. Inspect desktop/mobile layouts, operate the primary task and recovery
|
|
1283
1422
|
path, and check keyboard focus, fonts, reduced motion, and meaningful states.
|
|
1284
1423
|
Use [reviewing-a-design.md](./reviewing-a-design.md),
|
|
1285
1424
|
[reviewing-a-journey.md](./reviewing-a-journey.md), and
|
|
@@ -1590,6 +1729,15 @@ are optional: set `page-inset` and the `region-*` caps (`form`, `feed`, `content
|
|
|
1590
1729
|
`wide`, `full`) in `tokens.shared` only when the direction calls for a different
|
|
1591
1730
|
page proportion, and keep each cap's role.
|
|
1592
1731
|
|
|
1732
|
+
Overall type size is one token. Sable's scale is built on a 16px base;
|
|
1733
|
+
`"type-base": "14"` (or 13) in `tokens.shared` rebuilds every page tier at that
|
|
1734
|
+
base, keeping their ratios, and spacing follows because the rhythm is derived
|
|
1735
|
+
from body size. Use it when the direction is denser or more technical than the
|
|
1736
|
+
bundled presets (a tracker, a console, "like Linear"). Don't rescale the root
|
|
1737
|
+
font-size, and don't override tiers one by one to the same end. Retune a single
|
|
1738
|
+
tier only when that tier's role changes. Inside a Workspace the chrome already
|
|
1739
|
+
uses its own compact `ui-*` tiers.
|
|
1740
|
+
|
|
1593
1741
|
```bash
|
|
1594
1742
|
pnpm exec sable preset --template
|
|
1595
1743
|
pnpm exec sable preset --check .sable/presets/clay.json --json
|
|
@@ -2274,15 +2422,22 @@ In a monorepo where Sable belongs to one workspace package, run setup in that
|
|
|
2274
2422
|
package with `--root` naming the repository root (for example
|
|
2275
2423
|
`pnpm --filter web exec sable setup --agent --root ../..`); the entries go
|
|
2276
2424
|
there and resolve Sable through the package.
|
|
2425
|
+
Setup also adds a short managed block to the project's `AGENTS.md` and
|
|
2426
|
+
`CLAUDE.md`, the instructions an agent reads on every task, so a UI request
|
|
2427
|
+
goes through the skill even when it doesn't mention Sable. Only the text between
|
|
2428
|
+
its `<!-- sable:begin -->` and `<!-- sable:end -->` markers is Sable's;
|
|
2429
|
+
`--no-instructions` skips it.
|
|
2277
2430
|
Each entry resolves `skills/sable/SKILL.md` from the installed package, so package
|
|
2278
2431
|
upgrades also update the workflow. Other agents can read that file directly.
|
|
2279
2432
|
Use `$sable build <idea>`, `inspire <references>`, `audit <target>`, or
|
|
2280
2433
|
`polish <target>` in Codex; use `/sable` with the same modes in Claude Code or
|
|
2281
2434
|
Cursor. `create` is a build alias. These are agent modes, not shell commands.
|
|
2282
2435
|
|
|
2283
|
-
Upgrade with `pnpm
|
|
2284
|
-
|
|
2285
|
-
|
|
2436
|
+
Upgrade with `pnpm exec sable upgrade`: it updates the package, then runs the
|
|
2437
|
+
new version's `init`, which refreshes the skill entries and the instructions
|
|
2438
|
+
block and checks the wiring. From 0.17 or earlier, which has no `upgrade`, run
|
|
2439
|
+
`pnpm update @sableui/react --latest` and then `pnpm exec sable init` once.
|
|
2440
|
+
Setup only replaces managed files that have not been locally edited; `--dry-run` checks without writing. Keep
|
|
2286
2441
|
project preferences in project instructions. Package installation alone does not
|
|
2287
2442
|
change agent settings. See [building-with-sable.md](./building-with-sable.md),
|
|
2288
2443
|
[creating-a-preset.md](./creating-a-preset.md), and
|
|
@@ -3382,8 +3537,7 @@ saved file. Replace, don't add beside:
|
|
|
3382
3537
|
## Upgrade the package and skill
|
|
3383
3538
|
|
|
3384
3539
|
```bash
|
|
3385
|
-
pnpm
|
|
3386
|
-
pnpm exec sable setup --agent --update
|
|
3540
|
+
pnpm exec sable upgrade
|
|
3387
3541
|
pnpm exec sable preset --check .sable/presets/clay.json --json
|
|
3388
3542
|
pnpm exec sable doctor
|
|
3389
3543
|
```
|
|
@@ -3542,7 +3696,9 @@ Every tier belongs to one family, and the family is the decision. Size follows f
|
|
|
3542
3696
|
6. **Cap reading text at --measure-prose (65ch).** Past roughly 75 characters the eye loses the line return. A wide region can still hold a 65ch column.
|
|
3543
3697
|
7. **Numbers that must align use a mono tier (tabular figures).** Proportional digits change width as values update, so totals and columns shimmy.
|
|
3544
3698
|
8. **text-wrap: balance on headings, pretty on ledes, neither on long prose.** balance evens short headlines; pretty only refuses a one-word last line. Both cost layout work that long prose does not repay.
|
|
3545
|
-
9. **
|
|
3699
|
+
9. **To make all the type smaller or larger, set the base: --type-base: 14 (a unitless px number; 16 is the default). Never retune tiers one by one for it.** Every tier is authored on a 16px base and keeps its ratio to it, so one number rebuilds the whole scale, headings to captions, and the baseline step and spacing follow the body. Shrinking tiers individually breaks the ratios between them. Set it on :root, a theme scope or a preset (tokens.shared["type-base"]) so the rhythm follows too.
|
|
3700
|
+
10. **Inside a Workspace, a panel's title is text-ui-title (14px · 600); compact text-title-lg resolves to it. A reading view inside the tool (an overview, a document, a long form) is wrapped in <Density value="comfortable"> and gets the page tiers back.** The compact register remaps page tiers onto the 10-12px chrome ramp, which is right for chrome and wrong for prose: an issue overview set in a tool rendered its paragraphs at 11px. The remap stops at a comfortable boundary, so the wrapper is the whole fix.
|
|
3701
|
+
11. **A mark attached to a line of type (a dot, a bullet, a timeline marker, a leading icon) sits in a box one line tall (h-[1lh] items-center) and is sized in em.** 1lh is the line-height of the text beside it, so the mark centres on that line in every tier, density and theme. A px size with a mt-[3px] nudge is right for one font at one size and wrong everywhere else.
|
|
3546
3702
|
|
|
3547
3703
|
## Never
|
|
3548
3704
|
|
|
@@ -3551,6 +3707,7 @@ Every tier belongs to one family, and the family is the decision. Size follows f
|
|
|
3551
3707
|
- **Never:** Uppercase in product chrome: nav and menu group labels, page eyebrows, table headers, section titles. **Instead:** Sentence-case text-caption or text-label-md in muted-foreground; hierarchy from size, weight and space. Tracked capitals spread across a UI read as shouting and slow word recognition. The eyebrow tier (label-xs / overline) is an editorial accent: at most one per marketing view.
|
|
3552
3708
|
- **Never:** Display tiers inside product UI, or two display sizes in one view. **Instead:** heading-1 for a page title; mono-xl for a metric. Display is type as image. In chrome it competes with the content it is supposed to frame.
|
|
3553
3709
|
- **Never:** Justified text, or reading copy below body-sm. **Instead:** Ragged-right at body-md; body-xs only for dense data. Justification opens rivers on the web, which has no hyphenation control; 12px prose fails people with low vision.
|
|
3710
|
+
- **Never:** Changing the root font-size (html { font-size: 14px }) to make a product denser. **Instead:** --type-base: 14, with Workspace or <Density value="compact"> for denser controls. The root also sizes every rem the app wrote: widths, insets, media queries. --type-base moves only the type and the rhythm built on it, and density moves the controls, so each can be chosen on its own.
|
|
3554
3711
|
- **Never:** A new font family, or raw font-feature-settings / font-variation-settings. **Instead:** The theme's sans, mono and optional display; font-variant-numeric and font-weight. Raw axis tags silently do nothing on a fallback font, and every theme declares a fallback stack.
|
|
3555
3712
|
|
|
3556
3713
|
## Details
|
|
@@ -5113,7 +5270,7 @@ App-shell admin dashboard: a branded Sidebar with an account footer (AppHeader +
|
|
|
5113
5270
|
## Source
|
|
5114
5271
|
|
|
5115
5272
|
```tsx
|
|
5116
|
-
import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge,
|
|
5273
|
+
import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Meter, NotificationBadge, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
|
|
5117
5274
|
|
|
5118
5275
|
// Account status → Badge tone. Sable has no status vocabulary of its own;
|
|
5119
5276
|
// an app maps ITS states onto tones once, here, and every badge follows.
|
|
@@ -5225,20 +5382,23 @@ export function DashboardPage() {
|
|
|
5225
5382
|
<SidebarItem icon={<Icon name="gear" />} label="Settings" render={<a href="/settings" />} />
|
|
5226
5383
|
</SidebarNav>
|
|
5227
5384
|
<SidebarFooter>
|
|
5228
|
-
<
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
</
|
|
5240
|
-
|
|
5241
|
-
|
|
5385
|
+
<Menu>
|
|
5386
|
+
<MenuTrigger
|
|
5387
|
+
render={
|
|
5388
|
+
<SidebarAccount
|
|
5389
|
+
avatar={<Avatar variant="art" size="xs" fallback="AL" alt="" />}
|
|
5390
|
+
label="Ada Lovelace"
|
|
5391
|
+
detail="@ada"
|
|
5392
|
+
/>
|
|
5393
|
+
}
|
|
5394
|
+
/>
|
|
5395
|
+
<MenuContent side="top" align="start">
|
|
5396
|
+
<MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
|
|
5397
|
+
<MenuItem render={<a href="/settings" />}>Settings</MenuItem>
|
|
5398
|
+
<MenuSeparator />
|
|
5399
|
+
<MenuItem>Sign out</MenuItem>
|
|
5400
|
+
</MenuContent>
|
|
5401
|
+
</Menu>
|
|
5242
5402
|
</SidebarFooter>
|
|
5243
5403
|
</Sidebar>
|
|
5244
5404
|
}
|
|
@@ -7009,6 +7169,459 @@ export function SettingsPage() {
|
|
|
7009
7169
|
|
|
7010
7170
|
---
|
|
7011
7171
|
|
|
7172
|
+
# TrackerPage
|
|
7173
|
+
|
|
7174
|
+
Records workspace in the tool register: a Sidebar rail of grouped views ending in an account menu, a WorkspaceBar of Breadcrumbs over pill Tabs, a filter row, one grouped DataTable, and a docked inspector of PanelSection property rows with a pinned comment form. Below lg the rail and the inspector become sheets and a BottomNav takes the last row.
|
|
7175
|
+
|
|
7176
|
+
**Intent:** Reach for this when a team works through records in a dense tool — an issue tracker, a support queue, a pipeline: a grouped list, a docked inspector for the selected record, and a rail of views. For records a person reads and edits one at a time on a page, ListDetailPage is the page-register shape; for monitoring with headline numbers, DashboardPage.
|
|
7177
|
+
|
|
7178
|
+
## Notes
|
|
7179
|
+
|
|
7180
|
+
- Workspace sets the compact density for everything inside: 32px rail rows, 28px tables, 24px property fields. Nothing in the pattern sizes a control or a row by hand. The Overview tab is a reading view, so it sits in `<Density value="comfortable">` and its prose gets page type back.
|
|
7181
|
+
- The rail is Sable's own parts on one grid: SidebarBrand, SidebarGroup, SidebarItem and SidebarAccount share the icon and label columns. The account row is the trigger of the account Menu, which holds Settings, an Appearance submenu wired to useSableTheme (light, dark, system) and Sign out. No Settings button or badge sits in the footer.
|
|
7182
|
+
- There is no Workspace `toolbar`: the bars belong to the list column, so the inspector runs the full height beside them. The first WorkspaceBar carries Breadcrumbs; the second carries pill Tabs, a view switch in chrome, which sits beside the bar's other controls rather than drawing a rule.
|
|
7183
|
+
- One DataTable groups the rows (`groupBy`, `groups`): one header for every group, a StatusDot, a count and an add action per group header, and Done starting closed. The segmented ToggleGroup switches the grouping between status and assignee. Filtering to nothing swaps the table for an EmptyState with a Clear filters action.
|
|
7184
|
+
- Workflow states map onto StatusDot tones once (`STATUSES`), so group headers and the status Select read from one place. A product with a richer status glyph keeps that one mapping and swaps the icon.
|
|
7185
|
+
- The inspector is a Panel with `narrow="sheet"`: docked at 320px from lg, a right sheet below it, its content rendered once. Below lg it opens only when a row is picked (`inspecting`), so a phone never loads with a sheet over the list. Properties are PanelSection + PropertyRow with xs Select triggers and an xs DatePicker; Activity uses RelativeTime; the comment Form is pinned under the scrolling body.
|
|
7186
|
+
- The rail is a Panel with `narrow="sheet" side="left"`, opened from a `lg:hidden` menu button in the first bar; in the sheet the Sidebar fills its width. Workspace `bottomNav` reserves the BottomNav's row below lg, so the status bar ends above it.
|
|
7187
|
+
|
|
7188
|
+
## Accessibility
|
|
7189
|
+
|
|
7190
|
+
- Workspace renders a skip link to the canvas (`Skip to issues`) and WorkspaceCanvas is the main landmark; the rail and the inspector are labelled complementary regions (`Workspace navigation`, `Issue details`), and as sheets they keep those labels as their dialog titles.
|
|
7191
|
+
- The page has one level-1 heading (visually hidden; the breadcrumb shows the place) and the inspector's title is a level-2 heading.
|
|
7192
|
+
- Group header rows are real buttons with aria-expanded; rows open the inspector from the keyboard (DataTable's row activation). Every icon-only action has an accessible name and a tooltip.
|
|
7193
|
+
- Each SidebarGroup is a named group; the account row names the person, and its menu is a standard Menu with a radio group for the colour mode.
|
|
7194
|
+
|
|
7195
|
+
## Source
|
|
7196
|
+
|
|
7197
|
+
```tsx
|
|
7198
|
+
import { useState } from "react"
|
|
7199
|
+
import { Avatar, Badge, BottomNav, BottomNavItem, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, Breadcrumbs, BreadcrumbSeparator, Button, DataTable, DatePicker, Density, EmptyState, Form, Icon, Kbd, Menu, MenuContent, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuSubmenu, MenuSubmenuTrigger, MenuTrigger, Panel, PanelGroup, PanelSection, PropertyRow, RelativeTime, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarGroup, SidebarHeader, SidebarItem, SidebarNav, SimpleTooltip, StatusBar, StatusBarGroup, StatusDot, Tabs, TabsList, TabsPanel, TabsTrigger, Textarea, Toggle, ToggleGroup, useBelowBreakpoint, useSableTheme, Workspace, WorkspaceBar, WorkspaceCanvas, type DataTableColumn } from "@sableui/react"
|
|
7200
|
+
|
|
7201
|
+
// Workflow states → StatusDot tones, mapped once here so every group header,
|
|
7202
|
+
// row and select follows. Sable has no status vocabulary of its own.
|
|
7203
|
+
const STATUSES = [
|
|
7204
|
+
{ key: "progress", label: "In progress", tone: "warning" },
|
|
7205
|
+
{ key: "review", label: "In review", tone: "2" },
|
|
7206
|
+
{ key: "todo", label: "Todo", tone: "neutral" },
|
|
7207
|
+
{ key: "done", label: "Done", tone: "success" },
|
|
7208
|
+
] as const
|
|
7209
|
+
type Status = (typeof STATUSES)[number]["key"]
|
|
7210
|
+
|
|
7211
|
+
const PRIORITIES = ["Urgent", "High", "Medium", "Low"] as const
|
|
7212
|
+
type Priority = (typeof PRIORITIES)[number]
|
|
7213
|
+
|
|
7214
|
+
const people: Record<string, string> = {
|
|
7215
|
+
AK: "Ama Kusi",
|
|
7216
|
+
JR: "Jonas Reiter",
|
|
7217
|
+
LM: "Lena Moreau",
|
|
7218
|
+
TO: "Tomás Ortega",
|
|
7219
|
+
}
|
|
7220
|
+
|
|
7221
|
+
const MIN = 60 * 1000
|
|
7222
|
+
const HOUR = 60 * MIN
|
|
7223
|
+
const DAY = 24 * HOUR
|
|
7224
|
+
const isoDay = (offset: number) => {
|
|
7225
|
+
const d = new Date(Date.now() + offset)
|
|
7226
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
7227
|
+
}
|
|
7228
|
+
|
|
7229
|
+
type Note = { id: string; who: string; text: string; at: number }
|
|
7230
|
+
type Issue = {
|
|
7231
|
+
id: string
|
|
7232
|
+
title: string
|
|
7233
|
+
detail: string
|
|
7234
|
+
status: Status
|
|
7235
|
+
priority: Priority
|
|
7236
|
+
assignee: string
|
|
7237
|
+
label: string
|
|
7238
|
+
due: Date
|
|
7239
|
+
notes: Note[]
|
|
7240
|
+
}
|
|
7241
|
+
|
|
7242
|
+
const initialIssues: Issue[] = [
|
|
7243
|
+
{ id: "FW-104", title: "Sync offline edits when the app reconnects", detail: "Edits made offline are queued but replayed out of order after a reconnect. Replay them by timestamp and surface conflicts.", status: "progress", priority: "Urgent", assignee: "AK", label: "Sync", due: isoDay(2 * DAY), notes: [{ id: "n1", who: "JR", text: "Repro steps are in the test plan. It only happens after a cold start.", at: -3 * HOUR }] },
|
|
7244
|
+
{ id: "FW-105", title: "Onboarding checklist on first launch", detail: "Three steps: connect a calendar, invite a teammate, create a first project.", status: "progress", priority: "Medium", assignee: "LM", label: "Onboarding", due: isoDay(5 * DAY), notes: [] },
|
|
7245
|
+
{ id: "FW-108", title: "Push notification settings per project", detail: "Let people mute a project without muting the workspace.", status: "review", priority: "High", assignee: "TO", label: "Notifications", due: isoDay(1 * DAY), notes: [] },
|
|
7246
|
+
{ id: "FW-110", title: "Crash when attaching a large photo", detail: "Photos over 20 MB crash the uploader on older devices.", status: "review", priority: "Urgent", assignee: "JR", label: "Bug", due: isoDay(-1 * DAY), notes: [] },
|
|
7247
|
+
{ id: "FW-112", title: "Search across archived projects", detail: "Archived projects are left out of search today.", status: "todo", priority: "Low", assignee: "AK", label: "Search", due: isoDay(9 * DAY), notes: [] },
|
|
7248
|
+
{ id: "FW-113", title: "Keyboard shortcuts for the board view", detail: "J and K move between cards; C creates an issue in the focused column.", status: "todo", priority: "Medium", assignee: "LM", label: "Board", due: isoDay(12 * DAY), notes: [] },
|
|
7249
|
+
{ id: "FW-114", title: "Export a project as CSV", detail: "One row per issue, with status, assignee and dates.", status: "todo", priority: "Low", assignee: "TO", label: "Export", due: isoDay(14 * DAY), notes: [] },
|
|
7250
|
+
{ id: "FW-101", title: "Dark mode for the widget", detail: "The home-screen widget ignores the system appearance.", status: "done", priority: "Medium", assignee: "JR", label: "Widget", due: isoDay(-4 * DAY), notes: [] },
|
|
7251
|
+
]
|
|
7252
|
+
|
|
7253
|
+
const MODE_LABEL = { light: "Light", dark: "Dark", system: "System" } as const
|
|
7254
|
+
|
|
7255
|
+
/** One icon-only action with a visible name on hover and focus. */
|
|
7256
|
+
export function TrackerPage() {
|
|
7257
|
+
const [issues, setIssues] = useState(initialIssues)
|
|
7258
|
+
const [selectedId, setSelectedId] = useState<string | null>("FW-104")
|
|
7259
|
+
// Below lg the inspector is a sheet; it opens only when someone picks a row,
|
|
7260
|
+
// so a phone does not load with a sheet over the list.
|
|
7261
|
+
const [inspecting, setInspecting] = useState(false)
|
|
7262
|
+
const [navOpen, setNavOpen] = useState(false)
|
|
7263
|
+
const [query, setQuery] = useState("")
|
|
7264
|
+
const [priority, setPriority] = useState<"all" | Priority>("all")
|
|
7265
|
+
const [groupBy, setGroupBy] = useState<"status" | "assignee">("status")
|
|
7266
|
+
const [tab, setTab] = useState("issues")
|
|
7267
|
+
const [draft, setDraft] = useState("")
|
|
7268
|
+
const narrow = useBelowBreakpoint("lg")
|
|
7269
|
+
const { preference, setPreference } = useSableTheme()
|
|
7270
|
+
|
|
7271
|
+
const needle = query.trim().toLowerCase()
|
|
7272
|
+
const visible = issues.filter(
|
|
7273
|
+
(i) =>
|
|
7274
|
+
(priority === "all" || i.priority === priority) &&
|
|
7275
|
+
`${i.id} ${i.title} ${i.label}`.toLowerCase().includes(needle),
|
|
7276
|
+
)
|
|
7277
|
+
const selected = issues.find((i) => i.id === selectedId) ?? null
|
|
7278
|
+
const done = issues.filter((i) => i.status === "done").length
|
|
7279
|
+
|
|
7280
|
+
function update(id: string, patch: Partial<Issue>) {
|
|
7281
|
+
setIssues((all) => all.map((i) => (i.id === id ? { ...i, ...patch } : i)))
|
|
7282
|
+
}
|
|
7283
|
+
function open(id: string) {
|
|
7284
|
+
setSelectedId(id)
|
|
7285
|
+
setInspecting(true)
|
|
7286
|
+
}
|
|
7287
|
+
function close() {
|
|
7288
|
+
setInspecting(false)
|
|
7289
|
+
if (!narrow) setSelectedId(null)
|
|
7290
|
+
}
|
|
7291
|
+
function addIssue(status: Status = "todo") {
|
|
7292
|
+
const id = `FW-${115 + issues.length}`
|
|
7293
|
+
setIssues((all) => [
|
|
7294
|
+
...all,
|
|
7295
|
+
{ id, title: "New issue", detail: "", status, priority: "Medium", assignee: "AK", label: "Triage", due: isoDay(7 * DAY), notes: [] },
|
|
7296
|
+
])
|
|
7297
|
+
open(id)
|
|
7298
|
+
}
|
|
7299
|
+
function addNote() {
|
|
7300
|
+
if (!selected || !draft.trim()) return
|
|
7301
|
+
update(selected.id, { notes: [...selected.notes, { id: `n${Date.now()}`, who: "AK", text: draft.trim(), at: 0 }] })
|
|
7302
|
+
setDraft("")
|
|
7303
|
+
}
|
|
7304
|
+
|
|
7305
|
+
const columns: DataTableColumn<Issue>[] = [
|
|
7306
|
+
{ key: "id", header: "ID", className: "w-20 whitespace-nowrap", cell: (i) => <span className="text-ui-num text-muted-foreground">{i.id}</span> },
|
|
7307
|
+
{ key: "title", header: "Issue", className: "w-full max-w-0", cell: (i) => <span className="block truncate">{i.title}</span> },
|
|
7308
|
+
{ key: "label", header: "Label", className: "w-28 max-sm:hidden", cell: (i) => <Badge appearance="outline" size="sm">{i.label}</Badge> },
|
|
7309
|
+
{ key: "due", header: "Due", className: "w-20 whitespace-nowrap max-sm:hidden", sortValue: (i) => i.due.getTime(), cell: (i) => <span className="text-muted-foreground">{i.due.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span> },
|
|
7310
|
+
{ key: "assignee", header: <span className="sr-only">Assignee</span>, className: "w-10", cell: (i) => <Avatar size="2xs" fallback={i.assignee} alt={people[i.assignee]} /> },
|
|
7311
|
+
]
|
|
7312
|
+
|
|
7313
|
+
const groups =
|
|
7314
|
+
groupBy === "status"
|
|
7315
|
+
? STATUSES.map((s) => ({
|
|
7316
|
+
key: s.key,
|
|
7317
|
+
label: s.label,
|
|
7318
|
+
icon: <StatusDot tone={s.tone} />,
|
|
7319
|
+
action: <SimpleTooltip content={`Add to ${s.label}`}><Button size="icon-xs" variant="ghost" aria-label={`Add to ${s.label}`} onClick={() => addIssue(s.key)}><Icon name="plus" /></Button></SimpleTooltip>,
|
|
7320
|
+
defaultExpanded: s.key !== "done",
|
|
7321
|
+
}))
|
|
7322
|
+
: Object.entries(people).map(([key, name]) => ({
|
|
7323
|
+
key,
|
|
7324
|
+
label: name,
|
|
7325
|
+
icon: <Avatar size="2xs" fallback={key} alt="" />,
|
|
7326
|
+
}))
|
|
7327
|
+
|
|
7328
|
+
const sidebar = (
|
|
7329
|
+
<Sidebar variant="inline" className="h-full w-full">
|
|
7330
|
+
<SidebarHeader>
|
|
7331
|
+
<SidebarBrand mark={<Avatar shape="square" size="2xs" tone="teal" fallback="F" alt="" />} label="Fieldwork" />
|
|
7332
|
+
<Button variant="outline" size="sm" className="basis-full justify-between">
|
|
7333
|
+
<span className="flex items-center gap-2">
|
|
7334
|
+
<Icon name="search" />
|
|
7335
|
+
Search
|
|
7336
|
+
</span>
|
|
7337
|
+
<Kbd>Ctrl K</Kbd>
|
|
7338
|
+
</Button>
|
|
7339
|
+
</SidebarHeader>
|
|
7340
|
+
<SidebarNav aria-label="Workspace">
|
|
7341
|
+
<SidebarItem icon={<Icon name="inbox" />} label="Inbox" badge={<span className="text-ui-num text-muted-foreground">3</span>} />
|
|
7342
|
+
<SidebarItem icon={<Icon name="circle-check" />} label="My issues" />
|
|
7343
|
+
<SidebarGroup label="Mobile app">
|
|
7344
|
+
<SidebarItem icon={<Icon name="layers" />} label="Issues" active />
|
|
7345
|
+
<SidebarItem icon={<Icon name="refresh" />} label="Cycles" />
|
|
7346
|
+
<SidebarItem icon={<Icon name="archive" />} label="Backlog" />
|
|
7347
|
+
</SidebarGroup>
|
|
7348
|
+
<SidebarGroup label="Workspace">
|
|
7349
|
+
<SidebarItem icon={<Icon name="folder" />} label="Projects" />
|
|
7350
|
+
<SidebarItem icon={<Icon name="users" />} label="Members" />
|
|
7351
|
+
</SidebarGroup>
|
|
7352
|
+
</SidebarNav>
|
|
7353
|
+
<SidebarFooter>
|
|
7354
|
+
<Menu>
|
|
7355
|
+
<MenuTrigger
|
|
7356
|
+
render={<SidebarAccount avatar={<Avatar size="2xs" fallback="AK" alt="" />} label="Ama Kusi" detail="Fieldwork · Admin" />}
|
|
7357
|
+
/>
|
|
7358
|
+
<MenuContent side="top" align="start">
|
|
7359
|
+
<MenuItem icon={<Icon name="gear" />}>Settings</MenuItem>
|
|
7360
|
+
<MenuSubmenu>
|
|
7361
|
+
<MenuSubmenuTrigger
|
|
7362
|
+
icon={<Icon name={preference === "dark" ? "moon" : preference === "light" ? "sun" : "desktop"} />}
|
|
7363
|
+
value={MODE_LABEL[preference]}
|
|
7364
|
+
>
|
|
7365
|
+
Appearance
|
|
7366
|
+
</MenuSubmenuTrigger>
|
|
7367
|
+
<MenuContent side="inline-end">
|
|
7368
|
+
<MenuRadioGroup value={preference} onValueChange={(v) => setPreference(v as typeof preference)}>
|
|
7369
|
+
<MenuRadioItem value="light">Light</MenuRadioItem>
|
|
7370
|
+
<MenuRadioItem value="dark">Dark</MenuRadioItem>
|
|
7371
|
+
<MenuRadioItem value="system">System</MenuRadioItem>
|
|
7372
|
+
</MenuRadioGroup>
|
|
7373
|
+
</MenuContent>
|
|
7374
|
+
</MenuSubmenu>
|
|
7375
|
+
<MenuSeparator />
|
|
7376
|
+
<MenuItem icon={<Icon name="logout" />}>Sign out</MenuItem>
|
|
7377
|
+
</MenuContent>
|
|
7378
|
+
</Menu>
|
|
7379
|
+
</SidebarFooter>
|
|
7380
|
+
</Sidebar>
|
|
7381
|
+
)
|
|
7382
|
+
|
|
7383
|
+
const list =
|
|
7384
|
+
visible.length === 0 ? (
|
|
7385
|
+
<EmptyState
|
|
7386
|
+
icon={<Icon name="search" />}
|
|
7387
|
+
title="No issues match"
|
|
7388
|
+
description="Try another search, or clear the priority filter."
|
|
7389
|
+
action={
|
|
7390
|
+
<Button
|
|
7391
|
+
variant="secondary"
|
|
7392
|
+
size="sm"
|
|
7393
|
+
onClick={() => {
|
|
7394
|
+
setQuery("")
|
|
7395
|
+
setPriority("all")
|
|
7396
|
+
}}
|
|
7397
|
+
>
|
|
7398
|
+
Clear filters
|
|
7399
|
+
</Button>
|
|
7400
|
+
}
|
|
7401
|
+
/>
|
|
7402
|
+
) : (
|
|
7403
|
+
<DataTable
|
|
7404
|
+
columns={columns}
|
|
7405
|
+
data={visible}
|
|
7406
|
+
rowKey={(i) => i.id}
|
|
7407
|
+
groupBy={(i) => (groupBy === "status" ? i.status : i.assignee)}
|
|
7408
|
+
groups={groups}
|
|
7409
|
+
onRowClick={(i) => open(i.id)}
|
|
7410
|
+
// A phone shows the selection only while its sheet is open.
|
|
7411
|
+
isRowSelected={(i) => i.id === selectedId && (!narrow || inspecting)}
|
|
7412
|
+
/>
|
|
7413
|
+
)
|
|
7414
|
+
|
|
7415
|
+
const inspector = selected ? (
|
|
7416
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
7417
|
+
<WorkspaceBar className="justify-between">
|
|
7418
|
+
<span className="text-ui-num text-muted-foreground">{selected.id}</span>
|
|
7419
|
+
<SimpleTooltip content="Close issue"><Button size="icon-xs" variant="ghost" aria-label="Close issue" onClick={close}><Icon name="xmark" /></Button></SimpleTooltip>
|
|
7420
|
+
</WorkspaceBar>
|
|
7421
|
+
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
|
|
7422
|
+
<div className="flex flex-col gap-2 px-2 pt-4 pb-3">
|
|
7423
|
+
<h2 className="text-title-lg">{selected.title}</h2>
|
|
7424
|
+
<p className="text-ui-body text-muted-foreground">{selected.detail || "Add the details the team needs."}</p>
|
|
7425
|
+
</div>
|
|
7426
|
+
<PanelSection title="Properties">
|
|
7427
|
+
<PropertyRow label="Status">
|
|
7428
|
+
<Select value={selected.status} items={STATUSES.map((s) => ({ value: s.key, label: s.label }))} onValueChange={(v) => v && update(selected.id, { status: v as Status })}>
|
|
7429
|
+
<SelectTrigger size="xs" aria-label="Status" />
|
|
7430
|
+
<SelectContent>
|
|
7431
|
+
{STATUSES.map((s) => (
|
|
7432
|
+
<SelectItem key={s.key} value={s.key}>{s.label}</SelectItem>
|
|
7433
|
+
))}
|
|
7434
|
+
</SelectContent>
|
|
7435
|
+
</Select>
|
|
7436
|
+
</PropertyRow>
|
|
7437
|
+
<PropertyRow label="Priority">
|
|
7438
|
+
<Select value={selected.priority} onValueChange={(v) => v && update(selected.id, { priority: v as Priority })}>
|
|
7439
|
+
<SelectTrigger size="xs" aria-label="Priority" />
|
|
7440
|
+
<SelectContent>
|
|
7441
|
+
{PRIORITIES.map((p) => (
|
|
7442
|
+
<SelectItem key={p} value={p}>{p}</SelectItem>
|
|
7443
|
+
))}
|
|
7444
|
+
</SelectContent>
|
|
7445
|
+
</Select>
|
|
7446
|
+
</PropertyRow>
|
|
7447
|
+
<PropertyRow label="Assignee">
|
|
7448
|
+
<Select value={selected.assignee} items={people} onValueChange={(v) => v && update(selected.id, { assignee: v })}>
|
|
7449
|
+
<SelectTrigger size="xs" aria-label="Assignee" />
|
|
7450
|
+
<SelectContent>
|
|
7451
|
+
{Object.entries(people).map(([key, name]) => (
|
|
7452
|
+
<SelectItem key={key} value={key}>{name}</SelectItem>
|
|
7453
|
+
))}
|
|
7454
|
+
</SelectContent>
|
|
7455
|
+
</Select>
|
|
7456
|
+
</PropertyRow>
|
|
7457
|
+
<PropertyRow label="Due date">
|
|
7458
|
+
<DatePicker
|
|
7459
|
+
size="xs"
|
|
7460
|
+
aria-label="Due date"
|
|
7461
|
+
value={selected.due}
|
|
7462
|
+
formatOptions={{ month: "short", day: "numeric", year: "numeric" }}
|
|
7463
|
+
onValueChange={(d) => d && update(selected.id, { due: d })}
|
|
7464
|
+
/>
|
|
7465
|
+
</PropertyRow>
|
|
7466
|
+
</PanelSection>
|
|
7467
|
+
<PanelSection title="Activity">
|
|
7468
|
+
<div className="flex flex-col gap-3 pb-4">
|
|
7469
|
+
<div className="flex items-center gap-2 text-ui-sm text-muted-foreground">
|
|
7470
|
+
<Icon name="plus" />
|
|
7471
|
+
<span>Created in Mobile app</span>
|
|
7472
|
+
</div>
|
|
7473
|
+
{selected.notes.map((n) => (
|
|
7474
|
+
<div key={n.id} className="flex items-start gap-2">
|
|
7475
|
+
<Avatar size="2xs" fallback={n.who} alt="" />
|
|
7476
|
+
<div className="min-w-0">
|
|
7477
|
+
<div className="flex items-center gap-2">
|
|
7478
|
+
<span className="text-ui-md">{people[n.who]}</span>
|
|
7479
|
+
<RelativeTime date={new Date(Date.now() + n.at)} className="text-ui-sm text-muted-foreground" />
|
|
7480
|
+
</div>
|
|
7481
|
+
<p className="mt-1 text-ui-body">{n.text}</p>
|
|
7482
|
+
</div>
|
|
7483
|
+
</div>
|
|
7484
|
+
))}
|
|
7485
|
+
</div>
|
|
7486
|
+
</PanelSection>
|
|
7487
|
+
</div>
|
|
7488
|
+
<Form
|
|
7489
|
+
className="flex shrink-0 flex-col gap-2 border-t border-hairline p-2"
|
|
7490
|
+
onSubmit={(e) => {
|
|
7491
|
+
e.preventDefault()
|
|
7492
|
+
addNote()
|
|
7493
|
+
}}
|
|
7494
|
+
>
|
|
7495
|
+
<Textarea aria-label="Add a comment" placeholder="Leave a comment…" rows={2} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
7496
|
+
<Button size="xs" variant="secondary" type="submit" className="self-end" disabled={!draft.trim()} iconStart={<Icon name="arrow-up" />}>
|
|
7497
|
+
Comment
|
|
7498
|
+
</Button>
|
|
7499
|
+
</Form>
|
|
7500
|
+
</div>
|
|
7501
|
+
) : null
|
|
7502
|
+
|
|
7503
|
+
return (
|
|
7504
|
+
<Workspace
|
|
7505
|
+
skipLinkLabel="Skip to issues"
|
|
7506
|
+
statusBar={
|
|
7507
|
+
<StatusBar>
|
|
7508
|
+
<span className="flex items-center gap-2">
|
|
7509
|
+
<StatusDot tone="success" size="sm" />
|
|
7510
|
+
Saved
|
|
7511
|
+
</span>
|
|
7512
|
+
<span className="max-sm:hidden">
|
|
7513
|
+
{issues.length} issues · {done} done
|
|
7514
|
+
</span>
|
|
7515
|
+
<StatusBarGroup align="end">
|
|
7516
|
+
<span className="max-sm:hidden">Ctrl K to search</span>
|
|
7517
|
+
</StatusBarGroup>
|
|
7518
|
+
</StatusBar>
|
|
7519
|
+
}
|
|
7520
|
+
bottomNav={
|
|
7521
|
+
<BottomNav aria-label="Primary">
|
|
7522
|
+
<BottomNavItem icon={<Icon name="inbox" />} label="Inbox" showLabel />
|
|
7523
|
+
<BottomNavItem icon={<Icon name="layers" />} label="Issues" showLabel active />
|
|
7524
|
+
<BottomNavItem icon={<Icon name="folder" />} label="Projects" showLabel />
|
|
7525
|
+
<BottomNavItem icon={<Icon name="users" />} label="Members" showLabel />
|
|
7526
|
+
</BottomNav>
|
|
7527
|
+
}
|
|
7528
|
+
>
|
|
7529
|
+
<PanelGroup>
|
|
7530
|
+
{/* Docked from lg; below it the same rail is a left sheet. */}
|
|
7531
|
+
<Panel label="Workspace navigation" width={232} narrow="sheet" side="left" open={narrow ? navOpen : true} onOpenChange={setNavOpen} scroll={false}>
|
|
7532
|
+
{sidebar}
|
|
7533
|
+
</Panel>
|
|
7534
|
+
<WorkspaceCanvas className="flex flex-col">
|
|
7535
|
+
<WorkspaceBar className="gap-2 px-3">
|
|
7536
|
+
<span className="lg:hidden">
|
|
7537
|
+
<SimpleTooltip content="Open navigation"><Button size="icon-xs" variant="ghost" aria-label="Open navigation" onClick={() => setNavOpen(true)}><Icon name="bars" /></Button></SimpleTooltip>
|
|
7538
|
+
</span>
|
|
7539
|
+
<h1 className="sr-only">Mobile app issues</h1>
|
|
7540
|
+
<Breadcrumbs className="min-w-0">
|
|
7541
|
+
<BreadcrumbItem className="max-sm:hidden">
|
|
7542
|
+
<BreadcrumbLink href="#">Fieldwork</BreadcrumbLink>
|
|
7543
|
+
</BreadcrumbItem>
|
|
7544
|
+
<BreadcrumbSeparator className="max-sm:hidden" />
|
|
7545
|
+
<BreadcrumbItem>
|
|
7546
|
+
<BreadcrumbPage>Mobile app</BreadcrumbPage>
|
|
7547
|
+
</BreadcrumbItem>
|
|
7548
|
+
</Breadcrumbs>
|
|
7549
|
+
</WorkspaceBar>
|
|
7550
|
+
<Tabs value={tab} onValueChange={(v) => setTab(String(v))} className="flex min-h-0 flex-1 flex-col">
|
|
7551
|
+
{/* A view switch in tool chrome: pill, beside the bar's other controls. */}
|
|
7552
|
+
<WorkspaceBar className="px-3">
|
|
7553
|
+
<TabsList variant="pill">
|
|
7554
|
+
<TabsTrigger value="issues">
|
|
7555
|
+
Issues <span className="text-ui-num text-muted-foreground">{issues.length}</span>
|
|
7556
|
+
</TabsTrigger>
|
|
7557
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
7558
|
+
</TabsList>
|
|
7559
|
+
</WorkspaceBar>
|
|
7560
|
+
<TabsPanel value="issues" className="flex min-h-0 flex-1 flex-col">
|
|
7561
|
+
<div className="flex flex-wrap items-center gap-2 border-b border-hairline px-3 py-2">
|
|
7562
|
+
<Menu>
|
|
7563
|
+
<MenuTrigger render={<Button variant="ghost" size="xs" iconStart={<Icon name="filter" />}>{priority === "all" ? "Filter" : priority}</Button>} />
|
|
7564
|
+
<MenuContent>
|
|
7565
|
+
<MenuRadioGroup value={priority} onValueChange={(v) => setPriority(v as typeof priority)}>
|
|
7566
|
+
<MenuRadioItem value="all">All priorities</MenuRadioItem>
|
|
7567
|
+
{PRIORITIES.map((p) => (
|
|
7568
|
+
<MenuRadioItem key={p} value={p}>{p}</MenuRadioItem>
|
|
7569
|
+
))}
|
|
7570
|
+
</MenuRadioGroup>
|
|
7571
|
+
</MenuContent>
|
|
7572
|
+
</Menu>
|
|
7573
|
+
<SearchInput size="xs" value={query} onValueChange={setQuery} placeholder="Find issue…" aria-label="Find issue" className="w-40" />
|
|
7574
|
+
<ToggleGroup appearance="segmented" value={[groupBy]} onValueChange={(v) => v[0] && setGroupBy(v[0] as typeof groupBy)} aria-label="Group by" className="ms-auto">
|
|
7575
|
+
<Toggle value="status">Status</Toggle>
|
|
7576
|
+
<Toggle value="assignee">Assignee</Toggle>
|
|
7577
|
+
</ToggleGroup>
|
|
7578
|
+
<Button size="xs" iconStart={<Icon name="plus" />} onClick={() => addIssue()}>
|
|
7579
|
+
New issue
|
|
7580
|
+
</Button>
|
|
7581
|
+
</div>
|
|
7582
|
+
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">{list}</div>
|
|
7583
|
+
</TabsPanel>
|
|
7584
|
+
<TabsPanel value="overview" className="flex min-h-0 flex-1 flex-col">
|
|
7585
|
+
{/* A reading view: page type comes back inside the compact workspace. */}
|
|
7586
|
+
<Density value="comfortable" className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
|
|
7587
|
+
<article className="flex max-w-prose flex-col gap-4 p-8 max-sm:p-5">
|
|
7588
|
+
<h2 className="text-heading-3">Mobile app</h2>
|
|
7589
|
+
<p className="text-body-md text-muted-foreground">
|
|
7590
|
+
The phone app for field teams: capture work on site, sync it when the signal comes back, and hand it to the office without retyping anything.
|
|
7591
|
+
</p>
|
|
7592
|
+
<dl className="sable-list-dl">
|
|
7593
|
+
<dt>Lead</dt>
|
|
7594
|
+
<dd>{people.AK}</dd>
|
|
7595
|
+
<dt>Open issues</dt>
|
|
7596
|
+
<dd>{issues.length - done}</dd>
|
|
7597
|
+
<dt>Target</dt>
|
|
7598
|
+
<dd>{isoDay(30 * DAY).toLocaleDateString("en-US", { month: "long", day: "numeric" })}</dd>
|
|
7599
|
+
</dl>
|
|
7600
|
+
</article>
|
|
7601
|
+
</Density>
|
|
7602
|
+
</TabsPanel>
|
|
7603
|
+
</Tabs>
|
|
7604
|
+
</WorkspaceCanvas>
|
|
7605
|
+
{/* Docked beside the list from lg; a sheet below it. Its content renders once. */}
|
|
7606
|
+
<Panel
|
|
7607
|
+
label="Issue details"
|
|
7608
|
+
width={320}
|
|
7609
|
+
narrow="sheet"
|
|
7610
|
+
open={narrow ? inspecting && selected !== null : selected !== null}
|
|
7611
|
+
onOpenChange={(next) => !next && close()}
|
|
7612
|
+
scroll={false}
|
|
7613
|
+
className="border-s border-hairline"
|
|
7614
|
+
>
|
|
7615
|
+
{inspector}
|
|
7616
|
+
</Panel>
|
|
7617
|
+
</PanelGroup>
|
|
7618
|
+
</Workspace>
|
|
7619
|
+
)
|
|
7620
|
+
}
|
|
7621
|
+
```
|
|
7622
|
+
|
|
7623
|
+
---
|
|
7624
|
+
|
|
7012
7625
|
# ApiKeysBlock
|
|
7013
7626
|
|
|
7014
7627
|
An API keys card: each key's name with its masked hint in Code, when it was created and last used, and an OverflowMenu to rename, roll or revoke.
|
|
@@ -10317,7 +10930,7 @@ export function WalletBlock({ className, framed = true, onSend, onReceive }: Wal
|
|
|
10317
10930
|
|
|
10318
10931
|
**Status:** stable
|
|
10319
10932
|
|
|
10320
|
-
The page region: fluid, edge-to-edge with the fluid page inset (--page-inset). Opt-in centered max cap picked by ROLE (prose/form/feed/content/wide/full), never a global page max-width. The root of nearly every route.
|
|
10933
|
+
The page region: fluid, edge-to-edge with the fluid page inset (--page-inset). Opt-in centered max cap picked by ROLE (prose/form/feed/content/wide/full), never a global page max-width. The root of nearly every route. The inset and cap are classes (px-(--page-inset), max-w-…), so className overrides them at a breakpoint (max-sm:px-0); only safeArea padding is inline.
|
|
10321
10934
|
|
|
10322
10935
|
**Keywords:** region, page width, container, layout
|
|
10323
10936
|
|
|
@@ -10424,7 +11037,7 @@ import { Card, CardContent, Page, PageHeader } from "@sableui/react"
|
|
|
10424
11037
|
|
|
10425
11038
|
**Status:** stable
|
|
10426
11039
|
|
|
10427
|
-
CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track="fill"), so one result stays one cell wide; track="fit" stretches a fixed set across the row.
|
|
11040
|
+
CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track="fill"), so one result stays one cell wide; track="fit" stretches a fixed set across the row. display is the grid class and the gap a CSS variable, so className utilities win at any breakpoint (hidden md:grid, md:gap-6, lg:grid-cols-3).
|
|
10428
11041
|
|
|
10429
11042
|
**Keywords:** grid, card grid, auto fit, layout
|
|
10430
11043
|
|
|
@@ -10495,7 +11108,7 @@ import { Badge, Grid } from "@sableui/react"
|
|
|
10495
11108
|
|
|
10496
11109
|
**Status:** stable
|
|
10497
11110
|
|
|
10498
|
-
An explicit N-column track (default 12) for proportioned layouts — pair with Col spans (list-detail, asymmetric editorial layouts). For uniform card grids prefer Grid minItemWidth.
|
|
11111
|
+
An explicit N-column track (default 12) for proportioned layouts — pair with Col spans (list-detail, asymmetric editorial layouts). For uniform card grids prefer Grid minItemWidth. display is the grid class, so hidden md:grid works; a gap-* utility replaces both gaps and the column-gap cap.
|
|
10499
11112
|
|
|
10500
11113
|
**Keywords:** columns, 12 column, span, layout
|
|
10501
11114
|
|
|
@@ -10549,7 +11162,7 @@ import { Card, CardHeader, CardTitle, Col, Columns } from "@sableui/react"
|
|
|
10549
11162
|
|
|
10550
11163
|
**Status:** stable
|
|
10551
11164
|
|
|
10552
|
-
Vertical flow on the 8px rhythm — the default container for stacked content. Gap by role: tight within a group (1.5–2), looser between groups (3–4), generous between sections (6+).
|
|
11165
|
+
Vertical flow on the 8px rhythm — the default container for stacked content. Gap by role: tight within a group (1.5–2), looser between groups (3–4), generous between sections (6+). Layout lives in classes (flex flex-col items-* justify-*) and the gap in a CSS variable, never inline style, so className utilities override any prop at a breakpoint (max-lg:hidden, md:flex-row, md:gap-8).
|
|
10553
11166
|
|
|
10554
11167
|
**Keywords:** stack, vertical rhythm, layout, spacing
|
|
10555
11168
|
|
|
@@ -10591,7 +11204,7 @@ import { Field, Input, Stack, Textarea } from "@sableui/react"
|
|
|
10591
11204
|
|
|
10592
11205
|
**Status:** stable
|
|
10593
11206
|
|
|
10594
|
-
Horizontal flex flow — inline rows of controls, toolbars, action clusters. Defaults to center-aligned, no wrap.
|
|
11207
|
+
Horizontal flex flow — inline rows of controls, toolbars, action clusters. Defaults to center-aligned, no wrap. Layout lives in classes (flex flex-row items-* justify-*) and the gap in a CSS variable, never inline style, so className utilities override any prop at a breakpoint (hidden md:flex, max-md:flex-col, md:gap-6).
|
|
10595
11208
|
|
|
10596
11209
|
**Keywords:** row, toolbar, horizontal, layout
|
|
10597
11210
|
|
|
@@ -10628,13 +11241,21 @@ import { Button, Row } from "@sableui/react"
|
|
|
10628
11241
|
<Row gap={2} justify="end"><Button variant="ghost">Cancel</Button><Button>Save changes</Button></Row>
|
|
10629
11242
|
```
|
|
10630
11243
|
|
|
11244
|
+
### Responsive through className
|
|
11245
|
+
|
|
11246
|
+
```tsx
|
|
11247
|
+
import { Button, Row } from "@sableui/react"
|
|
11248
|
+
|
|
11249
|
+
<Row justify="between"><span className="text-title-sm">Acme</span><Row gap={1} className="max-md:hidden"><Button variant="ghost" size="sm">Docs</Button><Button variant="ghost" size="sm">Pricing</Button></Row></Row>
|
|
11250
|
+
```
|
|
11251
|
+
|
|
10631
11252
|
---
|
|
10632
11253
|
|
|
10633
11254
|
# Spacer
|
|
10634
11255
|
|
|
10635
11256
|
**Status:** stable
|
|
10636
11257
|
|
|
10637
|
-
Explicit space on the rhythm, or a flexible grow pusher between siblings (e.g. pushing a footer item to the end of a Row).
|
|
11258
|
+
Explicit space on the rhythm, or a flexible grow pusher between siblings (e.g. pushing a footer item to the end of a Row). The size is a class reading a CSS variable, so className resizes or hides it at a breakpoint.
|
|
10638
11259
|
|
|
10639
11260
|
**Keywords:** spacer, spacing, grow, layout
|
|
10640
11261
|
|
|
@@ -10676,7 +11297,7 @@ import { Button, Row, Spacer } from "@sableui/react"
|
|
|
10676
11297
|
|
|
10677
11298
|
**Status:** stable
|
|
10678
11299
|
|
|
10679
|
-
Horizontally center a block, optionally capping its width (commonly "prose" for a reading column inside a wider layout).
|
|
11300
|
+
Horizontally center a block, optionally capping its width (commonly "prose" for a reading column inside a wider layout). Centering and cap are classes, so className overrides them at a breakpoint.
|
|
10680
11301
|
|
|
10681
11302
|
**Keywords:** center, measure, reading width, layout
|
|
10682
11303
|
|
|
@@ -10716,7 +11337,7 @@ import { Center } from "@sableui/react"
|
|
|
10716
11337
|
|
|
10717
11338
|
**Status:** stable
|
|
10718
11339
|
|
|
10719
|
-
Reserves space via aspect-ratio so async media loads with zero layout shift. Wrap images, video, embeds.
|
|
11340
|
+
Reserves space via aspect-ratio so async media loads with zero layout shift. Wrap images, video, embeds. The ratio is a class reading a CSS variable, so className reshapes it at a breakpoint (md:aspect-video).
|
|
10720
11341
|
|
|
10721
11342
|
**Keywords:** aspect box, aspect ratio, media, layout shift
|
|
10722
11343
|
|
|
@@ -10756,7 +11377,7 @@ import { AspectBox } from "@sableui/react"
|
|
|
10756
11377
|
|
|
10757
11378
|
**Status:** stable
|
|
10758
11379
|
|
|
10759
|
-
Escape a width-capped or inset ancestor to span the full CONTENT area (container-query units, so it stops at a fixed Sidebar instead of bleeding under it). Hero bands, profile covers, full-bleed media inside a capped Page.
|
|
11380
|
+
Escape a width-capped or inset ancestor to span the full CONTENT area (container-query units, so it stops at a fixed Sidebar instead of bleeding under it). Hero bands, profile covers, full-bleed media inside a capped Page. Width and margins are classes, so a breakpoint can turn the bleed off (lg:mx-0 lg:w-auto).
|
|
10760
11381
|
|
|
10761
11382
|
**Keywords:** bleed, full bleed, media band, layout
|
|
10762
11383
|
|
|
@@ -10790,7 +11411,7 @@ import { AspectBox, Bleed, Page, PageHeader } from "@sableui/react"
|
|
|
10790
11411
|
|
|
10791
11412
|
**Status:** stable
|
|
10792
11413
|
|
|
10793
|
-
Responsive two-pane layout: side-by-side at/above a breakpoint, stacked (primary first) below. List-detail, media + metadata.
|
|
11414
|
+
Responsive two-pane layout: side-by-side at/above a breakpoint, stacked (primary first) below. List-detail, media + metadata. display is the grid class and the breakpoint rules are layered, so className utilities (hidden lg:grid, gap-8) win.
|
|
10794
11415
|
|
|
10795
11416
|
**Keywords:** split pane, two pane, list detail, responsive
|
|
10796
11417
|
|
|
@@ -11010,6 +11631,142 @@ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, Icon } from
|
|
|
11010
11631
|
|
|
11011
11632
|
---
|
|
11012
11633
|
|
|
11634
|
+
# ActivityHeatmap
|
|
11635
|
+
|
|
11636
|
+
**Status:** stable
|
|
11637
|
+
|
|
11638
|
+
A calendar heatmap: one value per day as a grid of weeks by weekdays — the GitHub contribution graph, for any daily measure. Levels by quartile, categories by source, a real ARIA grid with one tab stop, and a sideways scroll on narrow screens that opens on the latest week.
|
|
11639
|
+
|
|
11640
|
+
**Keywords:** heatmap, calendar heatmap, contribution graph, contributions, activity, github, streak, habit, daily, commits, uptime
|
|
11641
|
+
|
|
11642
|
+
## Usage
|
|
11643
|
+
|
|
11644
|
+
Use ActivityHeatmap to show how often something happened day by day over weeks or months: commits, habits kept, orders, uptime. Pass days as ISO dates with a value; levels, colours, labels, keyboard and tooltips work with no further props. Put it inside a Card and compose any totals beside it (NumberTicker, StatRowBlock). For a value over time that a reader needs to compare precisely, use AreaChart or BarChart; for a month calendar with marked days (a streak calendar), use Calendar.
|
|
11645
|
+
|
|
11646
|
+
### Best practices
|
|
11647
|
+
|
|
11648
|
+
- Do: Pass dates as "YYYY-MM-DD" strings. The grid reads them as calendar days, so no time zone can shift a day — never build them with `new Date(...).toISOString()` from a local date.
|
|
11649
|
+
- Do: Give it a `unit` ("contribution" / "contributions") so the tooltip and every cell's name say what the number counts.
|
|
11650
|
+
- Do: Let the default quantile scale pick levels — one exceptional day then does not wash every other day out. Pass a day's own `level` when the source already has one (GitHub's API).
|
|
11651
|
+
- Don't: Use it for a handful of values or for values a reader must compare exactly; a heatmap shows rhythm, not amounts. Use BarChart. → use **BarChart**, **AreaChart**
|
|
11652
|
+
- Don't: Use it for a single month of marked days with a streak; that is Calendar with modifiers. → use **Calendar**
|
|
11653
|
+
- Don't: Fetch inside the component or call GitHub from the browser with a token. Fetch on your server and pass days in; `@sableui/react/adapters/github` converts GitHub's response.
|
|
11654
|
+
|
|
11655
|
+
### Anatomy
|
|
11656
|
+
|
|
11657
|
+
| Part | Required | Description |
|
|
11658
|
+
| --- | --- | --- |
|
|
11659
|
+
| Weekday labels | no | Short weekday names beside the rows, outside the scroller so the weeks never slide under them. |
|
|
11660
|
+
| Month labels | no | Short month names over each month's first week; they scroll with their weeks. |
|
|
11661
|
+
| Cell grid | yes | A role=grid table, one gridcell per day, inside a horizontal Scroller that opens on the latest week. |
|
|
11662
|
+
| Tooltip | no | The chart tooltip card, anchored to the hovered, focused or tapped day. |
|
|
11663
|
+
| Legend | no | A Less–More ramp for intensity, or swatches for categories. |
|
|
11664
|
+
|
|
11665
|
+
## Props
|
|
11666
|
+
|
|
11667
|
+
| Name | Type | Default | Description |
|
|
11668
|
+
| --- | --- | --- | --- |
|
|
11669
|
+
| label | string | — | What the grid shows ("Contributions in 2026") — its caption and accessible name. |
|
|
11670
|
+
| data | ActivityDay[] \| Record<string, number> | — | The days: `{ date: "YYYY-MM-DD", value, level?, category? }[]`, or a map of date to value. Sparse is fine — a missing day is 0; duplicates sum. |
|
|
11671
|
+
| start | string | 53 weeks before end | First day drawn, "YYYY-MM-DD". The default starts at the beginning of that week, as GitHub's does. |
|
|
11672
|
+
| end | string | today | Last day drawn. Days after today inside the range are drawn hollow and skipped by the keyboard. |
|
|
11673
|
+
| year | number | — | Draw Jan 1 – Dec 31 of this year. Wins over start / end. |
|
|
11674
|
+
| weekStart | 0 \| 1 | the locale's | 0 puts Sunday on the top row, 1 Monday. |
|
|
11675
|
+
| levels | number | 5 | How many levels, including the empty level 0. |
|
|
11676
|
+
| levelScale | "quantile" \| "linear" \| number[] \| ((value, stats) => number) | "quantile" | How a value becomes a level. Quantile splits the positive values evenly, as GitHub does; an array gives each level's lower bound from 1 up; a function decides. A day's own `level` always wins. |
|
|
11677
|
+
| tone | ChartTone | "1" | The hue of the intensity ramp. Decorative tones lead, as on every chart; "success" gives the GitHub look. |
|
|
11678
|
+
| categories | ChartCategories | — | Colour days by `day.category` (label, tone or colour per key). Pair with `levels={2}` for flat category colours. |
|
|
11679
|
+
| unit | string \| { one: string; other: string } | — | Names the value in the tooltip and each cell's accessible name, pluralised with Intl.PluralRules. |
|
|
11680
|
+
| formatValue | (value: number) => string | full to 9,999, compact past | Formats a value wherever it is shown or read. |
|
|
11681
|
+
| tooltip | boolean \| ((day: ActivityHeatmapDay) => ReactNode) | true | Show the tooltip on hover, focus and tap; a function replaces its content on the same card. |
|
|
11682
|
+
| legend | boolean | true | The Less–More ramp for intensity, swatches for categories. |
|
|
11683
|
+
| legendLabels | [string, string] | ["Less", "More"] | The ramp's end words. |
|
|
11684
|
+
| monthLabels | boolean | true | Short month names over each month's first week. |
|
|
11685
|
+
| weekdayLabels | "alternate" \| "all" \| "none" | "alternate" | Which weekday names to show beside the rows; alternate is Mon, Wed, Fri. |
|
|
11686
|
+
| size | "sm" \| "md" \| "lg" | "md" | The smallest a cell may get (10, 12, 14 px) before the grid scrolls instead of shrinking. |
|
|
11687
|
+
| stretch | boolean | true | Grow cells to fill the width, up to 20 px. false keeps them at `size`. |
|
|
11688
|
+
| activeDate | string \| null | — | The hovered or focused day, controlled. Pair with defaultActiveDate / onActiveDateChange. |
|
|
11689
|
+
| selectedDate | string \| null | — | The selected day, controlled. Selection is on only when onSelectDate is set. |
|
|
11690
|
+
| onSelectDate | (date: string) => void | — | Called when a day is clicked or chosen with Enter / Space — to filter a feed below, as GitHub does. |
|
|
11691
|
+
| renderCell | (day, cell) => ReactNode | — | Draw something over a cell's fill — an icon for a milestone, a reward, an outage. |
|
|
11692
|
+
| loading | boolean | false | Draw the empty grid, shimmering, while data loads. |
|
|
11693
|
+
| motion | "full" \| "subtle" \| "off" | "full" | Turns the arrival down; `subtle` shows the finished grid at once. |
|
|
11694
|
+
| locale | string | runtime locale | Month and weekday names, numbers and plurals. |
|
|
11695
|
+
|
|
11696
|
+
## Tokens used
|
|
11697
|
+
|
|
11698
|
+
`foreground`, `muted-foreground`, `border-strong`, `ring`, `tone-1-mark`, `tone-2-mark`, `tone-3-mark`, `success-mark`, `tone-info-mark`
|
|
11699
|
+
|
|
11700
|
+
## Motion
|
|
11701
|
+
|
|
11702
|
+
- **arrival** · Cell grid · _custom (outside the recipe layer)_ — Weeks fade in left to right as the grid first scrolls into view, staggered on the chart motion helpers; expressive, so subtle shows the finished grid.
|
|
11703
|
+
- **response** · Cell · _custom (outside the recipe layer)_ — The active day takes a ring outline at once and the tooltip glides to it; the other cells stay still — dimming hundreds of cells reads as a flash.
|
|
11704
|
+
- **transition** · Cell · _custom (outside the recipe layer)_ — When data or the range changes, each cell's fill cross-fades on the base duration token, so the reader sees which days changed.
|
|
11705
|
+
- **activity** · Cell grid · `motion-shimmer` — `loading` shimmers the empty grid until days arrive.
|
|
11706
|
+
|
|
11707
|
+
## Accessibility
|
|
11708
|
+
|
|
11709
|
+
- The grid is a role=grid table, as GitHub's is: weekday row headers and month column headers carry full names for screen readers, and the caption is `label`.
|
|
11710
|
+
- One tab stop. Arrows move a day or a week; Home / End go to the ends of the row, Page Up / Page Down to the ends of the column, Ctrl+Home / Ctrl+End to the first and last day. Enter or Space selects when onSelectDate is set; Escape hides the tooltip.
|
|
11711
|
+
- Each cell is named with its date and value ("Tuesday, October 14, 2026: 12 contributions") and described by its level's words, which the legend repeats visually.
|
|
11712
|
+
- Levels rise in lightness, so they read without colour; under forced colours each level draws as a larger inner square.
|
|
11713
|
+
|
|
11714
|
+
## Related
|
|
11715
|
+
|
|
11716
|
+
- Components: Card, NumberTicker, BarChart, Calendar, Scroller
|
|
11717
|
+
|
|
11718
|
+
## Examples
|
|
11719
|
+
|
|
11720
|
+
### Contributions, last year
|
|
11721
|
+
|
|
11722
|
+
```tsx
|
|
11723
|
+
import { ActivityHeatmap } from "@sableui/react"
|
|
11724
|
+
|
|
11725
|
+
const days = [
|
|
11726
|
+
{ date: "2026-10-12", value: 4 },
|
|
11727
|
+
{ date: "2026-10-13", value: 11 },
|
|
11728
|
+
{ date: "2026-10-14", value: 2 },
|
|
11729
|
+
]
|
|
11730
|
+
|
|
11731
|
+
<ActivityHeatmap label="Contributions in the last year" data={days} tone="success" unit={{ one: "contribution", other: "contributions" }} />
|
|
11732
|
+
```
|
|
11733
|
+
|
|
11734
|
+
### Two sources, in a card
|
|
11735
|
+
|
|
11736
|
+
```tsx
|
|
11737
|
+
import { ActivityHeatmap, Card, CardContent, CardHeader, CardTitle, mergeActivity } from "@sableui/react"
|
|
11738
|
+
|
|
11739
|
+
const github = [{ date: "2026-10-13", value: 6 }]
|
|
11740
|
+
const codex = [{ date: "2026-10-13", value: 1 }, { date: "2026-10-14", value: 3 }]
|
|
11741
|
+
|
|
11742
|
+
<Card>
|
|
11743
|
+
<CardHeader>
|
|
11744
|
+
<CardTitle>Building, day by day</CardTitle>
|
|
11745
|
+
</CardHeader>
|
|
11746
|
+
<CardContent>
|
|
11747
|
+
<ActivityHeatmap
|
|
11748
|
+
label="GitHub and Codex activity in 2026"
|
|
11749
|
+
year={2026}
|
|
11750
|
+
levels={2}
|
|
11751
|
+
data={mergeActivity({ github, codex })}
|
|
11752
|
+
categories={{ github: { label: "GitHub", tone: "success" }, codex: { label: "Codex", tone: "info" }, both: { label: "Both", tone: "2" } }}
|
|
11753
|
+
/>
|
|
11754
|
+
</CardContent>
|
|
11755
|
+
</Card>
|
|
11756
|
+
```
|
|
11757
|
+
|
|
11758
|
+
### Selecting a day
|
|
11759
|
+
|
|
11760
|
+
```tsx
|
|
11761
|
+
import { ActivityHeatmap } from "@sableui/react"
|
|
11762
|
+
|
|
11763
|
+
const [day, setDay] = useState<string | null>(null)
|
|
11764
|
+
|
|
11765
|
+
<ActivityHeatmap label="Orders per day" data={{ "2026-10-13": 18, "2026-10-14": 25 }} unit="orders" selectedDate={day} onSelectDate={setDay} />
|
|
11766
|
+
```
|
|
11767
|
+
|
|
11768
|
+
---
|
|
11769
|
+
|
|
11013
11770
|
# AppHeader
|
|
11014
11771
|
|
|
11015
11772
|
**Status:** stable
|
|
@@ -11536,9 +12293,9 @@ import { Attachment, Message, RelativeTime } from "@sableui/react"
|
|
|
11536
12293
|
|
|
11537
12294
|
**Status:** stable
|
|
11538
12295
|
|
|
11539
|
-
Circular profile image over Base UI Avatar — automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.
|
|
12296
|
+
Circular (or square) profile image over Base UI Avatar — automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.
|
|
11540
12297
|
|
|
11541
|
-
**Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile
|
|
12298
|
+
**Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile, monogram, project mark, workspace icon, org logo, assignee
|
|
11542
12299
|
|
|
11543
12300
|
## Usage
|
|
11544
12301
|
|
|
@@ -11549,12 +12306,13 @@ Use Avatar to represent a person, team, or account with a compact visual identit
|
|
|
11549
12306
|
- Do: Use `variant="art"` where people without photos sit side by side — a member list, an activity feed, a chat. Seeded by the person, each gets their own colour, so faces stay tellable apart at a glance; the neutral fallback makes everyone the same grey circle.
|
|
11550
12307
|
- Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
|
|
11551
12308
|
- Do: Use initials or a simple glyph fallback so missing images still communicate identity.
|
|
11552
|
-
- Do: Pick the size by the job: sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group.
|
|
12309
|
+
- Do: Pick the size by the job: 2xs (20px) or xs (24px) in a compact workspace row (an assignee in an issue list, a project mark in a nav item), sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group.
|
|
11553
12310
|
- Do: Use AvatarGroup for a stack of people, and give it an aria-label with the real count ("12 collaborators"). Set `surface` (or `--avatar-group-ring`) to whatever the group sits on, so the separating ring matches a card or popover, not the page. When it shares a row with actions, set `fit` so it gives up avatars to the "+N" count before the row has to wrap.
|
|
12311
|
+
- Do: Use `shape="square"` for what is not a person (a workspace, a project, an org, a team), with `tone` set to a palette family so each one keeps its own colour: `<Avatar shape="square" tone="teal" fallback="N" alt="Northwind storefront" size="xs" />`. Circles mean people; keep that distinction across the app.
|
|
11554
12312
|
- Don't: Hand-build a stack with negative margins and per-avatar ring classes, or fake the overflow as an Avatar with "+4" initials — screen readers hear "plus 4" with no context. Use AvatarGroup's `max`/`total`, or AvatarGroupCount.
|
|
11555
12313
|
- Don't: Use avatars as decoration without nearby identity context. They should help users recognize a person or account.
|
|
11556
12314
|
- Don't: Overload a dense table with large avatars; use sm or omit images when scanning data is more important.
|
|
11557
|
-
- Don't:
|
|
12315
|
+
- Don't: Resize an Avatar with size classes (`className="size-5"`, `className="size-40"`) — the initials, the status indicator and the image fade stay at the size they were drawn for. Use size="2xs"/"xs" for dense rows and size="xl"/"2xl" for portraits, which scale all three. Don't hand-build a tinted initials tile for a project or workspace mark either: that is Avatar shape="square" with a tone. Past 160px, or for a cover, it is media: an AspectBox, not an avatar.
|
|
11558
12316
|
|
|
11559
12317
|
### Anatomy
|
|
11560
12318
|
|
|
@@ -11570,11 +12328,11 @@ Use Avatar to represent a person, team, or account with a compact visual identit
|
|
|
11570
12328
|
|
|
11571
12329
|
| Name | Type | Description |
|
|
11572
12330
|
| --- | --- | --- |
|
|
11573
|
-
| AvatarRoot | Base UI Avatar.Root props + size + className | Low-level circular frame for full composition control — clips the image and holds the `size` axis. Wraps AvatarImage and AvatarFallback. |
|
|
12331
|
+
| AvatarRoot | Base UI Avatar.Root props + size + shape + tone + className | Low-level circular frame for full composition control — clips the image and holds the `size` axis. Wraps AvatarImage and AvatarFallback. |
|
|
11574
12332
|
| AvatarImage | Base UI Avatar.Image props + className | The profile image. Fills and crops the frame; hidden by Base UI until it loads. |
|
|
11575
12333
|
| AvatarFallback | Base UI Avatar.Fallback props (delay) + className | Shown when no image is available — typically initials or a glyph. Accepts a delay prop. |
|
|
11576
|
-
| AvatarGroup | { size?: "sm" \| "md" \| "lg"; surface?: "background" \| "card" \| "popover" \| "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode } | Overlapping stack (role=group) — name it with aria-label ("12 collaborators"). `size` applies to every avatar inside. `max` shows that many and collapses the rest into a "+N" count; `total` is the real head-count when you only pass some people. `fit` shows only as many avatars as fit the width the group is given (it fills the space left in a flex row) and folds the rest into the count, so a tight row reads "MO SR +5" instead of wrapping. The separating ring reads the inherited `--avatar-group-ring` (default: the page background); `surface` sets it to card, popover, or muted. |
|
|
11577
|
-
| AvatarGroupCount | { count: number; label?: string; size?: "sm" \| "md" \| "lg"; className?: string } | The "+N" avatar. Shows "+4" and reads "and 4 more" (override with label). AvatarGroup renders it for you from max/total; use it directly only when composing the stack yourself. |
|
|
12334
|
+
| AvatarGroup | { size?: "2xs" \| "xs" \| "sm" \| "md" \| "lg"; surface?: "background" \| "card" \| "popover" \| "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode } | Overlapping stack (role=group) — name it with aria-label ("12 collaborators"). `size` applies to every avatar inside. `max` shows that many and collapses the rest into a "+N" count; `total` is the real head-count when you only pass some people. `fit` shows only as many avatars as fit the width the group is given (it fills the space left in a flex row) and folds the rest into the count, so a tight row reads "MO SR +5" instead of wrapping. The separating ring reads the inherited `--avatar-group-ring` (default: the page background); `surface` sets it to card, popover, or muted. |
|
|
12335
|
+
| AvatarGroupCount | { count: number; label?: string; size?: "2xs" \| "xs" \| "sm" \| "md" \| "lg"; className?: string } | The "+N" avatar. Shows "+4" and reads "and 4 more" (override with label). AvatarGroup renders it for you from max/total; use it directly only when composing the stack yourself. |
|
|
11578
12336
|
|
|
11579
12337
|
## Props
|
|
11580
12338
|
|
|
@@ -11585,13 +12343,16 @@ Use Avatar to represent a person, team, or account with a compact visual identit
|
|
|
11585
12343
|
| fallback | ReactNode | — | Initials or a glyph rendered when no image is displayed. |
|
|
11586
12344
|
| fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
|
|
11587
12345
|
| status | ReactNode | — | A presence indicator pinned to the bottom-end edge, usually <StatusDot tone="success" />. It sits outside the clipped circle and wears a ring in the surface colour (--avatar-group-ring, else the page background). Use this instead of absolutely positioning a dot over an Avatar. |
|
|
11588
|
-
| size | "sm" \| "md" \| "lg" \| "xl" \| "2xl" | "md" | Square footprint: 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface. |
|
|
12346
|
+
| size | "2xs" \| "xs" \| "sm" \| "md" \| "lg" \| "xl" \| "2xl" | "md" | Square footprint: 20px / 24px for compact workspace rows; 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface. |
|
|
12347
|
+
| shape | "circle" \| "square" | "circle" | The frame's corner. circle is a person. square is what is not a face — a workspace, a project, an org, a team — with a radius from the theme's steps that grows with the size (4px at 20–24px to 20px at 160px), so a square theme stays square. A status indicator sits on the square's corner. |
|
|
12348
|
+
| tone | "neutral" \| "red" \| "orange" \| "yellow" \| "green" \| "teal" \| "blue" \| "purple" \| "pink" | "neutral" | The fallback's colour. neutral is the muted surface; a palette family paints its wash behind the initials and its deep ink on them (a soft Badge's pair, held to contrast in every theme), for a category colour such as one per project. Ignored by variant="art", which colours by seed. |
|
|
11589
12349
|
| variant | "neutral" \| "art" | "neutral" | What the fallback looks like. neutral: initials on the muted surface. art: initials over GradientArt seeded by the person, so each person without a photo gets their own stable colour from the theme. |
|
|
11590
12350
|
| seed | string \| number | — | The art variant's seed. Defaults to the fallback text, then `alt`, so a person keeps one colour wherever they appear; pass a user id to tell apart two people who share initials. |
|
|
11591
12351
|
|
|
11592
12352
|
## Variants
|
|
11593
12353
|
|
|
11594
|
-
- **size**: sm, md, lg, xl, 2xl
|
|
12354
|
+
- **size**: 2xs, xs, sm, md, lg, xl, 2xl
|
|
12355
|
+
- **shape**: circle, square
|
|
11595
12356
|
- **variant**: neutral, art
|
|
11596
12357
|
|
|
11597
12358
|
## Tokens used
|
|
@@ -11668,6 +12429,25 @@ import { Avatar, StatusDot } from "@sableui/react"
|
|
|
11668
12429
|
</div>
|
|
11669
12430
|
```
|
|
11670
12431
|
|
|
12432
|
+
### Dense row sizes
|
|
12433
|
+
|
|
12434
|
+
```tsx
|
|
12435
|
+
import { Avatar } from "@sableui/react"
|
|
12436
|
+
|
|
12437
|
+
<Avatar fallback="EL" alt="Emma Lee" size="2xs" />
|
|
12438
|
+
<Avatar fallback="EL" alt="Emma Lee" size="xs" />
|
|
12439
|
+
```
|
|
12440
|
+
|
|
12441
|
+
### Project marks
|
|
12442
|
+
|
|
12443
|
+
```tsx
|
|
12444
|
+
import { Avatar } from "@sableui/react"
|
|
12445
|
+
|
|
12446
|
+
<Avatar shape="square" tone="teal" size="xs" fallback="N" alt="Northwind storefront" />
|
|
12447
|
+
<Avatar shape="square" tone="orange" size="xs" fallback="H" alt="Harbor brand refresh" />
|
|
12448
|
+
<Avatar shape="square" tone="purple" size="xs" fallback="L" alt="Lumen editorial" />
|
|
12449
|
+
```
|
|
12450
|
+
|
|
11671
12451
|
### Low-level parts
|
|
11672
12452
|
|
|
11673
12453
|
```tsx
|
|
@@ -13674,6 +14454,7 @@ Use CodeBlock for multiline code samples that users may read, inspect, or copy.
|
|
|
13674
14454
|
| showCopy | boolean | true | Show the copy affordance — in the header bar with a filename, floating in the corner without one. |
|
|
13675
14455
|
| highlight | ((code: string) => ReactNode) \| false | — | Highlighter for this block; overrides CodeHighlightProvider. false renders plain text inside a provider. |
|
|
13676
14456
|
| prompt | string | — | A shell prompt ("$", ">"). Makes it a one-line command: the prompt is dimmed and unselectable, the line truncates, and copy takes only code. |
|
|
14457
|
+
| wrap | boolean | false | Wrap long lines instead of scrolling them. For prose in a code face (a prompt to paste into an agent, a log line); code keeps scrolling, since wrapping breaks its indentation. |
|
|
13677
14458
|
|
|
13678
14459
|
## Tokens used
|
|
13679
14460
|
|
|
@@ -13730,7 +14511,7 @@ import { CodeBlock } from "@sableui/react"
|
|
|
13730
14511
|
|
|
13731
14512
|
Show/hide a panel of content over Base UI Collapsible — open/close state, ARIA expanded wiring, and the animated height transition. The minimal primitive behind Accordion: a trigger that toggles an overflow-clipped panel.
|
|
13732
14513
|
|
|
13733
|
-
**Keywords:** collapsible, disclosure, expand, collapse
|
|
14514
|
+
**Keywords:** collapsible, disclosure, expand, collapse, chevron, caret, rotate, group header, section toggle
|
|
13734
14515
|
|
|
13735
14516
|
## Usage
|
|
13736
14517
|
|
|
@@ -13757,7 +14538,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
|
|
|
13757
14538
|
| --- | --- | --- |
|
|
13758
14539
|
| CollapsibleTrigger | Base UI Collapsible.Trigger props + render | The button that toggles the panel. Compose with render={<Button variant="ghost" … />}. |
|
|
13759
14540
|
| CollapsiblePanel | Base UI Collapsible.Panel props + className (keepMounted, hiddenUntilFound) | The collapsible contents. Overflow-hidden; animates height via the --collapsible-panel-height var Base UI sets. |
|
|
13760
|
-
| CollapsibleIndicator | { className? } | Auto-rotating disclosure chevron. Place it inside the trigger content
|
|
14541
|
+
| CollapsibleIndicator | { direction?: "down" \| "end"; className? } | Auto-rotating disclosure chevron. Place it anywhere inside the trigger content; it reads the trigger's open state itself, no class to add. direction="down" (default) flips 180° on open; direction="end" points to the inline end while closed and turns down on open — the caret for a grouped list's section header or a tree row. Use it instead of rotating your own chevron with CSS, which snaps with no transition. |
|
|
13761
14542
|
|
|
13762
14543
|
## Props
|
|
13763
14544
|
|
|
@@ -13776,7 +14557,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
|
|
|
13776
14557
|
- **arrival** · Root · _no motion_ — A collapsible is part of the page it sits in, present from first paint. Its trigger is what the reader notices, not an entrance.
|
|
13777
14558
|
- **response** · CollapsibleTrigger · _no motion_ — The trigger is whatever the consumer renders (usually a Button), and that control brings its own response. Adding one here would double it.
|
|
13778
14559
|
- **transition** · CollapsiblePanel · `motion-disclosure` — The height change ties the content that appears to the trigger that was pressed; snapped open, everything below jumps with nothing linking cause to effect.
|
|
13779
|
-
- **transition** · CollapsibleIndicator · `motion-interactive` — The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180
|
|
14560
|
+
- **transition** · CollapsibleIndicator · `motion-interactive` — The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 and rotate-0 write.
|
|
13780
14561
|
- **activity** · Root · _no motion_ — Nothing here is indeterminate; content that loads on open should put its own loader inside the panel.
|
|
13781
14562
|
|
|
13782
14563
|
## Accessibility
|
|
@@ -13813,7 +14594,15 @@ import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@sabl
|
|
|
13813
14594
|
```tsx
|
|
13814
14595
|
import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
|
|
13815
14596
|
|
|
13816
|
-
<Collapsible><CollapsibleTrigger
|
|
14597
|
+
<Collapsible><CollapsibleTrigger render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>
|
|
14598
|
+
```
|
|
14599
|
+
|
|
14600
|
+
### Group header with a side caret
|
|
14601
|
+
|
|
14602
|
+
```tsx
|
|
14603
|
+
import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
|
|
14604
|
+
|
|
14605
|
+
<Collapsible defaultOpen><CollapsibleTrigger render={<Button variant="ghost" size="xs"><CollapsibleIndicator direction="end" />In progress <span className="text-ui-num text-muted-foreground">5</span></Button>} /><CollapsiblePanel><ul className="text-ui-body"><li>Refine product detail page</li><li>Build the ingredient library</li></ul></CollapsiblePanel></Collapsible>
|
|
13817
14606
|
```
|
|
13818
14607
|
|
|
13819
14608
|
---
|
|
@@ -14501,9 +15290,9 @@ import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuGroupLabe
|
|
|
14501
15290
|
|
|
14502
15291
|
**Status:** stable
|
|
14503
15292
|
|
|
14504
|
-
A data-driven table over the Table primitives — a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc → desc → none), optional row click / selection, and an empty state. For server data or paging, sort/page the data and pair with Pagination.
|
|
15293
|
+
A data-driven table over the Table primitives — a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc → desc → 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.
|
|
14505
15294
|
|
|
14506
|
-
**Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list
|
|
15295
|
+
**Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list, grouped, group by, sections, issue list, issue tracker, status groups, collapsible groups
|
|
14507
15296
|
|
|
14508
15297
|
## Usage
|
|
14509
15298
|
|
|
@@ -14515,7 +15304,8 @@ Use DataTable when a data array and column definition should render a sortable,
|
|
|
14515
15304
|
- Do: Provide stable rowKey values for dynamic data so selection and sorting do not jump.
|
|
14516
15305
|
- Do: Use sortable only on columns where ordering is obvious and useful.
|
|
14517
15306
|
- Do: Pair server-side datasets with Pagination and pass already-sorted/paged rows.
|
|
14518
|
-
- Do:
|
|
15307
|
+
- Do: 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.
|
|
15308
|
+
- Do: 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.
|
|
14519
15309
|
- Don't: Make every row clickable and also fill it with competing buttons. Choose a clear row interaction model.
|
|
14520
15310
|
- Don't: Use DataTable for heterogeneous cards or timeline content. A list or card layout will scan better.
|
|
14521
15311
|
|
|
@@ -14526,6 +15316,7 @@ Use DataTable when a data array and column definition should render a sortable,
|
|
|
14526
15316
|
| Columns | yes | Definitions for headers, cell renderers, sorting, alignment, and classes. |
|
|
14527
15317
|
| Rows | yes | The data array rendered through the column definitions. |
|
|
14528
15318
|
| Empty state | no | Content spanning all columns when the row array is empty. |
|
|
15319
|
+
| Group header row | no | 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. |
|
|
14529
15320
|
| Caption | no | Accessible table caption rendered by the underlying Table. |
|
|
14530
15321
|
|
|
14531
15322
|
## Props
|
|
@@ -14544,6 +15335,11 @@ Use DataTable when a data array and column definition should render a sortable,
|
|
|
14544
15335
|
| defaultSort | { key: string; direction: "asc" \| "desc" } | — | Initial sort column + direction. |
|
|
14545
15336
|
| empty | ReactNode | "No results." | Rendered spanning all columns when data is empty. Pass an EmptyState for the full treatment (icon, title, next step). |
|
|
14546
15337
|
| caption | ReactNode | — | Bottom table caption. |
|
|
15338
|
+
| groupBy | (row) => string | — | 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. |
|
|
15339
|
+
| groups | { key: string; label?: ReactNode; icon?: ReactNode; action?: ReactNode; defaultExpanded?: boolean }[] | — | 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. |
|
|
15340
|
+
| collapsible | boolean | true | 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. |
|
|
15341
|
+
| expandedGroups | ReadonlySet<string> | — | The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state. |
|
|
15342
|
+
| onExpandedGroupsChange | (keys: Set<string>) => void | — | Called with the new set of open group keys when a group header is toggled. |
|
|
14547
15343
|
|
|
14548
15344
|
## Tokens used
|
|
14549
15345
|
|
|
@@ -14552,9 +15348,11 @@ Use DataTable when a data array and column definition should render a sortable,
|
|
|
14552
15348
|
## Motion
|
|
14553
15349
|
|
|
14554
15350
|
- **arrival** · Root · _no motion_ — Rows render with the page or the query that produced them. Staggering rows in delays reading a table someone opened to scan.
|
|
15351
|
+
- **arrival** · Group rows · _no motion_ — 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.
|
|
14555
15352
|
- **response** · Row · `motion-interactive` — Body rows ease to a muted wash under the pointer (TableRow's recipe), which keeps the eye on the right line across a wide row of numbers. Sortable headers are ghost Buttons and answer on Button's recipe.
|
|
14556
15353
|
- **transition** · Sort chevron · `motion-interactive` — One chevron turns to show direction instead of two glyphs swapping, so ascending to descending reads as the same column reversing. Its opacity rises from the resting 60% as the column becomes the sort.
|
|
14557
15354
|
- **transition** · Row · `motion-interactive` — 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.
|
|
15355
|
+
- **transition** · Group chevron · `motion-interactive` — 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.
|
|
14558
15356
|
- **activity** · Root · _no motion_ — DataTable has no loading state. While data is fetched, render Skeleton rows (motion-shimmer) in its place — only the caller knows a fetch is in flight.
|
|
14559
15357
|
|
|
14560
15358
|
## Accessibility
|
|
@@ -14562,6 +15360,7 @@ Use DataTable when a data array and column definition should render a sortable,
|
|
|
14562
15360
|
- Sorted columns set aria-sort=ascending/descending on the header cell.
|
|
14563
15361
|
- Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.
|
|
14564
15362
|
- Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.
|
|
15363
|
+
- 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.
|
|
14565
15364
|
|
|
14566
15365
|
## Related
|
|
14567
15366
|
|
|
@@ -14630,6 +15429,33 @@ import { Card, CardContent, CardHeader, CardTitle, DataTable } from "@sableui/re
|
|
|
14630
15429
|
<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>
|
|
14631
15430
|
```
|
|
14632
15431
|
|
|
15432
|
+
### Issues grouped by status
|
|
15433
|
+
|
|
15434
|
+
```tsx
|
|
15435
|
+
import { Button, DataTable, Icon, StatusDot } from "@sableui/react"
|
|
15436
|
+
|
|
15437
|
+
const STATUSES = [
|
|
15438
|
+
{ key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },
|
|
15439
|
+
{ key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },
|
|
15440
|
+
{ key: "todo", label: "Todo", icon: <StatusDot /> },
|
|
15441
|
+
{ key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },
|
|
15442
|
+
]
|
|
15443
|
+
|
|
15444
|
+
<DataTable
|
|
15445
|
+
data={issues}
|
|
15446
|
+
rowKey={(t) => t.id}
|
|
15447
|
+
groupBy={(t) => t.status}
|
|
15448
|
+
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> }))}
|
|
15449
|
+
onRowClick={(t) => setSelectedId(t.id)}
|
|
15450
|
+
isRowSelected={(t) => t.id === selectedId}
|
|
15451
|
+
columns={[
|
|
15452
|
+
{ key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },
|
|
15453
|
+
{ key: "title", header: "Issue", sortable: true },
|
|
15454
|
+
{ key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },
|
|
15455
|
+
]}
|
|
15456
|
+
/>
|
|
15457
|
+
```
|
|
15458
|
+
|
|
14633
15459
|
### Empty state
|
|
14634
15460
|
|
|
14635
15461
|
```tsx
|
|
@@ -14646,7 +15472,7 @@ import { Button, DataTable, EmptyState, Icon } from "@sableui/react"
|
|
|
14646
15472
|
|
|
14647
15473
|
Date (and optionally time) picker: a field-shaped trigger (the Input / SelectTrigger shell) opens a Popover holding Sable's Calendar. With granularity="day" selecting a day fires onValueChange and closes; granularity="minute" adds an hour + minute row of Selects and keeps the time-of-day when the day changes.
|
|
14648
15474
|
|
|
14649
|
-
**Keywords:** date picker, calendar, date input, date time picker, time, schedule, form
|
|
15475
|
+
**Keywords:** date picker, due date, property row, inspector, calendar, date input, date time picker, time, schedule, form
|
|
14650
15476
|
|
|
14651
15477
|
## Usage
|
|
14652
15478
|
|
|
@@ -14675,6 +15501,8 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
|
|
|
14675
15501
|
| disabled | boolean | — | Disable the trigger and block selection. |
|
|
14676
15502
|
| minDate | Date | — | Earliest selectable day (inclusive). Earlier days are disabled and navigation is bounded. |
|
|
14677
15503
|
| maxDate | Date | — | Latest selectable day (inclusive). Later days are disabled and navigation is bounded. |
|
|
15504
|
+
| size | "md" \| "xs" | "md" | 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 — use it for a due date in a PropertyRow instead of a native date input. |
|
|
15505
|
+
| formatOptions | Intl.DateTimeFormatOptions | — | How the trigger writes the chosen date, as Intl.DateTimeFormat options in the reader's locale — { 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"). |
|
|
14678
15506
|
| className | string | — | Extra classes merged onto the trigger. |
|
|
14679
15507
|
| id / aria-label / aria-labelledby / aria-describedby / aria-invalid / aria-required | button attributes | — | Forwarded to the trigger, so a wrapping Field ties its label to the picker and announces its description and error. Pass them yourself only when labelling the picker without a Field. |
|
|
14680
15508
|
|
|
@@ -14696,6 +15524,18 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
|
|
|
14696
15524
|
|
|
14697
15525
|
## Examples
|
|
14698
15526
|
|
|
15527
|
+
### In an inspector row
|
|
15528
|
+
|
|
15529
|
+
```tsx
|
|
15530
|
+
import { DatePicker, PanelSection, PropertyRow } from "@sableui/react"
|
|
15531
|
+
|
|
15532
|
+
<PanelSection title="Properties">
|
|
15533
|
+
<PropertyRow label="Due date">
|
|
15534
|
+
<DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
|
|
15535
|
+
</PropertyRow>
|
|
15536
|
+
</PanelSection>
|
|
15537
|
+
```
|
|
15538
|
+
|
|
14699
15539
|
### Controlled
|
|
14700
15540
|
|
|
14701
15541
|
```tsx
|
|
@@ -14920,11 +15760,14 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
14920
15760
|
- Do: Use showCloseButton for dismissible informational dialogs; keep destructive confirmations action-led.
|
|
14921
15761
|
- Do: In a destructive confirmation, state the consequence in DialogDescription ("This is permanent.") and reserve the destructive button variant for actions that cannot be undone.
|
|
14922
15762
|
- Do: Use size="fullscreen" when an image, video, or gallery needs a larger view than the layout can give it. Give the popup an accessible name (aria-label on DialogContent, or a visually hidden DialogTitle) and alt text on the media.
|
|
15763
|
+
- Do: 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.
|
|
15764
|
+
- Do: 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.
|
|
14923
15765
|
- Do: 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 — only Cancel, Esc, or the action. For irreversible deletes, use DestructiveActionModal, which is built on it.
|
|
14924
15766
|
- Don't: Put large multipage workflows in a centered dialog. Use Sheet or a page route when the task needs space. → use **Sheet**
|
|
14925
15767
|
- Don't: Use size="fullscreen" for forms, confirmations, or anything that isn't media. A dialog that happens to need room is a Sheet or a page; the fullscreen affordances (dark scrim, corner close, centred content) all assume something to look at. → use **Sheet**
|
|
14926
15768
|
- Don't: Hide the close button at size="fullscreen" without another visible way out. A full-bleed viewer that only Esc dismisses is a dead end for anyone who doesn't know the key.
|
|
14927
15769
|
- Don't: Open a dialog for passive status updates. Use Toast, Banner, or InlineStatus instead. → use **Toast**, **Banner**, **InlineStatus**
|
|
15770
|
+
- Don't: 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.
|
|
14928
15771
|
|
|
14929
15772
|
### Anatomy
|
|
14930
15773
|
|
|
@@ -14940,7 +15783,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
14940
15783
|
|
|
14941
15784
|
| Name | Type | Description |
|
|
14942
15785
|
| --- | --- | --- |
|
|
14943
|
-
| DialogContent | Base UI Popup props + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className | 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. |
|
|
15786
|
+
| DialogContent | Base UI Popup props (initialFocus, finalFocus) + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + keepMounted?: boolean + className | 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. |
|
|
14944
15787
|
| DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/(--scrim-alpha) by default (50% light, 72% dark, so it dims a near-black page as visibly as a white one), near-opaque bg-overlay/95 at size="fullscreen". Composed into DialogContent (at the matching size) by default; exported for manual styling. |
|
|
14945
15788
|
| DialogTrigger | Base UI Dialog.Trigger props (render) | Element that opens the dialog. Compose via render prop. |
|
|
14946
15789
|
| DialogClose | Base UI Dialog.Close props (render) | Element that dismisses the dialog. Compose via render prop. |
|
|
@@ -14948,7 +15791,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
14948
15791
|
| DialogTitle | Base UI Dialog.Title props + className | The dialog's accessible name — wires aria-labelledby on the popup. |
|
|
14949
15792
|
| DialogDescription | Base UI Dialog.Description props + className | Muted supporting line; wires aria-describedby. |
|
|
14950
15793
|
| DialogFooter | ComponentProps<"div"> | Bottom action row (right-aligned buttons). |
|
|
14951
|
-
| AlertDialog (Root) | Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange) | Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss — only an AlertDialogClose, an action that sets open to false, or Esc. |
|
|
15794
|
+
| AlertDialog (Root) | Base UI AlertDialog.Root props (open, defaultOpen, onOpenChange) | Root for a confirmation that needs an answer. Always modal; the popup is role="alertdialog" and a click outside does not dismiss — 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. |
|
|
14952
15795
|
| AlertDialogTrigger | Base UI AlertDialog.Trigger props (render) | Opens the alert dialog. |
|
|
14953
15796
|
| AlertDialogContent / AlertDialogOverlay / AlertDialogTitle / AlertDialogDescription / AlertDialogHeader / AlertDialogFooter / AlertDialogClose | Same props as the matching Dialog part | The Dialog parts under AlertDialog names — Base UI shares the popup, backdrop, title, description and close parts between the two, so the surface is identical. Pass initialFocus to AlertDialogContent (a ref to the Cancel button) to open focus on the safe action. |
|
|
14954
15797
|
|
|
@@ -14956,7 +15799,10 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
14956
15799
|
|
|
14957
15800
|
| Name | Type | Default | Description |
|
|
14958
15801
|
| --- | --- | --- | --- |
|
|
14959
|
-
| Dialog (Root) | Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal) | — | Controls open state. Uncontrolled by default. |
|
|
15802
|
+
| Dialog (Root) | Base UI Dialog.Root props (open, defaultOpen, onOpenChange, modal) | — | 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. |
|
|
15803
|
+
| open | boolean | — | Controlled open state. Pair with onOpenChange — for a dialog opened from your own button instead of a DialogTrigger. |
|
|
15804
|
+
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
|
|
15805
|
+
| onOpenChange | (open: boolean, eventDetails) => void | — | Called when the dialog asks to open or close (a trigger, Esc, an outside click, DialogClose). |
|
|
14960
15806
|
|
|
14961
15807
|
## Variants
|
|
14962
15808
|
|
|
@@ -14978,6 +15824,8 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
|
|
|
14978
15824
|
|
|
14979
15825
|
- Base UI provides focus trapping, scroll lock, aria-modal, and labelled-by wiring (DialogTitle).
|
|
14980
15826
|
- Content taller than the viewport scrolls inside the dialog (thin scrollbar, contained overscroll) rather than overflowing off screen — the page behind is scroll-locked, so anything past the edge would be unreachable.
|
|
15827
|
+
- 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.
|
|
15828
|
+
- 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.
|
|
14981
15829
|
- Esc and click-outside dismiss by default. AlertDialog keeps Esc but ignores click-outside, and its popup is role="alertdialog".
|
|
14982
15830
|
- Animate via data-[starting-style]/data-[ending-style] attributes.
|
|
14983
15831
|
- 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 — 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).
|
|
@@ -15021,6 +15869,29 @@ import { Button, Dialog, DialogClose, DialogContent, DialogDescription, DialogFo
|
|
|
15021
15869
|
<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>
|
|
15022
15870
|
```
|
|
15023
15871
|
|
|
15872
|
+
### Opened from your own button, keeping a draft
|
|
15873
|
+
|
|
15874
|
+
```tsx
|
|
15875
|
+
import { Button, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Field, Textarea } from "@sableui/react"
|
|
15876
|
+
|
|
15877
|
+
const [open, setOpen] = useState(false)
|
|
15878
|
+
|
|
15879
|
+
<Button variant="secondary" onClick={() => setOpen(true)}>New message</Button>
|
|
15880
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
15881
|
+
<DialogContent keepMounted showCloseButton>
|
|
15882
|
+
<DialogHeader>
|
|
15883
|
+
<DialogTitle>New message</DialogTitle>
|
|
15884
|
+
<DialogDescription>Close it any time. Your draft is here when you come back.</DialogDescription>
|
|
15885
|
+
</DialogHeader>
|
|
15886
|
+
<Field label="Message"><Textarea /></Field>
|
|
15887
|
+
<DialogFooter>
|
|
15888
|
+
<DialogClose render={<Button variant="secondary">Close</Button>} />
|
|
15889
|
+
<Button onClick={() => setOpen(false)}>Send</Button>
|
|
15890
|
+
</DialogFooter>
|
|
15891
|
+
</DialogContent>
|
|
15892
|
+
</Dialog>
|
|
15893
|
+
```
|
|
15894
|
+
|
|
15024
15895
|
### Destructive confirmation
|
|
15025
15896
|
|
|
15026
15897
|
```tsx
|
|
@@ -16983,6 +17854,7 @@ Use MediaImage wherever content imagery may be missing or broken: a cover in a g
|
|
|
16983
17854
|
- Do: Give the box the shape: `<AspectBox ratio={1} className="rounded-md"><MediaImage … /></AspectBox>`, or `size-14 rounded-md` on MediaImage itself for a thumbnail. It clips to whatever radius the box has.
|
|
16984
17855
|
- Do: Pass `seed` (the title or an id) when `alt` is empty, so the fallback art is the item's own and stays the same in every list it appears in.
|
|
16985
17856
|
- Do: Leave `alt` empty when a visible title sits next to the image — a tile, a row — and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL.
|
|
17857
|
+
- Do: Set `position` when a cover crop would cut off the subject — 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.
|
|
16986
17858
|
- Don't: 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.
|
|
16987
17859
|
- Don't: Do not use it for profile pictures. A person is a circle with initials — Avatar, whose `variant="art"` is the same fallback with the person's initials over it.
|
|
16988
17860
|
|
|
@@ -16995,6 +17867,7 @@ Use MediaImage wherever content imagery may be missing or broken: a cover in a g
|
|
|
16995
17867
|
| seed | string \| number | — | Seeds the fallback art. Defaults to `alt`; pass the title or an id when `alt` is empty. |
|
|
16996
17868
|
| fallback | ReactNode | — | Replaces the GradientArt fallback — a glyph or monogram. Fills the frame under the same clipping. |
|
|
16997
17869
|
| fit | "cover" \| "contain" | "cover" | `cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped). |
|
|
17870
|
+
| position | CSSProperties["objectPosition"] | "center" | Where the image sits in its frame — 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. |
|
|
16998
17871
|
| srcSet | string | — | Responsive sources, passed to the `<img>`. |
|
|
16999
17872
|
| sizes | string | — | The `sizes` attribute for `srcSet`. |
|
|
17000
17873
|
| loading | "lazy" \| "eager" | "lazy" | Native lazy loading. Use `eager` for the first row of a grid above the fold. |
|
|
@@ -17065,6 +17938,16 @@ import { AspectBox, MediaImage } from "@sableui/react"
|
|
|
17065
17938
|
</AspectBox>
|
|
17066
17939
|
```
|
|
17067
17940
|
|
|
17941
|
+
### Crop focus per image
|
|
17942
|
+
|
|
17943
|
+
```tsx
|
|
17944
|
+
import { AspectBox, MediaImage } from "@sableui/react"
|
|
17945
|
+
|
|
17946
|
+
<AspectBox ratio={1} className="rounded-md">
|
|
17947
|
+
<MediaImage src={project.thumbnail} seed={project.title} position={project.focus ?? "center"} />
|
|
17948
|
+
</AspectBox>
|
|
17949
|
+
```
|
|
17950
|
+
|
|
17068
17951
|
---
|
|
17069
17952
|
|
|
17070
17953
|
# MediaTile
|
|
@@ -17111,6 +17994,7 @@ The item in a media grid or shelf: an album, a playlist, a video, a podcast, a f
|
|
|
17111
17994
|
| sizes | string | — | The cover's layout width for `srcSet`, e.g. `"(min-width: 64rem) 25vw, 50vw"`. |
|
|
17112
17995
|
| alt | string | "" | Cover alt text. Empty by default: the title below already names the item. |
|
|
17113
17996
|
| seed | string \| number | — | Seeds the fallback art. Defaults to `title` when it is a string. |
|
|
17997
|
+
| position | CSSProperties["objectPosition"] | "center" | Which part of the cover the crop keeps — 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. |
|
|
17114
17998
|
| aspect | "square" \| "video" \| "poster" \| number | "square" | The cover's shape: square (albums, podcasts), video (16:9), poster (2:3 — films, books), or any width / height ratio. |
|
|
17115
17999
|
| size | "sm" \| "md" \| "lg" | "md" | Moves the type tier, the cover-to-text gap, the corner and the overlay inset together. |
|
|
17116
18000
|
| href | string | — | Where the tile goes. The title becomes the link, stretched over the whole tile. |
|
|
@@ -17235,6 +18119,26 @@ import { Grid, MediaTile } from "@sableui/react"
|
|
|
17235
18119
|
</Grid>
|
|
17236
18120
|
```
|
|
17237
18121
|
|
|
18122
|
+
### Portfolio grid with crop focus
|
|
18123
|
+
|
|
18124
|
+
```tsx
|
|
18125
|
+
import { Grid, MediaTile } from "@sableui/react"
|
|
18126
|
+
|
|
18127
|
+
<Grid minItemWidth="16rem" gap={4}>
|
|
18128
|
+
{projects.map((project) => (
|
|
18129
|
+
<MediaTile
|
|
18130
|
+
key={project.slug}
|
|
18131
|
+
aspect="video"
|
|
18132
|
+
title={project.title}
|
|
18133
|
+
subtitle={project.client}
|
|
18134
|
+
src={project.thumbnail}
|
|
18135
|
+
position={project.focus}
|
|
18136
|
+
href={`/work/${project.slug}`}
|
|
18137
|
+
/>
|
|
18138
|
+
))}
|
|
18139
|
+
</Grid>
|
|
18140
|
+
```
|
|
18141
|
+
|
|
17238
18142
|
### Poster shelf
|
|
17239
18143
|
|
|
17240
18144
|
```tsx
|
|
@@ -17286,7 +18190,7 @@ Use Menu for a compact list of actions opened from a visible trigger.
|
|
|
17286
18190
|
| MenuLinkItem | Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode | A row that navigates: a real <a>, so it opens in a new tab, middle-clicks and copies as a link. With target="_blank" it adds rel="noopener noreferrer" and a trailing ↗. Takes icon and description like MenuItem. |
|
|
17287
18191
|
| MenuSeparator | Base UI Separator props + className | Hairline rule between item runs. |
|
|
17288
18192
|
| MenuSubmenu | Base UI Menu.SubmenuRoot props | A nested menu. Wraps a MenuSubmenuTrigger and its own MenuContent — pass side="inline-end" to that content so the submenu opens away from its parent in RTL as well as LTR. |
|
|
17289
|
-
| MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + className | 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 ›" rather than making the reader open it to find out. |
|
|
18193
|
+
| MenuSubmenuTrigger | Base UI Menu.SubmenuTrigger props + value: ReactNode + icon: ReactNode + className | 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 ›" 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. |
|
|
17290
18194
|
| MenuRadioGroup | Base UI Menu.RadioGroup props (value, defaultValue, onValueChange) | A single-choice group inside a menu. Controlled via value/onValueChange, uncontrolled via defaultValue. |
|
|
17291
18195
|
| MenuRadioItem | Base UI Menu.RadioItem props + className (value, disabled) | One option in a MenuRadioGroup, rendered as menuitemradio with a real checked state. The check sits in a reserved leading column so labels never shift sideways as the highlight moves. |
|
|
17292
18196
|
| MenuCheckboxItem | Base UI Menu.CheckboxItem props + className + { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean } | A toggling row, with the same reserved leading column as MenuRadioItem so a menu mixing the two keeps one text margin. |
|
|
@@ -18614,7 +19518,7 @@ import { Button, Icon, MenuItem, OverflowMenu, Row } from "@sableui/react"
|
|
|
18614
19518
|
|
|
18615
19519
|
In-flow page heading block: a title row (optional back control + actions) over an optional description, with room for children (filters, tabs) below.
|
|
18616
19520
|
|
|
18617
|
-
**Keywords:** page header, title, lede, page actions
|
|
19521
|
+
**Keywords:** page header, title, lede, page actions, page title size, display title, editorial header, hero title
|
|
18618
19522
|
|
|
18619
19523
|
## Usage
|
|
18620
19524
|
|
|
@@ -18624,6 +19528,8 @@ Use PageHeader to establish a page's title, supporting description, and primary
|
|
|
18624
19528
|
|
|
18625
19529
|
- Do: Use one PageHeader per major page or route.
|
|
18626
19530
|
- Do: Keep the title literal and put explanation in the lede or nearby content.
|
|
19531
|
+
- Do: 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 → heading-2 → body.
|
|
19532
|
+
- Don't: 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.
|
|
18627
19533
|
- Don't: Do not use PageHeader inside repeated cards, dialogs, or compact panels.
|
|
18628
19534
|
- Don't: Do not use a second PageHeader for a section of the page. It is the route's one h1; each section below it takes a SectionHeader (an h2 with the same title row, description and actions).
|
|
18629
19535
|
- Don't: Do not put it outside the Region that holds the page content. It then sits at a different measure from what it introduces, and the page reads as two documents.
|
|
@@ -18637,6 +19543,11 @@ Use PageHeader to establish a page's title, supporting description, and primary
|
|
|
18637
19543
|
| back | ReactNode | — | Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size="icon", variant="ghost", aria-label="Back") so it stays quieter than the title. A ghost back control bleeds by its inset at the start, so its glyph lines up with the content edge below (Button bleed="none" opts out). |
|
|
18638
19544
|
| actions | ReactNode | — | 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. |
|
|
18639
19545
|
| titleAs | "h1" \| "h2" | "h1" | The title's heading element. Keep h1 — 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. |
|
|
19546
|
+
| size | "md" \| "lg" | "md" | 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. |
|
|
19547
|
+
|
|
19548
|
+
## Variants
|
|
19549
|
+
|
|
19550
|
+
- **size**: md, lg
|
|
18640
19551
|
|
|
18641
19552
|
## Tokens used
|
|
18642
19553
|
|
|
@@ -18661,6 +19572,14 @@ import { Button, PageHeader } from "@sableui/react"
|
|
|
18661
19572
|
<PageHeader title="Storage credits" description="Fund permanent storage." actions={<Button>Top up</Button>} />
|
|
18662
19573
|
```
|
|
18663
19574
|
|
|
19575
|
+
### Editorial page title (size lg)
|
|
19576
|
+
|
|
19577
|
+
```tsx
|
|
19578
|
+
import { Button, PageHeader } from "@sableui/react"
|
|
19579
|
+
|
|
19580
|
+
<PageHeader size="lg" title="Field notes" description="Essays on making things slowly, from a studio by the sea." actions={<Button variant="outline">Subscribe</Button>} />
|
|
19581
|
+
```
|
|
19582
|
+
|
|
18664
19583
|
### One per page, inside the region
|
|
18665
19584
|
|
|
18666
19585
|
```tsx
|
|
@@ -18871,7 +19790,7 @@ const [page, setPage] = useState(searchParams.page ?? 1)
|
|
|
18871
19790
|
|
|
18872
19791
|
A row of docked chrome panels around a flexible centre, with draggable and keyboard-resizable edges and optional persisted widths.
|
|
18873
19792
|
|
|
18874
|
-
**Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout
|
|
19793
|
+
**Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout, mobile, responsive, sheet, drawer, narrow, detail pane
|
|
18875
19794
|
|
|
18876
19795
|
## Usage
|
|
18877
19796
|
|
|
@@ -18882,6 +19801,8 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
|
|
|
18882
19801
|
- Do: Give every docked Panel a fixed width and leave the centre Panel width-less so it absorbs the remaining space.
|
|
18883
19802
|
- Do: Drive the width with usePanelWidth and pass a storageKey, so a user's layout survives a reload. Spread its bounds onto the PanelResizer rather than repeating min and max by hand.
|
|
18884
19803
|
- Do: Label every Panel — they are landmarks, and 'Layers' or 'Properties' is what a screen-reader user navigates by.
|
|
19804
|
+
- Do: Decide what each docked Panel becomes on a phone with `narrow`: "sheet" for an inspector or a navigation rail the user opens on demand, "hidden" for chrome a phone does without. Drive a sheet's `open` from the same state that selects the record, so selecting a row opens the inspector at every width.
|
|
19805
|
+
- Don't: Render the same inspector twice, once in a Panel and once in a Sheet behind a hand-rolled viewport check. `narrow="sheet"` renders the content once, in whichever place applies.
|
|
18885
19806
|
- Don't: Let the group scroll. Only panels and the canvas scroll; a scrolling group means the shell lost its height constraint.
|
|
18886
19807
|
- Don't: Use it for page content columns — that's Grid or Columns. PanelGroup is chrome, and it sits outside the content grid.
|
|
18887
19808
|
- Don't: Omit PanelResizer to save effort and then let users live with a fixed panel. If a panel holds a tree or a property grid, its width is a preference.
|
|
@@ -18898,7 +19819,7 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
|
|
|
18898
19819
|
|
|
18899
19820
|
| Name | Type | Description |
|
|
18900
19821
|
| --- | --- | --- |
|
|
18901
|
-
| Panel | { label: string; width?: number; scroll?: boolean } | A docked or flexible column. `label` names the landmark; omit `width` for the flexible centre. `scroll` defaults to true — set it false for a header + scrolling body + pinned footer, so the footer doesn't scroll away with the list. When you own the layout, give the header and footer shrink-0 and the middle min-h-0 flex-1 overflow-y-auto. overflow-y-auto only activates if every flex ancestor can shrink: a single missing min-h-0 anywhere up the chain makes the panel grow instead of scroll, and nothing reports it. |
|
|
19822
|
+
| Panel | { label: string; width?: number; scroll?: boolean; narrow?: "dock" \| "sheet" \| "hidden"; open?: boolean; onOpenChange?: (open: boolean) => void; side?: "left" \| "right"; below?: "sm" \| "md" \| "lg" \| "xl" \| "2xl" } | A docked or flexible column. Below `below` (default lg, where Sidebar and BottomNav swap), `narrow` decides what it becomes: "dock" (default) stays docked, "sheet" renders the same content once inside a Sheet from `side` (default "right"; a panel before the canvas is "left"), titled by `label`, open while `open` is true and closed through `onOpenChange` (Esc, scrim, swipe), and "hidden" drops it. `open` defaults to true docked (false removes the column, so the canvas takes its width) and false as a sheet. Crossing the breakpoint remounts the content, so keep state that must survive it in the app. `label` names the landmark; omit `width` for the flexible centre. `scroll` defaults to true — set it false for a header + scrolling body + pinned footer, so the footer doesn't scroll away with the list. When you own the layout, give the header and footer shrink-0 and the middle min-h-0 flex-1 overflow-y-auto. overflow-y-auto only activates if every flex ancestor can shrink: a single missing min-h-0 anywhere up the chain makes the panel grow instead of scroll, and nothing reports it. |
|
|
18902
19823
|
| PanelResizer | { side: 'left' \| 'right'; width: number; onWidthChange: (n: number) => void; label: string; min?: number; max?: number; step?: number } | The draggable edge. `side` says which side the resized panel is on, so the drag delta's sign is right. |
|
|
18903
19824
|
| usePanelWidth | (defaultWidth: number, options?: { storageKey?: string; min?: number; max?: number }) => readonly [number, (n: number) => void, { min: number; max: number }] | Controlled width for a Panel, clamped and optionally persisted to localStorage (only when `storageKey` is passed). Read through useSyncExternalStore, so there is no server/client hydration mismatch and no setState in an effect; do not hand-roll a localStorage read during render or a write from an effect. Without `storageKey` the width is kept in memory per hook instance. The third element is the `{ min, max }` it clamps to: spread it onto the paired PanelResizer so the separator's aria-valuemin/max and Home/End match the real clamp. |
|
|
18904
19825
|
|
|
@@ -18977,6 +19898,36 @@ function Body() {
|
|
|
18977
19898
|
}
|
|
18978
19899
|
```
|
|
18979
19900
|
|
|
19901
|
+
### Inspector that becomes a sheet on phones
|
|
19902
|
+
|
|
19903
|
+
```tsx
|
|
19904
|
+
import { Button, Input, Panel, PanelGroup, PanelSection, PropertyRow } from "@sableui/react"
|
|
19905
|
+
|
|
19906
|
+
function Body() {
|
|
19907
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
19908
|
+
return (
|
|
19909
|
+
<PanelGroup>
|
|
19910
|
+
<Panel label="Issues">
|
|
19911
|
+
<div className="p-2">
|
|
19912
|
+
<Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
|
|
19913
|
+
</div>
|
|
19914
|
+
</Panel>
|
|
19915
|
+
<Panel
|
|
19916
|
+
label="Issue details"
|
|
19917
|
+
width={320}
|
|
19918
|
+
narrow="sheet"
|
|
19919
|
+
open={selected !== null}
|
|
19920
|
+
onOpenChange={(open) => !open && setSelected(null)}
|
|
19921
|
+
>
|
|
19922
|
+
<PanelSection title={selected ?? ""}>
|
|
19923
|
+
<PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
|
|
19924
|
+
</PanelSection>
|
|
19925
|
+
</Panel>
|
|
19926
|
+
</PanelGroup>
|
|
19927
|
+
)
|
|
19928
|
+
}
|
|
19929
|
+
```
|
|
19930
|
+
|
|
18980
19931
|
### Fixed panel, no resizer
|
|
18981
19932
|
|
|
18982
19933
|
```tsx
|
|
@@ -19341,7 +20292,7 @@ import { PieChart } from "@sableui/react"
|
|
|
19341
20292
|
|
|
19342
20293
|
**Status:** stable
|
|
19343
20294
|
|
|
19344
|
-
A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size md or sm. A static label is a Badge.
|
|
20295
|
+
A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size lg, md or sm. A static label is a Badge.
|
|
19345
20296
|
|
|
19346
20297
|
**Keywords:** pill, chip, filter, tag, toggle chip, category
|
|
19347
20298
|
|
|
@@ -19353,9 +20304,10 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
|
|
|
19353
20304
|
|
|
19354
20305
|
- Do: Use short text that names the filter or choice.
|
|
19355
20306
|
- Do: Reflect the current choice with selected, so the active filter reads at a glance.
|
|
20307
|
+
- Do: In a toolbar where filter chips share a row with a search field ("All 8 · Products 5 · Open source 2 … [Find a project]"), pair Pill size="lg" with SearchInput size="sm", and Button size="sm" for any action beside them. All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Pill md (28px) has no text field of its height in the page register, so md beside a field always reads as a mismatch.
|
|
19356
20308
|
- Don't: Do not use Pill as a static label. A pressable-looking chip that does nothing is the most common cause of dead clicks in a filter row — that is a Badge. → use **Badge**
|
|
19357
20309
|
- Don't: Do not use Pill for primary actions or important system status.
|
|
19358
|
-
- Don't: Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system.
|
|
20310
|
+
- Don't: Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system. Likewise never set h-* on a Pill to match a neighbour: pick the size that shares its rung.
|
|
19359
20311
|
|
|
19360
20312
|
## Props
|
|
19361
20313
|
|
|
@@ -19363,14 +20315,14 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
|
|
|
19363
20315
|
| --- | --- | --- | --- |
|
|
19364
20316
|
| selected | boolean | false | Marks the pill as chosen. Solid lifts onto the primary surface; outline takes a primary tint and soft primary edge. Sets aria-pressed. |
|
|
19365
20317
|
| appearance | "solid" \| "outline" | "solid" | solid for a filter row; outline for quiet chips that annotate other content (a reaction under a message, a tag on a card), where a filled chip would compete with what it annotates. |
|
|
19366
|
-
| size | "md" \| "sm" | "md" |
|
|
20318
|
+
| size | "lg" \| "md" \| "sm" | "md" | lg sits on --control-sm (32px, 28 compact), the toolbar rung it shares with SearchInput sm and Button sm, for a filter row beside a search field. md sits on --control-xs (28px, 24 compact), the chip in a row of its own or among content. sm is a fixed 24px (the target floor, which density does not shrink), for chips under or inside other content. |
|
|
19367
20319
|
| tone | "neutral" \| "red" \| "orange" \| "yellow" \| "green" \| "teal" \| "blue" \| "purple" \| "pink" | "neutral" | neutral is the chip as described. A palette family colours it as a category, like the choices in a tag picker: a wash at rest, the solid family colour when selected. Painted from the theme's generated palette. |
|
|
19368
20320
|
| icon | ReactNode | — | Optional leading glyph (e.g. <Icon name="lock" />), rendered before the children with the base gap. |
|
|
19369
20321
|
|
|
19370
20322
|
## Variants
|
|
19371
20323
|
|
|
19372
20324
|
- **appearance**: solid, outline
|
|
19373
|
-
- **size**: md, sm
|
|
20325
|
+
- **size**: lg, md, sm
|
|
19374
20326
|
- **selected**: true, false
|
|
19375
20327
|
- **tone**: neutral, red, orange, yellow, green, teal, blue, purple, pink
|
|
19376
20328
|
|
|
@@ -19393,7 +20345,7 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
|
|
|
19393
20345
|
|
|
19394
20346
|
## Related
|
|
19395
20347
|
|
|
19396
|
-
- Components: Badge, ToggleGroup
|
|
20348
|
+
- Components: Badge, ToggleGroup, SearchInput
|
|
19397
20349
|
|
|
19398
20350
|
## Examples
|
|
19399
20351
|
|
|
@@ -19413,6 +20365,24 @@ import { Pill, Row } from "@sableui/react"
|
|
|
19413
20365
|
<Row gap={1}><Pill selected onClick={() => {}}>All</Pill><Pill onClick={() => {}}>Projects</Pill><Pill onClick={() => {}}>Reports</Pill></Row>
|
|
19414
20366
|
```
|
|
19415
20367
|
|
|
20368
|
+
### Filters with search
|
|
20369
|
+
|
|
20370
|
+
```tsx
|
|
20371
|
+
import { Pill, Row, SearchInput } from "@sableui/react"
|
|
20372
|
+
|
|
20373
|
+
const [filter, setFilter] = useState("all")
|
|
20374
|
+
const [q, setQ] = useState("")
|
|
20375
|
+
|
|
20376
|
+
<Row gap={2} align="center" justify="between" wrap>
|
|
20377
|
+
<Row gap={1}>
|
|
20378
|
+
<Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>
|
|
20379
|
+
<Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>
|
|
20380
|
+
<Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>
|
|
20381
|
+
</Row>
|
|
20382
|
+
<SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project…" aria-label="Find a project" className="max-w-64" />
|
|
20383
|
+
</Row>
|
|
20384
|
+
```
|
|
20385
|
+
|
|
19416
20386
|
### Outline, small (reactions)
|
|
19417
20387
|
|
|
19418
20388
|
```tsx
|
|
@@ -20431,7 +21401,7 @@ import { PropertyRow, ScrubField, Slider } from "@sableui/react"
|
|
|
20431
21401
|
|
|
20432
21402
|
**Status:** stable
|
|
20433
21403
|
|
|
20434
|
-
Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value.
|
|
21404
|
+
Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value. size md (form), sm (toolbar, matching Pill lg and Button sm) or xs (inset tool-panel field).
|
|
20435
21405
|
|
|
20436
21406
|
**Keywords:** search, input, filter, query
|
|
20437
21407
|
|
|
@@ -20445,6 +21415,8 @@ Use SearchInput for text queries that filter or search a known content set.
|
|
|
20445
21415
|
- Do: Provide a visible clear affordance when query state persists.
|
|
20446
21416
|
- Do: When a button stands in for search (a header trigger that opens CommandMenu with ⌘K), use CommandMenuTrigger. It wears this field's shell from the same source, so the two cannot drift; a hand-styled button already did once, into a borderless chip that matched neither the field nor a button.
|
|
20447
21417
|
- Do: Light the query in the results it filters with `useMatchHighlight(query)` — a callback ref for the results container, with `selector` to keep it to titles. It paints on the `highlight` pair without wrapping matches in `<mark>`, the same paint Combobox and CommandMenu use for their rows.
|
|
21418
|
+
- Do: In a toolbar beside filter chips, use size="sm" with Pill size="lg" (and Button size="sm" for actions). All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Keep md for a search field in a form or a page header beside md buttons, and xs for a filter inside a workspace panel, where it matches Input xs's inset surface.
|
|
21419
|
+
- Don't: Do not match a neighbour's height with h-* on className or inputClassName. The magnifier, padding and clear button would stay sized for the old rung; pick the size that shares the neighbour's rung.
|
|
20448
21420
|
- Don't: Do not use SearchInput as a command palette; use CommandMenu for global commands, opened from the header by CommandMenuTrigger. A SearchInput that opens a dialog on focus is a text box you cannot type in.
|
|
20449
21421
|
- Don't: Do not search on every keystroke against an expensive backend without debouncing. The user sees results flicker and reorder under them, and you send a request per character.
|
|
20450
21422
|
|
|
@@ -20452,6 +21424,7 @@ Use SearchInput for text queries that filter or search a known content set.
|
|
|
20452
21424
|
|
|
20453
21425
|
| Name | Type | Default | Description |
|
|
20454
21426
|
| --- | --- | --- | --- |
|
|
21427
|
+
| size | "md" \| "sm" \| "xs" | "md" | The rung of the control ladder, named as on Input, Select and Combobox. md is the form field (--control-md, 40px / 32 compact). sm is the toolbar field (--control-sm, 32px / 28 compact), the rung Pill lg and Button sm share, so filters + search + actions in one row are one height. xs is the tool-panel field (--control-xs, 28px / 24 compact) on Input xs's inset panel-field surface. The magnifier, inline padding and clear button scale with the rung and stay vertically centred. |
|
|
20455
21428
|
| value | string | — | Controlled value of the search field. |
|
|
20456
21429
|
| defaultValue | string | — | Uncontrolled initial value. The field tracks its own text, so the clear button still appears and empties it. |
|
|
20457
21430
|
| className | string | — | Sizes and places the whole field — magnifier, input and clear button together (e.g. max-w-80). A width cap here keeps the clear button inside the field. |
|
|
@@ -20460,11 +21433,11 @@ Use SearchInput for text queries that filter or search a known content set.
|
|
|
20460
21433
|
| onClear | () => void | — | Called when the clear button is pressed. The component also fires onValueChange('') so a controlled value resets. |
|
|
20461
21434
|
| placeholder | string | "Search" | Placeholder text shown when the field is empty. |
|
|
20462
21435
|
| disabled | boolean | false | Dims the control and blocks pointer interaction. |
|
|
20463
|
-
| ...props | ComponentPropsWithRef<"input"> | — | All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange,
|
|
21436
|
+
| ...props | ComponentPropsWithRef<"input"> | — | All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange, className, which sizes the whole field, and the native numeric size attribute, which the size prop replaces. |
|
|
20464
21437
|
|
|
20465
21438
|
## Tokens used
|
|
20466
21439
|
|
|
20467
|
-
`field-border`, `field`, `foreground`, `muted-foreground`, `ring`, `destructive`
|
|
21440
|
+
`field-border`, `field`, `foreground`, `muted-foreground`, `ring`, `destructive`, `panel-field`, `panel-field-hover`
|
|
20468
21441
|
|
|
20469
21442
|
## Motion
|
|
20470
21443
|
|
|
@@ -20483,7 +21456,7 @@ Use SearchInput for text queries that filter or search a known content set.
|
|
|
20483
21456
|
|
|
20484
21457
|
## Related
|
|
20485
21458
|
|
|
20486
|
-
- Components: Input, CommandMenu, Combobox
|
|
21459
|
+
- Components: Input, CommandMenu, Combobox, Pill
|
|
20487
21460
|
|
|
20488
21461
|
## Examples
|
|
20489
21462
|
|
|
@@ -20529,6 +21502,36 @@ const shown = posts.filter((post) => post.title.toLowerCase().includes(q.trim().
|
|
|
20529
21502
|
</ul>
|
|
20530
21503
|
```
|
|
20531
21504
|
|
|
21505
|
+
### Filters with search
|
|
21506
|
+
|
|
21507
|
+
```tsx
|
|
21508
|
+
import { Pill, Row, SearchInput } from "@sableui/react"
|
|
21509
|
+
|
|
21510
|
+
const [filter, setFilter] = useState("all")
|
|
21511
|
+
const [q, setQ] = useState("")
|
|
21512
|
+
|
|
21513
|
+
<Row gap={2} align="center" justify="between" wrap>
|
|
21514
|
+
<Row gap={1}>
|
|
21515
|
+
<Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>
|
|
21516
|
+
<Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>
|
|
21517
|
+
<Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>
|
|
21518
|
+
</Row>
|
|
21519
|
+
<SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project…" aria-label="Find a project" className="max-w-64" />
|
|
21520
|
+
</Row>
|
|
21521
|
+
```
|
|
21522
|
+
|
|
21523
|
+
### Sizes
|
|
21524
|
+
|
|
21525
|
+
```tsx
|
|
21526
|
+
import { SearchInput, Stack } from "@sableui/react"
|
|
21527
|
+
|
|
21528
|
+
<Stack gap={1}>
|
|
21529
|
+
<SearchInput size="md" placeholder="Search posts" aria-label="Search posts" />
|
|
21530
|
+
<SearchInput size="sm" placeholder="Find a project…" aria-label="Find a project" />
|
|
21531
|
+
<SearchInput size="xs" placeholder="Filter layers" aria-label="Filter layers" />
|
|
21532
|
+
</Stack>
|
|
21533
|
+
```
|
|
21534
|
+
|
|
20532
21535
|
### Disabled
|
|
20533
21536
|
|
|
20534
21537
|
```tsx
|
|
@@ -20545,7 +21548,7 @@ import { SearchInput } from "@sableui/react"
|
|
|
20545
21548
|
|
|
20546
21549
|
The heading block for one section of a page: a title (h2 by default), an optional line of description, and actions on the title row — a "View all" link or a couple of buttons — that drop beneath the title only when the row runs out of room.
|
|
20547
21550
|
|
|
20548
|
-
**Keywords:** section header, section heading, section title, shelf, view all, see all, show all, h2, subsection, optical alignment, bleed
|
|
21551
|
+
**Keywords:** section header, section heading, section title, shelf, view all, see all, show all, h2, subsection, optical alignment, bleed, editorial section, large section title
|
|
20549
21552
|
|
|
20550
21553
|
## Usage
|
|
20551
21554
|
|
|
@@ -20554,7 +21557,7 @@ Use SectionHeader to head each section below the page title — a home-page shel
|
|
|
20554
21557
|
### Best practices
|
|
20555
21558
|
|
|
20556
21559
|
- Do: Wrap the header and its content in a `<section aria-labelledby={titleId}>` (a flex column with a gap, or a Stack inside it) so the section is a named region and the header's title is its name.
|
|
20557
|
-
- Do: Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: md for a page's sections and shelves, sm for denser groups like settings.
|
|
21560
|
+
- Do: Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: lg for an editorial page's sections under a PageHeader size="lg", md for a page's sections and shelves, sm for denser groups like settings.
|
|
20558
21561
|
- Do: Keep actions to one or two `size="sm"` buttons — the title is centred on that height. A shelf's "View all" is a ghost Button rendered as a link (`render={<a href=… />}`), named for what it opens when the label alone is ambiguous (aria-label="View all new releases").
|
|
20559
21562
|
- Do: Pass a ghost or link action as a plain Button and let the header align it: the last direct child bleeds by its own padding at the end, so "View all" sits on the edge of the grid below, and the first bleeds at the start, which is the edge once the actions wrap beneath the title. Filled buttons keep their edge on the line. Set bleed="none" on the Button to opt one out.
|
|
20560
21563
|
- Don't: Nudge a View all onto the edge with -me-3, or wrap the actions in your own div: the header aligns direct children only, and only by each button's real padding. If you must wrap them, set bleed on the Button inside instead.
|
|
@@ -20579,11 +21582,11 @@ Use SectionHeader to head each section below the page title — a home-page shel
|
|
|
20579
21582
|
| actions | ReactNode | — | Trailing actions on the title row — a View all link or a couple of buttons, size="sm". They stay on the title's row and drop beneath it only when the row runs out of room; the title wraps rather than truncating. A ghost or link Button that ends the group bleeds by its padding at the end (and one that starts it, at the start — the edge once wrapped), so its label lines up with the content below; Button bleed="none" opts out. Direct children only. |
|
|
20580
21583
|
| titleAs | "h2" \| "h3" \| "h4" | "h2" | The heading element, chosen by the document outline: h2 for a section under the page's h1, h3 for a subsection inside one. |
|
|
20581
21584
|
| titleId | string | — | The title's id, so the section it heads can point at it: <section aria-labelledby={titleId}>. Generated when omitted. |
|
|
20582
|
-
| size | "md" \| "sm" | "md" | Visual weight, independent of the heading level: md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections. |
|
|
21585
|
+
| size | "lg" \| "md" \| "sm" | "md" | Visual weight, independent of the heading level: lg (text-heading-2 over a text-body-md description) for an editorial page's sections, under a PageHeader size="lg"; md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections. lg stops at heading-2: heading-1 is the page title's, one per route. The actions stay centred on the title's first line at every size. |
|
|
20583
21586
|
|
|
20584
21587
|
## Variants
|
|
20585
21588
|
|
|
20586
|
-
- **size**: md, sm
|
|
21589
|
+
- **size**: lg, md, sm
|
|
20587
21590
|
|
|
20588
21591
|
## Tokens used
|
|
20589
21592
|
|
|
@@ -20676,6 +21679,30 @@ import { Button, Entity, Icon, SectionHeader, Stack } from "@sableui/react"
|
|
|
20676
21679
|
</section>
|
|
20677
21680
|
```
|
|
20678
21681
|
|
|
21682
|
+
### An editorial section (size lg)
|
|
21683
|
+
|
|
21684
|
+
```tsx
|
|
21685
|
+
import { Button, Card, CardDescription, CardHeader, CardTitle, Grid, SectionHeader } from "@sableui/react"
|
|
21686
|
+
|
|
21687
|
+
<section aria-labelledby="selected-work" className="flex flex-col gap-6">
|
|
21688
|
+
<SectionHeader
|
|
21689
|
+
titleId="selected-work"
|
|
21690
|
+
size="lg"
|
|
21691
|
+
title="Selected work"
|
|
21692
|
+
description="Five projects from the last two years."
|
|
21693
|
+
actions={
|
|
21694
|
+
<Button variant="ghost" size="sm" render={<a href="/work" />} aria-label="View all work">
|
|
21695
|
+
View all
|
|
21696
|
+
</Button>
|
|
21697
|
+
}
|
|
21698
|
+
/>
|
|
21699
|
+
<Grid minItemWidth="16rem" gap={3}>
|
|
21700
|
+
<Card><CardHeader><CardTitle>Tidewater</CardTitle><CardDescription>Brand identity</CardDescription></CardHeader></Card>
|
|
21701
|
+
<Card><CardHeader><CardTitle>Northlight</CardTitle><CardDescription>Product design</CardDescription></CardHeader></Card>
|
|
21702
|
+
</Grid>
|
|
21703
|
+
</section>
|
|
21704
|
+
```
|
|
21705
|
+
|
|
20679
21706
|
### A subsection inside a section
|
|
20680
21707
|
|
|
20681
21708
|
```tsx
|
|
@@ -21269,7 +22296,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
21269
22296
|
|
|
21270
22297
|
| Name | Type | Default | Description |
|
|
21271
22298
|
| --- | --- | --- | --- |
|
|
21272
|
-
| Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. |
|
|
22299
|
+
| Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. Records the element focused when the sheet opens, trigger or not, and returns focus there on close. |
|
|
21273
22300
|
| snapPoints | (number \| string)[] | — | Stops a bottom sheet rests at, smallest first: a fraction of the viewport (0.4), pixels (320), or a px/rem string ("20rem"). The panel is the full stop's height and opens at the first stop; a swipe moves between stops and a swipe below the first closes it. side="bottom" only: other sides ignore them, with a development warning. |
|
|
21274
22301
|
| snapPoint | number \| string \| null | — | The active stop, controlled. Pair with onSnapPointChange; use defaultSnapPoint to open at a different stop uncontrolled. |
|
|
21275
22302
|
| onSnapPointChange | (snapPoint: number \| string \| null, eventDetails) => void | — | Fires when a swipe (or your code) moves the sheet to another stop. |
|
|
@@ -21289,7 +22316,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
21289
22316
|
- The panel scrolls its own content (thin scrollbar, contained overscroll) when it outgrows its bound — the full height on left/right, 80vh on top/bottom.
|
|
21290
22317
|
- Esc and click-outside dismiss by default; on touch, a swipe toward the sheet's own edge dismisses too. A short or slow drag springs back.
|
|
21291
22318
|
- Swipe is an extra path, never the only one: Esc, click-outside and SheetClose all still work.
|
|
21292
|
-
- Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down)
|
|
22319
|
+
- Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down). On close, focus returns to the element that held it when the sheet opened: the SheetTrigger, or your own button when it drives a controlled open. It is captured before the content mounts, so an autoFocus field inside cannot displace it; an explicit finalFocus on SheetContent wins.
|
|
21293
22320
|
- Render a SheetClose inside SheetContent so screen-reader users can escape the popup.
|
|
21294
22321
|
- Slide animation runs via data-[starting-style]/data-[ending-style] attributes on the motion-duration token; while swiping the panel tracks the finger directly, and a released swipe finishes on the same token (scaled by flick speed), so reduced motion makes it instant.
|
|
21295
22322
|
- A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge.
|
|
@@ -21419,9 +22446,9 @@ import { ShowMore } from "@sableui/react"
|
|
|
21419
22446
|
|
|
21420
22447
|
**Status:** stable
|
|
21421
22448
|
|
|
21422
|
-
Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
|
|
22449
|
+
Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarGroup / SidebarItem / SidebarFooter / SidebarAccount parts, all on one set of columns. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.
|
|
21423
22450
|
|
|
21424
|
-
**Keywords:** sidebar, navigation, app chrome, rail
|
|
22451
|
+
**Keywords:** sidebar, navigation, app chrome, rail, nav group, section label, account, user menu, profile, account switcher, workspace switcher, sidebar footer
|
|
21425
22452
|
|
|
21426
22453
|
## Usage
|
|
21427
22454
|
|
|
@@ -21433,6 +22460,9 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
|
|
|
21433
22460
|
- Do: Keep item labels predictable and active state obvious.
|
|
21434
22461
|
- Do: Set surface="page" when a full-width top bar sits above the rail. A theme's tinted or dark rail is for a full-height rail that frames the app.
|
|
21435
22462
|
- Do: Keep surface="rail" inside AppShell frame="inset": the rail's tint becomes the ground the main panel floats on. Do not add a border-r class back — the gap is the divider.
|
|
22463
|
+
- Do: Group items with SidebarGroup and end the rail with one SidebarAccount that opens the account menu (settings, appearance, sign out). Every part sits on the same icon and label columns, at either density.
|
|
22464
|
+
- Don't: Do not put SideNav, an Entity or a stack of ghost Buttons in the rail. Each brings its own insets, so the icons and labels land on four different columns. SideNav is the in-page section list; the rail has its own parts.
|
|
22465
|
+
- Don't: Do not put a Badge or pill in the account row. Its one quiet detail line carries the plan, role or 'Demo'.
|
|
21436
22466
|
- Don't: Do not use Sidebar for page-local settings, filters, or temporary action groups.
|
|
21437
22467
|
- Don't: Do not put page content in it. It is fixed-width chrome OUTSIDE the content grid, so anything in it is exempt from the page's measure and rhythm.
|
|
21438
22468
|
|
|
@@ -21447,9 +22477,11 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
|
|
|
21447
22477
|
|
|
21448
22478
|
| Name | Type | Description |
|
|
21449
22479
|
| --- | --- | --- |
|
|
21450
|
-
| SidebarHeader | div props | 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. |
|
|
22480
|
+
| SidebarHeader | div props | The top of the rail, at least 64px tall (the chrome bar's height inside a compact Workspace, so it lines up with the WorkspaceBar beside it). 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. |
|
|
21451
22481
|
| SidebarBrand | { mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props | The brand row, inside SidebarHeader, on the same grid as SidebarItem: mark in the icons' 24px slot, label on the labels' column, so a logo lines up with the nav below it without copying the rail's inset numbers. Collapsed, it shows the mark alone (named by a string label). render={<Link to="/" />} makes it the home link. |
|
|
21452
|
-
| SidebarItem | { label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props | A single nav row. label is the row text (inline when expanded, in a hover tooltip when collapsed); icon is the leading icon node (the consumer swaps fill weight for the active state); active marks the current route (accent fill + aria-current="page"); badge is a trailing node (e.g. a count) pushed to the row end and hidden when collapsed; render composes another element, e.g. render={<Link to="/x" />}. |
|
|
22482
|
+
| SidebarItem | { label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props | A single nav row, 44px on a page and 32px inside a compact scope such as a Workspace (the rail reads the density; nothing to set). label is the row text (inline when expanded, in a hover tooltip when collapsed); icon is the leading icon node (the consumer swaps fill weight for the active state); active marks the current route (accent fill + aria-current="page"); badge is a trailing node (e.g. a count) pushed to the row end and hidden when collapsed; render composes another element, e.g. render={<Link to="/x" />}. |
|
|
22483
|
+
| SidebarGroup | { label?: ReactNode } + div props | A labelled run of SidebarItems inside SidebarNav ("Workspace", "Favorites"). The label starts on the items' icon column; collapsed, a hairline stands in for it and the label stays as the group's accessible name. |
|
|
22484
|
+
| SidebarAccount | { avatar: ReactNode; label: ReactNode; detail?: ReactNode; indicator?: ReactNode \| false; render?: useRender.RenderProp } + button props | The account row at the foot of the rail, inside SidebarFooter: the avatar in the icon slot (Avatar xs on a page rail, 2xs in a compact one), the label on the label column, one muted detail line, and an up-down chevron. Make it the trigger of the account Menu (render it through MenuTrigger render) and put Settings, appearance and sign-out there; it holds its fill while the menu is open. Collapsed, the avatar alone, with the label in a tooltip. |
|
|
21453
22485
|
|
|
21454
22486
|
## Props
|
|
21455
22487
|
|
|
@@ -21509,6 +22541,14 @@ const [collapsed, setCollapsed] = useState(false)
|
|
|
21509
22541
|
<Sidebar collapsed={collapsed}><SidebarNav><SidebarItem icon={<Icon name="home" />} label="Home" active render={<Link to="/" />} /></SidebarNav><SidebarFooter><Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle sidebar"><Icon name="chevron-left" /></Button></SidebarFooter></Sidebar>
|
|
21510
22542
|
```
|
|
21511
22543
|
|
|
22544
|
+
### Grouped nav with an account menu
|
|
22545
|
+
|
|
22546
|
+
```tsx
|
|
22547
|
+
import { Avatar, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarGroup, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
|
|
22548
|
+
|
|
22549
|
+
<Sidebar><SidebarHeader><SidebarBrand mark={<Avatar shape="square" size="xs" fallback="F" alt="" />} label="Forma Studio" /></SidebarHeader><SidebarNav><SidebarItem icon={<Icon name="inbox" />} label="Inbox" badge={<span className="text-ui-num">4</span>} /><SidebarGroup label="Workspace"><SidebarItem icon={<Icon name="folder" />} label="Projects" active /><SidebarItem icon={<Icon name="users" />} label="Clients" /></SidebarGroup></SidebarNav><SidebarFooter><Menu><MenuTrigger render={<SidebarAccount avatar={<Avatar size="xs" fallback="EL" alt="" />} label="Emma Lee" detail="Forma Studio" />} /><MenuContent side="top" align="start"><MenuItem>Settings</MenuItem><MenuSeparator /><MenuItem>Sign out</MenuItem></MenuContent></Menu></SidebarFooter></Sidebar>
|
|
22550
|
+
```
|
|
22551
|
+
|
|
21512
22552
|
### Inline (inside a bounded frame)
|
|
21513
22553
|
|
|
21514
22554
|
```tsx
|
|
@@ -22623,6 +23663,7 @@ Use Tabs to switch between related panels within the same page context.
|
|
|
22623
23663
|
### Best practices
|
|
22624
23664
|
|
|
22625
23665
|
- Do: Use tabs when each panel is peer content and users may compare or switch often.
|
|
23666
|
+
- Do: In tool chrome (a WorkspaceBar, a compact panel header) use variant="pill" for a view switch such as Overview / Issues / Updates: it reads as a control beside the bar's other controls. Underline is the page-section idiom; inside a WorkspaceBar it sits on the bar's hairline rather than drawing its own.
|
|
22626
23667
|
- Do: Keep tab labels short and make the active tab unmistakable.
|
|
22627
23668
|
- Do: Use variant="pill" for a short switcher inside a card or toolbar (Preview / Code, List / Grid); keep the default underline for sections of a page.
|
|
22628
23669
|
- Don't: Do not use Tabs for unrelated pages or primary navigation that needs distinct URLs. → use **SideNav**, **NavigationMenu**
|
|
@@ -24111,7 +25152,7 @@ import { Video } from "@sableui/react"
|
|
|
24111
25152
|
|
|
24112
25153
|
The shell for a tool: a fixed viewport partitioned into independently scrolling regions, with a compact density scope, a skip link, and a canvas landmark.
|
|
24113
25154
|
|
|
24114
|
-
**Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport
|
|
25155
|
+
**Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport, issue tracker, tracker, inspector, reading view, mobile, responsive, bottom nav, media query, breakpoint, top bar, topbar
|
|
24115
25156
|
|
|
24116
25157
|
## Usage
|
|
24117
25158
|
|
|
@@ -24122,6 +25163,9 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
|
|
|
24122
25163
|
- Do: Put a PanelGroup in the body and a WorkspaceCanvas inside it, so panels are aside chrome and the work surface is the main landmark.
|
|
24123
25164
|
- Do: Let each panel own its scrolling. The shell never scrolls, and the canvas clips rather than scrolls — panning is the viewport's job.
|
|
24124
25165
|
- Do: Keep the density scope at compact unless you have a specific reason; the workspace register's measured control ladder is what makes tool chrome read as tool chrome.
|
|
25166
|
+
- Do: Put a WorkspaceBar inside a column when the bar belongs to that column only. An issue tracker's bar (breadcrumb, view tabs, filters) sits above the list, while the sidebar and the inspector beside it run the full height: put the WorkspaceBar first inside the WorkspaceCanvas, not in the `toolbar` slot, which spans every panel. Don't hand-build a fixed-height header for it.
|
|
25167
|
+
- Do: Wrap a reading view inside the workspace (a project overview, an update, a doc, release notes) in <Density value="comfortable">. The compact scope maps body text to the 11–12px UI tiers, which is right for chrome and too small for paragraphs; the comfortable scope gives prose its page size again while the chrome around it stays compact.
|
|
25168
|
+
- Do: Plan the phone layout with the shell rather than around it: pass a BottomNav to `bottomNav`, and give docked Panels `narrow="sheet"` (an inspector, a navigation rail) or `narrow="hidden"`. Don't measure the viewport with a resize listener, render the inspector twice, or subtract the nav's height from 100dvh.
|
|
24125
25169
|
- Don't: Nest a Region or Page inside it to set a width. Region caps are a page-register idea; a workspace is edge-to-edge and its panels are fixed-width chrome outside the content grid.
|
|
24126
25170
|
- Don't: Hardcode a control height (h-[32px]). CI rejects it (raw-control-height); read the ladder instead, e.g. h-(--control-sm), so the density scope can size it.
|
|
24127
25171
|
- Don't: Put a Card in the chrome. Use PanelSection — a card inside a narrow panel spends its border and inset on nothing.
|
|
@@ -24135,6 +25179,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
|
|
|
24135
25179
|
| Body | yes | The flexible middle row, normally a PanelGroup. Constrained so its children can shrink and scroll. |
|
|
24136
25180
|
| WorkspaceCanvas | no | The work surface and the main#main-content landmark the skip link targets. Clips rather than scrolls. |
|
|
24137
25181
|
| StatusBar | no | Bottom chrome for ambient document facts. |
|
|
25182
|
+
| BottomNav | no | Phone navigation in the `bottomNav` slot. Its row is reserved below lg and gone above it, so the body ends where the nav begins. |
|
|
24138
25183
|
|
|
24139
25184
|
## Subcomponents
|
|
24140
25185
|
|
|
@@ -24142,6 +25187,8 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
|
|
|
24142
25187
|
| --- | --- | --- |
|
|
24143
25188
|
| WorkspaceBar | ComponentProps<'header'> | The top chrome strip, sized to --chrome-bar-height with a hairline beneath. |
|
|
24144
25189
|
| WorkspaceCanvas | ComponentProps<'main'> | The work surface: renders main#main-content, fills the body, clips its overflow. |
|
|
25190
|
+
| useMediaQuery | (query: string, options?: { serverValue?: boolean }) => boolean | True while a media query matches, read through useSyncExternalStore: no resize listener, no setState in an effect, no hydration mismatch (the server reports serverValue, default false). Use it to branch structure a class cannot express; style-only changes stay as lg: classes. |
|
|
25191
|
+
| useBelowBreakpoint | (breakpoint?: "sm" \| "md" \| "lg" \| "xl" \| "2xl", options?: { serverValue?: boolean }) => boolean | True below a named breakpoint (default lg), flipping at the width where that breakpoint's classes start to apply (Tailwind's sm 40 / md 48 / lg 64 / xl 80 / 2xl 96rem). Panel's `narrow` uses it; reach for it instead of a hand-rolled window.innerWidth check. |
|
|
24145
25192
|
|
|
24146
25193
|
## Props
|
|
24147
25194
|
|
|
@@ -24149,6 +25196,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
|
|
|
24149
25196
|
| --- | --- | --- | --- |
|
|
24150
25197
|
| toolbar | ReactNode | — | Top chrome, normally a WorkspaceBar. |
|
|
24151
25198
|
| statusBar | ReactNode | — | Bottom chrome, normally a StatusBar. |
|
|
25199
|
+
| bottomNav | ReactNode | — | Phone navigation, normally a BottomNav. Below lg (BottomNav's own breakpoint) the shell reserves the nav's height as its last row, so the body and the status bar end above the nav instead of under it; above lg the row is gone. Every slot is optional: the body is the shell's one growing row whichever of toolbar, statusBar and bottomNav are present. |
|
|
24152
25200
|
| density | "compact" \| "comfortable" | "compact" | Density scope for everything inside. Sets the --control-* ladder via data-density; compact is the workspace register. Nothing hardcodes a height, so the same size="md" Button is 40px on a page and 32px inside a compact Workspace. A Dialog opened from inside resets to comfortable, because a modal is a page-register surface even when a tool launched it. |
|
|
24153
25201
|
| skipLinkLabel | string | "Skip to canvas" | Text for the skip link that targets the canvas landmark. |
|
|
24154
25202
|
|
|
@@ -24230,6 +25278,79 @@ import { Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspa
|
|
|
24230
25278
|
</Workspace>
|
|
24231
25279
|
```
|
|
24232
25280
|
|
|
25281
|
+
### Issue tracker: column bar, docked inspector, phone sheet
|
|
25282
|
+
|
|
25283
|
+
```tsx
|
|
25284
|
+
import { BottomNav, BottomNavItem, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, Breadcrumbs, BreadcrumbSeparator, Button, Icon, Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/react"
|
|
25285
|
+
|
|
25286
|
+
function Tracker() {
|
|
25287
|
+
const [selected, setSelected] = useState<string | null>("AES-104")
|
|
25288
|
+
return (
|
|
25289
|
+
<Workspace
|
|
25290
|
+
bottomNav={
|
|
25291
|
+
<BottomNav>
|
|
25292
|
+
<BottomNavItem showLabel label="Issues" icon={<Icon name="list" />} active />
|
|
25293
|
+
<BottomNavItem showLabel label="Inbox" icon={<Icon name="inbox" />} />
|
|
25294
|
+
</BottomNav>
|
|
25295
|
+
}
|
|
25296
|
+
statusBar={<StatusBar><span>12 issues</span></StatusBar>}
|
|
25297
|
+
>
|
|
25298
|
+
<PanelGroup>
|
|
25299
|
+
<Panel label="Navigation" width={220} narrow="hidden">
|
|
25300
|
+
<PanelSection title="Workspace">
|
|
25301
|
+
<PropertyRow label="Team"><Input defaultValue="Studio" /></PropertyRow>
|
|
25302
|
+
</PanelSection>
|
|
25303
|
+
</Panel>
|
|
25304
|
+
<WorkspaceCanvas className="flex flex-col">
|
|
25305
|
+
<WorkspaceBar>
|
|
25306
|
+
<Breadcrumbs>
|
|
25307
|
+
<BreadcrumbItem><BreadcrumbLink href="#">Studio</BreadcrumbLink></BreadcrumbItem>
|
|
25308
|
+
<BreadcrumbSeparator />
|
|
25309
|
+
<BreadcrumbItem><BreadcrumbPage>Digital flagship</BreadcrumbPage></BreadcrumbItem>
|
|
25310
|
+
</Breadcrumbs>
|
|
25311
|
+
</WorkspaceBar>
|
|
25312
|
+
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
|
|
25313
|
+
<Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
|
|
25314
|
+
</div>
|
|
25315
|
+
</WorkspaceCanvas>
|
|
25316
|
+
<Panel
|
|
25317
|
+
label="Issue details"
|
|
25318
|
+
width={320}
|
|
25319
|
+
narrow="sheet"
|
|
25320
|
+
open={selected !== null}
|
|
25321
|
+
onOpenChange={(open) => !open && setSelected(null)}
|
|
25322
|
+
>
|
|
25323
|
+
<PanelSection title={selected ?? ""}>
|
|
25324
|
+
<PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
|
|
25325
|
+
</PanelSection>
|
|
25326
|
+
</Panel>
|
|
25327
|
+
</PanelGroup>
|
|
25328
|
+
</Workspace>
|
|
25329
|
+
)
|
|
25330
|
+
}
|
|
25331
|
+
```
|
|
25332
|
+
|
|
25333
|
+
### Reading view inside a workspace
|
|
25334
|
+
|
|
25335
|
+
```tsx
|
|
25336
|
+
import { Density, PanelGroup, Workspace, WorkspaceCanvas } from "@sableui/react"
|
|
25337
|
+
|
|
25338
|
+
<Workspace>
|
|
25339
|
+
<PanelGroup>
|
|
25340
|
+
<WorkspaceCanvas className="overflow-y-auto scrollbar-quiet">
|
|
25341
|
+
<Density value="comfortable">
|
|
25342
|
+
<article className="max-w-prose p-6">
|
|
25343
|
+
<h2 className="text-heading-3">Project overview</h2>
|
|
25344
|
+
<p className="mt-3 text-body-md text-muted-foreground">
|
|
25345
|
+
Paragraphs keep their page size here, while the chrome around the canvas stays compact.
|
|
25346
|
+
</p>
|
|
25347
|
+
</article>
|
|
25348
|
+
</Density>
|
|
25349
|
+
</WorkspaceCanvas>
|
|
25350
|
+
</PanelGroup>
|
|
25351
|
+
</Workspace>
|
|
25352
|
+
```
|
|
25353
|
+
|
|
24233
25354
|
### Comfortable density
|
|
24234
25355
|
|
|
24235
25356
|
```tsx
|
|
@@ -24439,8 +25560,10 @@ Which token to reach for, and what it is most often confused with. Generated fro
|
|
|
24439
25560
|
- **`region-ultrawide (legacy — prefer region-full)`** — Deprecated. Kept so existing usage keeps resolving. *Use for:* Nothing new. Use `region-full`.
|
|
24440
25561
|
- **Not** `region-full`, which is what this became.
|
|
24441
25562
|
|
|
24442
|
-
### typographyOverrides (
|
|
25563
|
+
### typographyOverrides (61)
|
|
24443
25564
|
|
|
25565
|
+
- **`type-base`** — The base size the page type scale is built on, as a unitless px number — 16 by default. Every page tier keeps its ratio to it, so `14` rebuilds the whole scale at 14, and the baseline step and spacing follow the body down. *Use for:* When the product asks for smaller or larger type everywhere: a dense technical tool at 13-14, a reading product at 17. Set it on :root, a theme scope or a preset (`tokens.shared["type-base"]: "14"`), not per component.
|
|
25566
|
+
- **Not** Changing the root font-size, which also moves every rem the app wrote and the control ladder; or overriding tier sizes one by one, which breaks the ratios between them. Unitless: `14`, never `14px`.
|
|
24444
25567
|
- **`type-display-4xl-size`** — The largest type: display-lg through display-4xl. Marketing-scale, one per view at most. *Use for:* A landing page hero, a section opener on a brand surface.
|
|
24445
25568
|
- **Not** `heading-1` for page titles. Display is a *statement*; headings are *structure*. A settings page titled in display-3xl looks like an advert.
|
|
24446
25569
|
- **`type-title-lg-size`** — The title of a *component*, not of a document. *Use for:* A card's heading, a dialog title, a section label inside a panel.
|
|
@@ -24714,7 +25837,7 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
24714
25837
|
- **Instead of** `scrollbar-quiet` on a vertical list. A vertical region with no bar and no fade tells a reader nothing about how far through they are.
|
|
24715
25838
|
- `<div className="flex gap-3 overflow-x-auto scrollbar-hidden">…</div>`
|
|
24716
25839
|
|
|
24717
|
-
### text (
|
|
25840
|
+
### text (13)
|
|
24718
25841
|
|
|
24719
25842
|
- **`sable-list`** — A bulleted list as typography: body type, a hanging muted marker, and item rhythm. Put it on a native `<ul>`. *Use for:* Running content with bullets: release notes, a feature list, steps that are not ordered.
|
|
24720
25843
|
- **Instead of** A stack of `<div>`s with a hand-drawn dot, or an unstyled `<ul>` that inherits the reset (no markers, no indent).
|
|
@@ -24731,6 +25854,9 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
|
|
|
24731
25854
|
- **`text-overline`** — The eyebrow / overline / kicker tier: small, letter-spaced, uppercase by default (a theme may set sentence case via `--type-overline-transform`). *Use for:* A section eyebrow above a heading, a stat's label, a badge-style category line. Sits ABOVE and categorises.
|
|
24732
25855
|
- **Instead of** `text-label-xs uppercase tracking-wider` assembled by hand, or `text-caption`, which sits BELOW and explains. In tool chrome, panel headers use `text-ui-section` instead.
|
|
24733
25856
|
- `<p className="text-overline text-muted-foreground">Latest news</p>`
|
|
25857
|
+
- **`text-ui-title`** — The title of what a panel or inspector shows — the open issue, the selected object: 14px at 600 on a 20px line, one step above `text-ui-md`. *Use for:* The one title at the top of an inspector, a docked panel or a peek view inside the workspace register. Compact `text-title-lg` resolves to it on its own.
|
|
25858
|
+
- **Instead of** `text-ui-md` made heavier (a title the size of the description under it reads as a label), or `text-heading-*`, which is page structure rather than chrome.
|
|
25859
|
+
- `<h2 className="text-ui-title text-panel-foreground">Refine product detail page</h2>`
|
|
24734
25860
|
- **`text-ui-md`** — Workspace-register chrome text, the default tier: ~12px at weight ~500 on 16px leading. *Use for:* Labels and values in tool chrome — panels, inspectors, toolbars, trees — inside the workspace register.
|
|
24735
25861
|
- **Instead of** `text-label-sm` or `text-body-sm` in a dense panel (the page register's weights read as shouting at this size), or a size class plus a weight class composed by hand.
|
|
24736
25862
|
- `<span className="text-ui-md text-panel-foreground">Opacity</span>`
|