@sableui/react 0.18.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/bin/sable.mjs +108 -14
  3. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  4. package/dist/blocks/index.d.ts +1 -1
  5. package/dist/components/avatar/avatar.js +16 -7
  6. package/dist/components/avatar/avatar.meta.js +43 -10
  7. package/dist/components/avatar/avatar.variants.js +26 -2
  8. package/dist/components/collapsible/collapsible.js +6 -5
  9. package/dist/components/collapsible/collapsible.meta.js +14 -5
  10. package/dist/components/data-table/data-table.js +132 -45
  11. package/dist/components/data-table/data-table.meta.js +63 -4
  12. package/dist/components/date-picker/date-picker.js +13 -6
  13. package/dist/components/date-picker/date-picker.meta.js +22 -0
  14. package/dist/components/menu/menu.js +2 -0
  15. package/dist/components/menu/menu.meta.js +2 -2
  16. package/dist/components/panel-group/panel-group.js +17 -1
  17. package/dist/components/panel-group/panel-group.meta.js +43 -3
  18. package/dist/components/side-nav/side-nav.variants.js +8 -3
  19. package/dist/components/sidebar/index.js +4 -0
  20. package/dist/components/sidebar/sidebar.js +90 -11
  21. package/dist/components/sidebar/sidebar.meta.js +38 -4
  22. package/dist/components/table/table.js +7 -3
  23. package/dist/components/tabs/tabs.js +10 -2
  24. package/dist/components/tabs/tabs.meta.js +4 -0
  25. package/dist/components/workspace/workspace.js +13 -3
  26. package/dist/components/workspace/workspace.meta.js +109 -1
  27. package/dist/file-hashes.json +53 -49
  28. package/dist/foundations/typography.js +13 -0
  29. package/dist/icon/Icon.js +21 -2
  30. package/dist/icon/index.d.ts +36 -1
  31. package/dist/icon/index.js +5 -0
  32. package/dist/icon/names.js +240 -0
  33. package/dist/icon-sets/reicon.js +19 -1
  34. package/dist/index.d.ts +272 -50
  35. package/dist/index.js +11 -1
  36. package/dist/lib/chart/heat.js +1 -1
  37. package/dist/lib/cn.js +1 -0
  38. package/dist/lib/use-media-query.js +41 -0
  39. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  40. package/dist/patterns/dashboard-page.js +23 -17
  41. package/dist/patterns/index.d.ts +7 -2
  42. package/dist/patterns/index.js +5 -1
  43. package/dist/patterns/tracker-page.js +392 -0
  44. package/dist/patterns/tracker-page.meta.js +40 -0
  45. package/dist/presets/index.d.ts +1 -1
  46. package/dist/styles.css +179 -74
  47. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  48. package/dist/themes/_contract.js +9 -1
  49. package/dist/themes/atelier/index.d.ts +1 -1
  50. package/dist/themes/ember/index.d.ts +1 -1
  51. package/dist/themes/index.d.ts +2 -2
  52. package/dist/themes/meridian/index.d.ts +1 -1
  53. package/dist/themes/poppy/index.d.ts +1 -1
  54. package/dist/themes/prism/index.d.ts +1 -1
  55. package/dist/themes/sable/index.d.ts +1 -1
  56. package/dist/themes/verdant/index.d.ts +1 -1
  57. package/llms/components/avatar.md +32 -9
  58. package/llms/components/collapsible.md +12 -4
  59. package/llms/components/data-table.md +40 -3
  60. package/llms/components/date-picker.md +15 -1
  61. package/llms/components/menu.md +1 -1
  62. package/llms/components/panel-group.md +34 -2
  63. package/llms/components/sidebar.md +17 -4
  64. package/llms/components/tabs.md +1 -0
  65. package/llms/components/workspace.md +81 -1
  66. package/llms/foundations/typography.md +4 -1
  67. package/llms/patterns/dashboard-page.md +18 -15
  68. package/llms/patterns/tracker-page.md +450 -0
  69. package/llms/reference/token-intent.md +3 -1
  70. package/llms/reference/utilities.md +4 -1
  71. package/llms/tasks/auditing-an-app.md +4 -1
  72. package/llms/tasks/building-a-page.md +3 -2
  73. package/llms/tasks/building-a-tool.md +101 -5
  74. package/llms/tasks/building-with-sable.md +11 -3
  75. package/llms/tasks/creating-a-preset.md +9 -0
  76. package/llms-full.txt +843 -54
  77. package/llms.txt +8 -7
  78. package/package.json +2 -1
  79. package/registry.json +491 -36
  80. package/scripts/adoption.mjs +15 -2
  81. package/scripts/app-css.mjs +129 -0
  82. package/scripts/grade.mjs +161 -2
  83. package/scripts/lint-rules.mjs +126 -0
  84. package/skills/sable/SKILL.md +33 -4
