@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
@@ -1,17 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
4
- import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
5
4
  import { useMountTransition, usePrefersReducedMotion } from '../../motion';
5
+ import {
6
+ OVERLAY_MOTION_DURATION_MS,
7
+ useOverlayFocusCapture,
8
+ useOverlayKeyboardTrap,
9
+ useOverlayScrollLock,
10
+ } from '../../overlay-shell';
11
+ import { Icon } from '../Icon/Icon';
6
12
  import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
7
13
  import styles from './Dialog.module.css';
8
14
 
9
- // Matches --sv-motion-duration-base (Dialog.module.css) — kept as a plain JS
10
- // constant rather than read from the CSS custom property so the unmount timer
11
- // and the CSS transition duration can't silently drift apart at build time;
12
- // change both together if this value ever changes.
13
- const MOTION_DURATION_MS = 250;
14
-
15
15
  // undefined (the default, outside any Provider) means "no Dialog ancestor" —
16
16
  // distinct from a real setter function, so useOverlaySecondRow can silently
17
17
  // no-op when called outside a Dialog instead of throwing.
@@ -22,11 +22,13 @@ const OverlaySecondRowContext = createContext<((node: ReactNode | null) => void)
22
22
  /**
23
23
  * Lets content deep inside a `Dialog` (e.g. a plugin's own route layout,
24
24
  * rendered several levels below wherever the `Dialog` itself is
25
- * instantiated) supply the second-row content of the Dialog's mobile
26
- * `OverlayHeader` — typically a tab strip. Solves the "double header"
27
- * problem: without this, a plugin's own tab strip has no way to reach the
28
- * Dialog's header and ends up rendered a second time, as its own sticky bar,
29
- * inside the scrolling content area.
25
+ * instantiated) supply the second-row content of the Dialog's `OverlayHeader`
26
+ * — typically a tab strip. Solves the "double header" problem: without this,
27
+ * a plugin's own tab strip has no way to reach the Dialog's header and ends
28
+ * up rendered a second time, as its own sticky bar, inside the scrolling
29
+ * content area. Reaches the header regardless of whether it's showing via
30
+ * the `header` prop (both breakpoints) or the default `title`-driven mobile
31
+ * bar (mobile only) — see `DialogProps.header`'s own doc comment.
30
32
  *
31
33
  * A no-op when there is no enclosing `Dialog` (e.g. the same plugin layout
32
34
  * also rendered on a plain, non-overlay route) — safe to call unconditionally.
@@ -52,7 +54,27 @@ export function useOverlaySecondRow(node: ReactNode | null): boolean {
52
54
  return setSecondRow !== undefined;
53
55
  }
54
56
 
55
- export type DialogSize = 'sm' | 'md' | 'lg' | 'full';
57
+ // BREAKING (packages/ui minor bump + docs/upgrade.md migration note): `xl`
58
+ // and `full` were removed here — `full` was a dead alias of `lg` (identical
59
+ // CSS), and `xl` was a fixed-width variant with no distinct behavior `auto`
60
+ // doesn't now cover better. `auto` sizes to content on BOTH width and
61
+ // height, capped rather than fixed on either axis — for content whose
62
+ // natural size varies a lot (a card detail modal with an optional
63
+ // checklist/comments section) rather than reliably filling a fixed width.
64
+ // `lg` is a true fixed 100%/100% box — the panel holds still regardless of
65
+ // content, at the cost of always filling the viewport.
66
+ //
67
+ // `fixed` (added for epic task 14.5, Account's vertical nav): a true fixed
68
+ // box like `lg` — width AND height set, content never resizes it — but
69
+ // capped rather than filling the viewport. Named distinctly from the
70
+ // component's own always-applied `.panel` CSS class (Dialog.module.css),
71
+ // not from `lg`'s own fixed-ness. For a plugin that needs a stable
72
+ // footprint (e.g. one whose route tree includes a client-side redirect
73
+ // through a near-empty intermediate page — `auto`/`sm`/`md`'s
74
+ // content-driven sizing would visibly shrink to fit that empty page, then
75
+ // grow once the real content lands) without reading as heavy as `lg`'s
76
+ // full-screen box for what is a compact settings surface.
77
+ export type DialogSize = 'sm' | 'md' | 'lg' | 'auto' | 'fixed';
56
78
 
57
79
  export interface DialogProps {
58
80
  /** Whether the dialog is shown. When false, nothing renders. */
