@sableui/react 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +235 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +266 -30
  5. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  6. package/dist/activity-DT8VK9fx.d.ts +25 -0
  7. package/dist/adapters/github.d.ts +41 -0
  8. package/dist/adapters/github.js +24 -0
  9. package/dist/blocks/index.d.ts +1 -1
  10. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  11. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  12. package/dist/components/activity-heatmap/index.js +9 -0
  13. package/dist/components/avatar/avatar.js +16 -7
  14. package/dist/components/avatar/avatar.meta.js +43 -10
  15. package/dist/components/avatar/avatar.variants.js +26 -2
  16. package/dist/components/code-block/code-block.js +5 -3
  17. package/dist/components/code-block/code-block.meta.js +6 -0
  18. package/dist/components/collapsible/collapsible.js +6 -5
  19. package/dist/components/collapsible/collapsible.meta.js +14 -5
  20. package/dist/components/data-table/data-table.js +132 -45
  21. package/dist/components/data-table/data-table.meta.js +63 -4
  22. package/dist/components/date-picker/date-picker.js +13 -6
  23. package/dist/components/date-picker/date-picker.meta.js +22 -0
  24. package/dist/components/dialog/dialog.js +25 -5
  25. package/dist/components/dialog/dialog.meta.js +26 -5
  26. package/dist/components/media-image/media-image.js +3 -1
  27. package/dist/components/media-image/media-image.meta.js +15 -1
  28. package/dist/components/media-tile/media-tile.js +13 -1
  29. package/dist/components/media-tile/media-tile.meta.js +10 -0
  30. package/dist/components/menu/menu.js +2 -0
  31. package/dist/components/menu/menu.meta.js +2 -2
  32. package/dist/components/page-header/page-header.js +9 -4
  33. package/dist/components/page-header/page-header.meta.js +26 -1
  34. package/dist/components/panel-group/panel-group.js +17 -1
  35. package/dist/components/panel-group/panel-group.meta.js +43 -3
  36. package/dist/components/pill/pill.meta.js +15 -6
  37. package/dist/components/pill/pill.variants.js +1 -0
  38. package/dist/components/search-input/search-input.js +30 -5
  39. package/dist/components/search-input/search-input.meta.js +29 -4
  40. package/dist/components/section-header/section-header.js +27 -17
  41. package/dist/components/section-header/section-header.meta.js +27 -5
  42. package/dist/components/sheet/sheet.js +15 -3
  43. package/dist/components/sheet/sheet.meta.js +2 -2
  44. package/dist/components/side-nav/side-nav.variants.js +8 -3
  45. package/dist/components/sidebar/index.js +4 -0
  46. package/dist/components/sidebar/sidebar.js +90 -11
  47. package/dist/components/sidebar/sidebar.meta.js +38 -4
  48. package/dist/components/table/table.js +7 -3
  49. package/dist/components/tabs/tabs.js +10 -2
  50. package/dist/components/tabs/tabs.meta.js +4 -0
  51. package/dist/components/workspace/workspace.js +13 -3
  52. package/dist/components/workspace/workspace.meta.js +109 -1
  53. package/dist/file-hashes.json +94 -80
  54. package/dist/foundations/typography.js +13 -0
  55. package/dist/icon/Icon.js +21 -2
  56. package/dist/icon/index.d.ts +36 -1
  57. package/dist/icon/index.js +5 -0
  58. package/dist/icon/names.js +240 -0
  59. package/dist/icon-sets/reicon.js +41 -1
  60. package/dist/index.d.ts +540 -75
  61. package/dist/index.js +18 -1
  62. package/dist/layout/AspectBox.js +2 -5
  63. package/dist/layout/Bleed.js +10 -6
  64. package/dist/layout/Center.js +11 -3
  65. package/dist/layout/Columns.js +6 -3
  66. package/dist/layout/Grid.js +14 -6
  67. package/dist/layout/GridOverlay.js +7 -7
  68. package/dist/layout/Region.js +18 -3
  69. package/dist/layout/Row.js +27 -20
  70. package/dist/layout/Spacer.js +13 -2
  71. package/dist/layout/SplitPane.js +1 -1
  72. package/dist/layout/Stack.js +25 -18
  73. package/dist/layout/index.d.ts +57 -6
  74. package/dist/lib/chart/activity.js +32 -0
  75. package/dist/lib/chart/calendar.js +67 -0
  76. package/dist/lib/chart/chart-tooltip.js +2 -2
  77. package/dist/lib/chart/heat.js +55 -0
  78. package/dist/lib/cn.js +1 -0
  79. package/dist/lib/focus-return.js +53 -0
  80. package/dist/lib/taxonomy.js +1 -0
  81. package/dist/lib/use-media-query.js +41 -0
  82. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  83. package/dist/patterns/dashboard-page.js +23 -17
  84. package/dist/patterns/index.d.ts +7 -2
  85. package/dist/patterns/index.js +5 -1
  86. package/dist/patterns/tracker-page.js +392 -0
  87. package/dist/patterns/tracker-page.meta.js +40 -0
  88. package/dist/presets/index.d.ts +1 -1
  89. package/dist/styles.css +301 -188
  90. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  91. package/dist/themes/_contract.js +9 -1
  92. package/dist/themes/atelier/index.d.ts +1 -1
  93. package/dist/themes/ember/index.d.ts +1 -1
  94. package/dist/themes/index.d.ts +2 -2
  95. package/dist/themes/meridian/index.d.ts +1 -1
  96. package/dist/themes/poppy/index.d.ts +1 -1
  97. package/dist/themes/prism/index.d.ts +1 -1
  98. package/dist/themes/sable/index.d.ts +1 -1
  99. package/dist/themes/verdant/index.d.ts +1 -1
  100. package/llms/components/activity-heatmap.md +133 -0
  101. package/llms/components/avatar.md +32 -9
  102. package/llms/components/code-block.md +1 -0
  103. package/llms/components/collapsible.md +12 -4
  104. package/llms/components/data-table.md +40 -3
  105. package/llms/components/date-picker.md +15 -1
  106. package/llms/components/dialog.md +34 -3
  107. package/llms/components/media-image.md +12 -0
  108. package/llms/components/media-tile.md +21 -0
  109. package/llms/components/menu.md +1 -1
  110. package/llms/components/page-header.md +16 -1
  111. package/llms/components/panel-group.md +34 -2
  112. package/llms/components/pill.md +24 -5
  113. package/llms/components/search-input.md +37 -4
  114. package/llms/components/section-header.md +28 -4
  115. package/llms/components/sheet.md +2 -2
  116. package/llms/components/sidebar.md +17 -4
  117. package/llms/components/tabs.md +1 -0
  118. package/llms/components/workspace.md +81 -1
  119. package/llms/foundations/typography.md +4 -1
  120. package/llms/layout/aspect-box.md +1 -1
  121. package/llms/layout/bleed.md +1 -1
  122. package/llms/layout/center.md +1 -1
  123. package/llms/layout/columns.md +1 -1
  124. package/llms/layout/grid.md +1 -1
  125. package/llms/layout/region.md +1 -1
  126. package/llms/layout/row.md +9 -1
  127. package/llms/layout/spacer.md +1 -1
  128. package/llms/layout/split-pane.md +1 -1
  129. package/llms/layout/stack.md +1 -1
  130. package/llms/patterns/dashboard-page.md +18 -15
  131. package/llms/patterns/tracker-page.md +450 -0
  132. package/llms/reference/token-intent.md +3 -1
  133. package/llms/reference/utilities.md +4 -1
  134. package/llms/tasks/auditing-an-app.md +35 -1
  135. package/llms/tasks/building-a-page.md +3 -2
  136. package/llms/tasks/building-a-tool.md +101 -5
  137. package/llms/tasks/building-with-sable.md +11 -3
  138. package/llms/tasks/creating-a-preset.md +9 -0
  139. package/llms/tasks/getting-started.md +10 -3
  140. package/llms/tasks/using-presets.md +1 -2
  141. package/llms-full.txt +1215 -89
  142. package/llms.txt +11 -9
  143. package/package.json +7 -1
  144. package/registry.json +1021 -70
  145. package/scripts/adoption.mjs +252 -0
  146. package/scripts/agent-setup.mjs +110 -18
  147. package/scripts/app-css.mjs +129 -0
  148. package/scripts/grade.mjs +178 -2
  149. package/scripts/lint-rules.mjs +126 -0
  150. package/scripts/wiring.mjs +2 -1
  151. package/skills/entry/SKILL.md +1 -1
  152. package/skills/sable/SKILL.md +64 -6
  153. package/skills/sable/agents/openai.yaml +1 -1
  154. package/targets/ios.json +9 -0
