@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,21 +1,176 @@
1
+ 'use client';
2
+
1
3
  import type { ReactNode } from 'react';
4
+ import { Button } from '../Button/Button';
5
+ import { Icon } from '../Icon/Icon';
6
+ import type { IconName } from '../Icon/Icon';
7
+ import { ResponsiveSurface } from '../ResponsiveSurface/ResponsiveSurface';
2
8
  import styles from './PageHeader.module.css';
3
9
 
4
10
  export interface PageHeaderProps {
5
11
  title: string;
6
12
  description?: string;
13
+ /** Optional icon rendered inline before the title text, on both breakpoints — e.g. `folder-open` next to a folder's name. */
14
+ icon?: IconName;
15
+ /**
16
+ * Page-level controls — anywhere from one button to a whole toolbar.
17
+ * Renders on both breakpoints unchanged; if it needs to look different on
18
+ * mobile (e.g. collapsing into its own "⋮" trigger), that's the action
19
+ * content's own responsibility — same pattern Shopper's `ListHeaderActions`
20
+ * already follows (it calls `useIsMobile()` internally). `PageHeader`
21
+ * itself has no opinion on what's inside this slot.
22
+ */
7
23
  action?: ReactNode;
8
24
  className?: string;
25
+ /** Heading level for the title. Defaults to `1` for standalone use; pass
26
+ * `2` or `3` when the page already sits under a shell/plugin `<h1>`, to
27
+ * avoid a duplicate top-level heading. Visual style is unchanged at every
28
+ * level — only the rendered tag changes. Applies on both breakpoints. */
29
+ headingLevel?: 1 | 2 | 3;
30
+ /** Mobile only — shows a back button (calling this) to the left of the
31
+ * title when provided. Omit it to render without one. */
32
+ onBack?: () => void;
33
+ /** Mobile only — shows a vertical-ellipsis icon button (calling this) at
34
+ * the far right when provided, typically to open a drawer/menu. PageHeader
35
+ * has no opinion on what that menu contains — it only renders the trigger
36
+ * and calls back. Separate from `action`, which renders identically on
37
+ * both breakpoints; this is a mobile-only affordance with no web
38
+ * equivalent. */
39
+ onMenuClick?: () => void;
9
40
  }
10
41
 
