@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.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 +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,515 @@
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
+ // `resize` can fire dozens of times per second while a window is being
220
+ // dragged — coalesce every event inside one frame into a single state
221
+ // update via rAF, instead of re-rendering on each raw event.
222
+ let rafId: number | null = null;
223
+ const onResize = () => {
224
+ if (rafId !== null) return;
225
+ rafId = requestAnimationFrame(() => {
226
+ rafId = null;
227
+ setViewportWidth(window.innerWidth);
228
+ });
229
+ };
230
+ window.addEventListener("resize", onResize);
231
+ return () => {
232
+ window.removeEventListener("resize", onResize);
233
+ if (rafId !== null) cancelAnimationFrame(rafId);
234
+ };
235
+ }, []);
236
+
237
+ const isWidthControlled = width !== undefined;
238
+ const [uncontrolledWidth, setUncontrolledWidth] = useState(defaultWidth);
239
+ const requestedWidth = isWidthControlled ? (width as number) : uncontrolledWidth;
240
+ const upperBound = Math.min(maxWidth, viewportWidth - minContentWidth);
241
+ const clampedWidth = clampWidth(
242
+ requestedWidth,
243
+ viewportWidth,
244
+ minWidth,
245
+ maxWidth,
246
+ minContentWidth,
247
+ );
248
+
249
+ // A ref mirror of the currently-rendered clamped width, refreshed on every
250
+ // render (not via an effect) so the keyboard/pointer handlers below can
251
+ // read and update it synchronously between two native browser events —
252
+ // React may not have committed a re-render from the previous event yet.
253
+ const latestWidthRef = useRef(clampedWidth);
254
+ latestWidthRef.current = clampedWidth;
255
+
256
+ // Convergence (fixed contract decision): a CONTROLLED width outside the
257
+ // clamp renders the clamped number and reports it back once — on mount,
258
+ // and again whenever a viewport resize moves the bound. Deps are scoped to
259
+ // the VALUES only (not the callback props), so an inline
260
+ // onWidthChange/onWidthCommit from the caller can't re-trigger this on
261
+ // every render — it fires only when what it reports actually changes.
262
+ useEffect(() => {
263
+ if (!isWidthControlled) return;
264
+ if (clampedWidth === width) return;
265
+ onWidthChange?.(clampedWidth);
266
+ onWidthCommit?.(clampedWidth);
267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
268
+ }, [isWidthControlled, clampedWidth, width]);
269
+
270
+ /** A no-op never emits (Ruling 26 §2). Returns whether it actually changed. */
271
+ function requestWidth(next: number): boolean {
272
+ if (next === latestWidthRef.current) return false;
273
+ latestWidthRef.current = next;
274
+ if (!isWidthControlled) setUncontrolledWidth(next);
275
+ onWidthChange?.(next);
276
+ return true;
277
+ }
278
+
279
+ // Shared across the keyboard and pointer gestures below: did THIS gesture
280
+ // (the run of keydowns between focus and keyup, or the pointerdown-to-
281
+ // pointerup drag) actually change the value? `onWidthCommit` is a
282
+ // persistence trigger — firing it for a no-op gesture is noise.
283
+ const gestureChangedRef = useRef(false);
284
+
285
+ function handleResizeKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
286
+ // Physical, not logical/RTL-aware (Mechanism §3): on a right-hand dock
287
+ // ArrowLeft widens (the handle moves toward the content); a left-hand
288
+ // dock swaps the two.
289
+ const widenKey = side === "right" ? "ArrowLeft" : "ArrowRight";
290
+ const narrowKey = side === "right" ? "ArrowRight" : "ArrowLeft";
291
+ const step = event.shiftKey ? KEY_STEP * KEY_STEP_SHIFT_MULTIPLIER : KEY_STEP;
292
+ let requested: number | undefined;
293
+ if (event.key === widenKey) requested = latestWidthRef.current + step;
294
+ else if (event.key === narrowKey) requested = latestWidthRef.current - step;
295
+ else if (event.key === "Home") requested = minWidth;
296
+ else if (event.key === "End") requested = upperBound;
297
+ else return;
298
+ event.preventDefault();
299
+ const next = clampWidth(requested, viewportWidth, minWidth, maxWidth, minContentWidth);
300
+ if (requestWidth(next)) gestureChangedRef.current = true;
301
+ }
302
+
303
+ function handleResizeKeyUp() {
304
+ if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
305
+ gestureChangedRef.current = false;
306
+ }
307
+
308
+ const pointerStartRef = useRef<{ x: number; width: number } | null>(null);
309
+ const [isDragging, setIsDragging] = useState(false);
310
+
311
+ function handleResizePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
312
+ if (event.pointerType === "mouse" && event.button !== 0) return;
313
+ event.currentTarget.setPointerCapture?.(event.pointerId);
314
+ gestureChangedRef.current = false;
315
+ pointerStartRef.current = { x: event.clientX, width: latestWidthRef.current };
316
+ setIsDragging(true);
317
+ }
318
+
319
+ function handleResizePointerMove(event: ReactPointerEvent<HTMLDivElement>) {
320
+ const start = pointerStartRef.current;
321
+ if (!start) return;
322
+ const delta = event.clientX - start.x;
323
+ const signedDelta = side === "right" ? -delta : delta;
324
+ const next = clampWidth(
325
+ start.width + signedDelta,
326
+ viewportWidth,
327
+ minWidth,
328
+ maxWidth,
329
+ minContentWidth,
330
+ );
331
+ if (requestWidth(next)) gestureChangedRef.current = true;
332
+ }
333
+
334
+ function endPointerGesture(event: ReactPointerEvent<HTMLDivElement>) {
335
+ if (!pointerStartRef.current) return;
336
+ pointerStartRef.current = null;
337
+ setIsDragging(false);
338
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
339
+ if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
340
+ gestureChangedRef.current = false;
341
+ }
342
+
343
+ // The generic `close` key (messages.ts "Generic" section) — reused per the
344
+ // brief rather than minting a new one.
345
+ const closeLabel = t("close");
346
+ const resizeValueText = t("ui.sideDock.widthValue", {
347
+ count: Math.round(clampedWidth),
348
+ size: formatNumber(Math.round(clampedWidth)),
349
+ });
350
+
351
+ const resizeHandle = resizable && (
352
+ <div
353
+ role="separator"
354
+ aria-orientation="vertical"
355
+ aria-labelledby={`${resizeLabelId} ${titleId}`}
356
+ aria-valuenow={Math.round(clampedWidth)}
357
+ aria-valuemin={minWidth}
358
+ aria-valuemax={Math.round(upperBound)}
359
+ aria-valuetext={resizeValueText}
360
+ tabIndex={0}
361
+ data-slot="side-dock-resize-handle"
362
+ onPointerDown={handleResizePointerDown}
363
+ onPointerMove={handleResizePointerMove}
364
+ onPointerUp={endPointerGesture}
365
+ onPointerCancel={endPointerGesture}
366
+ onKeyDown={handleResizeKeyDown}
367
+ onKeyUp={handleResizeKeyUp}
368
+ className={cn(
369
+ // A literal px hit box, not a `w-*` spacing utility — `--spacing` is
370
+ // what `data-density="compact"` rescales (Mechanism §3).
371
+ "absolute inset-y-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
372
+ side === "right" ? "left-0" : "right-0",
373
+ "focus-visible:outline-none",
374
+ isDragging
375
+ ? cn(
376
+ "after:absolute after:inset-y-0 after:w-1 after:bg-primary after:content-['']",
377
+ side === "right" ? "after:left-0" : "after:right-0",
378
+ )
379
+ : cn(
380
+ "after:absolute after:inset-y-0 after:w-px after:bg-muted-foreground after:content-['']",
381
+ "hover:after:w-1 focus-visible:after:w-1",
382
+ side === "right" ? "after:left-0" : "after:right-0",
383
+ ),
384
+ "focus-visible:after:focus-ring-static",
385
+ )}
386
+ >
387
+ <span id={resizeLabelId} className="sr-only">
388
+ {t("ui.sideDock.resize")}
389
+ </span>
390
+ </div>
391
+ );
392
+
393
+ if (isOverlay) {
394
+ return (
395
+ <Sheet open={panel.open} onOpenChange={panel.setOpen}>
396
+ <SheetContent
397
+ // Runtime-safe: Radix's Content ref resolves to a plain
398
+ // HTMLDivElement, same as the column branch's <aside> ref target
399
+ // at the type level (both extend HTMLElement).
400
+ ref={ref as unknown as Ref<HTMLDivElement>}
401
+ side={side}
402
+ data-slot="side-dock"
403
+ className={cn("flex flex-col bg-card p-0", className)}
404
+ style={style}
405
+ // With no description the dialog is still named by its title, so
406
+ // opt out of `aria-describedby` explicitly rather than let Radix
407
+ // warn about a missing description (mirrors ExpandDialogContent).
408
+ {...(description ? {} : { "aria-describedby": undefined })}
409
+ {...props}
410
+ >
411
+ <SheetHeader
412
+ data-slot="side-dock-header"
413
+ className="flex-row items-start gap-2 space-y-0 border-b border-border px-4 py-3"
414
+ >
415
+ <div className="min-w-0 flex-1">
416
+ <SheetTitle data-slot="side-dock-title" className="text-title">
417
+ {title}
418
+ </SheetTitle>
419
+ </div>
420
+ {headerActions}
421
+ </SheetHeader>
422
+ {description && <SheetDescription className="sr-only">{description}</SheetDescription>}
423
+ <div
424
+ data-slot="side-dock-body"
425
+ // Focusable because it scrolls; `focus-ring-inset` because the Sheet
426
+ // panel clips anything drawn outside this box, and both layers of
427
+ // the plain rung are drawn outside it. A dock's children are
428
+ // caller-supplied and routinely have no focusable descendant (a
429
+ // change log, a read-only detail panel), so without a tab stop
430
+ // there is no keyboard route into the region once it overflows
431
+ // (WCAG 2.1.1, axe `scrollable-region-focusable`).
432
+ tabIndex={0}
433
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
434
+ >
435
+ {children}
436
+ </div>
437
+ </SheetContent>
438
+ </Sheet>
439
+ );
440
+ }
441
+
442
+ return (
443
+ <div
444
+ data-slot="side-dock"
445
+ // `group`: the ancestor `group-data-[state=collapsed]:…` selectors in
446
+ // `panel.spacerClassName`/`panel.containerClassName` resolve against
447
+ // THIS element (Mechanism §1 — mirrors Sidebar's outer `group` div).
448
+ // `ref={groupRef}` + `tabIndex={-1}`: the focus-restoration fallback
449
+ // target (Finding 2) — this wrapper is never `inert`, unlike the
450
+ // `<aside>` below, so it is always a safe place to land focus. -1
451
+ // keeps it out of the normal tab order; it is only ever focused
452
+ // programmatically.
453
+ ref={groupRef}
454
+ tabIndex={-1}
455
+ className="group"
456
+ {...panel.attrs}
457
+ style={{ "--collapsible-panel-width": `${clampedWidth}px` } as CSSProperties}
458
+ >
459
+ <div data-slot="side-dock-spacer" className={panel.spacerClassName} />
460
+ <aside
461
+ ref={mergedContainerRef}
462
+ data-slot="side-dock-container"
463
+ aria-labelledby={titleId}
464
+ // The collapsed container stays mounted (for the closing transition)
465
+ // but stops being interactive/reachable (fixed contract decision).
466
+ inert={!panel.open}
467
+ className={cn(
468
+ panel.containerClassName,
469
+ "flex-col bg-card",
470
+ side === "right" ? "border-l" : "border-r",
471
+ className,
472
+ )}
473
+ style={style}
474
+ {...props}
475
+ >
476
+ <div
477
+ data-slot="side-dock-header"
478
+ className="flex items-start gap-2 border-b border-border px-4 py-3"
479
+ >
480
+ <div className="min-w-0 flex-1">
481
+ <div id={titleId} data-slot="side-dock-title" className="text-title">
482
+ {title}
483
+ </div>
484
+ {description && <div className="text-body text-muted-foreground">{description}</div>}
485
+ </div>
486
+ {headerActions}
487
+ <button
488
+ type="button"
489
+ data-slot="side-dock-close"
490
+ aria-label={closeLabel}
491
+ onClick={() => panel.setOpen(false)}
492
+ className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
493
+ >
494
+ <X className="size-4" aria-hidden="true" />
495
+ </button>
496
+ </div>
497
+ <div
498
+ data-slot="side-dock-body"
499
+ // Focusable because it scrolls; `focus-ring-inset` because the dock's
500
+ // own container clips anything drawn outside this box (the collapse
501
+ // tween animates its width). Worse here than in the overlay branch:
502
+ // the column is user-resizable, so narrowing it re-wraps every child
503
+ // taller — overflow is one keyboard gesture on the resize handle
504
+ // away, not a hypothetical viewport (WCAG 2.1.1, axe
505
+ // `scrollable-region-focusable`).
506
+ tabIndex={0}
507
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
508
+ >
509
+ {children}
510
+ </div>
511
+ {resizeHandle}
512
+ </aside>
513
+ </div>
514
+ );
515
+ });
@@ -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
+ });