@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,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for WorkspacePicker (`packages/ui/src/components/workspace-picker/workspace-picker.tsx`), derived from its
4
+ * `Default` story (packages/ui/src/components/workspace-picker/workspace-picker.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../components/workspace-picker/workspace-picker.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `core-workspacepicker--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("WorkspacePicker contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="workspace-picker" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="workspace-picker"]')).not.toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ /**
7
+ * Regression guard for issue #26 — `react-hook-form` / `@hookform/resolvers`
8
+ * are used ONLY by the RHF-bound `Form` family
9
+ * (`packages/ui/src/components/form/form.tsx`); `FieldRow` and every other
10
+ * component in this package render from plain props and never import either.
11
+ *
12
+ * A consumer who never imports `Form` should not be forced to install (or
13
+ * have their bundler resolve) react-hook-form. Declaring both as
14
+ * `dependencies` defeats that — npm/pnpm install them unconditionally for
15
+ * every consumer of `@elabs-ai/components-ui`, regardless of whether `Form`
16
+ * is ever imported. This test locks the fix: both packages are declared as
17
+ * OPTIONAL peers, and neither reappears as a hard `dependency`.
18
+ *
19
+ * `zod` is used only inside `form.stories.tsx` (the `zodResolver` demo) and
20
+ * never in shipped runtime source — it belongs in `devDependencies`, never as
21
+ * a runtime `dependency` or a peer a consumer must install.
22
+ */
23
+ const PACKAGE_JSON_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
24
+
25
+ function readPackageJson(): {
26
+ dependencies?: Record<string, string>;
27
+ devDependencies?: Record<string, string>;
28
+ peerDependencies?: Record<string, string>;
29
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
30
+ } {
31
+ return JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8"));
32
+ }
33
+
34
+ describe("packages/ui/package.json — react-hook-form is an optional peer, not a hard dependency", () => {
35
+ const pkg = readPackageJson();
36
+
37
+ it.each(["react-hook-form", "@hookform/resolvers"])(
38
+ "declares %s as an OPTIONAL peerDependency",
39
+ (name) => {
40
+ expect(pkg.dependencies?.[name]).toBeUndefined();
41
+ expect(pkg.peerDependencies?.[name]).toBeTruthy();
42
+ expect(pkg.peerDependenciesMeta?.[name]?.optional).toBe(true);
43
+ },
44
+ );
45
+
46
+ it("still lists react-hook-form + @hookform/resolvers as devDependencies for the workspace's own build/test", () => {
47
+ // The package's own `Form` tests/stories (and `form.tsx` itself) need the
48
+ // real library to typecheck/run inside this workspace — peer alone does
49
+ // not guarantee resolution here, only for an external consumer.
50
+ expect(pkg.devDependencies?.["react-hook-form"]).toBeTruthy();
51
+ expect(pkg.devDependencies?.["@hookform/resolvers"]).toBeTruthy();
52
+ });
53
+
54
+ it("declares zod as a devDependency only — never a runtime dependency or peer", () => {
55
+ expect(pkg.dependencies?.zod).toBeUndefined();
56
+ expect(pkg.peerDependencies?.zod).toBeUndefined();
57
+ expect(pkg.devDependencies?.zod).toBeTruthy();
58
+ });
59
+ });
@@ -7,7 +7,7 @@
7
7
  * cells, a price row, and a per-plan CTA row. Reuses brand-ui's strongest
8
8
  * assets — Table + Badge + Button — and stays token-only.
9
9
  *
10
- * Reads in all three themes. Verify with globals=theme:<slug>.
10
+ * Reads in every theme. Verify with globals=theme:<slug>.
11
11
  */
12
12
  import type { Meta, StoryObj } from "@storybook/react-vite";
13
13
  import { Check, Minus } from "lucide-react";
@@ -152,7 +152,7 @@ const meta = {
152
152
  docs: {
153
153
  description: {
154
154
  component:
155
- "The canonical plan/feature comparison matrix (brand-ui's Compare gap): a header with one featured column (badge + recessed wash), feature rows with check / dash / value cells, a price row, and a per-plan CTA row. Composes Table + Badge + Button; semantic tokens only; reads in all three themes.",
155
+ "The canonical plan/feature comparison matrix (brand-ui's Compare gap): a header with one featured column (badge + recessed wash), feature rows with check / dash / value cells, a price row, and a per-plan CTA row. Composes Table + Badge + Button; semantic tokens only; reads in every theme.",
156
156
  },
157
157
  },
158
158
  },
@@ -9,7 +9,14 @@ export const AccordionItem = forwardRef<
9
9
  ElementRef<typeof AccordionPrimitive.Item>,
10
10
  ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
11
11
  >(function AccordionItem({ className, ...props }, ref) {
12
- return <AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />;
12
+ return (
13
+ <AccordionPrimitive.Item
14
+ ref={ref}
15
+ data-slot="accordion-item"
16
+ className={cn("border-b", className)}
17
+ {...props}
18
+ />
19
+ );
13
20
  });
14
21
 
15
22
  export const AccordionTrigger = forwardRef<
@@ -17,11 +24,12 @@ export const AccordionTrigger = forwardRef<
17
24
  ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
18
25
  >(function AccordionTrigger({ className, children, ...props }, ref) {
19
26
  return (
20
- <AccordionPrimitive.Header className="flex">
27
+ <AccordionPrimitive.Header className="flex" data-slot="accordion-header">
21
28
  <AccordionPrimitive.Trigger
22
29
  ref={ref}
30
+ data-slot="accordion-trigger"
23
31
  className={cn(
24
- "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>svg]:rotate-180",
32
+ "flex flex-1 items-center justify-between py-4 text-body font-medium transition-all hover:underline focus-ring [&[data-state=open]>svg]:rotate-180",
25
33
  className,
26
34
  )}
27
35
  {...props}
@@ -40,7 +48,8 @@ export const AccordionContent = forwardRef<
40
48
  return (
41
49
  <AccordionPrimitive.Content
42
50
  ref={ref}
43
- className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
51
+ data-slot="accordion-content"
52
+ className="overflow-hidden text-body data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
44
53
  {...props}
45
54
  >
46
55
  <div className={cn("pb-4 pt-0 text-muted-foreground", className)}>{children}</div>
@@ -85,7 +85,7 @@ export function AdvancedGroup({
85
85
  >
86
86
  <CollapsibleTrigger
87
87
  data-slot="advanced-group-trigger"
88
- className="group -mx-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-body font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
88
+ className="group -mx-2 flex items-center gap-2 rounded-md px-2 py-1.5 text-body font-medium hover:bg-accent hover:text-accent-foreground focus-ring"
89
89
  >
90
90
  <ChevronDown
91
91
  aria-hidden="true"
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "../../lib/cn";
4
4
 
5
5
  export const alertVariants = cva(
6
- "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg]:text-current [&>svg~*]:ps-7",
6
+ "relative w-full rounded-lg border px-4 py-3 text-body [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg]:text-current [&>svg~*]:ps-7",
7
7
  {
8
8
  variants: {
9
9
  variant: {
@@ -11,8 +11,11 @@ export const alertVariants = cva(
11
11
  info: "border-info/40 bg-info/10 text-foreground",
12
12
  success: "border-success/40 bg-success/10 text-foreground",
13
13
  warning: "border-warning/50 bg-warning/10 text-foreground",
14
+ // #124: the bare `text-destructive` set the ambient colour for the
15
+ // Title/Description running text — ink rung. `[&>svg]:text-destructive`
16
+ // (the icon mark) is unchanged.
14
17
  destructive:
15
- "border-destructive/40 bg-destructive/10 text-destructive [&>svg]:text-destructive",
18
+ "border-destructive/40 bg-destructive/10 text-destructive-text [&>svg]:text-destructive",
16
19
  },
17
20
  },
18
21
  defaultVariants: { variant: "default" },
@@ -31,7 +34,13 @@ export const Alert = forwardRef<
31
34
  ref,
32
35
  ) {
33
36
  return (
34
- <div ref={ref} role={role} className={cn(alertVariants({ variant }), className)} {...props} />
37
+ <div
38
+ ref={ref}
39
+ role={role}
40
+ data-slot="alert"
41
+ className={cn(alertVariants({ variant }), className)}
42
+ {...props}
43
+ />
35
44
  );
36
45
  });
37
46
 
@@ -53,6 +62,7 @@ export const AlertTitle = forwardRef<HTMLHeadingElement, AlertTitleProps>(functi
53
62
  return (
54
63
  <Tag
55
64
  ref={ref}
65
+ data-slot="alert-title"
56
66
  className={cn("mb-1 font-medium leading-none tracking-tight", className)}
57
67
  {...props}
58
68
  />
@@ -79,8 +89,9 @@ export const AlertDescription = forwardRef<HTMLParagraphElement, AlertDescriptio
79
89
  return (
80
90
  <div
81
91
  ref={ref}
92
+ data-slot="alert-description"
82
93
  className={cn(
83
- "text-sm [&_p]:leading-relaxed text-muted-foreground",
94
+ "text-body [&_p]:leading-relaxed text-muted-foreground",
84
95
  measure && "max-w-prose",
85
96
  className,
86
97
  )}
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogHeader,
8
+ AlertDialogTitle,
9
+ } from "./alert-dialog";
10
+
11
+ describe("AlertDialogContent overflow", () => {
12
+ it("caps height and scrolls a long body instead of overflowing the viewport", () => {
13
+ render(
14
+ <AlertDialog open>
15
+ <AlertDialogContent>
16
+ <AlertDialogHeader>
17
+ <AlertDialogTitle>Title</AlertDialogTitle>
18
+ <AlertDialogDescription>Description</AlertDialogDescription>
19
+ </AlertDialogHeader>
20
+ Body
21
+ </AlertDialogContent>
22
+ </AlertDialog>,
23
+ );
24
+ const content = screen.getByRole("alertdialog");
25
+ // Without a height ceiling + `overflow-y-auto`, a body longer than the
26
+ // viewport overflows past both edges — and the `-translate-y-1/2`
27
+ // centring pushes its top half off-screen, unreachable even by scrolling
28
+ // the page.
29
+ expect(content.className).toContain("max-h-[85dvh]");
30
+ expect(content.className).toContain("overflow-y-auto");
31
+ });
32
+ });
@@ -19,8 +19,9 @@ const AlertDialogOverlay = forwardRef<
19
19
  return (
20
20
  <AlertDialogPrimitive.Overlay
21
21
  ref={ref}
22
+ data-slot="alert-dialog-overlay"
22
23
  className={cn(
23
- "fixed inset-0 z-50 bg-foreground/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24
+ "fixed inset-0 z-50 bg-overlay backdrop-blur-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24
25
  className,
25
26
  )}
26
27
  {...props}
@@ -37,8 +38,13 @@ export const AlertDialogContent = forwardRef<
37
38
  <AlertDialogOverlay />
38
39
  <AlertDialogPrimitive.Content
39
40
  ref={ref}
41
+ data-slot="alert-dialog-content"
40
42
  className={cn(
41
- "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-card p-6 text-card-foreground shadow-ring-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
43
+ // `max-h` + `overflow-y-auto`: without a height ceiling, a long body
44
+ // (a big diff, a long list of items to confirm) overflows past both
45
+ // viewport edges with its actions unreachable — the `-translate-y-1/2`
46
+ // centring only makes this worse by pushing the top half off-screen.
47
+ "fixed left-1/2 top-1/2 z-50 grid max-h-[85dvh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl bg-card p-6 text-card-foreground shadow-ring-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
42
48
  className,
43
49
  )}
44
50
  {...props}
@@ -48,11 +54,18 @@ export const AlertDialogContent = forwardRef<
48
54
  });
49
55
 
50
56
  export function AlertDialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
51
- return <div className={cn("flex flex-col gap-1.5 text-start", className)} {...props} />;
57
+ return (
58
+ <div
59
+ data-slot="alert-dialog-header"
60
+ className={cn("flex flex-col gap-1.5 text-start", className)}
61
+ {...props}
62
+ />
63
+ );
52
64
  }
53
65
  export function AlertDialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
54
66
  return (
55
67
  <div
68
+ data-slot="alert-dialog-footer"
56
69
  className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
57
70
  {...props}
58
71
  />
@@ -63,9 +76,11 @@ export const AlertDialogTitle = forwardRef<
63
76
  ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
64
77
  >(function AlertDialogTitle({ className, ...props }, ref) {
65
78
  return (
79
+ // `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`.
66
80
  <AlertDialogPrimitive.Title
67
81
  ref={ref}
68
- className={cn("text-lg font-semibold", className)}
82
+ data-slot="alert-dialog-title"
83
+ className={cn("text-title leading-none", className)}
69
84
  {...props}
70
85
  />
71
86
  );
@@ -77,7 +92,8 @@ export const AlertDialogDescription = forwardRef<
77
92
  return (
78
93
  <AlertDialogPrimitive.Description
79
94
  ref={ref}
80
- className={cn("text-sm text-muted-foreground", className)}
95
+ data-slot="alert-dialog-description"
96
+ className={cn("text-body text-muted-foreground", className)}
81
97
  {...props}
82
98
  />
83
99
  );
@@ -87,7 +103,12 @@ export const AlertDialogAction = forwardRef<
87
103
  ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
88
104
  >(function AlertDialogAction({ className, ...props }, ref) {
89
105
  return (
90
- <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
106
+ <AlertDialogPrimitive.Action
107
+ ref={ref}
108
+ data-slot="alert-dialog-action"
109
+ className={cn(buttonVariants(), className)}
110
+ {...props}
111
+ />
91
112
  );
92
113
  });
93
114
  export const AlertDialogCancel = forwardRef<
@@ -97,6 +118,7 @@ export const AlertDialogCancel = forwardRef<
97
118
  return (
98
119
  <AlertDialogPrimitive.Cancel
99
120
  ref={ref}
121
+ data-slot="alert-dialog-cancel"
100
122
  className={cn(buttonVariants({ variant: "outline" }), className)}
101
123
  {...props}
102
124
  />
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
2
3
  import { AppShell } from "./app-shell";
3
4
  import { TopNav } from "../top-nav";
4
5
  import { PageShell } from "../page-shell";
@@ -6,7 +7,7 @@ import { SectionHeader } from "../section-header";
6
7
  import { Button } from "../button";
7
8
 
8
9
  const meta = {
9
- title: "Layout/App Shell/Basic",
10
+ title: "Layout/App Shell/Minimal",
10
11
  component: AppShell,
11
12
  parameters: { layout: "fullscreen" },
12
13
  } satisfies Meta<typeof AppShell>;
@@ -46,8 +47,10 @@ export const Default: Story = {
46
47
  <PageShell
47
48
  header={
48
49
  <SectionHeader
50
+ // The page's own title, so it owns the document outline root.
51
+ as="h1"
49
52
  title="Overview"
50
- description="A lightweight generic shell. For full sidebar behavior use the Sidebar primitive."
53
+ description="A minimal, unstyled two-region shell — the honest answer for a simple layout. For full sidebar behavior use the Sidebar primitive; for a fully wired enterprise console see Layout/App Shell/Flagship."
51
54
  />
52
55
  }
53
56
  >
@@ -58,4 +61,43 @@ export const Default: Story = {
58
61
  </AppShell>
59
62
  </div>
60
63
  ),
64
+ play: async ({ canvasElement }) => {
65
+ const canvas = within(canvasElement);
66
+
67
+ // The shell ships a skip link, so a keyboard user can pass the nav rail
68
+ // instead of tabbing through it on every route (WCAG 2.4.1). Resolve the
69
+ // TARGET FROM THE LINK, never from a hard-coded `#main-content`: querying
70
+ // the id directly stays green when the link and the target are deleted
71
+ // together.
72
+ const skip = canvas.getByRole("link", { name: "Skip to main content" });
73
+ const targetId = (skip.getAttribute("href") ?? "").replace(/^#/, "");
74
+ await expect(targetId).not.toBe("");
75
+ const target = canvasElement.querySelector(`#${CSS.escape(targetId)}`) as HTMLElement;
76
+ await expect(target).toBeVisible();
77
+ await expect(target.tagName).toBe("MAIN");
78
+ await expect(canvas.getByRole("main")).toBe(target);
79
+
80
+ // …and here <main> IS the scroll port (in the registry blocks the skip
81
+ // target and the port are separate elements), so it takes a REAL tab stop,
82
+ // not the `-1` a pure skip target would carry: a region that scrolls has to
83
+ // be keyboard-operable even when nothing inside it is focusable (WCAG
84
+ // 2.1.1, axe `scrollable-region-focusable`). Read as an ATTRIBUTE — the
85
+ // `tabIndex` IDL getter answers -1 for any non-focusable element whether or
86
+ // not anyone set it, so it cannot tell "-1 was set" from "nothing was set".
87
+ await expect(target).toHaveAttribute("tabindex", "0");
88
+ await expect(getComputedStyle(target).overflowY).toBe("auto");
89
+ // The INSET rung: the shell root above carries `overflow-hidden`, which
90
+ // clips both layers of the plain `focus-ring`. Asserting only that
91
+ // `focus-ring-inset` is present would still pass with both classes on the
92
+ // element — and then the clipped one is what paints.
93
+ await expect(target.classList.contains("focus-ring-inset")).toBe(true);
94
+ await expect(target.classList.contains("focus-ring")).toBe(false);
95
+
96
+ // The outline has a root. axe cannot catch its absence here:
97
+ // `page-has-heading-one` is a best-practice rule outside the wcag2a/wcag2aa
98
+ // tag set, and `heading-order` passes happily on levels that start at 2.
99
+ const h1s = canvasElement.querySelectorAll("h1");
100
+ await expect(h1s.length).toBe(1);
101
+ await expect(canvas.getByRole("heading", { level: 1 })).toHaveTextContent("Overview");
102
+ },
61
103
  };
