@sovereignfs/ui 0.45.0 → 0.83.1

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 (323) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/LegalLinks.module.css +24 -0
  26. package/dist/Markdown.module.css +73 -0
  27. package/dist/Menu.module.css +1 -0
  28. package/dist/Message.module.css +1 -1
  29. package/dist/MessageScroller.module.css +12 -1
  30. package/dist/MobileAppsDrawer.module.css +82 -0
  31. package/dist/MobileFooter.module.css +73 -0
  32. package/dist/MobileHeader.module.css +76 -0
  33. package/dist/NavList.module.css +130 -0
  34. package/dist/NavTabs.module.css +1 -1
  35. package/dist/NotificationsPanel.module.css +258 -0
  36. package/dist/OverlayHeader.module.css +1 -1
  37. package/dist/PageContainer.module.css +82 -0
  38. package/dist/PageHeader.module.css +54 -0
  39. package/dist/PageLayout.module.css +51 -0
  40. package/dist/Popover.module.css +1 -1
  41. package/dist/QuantityStepper.module.css +1 -1
  42. package/dist/Resizable.module.css +3 -3
  43. package/dist/RootLayout.module.css +12 -0
  44. package/dist/SegmentedControl.module.css +1 -1
  45. package/dist/Select.module.css +1 -1
  46. package/dist/Sheet.module.css +4 -1
  47. package/dist/SplitPane.module.css +4 -4
  48. package/dist/StatusBadge.module.css +1 -1
  49. package/dist/StepStrip.module.css +53 -0
  50. package/dist/SuggestionInput.module.css +2 -2
  51. package/dist/SwipableMobileCarousel.module.css +64 -0
  52. package/dist/SwipableMobileCarouselDots.module.css +64 -0
  53. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  54. package/dist/SwipeStack.module.css +101 -0
  55. package/dist/SystemBanner.module.css +10 -0
  56. package/dist/Table.module.css +2 -2
  57. package/dist/Tabs.module.css +1 -1
  58. package/dist/TagInput.module.css +2 -2
  59. package/dist/Textarea.module.css +9 -1
  60. package/dist/ThreeColumnLayout.module.css +35 -0
  61. package/dist/Toast.module.css +62 -3
  62. package/dist/Tooltip.module.css +1 -1
  63. package/dist/UserMenu.module.css +90 -0
  64. package/dist/index.d.ts +1680 -30
  65. package/dist/index.js +4618 -1443
  66. package/dist/tokens/primitives.css +36 -0
  67. package/dist/tokens/semantic.css +32 -0
  68. package/dist/tokens/theme-presets.css +33 -0
  69. package/package.json +32 -12
  70. package/src/__tests__/package-json.test.ts +8 -0
  71. package/src/components/Accordion/Accordion.module.css +1 -1
  72. package/src/components/Alert/Alert.module.css +1 -1
  73. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  74. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  75. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  76. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  77. package/src/components/Avatar/Avatar.module.css +1 -1
  78. package/src/components/Badge/Badge.module.css +30 -7
  79. package/src/components/Badge/Badge.stories.tsx +16 -4
  80. package/src/components/Badge/Badge.tsx +8 -11
  81. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  82. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  83. package/src/components/Button/Button.module.css +18 -2
  84. package/src/components/Card/Card.module.css +1 -1
  85. package/src/components/CardTile/CardTile.module.css +181 -0
  86. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  87. package/src/components/CardTile/CardTile.tsx +128 -0
  88. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  89. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  90. package/src/components/Collapsible/Collapsible.module.css +1 -1
  91. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  92. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  93. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  94. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  95. package/src/components/Combobox/Combobox.module.css +2 -2
  96. package/src/components/Command/Command.module.css +1 -1
  97. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  98. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  99. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  100. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  101. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  102. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  103. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  104. package/src/components/DatePicker/DatePicker.module.css +1 -1
  105. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  106. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  107. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  108. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  109. package/src/components/Dialog/Dialog.module.css +130 -21
  110. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  111. package/src/components/Dialog/Dialog.tsx +137 -94
  112. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  113. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  114. package/src/components/Drawer/Drawer.module.css +36 -7
  115. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  116. package/src/components/Drawer/Drawer.tsx +27 -66
  117. package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
  118. package/src/components/GripIcon/GripIcon.tsx +27 -0
  119. package/src/components/Header/Header.module.css +62 -0
  120. package/src/components/Header/Header.stories.tsx +452 -0
  121. package/src/components/Header/Header.tsx +102 -0
  122. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  123. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  124. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  125. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  126. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  127. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  128. package/src/components/Icon/icons/bold.tsx +20 -0
  129. package/src/components/Icon/icons/calendar.tsx +4 -4
  130. package/src/components/Icon/icons/carrot.tsx +3 -3
  131. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  132. package/src/components/Icon/icons/code.tsx +21 -0
  133. package/src/components/Icon/icons/copy.tsx +21 -0
  134. package/src/components/Icon/icons/credit-card.tsx +21 -0
  135. package/src/components/Icon/icons/database.tsx +22 -0
  136. package/src/components/Icon/icons/download.tsx +22 -0
  137. package/src/components/Icon/icons/file-text.tsx +24 -0
  138. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  139. package/src/components/Icon/icons/folder-open.tsx +20 -0
  140. package/src/components/Icon/icons/folder.tsx +20 -0
  141. package/src/components/Icon/icons/folders.tsx +21 -0
  142. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  143. package/src/components/Icon/icons/history.tsx +22 -0
  144. package/src/components/Icon/icons/inbox.tsx +21 -0
  145. package/src/components/Icon/icons/index.ts +169 -1
  146. package/src/components/Icon/icons/italic.tsx +22 -0
  147. package/src/components/Icon/icons/layers.tsx +22 -0
  148. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  149. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  150. package/src/components/Icon/icons/link.tsx +21 -0
  151. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  152. package/src/components/Icon/icons/list.tsx +25 -0
  153. package/src/components/Icon/icons/luggage.tsx +24 -0
  154. package/src/components/Icon/icons/map-pin.tsx +21 -0
  155. package/src/components/Icon/icons/menu.tsx +22 -0
  156. package/src/components/Icon/icons/minus.tsx +20 -0
  157. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  158. package/src/components/Icon/icons/panel-left.tsx +21 -0
  159. package/src/components/Icon/icons/pin.tsx +21 -0
  160. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  161. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  162. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  163. package/src/components/Icon/icons/route.tsx +22 -0
  164. package/src/components/Icon/icons/send.tsx +21 -0
  165. package/src/components/Icon/icons/share-2.tsx +24 -0
  166. package/src/components/Icon/icons/sheet.tsx +24 -0
  167. package/src/components/Icon/icons/smartphone.tsx +21 -0
  168. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  169. package/src/components/Icon/icons/table.tsx +23 -0
  170. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  171. package/src/components/Icon/icons/users.tsx +23 -0
  172. package/src/components/IconPicker/IconPicker.module.css +1 -1
  173. package/src/components/Input/Input.module.css +1 -1
  174. package/src/components/Input/Input.tsx +8 -2
  175. package/src/components/Kbd/Kbd.module.css +1 -1
  176. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  177. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  178. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  179. package/src/components/Markdown/Markdown.module.css +73 -0
  180. package/src/components/Markdown/Markdown.stories.tsx +40 -0
  181. package/src/components/Markdown/Markdown.tsx +305 -0
  182. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  183. package/src/components/Menu/Menu.module.css +1 -0
  184. package/src/components/Menu/Menu.tsx +72 -22
  185. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  186. package/src/components/Menubar/Menubar.tsx +1 -1
  187. package/src/components/Message/Message.module.css +1 -1
  188. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  189. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  190. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -0
  191. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  192. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  193. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  194. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  195. package/src/components/MobileFooter/MobileFooter.tsx +122 -0
  196. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
  197. package/src/components/MobileHeader/MobileHeader.module.css +76 -0
  198. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  199. package/src/components/MobileHeader/MobileHeader.tsx +90 -0
  200. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
  201. package/src/components/NavList/NavList.module.css +130 -0
  202. package/src/components/NavList/NavList.tsx +136 -0
  203. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  204. package/src/components/NavTabs/NavTabs.module.css +1 -1
  205. package/src/components/NavTabs/NavTabs.tsx +39 -11
  206. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  207. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  208. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  209. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  210. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  211. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  212. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  213. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  214. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  215. package/src/components/PageContainer/PageContainer.module.css +82 -0
  216. package/src/components/PageContainer/PageContainer.tsx +64 -0
  217. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
  218. package/src/components/PageHeader/PageHeader.module.css +54 -0
  219. package/src/components/PageHeader/PageHeader.tsx +157 -2
  220. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  221. package/src/components/PageLayout/PageLayout.module.css +51 -0
  222. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  223. package/src/components/PageLayout/PageLayout.tsx +51 -0
  224. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  225. package/src/components/Popover/Popover.module.css +1 -1
  226. package/src/components/Popover/Popover.tsx +86 -4
  227. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  228. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  229. package/src/components/Resizable/Resizable.module.css +3 -3
  230. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  231. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  232. package/src/components/RootLayout/RootLayout.module.css +12 -0
  233. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  234. package/src/components/RootLayout/RootLayout.tsx +185 -0
  235. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  236. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  237. package/src/components/Select/Select.module.css +1 -1
  238. package/src/components/Sheet/Sheet.module.css +4 -1
  239. package/src/components/Sheet/Sheet.tsx +14 -56
  240. package/src/components/SplitPane/SplitPane.module.css +4 -4
  241. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  242. package/src/components/StepStrip/StepStrip.module.css +53 -0
  243. package/src/components/StepStrip/StepStrip.tsx +74 -0
  244. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  245. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
  246. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -0
  247. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  248. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  249. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  250. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  251. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  252. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +214 -0
  253. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  254. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  255. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +64 -0
  256. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
  257. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  258. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  259. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  260. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  261. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  262. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  263. package/src/components/Table/Table.module.css +2 -2
  264. package/src/components/Tabs/Tabs.module.css +1 -1
  265. package/src/components/TagInput/TagInput.module.css +2 -2
  266. package/src/components/Textarea/Textarea.module.css +9 -1
  267. package/src/components/Textarea/Textarea.tsx +28 -5
  268. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  269. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  270. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  271. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  272. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  273. package/src/components/Toast/Toast.module.css +62 -3
  274. package/src/components/Toast/Toast.stories.tsx +1 -1
  275. package/src/components/Tooltip/Tooltip.module.css +1 -1
  276. package/src/components/UserMenu/UserMenu.module.css +90 -0
  277. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  278. package/src/components/UserMenu/UserMenu.tsx +132 -0
  279. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  280. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  281. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  282. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  283. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  284. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  285. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  286. package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
  287. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  288. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  289. package/src/hooks/index.ts +27 -0
  290. package/src/hooks/useCarouselRouteSync.ts +92 -0
  291. package/src/hooks/useIsOffline.ts +41 -0
  292. package/src/hooks/useOfflineTileState.ts +45 -0
  293. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  294. package/src/hooks/useReorderSensors.ts +88 -0
  295. package/src/hooks/useResponsiveLayout.ts +36 -0
  296. package/src/hooks/useSnapCarousel.ts +284 -0
  297. package/src/hooks/useSwipeReveal.ts +107 -0
  298. package/src/hooks/useSwipeStack.ts +235 -0
  299. package/src/index.ts +124 -3
  300. package/src/overlay-shell.ts +159 -0
  301. package/src/stories/DesignSystemOverview.stories.tsx +933 -6
  302. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  303. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  304. package/src/stories/GripIcon.stories.tsx +29 -0
  305. package/src/stories/InteractionHooks.stories.tsx +483 -0
  306. package/src/stories/LayoutSystem.stories.tsx +639 -0
  307. package/src/stories/MenuEntries.stories.tsx +86 -0
  308. package/src/stories/MobilePatterns.stories.tsx +247 -2
  309. package/src/stories/NavList.stories.tsx +147 -0
  310. package/src/stories/NavTabs.stories.tsx +18 -0
  311. package/src/stories/OfflineGate.stories.tsx +42 -0
  312. package/src/stories/PageContainer.stories.tsx +82 -0
  313. package/src/stories/PageHeader.stories.tsx +65 -1
  314. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  315. package/src/stories/StepStrip.stories.tsx +76 -0
  316. package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
  317. package/src/stories/SwipeStack.stories.tsx +200 -0
  318. package/src/stories/Textarea.stories.tsx +12 -0
  319. package/src/stories/TokenGallery.stories.tsx +115 -0
  320. package/src/tokens/primitives.css +36 -0
  321. package/src/tokens/semantic.css +32 -0
  322. package/src/tokens/theme-presets.css +33 -0
  323. package/src/tokens/theme-presets.ts +72 -0
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, fireEvent, render } from '@testing-library/react';
4
+ import { SwipeStack } from '../SwipeStack';
5
+ import { SwipeStackCard } from '../SwipeStackCard';
6
+ import { SWIPE_STACK_TRANSITION_MS } from '../../../hooks/useSwipeStack';
7
+
8
+ // jsdom doesn't implement pointer capture — stub it as a no-op so
9
+ // `e.currentTarget.setPointerCapture(...)` doesn't throw.
10
+ beforeEach(() => {
11
+ if (!HTMLElement.prototype.setPointerCapture) {
12
+ HTMLElement.prototype.setPointerCapture = vi.fn();
13
+ }
14
+ });
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ vi.restoreAllMocks();
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ function pointer(x: number, y: number) {
23
+ return { clientX: x, clientY: y, pointerId: 1 };
24
+ }
25
+
26
+ function renderStack(onSwipe = vi.fn()) {
27
+ const utils = render(
28
+ <SwipeStack
29
+ aria-label="Today's agenda"
30
+ directions={{
31
+ up: { label: 'Done', icon: 'check' },
32
+ down: { label: 'Defer', icon: 'history' },
33
+ }}
34
+ onSwipe={onSwipe}
35
+ >
36
+ <SwipeStackCard cardId="a">Card A</SwipeStackCard>
37
+ <SwipeStackCard cardId="b">Card B</SwipeStackCard>
38
+ <SwipeStackCard cardId="c">Card C</SwipeStackCard>
39
+ </SwipeStack>,
40
+ );
41
+ return { ...utils, onSwipe };
42
+ }
43
+
44
+ describe('SwipeStack', () => {
45
+ it('renders the topmost card', () => {
46
+ const { getByText, queryByText } = renderStack();
47
+ expect(getByText('Card A')).toBeDefined();
48
+ expect(queryByText('Card C')).toBeNull();
49
+ });
50
+
51
+ it('labels the swipeable region', () => {
52
+ const { getByRole } = renderStack();
53
+ expect(getByRole('region', { name: "Today's agenda" })).toBeDefined();
54
+ });
55
+
56
+ it('only renders a fallback button for configured directions', () => {
57
+ const { getByRole, queryByRole } = renderStack();
58
+ expect(getByRole('button', { name: 'Done' })).toBeDefined();
59
+ expect(getByRole('button', { name: 'Defer' })).toBeDefined();
60
+ expect(queryByRole('button', { name: 'Skip' })).toBeNull();
61
+ });
62
+
63
+ it('calls onSwipe with the direction and cardId when a drag crosses threshold', () => {
64
+ const { getByText, onSwipe } = renderStack();
65
+ const card = getByText('Card A');
66
+ fireEvent.pointerDown(card, pointer(0, 0));
67
+ fireEvent.pointerMove(card, pointer(0, -200));
68
+ fireEvent.pointerUp(card, pointer(0, -200));
69
+ expect(onSwipe).toHaveBeenCalledWith('up', 'a');
70
+ });
71
+
72
+ it('does not call onSwipe when dragging toward a direction that is not configured', () => {
73
+ const { getByText, onSwipe } = renderStack();
74
+ const card = getByText('Card A');
75
+ fireEvent.pointerDown(card, pointer(0, 0));
76
+ fireEvent.pointerMove(card, pointer(200, 0));
77
+ fireEvent.pointerUp(card, pointer(200, 0));
78
+ expect(onSwipe).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('calls onSwipe when a fallback button is clicked, without any drag', () => {
82
+ const { getByRole, onSwipe } = renderStack();
83
+ fireEvent.click(getByRole('button', { name: 'Defer' }));
84
+ expect(onSwipe).toHaveBeenCalledWith('down', 'a');
85
+ });
86
+
87
+ it('advances to the next card only after the exit animation completes', () => {
88
+ vi.useFakeTimers();
89
+ const { getByRole, getByText, queryByText } = renderStack();
90
+ expect(getByText('Card A')).toBeDefined();
91
+
92
+ fireEvent.click(getByRole('button', { name: 'Done' }));
93
+ // Still mid-animation — the committed card stays put (so the fling-out
94
+ // it already started doesn't get cut off by a remount) and is still the
95
+ // one the fallback buttons act on; Card B is already visible as the
96
+ // peek behind it, but only decoratively (aria-hidden, not interactive).
97
+ expect(getByText('Card A')).toBeDefined();
98
+ const peekedB = getByText('Card B');
99
+ expect(peekedB.closest('[aria-hidden="true"]')).not.toBeNull();
100
+
101
+ act(() => {
102
+ vi.advanceTimersByTime(SWIPE_STACK_TRANSITION_MS);
103
+ });
104
+ expect(queryByText('Card A')).toBeNull();
105
+ expect(getByText('Card B').closest('[aria-hidden="true"]')).toBeNull();
106
+ });
107
+
108
+ it('never shows a dismissed card again, even if the caller still passes it as a child', () => {
109
+ vi.useFakeTimers();
110
+ const onSwipe = vi.fn();
111
+ const directions = { up: { label: 'Done', icon: 'check' as const } };
112
+ const { getByRole, getByText, queryByText, rerender } = render(
113
+ <SwipeStack aria-label="Today's agenda" directions={directions} onSwipe={onSwipe}>
114
+ <SwipeStackCard cardId="a">Card A</SwipeStackCard>
115
+ <SwipeStackCard cardId="b">Card B</SwipeStackCard>
116
+ </SwipeStack>,
117
+ );
118
+ fireEvent.click(getByRole('button', { name: 'Done' }));
119
+ act(() => {
120
+ vi.advanceTimersByTime(SWIPE_STACK_TRANSITION_MS);
121
+ });
122
+ expect(getByText('Card B')).toBeDefined();
123
+
124
+ // Re-render with the exact same children — a caller slow to remove the
125
+ // dismissed card from its own data. SwipeStack must not show it again.
126
+ rerender(
127
+ <SwipeStack aria-label="Today's agenda" directions={directions} onSwipe={onSwipe}>
128
+ <SwipeStackCard cardId="a">Card A</SwipeStackCard>
129
+ <SwipeStackCard cardId="b">Card B</SwipeStackCard>
130
+ </SwipeStack>,
131
+ );
132
+ expect(queryByText('Card A')).toBeNull();
133
+ expect(getByText('Card B')).toBeDefined();
134
+ });
135
+ });
136
+
137
+ describe('SwipeStack dev-mode child-type guard', () => {
138
+ function renderWithStrayChild() {
139
+ return render(
140
+ <SwipeStack aria-label="Today's agenda" directions={{}} onSwipe={() => {}}>
141
+ <SwipeStackCard cardId="a">Card A</SwipeStackCard>
142
+ {/* stray non-SwipeStackCard child */}
143
+ <div>oops</div>
144
+ </SwipeStack>,
145
+ );
146
+ }
147
+
148
+ it('warns via console.error in development, mentioning the offending child', () => {
149
+ const originalEnv = process.env.NODE_ENV;
150
+ vi.stubEnv('NODE_ENV', 'development');
151
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
152
+
153
+ expect(() => renderWithStrayChild()).not.toThrow();
154
+
155
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('<div>'));
156
+ vi.stubEnv('NODE_ENV', originalEnv ?? 'test');
157
+ });
158
+
159
+ it('does not call console.error in production', () => {
160
+ const originalEnv = process.env.NODE_ENV;
161
+ vi.stubEnv('NODE_ENV', 'production');
162
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
163
+
164
+ expect(() => renderWithStrayChild()).not.toThrow();
165
+
166
+ expect(errorSpy).not.toHaveBeenCalled();
167
+ vi.stubEnv('NODE_ENV', originalEnv ?? 'test');
168
+ });
169
+ });
@@ -3,6 +3,16 @@
3
3
  top: 0;
4
4
  /* Sits above sidebar rail (z-index ~10) but below dialogs/overlays (z-index 100) */
5
5
  z-index: 50;
6
+ /* WebKit momentum-scroll re-tiling quirk: a position: sticky element with
7
+ no compositing layer of its own can go stale/blank for a stretch of
8
+ frames during a fast scroll on iOS Safari instead of repainting at its
9
+ stuck position. translateZ(0) promotes it to its own layer, rasterized
10
+ independently of the scroller's tile grid. See
11
+ docs/architecture-rules.md — found and fixed across three sticky
12
+ elements in sovereign-tasks, applied here proactively since this
13
+ component is exactly the same pattern (sticky, inside scrolling
14
+ content). */
15
+ transform: translateZ(0);
6
16
  display: flex;
7
17
  align-items: center;
8
18
  justify-content: center;
@@ -20,11 +20,11 @@
20
20
  }