@@ -61,17 +83,45 @@ export interface DialogProps {
61
83
  onClose: () => void;
62
84
  /** Panel size on desktop. Mobile always renders as a full-screen sheet. */
63
85
  size?: DialogSize;
64
- /** Accessible name for the dialog (sets `aria-label` on the panel). */
86
+ /** Accessible name for the dialog (sets `aria-label` on the panel). Falls
87
+ * back to `title` when omitted — this still applies even when `header` is
88
+ * used instead of `title` for the visible header (see `header`'s own doc
89
+ * comment), so pass `aria-label` explicitly in that case if `title` is
90
+ * also omitted. */
65
91
  'aria-label'?: string;
66
- /** On mobile: shown in the top bar alongside the close button so the title
67
- * and dismiss affordance occupy the same row instead of stacking. */
92
+ /** Shown in the top bar alongside the close button, on mobile only —
93
+ * desktop has no header row by default (see `header` for that). Ignored
94
+ * as visible content when `header` is provided; still used as the
95
+ * `aria-label` fallback either way. */
68
96
  title?: string;
97
+ /** Renders a persistent header row (via the same `OverlayHeader` component
98
+ * `title` alone only shows on mobile) on **both** desktop and mobile —
99
+ * the "Header + Body" / "Header + Body + Footer" shapes. Supersedes
100
+ * `title` for visible content when provided; `title` remains the
101
+ * `aria-label` fallback if `aria-label` is also omitted. Omitting both
102
+ * `header` and `footer` is "Body only" — today's default, unchanged. */
103
+ header?: ReactNode;
104
+ /** Renders a persistent footer row below the scrollable body — pinned on
105
+ * both desktop and mobile the same way `header`/the mobile title bar are
106
+ * (a non-scrolling flex sibling, not `position: sticky`): only the region
107
+ * between header and footer scrolls. Typically action buttons (Save/
108
+ * Cancel). Omit for no footer — today's default, where any actions live
109
+ * inside `children` and scroll with the rest of the body. Layout-agnostic
110
+ * (no built-in button alignment) — arrange the footer's own content as
111
+ * needed, same as `children`. */
112
+ footer?: ReactNode;
113
+ /** Forwarded to the active `OverlayHeader` instance's own `rowClassName`
114
+ * (whichever is showing — `header` or the mobile `title` bar) — restyle
115
+ * the title row (e.g. tighter padding to match a consumer's own compact
116
+ * header elsewhere) without changing its title/close layout. */
117
+ rowClassName?: string;
118
+ /** Forwarded to the active `OverlayHeader` instance's own `titleClassName`
119
+ * — restyle the title text (e.g. a smaller font size to match a
120
+ * consumer's own compact header elsewhere). */
121
+ titleClassName?: string;
69
122
  children: ReactNode;
70
123
  }
71
124
 
72
- const FOCUSABLE =
73
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
74
-
75
125
  /**
76
126
  * Dialog — a modal surface (scrim + panel) for overlay-shell plugins and any
77
127
  * plugin that needs a dismissable layer. Router-agnostic: the caller decides
@@ -87,6 +137,12 @@ const FOCUSABLE =
87
137
  * `onClose` API is unchanged — closing still stays mounted internally for the
88
138
  * exit transition before actually unmounting; `prefers-reduced-motion: reduce`
89
139
  * collapses both to near-instant.
140
+ *
141
+ * Three composition shapes, chosen by which of `header`/`footer` are passed:
142
+ * Body only (both omitted — today's default), Header + Body (`header` only),
143
+ * Header + Body + Footer (`header` and `footer`). Whichever of header/footer
144
+ * are present render as non-scrolling flex siblings around `.content`, which
145
+ * stays the only scrollable region in every shape.
90
146
  */