@@ -0,0 +1,99 @@
1
+ import { createRef } from "react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { AppShell } from "./app-shell";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarMenu,
10
+ SidebarMenuButton,
11
+ SidebarMenuItem,
12
+ SidebarProvider,
13
+ SidebarTrigger,
14
+ } from "../sidebar";
15
+
16
+ describe("AppShell", () => {
17
+ it("forwards a ref to the root element", () => {
18
+ const ref = createRef<HTMLDivElement>();
19
+ render(
20
+ <AppShell ref={ref} mainId="main">
21
+ Content
22
+ </AppShell>,
23
+ );
24
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
25
+ // The forwarded node IS the shell root — the flex/overflow box that
26
+ // establishes the fixed-height viewport, not some inner wrapper.
27
+ expect(ref.current?.className).toContain("h-dvh");
28
+ });
29
+ });
30
+
31
+ describe("AppShell — mobile navigation (#review 2.1: sidebar disappears below md)", () => {
32
+ const originalInnerWidth = window.innerWidth;
33
+
34
+ afterEach(() => {
35
+ Object.defineProperty(window, "innerWidth", {
36
+ writable: true,
37
+ configurable: true,
38
+ value: originalInnerWidth,
39
+ });
40
+ });
41
+
42
+ /**
43
+ * The `sidebar` slot is `hidden md:flex` with no fallback of its own (see
44
+ * `app-shell.tsx`'s module doc comment) — by design, because `<Sidebar>`
45
+ * already renders its own off-canvas `Sheet` below the mobile breakpoint,
46
+ * through a Radix portal that mounts into `document.body`, entirely
47
+ * unaffected by AppShell's `hidden md:flex` wrapper (a portal is not a
48
+ * descendant of that wrapper's DOM subtree). Composing `<AppShell>` with
49
+ * the real `<Sidebar>` primitive (not a bare custom `<nav>`) and placing a
50
+ * `<SidebarTrigger>` in `topNav` — both wrapped in ONE shared
51
+ * `<SidebarProvider>`, which is a plain React context and so is never
52
+ * blocked by AppShell's internal wrapper `<div>` — is therefore ALREADY a
53
+ * working mobile nav path with no AppShell code change: this test locks
54
+ * that composition rather than reworking AppShell into owning a second,
55
+ * competing `Sheet` (which would have to unmount `<Sidebar>` whenever ITS
56
+ * own drawer closes — see the doc comment for why that is a regression,
57
+ * not a fix). A consumer who instead passes a bare nav node still needs to
58
+ * bring its own responsive fallback, exactly as documented.
59
+ */
60
+ it("reaches sidebar navigation through Sidebar's own Sheet fallback at a mobile width", async () => {
61
+ Object.defineProperty(window, "innerWidth", {
62
+ writable: true,
63
+ configurable: true,
64
+ value: 500,
65
+ });
66
+ const user = userEvent.setup();
67
+ render(
68
+ <SidebarProvider>
69
+ <AppShell
70
+ mainId="main"
71
+ topNav={<SidebarTrigger />}
72
+ sidebar={
73
+ <Sidebar>
74
+ <SidebarContent>
75
+ <SidebarMenu>
76
+ <SidebarMenuItem>
77
+ <SidebarMenuButton>Dashboard</SidebarMenuButton>
78
+ </SidebarMenuItem>
79
+ </SidebarMenu>
80
+ </SidebarContent>
81
+ </Sidebar>
82
+ }
83
+ >
84
+ Main content
85
+ </AppShell>
86
+ </SidebarProvider>,
87
+ );
88
+
89
+ // Below `md`, the desktop sidebar branch renders nothing (it is inside
90
+ // AppShell's `hidden md:flex` slot AND `<Sidebar>` itself renders `null`
91
+ // desktop markup on mobile) — the nav item is reachable only once the
92
+ // trigger opens the portal-rendered Sheet.
93
+ expect(screen.queryByRole("button", { name: "Dashboard" })).not.toBeInTheDocument();
94
+
95
+ await user.click(screen.getByRole("button", { name: "Toggle Sidebar" }));
96
+
97
+ expect(await screen.findByRole("button", { name: "Dashboard" })).toBeInTheDocument();
98
+ });
99
+ });
@@ -1,5 +1,6 @@
1
- import { type ReactNode } from "react";
1
+ import { forwardRef, type ReactNode } from "react";
2
2
  import { cn } from "../../lib/cn";
