@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,502 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SideDock — a *summoned* panel that docks to one edge of the content area:
5
+ * closed by default, opened by something the user does, resizable by
6
+ * pointer AND keyboard, and — below a viewport threshold — presented as an
7
+ * overlay `Sheet` instead of a column. See
8
+ * `docs/ADR/0035-context-rail-and-side-dock.md` §5. `ContextRail` (the
9
+ * sibling in this package) is a *persistent* rail with a 48px icon collapsed
10
+ * state; SideDock is not persistent and has no icon state.
11
+ *
12
+ * Built on the mandatory `useCollapsiblePanel` mechanism (open/close tween)
13
+ * and `useIsMobile(overlayBreakpoint)` (the column/overlay switch) — both
14
+ * shared with `ContextRail`, never re-implemented here.
15
+ */
16
+ import {
17
+ forwardRef,
18
+ useEffect,
19
+ useId,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ type ComponentProps,
24
+ type CSSProperties,
25
+ type KeyboardEvent as ReactKeyboardEvent,
26
+ type PointerEvent as ReactPointerEvent,
27
+ type ReactNode,
28
+ type Ref,
29
+ } from "react";
30
+ import { X } from "lucide-react";
31
+ import { cn } from "../../lib/cn";
32
+ import { mergeRefs } from "../../lib/merge-refs";
33
+ import { useIsMobile } from "../../lib/use-mobile";
34
+ import { useCollapsiblePanel } from "../collapsible-panel";
35
+ import { useLocale } from "../locale-provider/locale-provider";
36
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
37
+
38
+ export interface SideDockProps extends Omit<ComponentProps<"aside">, "title"> {
39
+ /**
40
+ * The dock's name. Rendered as its header heading, and as the `SheetTitle`
41
+ * of the overlay presentation. REQUIRED: the overlay cannot be accessible
42
+ * without it, and an optional prop with a generic default would ship a
43
+ * meaningless announced name into real apps.
44
+ */
45
+ title: ReactNode;
46
+ /**
47
+ * Optional longer description. Visible under the title in the column
48
+ * presentation; `sr-only` in the overlay presentation (the compact sheet
49
+ * header has no room for it, but it still reaches assistive tech).
50
+ */
51
+ description?: ReactNode;
52
+ /** Optional controls placed in the header row beside the built-in close button. */
53
+ headerActions?: ReactNode;
54
+
55
+ /** Controlled open state. Closed means zero width (content stays mounted, inert). */
56
+ open?: boolean;
57
+ /** Uncontrolled initial open state. Default `false` — a dock is summoned. */
58
+ defaultOpen?: boolean;
59
+ /** Called with the next open state. */
60
+ onOpenChange?: (open: boolean) => void;
61
+
62
+ /** Which edge it docks to. Default `"right"`. */
63
+ side?: "left" | "right";
64
+
65
+ /**
66
+ * `width`, `defaultWidth`, `minWidth`, `maxWidth`, `minContentWidth` and
67
+ * `resizable` (below) govern the COLUMN presentation only — viewport at or
68
+ * above `overlayBreakpoint`. Below that breakpoint the dock renders as an
69
+ * overlay `Sheet` sized by the viewport (`Sheet`'s own `w-3/4 max-w-sm`),
70
+ * so none of these props have any effect there.
71
+ */
72
+ /** Controlled width in px. */
73
+ width?: number;
74
+ /** Uncontrolled initial width in px. Default `400`. */
75
+ defaultWidth?: number;
76
+ /** Fires continuously during a resize — drive layout from this. */
77
+ onWidthChange?: (width: number) => void;
78
+ /** Fires once when an interaction ends — PERSIST from this. */
79
+ onWidthCommit?: (width: number) => void;
80
+
81
+ /** Lower bound in px. Default `320`. */
82
+ minWidth?: number;
83
+ /** Upper bound in px. Default `640`. */
84
+ maxWidth?: number;
85
+ /** Content width preserved when clamping against the live viewport, px. Default `480`. */
86
+ minContentWidth?: number;
87
+ /** Whether the resize handle renders. Default `true`. */
88
+ resizable?: boolean;
89
+
90
+ /**
91
+ * Viewport width in px below which the dock renders as an overlay `Sheet`
92
+ * instead of a column. Default `1100`. Deliberately ABOVE the library's
93
+ * 768px mobile breakpoint: a 400px dock at 768px leaves ~360px of content.
94
+ * This is the switch point for the column-only sizing props above — the
95
+ * overlay presentation ignores them entirely.
96
+ */
97
+ overlayBreakpoint?: number;
98
+ }
99
+
100
+ const DEFAULT_WIDTH = 400;
101
+ const DEFAULT_MIN_WIDTH = 320;
102
+ const DEFAULT_MAX_WIDTH = 640;
103
+ const DEFAULT_MIN_CONTENT_WIDTH = 480;
104
+ const DEFAULT_OVERLAY_BREAKPOINT = 1100;
105
+ // A single arrow-key step, and Shift's multiplier on it (Mechanism §3).
106
+ const KEY_STEP = 16;
107
+ const KEY_STEP_SHIFT_MULTIPLIER = 4;
108
+ // SSR-only fallback — "use client" means this never actually renders on a
109
+ // server, but a hook initializer still runs once during any non-browser
110
+ // render pass (e.g. a framework's SSR pass before hydration).
111
+ const FALLBACK_VIEWPORT_WIDTH = 1024;
112
+
113
+ /**
114
+ * The clamp, written out exactly as specified (Ruling 26 §3):
115
+ * `minWidth` wins last, so a hostile viewport can never render a
116
+ * sub-minimum dock.
117
+ */
118
+ function clampWidth(
119
+ requested: number,
120
+ viewportWidth: number,
121
+ minWidth: number,
122
+ maxWidth: number,
123
+ minContentWidth: number,
124
+ ): number {
125
+ const upperBound = Math.min(maxWidth, viewportWidth - minContentWidth);
126
+ return Math.max(minWidth, Math.min(requested, upperBound));
127
+ }
128
+
129
+ export const SideDock = forwardRef<HTMLElement, SideDockProps>(function SideDock(
130
+ {
131
+ title,
132
+ description,
133
+ headerActions,
134
+ open,
135
+ defaultOpen = false,
136
+ onOpenChange,
137
+ side = "right",
138
+ width,
139
+ defaultWidth = DEFAULT_WIDTH,
140
+ onWidthChange,
141
+ onWidthCommit,
142
+ minWidth = DEFAULT_MIN_WIDTH,
143
+ maxWidth = DEFAULT_MAX_WIDTH,
144
+ minContentWidth = DEFAULT_MIN_CONTENT_WIDTH,
145
+ resizable = true,
146
+ overlayBreakpoint = DEFAULT_OVERLAY_BREAKPOINT,
147
+ className,
148
+ style,
149
+ children,
150
+ ...props
151
+ },
152
+ ref,
153
+ ) {
154
+ const { t, formatNumber } = useLocale();
155
+ const titleId = useId();
156
+ const resizeLabelId = useId();
157
+
158
+ const panel = useCollapsiblePanel({ side, open, defaultOpen, onOpenChange });
159
+ const isOverlay = useIsMobile(overlayBreakpoint);
160
+
161
+ // Focus restoration for the COLUMN presentation only (Finding 2,
162
+ // task-11f-brief.md). The overlay presentation is a Radix `Dialog` and
163
+ // already restores focus to its opener on close; the column `<aside>`
164
+ // has no such mechanism, and marking it `inert` while closed makes the
165
+ // browser's own HTML focus-fixup rule drop focus to `<body>` the instant
166
+ // a focused descendant becomes inert — the exact stranding this repairs
167
+ // (see `.claude/rules/interaction-guidelines.md`'s identical note about
168
+ // the native `disabled` attribute).
169
+ //
170
+ // `containerRef` is an internal mirror of the forwarded `ref` (merged
171
+ // below) so this effect can ask "was focus inside the dock?" regardless
172
+ // of what the caller's own `ref` points at. `groupRef` targets the outer
173
+ // wrapper, which never becomes `inert`, as the in-page fallback when the
174
+ // recorded opener is no longer usable.
175
+ const containerRef = useRef<HTMLElement | null>(null);
176
+ const mergedContainerRef = useMemo(() => mergeRefs<HTMLElement>(ref, containerRef), [ref]);
177
+ const groupRef = useRef<HTMLDivElement>(null);
178
+ const openerRef = useRef<Element | null>(null);
179
+ const wasOpenRef = useRef(panel.open);
180
+
181
+ useEffect(() => {
182
+ const wasOpen = wasOpenRef.current;
183
+ const isOpen = panel.open;
184
+ wasOpenRef.current = isOpen;
185
+
186
+ if (isOpen && !wasOpen) {
187
+ // Closed → open: remember whatever summoned the dock, so a later
188
+ // close can hand focus back to it.
189
+ openerRef.current = document.activeElement;
190
+ return;
191
+ }
192
+
193
+ if (!isOpen && wasOpen) {
194
+ // Open → closed: only act if focus was actually inside the dock —
195
+ // never steal focus the user had already moved elsewhere.
196
+ const container = containerRef.current;
197
+ const active = document.activeElement;
198
+ const focusWasInside = container != null && active != null && container.contains(active);
199
+ if (!focusWasInside) return;
200
+
201
+ const opener = openerRef.current;
202
+ if (opener instanceof HTMLElement && opener.isConnected && opener !== document.body) {
203
+ opener.focus();
204
+ } else {
205
+ // The recorded opener unmounted (or there wasn't one) — land on
206
+ // the outer wrapper rather than let focus fall through to
207
+ // `<body>`. It never joins the tab order (`tabIndex={-1}`).
208
+ groupRef.current?.focus();
209
+ }
210
+ }
211
+ }, [panel.open]);
212
+
213
+ // The live viewport width the clamp reads — `useIsMobile` only exposes a
214
+ // boolean threshold, not the pixel value clamping needs.
215
+ const [viewportWidth, setViewportWidth] = useState<number>(() =>
216
+ typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT_WIDTH,
217
+ );
218
+ useEffect(() => {
219
+ const onResize = () => setViewportWidth(window.innerWidth);
220
+ window.addEventListener("resize", onResize);
221
+ return () => window.removeEventListener("resize", onResize);
222
+ }, []);
223
+
224
+ const isWidthControlled = width !== undefined;
225
+ const [uncontrolledWidth, setUncontrolledWidth] = useState(defaultWidth);
226
+ const requestedWidth = isWidthControlled ? (width as number) : uncontrolledWidth;
227
+ const upperBound = Math.min(maxWidth, viewportWidth - minContentWidth);
228
+ const clampedWidth = clampWidth(
229
+ requestedWidth,
230
+ viewportWidth,
231
+ minWidth,
232
+ maxWidth,
233
+ minContentWidth,
234
+ );
235
+
236
+ // A ref mirror of the currently-rendered clamped width, refreshed on every
237
+ // render (not via an effect) so the keyboard/pointer handlers below can
238
+ // read and update it synchronously between two native browser events —
239
+ // React may not have committed a re-render from the previous event yet.
240
+ const latestWidthRef = useRef(clampedWidth);
241
+ latestWidthRef.current = clampedWidth;
242
+
243
+ // Convergence (fixed contract decision): a CONTROLLED width outside the
244
+ // clamp renders the clamped number and reports it back once — on mount,
245
+ // and again whenever a viewport resize moves the bound. Deps are scoped to
246
+ // the VALUES only (not the callback props), so an inline
247
+ // onWidthChange/onWidthCommit from the caller can't re-trigger this on
248
+ // every render — it fires only when what it reports actually changes.
249
+ useEffect(() => {
250
+ if (!isWidthControlled) return;
251
+ if (clampedWidth === width) return;
252
+ onWidthChange?.(clampedWidth);
253
+ onWidthCommit?.(clampedWidth);
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ }, [isWidthControlled, clampedWidth, width]);
256
+
257
+ /** A no-op never emits (Ruling 26 §2). Returns whether it actually changed. */
258
+ function requestWidth(next: number): boolean {
259
+ if (next === latestWidthRef.current) return false;
260
+ latestWidthRef.current = next;
261
+ if (!isWidthControlled) setUncontrolledWidth(next);
262
+ onWidthChange?.(next);
263
+ return true;
264
+ }
265
+
266
+ // Shared across the keyboard and pointer gestures below: did THIS gesture
267
+ // (the run of keydowns between focus and keyup, or the pointerdown-to-
268
+ // pointerup drag) actually change the value? `onWidthCommit` is a
269
+ // persistence trigger — firing it for a no-op gesture is noise.
270
+ const gestureChangedRef = useRef(false);
271
+
272
+ function handleResizeKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
273
+ // Physical, not logical/RTL-aware (Mechanism §3): on a right-hand dock
274
+ // ArrowLeft widens (the handle moves toward the content); a left-hand
275
+ // dock swaps the two.
276
+ const widenKey = side === "right" ? "ArrowLeft" : "ArrowRight";
277
+ const narrowKey = side === "right" ? "ArrowRight" : "ArrowLeft";
278
+ const step = event.shiftKey ? KEY_STEP * KEY_STEP_SHIFT_MULTIPLIER : KEY_STEP;
279
+ let requested: number | undefined;
280
+ if (event.key === widenKey) requested = latestWidthRef.current + step;
281
+ else if (event.key === narrowKey) requested = latestWidthRef.current - step;
282
+ else if (event.key === "Home") requested = minWidth;
283
+ else if (event.key === "End") requested = upperBound;
284
+ else return;
285
+ event.preventDefault();
286
+ const next = clampWidth(requested, viewportWidth, minWidth, maxWidth, minContentWidth);
287
+ if (requestWidth(next)) gestureChangedRef.current = true;
288
+ }
289
+
290
+ function handleResizeKeyUp() {
291
+ if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
292
+ gestureChangedRef.current = false;
293
+ }
294
+
295
+ const pointerStartRef = useRef<{ x: number; width: number } | null>(null);
296
+ const [isDragging, setIsDragging] = useState(false);
297
+
298
+ function handleResizePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
299
+ if (event.pointerType === "mouse" && event.button !== 0) return;
300
+ event.currentTarget.setPointerCapture?.(event.pointerId);
301
+ gestureChangedRef.current = false;
302
+ pointerStartRef.current = { x: event.clientX, width: latestWidthRef.current };
303
+ setIsDragging(true);
304
+ }
305
+
306
+ function handleResizePointerMove(event: ReactPointerEvent<HTMLDivElement>) {
307
+ const start = pointerStartRef.current;
308
+ if (!start) return;
309
+ const delta = event.clientX - start.x;
310
+ const signedDelta = side === "right" ? -delta : delta;
311
+ const next = clampWidth(
312
+ start.width + signedDelta,
313
+ viewportWidth,
314
+ minWidth,
315
+ maxWidth,
316
+ minContentWidth,
317
+ );
318
+ if (requestWidth(next)) gestureChangedRef.current = true;
319
+ }
320
+
321
+ function endPointerGesture(event: ReactPointerEvent<HTMLDivElement>) {
322
+ if (!pointerStartRef.current) return;
323
+ pointerStartRef.current = null;
324
+ setIsDragging(false);
325
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
326
+ if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
327
+ gestureChangedRef.current = false;
328
+ }
329
+
330
+ // The generic `close` key (messages.ts "Generic" section) — reused per the
331
+ // brief rather than minting a new one.
332
+ const closeLabel = t("close");
333
+ const resizeValueText = t("ui.sideDock.widthValue", {
334
+ count: Math.round(clampedWidth),
335
+ size: formatNumber(Math.round(clampedWidth)),
336
+ });
337
+
338
+ const resizeHandle = resizable && (
339
+ <div
340
+ role="separator"
341
+ aria-orientation="vertical"
342
+ aria-labelledby={`${resizeLabelId} ${titleId}`}
343
+ aria-valuenow={Math.round(clampedWidth)}
344
+ aria-valuemin={minWidth}
345
+ aria-valuemax={Math.round(upperBound)}
346
+ aria-valuetext={resizeValueText}
347
+ tabIndex={0}
348
+ data-slot="side-dock-resize-handle"
349
+ onPointerDown={handleResizePointerDown}
350
+ onPointerMove={handleResizePointerMove}
351
+ onPointerUp={endPointerGesture}
352
+ onPointerCancel={endPointerGesture}
353
+ onKeyDown={handleResizeKeyDown}
354
+ onKeyUp={handleResizeKeyUp}
355
+ className={cn(
356
+ // A literal px hit box, not a `w-*` spacing utility — `--spacing` is
357
+ // what `data-density="compact"` rescales (Mechanism §3).
358
+ "absolute inset-y-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
359
+ side === "right" ? "left-0" : "right-0",
360
+ "focus-visible:outline-none",
361
+ isDragging
362
+ ? cn(
363
+ "after:absolute after:inset-y-0 after:w-1 after:bg-primary after:content-['']",
364
+ side === "right" ? "after:left-0" : "after:right-0",
365
+ )
366
+ : cn(
367
+ "after:absolute after:inset-y-0 after:w-px after:bg-muted-foreground after:content-['']",
368
+ "hover:after:w-1 focus-visible:after:w-1",
369
+ side === "right" ? "after:left-0" : "after:right-0",
370
+ ),
371
+ "focus-visible:after:focus-ring-static",
372
+ )}
373
+ >
374
+ <span id={resizeLabelId} className="sr-only">
375
+ {t("ui.sideDock.resize")}
376
+ </span>
377
+ </div>
378
+ );
379
+
380
+ if (isOverlay) {
381
+ return (
382
+ <Sheet open={panel.open} onOpenChange={panel.setOpen}>
383
+ <SheetContent
384
+ // Runtime-safe: Radix's Content ref resolves to a plain
385
+ // HTMLDivElement, same as the column branch's <aside> ref target
386
+ // at the type level (both extend HTMLElement).
387
+ ref={ref as unknown as Ref<HTMLDivElement>}
388
+ side={side}
389
+ data-slot="side-dock"
390
+ className={cn("flex flex-col bg-card p-0", className)}
391
+ style={style}
392
+ // With no description the dialog is still named by its title, so
393
+ // opt out of `aria-describedby` explicitly rather than let Radix
394
+ // warn about a missing description (mirrors ExpandDialogContent).
395
+ {...(description ? {} : { "aria-describedby": undefined })}
396
+ {...props}
397
+ >
398
+ <SheetHeader
399
+ data-slot="side-dock-header"
400
+ className="flex-row items-start gap-2 space-y-0 border-b border-border px-4 py-3"
401
+ >
402
+ <div className="min-w-0 flex-1">
403
+ <SheetTitle data-slot="side-dock-title" className="text-title">
404
+ {title}
405
+ </SheetTitle>
406
+ </div>
407
+ {headerActions}
408
+ </SheetHeader>
409
+ {description && <SheetDescription className="sr-only">{description}</SheetDescription>}
410
+ <div
411
+ data-slot="side-dock-body"
412
+ // Focusable because it scrolls; `focus-ring-inset` because the Sheet
413
+ // panel clips anything drawn outside this box, and both layers of
414
+ // the plain rung are drawn outside it. A dock's children are
415
+ // caller-supplied and routinely have no focusable descendant (a
416
+ // change log, a read-only detail panel), so without a tab stop
417
+ // there is no keyboard route into the region once it overflows
418
+ // (WCAG 2.1.1, axe `scrollable-region-focusable`).
419
+ tabIndex={0}
420
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
421
+ >
422
+ {children}
423
+ </div>
424
+ </SheetContent>
425
+ </Sheet>
426
+ );
427
+ }
428
+
429
+ return (
430
+ <div
431
+ data-slot="side-dock"
432
+ // `group`: the ancestor `group-data-[state=collapsed]:…` selectors in
433
+ // `panel.spacerClassName`/`panel.containerClassName` resolve against
434
+ // THIS element (Mechanism §1 — mirrors Sidebar's outer `group` div).
435
+ // `ref={groupRef}` + `tabIndex={-1}`: the focus-restoration fallback
436
+ // target (Finding 2) — this wrapper is never `inert`, unlike the
437
+ // `<aside>` below, so it is always a safe place to land focus. -1
438
+ // keeps it out of the normal tab order; it is only ever focused
439
+ // programmatically.
440
+ ref={groupRef}
441
+ tabIndex={-1}
442
+ className="group"
443
+ {...panel.attrs}
444
+ style={{ "--collapsible-panel-width": `${clampedWidth}px` } as CSSProperties}
445
+ >
446
+ <div data-slot="side-dock-spacer" className={panel.spacerClassName} />
447
+ <aside
448
+ ref={mergedContainerRef}
449
+ data-slot="side-dock-container"
450
+ aria-labelledby={titleId}
451
+ // The collapsed container stays mounted (for the closing transition)
452
+ // but stops being interactive/reachable (fixed contract decision).
453
+ inert={!panel.open}
454
+ className={cn(
455
+ panel.containerClassName,
456
+ "flex-col bg-card",
457
+ side === "right" ? "border-l" : "border-r",
458
+ className,
459
+ )}
460
+ style={style}
461
+ {...props}
462
+ >
463
+ <div
464
+ data-slot="side-dock-header"
465
+ className="flex items-start gap-2 border-b border-border px-4 py-3"
466
+ >
467
+ <div className="min-w-0 flex-1">
468
+ <div id={titleId} data-slot="side-dock-title" className="text-title">
469
+ {title}
470
+ </div>
471
+ {description && <div className="text-body text-muted-foreground">{description}</div>}
472
+ </div>
473
+ {headerActions}
474
+ <button
475
+ type="button"
476
+ data-slot="side-dock-close"
477
+ aria-label={closeLabel}
478
+ onClick={() => panel.setOpen(false)}
479
+ className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
480
+ >
481
+ <X className="size-4" aria-hidden="true" />
482
+ </button>
483
+ </div>
484
+ <div
485
+ data-slot="side-dock-body"
486
+ // Focusable because it scrolls; `focus-ring-inset` because the dock's
487
+ // own container clips anything drawn outside this box (the collapse
488
+ // tween animates its width). Worse here than in the overlay branch:
489
+ // the column is user-resizable, so narrowing it re-wraps every child
490
+ // taller — overflow is one keyboard gesture on the resize handle
491
+ // away, not a hypothetical viewport (WCAG 2.1.1, axe
492
+ // `scrollable-region-focusable`).
493
+ tabIndex={0}
494
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
495
+ >
496
+ {children}
497
+ </div>
498
+ {resizeHandle}
499
+ </aside>
500
+ </div>
501
+ );
502
+ });
@@ -1,3 +1,4 @@
1
+ export type { SidebarInsetGutter, SidebarInsetProps } from "./sidebar";
1
2
  export {
2
3
  Sidebar,
3
4
  SidebarProvider,
@@ -0,0 +1,162 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+
4
+ import { SidebarProvider, useSidebar } from "./sidebar";
5
+
6
+ /**
7
+ * Task 9A regression net for `SidebarProvider`'s `frame` prop (ADR 0035 §4).
8
+ * `frame="app"` (default, unset) must stay byte-identical to Task 8's
9
+ * behaviour; `frame="nested"` must suppress every row of the ADR's emission
10
+ * table except the two that survive `display: contents` — the CSS custom
11
+ * properties and the `SidebarContext` value.
12
+ */
13
+
14
+ function Probe() {
15
+ const { state, setOpen } = useSidebar();
16
+ return (
17
+ <div>
18
+ <span data-testid="state">{state}</span>
19
+ <button type="button" onClick={() => setOpen(true)}>
20
+ open
21
+ </button>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ function clearSidebarCookie() {
27
+ document.cookie = "sidebar_state=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
28
+ }
29
+
30
+ describe("SidebarProvider frame", () => {
31
+ beforeEach(() => {
32
+ clearSidebarCookie();
33
+ });
34
+
35
+ it("default (no frame prop) renders today's full frame surface", () => {
36
+ const { container } = render(<SidebarProvider>content</SidebarProvider>);
37
+ const wrapper = container.querySelector('[data-slot="sidebar-wrapper"]');
38
+ expect(wrapper).not.toBeNull();
39
+ expect(wrapper).toHaveAttribute("data-state", "expanded");
40
+ expect(wrapper?.className).toContain("group/sidebar-wrapper");
41
+ expect(wrapper?.className).toContain("flex");
42
+ expect(wrapper?.className).toContain("min-h-svh");
43
+ expect(wrapper?.className).toContain("w-full");
44
+ expect(wrapper?.className).toContain("text-foreground");
45
+ expect(wrapper?.className).toContain("has-data-[variant=inset]:bg-sidebar");
46
+ const style = (wrapper as HTMLElement).getAttribute("style") ?? "";
47
+ expect(style).toContain("--sidebar-width");
48
+ expect(style).toContain("--sidebar-width-icon");
49
+ });
50
+
51
+ it('frame="app" is byte-identical to the default', () => {
52
+ const { container: defaultContainer } = render(<SidebarProvider>content</SidebarProvider>);
53
+ const { container: appContainer } = render(
54
+ <SidebarProvider frame="app">content</SidebarProvider>,
55
+ );
56
+ const defaultWrapper = defaultContainer.querySelector('[data-slot="sidebar-wrapper"]');
57
+ const appWrapper = appContainer.querySelector('[data-slot="sidebar-wrapper"]');
58
+ expect(appWrapper?.outerHTML).toBe(defaultWrapper?.outerHTML);
59
+ });
60
+
61
+ it('frame="nested" emits none of the frame surface', () => {
62
+ const { container } = render(<SidebarProvider frame="nested">content</SidebarProvider>);
63
+ expect(container.querySelector('[data-slot="sidebar-wrapper"]')).toBeNull();
64
+
65
+ const root = container.firstElementChild as HTMLElement;
66
+ expect(root).not.toBeNull();
67
+ expect(root.className).not.toContain("group/sidebar-wrapper");
68
+ expect(root.className).not.toContain("min-h-svh");
69
+ expect(root.className).not.toContain("has-data-[variant=inset]");
70
+ expect(root.hasAttribute("data-variant")).toBe(false);
71
+ expect(root.hasAttribute("data-state")).toBe(false);
72
+ expect(root.className).toContain("contents");
73
+ const style = root.getAttribute("style") ?? "";
74
+ expect(style).toContain("--sidebar-width");
75
+ expect(style).toContain("--sidebar-width-icon");
76
+ });
77
+
78
+ it('suppresses data-variant/bg-sidebar under frame="nested" even when variant is passed', () => {
79
+ const { container: nestedContainer } = render(
80
+ <SidebarProvider frame="nested" variant="inset">
81
+ content
82
+ </SidebarProvider>,
83
+ );
84
+ const nestedRoot = nestedContainer.firstElementChild as HTMLElement;
85
+ expect(nestedRoot.hasAttribute("data-variant")).toBe(false);
86
+ expect(nestedRoot.className).not.toContain("bg-sidebar");
87
+
88
+ const { container: appContainer } = render(
89
+ <SidebarProvider frame="app" variant="inset">
90
+ content
91
+ </SidebarProvider>,
92
+ );
93
+ const appWrapper = appContainer.querySelector('[data-slot="sidebar-wrapper"]') as HTMLElement;
94
+ expect(appWrapper).toHaveAttribute("data-variant", "inset");
95
+ expect(appWrapper.className).toContain("bg-sidebar");
96
+ });
97
+
98
+ it('useSidebar() still works under frame="nested"', () => {
99
+ render(
100
+ <SidebarProvider frame="nested" defaultOpen={false}>
101
+ <Probe />
102
+ </SidebarProvider>,
103
+ );
104
+ expect(screen.getByTestId("state").textContent).toBe("collapsed");
105
+ fireEvent.click(screen.getByRole("button", { name: "open" }));
106
+ expect(screen.getByTestId("state").textContent).toBe("expanded");
107
+ });
108
+
109
+ it('registers no global keyboard shortcut under frame="nested", but does under frame="app"', () => {
110
+ const nestedSpy = vi.fn();
111
+ render(
112
+ <SidebarProvider frame="nested" open={false} onOpenChange={nestedSpy}>
113
+ content
114
+ </SidebarProvider>,
115
+ );
116
+ fireEvent.keyDown(window, { key: "b", metaKey: true });
117
+ expect(nestedSpy).not.toHaveBeenCalled();
118
+
119
+ const appSpy = vi.fn();
120
+ render(
121
+ <SidebarProvider frame="app" open={false} onOpenChange={appSpy}>
122
+ content
123
+ </SidebarProvider>,
124
+ );
125
+ fireEvent.keyDown(window, { key: "b", metaKey: true });
126
+ expect(appSpy).toHaveBeenCalled();
127
+ });
128
+
129
+ it('writes no sidebar_state cookie under frame="nested", but does under frame="app"', () => {
130
+ clearSidebarCookie();
131
+ render(
132
+ <SidebarProvider frame="nested">
133
+ <Probe />
134
+ </SidebarProvider>,
135
+ );
136
+ fireEvent.click(screen.getByRole("button", { name: "open" }));
137
+ expect(document.cookie).not.toContain("sidebar_state");
138
+
139
+ clearSidebarCookie();
140
+ render(
141
+ <SidebarProvider frame="app">
142
+ <Probe />
143
+ </SidebarProvider>,
144
+ );
145
+ const appButtons = screen.getAllByRole("button", { name: "open" });
146
+ fireEvent.click(appButtons[appButtons.length - 1]!);
147
+ expect(document.cookie).toContain("sidebar_state");
148
+ });
149
+
150
+ it("renders exactly one sidebar-wrapper when a nested provider sits inside an app frame", () => {
151
+ const { container } = render(
152
+ <SidebarProvider>
153
+ <div>
154
+ <SidebarProvider frame="nested">
155
+ <span>rail</span>
156
+ </SidebarProvider>
157
+ </div>
158
+ </SidebarProvider>,
159
+ );
160
+ expect(container.querySelectorAll('[data-slot="sidebar-wrapper"]')).toHaveLength(1);
161
+ });
162
+ });