11
- export function PageHeader({ title, description, action, className }: PageHeaderProps) {
42
+ const HEADING_TAG = { 1: 'h1', 2: 'h2', 3: 'h3' } as const;
43
+
44
+ interface PageHeaderWebProps {
45
+ title: string;
46
+ description?: string;
47
+ icon?: IconName;
48
+ action?: ReactNode;
49
+ headingLevel: 1 | 2 | 3;
50
+ className?: string;
51
+ }
52
+
53
+ // internal — not exported
54
+ function PageHeaderWeb({
55
+ title,
56
+ description,
57
+ icon,
58
+ action,
59
+ headingLevel,
60
+ className,
61
+ }: PageHeaderWebProps) {
62
+ const Heading = HEADING_TAG[headingLevel];
12
63
  return (
13
64
  <header className={[styles.header, className].filter(Boolean).join(' ')}>
14
65
  <div className={styles.text}>
15
- <h1 className={styles.title}>{title}</h1>
66
+ <div className={styles.titleRow}>
67
+ {icon && <Icon name={icon} size="lg" aria-hidden={true} />}
68
+ <Heading className={styles.title}>{title}</Heading>
69
+ </div>
16
70
  {description && <p className={styles.description}>{description}</p>}
17
71
  </div>
18
72
  {action && <div className={styles.action}>{action}</div>}
19
73
  </header>
20
74
  );
21
75
  }
76
+
77
+ interface PageHeaderMobileProps {
78
+ title: string;
79
+ icon?: IconName;
80
+ action?: ReactNode;
81
+ headingLevel: 1 | 2 | 3;
82
+ onBack?: () => void;
83
+ onMenuClick?: () => void;
84
+ className?: string;
85
+ }
86
+
87
+ // internal — not exported
88
+ // No `description` here, deliberately — matches the one real mobile
89
+ // page-header precedent this was generalized from (Kanban's own
90
+ // MobileBoardHeader), which has no subtitle handling either. A single
91
+ // fixed-shape row, consistent with every other mobile chrome bar in this
92
+ // design system rather than a variable-height two-line header.
93
+ function PageHeaderMobile({
94
+ title,
95
+ icon,
96
+ action,
97
+ headingLevel,
98
+ onBack,
99
+ onMenuClick,
100
+ className,
101
+ }: PageHeaderMobileProps) {
102
+ const Heading = HEADING_TAG[headingLevel];
103
+ return (
104
+ <header className={[styles.mobileHeader, className].filter(Boolean).join(' ')}>
105
+ {onBack && (
106
+ <Button
107
+ variant="ghost"
108
+ size="sm"
109
+ aria-label="Back"
110
+ onClick={onBack}
111
+ className={styles.mobileBack}
112
+ >
113
+ <Icon name="circle-chevron-left" size="md" aria-hidden />
114
+ </Button>
115
+ )}
116
+ {icon && <Icon name={icon} size="md" aria-hidden={true} className={styles.mobileTitleIcon} />}
117
+ <Heading className={styles.mobileTitle}>{title}</Heading>
118
+ {action && <div className={styles.mobileAction}>{action}</div>}
119
+ {onMenuClick && (
120
+ <Button
121
+ variant="ghost"
122
+ size="sm"
123
+ aria-label="Menu"
124
+ onClick={onMenuClick}
125
+ className={styles.mobileMenu}
126
+ >
127
+ <Icon name="ellipsis-vertical" size="md" aria-hidden />
128
+ </Button>
129
+ )}
130
+ </header>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * PageHeader — a page's title section. Web: title + description on the
136
+ * left, action on the right. Mobile: an optional back button, a single-line
137
+ * title, and the same action slot — a compact single row, matching Kanban's
138
+ * own hand-rolled MobileBoardHeader this was generalized from. The
139
+ * web/mobile fork is `ResponsiveSurface` — only one tree is ever mounted.
140
+ */
141
+ export function PageHeader({
142
+ title,
143
+ description,
144
+ icon,
145
+ action,
146
+ className,
147
+ headingLevel = 1,
148
+ onBack,
149
+ onMenuClick,
150
+ }: PageHeaderProps) {
151
+ return (
152
+ <ResponsiveSurface
153
+ web={
154
+ <PageHeaderWeb
155
+ title={title}
156
+ description={description}
157
+ icon={icon}
158
+ action={action}
159
+ headingLevel={headingLevel}
160
+ className={className}
161
+ />
162
+ }
163
+ mobile={
164
+ <PageHeaderMobile
165
+ title={title}
166
+ icon={icon}
167
+ action={action}
168
+ headingLevel={headingLevel}
169
+ onBack={onBack}
170
+ onMenuClick={onMenuClick}
171
+ className={className}
172
+ />
173
+ }
174
+ />
175
+ );
176
+ }
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { PageHeader } from '../PageHeader';
5
+
6
+ let matches = false;
7
+
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches,
13
+ media: query,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ })),
20
+ );
21
+ }
22
+
23
+ describe('PageHeader', () => {
24
+ beforeEach(() => {
25
+ matches = false;
26
+ installMatchMedia();
27
+ });
28
+ afterEach(() => {
29
+ vi.unstubAllGlobals();
30
+ cleanup();
31
+ });
32
+
33
+ it('web: renders title, description, and action', () => {
34
+ render(
35
+ <PageHeader
36
+ title="Users"
37
+ description="Manage who has access to this instance."
38
+ action={<button type="button">Invite user</button>}
39
+ />,
40
+ );
41
+
42
+ expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
43
+ expect(screen.getByText('Manage who has access to this instance.')).toBeDefined();
44
+ expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
45
+ });
46
+
47
+ it('web: renders no back button even when onBack is given', () => {
48
+ render(<PageHeader title="Users" onBack={() => undefined} />);
49
+
50
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
51
+ });
52
+
53
+ it('mobile: renders no description, no back button, no menu button by default', () => {
54
+ matches = true;
55
+ installMatchMedia();
56
+
57
+ render(<PageHeader title="Users" description="Manage who has access to this instance." />);
58
+
59
+ expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
60
+ expect(screen.queryByText('Manage who has access to this instance.')).toBeNull();
61
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
62
+ expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
63
+ });
64
+
65
+ it('mobile: renders a back button that calls onBack when given', () => {
66
+ matches = true;
67
+ installMatchMedia();
68
+ const onBack = vi.fn();
69
+
70
+ render(<PageHeader title="Users" onBack={onBack} />);
71
+
72
+ const back = screen.getByRole('button', { name: 'Back' });
73
+ fireEvent.click(back);
74
+ expect(onBack).toHaveBeenCalledOnce();
75
+ });
76
+
77
+ it('mobile: renders a menu button that calls onMenuClick when given', () => {
78
+ matches = true;
79
+ installMatchMedia();
80
+ const onMenuClick = vi.fn();
81
+
82
+ render(<PageHeader title="Users" onMenuClick={onMenuClick} />);
83
+
84
+ const menu = screen.getByRole('button', { name: 'Menu' });
85
+ fireEvent.click(menu);
86
+ expect(onMenuClick).toHaveBeenCalledOnce();
87
+ });
88
+
89
+ it('web: renders no menu button even when onMenuClick is given', () => {
90
+ render(<PageHeader title="Users" onMenuClick={() => undefined} />);
91
+
92
+ expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
93
+ });
94
+
95
+ it('mobile: renders the same action content as web', () => {
96
+ matches = true;
97
+ installMatchMedia();
98
+
99
+ render(<PageHeader title="Users" action={<button type="button">Invite user</button>} />);
100
+
101
+ expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
102
+ });
103
+ });
@@ -0,0 +1,51 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-width: 100%;
5
+ min-height: 100%;
6
+ }
7
+
8
+ .header {
9
+ flex-shrink: 0;
10
+ }
11
+
12
+ .content {
13
+ flex: 1;
14
+ }
15
+
16
+ /* ── Padding ────────────────────────────────────────────────────────────── */
17
+ /* Same four-step scale and --sv-page-gutter stand-down hook as
18
+ PageContainer (see that component's own CSS comment for the full
19
+ rationale) — kept identical so this can absorb PageContainer's role
20
+ later without a behavior change for existing consumers. */
21
+
22
+ .padNone {
23
+ padding: 0;
24
+ }
25
+
26
+ .padSm {
27
+ padding: var(--sv-page-gutter, var(--sv-space-4));
28
+ }
29
+
30
+ .padMd {
31
+ padding: var(--sv-page-gutter, var(--sv-space-8));
32
+ }
33
+
34
+ .padLg {
35
+ padding: var(--sv-page-gutter, var(--sv-space-12));
36
+ }
37
+
38
+ /* Matches the breakpoint Dialog, MobileHeader, and MobileFooter already use. */
39
+ @media (max-width: 768px) {
40
+ .padSm {
41
+ padding: var(--sv-page-gutter, var(--sv-space-3));
42
+ }
43
+
44
+ .padMd {
45
+ padding: var(--sv-page-gutter, var(--sv-space-4));
46
+ }
47
+
48
+ .padLg {
49
+ padding: var(--sv-page-gutter, var(--sv-space-6));
50
+ }
51
+ }
@@ -0,0 +1,73 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { PageLayout } from './PageLayout';
3
+
4
+ function Content() {
5
+ return (
6
+ <div
7
+ style={{
8
+ fontFamily: 'var(--sv-font-family)',
9
+ fontSize: 'var(--sv-font-size-sm)',
10
+ color: 'var(--sv-color-text-primary)',
11
+ }}
12
+ >
13
+ {Array.from({ length: 6 }, (_, index) => (
14
+ <p key={index}>Page content row {index + 1}.</p>
15
+ ))}
16
+ </div>
17
+ );
18
+ }
19
+
20
+ function PageHeader() {
21
+ return (
22
+ <div
23
+ style={{
24
+ display: 'flex',
25
+ alignItems: 'center',
26
+ justifyContent: 'space-between',
27
+ padding: 'var(--sv-space-3) var(--sv-space-4)',
28
+ borderBottom: '1px solid var(--sv-color-border)',
29
+ fontFamily: 'var(--sv-font-family)',
30
+ fontSize: 'var(--sv-font-size-md)',
31
+ fontWeight: 600,
32
+ color: 'var(--sv-color-text-primary)',
33
+ }}
34
+ >
35
+ Sv Wallet
36
+ </div>
37
+ );
38
+ }
39
+
40
+ const meta = {
41
+ title: 'Components/PageLayout',
42
+ component: PageLayout,
43
+ parameters: {
44
+ layout: 'padded',
45
+ docs: {
46
+ description: {
47
+ component:
48
+ "A single page's content area, nested inside RootLayout's main slot — not a replacement for RootLayout. No padding by default; opt in via the padding prop (same scale as PageContainer). The optional header is page-specific (e.g. a board title/toolbar), not the app-level header RootLayout already renders.",
49
+ },
50
+ },
51
+ },
52
+ args: {
53
+ children: <Content />,
54
+ },
55
+ } satisfies Meta<typeof PageLayout>;
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof meta>;
59
+
60
+ export const Default: Story = {};
61
+
62
+ export const WithPadding: Story = {
63
+ args: {
64
+ padding: 'lg',
65
+ },
66
+ };
67
+
68
+ export const WithHeader: Story = {
69
+ args: {
70
+ header: <PageHeader />,
71
+ padding: 'md',
72
+ },
73
+ };
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import styles from './PageLayout.module.css';
5
+
6
+ export type PageLayoutPadding = 'none' | 'sm' | 'md' | 'lg';
7
+
8
+ export interface PageLayoutProps {
9
+ /**
10
+ * A page-specific header — e.g. a board title + search/share toolbar.
11
+ * Not the app-level header (that's `RootLayout`'s `header`/`shell`
12
+ * variants) — this is a second, contextual row scoped to this one page.
13
+ * Renders edge-to-edge above the padded content, outside `padding`.
14
+ */
15
+ header?: ReactNode;
16
+ /** The page gutter. Default `'none'` — unlike `PageContainer`, a bare
17
+ * `PageLayout` applies no padding at all; opt in per page. Same four-step
18
+ * scale and `--sv-page-gutter` stand-down hook as `PageContainer` (steps
19
+ * down at the same 768px breakpoint `Dialog`/`MobileHeader` use). */
20
+ padding?: PageLayoutPadding;
21
+ children: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ const paddingClass: Record<PageLayoutPadding, string> = {
26
+ none: styles.padNone as string,
27
+ sm: styles.padSm as string,
28
+ md: styles.padMd as string,
29
+ lg: styles.padLg as string,
30
+ };
31
+
32
+ /**
33
+ * PageLayout — a single page's content area within a plugin (nested inside
34
+ * `RootLayout`'s `main` slot, not a replacement for it). Enforces only
35
+ * structure and dimensions; content always comes from props/children.
36
+ *
37
+ * `min-width: 100%; min-height: 100%` — safe here (unlike `RootLayout`'s own
38
+ * root) because `PageLayout` is always nested inside an already
39
+ * flex-stretched region (`ThreeColumnLayout`/`HeaderFooterLayout`'s
40
+ * `.main`), and a stretched flex item's cross-size is definite for its
41
+ * descendants' percentage resolution, so no separate height-cascading fix
42
+ * is needed the way `RootLayout`'s root required one.
43
+ */
44
+ export function PageLayout({ header, padding = 'none', children, className }: PageLayoutProps) {
45
+ return (
46
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
47
+ {header && <div className={styles.header}>{header}</div>}
48
+ <div className={[styles.content, paddingClass[padding]].join(' ')}>{children}</div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { PageLayout } from '../PageLayout';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('PageLayout', () => {
9
+ it('renders children with no header by default', () => {
10
+ render(<PageLayout>Content</PageLayout>);
11
+
12
+ expect(screen.getByText('Content')).toBeDefined();
13
+ expect(screen.queryByText('Header')).toBeNull();
14
+ });
15
+
16
+ it('renders a page-specific header when given', () => {
17
+ render(<PageLayout header={<span>Header</span>}>Content</PageLayout>);
18
+
19
+ expect(screen.getByText('Header')).toBeDefined();
20
+ expect(screen.getByText('Content')).toBeDefined();
21
+ });
22
+
23
+ it('applies no padding by default', () => {
24
+ render(<PageLayout>Content</PageLayout>);
25
+
26
+ expect(screen.getByText('Content').className).toContain('padNone');
27
+ });
28
+
29
+ it('applies the requested padding step', () => {
30
+ render(<PageLayout padding="lg">Content</PageLayout>);
31
+
32
+ expect(screen.getByText('Content').className).toContain('padLg');
33
+ });
34
+ });
@@ -8,7 +8,7 @@
8
8
  top: calc(100% + var(--sv-space-2));
9
9
  z-index: 60;
10
10
  background: var(--sv-color-surface);
11
- border: 1px solid var(--sv-color-border);
11
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
12
12
  border-radius: var(--sv-radius-2xl);
13
13
  box-shadow: var(--sv-shadow-popover);
14
14
  /* auto, not hidden: Popover.tsx applies an inline max-height only when the
@@ -9,6 +9,11 @@ import {
9
9
  type ReactElement,
10
10
  type ReactNode,
11
11
  } from 'react';
12
+ import {
13
+ isTopmostOpenOverlay,
14
+ registerOpenOverlay,
15
+ unregisterOpenOverlay,
16
+ } from '../../overlay-shell';
12
17
  import styles from './Popover.module.css';
13
18
 
14
19
  export interface PopoverProps {
@@ -30,6 +35,17 @@ export interface PopoverProps {
30
35
  * regardless of CSS module load order, unlike passing an extra className.
31
36
  */
32
37
  panelStyle?: CSSProperties;
38
+ /**
39
+ * Class applied to the popover's own wrapper around `trigger` — the
40
+ * element that participates in the caller's layout.
41
+ *
42
+ * Mostly needed for flex sizing: the wrapper is an `inline-flex` box with
43
+ * the default `min-width: auto`, which pins it to the trigger's content
44
+ * width. Inside a flex row that has to shrink, that stops a truncatable
45
+ * trigger from ever giving up space, and pushes its siblings out of the
46
+ * container instead. Pass a class setting `min-width: 0` there.
47
+ */
48
+ className?: string;
33
49
  'aria-label': string;
34
50
  children: ReactNode;
35
51
  }
@@ -78,6 +94,7 @@ export function Popover({
78
94
  align = 'right',
79
95
  width = 288,
80
96
  panelStyle,
97
+ className,
81
98
  'aria-label': ariaLabel,
82
99
  children,
83
100
  }: PopoverProps) {
@@ -92,6 +109,36 @@ export function Popover({
92
109
  // when the panel is taller than that — null leaves it at its natural
93
110
  // height, unconstrained, for the common case where it already fits.
94
111
  const [maxHeight, setMaxHeight] = useState<number | null>(null);
112
+ // Horizontal viewport clamp, as a `left` override (container-relative px)
113
+ // that replaces the `align`-driven `left: 0` / `right: 0` class rule —
114
+ // null leaves alignment at its natural, class-driven position, for the
115
+ // common case where the panel already fits. Needed because a fixed-width
116
+ // panel anchored to one edge of a trigger near the *opposite* edge of a
117
+ // narrow viewport (e.g. a 340px panel under a bell sitting near the right
118
+ // edge of a 375px mobile screen) has nowhere to go but off-screen — the
119
+ // existing vertical flip only ever solves the above/below axis, never
120
+ // this one.
121
+ const [horizontalOffset, setHorizontalOffset] = useState<number | null>(null);
122
+ const overlayIdRef = useRef<string | null>(null);
123
+
124
+ // Registers into the same open-overlay stack Dialog/Drawer/Sheet/
125
+ // ConfirmDialog use (overlay-shell.ts) purely so a Dialog this Popover was
126
+ // opened from inside of (e.g. a DatePicker/Combobox field in a modal form —
127
+ // Popover is what Menu/Combobox/DatePicker/ContextMenu/HoverCard/UserMenu
128
+ // are all built on) knows it is no longer the topmost overlay, and defers
129
+ // Escape to this one instead of closing itself too. A separate effect keyed
130
+ // only on `open`, matching useOverlayKeyboardTrap's own pattern — identity
131
+ // churn on `onClose` shouldn't re-register and bump this popover back to
132
+ // the top of the stack.
133
+ useEffect(() => {
134
+ if (!open) return;
135
+ const id = registerOpenOverlay();
136
+ overlayIdRef.current = id;
137
+ return () => {
138
+ unregisterOpenOverlay(id);
139
+ overlayIdRef.current = null;
140
+ };
141
+ }, [open]);
95
142
 
96
143
  // width="trigger": measure the trigger's own rendered width and keep the
97
144
  // panel matching it, including live resizes (e.g. rotating the device, or
@@ -127,6 +174,7 @@ export function Popover({
127
174
  if (!open) {
128
175
  setOpenUpward(false);
129
176
  setMaxHeight(null);
177
+ setHorizontalOffset(null);
130
178
  return;
131
179
  }
132
180
  const container = containerRef.current;
@@ -155,10 +203,37 @@ export function Popover({
155
203
  setOpenUpward(upward);
156
204
  const available = upward ? spaceAbove : spaceBelow;
157
205
  setMaxHeight(panelHeight > available ? Math.max(available, 0) : null);
206
+
207
+ // Horizontal clamp. Deliberately computed from `containerRect` (the
208
+ // trigger's own position, never affected by anything this effect does)
209
+ // and the panel's rendered *width* (also offset-independent) rather than
210
+ // reading the panel's current `left`/`right` off the DOM — those reflect
211
+ // whatever `horizontalOffset` a *previous* run of this same effect may
212
+ // already have applied. Measuring the rendered position directly is not
213
+ // idempotent: re-running this effect after it already nudged the panel
214
+ // on-screen would see an already-corrected rect that looks like it never
215
+ // needed correcting, and clear the very offset it just set (confirmed
216
+ // live — React StrictMode's dev-only double-invoke of layout effects hit
217
+ // exactly this: the second pass measured the first pass's fix as "fits
218
+ // fine" and reset `horizontalOffset` back to null before first paint).
219
+ // Rebuilding the *natural*, un-offset position analytically from the
220
+ // same `align`-driven rule the CSS class itself encodes (`right: 0` /
221
+ // `left: 0` relative to the container) sidesteps that entirely — the
222
+ // same inputs always produce the same output, however many times this
223
+ // runs.
224
+ const containerRect = container.getBoundingClientRect();
225
+ const panelWidth = panel.getBoundingClientRect().width;
226
+ const naturalLeft = align === 'right' ? containerRect.right - panelWidth : containerRect.left;
227
+ const clampedLeft = Math.min(
228
+ Math.max(naturalLeft, VIEWPORT_MARGIN),
229
+ window.innerWidth - VIEWPORT_MARGIN - panelWidth,
230
+ );
231
+ setHorizontalOffset(clampedLeft !== naturalLeft ? clampedLeft - containerRect.left : null);
158
232
  // children included: content that changes the panel's height (e.g. a
159
233
  // calendar switching months to a row with fewer/more weeks) should
160
234
  // re-run this measurement, not just the open/close transition itself.
161
- }, [open, children]);
235
+ // align included: the horizontal clamp above reads it directly.
236
+ }, [open, children, align]);
162
237
 
163
238
  // Outside-click dismissal
164
239
  useEffect(() => {
@@ -172,18 +247,24 @@ export function Popover({
172
247
  return () => document.removeEventListener('mousedown', handleMouseDown);
173
248
  }, [open, onClose]);
174
249
 
175
- // Escape key dismissal
250
+ // Escape key dismissal — deferred to a nested overlay (if any) via the same
251
+ // topmost-overlay check useOverlayKeyboardTrap uses, so e.g. opening a
252
+ // ConfirmDialog from inside this panel's content doesn't also close the
253
+ // popover out from under it on the same Escape press.
176
254
  useEffect(() => {
177
255
  if (!open) return;
178
256
  function handleKeyDown(e: KeyboardEvent) {
179
- if (e.key === 'Escape') onClose();
257
+ if (e.key !== 'Escape') return;
258
+ const id = overlayIdRef.current;
259
+ if (id !== null && !isTopmostOpenOverlay(id)) return;
260
+ onClose();
180
261
  }
181
262
  document.addEventListener('keydown', handleKeyDown);
182
263
  return () => document.removeEventListener('keydown', handleKeyDown);
183
264
  }, [open, onClose]);
184
265
 
185
266
  return (
186
- <div ref={containerRef} className={styles.container}>
267
+ <div ref={containerRef} className={[styles.container, className].filter(Boolean).join(' ')}>
187
268
  {trigger}
188
269
  {open && (
189
270
  <div
@@ -197,6 +278,7 @@ export function Popover({
197
278
  style={{
198
279
  width: width === 'trigger' ? (triggerWidth ?? undefined) : width,
199
280
  maxHeight: maxHeight ?? undefined,
281
+ ...(horizontalOffset != null ? { left: horizontalOffset, right: 'auto' } : {}),
200
282
  ...panelStyle,
201
283
  }}
202
284
  >