@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
@@ -8,8 +8,11 @@
8
8
  .select {
9
9
  display: block;
10
10
  width: 100%;
11
- /* Right-pad to clear the custom chevron */
12
- padding: var(--sv-space-2) var(--sv-space-8) var(--sv-space-2) var(--sv-space-3);
11
+ /* 36px rendered height. Explicit height + border-box, same technique as
12
+ Input. Right-pad to clear the custom chevron. */
13
+ height: 36px;
14
+ box-sizing: border-box;
15
+ padding: 4px var(--sv-space-8) 4px var(--sv-space-3);
13
16
  font-family: var(--sv-font-family);
14
17
  font-size: var(--sv-font-size-sm);
15
18
  line-height: 1.5;
@@ -25,8 +28,9 @@
25
28
  box-shadow 0.15s ease;
26
29
  }
27
30
 
28
- /* sm — compact for table cells and inline controls */
31
+ /* sm — compact for table cells and inline controls. 32px height. */
29
32
  .sm .select {
33
+ height: 32px;
30
34
  padding: 2px var(--sv-space-6) 2px var(--sv-space-2);
31
35
  font-size: var(--sv-font-size-sm);
32
36
  line-height: 1.4;
@@ -1,8 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useEffect, useRef } from 'react';
4
- import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
3
+ import { type ReactNode, useRef } from 'react';
5
4
  import { useMountTransition, usePrefersReducedMotion } from '../../motion';
5
+ import {
6
+ useOverlayFocusCapture,
7
+ useOverlayKeyboardTrap,
8
+ useOverlayScrollLock,
9
+ } from '../../overlay-shell';
6
10
  import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
7
11
  import styles from './Sheet.module.css';
8
12
 
@@ -36,9 +40,6 @@ export interface SheetProps {
36
40
  children: ReactNode;
37
41
  }
38
42
 
39
- const FOCUSABLE =
40
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
41
-
42
43
  // Matches --sv-motion-duration-base (Sheet.module.css) — see Dialog.tsx's
43
44
  // identical constant for why this stays a plain JS number.
44
45
  const MOTION_DURATION_MS = 250;
