@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
@@ -12,7 +12,7 @@ import { useId, useState } from "react";
12
12
  import { Icon } from "../../icon/index.js";
13
13
  import { cn } from "../../lib/cn.js";
14
14
  import { useDensity, useTypeTier } from "../../lib/density.js";
15
- import { FIELD_INVALID, FIELD_SHELL, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
15
+ import { FIELD_INVALID, FIELD_SHELL, FIELD_SHELL_XS, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
16
16
  import { Calendar } from "../calendar/calendar.js";
17
17
  import { Popover, PopoverContent, PopoverTrigger } from "../popover/popover.js";
18
18
  import {
@@ -56,6 +56,8 @@ function DatePicker(props) {
56
56
  disabled,
57
57
  minDate,
58
58
  maxDate,
59
+ size = "md",
60
+ formatOptions,
59
61
  className,
60
62
  id,
61
63
  "aria-label": ariaLabel,
@@ -102,11 +104,12 @@ function DatePicker(props) {
102
104
  if (next == null) return;
103
105
  commit(setMinutes(timeBase(), Number(next)));
104
106
  }
105
- const label = value ? (
107
+ const label = value ? formatOptions ? new Intl.DateTimeFormat(void 0, formatOptions).format(value) : (
106
108
  // 24-hour, matching the hour / minute Selects below — the trigger
107
109
  // and the row that sets it must not show the same time two ways.
108
110
  format(value, withTime ? "MMM d, yyyy 'at' HH:mm" : "PP")
109
111
  ) : placeholder ?? (withTime ? "Pick date & time" : "Pick a date");
112
+ const xs = size === "xs";
110
113
  return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
111
114
  /* @__PURE__ */ jsx(
112
115
  PopoverTrigger,
@@ -130,17 +133,21 @@ function DatePicker(props) {
130
133
  "aria-required": ariaRequired,
131
134
  disabled,
132
135
  className: cn(
133
- "inline-flex h-(--control-md) items-center gap-2 ps-3 pe-3 text-start text-foreground",
136
+ "inline-flex items-center text-start text-foreground",
137
+ xs ? "h-(--control-xs) gap-1 px-1.5" : "h-(--control-md) gap-2 ps-3 pe-3",
134
138
  // Select's type: the density tier, plus the 16px phone tier
135
- // its form-row neighbours take (lib/field-shell).
139
+ // its form-row neighbours take (lib/field-shell). The xs rung
140
+ // never sits in a page-register form row, so it skips it.
136
141
  tier.body,
137
- density === "comfortable" && MOBILE_FIELD_TIER,
142
+ density === "comfortable" && !xs && MOBILE_FIELD_TIER,
138
143
  FIELD_SHELL,
144
+ // After FIELD_SHELL, so its well replaces the outlined field.
145
+ xs && FIELD_SHELL_XS,
139
146
  FIELD_INVALID,
140
147
  className
141
148
  ),
142
149
  children: [
143
- /* @__PURE__ */ jsx(Icon, { name: "calendar", className: "shrink-0 text-sm text-muted-foreground" }),
150
+ /* @__PURE__ */ jsx(Icon, { name: "calendar", className: cn("shrink-0 text-muted-foreground", xs ? "text-xs" : "text-sm") }),
144
151
  /* @__PURE__ */ jsx("span", { className: cn("truncate", !value && "text-muted-foreground"), children: label })
145
152
  ]
146
153
  }
@@ -5,6 +5,9 @@ var datePickerMeta = {
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "date picker",
8
+ "due date",
9
+ "property row",
10
+ "inspector",
8
11
  "calendar",
9
12
  "date input",
10
13
  "date time picker",
@@ -104,6 +107,17 @@ var datePickerMeta = {
104
107
  type: "Date",
105
108
  description: "Latest selectable day (inclusive). Later days are disabled and navigation is bounded."
106
109
  },
110
+ {
111
+ name: "size",
112
+ type: '"md" | "xs"',
113
+ default: '"md"',
114
+ description: `The trigger's rung. md is the form field beside Input and Select. xs is the property-grid rung (24px in a workspace): an inset panel-field well with no border, the same shape as SelectTrigger size="xs" and ScrubField on the neighbouring rows of an inspector \u2014 use it for a due date in a PropertyRow instead of a native date input.`
115
+ },
116
+ {
117
+ name: "formatOptions",
118
+ type: "Intl.DateTimeFormatOptions",
119
+ description: `How the trigger writes the chosen date, as Intl.DateTimeFormat options in the reader's locale \u2014 { month: "short", day: "numeric" } for "Oct 23" where the rest of a list writes dates that way. Omitted: "Oct 23, 2026" (with "at 14:30" under granularity="minute").`
120
+ },
107
121
  {
108
122
  name: "className",
109
123
  type: "string",
@@ -137,6 +151,14 @@ var datePickerMeta = {
137
151
  'With granularity="minute" the hour and minute Selects are full Base UI listboxes (typeahead, ARIA) labelled "Hour" and "Minute"; selecting a day keeps the chosen time-of-day, so day and time can be set in any order.'
138
152
  ],
139
153
  examples: [
154
+ {
155
+ label: "In an inspector row",
156
+ code: `<PanelSection title="Properties">
157
+ <PropertyRow label="Due date">
158
+ <DatePicker size="xs" aria-label="Due date" value={due} onValueChange={setDue} formatOptions={{ month: "short", day: "numeric" }} />
159
+ </PropertyRow>
160
+ </PanelSection>`
161
+ },
140
162
  {
141
163
  label: "Controlled",
142
164
  code: "const [date, setDate] = useState<Date>()\n<DatePicker value={date} onValueChange={setDate} />"
@@ -131,6 +131,7 @@ function MenuSubmenuTrigger({
131
131
  className,
132
132
  children,
133
133
  value,
134
+ icon,
134
135
  ...props
135
136
  }) {
136
137
  const tier = useTypeTier();
@@ -143,6 +144,7 @@ function MenuSubmenuTrigger({
143
144
  ),
144
145
  ...props,
145
146
  children: [
147
+ icon,
146
148
  /* @__PURE__ */ jsx("span", { className: "flex-1 truncate text-start", children }),
147
149
  value !== void 0 ? /* @__PURE__ */ jsx("span", { className: "truncate text-muted-foreground", children: value }) : null,
148
150
  /* @__PURE__ */ jsx(Icon, { name: "chevron-right", className: "text-xs opacity-60" })
@@ -114,8 +114,8 @@ var menuMeta = {
114
114
  },
115
115
  {
116
116
  name: "MenuSubmenuTrigger",
117
- type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + className",
118
- description: 'The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High \u203A" rather than making the reader open it to find out.'
117
+ type: "Base UI Menu.SubmenuTrigger props + value: ReactNode + icon: ReactNode + className",
118
+ description: 'The row that opens a submenu: item styling, a trailing chevron, and an optional `value` showing the current choice so the row reads "Effort High \u203A" rather than making the reader open it to find out. `icon` leads the row as MenuItem\'s does; give it one whenever the items around it have icons, or its label starts a column to their left.'
119
119
  },
120
120
  {
121
121
  name: "MenuRadioGroup",
@@ -7,7 +7,9 @@ import {
7
7
  useSyncExternalStore
8
8
  } from "react";
9
9
  import { cn } from "../../lib/cn.js";
10
- import { jsx } from "react/jsx-runtime";
10
+ import { useBelowBreakpoint } from "../../lib/use-media-query.js";
11
+ import { Sheet, SheetContent, SheetTitle } from "../sheet/index.js";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
11
13
  var widthCache = /* @__PURE__ */ new Map();
12
14
  var listeners = /* @__PURE__ */ new Set();
13
15
  function clampWidth(value, min, max) {
@@ -64,10 +66,24 @@ function Panel({
64
66
  label,
65
67
  width,
66
68
  scroll = true,
69
+ narrow = "dock",
70
+ open,
71
+ onOpenChange,
72
+ side = "right",
73
+ below = "lg",
67
74
  className,
68
75
  children,
69
76
  ...props
70
77
  }) {
78
+ const isNarrow = useBelowBreakpoint(below);
79
+ if (narrow !== "dock" && isNarrow) {
80
+ if (narrow === "hidden") return null;
81
+ return /* @__PURE__ */ jsx(Sheet, { open: open ?? false, onOpenChange: (next) => onOpenChange?.(next), children: /* @__PURE__ */ jsxs(SheetContent, { side, className: "gap-0 p-0 [--sidebar-width:100%]", children: [
82
+ /* @__PURE__ */ jsx(SheetTitle, { className: "sr-only", children: label }),
83
+ children
84
+ ] }) });
85
+ }
86
+ if (open === false) return null;
71
87
  return /* @__PURE__ */ jsx(
72
88
  "aside",
73
89
  {
@@ -13,7 +13,13 @@ var panelGroupMeta = {
13
13
  "tool",
14
14
  "chrome",
15
15
  "inspector",
16
- "layout"
16
+ "layout",
17
+ "mobile",
18
+ "responsive",
19
+ "sheet",
20
+ "drawer",
21
+ "narrow",
22
+ "detail pane"
17
23
  ],
18
24
  usage: {
19
25
  description: "Use PanelGroup for the body of a tool workspace: fixed-width Panels docked left and/or right, a flexible Panel in the middle for the canvas, and a PanelResizer between them. Panels scroll internally and the group never scrolls. This is the workspace-register counterpart to SplitPane, which only switches between side-by-side and stacked at a breakpoint and cannot be resized.",
@@ -30,6 +36,14 @@ var panelGroupMeta = {
30
36
  guidance: "do",
31
37
  description: "Label every Panel \u2014 they are landmarks, and 'Layers' or 'Properties' is what a screen-reader user navigates by."
32
38
  },
39
+ {
40
+ guidance: "do",
41
+ description: '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.'
42
+ },
43
+ {
44
+ guidance: "dont",
45
+ description: '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.'
46
+ },
33
47
  {
34
48
  guidance: "dont",
35
49
  description: "Let the group scroll. Only panels and the canvas scroll; a scrolling group means the shell lost its height constraint."
@@ -69,8 +83,8 @@ var panelGroupMeta = {
69
83
  subcomponents: [
70
84
  {
71
85
  name: "Panel",
72
- type: "{ label: string; width?: number; scroll?: boolean }",
73
- description: "A docked or flexible column. `label` names the landmark; omit `width` for the flexible centre. `scroll` defaults to true \u2014 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."
86
+ type: '{ 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" }',
87
+ description: '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 \u2014 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.'
74
88
  },
75
89
  {
76
90
  name: "PanelResizer",
@@ -158,6 +172,32 @@ var panelGroupMeta = {
158
172
  </Panel>
159
173
  </PanelGroup>
160
174
  )
175
+ }`
176
+ },
177
+ {
178
+ label: "Inspector that becomes a sheet on phones",
179
+ code: `function Body() {
180
+ const [selected, setSelected] = useState<string | null>(null)
181
+ return (
182
+ <PanelGroup>
183
+ <Panel label="Issues">
184
+ <div className="p-2">
185
+ <Button size="xs" variant="ghost" onClick={() => setSelected("AES-104")}>AES-104 Refine product page</Button>
186
+ </div>
187
+ </Panel>
188
+ <Panel
189
+ label="Issue details"
190
+ width={320}
191
+ narrow="sheet"
192
+ open={selected !== null}
193
+ onOpenChange={(open) => !open && setSelected(null)}
194
+ >
195
+ <PanelSection title={selected ?? ""}>
196
+ <PropertyRow label="Status"><Input defaultValue="In progress" /></PropertyRow>
197
+ </PanelSection>
198
+ </Panel>
199
+ </PanelGroup>
200
+ )
161
201
  }`
162
202
  },
163
203
  {
@@ -11,7 +11,12 @@ var sideNavItemVariants = cva(
11
11
  md: "gap-2.5 rounded-sm px-3 py-1.5 text-body-sm",
12
12
  lg: "gap-3 rounded-sm px-3 py-2 text-body-md",
13
13
  // The app rail (Sidebar): chrome-weight rows on the label tier.
14
- rail: `${RAIL_ROW_INLINE} rounded-sm py-2.5 text-label-lg`
14
+ rail: `${RAIL_ROW_INLINE} rounded-sm py-2.5 text-label-lg`,
15
+ // The same rail inside a compact scope (a Workspace): the same
16
+ // columns, a 20px icon and a 32px row. Sidebar picks it from the
17
+ // density, so a tool's rail is not a page's 44px rows beside
18
+ // 28px chrome.
19
+ "rail-compact": `${RAIL_ROW_INLINE} rounded-sm py-1.5 text-label-lg`
15
20
  },
16
21
  activeVariant: {
17
22
  content: "text-foreground hover:bg-muted focus-visible:ring-ring data-[active]:bg-accent data-[active]:text-accent-foreground",
@@ -24,8 +29,8 @@ var sideNavItemVariants = cva(
24
29
  var sideNavItemIconVariants = cva(
25
30
  "flex shrink-0 items-center justify-center",
26
31
  {
27
- // md 20px icon in a 20px box; lg 20px in 24px; rail 24px in 24px.
28
- variants: { size: { md: "size-[1em] text-xl", lg: "size-[1.2em] text-xl", rail: "size-[1em] text-2xl" } },
32
+ // md 20px icon in a 20px box; lg 20px in 24px; rail 24px in 24px; compact rail 20px in 20px.
33
+ variants: { size: { md: "size-[1em] text-xl", lg: "size-[1.2em] text-xl", rail: "size-[1em] text-2xl", "rail-compact": "size-[1em] text-xl" } },
29
34
  defaultVariants: { size: "md" }
30
35
  }
31
36
  );
@@ -6,13 +6,17 @@ import {
6
6
  SidebarNav,
7
7
  SidebarFooter,
8
8
  SidebarItem,
9
+ SidebarGroup,
10
+ SidebarAccount,
9
11
  useSidebar
10
12
  } from "./sidebar.js";
11
13
  import { sidebarMeta } from "./sidebar.meta.js";
12
14
  export {
13
15
  Sidebar,
16
+ SidebarAccount,
14
17
  SidebarBrand,
15
18
  SidebarFooter,
19
+ SidebarGroup,
16
20
  SidebarHeader,
17
21
  SidebarItem,
18
22
  SidebarNav,
@@ -5,9 +5,12 @@ import { useRender } from "@base-ui/react/use-render";
5
5
  import {
6
6
  createContext,
7
7
  useContext,
8
+ useId,
8
9
  useState
9
10
  } from "react";
11
+ import { Icon } from "../../icon/index.js";
10
12
  import { cn } from "../../lib/cn.js";
13
+ import { useDensity } from "../../lib/density.js";
11
14
  import { HighlightGlide } from "../../lib/highlight-glide.js";
12
15
  import { motionAttribute } from "../../motion/MotionProvider.js";
13
16
  import { useAppShellFrame } from "../app-shell/app-shell-frame.js";
@@ -16,7 +19,8 @@ import { SimpleTooltip } from "../tooltip/index.js";
16
19
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
17
20
  var SidebarContext = createContext({
18
21
  collapsed: false,
19
- icons: true
22
+ icons: true,
23
+ rung: "rail"
20
24
  });
21
25
  var LABEL_FADE = "starting:opacity-0 transition-opacity duration-(--motion-duration-base) ease-decelerate";
22
26
  function useSidebar() {
@@ -58,7 +62,8 @@ function Sidebar({
58
62
  const [hasCollapsed, setHasCollapsed] = useState(collapsed);
59
63
  if (collapsed && !hasCollapsed) setHasCollapsed(true);
60
64
  const inset = useAppShellFrame() === "inset";
61
- return /* @__PURE__ */ jsx(SidebarContext.Provider, { value: { collapsed, icons, reopened: hasCollapsed && !collapsed }, children: /* @__PURE__ */ jsx(
65
+ const rung = useDensity() === "compact" ? "rail-compact" : "rail";
66
+ return /* @__PURE__ */ jsx(SidebarContext.Provider, { value: { collapsed, icons, reopened: hasCollapsed && !collapsed, rung }, children: /* @__PURE__ */ jsx(
62
67
  "aside",
63
68
  {
64
69
  "data-motion": motionAttribute(motion),
@@ -103,12 +108,15 @@ function SidebarHeader({
103
108
  className,
104
109
  ...props
105
110
  }) {
106
- const { collapsed } = useSidebar();
111
+ const { collapsed, rung } = useSidebar();
107
112
  return /* @__PURE__ */ jsx(
108
113
  "div",
109
114
  {
110
115
  className: cn(
111
- "flex min-h-16 flex-wrap content-center items-center gap-y-2 px-3 has-[>:nth-child(2)]:py-3",
116
+ "flex flex-wrap content-center items-center gap-y-2 px-3",
117
+ // Compact, the brand row is the height of the tool's own chrome bar,
118
+ // so it lines up with the WorkspaceBar beside it.
119
+ rung === "rail-compact" ? "min-h-(--chrome-bar-height) has-[>:nth-child(2)]:py-2" : "min-h-16 has-[>:nth-child(2)]:py-3",
112
120
  // Collapsed, the brand mark centres on the same axis as the item icons.
113
121
  collapsed && "justify-center px-0",
114
122
  className
@@ -119,7 +127,7 @@ function SidebarHeader({
119
127
  }
120
128
  SidebarHeader.displayName = "SidebarHeader";
121
129
  function SidebarBrand({ mark, label, render, className, "aria-label": ariaLabel, ...props }) {
122
- const { collapsed, reopened } = useSidebar();
130
+ const { collapsed, reopened, rung } = useSidebar();
123
131
  return useRender({
124
132
  render: render ?? /* @__PURE__ */ jsx("div", {}),
125
133
  props: {
@@ -135,7 +143,7 @@ function SidebarBrand({ mark, label, render, className, "aria-label": ariaLabel,
135
143
  className
136
144
  ),
137
145
  children: /* @__PURE__ */ jsxs(Fragment, { children: [
138
- /* @__PURE__ */ jsx("span", { className: sideNavItemIconVariants({ size: "rail" }), children: mark }),
146
+ /* @__PURE__ */ jsx("span", { className: sideNavItemIconVariants({ size: rung }), children: mark }),
139
147
  collapsed ? null : /* @__PURE__ */ jsx("span", { className: cn("min-w-0 truncate text-title-sm", reopened && LABEL_FADE), children: label })
140
148
  ] })
141
149
  },
@@ -148,6 +156,7 @@ function SidebarNav({
148
156
  children,
149
157
  ...props
150
158
  }) {
159
+ const { rung } = useSidebar();
151
160
  return /* @__PURE__ */ jsxs(
152
161
  "nav",
153
162
  {
@@ -155,7 +164,8 @@ function SidebarNav({
155
164
  // Thin scrollbar so a long nav does not hand a fat platform bar to a
156
165
  // narrow rail, and contained overscroll so a wheel gesture that runs
157
166
  // out of nav stops there instead of scrolling the page behind it.
158
- "flex flex-1 flex-col gap-1 overflow-y-auto overscroll-contain px-3 py-4 scrollbar-quiet",
167
+ "flex flex-1 flex-col overflow-y-auto overscroll-contain px-3 scrollbar-quiet",
168
+ rung === "rail-compact" ? "gap-0.5 py-2" : "gap-1 py-4",
159
169
  "[&[data-glide]_[data-active]]:bg-transparent",
160
170
  className
161
171
  ),
@@ -173,11 +183,13 @@ function SidebarFooter({
173
183
  ...props
174
184
  }) {
175
185
  const inset = useAppShellFrame() === "inset";
186
+ const { rung } = useSidebar();
176
187
  return /* @__PURE__ */ jsx(
177
188
  "div",
178
189
  {
179
190
  className: cn(
180
- "flex flex-col gap-1 border-t border-sidebar-border px-3 py-4",
191
+ "flex flex-col gap-1 border-t border-sidebar-border px-3",
192
+ rung === "rail-compact" ? "py-2" : "py-4",
181
193
  // In an inset shell the rail has no edge of its own (the gap is the
182
194
  // divider), so a full-width rule ran out into that gap and stopped on
183
195
  // nothing. Inset to the content column it spans what the rows span:
@@ -201,7 +213,7 @@ function SidebarItem({
201
213
  "aria-label": ariaLabel,
202
214
  ...props
203
215
  }) {
204
- const { collapsed, icons, reopened } = useSidebar();
216
+ const { collapsed, icons, reopened, rung } = useSidebar();
205
217
  const showIcon = icon != null && (icons || collapsed);
206
218
  const element = useRender({
207
219
  render: render ?? /* @__PURE__ */ jsx("button", { type: "button" }),
@@ -214,12 +226,12 @@ function SidebarItem({
214
226
  "aria-label": ariaLabel ?? (collapsed && typeof label === "string" ? label : void 0),
215
227
  className: cn(
216
228
  // SideNav's row recipe on the rail rung — one row, not a copy.
217
- sideNavItemVariants({ size: "rail", activeVariant: "sidebar" }),
229
+ sideNavItemVariants({ size: rung, activeVariant: "sidebar" }),
218
230
  collapsed && "justify-center px-0",
219
231
  className
220
232
  ),
221
233
  children: /* @__PURE__ */ jsxs(Fragment, { children: [
222
- showIcon ? /* @__PURE__ */ jsx("span", { className: sideNavItemIconVariants({ size: "rail" }), children: icon }) : null,
234
+ showIcon ? /* @__PURE__ */ jsx("span", { className: sideNavItemIconVariants({ size: rung }), children: icon }) : null,
223
235
  collapsed ? null : /* @__PURE__ */ jsxs(Fragment, { children: [
224
236
  /* @__PURE__ */ jsx("span", { className: cn("min-w-0 flex-1 truncate", reopened && LABEL_FADE), children: label }),
225
237
  badge ? /* @__PURE__ */ jsx("span", { className: "ms-auto shrink-0", children: badge }) : null
@@ -234,10 +246,77 @@ function SidebarItem({
234
246
  return element;
235
247
  }
236
248
  SidebarItem.displayName = "SidebarItem";
249
+ function SidebarGroup({ label, className, children, ...props }) {
250
+ const { collapsed, rung } = useSidebar();
251
+ const labelId = useId();
252
+ return /* @__PURE__ */ jsxs(
253
+ "div",
254
+ {
255
+ role: "group",
256
+ "aria-labelledby": label ? labelId : void 0,
257
+ className: cn("flex flex-col", rung === "rail-compact" ? "gap-0.5" : "gap-1", className),
258
+ ...props,
259
+ children: [
260
+ label ? collapsed ? /* @__PURE__ */ jsxs(Fragment, { children: [
261
+ /* @__PURE__ */ jsx("span", { id: labelId, className: "sr-only", children: label }),
262
+ /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "mx-3 my-2 h-px bg-sidebar-border" })
263
+ ] }) : /* @__PURE__ */ jsx("div", { id: labelId, className: "truncate px-3 pt-4 pb-1 text-caption text-muted-foreground", children: label }) : null,
264
+ children
265
+ ]
266
+ }
267
+ );
268
+ }
269
+ SidebarGroup.displayName = "SidebarGroup";
270
+ function SidebarAccount({
271
+ avatar,
272
+ label,
273
+ detail,
274
+ indicator,
275
+ render,
276
+ className,
277
+ "aria-label": ariaLabel,
278
+ ...props
279
+ }) {
280
+ const { collapsed, reopened, rung } = useSidebar();
281
+ const element = useRender({
282
+ // design-lint-disable-next-line raw-button -- a rail row with its own anatomy (avatar slot, two-line label, chevron) on SidebarItem's row recipe, and usually a MenuTrigger's render target; not a button-shaped action
283
+ render: render ?? /* @__PURE__ */ jsx("button", { type: "button" }),
284
+ props: {
285
+ ...props,
286
+ "aria-label": ariaLabel ?? (collapsed && typeof label === "string" ? label : void 0),
287
+ className: cn(
288
+ sideNavItemVariants({ size: rung, activeVariant: "sidebar" }),
289
+ // Holds the hover fill while its menu is open, so the row reads as
290
+ // the thing the menu came from.
291
+ "data-[popup-open]:bg-sidebar-accent data-[popup-open]:text-sidebar-accent-foreground",
292
+ collapsed && "justify-center px-0",
293
+ className
294
+ ),
295
+ children: /* @__PURE__ */ jsxs(Fragment, { children: [
296
+ /* @__PURE__ */ jsx("span", { className: sideNavItemIconVariants({ size: rung }), children: avatar }),
297
+ collapsed ? null : /* @__PURE__ */ jsxs(Fragment, { children: [
298
+ /* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-1 flex-col", reopened && LABEL_FADE), children: [
299
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: label }),
300
+ detail ? /* @__PURE__ */ jsx("span", { className: "truncate text-caption text-muted-foreground", children: detail }) : null
301
+ ] }),
302
+ indicator === false ? null : /* @__PURE__ */ jsx("span", { className: "ms-auto flex shrink-0 text-muted-foreground", "aria-hidden": true, children: indicator ?? /* @__PURE__ */ jsx(Icon, { name: "chevron-up-down", className: "text-base" }) })
303
+ ] })
304
+ ] })
305
+ },
306
+ defaultTagName: "button"
307
+ });
308
+ if (collapsed) {
309
+ return /* @__PURE__ */ jsx(SimpleTooltip, { content: label, side: "right", children: element });
310
+ }
311
+ return element;
312
+ }
313
+ SidebarAccount.displayName = "SidebarAccount";
237
314
  export {
238
315
  Sidebar,
316
+ SidebarAccount,
239
317
  SidebarBrand,
240
318
  SidebarFooter,
319
+ SidebarGroup,
241
320
  SidebarHeader,
242
321
  SidebarItem,
243
322
  SidebarNav,
@@ -1,13 +1,21 @@
1
1
  // src/components/sidebar/sidebar.meta.ts
2
2
  var sidebarMeta = {
3
3
  name: "Sidebar",
4
- summary: "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.",
4
+ summary: "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.",
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "sidebar",
8
8
  "navigation",
9
9
  "app chrome",
10
- "rail"
10
+ "rail",
11
+ "nav group",
12
+ "section label",
13
+ "account",
14
+ "user menu",
15
+ "profile",
16
+ "account switcher",
17
+ "workspace switcher",
18
+ "sidebar footer"
11
19
  ],
12
20
  usage: {
13
21
  description: "Use Sidebar for persistent desktop navigation or application chrome outside the main content region.",
@@ -28,6 +36,18 @@ var sidebarMeta = {
28
36
  guidance: "do",
29
37
  description: `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 \u2014 the gap is the divider.`
30
38
  },
39
+ {
40
+ guidance: "do",
41
+ description: "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."
42
+ },
43
+ {
44
+ guidance: "dont",
45
+ description: "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."
46
+ },
47
+ {
48
+ guidance: "dont",
49
+ description: "Do not put a Badge or pill in the account row. Its one quiet detail line carries the plan, role or 'Demo'."
50
+ },
31
51
  {
32
52
  guidance: "dont",
33
53
  description: "Do not use Sidebar for page-local settings, filters, or temporary action groups."
@@ -93,7 +113,7 @@ var sidebarMeta = {
93
113
  {
94
114
  name: "SidebarHeader",
95
115
  type: "div props",
96
- description: "The top of the rail, at least 64px tall. It holds SidebarBrand and may take a second row under it, such as a search field or a notifications button: give that row basis-full and the header grows to hold it instead of clipping."
116
+ description: "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."
97
117
  },
98
118
  {
99
119
  name: "SidebarBrand",
@@ -103,7 +123,17 @@ var sidebarMeta = {
103
123
  {
104
124
  name: "SidebarItem",
105
125
  type: "{ label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props",
106
- description: '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" />}.'
126
+ description: '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" />}.'
127
+ },
128
+ {
129
+ name: "SidebarGroup",
130
+ type: "{ label?: ReactNode } + div props",
131
+ description: `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.`
132
+ },
133
+ {
134
+ name: "SidebarAccount",
135
+ type: "{ avatar: ReactNode; label: ReactNode; detail?: ReactNode; indicator?: ReactNode | false; render?: useRender.RenderProp } + button props",
136
+ description: "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."
107
137
  }
108
138
  ],
109
139
  motion: [
@@ -165,6 +195,10 @@ var sidebarMeta = {
165
195
  label: "Collapsible (icon rail)",
166
196
  code: 'const [collapsed, setCollapsed] = useState(false)\n<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>'
167
197
  },
198
+ {
199
+ label: "Grouped nav with an account menu",
200
+ code: '<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>'
201
+ },
168
202
  {
169
203
  label: "Inline (inside a bounded frame)",
170
204
  code: '<div className="flex h-80 overflow-hidden rounded-lg border border-border"><Sidebar variant="inline"><SidebarNav><SidebarItem icon={<Icon name="home" />} label="Home" active /></SidebarNav></Sidebar><div className="flex-1 p-6">Content</div></div>'
@@ -1,7 +1,11 @@
1
+ "use client";
2
+
1
3
  // src/components/table/table.tsx
2
4
  import { cn } from "../../lib/cn.js";
5
+ import { useDensity } from "../../lib/density.js";
3
6
  import { jsx } from "react/jsx-runtime";
4
7
  function Table({ className, ...props }) {
8
+ const tier = useDensity() === "compact" ? "text-ui-body" : "text-body-sm";
5
9
  return (
6
10
  // Thin scrollbar so a wide table's horizontal bar reads as part of the
7
11
  // table rather than as chrome, and contained overscroll so swiping past
@@ -9,7 +13,7 @@ function Table({ className, ...props }) {
9
13
  /* @__PURE__ */ jsx("div", { className: "w-full overflow-x-auto overscroll-x-contain scrollbar-quiet", children: /* @__PURE__ */ jsx(
10
14
  "table",
11
15
  {
12
- className: cn("w-full caption-bottom text-body-sm", className),
16
+ className: cn("w-full caption-bottom", tier, className),
13
17
  ...props
14
18
  }
15
19
  ) })
@@ -66,7 +70,7 @@ function TableHead({ className, numeric = false, ...props }) {
66
70
  "th",
67
71
  {
68
72
  className: cn(
69
- "h-10 px-3 text-start align-middle text-label-md text-muted-foreground first:ps-[var(--table-edge-px,--spacing(3))] last:pe-[var(--table-edge-px,--spacing(3))]",
73
+ "h-[var(--table-head-h,--spacing(10))] px-3 text-start align-middle text-label-md text-muted-foreground first:ps-[var(--table-edge-px,--spacing(3))] last:pe-[var(--table-edge-px,--spacing(3))]",
70
74
  numeric && NUMERIC,
71
75
  className
72
76
  ),
@@ -80,7 +84,7 @@ function TableCell({ className, numeric = false, ...props }) {
80
84
  "td",
81
85
  {
82
86
  className: cn(
83
- "px-3 py-2.5 align-middle first:ps-[var(--table-edge-px,--spacing(3))] last:pe-[var(--table-edge-px,--spacing(3))]",
87
+ "px-3 py-[var(--table-cell-py,--spacing(2.5))] align-middle first:ps-[var(--table-edge-px,--spacing(3))] last:pe-[var(--table-edge-px,--spacing(3))]",
84
88
  numeric && NUMERIC,
85
89
  className
86
90
  ),
@@ -21,7 +21,15 @@ function TabsList({ className, variant = "underline", children, ...props }) {
21
21
  {
22
22
  "data-variant": variant,
23
23
  className: cn(
24
- variant === "underline" ? cn("relative inline-flex items-center gap-1 border-b border-border", UNDERLINE_HANDOFF) : cn("relative inline-flex items-center gap-1", PILL_HANDOFF),
24
+ variant === "underline" ? cn(
25
+ "relative inline-flex items-center gap-1 border-b border-border",
26
+ // In a WorkspaceBar the bar's hairline IS the divider: the list
27
+ // fills the bar's height, drops its own rule (which stopped
28
+ // after the last tab, a second line above the bar's), and the
29
+ // mark lands on the bar's edge.
30
+ "[[data-workspace-bar]_&]:self-stretch [[data-workspace-bar]_&]:border-b-0",
31
+ UNDERLINE_HANDOFF
32
+ ) : cn("relative inline-flex items-center gap-1", PILL_HANDOFF),
25
33
  className
26
34
  ),
27
35
  ...props,
@@ -48,7 +56,7 @@ function TabsTrigger({ className, children, ...props }) {
48
56
  variant === "underline" ? (
49
57
  // On the control ladder like the pill variant (it was py-2 plus
50
58
  // the label's line, ~36px — no rung).
51
- "-mb-px inline-flex h-(--control-md) items-center gap-(--control-gap) border-b-2 border-transparent px-3 data-[active]:border-foreground"
59
+ "-mb-px inline-flex h-(--control-md) items-center gap-(--control-gap) border-b-2 border-transparent px-3 data-[active]:border-foreground [[data-workspace-bar]_&]:h-full"
52
60
  ) : "inline-flex h-(--control-sm) items-center gap-(--control-gap) rounded-[var(--button-radius,9999px)] px-3 data-[active]:bg-muted",
53
61
  className
54
62
  ),
@@ -16,6 +16,10 @@ var tabsMeta = {
16
16
  guidance: "do",
17
17
  description: "Use tabs when each panel is peer content and users may compare or switch often."
18
18
  },
19
+ {
20
+ guidance: "do",
21
+ description: `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.`
22
+ },
19
23
  {
20
24
  guidance: "do",
21
25
  description: "Keep tab labels short and make the active tab unmistakable."