@sovereignfs/ui 0.54.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 (277) 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/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -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
  >
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ import { StrictMode, useState } from 'react';
2
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
4
  import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
5
  import { Popover } from '../Popover';
@@ -76,6 +77,53 @@ describe('Popover', () => {
76
77
  expect(onClose).toHaveBeenCalledOnce();
77
78
  });
78
79
 
80
+ describe('Escape precedence with a nested Popover', () => {
81
+ it('defers Escape to a nested open Popover instead of closing the outer one', () => {
82
+ const outerOnClose = vi.fn();
83
+
84
+ function NestedHarness() {
85
+ // Starts closed and opens via a later click, matching the real
86
+ // sequence (e.g. a date-picker Popover opened from a field inside a
87
+ // Combobox's own popover content) — mounting both already-open in
88
+ // the same initial render would instead have React fire the child's
89
+ // registration effect before the parent's in that one shared commit,
90
+ // the one case this stack-order approach doesn't handle (see the
91
+ // equivalent Dialog+ConfirmDialog regression test's own note).
92
+ const [innerOpen, setInnerOpen] = useState(false);
93
+ return (
94
+ <Popover open={true} onClose={outerOnClose} aria-label="Outer" trigger={trigger}>
95
+ <button type="button" onClick={() => setInnerOpen(true)}>
96
+ Open inner
97
+ </button>
98
+ <Popover
99
+ open={innerOpen}
100
+ onClose={() => setInnerOpen(false)}
101
+ aria-label="Inner"
102
+ trigger={<span />}
103
+ >
104
+ Inner content
105
+ </Popover>
106
+ </Popover>
107
+ );
108
+ }
109
+
110
+ render(<NestedHarness />);
111
+ fireEvent.click(screen.getByRole('button', { name: 'Open inner' }));
112
+ expect(screen.getByRole('dialog', { name: 'Inner' })).toBeTruthy();
113
+
114
+ // The nested Popover is topmost — the outer Popover's own Escape
115
+ // listener must not act while it's open.
116
+ fireEvent.keyDown(document, { key: 'Escape' });
117
+ expect(outerOnClose).not.toHaveBeenCalled();
118
+ expect(screen.queryByRole('dialog', { name: 'Inner' })).toBeNull();
119
+
120
+ // With the inner one dismissed, the outer Popover is topmost again —
121
+ // a second Escape now reaches it.
122
+ fireEvent.keyDown(document, { key: 'Escape' });
123
+ expect(outerOnClose).toHaveBeenCalledOnce();
124
+ });
125
+ });
126
+
79
127
  it('does not call onClose when clicking inside the panel', () => {
80
128
  const onClose = vi.fn();
81
129
  render(
@@ -95,4 +143,121 @@ describe('Popover', () => {
95
143
  );
96
144
  expect((screen.getByRole('dialog') as HTMLElement).style.width).toBe('320px');
97
145
  });
146
+
147
+ describe('horizontal viewport clamp', () => {
148
+ const rect = (partial: Partial<DOMRect>): DOMRect =>
149
+ ({
150
+ top: 0,
151
+ left: 0,
152
+ right: 0,
153
+ bottom: 0,
154
+ width: 0,
155
+ height: 0,
156
+ x: 0,
157
+ y: 0,
158
+ toJSON() {},
159
+ ...partial,
160
+ }) as DOMRect;
161
+
162
+ afterEach(() => {
163
+ vi.restoreAllMocks();
164
+ });
165
+
166
+ it('shifts the panel back on-screen when align="right" would overflow the left edge', () => {
167
+ // A 340px-wide panel right-aligned under a trigger sitting near the
168
+ // right edge of a 375px viewport (e.g. a mobile header bell) — its
169
+ // natural `right: 0` position runs off the left edge, mirroring the
170
+ // real KanbanNotificationBell overflow found live-testing at 375px.
171
+ Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
172
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
173
+ const containerRect = rect({ left: 275, right: 319, top: 12, bottom: 56 });
174
+ const panelRect = rect({
175
+ left: -21,
176
+ right: 319,
177
+ top: 64,
178
+ bottom: 200,
179
+ width: 340,
180
+ height: 136,
181
+ });
182
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
183
+ this: Element,
184
+ ) {
185
+ return this.getAttribute('role') === 'dialog' ? panelRect : containerRect;
186
+ });
187
+
188
+ render(
189
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
190
+ Content
191
+ </Popover>,
192
+ );
193
+
194
+ const panel = screen.getByRole('dialog') as HTMLElement;
195
+ // containerRect.left (275) + style.left must land the panel's left
196
+ // edge at the 4px viewport margin: 4 - 275 = -271.
197
+ expect(panel.style.left).toBe('-271px');
198
+ expect(panel.style.right).toBe('auto');
199
+ });
200
+
201
+ it('leaves alignment untouched when the panel already fits the viewport', () => {
202
+ Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
203
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
204
+ const containerRect = rect({ left: 900, right: 944, top: 12, bottom: 56 });
205
+ const panelRect = rect({
206
+ left: 604,
207
+ right: 944,
208
+ top: 64,
209
+ bottom: 200,
210
+ width: 340,
211
+ height: 136,
212
+ });
213
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
214
+ this: Element,
215
+ ) {
216
+ return this.getAttribute('role') === 'dialog' ? panelRect : containerRect;
217
+ });
218
+
219
+ render(
220
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
221
+ Content
222
+ </Popover>,
223
+ );
224
+
225
+ const panel = screen.getByRole('dialog') as HTMLElement;
226
+ expect(panel.style.left).toBe('');
227
+ expect(panel.style.right).toBe('');
228
+ });
229
+
230
+ it('stays clamped under StrictMode double-invoked layout effects', () => {
231
+ // Regression test: an earlier version of this clamp measured the
232
+ // panel's *current* `left`/`right` off the DOM to decide whether a
233
+ // correction was needed. That's not idempotent — StrictMode's dev-only
234
+ // double-invoke of layout effects re-ran the same measurement against
235
+ // the panel's own just-applied correction, saw a rect that already
236
+ // looked fine, and reset `horizontalOffset` back to null before first
237
+ // paint (found live: the panel visibly overflowed again after HMR).
238
+ // The fix derives the natural position analytically from `containerRect`
239
+ // instead, which StrictMode's replay can't perturb.
240
+ Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
241
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
242
+ const containerRect = rect({ left: 275, right: 319, top: 12, bottom: 56 });
243
+ const panelWidthOnly = rect({ width: 340, height: 136, top: 64, bottom: 200 });
244
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
245
+ this: Element,
246
+ ) {
247
+ return this.getAttribute('role') === 'dialog' ? panelWidthOnly : containerRect;
248
+ });
249
+
250
+ render(
251
+ <StrictMode>
252
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={340} trigger={trigger}>
253
+ Content
254
+ </Popover>
255
+ </StrictMode>,
256
+ );
257
+
258
+ const panel = screen.getByRole('dialog') as HTMLElement;
259
+ expect(panel.style.left).toBe('-271px');
260
+ expect(panel.style.right).toBe('auto');
261
+ });
262
+ });
98
263
  });
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  gap: var(--sv-space-1);
5
5
  padding: var(--sv-space-1);