@@ -1,4 +1,4 @@
1
- import { T as ThemeManifest } from '../../_contract-CDa9-NdB.js';
1
+ import { T as ThemeManifest } from '../../_contract-DlklV0Im.js';
2
2
 
3
3
  /** Original Sable preset; no third-party artwork or reference-site code. */
4
4
  declare const poppyTheme: ThemeManifest;
@@ -1,4 +1,4 @@
1
- import { T as ThemeManifest } from '../../_contract-CDa9-NdB.js';
1
+ import { T as ThemeManifest } from '../../_contract-DlklV0Im.js';
2
2
 
3
3
  /** Original Sable preset; no third-party artwork or reference-site code. */
4
4
  declare const prismTheme: ThemeManifest;
@@ -1,4 +1,4 @@
1
- import { T as ThemeManifest } from '../../_contract-CDa9-NdB.js';
1
+ import { T as ThemeManifest } from '../../_contract-DlklV0Im.js';
2
2
 
3
3
  /**
4
4
  * Sable — the default theme. Concrete token values live in
@@ -1,4 +1,4 @@
1
- import { T as ThemeManifest } from '../../_contract-CDa9-NdB.js';
1
+ import { T as ThemeManifest } from '../../_contract-DlklV0Im.js';
2
2
 
3
3
  /**
4
4
  * Verdant — a Sable theme. Concrete token values live in
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Status:** stable
4
4
 
5
- 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.
5
+ 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.
6
6
 
7
- **Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile
7
+ **Keywords:** avatar, profile, user, identity, initials, fallback, group, team, stack, overflow, collaborators, facepile, monogram, project mark, workspace icon, org logo, assignee
8
8
 
9
9
  ## Usage
10
10
 
@@ -15,12 +15,13 @@ Use Avatar to represent a person, team, or account with a compact visual identit
15
15
  - 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.
16
16
  - Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
17
17
  - Do: Use initials or a simple glyph fallback so missing images still communicate identity.
18
- - 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.
18
+ - 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.
19
19
  - 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.
20
+ - 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.
20
21
  - 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.
21
22
  - Don't: Use avatars as decoration without nearby identity context. They should help users recognize a person or account.
22
23
  - Don't: Overload a dense table with large avatars; use sm or omit images when scanning data is more important.
23
- - Don't: Build a portrait by stretching an Avatar with size classes (`className="size-40"`) — the initials, the status indicator and the image fade stay at row scale. Use size="xl" or "2xl", which scale all three. Past 160px, or for a non-circular cover, it is media: an AspectBox, not an avatar.
24
+ - 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.
24
25
 
25
26
  ### Anatomy
26
27
 
@@ -36,11 +37,11 @@ Use Avatar to represent a person, team, or account with a compact visual identit
36
37
 
37
38
  | Name | Type | Description |
38
39
  | --- | --- | --- |
39
- | 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. |
40
+ | 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. |
40
41
  | AvatarImage | Base UI Avatar.Image props + className | The profile image. Fills and crops the frame; hidden by Base UI until it loads. |
41
42
  | AvatarFallback | Base UI Avatar.Fallback props (delay) + className | Shown when no image is available — typically initials or a glyph. Accepts a delay prop. |
42
- | 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. |
43
- | 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. |
43
+ | 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. |
44
+ | 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. |
44
45
 
45
46
  ## Props
46
47
 
@@ -51,13 +52,16 @@ Use Avatar to represent a person, team, or account with a compact visual identit
51
52
  | fallback | ReactNode | — | Initials or a glyph rendered when no image is displayed. |
52
53
  | fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
53
54
  | 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. |
54
- | 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. |
55
+ | 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. |
56
+ | 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. |
57
+ | 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. |
55
58
  | 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. |
56
59
  | 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. |
57
60
 
58
61
  ## Variants
59
62
 
60
- - **size**: sm, md, lg, xl, 2xl
63
+ - **size**: 2xs, xs, sm, md, lg, xl, 2xl
64
+ - **shape**: circle, square
61
65
  - **variant**: neutral, art
62
66
 
63
67
  ## Tokens used
@@ -134,6 +138,25 @@ import { Avatar, StatusDot } from "@sableui/react"
134
138
  </div>
135
139
  ```
136
140
 
141
+ ### Dense row sizes
142
+
143
+ ```tsx
144
+ import { Avatar } from "@sableui/react"
145
+
146
+ <Avatar fallback="EL" alt="Emma Lee" size="2xs" />
147
+ <Avatar fallback="EL" alt="Emma Lee" size="xs" />
148
+ ```
149
+
150
+ ### Project marks
151
+
152
+ ```tsx
153
+ import { Avatar } from "@sableui/react"
154
+
155
+ <Avatar shape="square" tone="teal" size="xs" fallback="N" alt="Northwind storefront" />
156
+ <Avatar shape="square" tone="orange" size="xs" fallback="H" alt="Harbor brand refresh" />
157
+ <Avatar shape="square" tone="purple" size="xs" fallback="L" alt="Lumen editorial" />
158
+ ```
159
+
137
160
  ### Low-level parts
138
161
 
139
162
  ```tsx
@@ -4,7 +4,7 @@
4
4
 
5
5
  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.
6
6
 
7
- **Keywords:** collapsible, disclosure, expand, collapse
7
+ **Keywords:** collapsible, disclosure, expand, collapse, chevron, caret, rotate, group header, section toggle
8
8
 
9
9
  ## Usage
10
10
 
@@ -31,7 +31,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
31
31
  | --- | --- | --- |
32
32
  | CollapsibleTrigger | Base UI Collapsible.Trigger props + render | The button that toggles the panel. Compose with render={<Button variant="ghost" … />}. |
33
33
  | 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. |
34
- | CollapsibleIndicator | { className? } | Auto-rotating disclosure chevron. Place it inside the trigger content and add the `group/collapsible` class to the trigger so it flips 180° on open. |
34
+ | 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. |
35
35
 
36
36
  ## Props
37
37
 
@@ -50,7 +50,7 @@ Use Collapsible for a single piece of optional content that can expand in place.
50
50
  - **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.
51
51
  - **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.
52
52
  - **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.
53
- - **transition** · CollapsibleIndicator · `motion-interactive` — The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 writes.
53
+ - **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.
54
54
  - **activity** · Root · _no motion_ — Nothing here is indeterminate; content that loads on open should put its own loader inside the panel.
55
55
 
56
56
  ## Accessibility
@@ -87,5 +87,13 @@ import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@sabl
87
87
  ```tsx
88
88
  import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
89
89
 
90
- <Collapsible><CollapsibleTrigger className="group/collapsible" render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>
90
+ <Collapsible><CollapsibleTrigger render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>
91
+ ```
92
+
93
+ ### Group header with a side caret
94
+
95
+ ```tsx
96
+ import { Button, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger } from "@sableui/react"
97
+
98
+ <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>
91
99
  ```
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Status:** stable
4
4
 
5
- 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.
5
+ 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.
6
6
 
7
- **Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list
7
+ **Keywords:** data table, table, sortable, columns, rows, empty state, pagination, list, grouped, group by, sections, issue list, issue tracker, status groups, collapsible groups
8
8
 
9
9
  ## Usage
10
10
 
@@ -16,7 +16,8 @@ Use DataTable when a data array and column definition should render a sortable,
16
16
  - Do: Provide stable rowKey values for dynamic data so selection and sorting do not jump.
17
17
  - Do: Use sortable only on columns where ordering is obvious and useful.
18
18
  - Do: Pair server-side datasets with Pagination and pass already-sorted/paged rows.
19
- - Do: Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, 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.
19
+ - 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.
20
+ - 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.
20
21
  - Don't: Make every row clickable and also fill it with competing buttons. Choose a clear row interaction model.
21
22
  - Don't: Use DataTable for heterogeneous cards or timeline content. A list or card layout will scan better.
22
23
 
@@ -27,6 +28,7 @@ Use DataTable when a data array and column definition should render a sortable,
27
28
  | Columns | yes | Definitions for headers, cell renderers, sorting, alignment, and classes. |
28
29
  | Rows | yes | The data array rendered through the column definitions. |
29
30
  | Empty state | no | Content spanning all columns when the row array is empty. |
31
+ | 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. |
30
32
  | Caption | no | Accessible table caption rendered by the underlying Table. |
31
33
 
32
34
  ## Props
@@ -45,6 +47,11 @@ Use DataTable when a data array and column definition should render a sortable,
45
47
  | defaultSort | { key: string; direction: "asc" \| "desc" } | — | Initial sort column + direction. |
46
48
  | empty | ReactNode | "No results." | Rendered spanning all columns when data is empty. Pass an EmptyState for the full treatment (icon, title, next step). |
47
49
  | caption | ReactNode | — | Bottom table caption. |
50
+ | 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. |
51
+ | 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. |
52
+ | 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. |
53
+ | expandedGroups | ReadonlySet<string> | — | The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state. |
54
+ | onExpandedGroupsChange | (keys: Set<string>) => void | — | Called with the new set of open group keys when a group header is toggled. |
48
55
 
49
56
  ## Tokens used
50
57
 
@@ -53,9 +60,11 @@ Use DataTable when a data array and column definition should render a sortable,
53
60
  ## Motion
54
61
 
55
62
  - **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.
63
+ - **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.
56
64
  - **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.
57
65
  - **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.
58
66
  - **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.
67
+ - **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.
59
68
  - **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.
60
69
 
61
70
  ## Accessibility
@@ -63,6 +72,7 @@ Use DataTable when a data array and column definition should render a sortable,
63
72
  - Sorted columns set aria-sort=ascending/descending on the header cell.
64
73
  - Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.
65
74
  - Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.
75
+ - 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.
66
76
 
67
77
  ## Related
68
78
 
@@ -131,6 +141,33 @@ import { Card, CardContent, CardHeader, CardTitle, DataTable } from "@sableui/re
131
141
  <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>
132
142
  ```
133
143
 
144
+ ### Issues grouped by status
145
+
146
+ ```tsx
147
+ import { Button, DataTable, Icon, StatusDot } from "@sableui/react"
148
+
149
+ const STATUSES = [
150
+ { key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },
151
+ { key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },
152
+ { key: "todo", label: "Todo", icon: <StatusDot /> },
153
+ { key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },
154
+ ]
155
+
156
+ <DataTable
157
+ data={issues}
158
+ rowKey={(t) => t.id}
159
+ groupBy={(t) => t.status}
160
+ 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> }))}
161
+ onRowClick={(t) => setSelectedId(t.id)}
162
+ isRowSelected={(t) => t.id === selectedId}
163
+ columns={[
164
+ { key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },
165
+ { key: "title", header: "Issue", sortable: true },
166
+ { key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },
167
+ ]}
168
+ />
169
+ ```
170
+
134
171
  ### Empty state
135
172
 
136
173
  ```tsx
@@ -4,7 +4,7 @@
4
4
 
5
5
  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.
6
6
 
7
- **Keywords:** date picker, calendar, date input, date time picker, time, schedule, form
7
+ **Keywords:** date picker, due date, property row, inspector, calendar, date input, date time picker, time, schedule, form
8
8
 
9
9
  ## Usage
10
10
 
@@ -33,6 +33,8 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
33
33
  | disabled | boolean | — | Disable the trigger and block selection. |
34
34
  | minDate | Date | — | Earliest selectable day (inclusive). Earlier days are disabled and navigation is bounded. |
35
35
  | maxDate | Date | — | Latest selectable day (inclusive). Later days are disabled and navigation is bounded. |
36
+ | 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. |
37
+ | 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"). |
36
38
  | className | string | — | Extra classes merged onto the trigger. |
37
39
  | 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. |
38
40
 
@@ -54,6 +56,18 @@ Use DatePicker when users need to choose one date — or, with granularity="minu
54
56
 
55
57
  ## Examples
56
58
 
59
+ ### In an inspector row
60
+
61
+ ```tsx
62
+ import { DatePicker, PanelSection, PropertyRow } from "@sableui/react"
63
+
64
+ <PanelSection title="Properties">
65
+ <PropertyRow label="Due date">
66
+ <DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
67
+ </PropertyRow>
68
+ </PanelSection>
69
+ ```
70
+
57
71
  ### Controlled
58
72
 
59
73
  ```tsx
@@ -39,7 +39,7 @@ Use Menu for a compact list of actions opened from a visible trigger.
39
39
  | 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. |
40
40
  | MenuSeparator | Base UI Separator props + className | Hairline rule between item runs. |
41
41
  | 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. |
42
- | 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. |
42
+ | 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. |
43
43
  | MenuRadioGroup | Base UI Menu.RadioGroup props (value, defaultValue, onValueChange) | A single-choice group inside a menu. Controlled via value/onValueChange, uncontrolled via defaultValue. |
44
44
  | 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. |
45
45
  | 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. |
@@ -4,7 +4,7 @@
4
4
 
5
5
  A row of docked chrome panels around a flexible centre, with draggable and keyboard-resizable edges and optional persisted widths.
6
6
 
7
- **Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout
7
+ **Keywords:** panel, panels, resize, resizable, splitter, docked, workspace, tool, chrome, inspector, layout, mobile, responsive, sheet, drawer, narrow, detail pane
8
8
 
9
9
  ## Usage
10
10
 
@@ -15,6 +15,8 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
15
15
  - Do: Give every docked Panel a fixed width and leave the centre Panel width-less so it absorbs the remaining space.
16
16
  - 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.
17
17
  - Do: Label every Panel — they are landmarks, and 'Layers' or 'Properties' is what a screen-reader user navigates by.
18
+ - 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.
19
+ - 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.
18
20
  - Don't: Let the group scroll. Only panels and the canvas scroll; a scrolling group means the shell lost its height constraint.
19
21
  - Don't: Use it for page content columns — that's Grid or Columns. PanelGroup is chrome, and it sits outside the content grid.
20
22
  - 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.
@@ -31,7 +33,7 @@ Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left
31
33
 
32
34
  | Name | Type | Description |
33
35
  | --- | --- | --- |
34
- | 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. |
36
+ | 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. |
35
37
  | 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. |
36
38
  | 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. |
37
39
 
@@ -110,6 +112,36 @@ function Body() {
110
112
  }
111
113
  ```
112
114
 
115
+ ### Inspector that becomes a sheet on phones
116
+
117
+ ```tsx
118
+ import { Button, Input, Panel, PanelGroup, PanelSection, PropertyRow } from "@sableui/react"
119
+
120
+ function Body() {
121
+ const [selected, setSelected] = useState<string | null>(null)
122
+ return (
123
+ <PanelGroup>
124
+ <Panel label="Issues">
125
+ <div className="p-2">
126
+ <Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
127
+ </div>
128
+ </Panel>
129
+ <Panel
130
+ label="Issue details"
131
+ width={320}
132
+ narrow="sheet"
133
+ open={selected !== null}
134
+ onOpenChange={(open) => !open && setSelected(null)}
135
+ >
136
+ <PanelSection title={selected ?? ""}>
137
+ <PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
138
+ </PanelSection>
139
+ </Panel>
140
+ </PanelGroup>
141
+ )
142
+ }
143
+ ```
144
+
113
145
  ### Fixed panel, no resizer
114
146
 
115
147
  ```tsx
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Status:** stable
4
4
 
5
- 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.
5
+ 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.
6
6
 
7
- **Keywords:** sidebar, navigation, app chrome, rail
7
+ **Keywords:** sidebar, navigation, app chrome, rail, nav group, section label, account, user menu, profile, account switcher, workspace switcher, sidebar footer
8
8
 
9
9
  ## Usage
10
10
 
@@ -16,6 +16,9 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
16
16
  - Do: Keep item labels predictable and active state obvious.
17
17
  - 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.
18
18
  - 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.
19
+ - 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.
20
+ - 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.
21
+ - Don't: Do not put a Badge or pill in the account row. Its one quiet detail line carries the plan, role or 'Demo'.
19
22
  - Don't: Do not use Sidebar for page-local settings, filters, or temporary action groups.
20
23
  - 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.
21
24
 
@@ -30,9 +33,11 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
30
33
 
31
34
  | Name | Type | Description |
32
35
  | --- | --- | --- |
33
- | 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. |
36
+ | 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. |
34
37
  | 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. |
35
- | 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" />}. |
38
+ | 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" />}. |
39
+ | 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. |
40
+ | 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. |
36
41
 
37
42
  ## Props
38
43
 
@@ -92,6 +97,14 @@ const [collapsed, setCollapsed] = useState(false)
92
97
  <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>
93
98
  ```
94
99
 
100
+ ### Grouped nav with an account menu
101
+
102
+ ```tsx
103
+ import { Avatar, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Sidebar, SidebarAccount, SidebarBrand, SidebarFooter, SidebarGroup, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
104
+
105
+ <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>
106
+ ```
107
+
95
108
  ### Inline (inside a bounded frame)
96
109
 
97
110
  ```tsx
@@ -13,6 +13,7 @@ Use Tabs to switch between related panels within the same page context.
13
13
  ### Best practices
14
14
 
15
15
  - Do: Use tabs when each panel is peer content and users may compare or switch often.
16
+ - 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.
16
17
  - Do: Keep tab labels short and make the active tab unmistakable.
17
18
  - 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.
18
19
  - Don't: Do not use Tabs for unrelated pages or primary navigation that needs distinct URLs. → use **SideNav**, **NavigationMenu**
@@ -4,7 +4,7 @@
4
4
 
5
5
  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.
6
6
 
7
- **Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport
7
+ **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
8
8
 
9
9
  ## Usage
10
10
 
@@ -15,6 +15,9 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
15
15
  - 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.
16
16
  - Do: Let each panel own its scrolling. The shell never scrolls, and the canvas clips rather than scrolls — panning is the viewport's job.
17
17
  - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
18
21
  - 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.
19
22
  - 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.
20
23
  - Don't: Put a Card in the chrome. Use PanelSection — a card inside a narrow panel spends its border and inset on nothing.
@@ -28,6 +31,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
28
31
  | Body | yes | The flexible middle row, normally a PanelGroup. Constrained so its children can shrink and scroll. |
29
32
  | WorkspaceCanvas | no | The work surface and the main#main-content landmark the skip link targets. Clips rather than scrolls. |
30
33
  | StatusBar | no | Bottom chrome for ambient document facts. |
34
+ | 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. |
31
35
 
32
36
  ## Subcomponents
33
37
 
@@ -35,6 +39,8 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
35
39
  | --- | --- | --- |
36
40
  | WorkspaceBar | ComponentProps<'header'> | The top chrome strip, sized to --chrome-bar-height with a hairline beneath. |
37
41
  | WorkspaceCanvas | ComponentProps<'main'> | The work surface: renders main#main-content, fills the body, clips its overflow. |
42
+ | 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. |
43
+ | 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. |
38
44
 
39
45
  ## Props
40
46
 
@@ -42,6 +48,7 @@ Use Workspace as the outer frame for a tool — an editor, a console, a canvas a
42
48
  | --- | --- | --- | --- |
43
49
  | toolbar | ReactNode | — | Top chrome, normally a WorkspaceBar. |
44
50
  | statusBar | ReactNode | — | Bottom chrome, normally a StatusBar. |
51
+ | 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. |
45
52
  | 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. |
46
53
  | skipLinkLabel | string | "Skip to canvas" | Text for the skip link that targets the canvas landmark. |
47
54
 
@@ -123,6 +130,79 @@ import { Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspa
123
130
  </Workspace>
124
131
  ```
125
132
 
133
+ ### Issue tracker: column bar, docked inspector, phone sheet
134
+
135
+ ```tsx
136
+ import { BottomNav, BottomNavItem, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, Breadcrumbs, BreadcrumbSeparator, Button, Icon, Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/react"
137
+
138
+ function Tracker() {
139
+ const [selected, setSelected] = useState<string | null>("AES-104")
140
+ return (
141
+ <Workspace
142
+ bottomNav={
143
+ <BottomNav>
144
+ <BottomNavItem showLabel label="Issues" icon={<Icon name="list" />} active />
145
+ <BottomNavItem showLabel label="Inbox" icon={<Icon name="inbox" />} />
146
+ </BottomNav>
147
+ }
148
+ statusBar={<StatusBar><span>12 issues</span></StatusBar>}
149
+ >
150
+ <PanelGroup>
151
+ <Panel label="Navigation" width={220} narrow="hidden">
152
+ <PanelSection title="Workspace">
153
+ <PropertyRow label="Team"><Input defaultValue="Studio" /></PropertyRow>
154
+ </PanelSection>
155
+ </Panel>
156
+ <WorkspaceCanvas className="flex flex-col">
157
+ <WorkspaceBar>
158
+ <Breadcrumbs>
159
+ <BreadcrumbItem><BreadcrumbLink href="#">Studio</BreadcrumbLink></BreadcrumbItem>
160
+ <BreadcrumbSeparator />
161
+ <BreadcrumbItem><BreadcrumbPage>Digital flagship</BreadcrumbPage></BreadcrumbItem>
162
+ </Breadcrumbs>
163
+ </WorkspaceBar>
164
+ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
165
+ <Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
166
+ </div>
167
+ </WorkspaceCanvas>
168
+ <Panel
169
+ label="Issue details"
170
+ width={320}
171
+ narrow="sheet"
172
+ open={selected !== null}
173
+ onOpenChange={(open) => !open && setSelected(null)}
174
+ >
175
+ <PanelSection title={selected ?? ""}>
176
+ <PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
177
+ </PanelSection>
178
+ </Panel>
179
+ </PanelGroup>
180
+ </Workspace>
181
+ )
182
+ }
183
+ ```
184
+
185
+ ### Reading view inside a workspace
186
+
187
+ ```tsx
188
+ import { Density, PanelGroup, Workspace, WorkspaceCanvas } from "@sableui/react"
189
+
190
+ <Workspace>
191
+ <PanelGroup>
192
+ <WorkspaceCanvas className="overflow-y-auto scrollbar-quiet">
193
+ <Density value="comfortable">
194
+ <article className="max-w-prose p-6">
195
+ <h2 className="text-heading-3">Project overview</h2>
196
+ <p className="mt-3 text-body-md text-muted-foreground">
197
+ Paragraphs keep their page size here, while the chrome around the canvas stays compact.
198
+ </p>
199
+ </article>
200
+ </Density>
201
+ </WorkspaceCanvas>
202
+ </PanelGroup>
203
+ </Workspace>
204
+ ```
205
+
126
206
  ### Comfortable density
127
207
 
128
208
  ```tsx
@@ -49,7 +49,9 @@ Every tier belongs to one family, and the family is the decision. Size follows f
49
49
  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.
50
50
  7. **Numbers that must align use a mono tier (tabular figures).** Proportional digits change width as values update, so totals and columns shimmy.
51
51
  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.
52
- 9. **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.
52
+ 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.
53
+ 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.
54
+ 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.
53
55
 
54
56
  ## Never
55
57
 
@@ -58,6 +60,7 @@ Every tier belongs to one family, and the family is the decision. Size follows f
58
60
  - **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.
59
61
  - **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.
60
62
  - **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.
63
+ - **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.
61
64
  - **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.
62
65
 
63
66
  ## Details