@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,44 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useIsOffline } from '../../hooks/useIsOffline';
5
+ import { EmptyState } from '../EmptyState/EmptyState';
6
+
7
+ export interface OfflineGateProps {
8
+ children: ReactNode;
9
+ /** Defaults to a generic "this section" phrasing — pass the surface's own name. */
10
+ surfaceName?: string;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * OfflineGate — blocks administrative/settings surfaces from rendering
16
+ * (possibly stale) cached content while the device has no network, showing an
17
+ * explanatory empty state instead (research 0012, epic task 2.32).
18
+ *
19
+ * Distinct from `OfflineBanner`: the banner is an informational overlay that
20
+ * coexists with normal content everywhere in the shell. This is a hard block
21
+ * for surfaces where operating against stale data is actively wrong — e.g. a
22
+ * Console user list or an Account billing page reflects a point-in-time
23
+ * snapshot the moment it's server-rendered, and any cached copy replayed
24
+ * later has no way to signal that it may already be stale. `OfflineGate`
25
+ * doesn't try to tell staleness apart from freshness; it removes the
26
+ * question by not rendering the content at all while offline.
27
+ *
28
+ * `children` is rendered unconditionally while online, so this adds no cost
29
+ * to the common case — it only decides what to show, never how the wrapped
30
+ * content itself is fetched or cached.
31
+ */
32
+ export function OfflineGate({ children, surfaceName, className }: OfflineGateProps) {
33
+ const isOffline = useIsOffline();
34
+ if (!isOffline) return <>{children}</>;
35
+
36
+ return (
37
+ <EmptyState
38
+ icon="alert-triangle"
39
+ heading="You're offline"
40
+ description={`${surfaceName ?? 'This section'} needs a connection — reconnect to continue.`}
41
+ className={className}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen } from '@testing-library/react';
4
+ import { OfflineGate } from '../OfflineGate';
5
+
6
+ describe('OfflineGate', () => {
7
+ beforeEach(() => {
8
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
9
+ });
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ cleanup();
13
+ });
14
+
15
+ it('renders children unchanged while online', () => {
16
+ render(
17
+ <OfflineGate>
18
+ <p>Live content</p>
19
+ </OfflineGate>,
20
+ );
21
+ expect(screen.getByText('Live content')).toBeDefined();
22
+ });
23
+
24
+ it('replaces children with an explanatory empty state while offline', () => {
25
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
26
+ render(
27
+ <OfflineGate>
28
+ <p>Live content</p>
29
+ </OfflineGate>,
30
+ );
31
+ expect(screen.queryByText('Live content')).toBeNull();
32
+ expect(screen.getByText("You're offline")).toBeDefined();
33
+ });
34
+
35
+ it('names the surface in the offline message when provided', () => {
36
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
37
+ render(
38
+ <OfflineGate surfaceName="Console">
39
+ <p>Live content</p>
40
+ </OfflineGate>,
41
+ );
42
+ expect(screen.getByText(/Console needs a connection/)).toBeDefined();
43
+ });
44
+
45
+ it('falls back to generic phrasing with no surface name', () => {
46
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
47
+ render(
48
+ <OfflineGate>
49
+ <p>Live content</p>
50
+ </OfflineGate>,
51
+ );
52
+ expect(screen.getByText(/This section needs a connection/)).toBeDefined();
53
+ });
54
+
55
+ it('swaps back to children on reconnect', () => {
56
+ vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
57
+ render(
58
+ <OfflineGate>
59
+ <p>Live content</p>
60
+ </OfflineGate>,
61
+ );
62
+ expect(screen.getByText("You're offline")).toBeDefined();
63
+ act(() => window.dispatchEvent(new Event('online')));
64
+ expect(screen.getByText('Live content')).toBeDefined();
65
+ });
66
+ });
@@ -0,0 +1,20 @@
1
+ .container {
2
+ width: 100%;
3
+ margin-inline: auto;
4
+ }
5
+
6
+ .sm {
7
+ max-width: 640px;
8
+ }
9
+
10
+ .md {
11
+ max-width: 960px;
12
+ }
13
+
14
+ .lg {
15
+ max-width: 1200px;
16
+ }
17
+
18
+ .full {
19
+ max-width: none;
20
+ }
@@ -0,0 +1,31 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ import styles from './PageContainer.module.css';
3
+
4
+ export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
5
+ /** Constrains and centers content width. Adds no padding of its own — the
6
+ * runtime shell already pads plugin content. Defaults to 'md'. */
7
+ maxWidth?: 'sm' | 'md' | 'lg' | 'full';
8
+ children: ReactNode;
9
+ }
10
+
11
+ const maxWidthClass: Record<NonNullable<PageContainerProps['maxWidth']>, string> = {
12
+ sm: styles.sm as string,
13
+ md: styles.md as string,
14
+ lg: styles.lg as string,
15
+ full: styles.full as string,
16
+ };
17
+
18
+ export function PageContainer({
19
+ maxWidth = 'md',
20
+ className,
21
+ children,
22
+ ...rest
23
+ }: PageContainerProps) {
24
+ const cls = [styles.container, maxWidthClass[maxWidth], className].filter(Boolean).join(' ');
25
+
26
+ return (
27
+ <div className={cls} {...rest}>
28
+ {children}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { PageContainer } from '../PageContainer';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('PageContainer', () => {
9
+ it('renders its children', () => {
10
+ render(<PageContainer>content</PageContainer>);
11
+ expect(screen.getByText('content')).toBeDefined();
12
+ });
13
+
14
+ it('defaults to md max-width', () => {
15
+ render(<PageContainer>content</PageContainer>);
16
+ expect(screen.getByText('content').className).toContain('md');
17
+ });
18
+
19
+ it('applies sm, lg, and full max-width classes', () => {
20
+ const { rerender } = render(<PageContainer maxWidth="sm">content</PageContainer>);
21
+ expect(screen.getByText('content').className).toContain('sm');
22
+ rerender(<PageContainer maxWidth="lg">content</PageContainer>);
23
+ expect(screen.getByText('content').className).toContain('lg');
24
+ rerender(<PageContainer maxWidth="full">content</PageContainer>);
25
+ expect(screen.getByText('content').className).toContain('full');
26
+ });
27
+
28
+ it('forwards className and other div props', () => {
29
+ render(
30
+ <PageContainer className="custom" data-testid="container">
31
+ content
32
+ </PageContainer>,
33
+ );
34
+ const el = screen.getByTestId('container');
35
+ expect(el.className).toContain('custom');
36
+ });
37
+ });
@@ -6,13 +6,27 @@ export interface PageHeaderProps {
6
6
  description?: string;
7
7
  action?: ReactNode;
8
8
  className?: string;
9
+ /** Heading level for the title. Defaults to `1` for standalone use; pass
10
+ * `2` or `3` when the page already sits under a shell/plugin `<h1>`, to
11
+ * avoid a duplicate top-level heading. Visual style is unchanged at every
12
+ * level — only the rendered tag changes. */
13
+ headingLevel?: 1 | 2 | 3;
9
14
  }
10
15
 
11
- export function PageHeader({ title, description, action, className }: PageHeaderProps) {
16
+ const HEADING_TAG = { 1: 'h1', 2: 'h2', 3: 'h3' } as const;
17
+
18
+ export function PageHeader({
19
+ title,
20
+ description,
21
+ action,
22
+ className,
23
+ headingLevel = 1,
24
+ }: PageHeaderProps) {
25
+ const Heading = HEADING_TAG[headingLevel];
12
26
  return (
13
27
  <header className={[styles.header, className].filter(Boolean).join(' ')}>
14
28
  <div className={styles.text}>
15
- <h1 className={styles.title}>{title}</h1>
29
+ <Heading className={styles.title}>{title}</Heading>
16
30
  {description && <p className={styles.description}>{description}</p>}
17
31
  </div>
18
32
  {action && <div className={styles.action}>{action}</div>}
@@ -0,0 +1,98 @@
1
+ .nav {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-1);
5
+ }
6
+
7
+ .list {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--sv-space-1);
11
+ margin: 0;
12
+ padding: 0;
13
+ list-style: none;
14
+ }
15
+
16
+ /* Icon-only nav buttons — same footprint as Dialog's close button. */
17
+ .navButton {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ width: var(--sv-space-8);
22
+ height: var(--sv-space-8);
23
+ border: none;
24
+ border-radius: var(--sv-radius-md);
25
+ background: transparent;
26
+ color: var(--sv-color-text-primary);
27
+ cursor: pointer;
28
+ }
29
+
30
+ .navButton:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ @media (hover: hover) {
36
+ .navButton:not(:disabled):hover {
37
+ background: var(--sv-color-surface-sunken);
38
+ }
39
+ }
40
+
41
+ .navButton:focus-visible {
42
+ outline: 2px solid var(--sv-color-focus-ring);
43
+ outline-offset: 2px;
44
+ }
45
+
46
+ @media (pointer: coarse) {
47
+ .navButton {
48
+ width: var(--sv-touch-target-min, 44px);
49
+ height: var(--sv-touch-target-min, 44px);
50
+ }
51
+ }
52
+
53
+ .pageButton {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ min-width: var(--sv-space-8);
58
+ height: var(--sv-space-8);
59
+ padding: 0 var(--sv-space-2);
60
+ border: none;
61
+ border-radius: var(--sv-radius-md);
62
+ background: transparent;
63
+ color: var(--sv-color-text-primary);
64
+ font-size: var(--sv-font-size-sm);
65
+ cursor: pointer;
66
+ }
67
+
68
+ @media (hover: hover) {
69
+ .pageButton:not(.active):hover {
70
+ background: var(--sv-color-surface-sunken);
71
+ }
72
+ }
73
+
74
+ .pageButton:focus-visible {
75
+ outline: 2px solid var(--sv-color-focus-ring);
76
+ outline-offset: 2px;
77
+ }
78
+
79
+ .active {
80
+ background: var(--sv-color-accent);
81
+ color: var(--sv-color-text-on-accent);
82
+ }
83
+
84
+ @media (pointer: coarse) {
85
+ .pageButton {
86
+ min-width: var(--sv-touch-target-min, 44px);
87
+ height: var(--sv-touch-target-min, 44px);
88
+ }
89
+ }
90
+
91
+ .ellipsis {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ min-width: var(--sv-space-8);
96
+ height: var(--sv-space-8);
97
+ color: var(--sv-color-text-muted);
98
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Pagination } from './Pagination';
4
+
5
+ const meta = {
6
+ title: 'Components/Pagination',
7
+ component: Pagination,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Page-number / prev-next control. Shows first, last, and pages near the current one, with an ellipsis for gaps once the page count grows.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ page: 1,
19
+ totalPages: 5,
20
+ onChange: () => {},
21
+ },
22
+ } satisfies Meta<typeof Pagination>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const FewPages: Story = {
30
+ render: (args) => {
31
+ const [page, setPage] = useState(args.page);
32
+ return <Pagination {...args} page={page} totalPages={5} onChange={setPage} />;
33
+ },
34
+ };
35
+
36
+ export const ManyPagesWithEllipsis: Story = {
37
+ render: (_args) => {
38
+ const [page, setPage] = useState(8);
39
+ return <Pagination page={page} totalPages={40} onChange={setPage} />;
40
+ },
41
+ };
42
+
43
+ export const FirstPage: Story = {
44
+ args: { page: 1, totalPages: 10 },
45
+ };
46
+
47
+ export const LastPage: Story = {
48
+ args: { page: 10, totalPages: 10 },
49
+ };
@@ -0,0 +1,87 @@
1
+ import { Icon } from '../Icon/Icon';
2
+ import styles from './Pagination.module.css';
3
+
4
+ export interface PaginationProps {
5
+ /** 1-indexed current page. */
6
+ page: number;
7
+ totalPages: number;
8
+ onChange: (page: number) => void;
9
+ className?: string;
10
+ 'aria-label'?: string;
11
+ }
12
+
13
+ type PageEntry = number | 'ellipsis';
14
+
15
+ /** First page, last page, current page ± 1 sibling, ellipsis for gaps. */
16
+ function getPageList(page: number, totalPages: number): PageEntry[] {
17
+ if (totalPages <= 1) return [1];
18
+
19
+ const siblingCount = 1;
20
+ const startPage = Math.max(2, page - siblingCount);
21
+ const endPage = Math.min(totalPages - 1, page + siblingCount);
22
+
23
+ const pages: PageEntry[] = [1];
24
+ if (startPage > 2) pages.push('ellipsis');
25
+ for (let p = startPage; p <= endPage; p++) {
26
+ pages.push(p);
27
+ }
28
+ if (endPage < totalPages - 1) pages.push('ellipsis');
29
+ pages.push(totalPages);
30
+ return pages;
31
+ }
32
+
33
+ export function Pagination({
34
+ page,
35
+ totalPages,
36
+ onChange,
37
+ className,
38
+ 'aria-label': ariaLabel = 'Pagination',
39
+ }: PaginationProps) {
40
+ const pages = getPageList(page, totalPages);
41
+
42
+ return (
43
+ <nav aria-label={ariaLabel} className={[styles.nav, className].filter(Boolean).join(' ')}>
44
+ <button
45
+ type="button"
46
+ className={styles.navButton}
47
+ onClick={() => onChange(page - 1)}
48
+ disabled={page <= 1}
49
+ aria-label="Previous page"
50
+ >
51
+ <Icon name="chevron-left" size="sm" aria-hidden />
52
+ </button>
53
+ <ul className={styles.list}>
54
+ {pages.map((p, index) =>
55
+ p === 'ellipsis' ? (
56
+ <li key={`ellipsis-${index}`} className={styles.ellipsis} aria-hidden="true">
57
+ …
58
+ </li>
59
+ ) : (
60
+ <li key={p}>
61
+ <button
62
+ type="button"
63
+ className={[styles.pageButton, p === page ? styles.active : '']
64
+ .filter(Boolean)
65
+ .join(' ')}
66
+ onClick={() => onChange(p)}
67
+ aria-current={p === page ? 'page' : undefined}
68
+ aria-label={`Page ${p}`}
69
+ >
70
+ {p}
71
+ </button>
72
+ </li>
73
+ ),
74
+ )}
75
+ </ul>
76
+ <button
77
+ type="button"
78
+ className={styles.navButton}
79
+ onClick={() => onChange(page + 1)}
80
+ disabled={page >= totalPages}
81
+ aria-label="Next page"
82
+ >
83
+ <Icon name="chevron-right" size="sm" aria-hidden />
84
+ </button>
85
+ </nav>
86
+ );
87
+ }
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Pagination } from '../Pagination';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Pagination', () => {
9
+ it('marks the current page with aria-current="page"', () => {
10
+ render(<Pagination page={3} totalPages={5} onChange={() => {}} />);
11
+ expect(screen.getByRole('button', { name: 'Page 3' }).getAttribute('aria-current')).toBe(
12
+ 'page',
13
+ );
14
+ expect(screen.getByRole('button', { name: 'Page 2' }).getAttribute('aria-current')).toBeNull();
15
+ });
16
+
17
+ it('calls onChange with the clicked page number', () => {
18
+ const onChange = vi.fn();
19
+ render(<Pagination page={3} totalPages={5} onChange={onChange} />);
20
+ screen.getByRole('button', { name: 'Page 4' }).click();
21
+ expect(onChange).toHaveBeenCalledWith(4);
22
+ });
23
+
24
+ it('disables the previous button on the first page', () => {
25
+ render(<Pagination page={1} totalPages={5} onChange={() => {}} />);
26
+ expect(screen.getByRole('button', { name: 'Previous page' })).toHaveProperty('disabled', true);
27
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', false);
28
+ });
29
+
30
+ it('disables the next button on the last page', () => {
31
+ render(<Pagination page={5} totalPages={5} onChange={() => {}} />);
32
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', true);
33
+ });
34
+
35
+ it('next/previous buttons step the page by one', () => {
36
+ const onChange = vi.fn();
37
+ render(<Pagination page={3} totalPages={5} onChange={onChange} />);
38
+ screen.getByRole('button', { name: 'Next page' }).click();
39
+ expect(onChange).toHaveBeenLastCalledWith(4);
40
+ screen.getByRole('button', { name: 'Previous page' }).click();
41
+ expect(onChange).toHaveBeenLastCalledWith(2);
42
+ });
43
+
44
+ it('shows an ellipsis and jumps to the last page for a large page count', () => {
45
+ render(<Pagination page={5} totalPages={20} onChange={() => {}} />);
46
+ expect(screen.getByRole('button', { name: 'Page 1' })).toBeDefined();
47
+ expect(screen.getByRole('button', { name: 'Page 20' })).toBeDefined();
48
+ expect(screen.queryAllByText('…').length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it('renders no ellipsis for a small page count', () => {
52
+ render(<Pagination page={2} totalPages={4} onChange={() => {}} />);
53
+ expect(screen.queryAllByText('…').length).toBe(0);
54
+ expect(screen.getAllByRole('button', { name: /^Page \d$/ }).length).toBe(4);
55
+ });
56
+ });
@@ -0,0 +1,20 @@
1
+ .track {
2
+ width: 100%;
3
+ height: 8px;
4
+ border-radius: var(--sv-radius-full);
5
+ background: var(--sv-color-surface-sunken);
6
+ overflow: hidden;
7
+ }
8
+
9
+ .fill {
10
+ height: 100%;
11
+ border-radius: var(--sv-radius-full);
12
+ background: var(--sv-color-accent);
13
+ transition: width 0.2s ease;
14
+ }
15
+
16
+ @media (prefers-reduced-motion: reduce) {
17
+ .fill {
18
+ transition: none;
19
+ }
20
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Progress } from './Progress';
3
+
4
+ const meta = {
5
+ title: 'Components/Progress',
6
+ component: Progress,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Determinate progress bar. role="progressbar" with aria-valuenow/min/max so screen readers announce the current value. Indeterminate state is out of scope.',
13
+ },
14
+ },
15
+ },
16
+ args: {
17
+ value: 40,
18
+ label: 'Upload progress',
19
+ },
20
+ } satisfies Meta<typeof Progress>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const Default: Story = {};
28
+
29
+ export const Empty: Story = {
30
+ args: { value: 0 },
31
+ };
32
+
33
+ export const Complete: Story = {
34
+ args: { value: 100 },
35
+ };
36
+
37
+ export const OverMax: Story = {
38
+ name: 'Clamped (value > 100)',
39
+ args: { value: 140 },
40
+ };
@@ -0,0 +1,35 @@
1
+ import styles from './Progress.module.css';
2
+
3
+ export interface ProgressProps {
4
+ /** 0–100. Values outside that range are clamped. */
5
+ value: number;
6
+ /** Accessible name — Progress has no visible text of its own. */
7
+ label?: string;
8
+ id?: string;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Progress — determinate bar.
14
+ *
15
+ * `role="progressbar"` with `aria-valuenow`/`aria-valuemin`/`aria-valuemax`
16
+ * so screen readers announce the current value. Indeterminate state is out
17
+ * of scope until a consumer needs it.
18
+ */
19
+ export function Progress({ value, label, id, className }: ProgressProps) {
20
+ const clamped = Math.min(100, Math.max(0, value));
21
+
22
+ return (
23
+ <div
24
+ id={id}
25
+ role="progressbar"
26
+ aria-valuenow={clamped}
27
+ aria-valuemin={0}
28
+ aria-valuemax={100}
29
+ aria-label={label}
30
+ className={[styles.track, className].filter(Boolean).join(' ')}
31
+ >
32
+ <div className={styles.fill} style={{ width: `${clamped}%` }} />
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Progress } from '../Progress';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Progress', () => {
9
+ it('exposes progressbar ARIA attributes for the given value', () => {
10
+ render(<Progress value={40} label="Upload progress" />);
11
+ const bar = screen.getByRole('progressbar', { name: 'Upload progress' });
12
+ expect(bar.getAttribute('aria-valuenow')).toBe('40');
13
+ expect(bar.getAttribute('aria-valuemin')).toBe('0');
14
+ expect(bar.getAttribute('aria-valuemax')).toBe('100');
15
+ });
16
+
17
+ it('clamps a value above 100', () => {
18
+ render(<Progress value={140} label="Upload progress" />);
19
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('100');
20
+ });
21
+
22
+ it('clamps a negative value to 0', () => {
23
+ render(<Progress value={-10} label="Upload progress" />);
24
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('0');
25
+ });
26
+
27
+ it('renders without an accessible name when label is omitted', () => {
28
+ render(<Progress value={40} />);
29
+ expect(screen.getByRole('progressbar').getAttribute('aria-label')).toBeNull();
30
+ });
31
+ });