6
- border: 1px solid var(--sv-color-border-strong);
6
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
7
7
  border-radius: var(--sv-radius-full);
8
8
  background: var(--sv-color-surface-sunken);
9
9
  }
@@ -4,7 +4,7 @@
4
4
  width: 100%;
5
5
  height: 100%;
6
6
  min-height: 320px;
7
- border: 1px solid var(--sv-color-border);
7
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
8
8
  border-radius: var(--sv-radius-lg);
9
9
  overflow: hidden;
10
10
  background: var(--sv-color-surface);
@@ -27,7 +27,7 @@
27
27
  width: 12px;
28
28
  padding: 0;
29
29
  border: 0;
30
- border-inline: 1px solid var(--sv-color-border);
30
+ border-inline: var(--sv-border-width-hairline) solid var(--sv-color-border);
31
31
  cursor: col-resize;
32
32
  background: var(--sv-color-surface-sunken);
33
33
  }
@@ -36,7 +36,7 @@
36
36
  width: 100%;
37
37
  height: 12px;
38
38
  border-inline: 0;
39
- border-block: 1px solid var(--sv-color-border);
39
+ border-block: var(--sv-border-width-hairline) solid var(--sv-color-border);
40
40
  cursor: row-resize;
41
41
  }
42
42
 
@@ -0,0 +1,12 @@
1
+ /* A definite height, not min-height: ThreeColumnLayout/HeaderFooterLayout's
2
+ own .shell use height: 100% internally (fixed box, independently-
3
+ scrolling children via overflow-y: auto) — a percentage height only
4
+ resolves against an ancestor with a definite (not min-constrained)
5
+ height, so min-height here would leave their .shell unable to resolve
6
+ and collapse to content size. This doesn't clip taller content on its
7
+ own — nothing here sets overflow: hidden, so content taller than the
8
+ box simply overflows past it rather than being cut off. */
9
+ .root {
10
+ width: 100%;
11
+ height: 100%;
12
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { RootLayout } from './RootLayout';
3
+
4
+ function Block({ label }: { label: string }) {
5
+ return (
6
+ <div
7
+ style={{
8
+ display: 'flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ height: '100%',
12
+ padding: 'var(--sv-space-4)',
13
+ fontFamily: 'var(--sv-font-family)',
14
+ fontSize: 'var(--sv-font-size-sm)',
15
+ fontWeight: 600,
16
+ color: 'var(--sv-color-text-primary)',
17
+ background: 'var(--sv-color-surface-raised)',
18
+ }}
19
+ >
20
+ {label}
21
+ </div>
22
+ );
23
+ }
24
+
25
+ const meta = {
26
+ title: 'Components/RootLayout',
27
+ component: RootLayout,
28
+ parameters: {
29
+ layout: 'fullscreen',
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "The root-level layout a plugin's page tree sits in. Enforces only structure and dimensions — content always comes from children. Each variant is a fixed web+mobile pairing, not two independent axes; resize the viewport (or check the Mobile story) to see the fork. 'header' keeps the same header+main structure on both breakpoints, but headerHeight (48px, web) and mobileHeaderHeight (60px, mobile) are deliberately different — matching how real shell: minimal plugins do this in production.",
34
+ },
35
+ },
36
+ },
37
+ args: {
38
+ children: <Block label="Main" />,
39
+ },
40
+ } satisfies Meta<typeof RootLayout>;
41
+
42
+ export default meta;
43
+ type Story = StoryObj<typeof meta>;
44
+
45
+ /** variant="plain" is the prop's own default — a single child, no chrome. */
46
+ export const Default: Story = {
47
+ render: () => (
48
+ <div style={{ height: '100vh' }}>
49
+ <RootLayout variant="plain">
50
+ <Block label="Main" />
51
+ </RootLayout>
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ export const Sidebar: Story = {
57
+ render: () => (
58
+ <div style={{ height: '100vh' }}>
59
+ <RootLayout variant="sidebar">
60
+ <Block label="Sidebar" />
61
+ <Block label="Main" />
62
+ </RootLayout>
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ export const SidebarMobile: Story = {
68
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
69
+ render: () => (
70
+ <div style={{ height: '100vh' }}>
71
+ <RootLayout variant="sidebar">
72
+ <Block label="Sidebar (hidden on mobile)" />
73
+ <Block label="Main" />
74
+ </RootLayout>
75
+ </div>
76
+ ),
77
+ };
78
+
79
+ export const Header: Story = {
80
+ render: () => (
81
+ <div style={{ height: '100vh' }}>
82
+ <RootLayout variant="header">
83
+ <Block label="Header (48px)" />
84
+ <Block label="Main" />
85
+ </RootLayout>
86
+ </div>
87
+ ),
88
+ };
89
+
90
+ export const HeaderMobile: Story = {
91
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
92
+ render: () => (
93
+ <div style={{ height: '100vh' }}>
94
+ <RootLayout variant="header">
95
+ <Block label="Header (60px)" />
96
+ <Block label="Main" />
97
+ </RootLayout>
98
+ </div>
99
+ ),
100
+ };
101
+
102
+ export const Shell: Story = {
103
+ render: () => (
104
+ <div style={{ height: '100vh' }}>
105
+ <RootLayout variant="shell">
106
+ <Block label="Header (mobile only)" />
107
+ <Block label="Main" />
108
+ <Block label="Footer (mobile only)" />
109
+ </RootLayout>
110
+ </div>
111
+ ),
112
+ };
113
+
114
+ export const ShellMobile: Story = {
115
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
116
+ render: () => (
117
+ <div style={{ height: '100vh' }}>
118
+ <RootLayout variant="shell">
119
+ <Block label="Header" />
120
+ <Block label="Main" />
121
+ <Block label="Footer" />
122
+ </RootLayout>
123
+ </div>
124
+ ),
125
+ };