@@ -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
  {
@@ -1,7 +1,7 @@
1
1
  // src/components/pill/pill.meta.ts
2
2
  var pillMeta = {
3
3
  name: "Pill",
4
- summary: 'A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size md or sm. A static label is a Badge.',
4
+ summary: 'A chip someone clicks: a filter, a tag choice, an option in a row. Always a button with aria-pressed. appearance="solid" (filters; selected lifts onto the primary surface) or "outline" (quiet chips that annotate content, like reactions); size lg, md or sm. A static label is a Badge.',
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "pill",
@@ -22,6 +22,10 @@ var pillMeta = {
22
22
  guidance: "do",
23
23
  description: "Reflect the current choice with selected, so the active filter reads at a glance."
24
24
  },
25
+ {
26
+ guidance: "do",
27
+ description: 'In a toolbar where filter chips share a row with a search field ("All 8 \xB7 Products 5 \xB7 Open source 2 \u2026 [Find a project]"), pair Pill size="lg" with SearchInput size="sm", and Button size="sm" for any action beside them. All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Pill md (28px) has no text field of its height in the page register, so md beside a field always reads as a mismatch.'
28
+ },
25
29
  {
26
30
  guidance: "dont",
27
31
  description: "Do not use Pill as a static label. A pressable-looking chip that does nothing is the most common cause of dead clicks in a filter row \u2014 that is a Badge.",
@@ -33,14 +37,15 @@ var pillMeta = {
33
37
  },
34
38
  {
35
39
  guidance: "dont",
36
- description: 'Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system.'
40
+ description: 'Do not restyle a Pill with border or background classes to get a quieter chip. That is appearance="outline" (and size="sm" for a small one); an override is a look that exists nowhere else in the system. Likewise never set h-* on a Pill to match a neighbour: pick the size that shares its rung.'
37
41
  }
38
42
  ]
39
43
  },
40
44
  related: {
41
45
  components: [
42
46
  "Badge",
43
- "ToggleGroup"
47
+ "ToggleGroup",
48
+ "SearchInput"
44
49
  ]
45
50
  },
46
51
  props: [
@@ -58,9 +63,9 @@ var pillMeta = {
58
63
  },
59
64
  {
60
65
  name: "size",
61
- type: '"md" | "sm"',
66
+ type: '"lg" | "md" | "sm"',
62
67
  default: '"md"',
63
- description: "md sits on the density ladder's xs control height (28px); sm is 24px, for chips under or inside other content."
68
+ description: "lg sits on --control-sm (32px, 28 compact), the toolbar rung it shares with SearchInput sm and Button sm, for a filter row beside a search field. md sits on --control-xs (28px, 24 compact), the chip in a row of its own or among content. sm is a fixed 24px (the target floor, which density does not shrink), for chips under or inside other content."
64
69
  },
65
70
  {
66
71
  name: "tone",
@@ -76,7 +81,7 @@ var pillMeta = {
76
81
  ],
77
82
  variants: {
78
83
  appearance: ["solid", "outline"],
79
- size: ["md", "sm"],
84
+ size: ["lg", "md", "sm"],
80
85
  selected: ["true", "false"],
81
86
  tone: ["neutral", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink"]
82
87
  },
@@ -143,6 +148,10 @@ var pillMeta = {
143
148
  label: "Filter row",
144
149
  code: "<Row gap={1}><Pill selected onClick={() => {}}>All</Pill><Pill onClick={() => {}}>Projects</Pill><Pill onClick={() => {}}>Reports</Pill></Row>"
145
150
  },
151
+ {
152
+ label: "Filters with search",
153
+ code: 'const [filter, setFilter] = useState("all")\nconst [q, setQ] = useState("")\n\n<Row gap={2} align="center" justify="between" wrap>\n <Row gap={1}>\n <Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>\n <Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>\n <Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>\n </Row>\n <SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project\u2026" aria-label="Find a project" className="max-w-64" />\n</Row>'
154
+ },
146
155
  {
147
156
  label: "Outline, small (reactions)",
148
157
  code: '<Row gap={1}><Pill appearance="outline" size="sm" selected onClick={() => {}}>\u{1F389} 3</Pill><Pill appearance="outline" size="sm" onClick={() => {}}>\u{1F440}</Pill></Row>'
@@ -26,6 +26,7 @@ var pillVariants = cva(
26
26
  outline: "border"
27
27
  },
28
28
  size: {
29
+ lg: "h-(--control-sm) px-4 py-0 text-label-lg [--sable-optical-pad:0px]",
29
30
  md: "h-(--control-xs) px-3 py-0 [--sable-optical-pad:0px]",
30
31
  sm: "h-6 py-0 text-label-sm [--sable-optical-pad:0px]"
31
32
  },
@@ -2,10 +2,31 @@
2
2
  import { useState } from "react";
3
3
  import { Icon } from "../../icon/index.js";
4
4
  import { cn } from "../../lib/cn.js";
5
- import { FIELD_INVALID, FIELD_SHELL, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
5
+ import { FIELD_INVALID, FIELD_SHELL, FIELD_SHELL_XS, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
6
6
  import { useDensity, useTypeTier } from "../../lib/density.js";
7
+ import { hitTargetMin } from "../../lib/hit-target.js";
7
8
  import { Button } from "../button/button.js";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
+ var SIZES = {
11
+ md: {
12
+ field: "h-(--control-md) ps-9 pe-9",
13
+ icon: "start-3 text-sm",
14
+ clear: "end-2",
15
+ button: void 0
16
+ },
17
+ sm: {
18
+ field: "h-(--control-sm) ps-8 pe-8",
19
+ icon: "start-2.5 text-sm",
20
+ clear: "end-1",
21
+ button: cn("size-[calc(var(--control-sm)-var(--spacing)*2)] [--button-icon:0.875rem]", hitTargetMin)
22
+ },
23
+ xs: {
24
+ field: cn("h-(--control-xs) ps-6 pe-7", FIELD_SHELL_XS),
25
+ icon: "start-1.5 text-xs",
26
+ clear: "end-0.5",
27
+ button: cn("size-[calc(var(--control-xs)-var(--spacing))] [--button-icon:0.75rem]", hitTargetMin)
28
+ }
29
+ };
9
30
  function SearchInput({
10
31
  value,
11
32
  defaultValue,
@@ -14,10 +35,12 @@ function SearchInput({
14
35
  placeholder = "Search",
15
36
  className,
16
37
  inputClassName,
38
+ size = "md",
17
39
  ref,
18
40
  type,
19
41
  ...props
20
42
  }) {
43
+ const rung = SIZES[size];
21
44
  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
22
45
  const controlled = value !== void 0;
23
46
  const current = controlled ? value : uncontrolled;
@@ -41,7 +64,7 @@ function SearchInput({
41
64
  Icon,
42
65
  {
43
66
  name: "search",
44
- className: "pointer-events-none absolute start-3 text-sm text-muted-foreground"
67
+ className: cn("pointer-events-none absolute text-muted-foreground", rung.icon)
45
68
  }
46
69
  ),
47
70
  /* @__PURE__ */ jsx(
@@ -53,10 +76,12 @@ function SearchInput({
53
76
  onChange: (event) => change(event.target.value),
54
77
  placeholder,
55
78
  className: cn(
56
- "flex h-(--control-md) w-full ps-9 pe-9 text-foreground",
79
+ "flex w-full text-foreground",
57
80
  // The shell Input and CommandMenuTrigger wear too — lib/field-shell.
81
+ // xs layers the inset tool-panel surface over it, as Input xs does.
58
82
  FIELD_SHELL,
59
83
  FIELD_INVALID,
84
+ rung.field,
60
85
  "[&::-webkit-search-cancel-button]:appearance-none",
61
86
  tier.body,
62
87
  density === "comfortable" && MOBILE_FIELD_TIER,
@@ -72,14 +97,14 @@ function SearchInput({
72
97
  // transition-property slot — whichever sorted last would win, and
73
98
  // either the hover ease or the arrival would silently go. Removal
74
99
  // stays instant: an emptied field should not wait on its X.
75
- /* @__PURE__ */ jsx("span", { className: "absolute end-2 flex motion-appear", children: /* @__PURE__ */ jsx(
100
+ /* @__PURE__ */ jsx("span", { className: cn("absolute flex motion-appear", rung.clear), children: /* @__PURE__ */ jsx(
76
101
  Button,
77
102
  {
78
103
  variant: "ghost",
79
104
  size: "icon-xs",
80
105
  onClick: handleClear,
81
106
  "aria-label": "Clear search",
82
- className: "text-muted-foreground hover:text-foreground",
107
+ className: cn("text-muted-foreground hover:text-foreground", rung.button),
83
108
  children: /* @__PURE__ */ jsx(Icon, { name: "xmark" })
84
109
  }
85
110
  ) })
@@ -1,7 +1,7 @@
1
1
  // src/components/search-input/search-input.meta.ts
2
2
  var searchInputMeta = {
3
3
  name: "SearchInput",
4
- summary: "Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value.",
4
+ summary: "Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value. size md (form), sm (toolbar, matching Pill lg and Button sm) or xs (inset tool-panel field).",
5
5
  status: "stable",
6
6
  keywords: [
7
7
  "search",
@@ -28,6 +28,14 @@ var searchInputMeta = {
28
28
  guidance: "do",
29
29
  description: "Light the query in the results it filters with `useMatchHighlight(query)` \u2014 a callback ref for the results container, with `selector` to keep it to titles. It paints on the `highlight` pair without wrapping matches in `<mark>`, the same paint Combobox and CommandMenu use for their rows."
30
30
  },
31
+ {
32
+ guidance: "do",
33
+ description: `In a toolbar beside filter chips, use size="sm" with Pill size="lg" (and Button size="sm" for actions). All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Keep md for a search field in a form or a page header beside md buttons, and xs for a filter inside a workspace panel, where it matches Input xs's inset surface.`
34
+ },
35
+ {
36
+ guidance: "dont",
37
+ description: "Do not match a neighbour's height with h-* on className or inputClassName. The magnifier, padding and clear button would stay sized for the old rung; pick the size that shares the neighbour's rung."
38
+ },
31
39
  {
32
40
  guidance: "dont",
33
41
  description: "Do not use SearchInput as a command palette; use CommandMenu for global commands, opened from the header by CommandMenuTrigger. A SearchInput that opens a dialog on focus is a text box you cannot type in."
@@ -42,10 +50,17 @@ var searchInputMeta = {
42
50
  components: [
43
51
  "Input",
44
52
  "CommandMenu",
45
- "Combobox"
53
+ "Combobox",
54
+ "Pill"
46
55
  ]
47
56
  },
48
57
  props: [
58
+ {
59
+ name: "size",
60
+ type: '"md" | "sm" | "xs"',
61
+ default: '"md"',
62
+ description: "The rung of the control ladder, named as on Input, Select and Combobox. md is the form field (--control-md, 40px / 32 compact). sm is the toolbar field (--control-sm, 32px / 28 compact), the rung Pill lg and Button sm share, so filters + search + actions in one row are one height. xs is the tool-panel field (--control-xs, 28px / 24 compact) on Input xs's inset panel-field surface. The magnifier, inline padding and clear button scale with the rung and stay vertically centred."
63
+ },
49
64
  {
50
65
  name: "value",
51
66
  type: "string",
@@ -91,7 +106,7 @@ var searchInputMeta = {
91
106
  {
92
107
  name: "...props",
93
108
  type: 'ComponentPropsWithRef<"input">',
94
- description: "All native input attributes (name, autoFocus, ref, aria-invalid, \u2026) pass through to the <input> except value/onChange, which are owned by value/onValueChange, and className, which sizes the whole field."
109
+ description: "All native input attributes (name, autoFocus, ref, aria-invalid, \u2026) pass through to the <input> except value/onChange, which are owned by value/onValueChange, className, which sizes the whole field, and the native numeric size attribute, which the size prop replaces."
95
110
  }
96
111
  ],
97
112
  tokensUsed: [
@@ -100,7 +115,9 @@ var searchInputMeta = {
100
115
  "foreground",
101
116
  "muted-foreground",
102
117
  "ring",
103
- "destructive"
118
+ "destructive",
119
+ "panel-field",
120
+ "panel-field-hover"
104
121
  ],
105
122
  motion: [
106
123
  {
@@ -152,6 +169,14 @@ var searchInputMeta = {
152
169
  label: "Highlighting matches in the results",
153
170
  code: 'const [q, setQ] = useState("")\nconst results = useMatchHighlight<HTMLUListElement>(q, { selector: "[data-title]" })\nconst shown = posts.filter((post) => post.title.toLowerCase().includes(q.trim().toLowerCase()))\n\n<SearchInput value={q} onValueChange={setQ} placeholder="Search posts" />\n<ul ref={results}>\n {shown.map((post) => (\n <li key={post.id}><span data-title>{post.title}</span></li>\n ))}\n</ul>'
154
171
  },
172
+ {
173
+ label: "Filters with search",
174
+ code: 'const [filter, setFilter] = useState("all")\nconst [q, setQ] = useState("")\n\n<Row gap={2} align="center" justify="between" wrap>\n <Row gap={1}>\n <Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>\n <Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>\n <Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>\n </Row>\n <SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project\u2026" aria-label="Find a project" className="max-w-64" />\n</Row>'
175
+ },
176
+ {
177
+ label: "Sizes",
178
+ code: '<Stack gap={1}>\n <SearchInput size="md" placeholder="Search posts" aria-label="Search posts" />\n <SearchInput size="sm" placeholder="Find a project\u2026" aria-label="Find a project" />\n <SearchInput size="xs" placeholder="Filter layers" aria-label="Filter layers" />\n</Stack>'
179
+ },
155
180
  {
156
181
  label: "Disabled",
157
182
  code: '<SearchInput disabled placeholder="Search" />'
@@ -3,9 +3,10 @@ import { useId } from "react";
3
3
  import { cn } from "../../lib/cn.js";
4
4
  import { HeadingRow } from "../../lib/heading-row.js";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
- var TITLE_TIER = {
7
- md: "text-heading-3",
8
- sm: "text-heading-4"
6
+ var SIZE = {
7
+ lg: { root: "gap-3", title: "text-heading-2", description: "text-body-md" },
8
+ md: { root: "gap-2", title: "text-heading-3", description: "text-body-sm" },
9
+ sm: { root: "gap-2", title: "text-heading-4", description: "text-body-sm" }
9
10
  };
10
11
  function SectionHeader({
11
12
  title,
@@ -18,20 +19,29 @@ function SectionHeader({
18
19
  ...props
19
20
  }) {
20
21
  const generatedId = useId();
21
- return /* @__PURE__ */ jsxs("div", { "data-slot": "section-header", className: cn("flex flex-col gap-2", className), ...props, children: [
22
- /* @__PURE__ */ jsx(
23
- HeadingRow,
24
- {
25
- as: titleAs,
26
- title,
27
- titleId: titleId ?? generatedId,
28
- titleClassName: TITLE_TIER[size],
29
- band: "flex min-h-(--control-sm) items-center",
30
- actions
31
- }
32
- ),
33
- description ? /* @__PURE__ */ jsx("p", { className: "max-w-(--measure-prose) text-body-sm text-pretty text-muted-foreground", children: description }) : null
34
- ] });
22
+ return /* @__PURE__ */ jsxs(
23
+ "div",
24
+ {
25
+ "data-slot": "section-header",
26
+ "data-size": size,
27
+ className: cn("flex flex-col", SIZE[size].root, className),
28
+ ...props,
29
+ children: [
30
+ /* @__PURE__ */ jsx(
31
+ HeadingRow,
32
+ {
33
+ as: titleAs,
34
+ title,
35
+ titleId: titleId ?? generatedId,
36
+ titleClassName: SIZE[size].title,
37
+ band: "flex min-h-(--control-sm) items-center mt-[min(0px,calc((var(--control-sm)_-_1lh)/2))]",
38
+ actions
39
+ }
40
+ ),
41
+ description ? /* @__PURE__ */ jsx("p", { className: cn("max-w-(--measure-prose) text-pretty text-muted-foreground", SIZE[size].description), children: description }) : null
42
+ ]
43
+ }
44
+ );
35
45
  }
36
46
  SectionHeader.displayName = "SectionHeader";
37
47
  export {
@@ -14,7 +14,9 @@ var sectionHeaderMeta = {
14
14
  "h2",
15
15
  "subsection",
16
16
  "optical alignment",
17
- "bleed"
17
+ "bleed",
18
+ "editorial section",
19
+ "large section title"
18
20
  ],
19
21
  usage: {
20
22
  description: "Use SectionHeader to head each section below the page title \u2014 a home-page shelf with View all, a settings group, a list with its own actions. PageHeader is the route's single h1; every section under it takes a SectionHeader, with titleId wired to the section's aria-labelledby.",
@@ -25,7 +27,7 @@ var sectionHeaderMeta = {
25
27
  },
26
28
  {
27
29
  guidance: "do",
28
- description: "Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: md for a page's sections and shelves, sm for denser groups like settings."
30
+ description: `Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: lg for an editorial page's sections under a PageHeader size="lg", md for a page's sections and shelves, sm for denser groups like settings.`
29
31
  },
30
32
  {
31
33
  guidance: "do",
@@ -87,13 +89,13 @@ var sectionHeaderMeta = {
87
89
  },
88
90
  {
89
91
  name: "size",
90
- type: '"md" | "sm"',
92
+ type: '"lg" | "md" | "sm"',
91
93
  default: '"md"',
92
- description: "Visual weight, independent of the heading level: md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets \u2014 settings groups, subsections."
94
+ description: `Visual weight, independent of the heading level: lg (text-heading-2 over a text-body-md description) for an editorial page's sections, under a PageHeader size="lg"; md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets \u2014 settings groups, subsections. lg stops at heading-2: heading-1 is the page title's, one per route. The actions stay centred on the title's first line at every size.`
93
95
  }
94
96
  ],
95
97
  variants: {
96
- size: ["md", "sm"]
98
+ size: ["lg", "md", "sm"]
97
99
  },
98
100
  tokensUsed: ["foreground", "muted-foreground"],
99
101
  a11y: [
@@ -161,6 +163,26 @@ var sectionHeaderMeta = {
161
163
  <Entity className="[--entity-px:0]" title="Late drives" subtitle="42 tracks" />
162
164
  <Entity className="[--entity-px:0]" title="Focus" subtitle="18 tracks" />
163
165
  </Stack>
166
+ </section>`
167
+ },
168
+ {
169
+ label: "An editorial section (size lg)",
170
+ code: `<section aria-labelledby="selected-work" className="flex flex-col gap-6">
171
+ <SectionHeader
172
+ titleId="selected-work"
173
+ size="lg"
174
+ title="Selected work"
175
+ description="Five projects from the last two years."
176
+ actions={
177
+ <Button variant="ghost" size="sm" render={<a href="/work" />} aria-label="View all work">
178
+ View all
179
+ </Button>
180
+ }
181
+ />
182
+ <Grid minItemWidth="16rem" gap={3}>
183
+ <Card><CardHeader><CardTitle>Tidewater</CardTitle><CardDescription>Brand identity</CardDescription></CardHeader></Card>
184
+ <Card><CardHeader><CardTitle>Northlight</CardTitle><CardDescription>Product design</CardDescription></CardHeader></Card>
185
+ </Grid>
164
186
  </section>`
165
187
  },
166
188
  {
@@ -8,6 +8,7 @@ import {
8
8
  useState
9
9
  } from "react";
10
10
  import { cn } from "../../lib/cn.js";
11
+ import { FocusReturnProvider, useFocusReturn, useFocusReturnDefault } from "../../lib/focus-return.js";
11
12
  import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
12
13
  import { PresentationProvider, StackedActionsContext, usePresentation } from "../../lib/presentation.js";
13
14
  import { mergeRefs } from "../../lib/refs.js";
@@ -27,15 +28,20 @@ function Sheet({
27
28
  defaultSnapPoint,
28
29
  onSnapPointChange,
29
30
  snapToSequentialPoints,
31
+ open,
32
+ onOpenChange,
30
33
  ...props
31
34
  }) {
35
+ const focus = useFocusReturn(open, onOpenChange);
32
36
  const [side, setSide] = useState("right");
33
37
  const snaps = Boolean(snapPoints?.length);
34
38
  const context = useMemo(() => ({ reportSide: setSide, snaps }), [snaps]);
35
- return /* @__PURE__ */ jsx(SheetRootContext.Provider, { value: context, children: /* @__PURE__ */ jsx(
39
+ return /* @__PURE__ */ jsx(SheetRootContext.Provider, { value: context, children: /* @__PURE__ */ jsx(FocusReturnProvider, { value: focus.focusReturn, children: /* @__PURE__ */ jsx(
36
40
  Base.Root,
37
41
  {
38
42
  ...props,
43
+ open,
44
+ onOpenChange: focus.onOpenChange,
39
45
  snapPoints: snaps && side === "bottom" ? snapPoints : void 0,
40
46
  snapPoint,
41
47
  defaultSnapPoint,
@@ -43,7 +49,7 @@ function Sheet({
43
49
  snapToSequentialPoints,
44
50
  swipeDirection: SWIPE_DIRECTION[side]
45
51
  }
46
- ) });
52
+ ) }) });
47
53
  }
48
54
  function isDevelopment() {
49
55
  try {
@@ -81,10 +87,12 @@ function SheetContent({
81
87
  children,
82
88
  side,
83
89
  initialFocus,
90
+ finalFocus,
84
91
  ref,
85
92
  style,
86
93
  ...props
87
94
  }) {
95
+ const focusReturn = useFocusReturnDefault();
88
96
  const root = useContext(SheetRootContext);
89
97
  const resolvedSide = side ?? "right";
90
98
  const reportSide = root?.reportSide;
@@ -98,7 +106,10 @@ function SheetContent({
98
106
  }
99
107
  const popupRef = useRef(null);
100
108
  const [popup, setPopup] = useState(null);
101
- const mergedRef = useMemo(() => mergeRefs(popupRef, ref, setPopup), [ref]);
109
+ const mergedRef = useMemo(
110
+ () => mergeRefs(popupRef, ref, setPopup, focusReturn?.popupRef),
111
+ [ref, focusReturn]
112
+ );
102
113
  const keyboardInset = useKeyboardInset(resolvedSide === "bottom" && popup !== null);
103
114
  const keyboardVars = { "--sheet-keyboard-inset": `${keyboardInset}px` };
104
115
  useIsomorphicLayoutEffect(() => {
@@ -121,6 +132,7 @@ function SheetContent({
121
132
  {
122
133
  ref: mergedRef,
123
134
  initialFocus: initialFocus === void 0 ? (type) => type === "touch" ? popupRef.current : true : initialFocus,
135
+ finalFocus: finalFocus === void 0 ? focusReturn?.finalFocus : finalFocus,
124
136
  "data-sable-sheet": resolvedSide,
125
137
  "data-snap-points": snaps ? "" : void 0,
126
138
  className: cn(sheetVariants({ side }), className),
@@ -93,7 +93,7 @@ var sheetMeta = {
93
93
  {
94
94
  name: "Sheet (Root)",
95
95
  type: "Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) \u2014 minus swipeDirection",
96
- description: `Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it.`
96
+ description: `Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. Records the element focused when the sheet opens, trigger or not, and returns focus there on close.`
97
97
  },
98
98
  {
99
99
  name: "snapPoints",
@@ -171,7 +171,7 @@ var sheetMeta = {
171
171
  "The panel scrolls its own content (thin scrollbar, contained overscroll) when it outgrows its bound \u2014 the full height on left/right, 80vh on top/bottom.",
172
172
  "Esc and click-outside dismiss by default; on touch, a swipe toward the sheet's own edge dismisses too. A short or slow drag springs back.",
173
173
  "Swipe is an extra path, never the only one: Esc, click-outside and SheetClose all still work.",
174
- "Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down); focus returns to the trigger on close.",
174
+ "Focus matches Dialog: the first tabbable on mouse/keyboard open, the panel itself on touch (so the virtual keyboard stays down). On close, focus returns to the element that held it when the sheet opened: the SheetTrigger, or your own button when it drives a controlled open. It is captured before the content mounts, so an autoFocus field inside cannot displace it; an explicit finalFocus on SheetContent wins.",
175
175
  "Render a SheetClose inside SheetContent so screen-reader users can escape the popup.",
176
176
  "Slide animation runs via data-[starting-style]/data-[ending-style] attributes on the motion-duration token; while swiping the panel tracks the finger directly, and a released swipe finishes on the same token (scaled by flick speed), so reduced motion makes it instant.",
177
177
  "A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge.",
@@ -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,