@@ -71,55 +72,12 @@ export function Sheet({
71
72
  children,
72
73
  }: SheetProps) {
73
74
  const panelRef = useRef<HTMLDivElement>(null);
74
- const previouslyFocused = useRef<HTMLElement | null>(null);
75
75
  const reducedMotion = usePrefersReducedMotion();
76
76
  const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
77
77
 
78
- useEffect(() => {
79
- if (!mounted) return;
80
- lockBodyScroll();
81
- return unlockBodyScroll;
82
- }, [mounted]);
83
-
84
- useEffect(() => {
85
- if (!mounted) return;
86
- previouslyFocused.current = document.activeElement as HTMLElement | null;
87
- const panel = panelRef.current;
88
- const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
89
- (first ?? panel)?.focus();
90
- return () => previouslyFocused.current?.focus();
91
- }, [mounted]);
92
-
93
- useEffect(() => {
94
- if (!open) return;
95
- function handleKeyDown(e: KeyboardEvent) {
96
- if (e.key === 'Escape') {
97
- e.stopPropagation();
98
- onClose();
99
- return;
100
- }
101
- if (e.key !== 'Tab') return;
102
- const panel = panelRef.current;
103
- if (!panel) return;
104
- const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
105
- const first = focusable[0];
106
- const last = focusable.at(-1);
107
- if (!first || !last) {
108
- e.preventDefault();
109
- return;
110
- }
111
- const active = document.activeElement;
112
- if (e.shiftKey && active === first) {
113
- e.preventDefault();
114
- last.focus();
115
- } else if (!e.shiftKey && active === last) {
116
- e.preventDefault();
117
- first.focus();
118
- }
119
- }
120
- document.addEventListener('keydown', handleKeyDown);
121
- return () => document.removeEventListener('keydown', handleKeyDown);
122
- }, [open, onClose]);
78
+ useOverlayScrollLock(mounted);
79
+ useOverlayFocusCapture(panelRef, mounted);
80
+ useOverlayKeyboardTrap(panelRef, open, onClose);
123
81
 
124
82
  if (!mounted) return null;
125
83
 
@@ -0,0 +1,95 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--sv-space-2);
5
+ }
6
+
7
+ .label {
8
+ font-size: var(--sv-font-size-sm);
9
+ color: var(--sv-color-text-primary);
10
+ }
11
+
12
+ .slider {
13
+ appearance: none;
14
+ width: 100%;
15
+ height: 20px;
16
+ background: transparent;
17
+ cursor: pointer;
18
+ margin: 0;
19
+ }
20
+
21
+ /* Expand the vertical hit area for touch dragging without thickening the
22
+ visible track — same rationale as Button/Checkbox's touch-target
23
+ expansion, gated to the primary pointer. */
24
+ @media (pointer: coarse) {
25
+ .slider {
26
+ height: var(--sv-touch-target-min, 44px);
27
+ }
28
+ }
29
+
30
+ .slider:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ .slider:focus-visible {
36
+ outline: 2px solid var(--sv-color-focus-ring);
37
+ outline-offset: 2px;
38
+ }
39
+
40
+ /* Track — WebKit/Blink. The filled portion is a gradient computed from the
41
+ --slider-fill custom property the component sets inline; no JS-measured
42
+ pixel math, just a CSS variable driving a hard color stop. */
43
+ .slider::-webkit-slider-runnable-track {
44
+ height: 4px;
45
+ border-radius: var(--sv-radius-full);
46
+ background: linear-gradient(
47
+ to right,
48
+ var(--sv-color-accent) var(--slider-fill),
49
+ var(--sv-color-border-strong) var(--slider-fill)
50
+ );
51
+ }
52
+
53
+ .slider::-webkit-slider-thumb {
54
+ appearance: none;
55
+ width: 16px;
56
+ height: 16px;
57
+ margin-top: -6px;
58
+ border-radius: var(--sv-radius-full);
59
+ background: var(--sv-color-accent);
60
+ box-shadow: var(--sv-shadow-control);
61
+ cursor: pointer;
62
+ }
63
+
64
+ .slider:disabled::-webkit-slider-thumb {
65
+ cursor: not-allowed;
66
+ }
67
+
68
+ /* Track — Firefox. Separate track/progress pseudo-elements instead of a
69
+ gradient — Firefox renders the filled portion natively via
70
+ ::-moz-range-progress. */
71
+ .slider::-moz-range-track {
72
+ height: 4px;
73
+ border-radius: var(--sv-radius-full);
74
+ background: var(--sv-color-border-strong);
75
+ }
76
+
77
+ .slider::-moz-range-progress {
78
+ height: 4px;
79
+ border-radius: var(--sv-radius-full);
80
+ background: var(--sv-color-accent);
81
+ }
82
+
83
+ .slider::-moz-range-thumb {
84
+ width: 16px;
85
+ height: 16px;
86
+ border: none;
87
+ border-radius: var(--sv-radius-full);
88
+ background: var(--sv-color-accent);
89
+ box-shadow: var(--sv-shadow-control);
90
+ cursor: pointer;
91
+ }
92
+
93
+ .slider:disabled::-moz-range-thumb {
94
+ cursor: not-allowed;
95
+ }
@@ -0,0 +1,64 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Slider } from './Slider';
4
+
5
+ const meta = {
6
+ title: 'Components/Slider',
7
+ component: Slider,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Single-thumb range input. A native <input type="range"> under custom styling — arrow keys, Home/End, and touch-drag all come from the browser.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ value: 50,
19
+ onChange: () => {},
20
+ min: 0,
21
+ max: 100,
22
+ 'aria-label': 'Value',
23
+ },
24
+ } satisfies Meta<typeof Slider>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const Default: Story = {
32
+ render: (args) => {
33
+ const [value, setValue] = useState(args.value);
34
+ return <Slider {...args} value={value} onChange={setValue} />;
35
+ },
36
+ };
37
+
38
+ export const WithLabel: Story = {
39
+ render: (args) => {
40
+ const [value, setValue] = useState(args.value);
41
+ return <Slider {...args} label="Volume" value={value} onChange={setValue} />;
42
+ },
43
+ };
44
+
45
+ export const AtMin: Story = {
46
+ args: { value: 0 },
47
+ };
48
+
49
+ export const AtMax: Story = {
50
+ args: { value: 100 },
51
+ };
52
+
53
+ export const SteppedRange: Story = {
54
+ render: (_args) => {
55
+ const [value, setValue] = useState(5);
56
+ return (
57
+ <Slider value={value} onChange={setValue} min={0} max={10} step={1} label="Rating (0–10)" />
58
+ );
59
+ },
60
+ };
61
+
62
+ export const Disabled: Story = {
63
+ args: { disabled: true },
64
+ };
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
4
+ import type { CSSProperties } from 'react';
5
+ import styles from './Slider.module.css';
6
+
7
+ export interface SliderProps {
8
+ value: number;
9
+ onChange: (value: number) => void;
10
+ min: number;
11
+ max: number;
12
+ step?: number;
13
+ /** Renders a visible label above the track. Omit and pass `aria-label`
14
+ * instead for a standalone, unlabeled slider. */
15
+ label?: string;
16
+ disabled?: boolean;
17
+ id?: string;
18
+ className?: string;
19
+ 'aria-label'?: string;
20
+ 'aria-describedby'?: string;
21
+ 'aria-invalid'?: boolean;
22
+ required?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Slider — single-thumb range input.
27
+ *
28
+ * A native `<input type="range">` under custom track/thumb styling — arrow
29
+ * key (adjust by `step`), Home/End (jump to min/max), and touch-drag
30
+ * support all come from the browser, nothing hand-rolled. Dual-thumb/range
31
+ * selection is out of scope until a consumer needs it.
32
+ */
33
+ export function Slider({
34
+ value,
35
+ onChange,
36
+ min,
37
+ max,
38
+ step,
39
+ label,
40
+ disabled,
41
+ id,
42
+ className,
43
+ 'aria-label': ariaLabel,
44
+ 'aria-describedby': ariaDescribedBy,
45
+ 'aria-invalid': ariaInvalid,
46
+ required,
47
+ }: SliderProps) {
48
+ const generatedId = useId();
49
+ const inputId = id ?? generatedId;
50
+ const fillPercent = max > min ? ((value - min) / (max - min)) * 100 : 0;
51
+
52
+ return (
53
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
54
+ {label && (
55
+ <label htmlFor={inputId} className={styles.label}>
56
+ {label}
57
+ </label>
58
+ )}
59
+ <input
60
+ id={inputId}
61
+ type="range"
62
+ min={min}
63
+ max={max}
64
+ step={step}
65
+ value={value}
66
+ disabled={disabled}
67
+ required={required}
68
+ aria-label={label ? undefined : ariaLabel}
69
+ aria-describedby={ariaDescribedBy}
70
+ aria-invalid={ariaInvalid}
71
+ className={styles.slider}
72
+ style={{ '--slider-fill': `${fillPercent}%` } as CSSProperties}
73
+ onChange={(e) => onChange(Number(e.target.value))}
74
+ />
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,45 @@
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 { Slider } from '../Slider';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Slider', () => {
9
+ it('renders a range input with the given min/max/value', () => {
10
+ render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />);
11
+ const input = screen.getByRole('slider') as HTMLInputElement;
12
+ expect(input.min).toBe('0');
13
+ expect(input.max).toBe('100');
14
+ expect(input.value).toBe('50');
15
+ });
16
+
17
+ it('calls onChange with a number when the value changes', () => {
18
+ const onChange = vi.fn();
19
+ render(<Slider value={50} onChange={onChange} min={0} max={100} aria-label="Volume" />);
20
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '75' } });
21
+ expect(onChange).toHaveBeenCalledWith(75);
22
+ });
23
+
24
+ it('respects a step prop', () => {
25
+ render(<Slider value={5} onChange={() => {}} min={0} max={10} step={5} aria-label="Volume" />);
26
+ expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('5');
27
+ });
28
+
29
+ it('renders a visible label and associates it via htmlFor', () => {
30
+ render(<Slider value={50} onChange={() => {}} min={0} max={100} label="Volume" />);
31
+ expect(screen.getByRole('slider', { name: 'Volume' })).toBeDefined();
32
+ });
33
+
34
+ it('falls back to aria-label when no visible label is given', () => {
35
+ render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />);
36
+ expect(screen.getByRole('slider', { name: 'Volume' })).toBeDefined();
37
+ });
38
+
39
+ it('disables the input when disabled is set', () => {
40
+ render(
41
+ <Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" disabled />,
42
+ );
43
+ expect(screen.getByRole('slider')).toHaveProperty('disabled', true);
44
+ });
45
+ });
@@ -0,0 +1,55 @@
1
+ .wrap {
2
+ position: relative;
3
+ height: 100%;
4
+ width: 100%;
5
+ }
6
+
7
+ .scroller {
8
+ height: 100%;
9
+ width: 100%;
10
+ display: flex;
11
+ overflow-x: auto;
12
+ overflow-y: hidden;
13
+ scroll-snap-type: x mandatory;
14
+ /* Momentum + rubber-banding come from the browser's native scroll physics —
15
+ no hand-rolled pointer/touch dragging. Deliberately NOT
16
+ -webkit-overflow-scrolling: touch: that legacy iOS <13 hint promotes its
17
+ element to its own compositing layer for GPU-accelerated momentum
18
+ scroll, and that layer's paint order relative to siblings/fixed
19
+ descendants elsewhere in the tree isn't always reliable on real iOS
20
+ Safari (confirmed via sovereign-tasks' and sovereign-shopper's own
21
+ carousels, which hit and fixed this exact bug — a position:fixed
22
+ overlay opened above the scroller painted BEHIND it instead of above).
23
+ Removing it doesn't disable momentum scrolling on any iOS version this
24
+ platform realistically targets. */
25
+ }
26
+
27
+ .slide {
28
+ flex: 0 0 100%;
29
+ width: 100%;
30
+ height: 100%;
31
+ min-width: 0;
32
+ overflow: hidden;
33
+ scroll-snap-align: start;
34
+ scroll-snap-stop: always;
35
+ /* Slide content is a flex column so Header/Footer stay their natural size
36
+ and Body (flex: 1 1 auto, min-height: 0) fills and scrolls the rest —
37
+ see SwipableMobileCarouselSlideParts.module.css. */
38
+ display: flex;
39
+ flex-direction: column;
40
+ }
41
+
42
+ .dots {
43
+ /* Overlays the bottom of .wrap rather than taking its own layout row —
44
+ matches both sovereign-tasks' and sovereign-shopper's existing dots
45
+ placement. */
46
+ position: absolute;
47
+ left: 0;
48
+ right: 0;
49
+ bottom: var(--sv-space-3);
50
+ pointer-events: none;
51
+ }
52
+
53
+ .dots > * {
54
+ pointer-events: auto;
55
+ }
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Children,
5
+ isValidElement,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { useSnapCarousel } from '../../hooks/useSnapCarousel';
14
+ import { SwipableMobileCarouselDots } from '../SwipableMobileCarouselDots/SwipableMobileCarouselDots';
15
+ import { SwipableMobileCarouselSlide } from './SwipableMobileCarouselSlide';
16
+ import { CarouselSlideMountContext } from './context';
17
+ import styles from './SwipableMobileCarousel.module.css';
18
+
19
+ export interface SwipableMobileCarouselIndicatorProps {
20
+ count: number;
21
+ activeIndex: number;
22
+ labels: (string | undefined)[];
23
+ onJump: (index: number) => void;
24
+ }
25
+
26
+ export interface SwipableMobileCarouselProps {
27
+ /** Controlled active slide index — the caller owns this (typically via
28
+ * useCarouselRouteSync, or plain useState). */
29
+ activeIndex: number;
30
+ /** Called when a swipe gesture settles on a different index, or when the
31
+ * reorder-jump fix re-snaps after the active slide's position changes.
32
+ * The caller is responsible for updating `activeIndex` in response. */
33
+ onSettle: (index: number) => void;
34
+ /** How many slides on each side of the active one stay mounted. Default 1
35
+ * matches both sovereign-tasks' and sovereign-shopper's existing "swipe
36
+ * never shows a spinner" behavior. Slides outside this window keep their
37
+ * DOM slot (so scroll-snap's position-based indexing stays correct) but
38
+ * their children are not mounted at all. */
39
+ prefetchDistance?: number;
40
+ /** Passed straight through to the internal useSnapCarousel. */
41
+ settleDebounceMs?: number;
42
+ /** Required — labels the scroller region for assistive tech. No generic
43
+ * fallback, since one would be wrong for every actual consumer. */
44
+ 'aria-label': string;
45
+ /** Renders the indicator. Defaults to SwipableMobileCarouselDots. Pass
46
+ * `null` to render no indicator at all. */
47
+ renderIndicator?: null | ((props: SwipableMobileCarouselIndicatorProps) => ReactNode);
48
+ className?: string;
49
+ /** Must be SwipableMobileCarouselSlide elements (nullish/boolean children
50
+ * are safely skipped for conditional slides). A dev-mode warning is
51
+ * logged for any other child type — see resolveSlides below. */
52
+ children: ReactNode;
53
+ }
54
+
55
+ interface ResolvedSlide {
56
+ key: string;
57
+ label: string | undefined;
58
+ element: ReactElement;
59
+ }
60
+
61
+ function describeChild(child: unknown): string {
62
+ if (isValidElement(child)) {
63
+ const type = child.type;
64
+ if (typeof type === 'string') return `<${type}>`;
65
+ if (typeof type === 'function') {
66
+ const named = type as { displayName?: string; name?: string };
67
+ return `<${named.displayName ?? named.name ?? 'Component'}>`;
68
+ }
69
+ return '<unknown>';
70
+ }
71
+ if (typeof child === 'string') return `string "${child}"`;
72
+ return String(child);
73
+ }
74
+
75
+ function resolveSlides(children: ReactNode): ResolvedSlide[] {
76
+ const slides: ResolvedSlide[] = [];
77
+ Children.forEach(children, (child, index) => {
78
+ if (child === null || child === undefined || child === false || child === true) return;
79
+ if (!isValidElement(child) || child.type !== SwipableMobileCarouselSlide) {
80
+ if (process.env.NODE_ENV !== 'production') {
81
+ console.error(
82
+ '[SwipableMobileCarousel] Every child must be a <SwipableMobileCarouselSlide>. ' +
83
+ `Found ${describeChild(child)} at position ${index}. A non-Slide child shifts every ` +
84
+ "subsequent slide's index, desyncing scroll-snap position from activeIndex.",
85
+ );
86
+ }
87
+ return;
88
+ }
89
+ const slideProps = child.props as { slideKey: string; label?: string };
90
+ if (process.env.NODE_ENV !== 'production' && !slideProps.label) {
91
+ console.warn(
92
+ `[SwipableMobileCarousel] SwipableMobileCarouselSlide "${slideProps.slideKey}" has no ` +
93
+ 'label — it will show as "Slide N of count" in the default dots indicator.',
94
+ );
95
+ }
96
+ slides.push({ key: slideProps.slideKey, label: slideProps.label, element: child });
97
+ });
98
+ return slides;
99
+ }
100
+
101
+ /**
102
+ * SwipableMobileCarousel — a compound component for swiping between full-
103
+ * width slides (each an independent route/view on mobile), wrapping
104
+ * useSnapCarousel. This library's first compound component: every other
105
+ * component here is flat/prop-based, but slide content is large, arbitrary,
106
+ * per-plugin subtrees that need a header-known/body-loading split an
107
+ * array-of-objects prop would only express with worse JSX ergonomics.
108
+ *
109
+ * Owns rendering and mount-window mechanics only — it has no opinion on
110
+ * where slide data lives. Do NOT: (a) aggregate cross-slide data inside a
111
+ * SwipableMobileCarouselSlideBody (that recomputes on every slide render
112
+ * regardless of which slide is active — belongs in the parent, which
113
+ * already knows every slide exists); (b) mount a detail overlay (Sheet,
114
+ * Dialog) inside a Slide's children — mount it as a sibling of
115
+ * SwipableMobileCarousel instead, controlled by the same state that would
116
+ * otherwise drive a routed page's overlay. sovereign-tasks' current
117
+ * hand-rolled carousel does both and is measurably laggier than
118
+ * sovereign-shopper's equivalent, which keeps its overlay in the routed pane.
119
+ */
120
+ export function SwipableMobileCarousel({
121
+ activeIndex,
122
+ onSettle,
123
+ prefetchDistance = 1,
124
+ settleDebounceMs = 120,
125
+ 'aria-label': ariaLabel,
126
+ renderIndicator,
127
+ className,
128
+ children,
129
+ }: SwipableMobileCarouselProps) {
130
+ const slides = useMemo(() => resolveSlides(children), [children]);
131
+ const count = slides.length;
132
+ const clampedActiveIndex = count === 0 ? 0 : Math.max(0, Math.min(count - 1, activeIndex));
133
+
134
+ const { scrollRef, scrollToIndex } = useSnapCarousel({
135
+ itemCount: count,
136
+ onSettle,
137
+ debounceMs: settleDebounceMs,
138
+ });
139
+
140
+ // Initial scroll position, once — subsequent activeIndex changes come
141
+ // either from the user's own swipe (already reflected in DOM scroll
142
+ // position; re-syncing to the same index below is a harmless no-op) or an
143
+ // external navigation, which the effect below scrolls to explicitly.
144
+ const initialActiveIndexRef = useRef(clampedActiveIndex);
145
+ useLayoutEffect(() => {
146
+ scrollToIndex(initialActiveIndexRef.current, 'instant');
147
+ // Runs once on mount only, matching both existing plugins' identical pattern.
148
+ }, []);
149
+
150
+ const didMountActiveIndexSyncRef = useRef(false);
151
+ useEffect(() => {
152
+ if (!didMountActiveIndexSyncRef.current) {
153
+ didMountActiveIndexSyncRef.current = true;
154
+ return;
155
+ }
156
+ scrollToIndex(clampedActiveIndex, 'smooth');
157
+ }, [clampedActiveIndex, scrollToIndex]);
158
+
159
+ // Reorder-jump fix: scroll-snap position tracks DOM order, not slide
160
+ // identity. If the previously-active slide's key still exists but moved
161
+ // (a caller re-sorted their slide array while mounted), re-snap to its
162
+ // new position and report it via onSettle so the caller's activeIndex
163
+ // stays in sync — otherwise scroll position silently desyncs from slide
164
+ // identity. Runs every commit (no dependency array) since it needs to
165
+ // notice any children change, not just a specific prop.
166
+ const prevKeysRef = useRef<string[] | null>(null);
167
+ useLayoutEffect(() => {
168
+ const currentKeys = slides.map((s) => s.key);
169
+ const prevKeys = prevKeysRef.current;
170
+ if (prevKeys) {
171
+ const sameOrder =
172
+ prevKeys.length === currentKeys.length && prevKeys.every((k, i) => k === currentKeys[i]);
173
+ if (!sameOrder) {
174
+ const activeKey = prevKeys[clampedActiveIndex];
175
+ if (activeKey !== undefined) {
176
+ const newIndex = currentKeys.indexOf(activeKey);
177
+ if (newIndex !== -1 && newIndex !== clampedActiveIndex) {
178
+ scrollToIndex(newIndex, 'instant');
179
+ onSettle(newIndex);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ prevKeysRef.current = currentKeys;
185
+ });
186
+
187
+ function handleJump(index: number) {
188
+ // scrollToIndex sets useSnapCarousel's own dedupe ref synchronously, so
189
+ // the natural scroll-settle detection will NOT re-report this index —
190
+ // onSettle must be called directly here, same pairing as the
191
+ // reorder-jump fix above.
192
+ scrollToIndex(index, 'smooth');
193
+ onSettle(index);
194
+ }
195
+
196
+ const labels = slides.map((s) => s.label);
197
+ const indicator =
198
+ renderIndicator === null ? null : renderIndicator ? (
199
+ renderIndicator({ count, activeIndex: clampedActiveIndex, labels, onJump: handleJump })
200
+ ) : (
201
+ <SwipableMobileCarouselDots
202
+ className={styles.dots}
203
+ count={count}
204
+ activeIndex={clampedActiveIndex}
205
+ labels={labels}
206
+ aria-label={ariaLabel}
207
+ onJump={handleJump}
208
+ />
209
+ );
210
+
211
+ return (
212
+ <div className={[styles.wrap, className].filter(Boolean).join(' ')}>
213
+ <div className={styles.scroller} ref={scrollRef} role="region" aria-label={ariaLabel}>
214
+ {slides.map((slide, i) => (
215
+ <div className={styles.slide} key={slide.key}>
216
+ <CarouselSlideMountContext.Provider
217
+ value={{ isMounted: Math.abs(i - clampedActiveIndex) <= prefetchDistance }}
218
+ >
219
+ {slide.element}
220
+ </CarouselSlideMountContext.Provider>
221
+ </div>
222
+ ))}
223
+ </div>
224
+ {indicator}
225
+ </div>
226
+ );
227
+ }