91
147
  export function Dialog({
92
148
  open,
@@ -94,68 +150,23 @@ export function Dialog({
94
150
  size = 'lg',
95
151
  'aria-label': ariaLabel,
96
152
  title,
153
+ header,
154
+ footer,
155
+ rowClassName,
156
+ titleClassName,
97
157
  children,
98
158
  }: DialogProps) {
99
159
  const panelRef = useRef<HTMLDivElement>(null);
100
- const previouslyFocused = useRef<HTMLElement | null>(null);
101
160
  const reducedMotion = usePrefersReducedMotion();
102
- const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
161
+ const { mounted, phase } = useMountTransition(
162
+ open,
163
+ reducedMotion ? 0 : OVERLAY_MOTION_DURATION_MS,
164
+ );
103
165
  const [secondRow, setSecondRow] = useState<ReactNode | null>(null);
104
166
 
105
- // Prevent document-level scroll for the whole mounted lifetime, including
106
- // the exit animation — not just while `open` — so the background can't
107
- // scroll while the panel is still visibly sliding/fading away. Ref-counted
108
- // so nested overlays (e.g. a confirmation dialog inside an overlay plugin)
109
- // don't release the lock while a sibling is still open.
110
- useEffect(() => {
111
- if (!mounted) return;
112
- lockBodyScroll();
113
- return unlockBodyScroll;
114
- }, [mounted]);
115
-
116
- // Capture focus on open; restore it on close/unmount.
117
- useEffect(() => {
118
- if (!open) return;
119
- previouslyFocused.current = document.activeElement as HTMLElement | null;
120
- const panel = panelRef.current;
121
- const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
122
- (first ?? panel)?.focus();
123
- return () => previouslyFocused.current?.focus();
124
- }, [open]);
125
-
126
- // Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
127
- // Attached at document level so no keyboard listener is needed on the dialog
128
- // div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
129
- useEffect(() => {
130
- if (!open) return;
131
- function handleKeyDown(e: KeyboardEvent) {
132
- if (e.key === 'Escape') {
133
- e.stopPropagation();
134
- onClose();
135
- return;
136
- }
137
- if (e.key !== 'Tab') return;
138
- const panel = panelRef.current;
139
- if (!panel) return;
140
- const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
141
- const first = focusable[0];
142
- const last = focusable.at(-1);
143
- if (!first || !last) {
144
- e.preventDefault();
145
- return;
146
- }
147
- const active = document.activeElement;
148
- if (e.shiftKey && active === first) {
149
- e.preventDefault();
150
- last.focus();
151
- } else if (!e.shiftKey && active === last) {
152
- e.preventDefault();
153
- first.focus();
154
- }
155
- }
156
- document.addEventListener('keydown', handleKeyDown);
157
- return () => document.removeEventListener('keydown', handleKeyDown);
158
- }, [open, onClose]);
167
+ useOverlayScrollLock(mounted);
168
+ useOverlayFocusCapture(panelRef, open);
169
+ useOverlayKeyboardTrap(panelRef, open, onClose);
159
170
 
160
171
  if (!mounted) return null;
161
172
  const isOpenPhase = phase === 'open';
@@ -163,15 +174,17 @@ export function Dialog({
163
174
  return (
164
175
  // role="presentation" removes the scrim from the AT (it is purely visual).
165
176
  // e.target check lets clicks inside the panel bubble without triggering dismiss.
177
+ // No onKeyDown here — useOverlayKeyboardTrap above already owns Escape via a
178
+ // document-level listener; a second handler here used to double-fire onClose
179
+ // per keypress (the keydown bubbles through this element on its way to
180
+ // document), which is exactly the kind of bug that turns a single Escape
181
+ // press into two router.back() calls for @modal-driven consumers.
166
182
  <div
167
183
  className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
168
184
  role="presentation"
169
185
  onClick={(e) => {
170
186
  if (e.target === e.currentTarget) onClose();
171
187
  }}
172
- onKeyDown={(e) => {
173
- if (e.key === 'Escape') onClose();
174
- }}
175
188
  >
176
189
  <div
177
190
  ref={panelRef}
@@ -183,28 +196,58 @@ export function Dialog({
183
196
  .filter(Boolean)
184
197
  .join(' ')}
185
198
  >
186
- {/* Mobile: shared OverlayHeader (title + close in one row, hidden on
187
- desktop via CSS — see .mobileHeader). secondRow is populated by a
188
- descendant's useOverlaySecondRow call, e.g. a plugin's own tab
189
- strip — see that hook's doc comment for why. */}
190
- <OverlayHeader
191
- title={title}
192
- onClose={onClose}
193
- secondRow={secondRow}
194
- className={styles.mobileHeader}
195
- />
196
- {/* Desktop: absolute close button (hidden on mobile via CSS). */}
197
- <button type="button" className={styles.close} aria-label="Close" onClick={onClose}>
198
- ×
199
- </button>
199
+ {header ? (
200
+ /* header supersedes the mobile-only title bar below — OverlayHeader
201
+ itself has no internal breakpoint gating (see its own module
202
+ CSS), so rendering it with no Dialog-level display-toggling
203
+ className, as here, is what makes it show on both desktop and
204
+ mobile. It carries its own close button, so the separate
205
+ desktop-only .close button below is skipped entirely in this
206
+ branch — never render both at once. */
207
+ <OverlayHeader
208
+ title={header}
209
+ onClose={onClose}
210
+ secondRow={secondRow}
211
+ rowClassName={rowClassName}
212
+ titleClassName={titleClassName}
213
+ />
214
+ ) : (
215
+ <>
216
+ {/* Mobile: shared OverlayHeader (title + close in one row, hidden
217
+ on desktop via CSS — see .mobileHeader). secondRow is
218
+ populated by a descendant's useOverlaySecondRow call, e.g. a
219
+ plugin's own tab strip — see that hook's doc comment for why. */}
220
+ <OverlayHeader
221
+ title={title}
222
+ onClose={onClose}
223
+ secondRow={secondRow}
224
+ className={styles.mobileHeader}
225
+ rowClassName={rowClassName}
226
+ titleClassName={titleClassName}
227
+ />
228
+ {/* Desktop: absolute close button (hidden on mobile via CSS).
229
+ Plain `x`, matching the mobile OverlayHeader's own close icon
230
+ above it (OverlayHeader.tsx) — a single consistent close
231
+ affordance across breakpoints instead of desktop's own
232
+ separate `circle-x` glyph (the previous choice here).
233
+ Platform-wide: every `Dialog` consumer without a `header`
234
+ gets this, not just the one it was requested against —
235
+ `Dialog` has no per-instance override for its own close
236
+ button. */}
237
+ <button type="button" className={styles.close} aria-label="Close" onClick={onClose}>
238
+ <Icon name="x" size="md" aria-hidden={true} />
239
+ </button>
240
+ </>
241
+ )}
200
242
  {/* The panel is a fixed-size box; only this region scrolls, so the
201
- panel never resizes with its content and the close button stays
202
- pinned. */}
243
+ panel never resizes with its content and the header/close button
244
+ and footer (if present) stay pinned. */}
203
245
  <div className={styles.content}>
204
246
  <OverlaySecondRowContext.Provider value={setSecondRow}>
205
247
  {children}
206
248
  </OverlaySecondRowContext.Provider>
207
249
  </div>
250
+ {footer && <div className={styles.footer}>{footer}</div>}
208
251
  </div>
209
252
  </div>
210
253
  );
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { useState } from 'react';
4
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+ import { ConfirmDialog } from '../../ConfirmDialog/ConfirmDialog';
4
6
  import { Dialog, useOverlaySecondRow } from '../Dialog';
5
7
 
6
8
  // Dialog's exit animation reads prefers-reduced-motion via matchMedia, which
@@ -48,16 +50,24 @@ describe('Dialog', () => {
48
50
  expect(dialog.getAttribute('aria-modal')).toBe('true');
49
51
  });
50
52
 
51
- it('calls onClose on Escape', () => {
53
+ it('calls onClose exactly once on Escape with focus inside the panel', () => {
52
54
  const onClose = vi.fn();
53
55
  render(
54
- <Dialog open onClose={onClose}>
55
- Body
56
+ <Dialog open onClose={onClose} aria-label="Panel">
57
+ <button>Focus me</button>
56
58
  </Dialog>,
57
59
  );
58
- // Keyboard handler is registered on document (not the dialog element) so
59
- // screen readers and keyboard users can dismiss from anywhere in the page.
60
- fireEvent.keyDown(document, { key: 'Escape' });
60
+ // Dispatched from the actually-focused element (matching real usage,
61
+ // where useOverlayFocusCapture moves focus into the panel on open) so the
62
+ // keydown bubbles through the scrim on its way to document — the exact
63
+ // path that used to trigger both the scrim's own (now-removed) Escape
64
+ // handler and useOverlayKeyboardTrap's document-level listener, double-
65
+ // firing onClose. Dispatching straight on `document` (the previous form
66
+ // of this test) skips that bubble path and would pass even if the bug
67
+ // were reintroduced.
68
+ const button = screen.getByRole('button', { name: 'Focus me' });
69
+ button.focus();
70
+ fireEvent.keyDown(button, { key: 'Escape' });
61
71
  expect(onClose).toHaveBeenCalledOnce();
62
72
  });
63
73
 
@@ -78,11 +88,11 @@ describe('Dialog', () => {
78
88
 
79
89
  it('applies the size class', () => {
80
90
  render(
81
- <Dialog open onClose={() => {}} size="full" aria-label="Big">
91
+ <Dialog open onClose={() => {}} size="lg" aria-label="Big">
82
92
  Body
83
93
  </Dialog>,
84
94
  );
85
- expect(screen.getByRole('dialog').className).toContain('full');
95
+ expect(screen.getByRole('dialog').className).toContain('lg');
86
96
  });
87
97
 
88
98
  it('supports the sm size', () => {
@@ -94,6 +104,24 @@ describe('Dialog', () => {
94
104
  expect(screen.getByRole('dialog').className).toContain('sm');
95
105
  });
96
106
 
107
+ it('supports the auto size', () => {
108
+ render(
109
+ <Dialog open onClose={() => {}} size="auto" aria-label="Auto-sized">
110
+ Body
111
+ </Dialog>,
112
+ );
113
+ expect(screen.getByRole('dialog').className).toContain('auto');
114
+ });
115
+
116
+ it('supports the fixed size (epic task 14.5)', () => {
117
+ render(
118
+ <Dialog open onClose={() => {}} size="fixed" aria-label="Fixed-size">
119
+ Body
120
+ </Dialog>,
121
+ );
122
+ expect(screen.getByRole('dialog').className).toContain('fixed');
123
+ });
124
+
97
125
  it('renders a descendant-supplied secondRow via useOverlaySecondRow', () => {
98
126
  function TabStrip() {
99
127
  useOverlaySecondRow(<nav>Tab strip</nav>);
@@ -134,4 +162,157 @@ describe('Dialog', () => {
134
162
  expect(screen.getByText('Standalone content')).toBeTruthy();
135
163
  expect(screen.queryByText('Tab strip')).toBeNull();
136
164
  });
165
+
166
+ describe('Body only (default — no header/footer props)', () => {
167
+ it('renders exactly one close button and no footer', () => {
168
+ render(
169
+ <Dialog open onClose={() => {}} title="Settings" aria-label="Settings">
170
+ Body
171
+ </Dialog>,
172
+ );
173
+ // Both the mobile-only OverlayHeader's close button and the
174
+ // desktop-only floating one are always in the DOM (CSS toggles which
175
+ // is visible per breakpoint, not React) — unchanged from before this
176
+ // task, since this is the `header`-omitted branch.
177
+ expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(2);
178
+ expect(screen.getByText('Settings')).toBeTruthy();
179
+ });
180
+ });
181
+
182
+ describe('Header + Body (header prop)', () => {
183
+ it('renders header content via a single OverlayHeader, on both breakpoints, instead of the mobile-only title bar', () => {
184
+ render(
185
+ <Dialog open onClose={() => {}} header={<span>Card detail</span>} aria-label="Card detail">
186
+ Body
187
+ </Dialog>,
188
+ );
189
+ expect(screen.getByText('Card detail')).toBeTruthy();
190
+ // Only one close button now — the header branch skips the separate
191
+ // desktop .close button entirely (see Dialog.tsx's own comment).
192
+ expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(1);
193
+ });
194
+
195
+ it('supersedes title for visible content but keeps title as the aria-label fallback', () => {
196
+ render(
197
+ <Dialog open onClose={() => {}} title="Fallback name" header={<span>Visible header</span>}>
198
+ Body
199
+ </Dialog>,
200
+ );
201
+ expect(screen.getByText('Visible header')).toBeTruthy();
202
+ expect(screen.queryByText('Fallback name')).toBeNull();
203
+ expect(screen.getByRole('dialog', { name: 'Fallback name' })).toBeTruthy();
204
+ });
205
+ });
206
+
207
+ describe('Header + Body + Footer (header and footer props)', () => {
208
+ it('renders footer content as a non-scrolling sibling of the body', () => {
209
+ render(
210
+ <Dialog
211
+ open
212
+ onClose={() => {}}
213
+ header={<span>Card detail</span>}
214
+ footer={<button type="button">Save</button>}
215
+ aria-label="Card detail"
216
+ >
217
+ <p>Body content</p>
218
+ </Dialog>,
219
+ );
220
+ const footerButton = screen.getByRole('button', { name: 'Save' });
221
+ const bodyText = screen.getByText('Body content');
222
+ expect(footerButton).toBeTruthy();
223
+ // Footer is a sibling of .content (the scroll region), not nested
224
+ // inside it — it must never scroll away with the body.
225
+ expect(bodyText.closest('[class*="content"]')?.contains(footerButton)).toBe(false);
226
+ });
227
+
228
+ it('omits the footer row entirely when footer is not provided', () => {
229
+ render(
230
+ <Dialog open onClose={() => {}} header={<span>Card detail</span>} aria-label="Card detail">
231
+ Body
232
+ </Dialog>,
233
+ );
234
+ expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
235
+ });
236
+ });
237
+ });
238
+
239
+ describe('Dialog Escape precedence with a nested ConfirmDialog', () => {
240
+ beforeEach(() => {
241
+ installMatchMedia();
242
+ // jsdom does not implement HTMLDialogElement — see ConfirmDialog.test.tsx
243
+ // for the full rationale on the guard + deferred dispatch (both matter
244
+ // here too: without the guard, this file's own reuse of the mock across
245
+ // renders would double-dispatch; without deferring, ConfirmDialog's
246
+ // unregister-on-close wouldn't have run yet by the time this test reads
247
+ // the outer Dialog's response to a second Escape).
248
+ HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
249
+ this.setAttribute('open', '');
250
+ };
251
+ HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
252
+ if (!this.hasAttribute('open')) return;
253
+ this.removeAttribute('open');
254
+ queueMicrotask(() => this.dispatchEvent(new Event('close')));
255
+ };
256
+ });
257
+ afterEach(() => {
258
+ vi.unstubAllGlobals();
259
+ cleanup();
260
+ });
261
+
262
+ async function flush() {
263
+ await act(async () => {
264
+ await new Promise((resolve) => setTimeout(resolve, 0));
265
+ });
266
+ }
267
+
268
+ it('defers Escape to a nested open ConfirmDialog instead of closing the outer Dialog', async () => {
269
+ const outerOnClose = vi.fn();
270
+
271
+ function NestedHarness() {
272
+ // Starts closed and opens via a later click — matching the real
273
+ // sequence (CardDetailOverlay: the outer Dialog is already mounted and
274
+ // registered before the user ever triggers the nested confirm).
275
+ // Mounting both already-open in the same initial render would instead
276
+ // have React fire the child's (ConfirmDialog's) registration effect
277
+ // before the parent's (Dialog's) in that one shared commit — the one
278
+ // case this stack-order approach doesn't handle, and not the shape of
279
+ // the bug this task fixes.
280
+ const [confirmOpen, setConfirmOpen] = useState(false);
281
+ return (
282
+ <Dialog open onClose={outerOnClose} aria-label="Card detail">
283
+ <button onClick={() => setConfirmOpen(true)}>Delete card…</button>
284
+ <ConfirmDialog
285
+ open={confirmOpen}
286
+ onClose={() => setConfirmOpen(false)}
287
+ onConfirm={() => {}}
288
+ title="Delete card?"
289
+ message="This can't be undone."
290
+ />
291
+ </Dialog>
292
+ );
293
+ }
294
+
295
+ render(<NestedHarness />);
296
+ fireEvent.click(screen.getByRole('button', { name: 'Delete card…' }));
297
+ await flush();
298
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
299
+
300
+ // The nested ConfirmDialog is topmost — the outer Dialog's document-level
301
+ // Escape listener must not act while it's open.
302
+ fireEvent.keyDown(document, { key: 'Escape' });
303
+ await flush();
304
+ expect(outerOnClose).not.toHaveBeenCalled();
305
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
306
+
307
+ // Dismiss the nested confirm (unregisters it from the open-overlay
308
+ // stack) — the outer Dialog is now topmost again.
309
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
310
+ await flush();
311
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
312
+
313
+ // A second Escape now reaches the outer Dialog.
314
+ fireEvent.keyDown(document, { key: 'Escape' });
315
+ await flush();
316
+ expect(outerOnClose).toHaveBeenCalledOnce();
317
+ });
137
318
  });
@@ -1,4 +1,5 @@
1
1
  import type { HTMLAttributes, ReactNode } from 'react';
2
+ import { GripIcon } from '../GripIcon/GripIcon';
2
3
  import styles from './DragHandleRow.module.css';
3
4
 
4
5
  export interface DragHandleRowProps extends HTMLAttributes<HTMLDivElement> {
@@ -33,29 +34,9 @@ export function DragHandleRow({
33
34
  tabIndex={-1}
34
35
  {...handleProps}
35
36
  >
36
- <DragIcon />
37
+ <GripIcon className={styles.icon} />
37
38
  </button>
38
39
  <div className={styles.content}>{children}</div>
39
40
  </div>
40
41
  );
41
42
  }
42
-
43
- function DragIcon() {
44
- return (
45
- <svg
46
- width="14"
47
- height="14"
48
- viewBox="0 0 14 14"
49
- fill="none"
50
- aria-hidden="true"
51
- className={styles.icon}
52
- >
53
- {/* Two columns of three dots */}
54
- {[3, 7, 11].map((cy) =>
55
- [4, 10].map((cx) => (
56
- <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.2} fill="currentColor" />
57
- )),
58
- )}
59
- </svg>
60
- );
61
- }
@@ -7,8 +7,10 @@
7
7
  .scrim {
8
8
  position: fixed;
9
9
  inset: 0;
10
- /* When the shell declares --sv-shell-footer-height (mobile nav bar), stop the
11
- scrim above the footer so the panel never hides behind it. */
10
+ /* When --sv-shell-footer-height is set (mobile nav bar — by the platform
11
+ shell, or by a self-rendered MobileFooter via usePublishShellChromeHeight
12
+ for a shellConfig.mobileFooter: false plugin), stop the scrim above the
13
+ footer so the panel never hides behind it. */
12
14
  bottom: var(--sv-shell-footer-height, 0);
13
15
  z-index: 100;
14
16
  display: flex;
@@ -27,14 +29,21 @@
27
29
  .panel {
28
30
  width: 100%;
29
31
  max-height: 80dvh;
30
- overflow-y: auto;
31
- overscroll-behavior: contain;
32
+ /* The panel itself no longer scrolls — see .body below. display: flex
33
+ (column) lets .handle (and OverlayHeader, when title is set) stay
34
+ fixed while .body's own overflow-y handles content taller than the
35
+ 80dvh cap, the same fixed-header/scrollable-body split Sheet.module.css
36
+ already establishes (its .panel/.content pair). Before this, the whole
37
+ .panel scrolled as one block, so a tall drawer (e.g. many installed
38
+ plugins in the Apps grid) scrolled the grab handle itself out of view
39
+ along with the content — reachable in the DOM, invisible on screen. */
40
+ display: flex;
41
+ flex-direction: column;
42
+ overflow: hidden;
32
43
  background: var(--sv-color-surface-raised);
33
- border-top: 1px solid var(--sv-color-border);
44
+ border-top: var(--sv-border-width-hairline) solid var(--sv-color-border);
34
45
  border-radius: var(--sv-radius-3xl) var(--sv-radius-3xl) 0 0;
35
46
  box-shadow: var(--sv-shadow-overlay);
36
- /* Pull content above the home indicator / bottom bar in standalone mode. */
37
- padding-bottom: env(safe-area-inset-bottom);
38
47
  outline: none;
39
48
  /* Slide up from below the viewport. Always a bottom sheet, so unlike Dialog
40
49
  there's no desktop-vs-mobile fork here. */
@@ -42,6 +51,26 @@
42
51
  transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
43
52
  }
44
53
 
54
+ /* The scroll container — everything below the fixed handle/OverlayHeader.
55
+ Scrollbar hidden (not overflow removed) the same way Tabs.module.css's
56
+ .tablist already does for this design system; scroll-driven UI here reads
57
+ as a native swipe list, not a document needing a visible scroll affordance. */
58
+ .body {
59
+ flex: 1 1 auto;
60
+ min-height: 0;
61
+ overflow-y: auto;
62
+ overscroll-behavior: contain;
63
+ scrollbar-width: none;
64
+ /* Pull content above the home indicator / bottom bar in standalone mode —
65
+ moved here from .panel now that this is the element whose bottom edge
66
+ is the actual scrollable content boundary. */
67
+ padding-bottom: env(safe-area-inset-bottom);
68
+ }
69
+
70
+ .body::-webkit-scrollbar {
71
+ display: none;
72
+ }
73
+
45
74
  .panelOpen {
46
75
  transform: translateY(0);
47
76
  }