21
21
 
22
22
  .head .row {
23
- border-bottom: 1px solid var(--sv-color-border-strong);
23
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
24
24
  }
25
25
 
26
26
  .body .row {
27
- border-bottom: 1px solid var(--sv-color-border);
27
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
28
28
  }
29
29
 
30
30
  .body .row:last-child {
@@ -1,6 +1,6 @@
1
1
  .tablist {
2
2
  display: flex;
3
- border-bottom: 1px solid var(--sv-color-border);
3
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
4
4
  /* Horizontal scroll on mobile — scrollbar hidden for clean appearance */
5
5
  overflow-x: auto;
6
6
  scrollbar-width: none;
@@ -15,7 +15,7 @@
15
15
  padding: 5px var(--sv-space-2);
16
16
  color: var(--sv-color-text-primary);
17
17
  background: var(--sv-color-surface);
18
- border: 1px solid var(--sv-color-border-strong);
18
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
19
19
  border-radius: var(--sv-radius-md);
20
20
  transition:
21
21
  border-color 0.15s ease,
@@ -50,7 +50,7 @@
50
50
  padding-inline: var(--sv-space-2) 2px;
51
51
  color: var(--sv-color-text-primary);
52
52
  background: var(--sv-color-surface-sunken);
53
- border: 1px solid var(--sv-color-border);
53
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
54
54
  border-radius: var(--sv-radius-full);
55
55
  font-family: var(--sv-font-family);
56
56
  font-size: var(--sv-font-size-xs);
@@ -7,7 +7,7 @@
7
7
  line-height: 1.5;
8
8
  color: var(--sv-color-text-primary);
9
9
  background-color: var(--sv-color-surface);
10
- border: 1px solid var(--sv-color-border-strong);
10
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
11
11
  border-radius: var(--sv-radius-md);
12
12
  resize: vertical;
13
13
  transition:
@@ -37,3 +37,11 @@
37
37
  cursor: not-allowed;
38
38
  resize: none;
39
39
  }
40
+
41
+ /* Height is driven by the `autoGrow` layout effect (Textarea.tsx), not the
42
+ user — resize: none avoids a drag handle that would fight it; overflow
43
+ hidden avoids a scrollbar flashing on between the old and new height. */
44
+ .textareaAutoGrow {
45
+ overflow: hidden;
46
+ resize: none;
47
+ }
@@ -1,15 +1,38 @@
1
+ 'use client';
2
+
3
+ import { useLayoutEffect, useRef } from 'react';
1
4
  import type { TextareaHTMLAttributes } from 'react';
2
5
  import styles from './Textarea.module.css';
3
6
 
4
- export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
7
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
8
+ /** Grows the field's own height to fit its content as it's typed, instead
9
+ * of scrolling internally or leaving the user to drag-resize — e.g. an
10
+ * editable title that should read as a heading, not a fixed-size form
11
+ * field. Disables manual resize (`resize: none`) since a height driven by
12
+ * content and a user-draggable height would fight each other. `rows` is
13
+ * still honoured as the starting/minimum height. */
14
+ autoGrow?: boolean;
15
+ }
5
16
 