3
+ import { SkipLink } from "../skip-link";
3
4
 
4
5
  export interface AppShellProps {
5
6
  /** Persistent left navigation (often a <Sidebar />). */
@@ -10,22 +11,73 @@ export interface AppShellProps {
10
11
  className?: string;
11
12
  /** Class for the scrolling main content region. */
12
13
  mainClassName?: string;
14
+ /**
15
+ * The `<main>` region's id, and the skip link's target. Override it when a
16
+ * page mounts more than one shell — two elements sharing `main-content`
17
+ * would make the skip link ambiguous. @default "main-content"
18
+ */
19
+ mainId?: string;
13
20
  }
14
21
 
15
22
  /**
16
23
  * App-first layout: fixed-height viewport with an optional sidebar, a top bar,
17
24
  * and a scrollable main region. Pure layout — bring your own nav components.
25
+ *
26
+ * The `sidebar` slot is hidden below `md` with no fallback of its own: this
27
+ * shell does not know whether `sidebar` already manages its own responsive
28
+ * behavior. `<Sidebar>` (`../sidebar`) does — it renders as an offcanvas
29
+ * `Sheet` below the mobile breakpoint entirely on its own, through a Radix
30
+ * portal that is unaffected by this wrapper's `hidden md:flex` (a portal
31
+ * mounts into `document.body`, not into this DOM subtree) — pair it with a
32
+ * `<SidebarTrigger>` inside `topNav` for full mobile navigation. Wrapping an
33
+ * arbitrary `sidebar` node in a SECOND, shell-owned `Sheet` here would look
34
+ * like a fix but silently break that exact pattern: a Radix `Sheet`/`Dialog`
35
+ * only mounts its content while open, so `<Sidebar>` would be unmounted
36
+ * (not just hidden) whenever the shell's own drawer is closed — and with it,
37
+ * the very trigger a caller uses to open ITS mobile view. A bare nav node
38
+ * (no built-in responsive behavior of its own) has no such fallback; give it
39
+ * one explicitly, e.g. by rendering a `<Sheet>` alongside `topNav` in the
40
+ * consuming app.
41
+ *
42
+ * The `SidebarProvider` + `Sidebar` + `SidebarTrigger` composition above is
43
+ * regression-locked in `app-shell.test.tsx` at a mobile viewport width: no
44
+ * AppShell code change is needed to reach navigation below `md`, because
45
+ * `SidebarProvider` is a plain React context and is never blocked by this
46
+ * component's internal wrapper `<div>`.
18
47
  */
19
- export function AppShell({ sidebar, topNav, children, className, mainClassName }: AppShellProps) {
48
+ export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
49
+ { sidebar, topNav, children, className, mainClassName, mainId = "main-content" },
50
+ ref,
51
+ ) {
20
52
  return (
21
53
  <div
54
+ ref={ref}
22
55
  className={cn("flex h-dvh w-full overflow-hidden bg-background text-foreground", className)}
23
56
  >
57
+ {/* The first focusable element of the shell, so a keyboard user can pass
58
+ the nav rail instead of tabbing through it on every route (WCAG 2.4.1).
59
+ All four registry app-shell blocks ship one; the primitive they sit
60
+ beside must not be the exception, and it is the surface with the widest
61
+ reach — it is imported, not copy-owned. */}
62
+ <SkipLink targetId={mainId} />
24
63
  {sidebar ? <div className="hidden md:flex">{sidebar}</div> : null}
25
64
  <div className="flex min-w-0 flex-1 flex-col">
26
65
  {topNav}
27
- <main className={cn("min-h-0 flex-1 overflow-y-auto", mainClassName)}>{children}</main>
66
+ {/* `<main>` IS the scroll port here (in the blocks the skip target and
67
+ the scroll port are separate elements), so it takes `tabIndex={0}`
68
+ rather than `{-1}`: a region that scrolls has to be keyboard-operable
69
+ even when the content inside it is not focusable (WCAG 2.1.1, axe
70
+ `scrollable-region-focusable`). `focus-ring-inset`, not `focus-ring`
71
+ — the root above carries `overflow-hidden`, which clips both layers
72
+ of the plain rung. */}
73
+ <main
74
+ id={mainId}
75
+ tabIndex={0}
76
+ className={cn("min-h-0 flex-1 overflow-y-auto focus-ring-inset", mainClassName)}
77
+ >
78
+ {children}
79
+ </main>
28
80
  </div>
29
81
  </div>
30
82
  );
31
- }
83
+ });
@@ -17,7 +17,15 @@ const meta = {
17
17
  title: "Layout/AppSidebar",
18
18
  component: AppSidebar,
19
19
  tags: ["autodocs"],
20
- parameters: { layout: "fullscreen" },
20
+ parameters: {
21
+ layout: "fullscreen",
22
+ docs: {
23
+ description: {
24
+ component:
25
+ "The opinionated application sidebar — the raw primitive set it composes is `Layout/Sidebar`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `AppSidebar` wraps the `Sidebar` → `SidebarHeader` → `SidebarContent` → `SidebarFooter` skeleton behind typed `header` / `footer` slots with the navigation as `children`, and forwards every `Sidebar` prop. The registry’s sidebar blocks import this rather than rebuilding the skeleton.",
26
+ },
27
+ },
28
+ },
21
29
  decorators: [
22
30
  (Story) => (
23
31
  <SidebarProvider>
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useId, useMemo, type HTMLAttributes } from "react";
2
4
  import { cn } from "../../lib/cn";
3
5
  import { Badge } from "../badge";
@@ -74,7 +76,7 @@ function AttributionItem({ item, requiredLabel }: { item: Attribution; requiredL
74
76
  href={item.url}
75
77
  target="_blank"
76
78
  rel="noopener noreferrer"
77
- className="text-body font-medium underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xs"
79
+ className="text-body font-medium underline underline-offset-2 focus-ring rounded-xs"
78
80
  >
79
81
  {item.name}
80
82
  </a>