@sovereignfs/ui 0.41.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/LegalLinks.module.css +24 -0
  23. package/dist/Markdown.module.css +49 -0
  24. package/dist/Marker.module.css +33 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/MobileAppsDrawer.module.css +81 -0
  29. package/dist/MobileFooter.module.css +73 -0
  30. package/dist/MobileHeader.module.css +63 -0
  31. package/dist/NavigationMenu.module.css +53 -0
  32. package/dist/PageContainer.module.css +20 -0
  33. package/dist/Pagination.module.css +98 -0
  34. package/dist/Progress.module.css +20 -0
  35. package/dist/RadioGroup.module.css +88 -0
  36. package/dist/Resizable.module.css +70 -0
  37. package/dist/ScrollArea.module.css +20 -0
  38. package/dist/Select.module.css +7 -3
  39. package/dist/Slider.module.css +95 -0
  40. package/dist/SwipableMobileCarousel.module.css +55 -0
  41. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  42. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  43. package/dist/Table.module.css +45 -0
  44. package/dist/Tabs.module.css +6 -1
  45. package/dist/Toggle.module.css +6 -1
  46. package/dist/Typography.module.css +47 -0
  47. package/dist/index.d.ts +1268 -8
  48. package/dist/index.js +3046 -975
  49. package/dist/tokens/primitives.css +47 -8
  50. package/package.json +13 -9
  51. package/src/components/Accordion/Accordion.module.css +3 -0
  52. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  53. package/src/components/Accordion/Accordion.tsx +53 -0
  54. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  55. package/src/components/Alert/Alert.module.css +62 -0
  56. package/src/components/Alert/Alert.stories.tsx +96 -0
  57. package/src/components/Alert/Alert.tsx +58 -0
  58. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  59. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  60. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  61. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  62. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  63. package/src/components/Badge/Badge.module.css +75 -9
  64. package/src/components/Badge/Badge.stories.tsx +109 -1
  65. package/src/components/Badge/Badge.tsx +31 -9
  66. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  67. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  68. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  69. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  70. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  71. package/src/components/Button/Button.module.css +12 -3
  72. package/src/components/Button/Button.stories.tsx +3 -2
  73. package/src/components/Button/Button.tsx +1 -1
  74. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  75. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  76. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  77. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  78. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  79. package/src/components/Card/Card.module.css +6 -0
  80. package/src/components/Checkbox/Checkbox.module.css +3 -1
  81. package/src/components/Collapsible/Collapsible.module.css +70 -0
  82. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  83. package/src/components/Collapsible/Collapsible.tsx +66 -0
  84. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  85. package/src/components/Combobox/Combobox.module.css +122 -0
  86. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  87. package/src/components/Combobox/Combobox.tsx +186 -0
  88. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  89. package/src/components/Command/Command.module.css +80 -0
  90. package/src/components/Command/Command.stories.tsx +104 -0
  91. package/src/components/Command/Command.tsx +168 -0
  92. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  93. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  94. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  95. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  96. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  97. package/src/components/DataTable/DataTable.module.css +28 -0
  98. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  99. package/src/components/DataTable/DataTable.tsx +154 -0
  100. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  101. package/src/components/DatePicker/DatePicker.module.css +6 -2
  102. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  103. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  104. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  105. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  106. package/src/components/Dialog/Dialog.module.css +3 -0
  107. package/src/components/Dialog/Dialog.tsx +8 -59
  108. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  109. package/src/components/Drawer/Drawer.tsx +18 -56
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  111. package/src/components/HoverCard/HoverCard.module.css +3 -0
  112. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  113. package/src/components/HoverCard/HoverCard.tsx +111 -0
  114. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  115. package/src/components/Icon/icons/circle-check.tsx +21 -0
  116. package/src/components/Icon/icons/circle-x.tsx +22 -0
  117. package/src/components/Icon/icons/index.ts +16 -0
  118. package/src/components/Icon/icons/menu.tsx +22 -0
  119. package/src/components/Icon/icons/smartphone.tsx +21 -0
  120. package/src/components/Input/Input.module.css +6 -1
  121. package/src/components/Item/Item.module.css +70 -0
  122. package/src/components/Item/Item.stories.tsx +62 -0
  123. package/src/components/Item/Item.tsx +54 -0
  124. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  125. package/src/components/Kbd/Kbd.module.css +14 -0
  126. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  127. package/src/components/Kbd/Kbd.tsx +12 -0
  128. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  129. package/src/components/Label/Label.module.css +11 -0
  130. package/src/components/Label/Label.stories.tsx +40 -0
  131. package/src/components/Label/Label.tsx +24 -0
  132. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  133. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  134. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  135. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  136. package/src/components/Markdown/Markdown.module.css +49 -0
  137. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  138. package/src/components/Markdown/Markdown.tsx +168 -0
  139. package/src/components/Marker/Marker.module.css +33 -0
  140. package/src/components/Marker/Marker.stories.tsx +39 -0
  141. package/src/components/Marker/Marker.tsx +34 -0
  142. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  143. package/src/components/Menu/Menu.module.css +8 -1
  144. package/src/components/Menu/Menu.tsx +30 -8
  145. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  146. package/src/components/Menubar/Menubar.tsx +42 -0
  147. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  148. package/src/components/Message/Message.module.css +99 -0
  149. package/src/components/Message/Message.stories.tsx +79 -0
  150. package/src/components/Message/Message.tsx +41 -0
  151. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  152. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  153. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  154. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  155. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  156. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  157. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  158. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  159. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  160. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  161. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  162. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  163. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  164. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  165. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  166. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  167. package/src/components/NavTabs/NavTabs.tsx +39 -11
  168. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  169. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  170. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  171. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  172. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  173. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  174. package/src/components/PageContainer/PageContainer.module.css +20 -0
  175. package/src/components/PageContainer/PageContainer.tsx +31 -0
  176. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  177. package/src/components/PageHeader/PageHeader.tsx +16 -2
  178. package/src/components/Pagination/Pagination.module.css +98 -0
  179. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  180. package/src/components/Pagination/Pagination.tsx +87 -0
  181. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  182. package/src/components/Progress/Progress.module.css +20 -0
  183. package/src/components/Progress/Progress.stories.tsx +40 -0
  184. package/src/components/Progress/Progress.tsx +35 -0
  185. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  186. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  187. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  188. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  189. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  190. package/src/components/Resizable/Resizable.module.css +70 -0
  191. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  192. package/src/components/Resizable/Resizable.tsx +313 -0
  193. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  194. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  195. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  196. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  197. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  198. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  199. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  200. package/src/components/Select/Select.module.css +7 -3
  201. package/src/components/Sheet/Sheet.tsx +9 -51
  202. package/src/components/Slider/Slider.module.css +95 -0
  203. package/src/components/Slider/Slider.stories.tsx +64 -0
  204. package/src/components/Slider/Slider.tsx +77 -0
  205. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  206. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  207. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  208. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  209. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  210. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  211. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  212. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  213. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  214. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  215. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  216. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  217. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  218. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  219. package/src/components/Table/Table.module.css +45 -0
  220. package/src/components/Table/Table.stories.tsx +125 -0
  221. package/src/components/Table/Table.tsx +51 -0
  222. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  223. package/src/components/Tabs/Tabs.module.css +6 -1
  224. package/src/components/Toggle/Toggle.module.css +6 -1
  225. package/src/components/Typography/Typography.module.css +47 -0
  226. package/src/components/Typography/Typography.stories.tsx +62 -0
  227. package/src/components/Typography/Typography.tsx +30 -0
  228. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  229. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  230. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  231. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  232. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  233. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  234. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  235. package/src/hooks/index.ts +18 -0
  236. package/src/hooks/useCarouselRouteSync.ts +92 -0
  237. package/src/hooks/useIsOffline.ts +41 -0
  238. package/src/hooks/useOfflineTileState.ts +45 -0
  239. package/src/hooks/useResponsiveLayout.ts +36 -0
  240. package/src/hooks/useSnapCarousel.ts +79 -0
  241. package/src/hooks/useSwipeReveal.ts +107 -0
  242. package/src/index.ts +141 -3
  243. package/src/overlay-shell.ts +94 -0
  244. package/src/stories/DesignSystemOverview.stories.tsx +936 -3
  245. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  246. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  247. package/src/stories/InteractionHooks.stories.tsx +483 -0
  248. package/src/stories/MobilePatterns.stories.tsx +255 -4
  249. package/src/stories/NavTabs.stories.tsx +18 -0
  250. package/src/stories/OfflineGate.stories.tsx +42 -0
  251. package/src/stories/PageContainer.stories.tsx +37 -0
  252. package/src/stories/PageHeader.stories.tsx +11 -0
  253. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  254. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  255. package/src/stories/TokenGallery.stories.tsx +177 -16
  256. package/src/tokens/primitives.css +47 -8
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { MessageScroller } from '../MessageScroller';
5
+
6
+ afterEach(cleanup);
7
+
8
+ /** jsdom doesn't implement layout, so scrollHeight/clientHeight are always 0
9
+ * unless stubbed. Mirrors the common pattern for testing scroll behavior. */
10
+ function stubScrollMetrics(
11
+ el: HTMLElement,
12
+ {
13
+ scrollHeight,
14
+ clientHeight,
15
+ scrollTop,
16
+ }: {
17
+ scrollHeight: number;
18
+ clientHeight: number;
19
+ scrollTop: number;
20
+ },
21
+ ) {
22
+ Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
23
+ Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
24
+ Object.defineProperty(el, 'scrollTop', { value: scrollTop, writable: true, configurable: true });
25
+ }
26
+
27
+ describe('MessageScroller', () => {
28
+ it('auto-scrolls to the bottom when near-bottom and children change', () => {
29
+ const { container, rerender } = render(
30
+ <MessageScroller>
31
+ <div>message 1</div>
32
+ </MessageScroller>,
33
+ );
34
+ const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
35
+ stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
36
+
37
+ rerender(
38
+ <MessageScroller>
39
+ <div>message 1</div>
40
+ <div>message 2</div>
41
+ </MessageScroller>,
42
+ );
43
+
44
+ expect(scrollEl.scrollTop).toBe(500);
45
+ expect(screen.queryByText('New messages ↓')).toBeNull();
46
+ });
47
+
48
+ it('shows a "New messages" button instead of auto-scrolling when scrolled up', () => {
49
+ const { container, rerender } = render(
50
+ <MessageScroller>
51
+ <div>message 1</div>
52
+ </MessageScroller>,
53
+ );
54
+ const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
55
+ // Simulate the user having scrolled up, far from the bottom.
56
+ stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
57
+ fireEvent.scroll(scrollEl);
58
+
59
+ rerender(
60
+ <MessageScroller>
61
+ <div>message 1</div>
62
+ <div>message 2</div>
63
+ </MessageScroller>,
64
+ );
65
+
66
+ expect(screen.getByText('New messages ↓')).toBeDefined();
67
+ });
68
+
69
+ it('scrolling back near the bottom does not force scrollTop (no fighting the user)', () => {
70
+ const { container } = render(
71
+ <MessageScroller>
72
+ <div>message 1</div>
73
+ </MessageScroller>,
74
+ );
75
+ const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
76
+ stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 450 });
77
+ fireEvent.scroll(scrollEl);
78
+
79
+ expect(scrollEl.scrollTop).toBe(450);
80
+ });
81
+
82
+ it('clicking the jump button scrolls to bottom and hides the button', () => {
83
+ const { container, rerender } = render(
84
+ <MessageScroller>
85
+ <div>message 1</div>
86
+ </MessageScroller>,
87
+ );
88
+ const scrollEl = container.querySelector('[class*="scrollContainer"]') as HTMLElement;
89
+ stubScrollMetrics(scrollEl, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 });
90
+ fireEvent.scroll(scrollEl);
91
+ rerender(
92
+ <MessageScroller>
93
+ <div>message 1</div>
94
+ <div>message 2</div>
95
+ </MessageScroller>,
96
+ );
97
+ expect(screen.getByText('New messages ↓')).toBeDefined();
98
+
99
+ scrollEl.scrollTo = vi.fn();
100
+ fireEvent.click(screen.getByText('New messages ↓'));
101
+
102
+ expect(scrollEl.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' });
103
+ expect(screen.queryByText('New messages ↓')).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,81 @@
1
+ /* Matches the runtime shell's own Apps drawer grid exactly
2
+ (runtime/app/(platform)/_components/MobileNav.module.css's .drawerGrid/
3
+ .drawerGridItem/.drawerGridIcon/.drawerGridName) — this component is
4
+ meant to replace that hand-rolled markup for any consumer building its
5
+ own Apps-style launcher. No header row — see the component's own doc
6
+ comment for why. */
7
+
8
+ .grid {
9
+ list-style: none;
10
+ margin: 0;
11
+ /* Drawer's own grab handle already reserves a 44px touch target above this
12
+ — no extra top padding needed beyond a touch of breathing room. Bottom
13
+ gets noticeably more: with no header row to separate the grid from the
14
+ handle, a tight top/bottom balance read as top-heavy (grid crowding the
15
+ handle isn't the issue; the *last* row crowding the panel's bottom
16
+ edge/safe-area is). Drawer's own panel already adds
17
+ env(safe-area-inset-bottom) below everything, so this is on top of that,
18
+ not instead of it. */
19
+ padding: var(--sv-space-1) var(--sv-space-4) var(--sv-space-10);
20
+ display: grid;
21
+ grid-template-columns: repeat(3, 1fr);
22
+ row-gap: var(--sv-space-5);
23
+ }
24
+
25
+ .gridItem {
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ gap: var(--sv-space-2);
30
+ padding: var(--sv-space-1);
31
+ border: none;
32
+ background: none;
33
+ text-decoration: none;
34
+ color: var(--sv-color-text-primary);
35
+ font: inherit;
36
+ cursor: pointer;
37
+ width: 100%;
38
+ }
39
+
40
+ @media (hover: hover) {
41
+ .gridItem:hover .gridIcon {
42
+ background: var(--sv-color-border);
43
+ }
44
+ }
45
+
46
+ .gridItem:focus-visible {
47
+ outline: 2px solid var(--sv-color-focus-ring);
48
+ outline-offset: 2px;
49
+ border-radius: var(--sv-radius-md);
50
+ }
51
+
52
+ /* color: text-primary, not text-muted — the reference's own drawerGridIcon
53
+ uses text-muted only as its monogram-fallback text color for <img>-less
54
+ plugin icons (raster <img> icons don't inherit CSS color at all, so that
55
+ value never actually painted a real icon). Every tile here renders a real
56
+ stroke icon via the Icon component (currentColor), which does inherit —
57
+ matching the reference's own explicit override for that exact case
58
+ (.drawerGridIconSettings, used by its Console tile) rather than the
59
+ fallback default. */
60
+ .gridIcon {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ width: 56px;
65
+ height: 56px;
66
+ border-radius: 14px;
67
+ background: var(--sv-color-surface-sunken);
68
+ font-size: var(--sv-font-size-sm);
69
+ font-weight: var(--sv-font-weight-semibold);
70
+ color: var(--sv-color-text-primary);
71
+ transition: background 0.1s;
72
+ }
73
+
74
+ .gridName {
75
+ font-size: var(--sv-font-size-sm);
76
+ text-align: center;
77
+ color: var(--sv-color-text-primary);
78
+ line-height: 1.3;
79
+ word-break: break-word;
80
+ max-width: 80px;
81
+ }
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Drawer } from '../Drawer/Drawer';
3
+ import styles from './MobileAppsDrawer.module.css';
4
+
5
+ export interface MobileAppsDrawerItem {
6
+ /** Stable identity for this tile — used as the React key. */
7
+ key: string;
8
+ icon: ReactNode;
9
+ label: string;
10
+ /** Renders an `<a>` when set; otherwise a `<button>` calling `onClick`. */
11
+ href?: string;
12
+ onClick?: () => void;
13
+ }
14
+
15
+ export interface MobileAppsDrawerProps {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ /** Accessible name for the drawer panel — required since there's no
19
+ * visible title to fall back to (Drawer's grab handle plus swipe-down /
20
+ * scrim-tap already cover dismissal, so no header row is rendered). */
21
+ 'aria-label': string;
22
+ /** The grid tiles — supplied by the consumer (e.g. installed plugins,
23
+ * a plugin's own sections). MobileAppsDrawer owns only the drawer
24
+ * chrome/grid layout, never the item list itself. */
25
+ items: MobileAppsDrawerItem[];
26
+ }
27
+
28
+ function DrawerGridTile({ icon, label, href, onClick }: Omit<MobileAppsDrawerItem, 'key'>) {
29
+ const content = (
30
+ <>
31
+ <span className={styles.gridIcon} aria-hidden="true">
32
+ {icon}
33
+ </span>
34
+ <span className={styles.gridName}>{label}</span>
35
+ </>
36
+ );
37
+ if (href) {
38
+ return (
39
+ <a href={href} className={styles.gridItem} onClick={onClick}>
40
+ {content}
41
+ </a>
42
+ );
43
+ }
44
+ return (
45
+ <button type="button" className={styles.gridItem} onClick={onClick}>
46
+ {content}
47
+ </button>
48
+ );
49
+ }
50
+
51
+ /**
52
+ * The mobile "Apps" drawer: a bottom sheet with a 3-column grid of icon
53
+ * tiles — same tile size (56px, radius 14px) and grid layout as the runtime
54
+ * shell's own Apps drawer (MobileNav's Drawer content), generalized so the
55
+ * tile list comes from the consumer (the shell passes installed plugins; a
56
+ * plugin composing its own MobileFooter launcher passes whatever its own
57
+ * "apps" are, e.g. a section index) rather than being hardcoded to plugins
58
+ * specifically. No header row (title/close button) — Drawer's own grab
59
+ * handle plus swipe-down/scrim-tap dismissal are enough, and a header ate
60
+ * into the limited vertical space a launcher grid needs most.
61
+ */
62
+ export function MobileAppsDrawer({ open, onClose, items, ...rest }: MobileAppsDrawerProps) {
63
+ const ariaLabel = rest['aria-label'];
64
+
65
+ return (
66
+ <Drawer open={open} onClose={onClose} aria-label={ariaLabel}>
67
+ <nav aria-label={ariaLabel}>
68
+ <ul className={styles.grid}>
69
+ {items.map(({ key, ...item }) => (
70
+ <li key={key}>
71
+ <DrawerGridTile key={key} {...item} />
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </nav>
76
+ </Drawer>
77
+ );
78
+ }
@@ -0,0 +1,80 @@
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 { MobileAppsDrawer } from '../MobileAppsDrawer';
5
+
6
+ // Drawer's exit animation reads prefers-reduced-motion via matchMedia, which
7
+ // jsdom does not implement — see Drawer's own test for the full rationale.
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches: false,
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
+ function installPointerCapture() {
24
+ if (!Element.prototype.setPointerCapture) {
25
+ Element.prototype.setPointerCapture = () => {};
26
+ }
27
+ if (!Element.prototype.releasePointerCapture) {
28
+ Element.prototype.releasePointerCapture = () => {};
29
+ }
30
+ }
31
+
32
+ const items = [
33
+ { key: 'home', icon: <span>icon</span>, label: 'Home', href: '/home' },
34
+ { key: 'notes', icon: <span>icon</span>, label: 'Notes', onClick: vi.fn() },
35
+ ];
36
+
37
+ describe('MobileAppsDrawer', () => {
38
+ beforeEach(() => {
39
+ installMatchMedia();
40
+ installPointerCapture();
41
+ });
42
+ afterEach(() => {
43
+ vi.unstubAllGlobals();
44
+ cleanup();
45
+ });
46
+
47
+ it('renders nothing when closed', () => {
48
+ render(
49
+ <MobileAppsDrawer open={false} onClose={() => {}} aria-label="Sections" items={items} />,
50
+ );
51
+ expect(screen.queryByRole('dialog')).toBeNull();
52
+ });
53
+
54
+ it('renders every item when open, with no header title or close button', () => {
55
+ render(<MobileAppsDrawer open onClose={() => {}} aria-label="Sections" items={items} />);
56
+
57
+ expect(screen.getByRole('dialog', { name: 'Sections' })).toBeDefined();
58
+ expect(screen.getByText('Home')).toBeDefined();
59
+ expect(screen.getByText('Notes')).toBeDefined();
60
+ expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
61
+ });
62
+
63
+ it('renders an href item as a link and an onClick item as a button', () => {
64
+ render(<MobileAppsDrawer open onClose={() => {}} aria-label="Sections" items={items} />);
65
+
66
+ const link = screen.getByText('Home').closest('a');
67
+ expect(link).not.toBeNull();
68
+ expect(link?.getAttribute('href')).toBe('/home');
69
+
70
+ const button = screen.getByText('Notes').closest('button');
71
+ expect(button).not.toBeNull();
72
+ });
73
+
74
+ it('calls the item onClick handler when pressed', () => {
75
+ render(<MobileAppsDrawer open onClose={() => {}} aria-label="Sections" items={items} />);
76
+
77
+ fireEvent.click(screen.getByText('Notes'));
78
+ expect(items[1]?.onClick).toHaveBeenCalledTimes(1);
79
+ });
80
+ });
@@ -0,0 +1,73 @@
1
+ .footer {
2
+ display: flex;
3
+ align-items: center;
4
+ width: 100%;
5
+ /* Mobile-only component — never render wider than the platform's own
6
+ mobile breakpoint, even if a consumer accidentally mounts it in a wider
7
+ container (e.g. an un-gated preview). At real mobile widths this is a
8
+ no-op since the viewport itself is already narrower. */
9
+ max-width: 768px;
10
+ margin-inline: auto;
11
+ min-height: var(--sv-shell-footer-height, 60px);
12
+ padding: 0 var(--sv-space-4);
13
+ padding-bottom: max(var(--sv-space-2), env(safe-area-inset-bottom));
14
+ background-color: var(--sv-color-surface);
15
+ border-top: 1px solid var(--sv-color-border);
16
+ position: relative;
17
+ z-index: 101;
18
+ }
19
+
20
+ .navItem {
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ flex: 1;
25
+ min-height: 52px;
26
+ border: none;
27
+ background: transparent;
28
+ color: var(--sv-color-text-muted);
29
+ font: inherit;
30
+ cursor: pointer;
31
+ text-decoration: none;
32
+ border-radius: var(--sv-radius-md);
33
+ transition: color 0.1s;
34
+ outline: none;
35
+ }
36
+
37
+ @media (hover: hover) {
38
+ .navItem:hover {
39
+ color: var(--sv-color-text-primary);
40
+ }
41
+ }
42
+
43
+ .navItem:focus-visible {
44
+ outline: 2px solid var(--sv-color-focus-ring);
45
+ outline-offset: -2px;
46
+ }
47
+
48
+ .navItemActive {
49
+ color: var(--sv-color-text-primary);
50
+ }
51
+
52
+ .navItemApps {
53
+ position: relative;
54
+ }
55
+
56
+ .navItemAppsOpen {
57
+ color: var(--sv-color-text-primary);
58
+ }
59
+
60
+ .navItemAppsOpen::before {
61
+ content: '';
62
+ position: absolute;
63
+ width: 48px;
64
+ height: 48px;
65
+ border-radius: var(--sv-radius-full);
66
+ background: var(--sv-color-surface-sunken);
67
+ }
68
+
69
+ .navItemAppsOpen svg,
70
+ .navItemAppsOpen img {
71
+ position: relative;
72
+ z-index: 1;
73
+ }
@@ -0,0 +1,47 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { MobileFooter } from './MobileFooter';
4
+ import { Icon } from '../Icon/Icon';
5
+
6
+ const homeIcon = { icon: <Icon name="house" size="md" aria-hidden />, label: 'Home', active: true };
7
+ const searchIcon = { icon: <Icon name="search" size="md" aria-hidden />, label: 'Search' };
8
+ const calendarIcon = { icon: <Icon name="calendar" size="md" aria-hidden />, label: 'Calendar' };
9
+ const activityIcon = { icon: <Icon name="activity" size="md" aria-hidden />, label: 'Activity' };
10
+
11
+ const meta = {
12
+ title: 'Components/MobileFooter',
13
+ component: MobileFooter,
14
+ parameters: {
15
+ layout: 'padded',
16
+ docs: {
17
+ description: {
18
+ component:
19
+ 'Mobile shell footer nav row (RFC 0088). The centered launcher (onOpenApps) is always rendered; leftIcons/rightIcons take 1 or 2 items each, symmetric so the launcher stays centered. Presentational only, no data fetching.',
20
+ },
21
+ },
22
+ },
23
+ args: {
24
+ onOpenApps: () => {},
25
+ leftIcons: [homeIcon],
26
+ rightIcons: [searchIcon],
27
+ },
28
+ } satisfies Meta<typeof MobileFooter>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ export const Default: Story = {};
34
+
35
+ export const FiveIcons: Story = {
36
+ args: {
37
+ leftIcons: [homeIcon, calendarIcon],
38
+ rightIcons: [searchIcon, activityIcon],
39
+ },
40
+ };
41
+
42
+ export const LauncherOpen: Story = {
43
+ render: function Render(args) {
44
+ const [open, setOpen] = useState(true);
45
+ return <MobileFooter {...args} launcherOpen={open} onOpenApps={() => setOpen((v) => !v)} />;
46
+ },
47
+ };
@@ -0,0 +1,109 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ import { Icon } from '../Icon/Icon';
3
+ import styles from './MobileFooter.module.css';
4
+
5
+ export interface FooterIcon {
6
+ icon: ReactNode;
7
+ label: string;
8
+ href?: string;
9
+ onClick?: () => void;
10
+ active?: boolean;
11
+ }
12
+
13
+ export interface MobileFooterProps extends HTMLAttributes<HTMLElement> {
14
+ /** Opens the app drawer/launcher. Always rendered as the centered slot;
15
+ * not overridable. */
16
+ onOpenApps: () => void;
17
+ /** Custom launcher icon (e.g. a plugin-supplied image). Defaults to a
18
+ * generic grid icon. */
19
+ launcherIcon?: ReactNode;
20
+ /** Whether the launcher's own drawer is currently open (applies the
21
+ * pressed/active visual state). */
22
+ launcherOpen?: boolean;
23
+ /** 1 or 2 icons rendered to the left of the centered launcher. */
24
+ leftIcons: FooterIcon[];
25
+ /** 1 or 2 icons rendered to the right of the centered launcher. Must match
26
+ * `leftIcons.length` — the launcher stays visually centered, so a mismatch
27
+ * is flagged (dev-mode only, never thrown) rather than silently allowed. */
28
+ rightIcons: FooterIcon[];
29
+ }
30
+
31
+ function FooterNavItem({ icon, label, href, onClick, active }: FooterIcon) {
32
+ const className = [styles.navItem, active ? styles.navItemActive : ''].filter(Boolean).join(' ');
33
+ if (href) {
34
+ return (
35
+ <a
36
+ href={href}
37
+ className={className}
38
+ aria-label={label}
39
+ aria-current={active ? 'page' : undefined}
40
+ >
41
+ {icon}
42
+ </a>
43
+ );
44
+ }
45
+ return (
46
+ <button type="button" className={className} aria-label={label} onClick={onClick}>
47
+ {icon}
48
+ </button>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The mobile footer bar: a centered, fixed "Apps" launcher flanked by 1 or 2
54
+ * overridable icons on each side (3 or 5 total, matching standard
55
+ * iOS/Android bottom-nav conventions). Owns its own chrome — background,
56
+ * border-top, safe-area-aware bottom padding, and a 768px max-width (so it
57
+ * never stretches full-bleed if mounted outside an actual mobile viewport)
58
+ * — matching the runtime shell's own mobile footer (MobileNav) exactly,
59
+ * since this component is meant to replace that hand-rolled markup.
60
+ * Presentational only — no data fetching; the consumer owns the
61
+ * drawer/overlay `onOpenApps` opens. See RFC 0088 for the
62
+ * immutable/overridable boundary this enforces.
63
+ */
64
+ export function MobileFooter({
65
+ onOpenApps,
66
+ launcherIcon,
67
+ launcherOpen,
68
+ leftIcons,
69
+ rightIcons,
70
+ className,
71
+ ...rest
72
+ }: MobileFooterProps) {
73
+ if (process.env.NODE_ENV !== 'production' && leftIcons.length !== rightIcons.length) {
74
+ console.error(
75
+ `MobileFooter: leftIcons (${leftIcons.length}) and rightIcons (${rightIcons.length}) ` +
76
+ 'must have the same length so the launcher stays visually centered.',
77
+ );
78
+ }
79
+
80
+ const cls = [styles.footer, className].filter(Boolean).join(' ');
81
+ const launcherClassName = [
82
+ styles.navItem,
83
+ styles.navItemApps,
84
+ launcherOpen ? styles.navItemAppsOpen : '',
85
+ ]
86
+ .filter(Boolean)
87
+ .join(' ');
88
+
89
+ return (
90
+ <nav {...rest} className={cls} aria-label="App navigation">
91
+ {leftIcons.map((item) => (
92
+ <FooterNavItem key={item.label} {...item} />
93
+ ))}
94
+ <button
95
+ type="button"
96
+ className={launcherClassName}
97
+ aria-label="Apps"
98
+ aria-expanded={launcherOpen}
99
+ aria-haspopup="dialog"
100
+ onClick={onOpenApps}
101
+ >
102
+ {launcherIcon ?? <Icon name="grid-2x2" size="md" aria-hidden />}
103
+ </button>
104
+ {rightIcons.map((item) => (
105
+ <FooterNavItem key={item.label} {...item} />
106
+ ))}
107
+ </nav>
108
+ );
109
+ }
@@ -0,0 +1,71 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { MobileFooter } from '../MobileFooter';
5
+
6
+ afterEach(() => {
7
+ cleanup();
8
+ vi.unstubAllEnvs();
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ const oneIcon = [{ icon: <span>icon</span>, label: 'Home' }];
13
+ const twoIcons = [
14
+ { icon: <span>icon</span>, label: 'Home' },
15
+ { icon: <span>icon</span>, label: 'Calendar' },
16
+ ];
17
+ const leftTwo = [
18
+ { icon: <span>icon</span>, label: 'Home' },
19
+ { icon: <span>icon</span>, label: 'Calendar' },
20
+ ];
21
+ const rightTwo = [
22
+ { icon: <span>icon</span>, label: 'Search' },
23
+ { icon: <span>icon</span>, label: 'Activity' },
24
+ ];
25
+
26
+ describe('MobileFooter', () => {
27
+ it('always renders the centered launcher button', () => {
28
+ render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />);
29
+
30
+ expect(screen.getByRole('button', { name: 'Apps' })).toBeDefined();
31
+ });
32
+
33
+ it('calls onOpenApps when the launcher is pressed', () => {
34
+ const onOpenApps = vi.fn();
35
+ render(<MobileFooter onOpenApps={onOpenApps} leftIcons={oneIcon} rightIcons={oneIcon} />);
36
+
37
+ fireEvent.click(screen.getByRole('button', { name: 'Apps' }));
38
+ expect(onOpenApps).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it('renders 1+1 and 2+2 icon layouts', () => {
42
+ const { rerender } = render(
43
+ <MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={oneIcon} />,
44
+ );
45
+ expect(screen.getAllByRole('button').length).toBe(3);
46
+
47
+ rerender(<MobileFooter onOpenApps={() => {}} leftIcons={leftTwo} rightIcons={rightTwo} />);
48
+ expect(screen.getByLabelText('Calendar')).toBeDefined();
49
+ expect(screen.getByLabelText('Activity')).toBeDefined();
50
+ });
51
+
52
+ it('logs a dev-mode console.error when left/right icon counts mismatch, but never throws', () => {
53
+ vi.stubEnv('NODE_ENV', 'development');
54
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
55
+
56
+ expect(() =>
57
+ render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={twoIcons} />),
58
+ ).not.toThrow();
59
+
60
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('leftIcons'));
61
+ });
62
+
63
+ it('does not call console.error in production even with mismatched counts', () => {
64
+ vi.stubEnv('NODE_ENV', 'production');
65
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
66
+
67
+ render(<MobileFooter onOpenApps={() => {}} leftIcons={oneIcon} rightIcons={twoIcons} />);
68
+
69
+ expect(errorSpy).not.toHaveBeenCalled();
70
+ });
71
+ });