6
17
  /**
7
18
  * Textarea — the primitive multi-line text field. Presentational and
8
- * RSC-safe: it forwards all native textarea props to the underlying
19
+ * RSC-safe unless `autoGrow` is used (client-only, needs a layout
20
+ * measurement). Forwards all native textarea props to the underlying
9
21
  * `<textarea>`. Styling references `--sv-*` tokens via CSS Modules; there
10
22
  * are no hardcoded values.
11
23
  */
12
- export function Textarea({ className, rows = 4, ...rest }: TextareaProps) {
13
- const classes = [styles.textarea, className].filter(Boolean).join(' ');
14
- return <textarea rows={rows} className={classes} {...rest} />;
24
+ export function Textarea({ className, rows = 4, autoGrow = false, ...rest }: TextareaProps) {
25
+ const ref = useRef<HTMLTextAreaElement>(null);
26
+
27
+ useLayoutEffect(() => {
28
+ const el = ref.current;
29
+ if (!autoGrow || !el) return;
30
+ el.style.height = 'auto';
31
+ el.style.height = `${el.scrollHeight}px`;
32
+ }, [autoGrow, rest.value, rest.defaultValue]);
33
+
34
+ const classes = [styles.textarea, autoGrow ? styles.textareaAutoGrow : '', className]
35
+ .filter(Boolean)
36
+ .join(' ');
37
+ return <textarea ref={ref} rows={rows} className={classes} {...rest} />;
15
38
  }
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { cleanup, render, screen } from '@testing-library/react';
4
4
  import { Textarea } from '../Textarea';
5
5
 
@@ -26,4 +26,26 @@ describe('Textarea', () => {
26
26
  render(<Textarea aria-label="field" disabled />);
27
27
  expect((screen.getByLabelText('field') as HTMLTextAreaElement).disabled).toBe(true);
28
28
  });
29
+
30
+ it('does not apply the auto-grow class by default', () => {
31
+ render(<Textarea aria-label="field" />);
32
+ expect(screen.getByLabelText('field').className).not.toMatch(/textareaAutoGrow/);
33
+ });
34
+
35
+ it('applies the auto-grow class and sizes height to scrollHeight when autoGrow is set', () => {
36
+ // jsdom has no real layout engine — scrollHeight is always 0, so it must
37
+ // be stubbed on the prototype before mount (same pattern as
38
+ // MessageScroller's own tests) for useLayoutEffect's synchronous
39
+ // on-mount measurement to read anything meaningful. This only proves the
40
+ // effect reads scrollHeight and writes it back as `style.height`, not
41
+ // that real pixel math is correct.
42
+ const scrollHeightSpy = vi
43
+ .spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get')
44
+ .mockReturnValue(88);
45
+ render(<Textarea aria-label="field" autoGrow defaultValue="hello" />);
46
+ const el = screen.getByLabelText('field') as HTMLTextAreaElement;
47
+ expect(el.className).toMatch(/textareaAutoGrow/);
48
+ expect(el.style.height).toBe('88px');
49
+ scrollHeightSpy.mockRestore();
50
+ });
29
51
  });
@@ -0,0 +1,35 @@
1
+ .shell {
2
+ display: flex;
3
+ height: 100%;
4
+ overflow: hidden;
5
+ }
6
+
7
+ .sidebar {
8
+ flex-shrink: 0;
9
+ overflow-y: auto;
10
+ background: var(--sv-color-surface-raised);
11
+ border-right: var(--sv-border-width-hairline) solid var(--sv-color-border);
12
+ }
13
+
14
+ /*
15
+ * `display: none`, not `width: 0` — a zero-width flex item still paints its
16
+ * own `border-right` as a 1px hairline down the edge of the viewport, which
17
+ * reads as a stray line rather than a hidden column.
18
+ */
19
+ .sidebarHidden {
20
+ display: none;
21
+ }
22
+
23
+ .main {
24
+ flex: 1;
25
+ min-width: 0;
26
+ overflow-y: auto;
27
+ background: var(--sv-color-surface);
28
+ }
29
+
30
+ .detail {
31
+ flex-shrink: 0;
32
+ overflow-y: auto;
33
+ background: var(--sv-color-surface-raised);
34
+ border-left: var(--sv-border-width-hairline) solid var(--sv-color-border);
35
+ }
@@ -0,0 +1,152 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { ThreeColumnLayout } from './ThreeColumnLayout';
4
+
5
+ function Block({
6
+ title,
7
+ children,
8
+ padded = true,
9
+ }: {
10
+ title: string;
11
+ children?: React.ReactNode;
12
+ padded?: boolean;
13
+ }) {
14
+ return (
15
+ <div
16
+ style={{
17
+ height: '100%',
18
+ padding: padded ? 'var(--sv-space-4)' : 0,
19
+ fontFamily: 'var(--sv-font-family)',
20
+ }}
21
+ >
22
+ <h3
23
+ style={{
24
+ margin: '0 0 var(--sv-space-2)',
25
+ fontSize: 'var(--sv-font-size-sm)',
26
+ color: 'var(--sv-color-text-primary)',
27
+ }}
28
+ >
29
+ {title}
30
+ </h3>
31
+ {children}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ const meta = {
37
+ title: 'Components/ThreeColumnLayout',
38
+ component: ThreeColumnLayout,
39
+ parameters: {
40
+ layout: 'fullscreen',
41
+ docs: {
42
+ description: {
43
+ component:
44
+ 'Sidebar + main + optional detail column, for the common "list app" shape. Purely positional: pass 2 children for sidebar+main, or 3 for sidebar+main+detail. The layout has no awareness of what is inside each slot and no responsive behavior of its own.',
45
+ },
46
+ },
47
+ },
48
+ args: {
49
+ children: [<Block key="sidebar" title="Sidebar" />, <Block key="main" title="Main" />],
50
+ },
51
+ } satisfies Meta<typeof ThreeColumnLayout>;
52
+
53
+ export default meta;
54
+ type Story = StoryObj<typeof meta>;
55
+
56
+ /** The canonical two-child usage — sidebar + main, no detail column. */
57
+ export const Default: Story = {
58
+ render: () => (
59
+ <div style={{ height: '480px' }}>
60
+ <ThreeColumnLayout>
61
+ <Block title="Sidebar">
62
+ <ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
63
+ <li>Groceries</li>
64
+ <li>Work</li>
65
+ <li>Personal</li>
66
+ </ul>
67
+ </Block>
68
+ <Block title="Main">No item selected — main fills the full remaining width.</Block>
69
+ </ThreeColumnLayout>
70
+ </div>
71
+ ),
72
+ };
73
+
74
+ export const ThreeColumns: Story = {
75
+ render: () => (
76
+ <div style={{ height: '480px' }}>
77
+ <ThreeColumnLayout>
78
+ <Block title="Sidebar">
79
+ <ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
80
+ <li>Groceries</li>
81
+ <li>Work</li>
82
+ <li>Personal</li>
83
+ </ul>
84
+ </Block>
85
+ <Block title="Main">
86
+ <ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
87
+ <li>Buy milk</li>
88
+ <li>Buy eggs</li>
89
+ </ul>
90
+ </Block>
91
+ <Block title="Detail">Editing "Buy milk" — notes, due date, etc.</Block>
92
+ </ThreeColumnLayout>
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ export const CustomWidths: Story = {
98
+ render: () => (
99
+ <div style={{ height: '480px' }}>
100
+ <ThreeColumnLayout sidebarWidth={200} detailWidth={440}>
101
+ <Block title="Sidebar (200px)">Narrower nav column.</Block>
102
+ <Block title="Main">Fills whatever space is left.</Block>
103
+ <Block title="Detail (440px)">Wider detail column for a richer editor.</Block>
104
+ </ThreeColumnLayout>
105
+ </div>
106
+ ),
107
+ };
108
+
109
+ /** Demonstrates the intended usage: the detail column appears only once
110
+ * something is selected, by conditionally including the third child. */
111
+ export const ConditionalDetail: Story = {
112
+ render: function Render() {
113
+ const [selectedId, setSelectedId] = useState<string | null>(null);
114
+ const items = ['Buy milk', 'Buy eggs', 'Call plumber'];
115
+
116
+ return (
117
+ <div style={{ height: '480px' }}>
118
+ <ThreeColumnLayout>
119
+ <Block title="Sidebar">Lists</Block>
120
+ <Block title="Main" padded={false}>
121
+ <div style={{ padding: 'var(--sv-space-4)' }}>
122
+ {items.map((item) => (
123
+ <button
124
+ key={item}
125
+ type="button"
126
+ onClick={() => setSelectedId(item)}
127
+ style={{
128
+ display: 'block',
129
+ width: '100%',
130
+ textAlign: 'left',
131
+ padding: 'var(--sv-space-2)',
132
+ marginBottom: 'var(--sv-space-1)',
133
+ background:
134
+ selectedId === item ? 'var(--sv-color-accent-subtle)' : 'transparent',
135
+ border: 'none',
136
+ borderRadius: 'var(--sv-radius-sm)',
137
+ cursor: 'pointer',
138
+ font: 'inherit',
139
+ color: 'var(--sv-color-text-primary)',
140
+ }}
141
+ >
142
+ {item}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ </Block>
147
+ {selectedId && <Block title="Detail">Editing "{selectedId}"</Block>}
148
+ </ThreeColumnLayout>
149
+ </div>
150
+ );
151
+ },
152
+ };
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { Children } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import styles from './ThreeColumnLayout.module.css';
6
+
7
+ export interface ThreeColumnLayoutProps {
8
+ /**
9
+ * Exactly 2 (sidebar, main) or 3 (sidebar, main, detail) elements. Omit
10
+ * the third child entirely to render two columns — e.g.
11
+ * `{selected && <Detail />}` — React.Children.toArray drops falsy
12
+ * children, so no separate `detail` prop is needed to make that
13
+ * conditional.
14
+ */
15
+ children: ReactNode;
16
+ /** px. Fixed width of the first (leftmost) column. Default 280. */
17
+ sidebarWidth?: number;
18
+ /** px. Fixed width of the third column, when present. Default 360. */
19
+ detailWidth?: number;
20
+ /**
21
+ * Hides the sidebar column entirely — no width, no border, no layout box
22
+ * — while keeping it mounted and every sibling in its own stable position.
23
+ *
24
+ * Prefer this over conditionally omitting the sidebar child, or swapping
25
+ * to a different wrapper when collapsed. Omitting it shifts `main` into
26
+ * the sidebar slot, and swapping the surrounding element type unmounts
27
+ * `main`'s entire subtree — silently discarding its React state (a live
28
+ * stream, unsent composer text, an in-memory-only conversation) on every
29
+ * collapse toggle. Found live in Warden, whose chat column lost all of
30
+ * that each time the sidebar was shown or hidden.
31
+ */
32
+ sidebarHidden?: boolean;
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * ThreeColumnLayout — sidebar + main + optional detail column, for the
38
+ * common "list app" shape (a list sidebar, a primary content list, and a
39
+ * detail pane that only takes up space once something is selected).
40
+ *
41
+ * Purely positional and structural — it has no awareness of what's inside
42
+ * each slot (no injected landmarks/labels; those belong to the children
43
+ * themselves) and no responsive behavior of its own. Collapsing further at
44
+ * narrow desktop/tablet widths (e.g. swapping the detail column for an
45
+ * overlay) is the consuming plugin's decision; below the mobile breakpoint,
46
+ * compose with ResponsiveSurface to fork to a different tree entirely.
47
+ */
48
+ export function ThreeColumnLayout({
49
+ children,
50
+ sidebarWidth = 280,
51
+ detailWidth = 360,
52
+ sidebarHidden = false,
53
+ className,
54
+ }: ThreeColumnLayoutProps) {
55
+ const items = Children.toArray(children);
56
+
57
+ if (process.env.NODE_ENV !== 'production' && (items.length < 2 || items.length > 3)) {
58
+ console.warn(
59
+ `[ThreeColumnLayout] Expects 2 or 3 children (sidebar, main, optional detail); received ${items.length}.`,
60
+ );
61
+ }
62
+
63
+ const [sidebar, main, detail] = items;
64
+
65
+ return (
66
+ <div className={[styles.shell, className].filter(Boolean).join(' ')}>
67
+ {/* `hidden` (not just the class) so the column leaves the
68
+ accessibility tree and the tab order too, rather than staying
69
+ reachable by keyboard while invisible. */}
70
+ <div
71
+ className={[styles.sidebar, sidebarHidden && styles.sidebarHidden]
72
+ .filter(Boolean)
73
+ .join(' ')}
74
+ style={sidebarHidden ? undefined : { width: sidebarWidth }}
75
+ hidden={sidebarHidden}
76
+ >
77
+ {sidebar}
78
+ </div>
79
+ <div className={styles.main}>{main}</div>
80
+ {detail && (
81
+ <div className={styles.detail} style={{ width: detailWidth }}>
82
+ {detail}
83
+ </div>
84
+ )}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { ThreeColumnLayout } from '../ThreeColumnLayout';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ThreeColumnLayout', () => {
9
+ it('renders two columns and no detail column when only sidebar + main are given', () => {
10
+ render(
11
+ <ThreeColumnLayout>
12
+ <nav>Sidebar</nav>
13
+ <main>Main</main>
14
+ </ThreeColumnLayout>,
15
+ );
16
+
17
+ expect(screen.getByText('Sidebar')).toBeDefined();
18
+ expect(screen.getByText('Main')).toBeDefined();
19
+ });
20
+
21
+ it('renders the detail column when a third child is given', () => {
22
+ render(
23
+ <ThreeColumnLayout>
24
+ <nav>Sidebar</nav>
25
+ <main>Main</main>
26
+ <aside>Detail</aside>
27
+ </ThreeColumnLayout>,
28
+ );
29
+
30
+ expect(screen.getByText('Detail')).toBeDefined();
31
+ });
32
+
33
+ it('omits the detail column when the third child is conditionally falsy', () => {
34
+ const selected = false;
35
+ render(
36
+ <ThreeColumnLayout>
37
+ <nav>Sidebar</nav>
38
+ <main>Main</main>
39
+ {selected && <aside>Detail</aside>}
40
+ </ThreeColumnLayout>,
41
+ );
42
+
43
+ expect(screen.queryByText('Detail')).toBeNull();
44
+ });
45
+
46
+ it('applies sidebarWidth and detailWidth as inline widths', () => {
47
+ render(
48
+ <ThreeColumnLayout sidebarWidth={240} detailWidth={320}>
49
+ <nav>Sidebar</nav>
50
+ <main>Main</main>
51
+ <aside>Detail</aside>
52
+ </ThreeColumnLayout>,
53
+ );
54
+
55
+ expect(screen.getByText('Sidebar').parentElement?.style.width).toBe('240px');
56
+ expect(screen.getByText('Detail').parentElement?.style.width).toBe('320px');
57
+ });
58
+
59
+ it('warns in development when given fewer than 2 or more than 3 children', () => {
60
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
61
+
62
+ render(<ThreeColumnLayout>{[<main key="only">Only</main>]}</ThreeColumnLayout>);
63
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('received 1'));
64
+
65
+ warn.mockClear();
66
+ render(
67
+ <ThreeColumnLayout>
68
+ <nav>A</nav>
69
+ <main>B</main>
70
+ <aside>C</aside>
71
+ <aside>D</aside>
72
+ </ThreeColumnLayout>,
73
+ );
74
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('received 4'));
75
+
76
+ warn.mockRestore();
77
+ });
78
+ });