@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,313 @@
1
+ 'use client';
2
+
3
+ import { Children, isValidElement, useEffect, useRef, useState } from 'react';
4
+ import type {
5
+ CSSProperties,
6
+ KeyboardEvent as ReactKeyboardEvent,
7
+ PointerEvent as ReactPointerEvent,
8
+ ReactElement,
9
+ ReactNode,
10
+ } from 'react';
11
+ import styles from './Resizable.module.css';
12
+
13
+ export type ResizableDirection = 'horizontal' | 'vertical';
14
+
15
+ export interface ResizablePanelProps {
16
+ /** Initial size as a percentage of the group's main axis. Defaults to an
17
+ * equal split across all panels in the group. */
18
+ defaultSize?: number;
19
+ /** Minimum size as a percentage. Defaults to 10. */
20
+ minSize?: number;
21
+ /** Maximum size as a percentage. Defaults to 90. */
22
+ maxSize?: number;
23
+ children?: ReactNode;
24
+ className?: string;
25
+ /** Injected by ResizablePanelGroup — not part of the public API. */
26
+ style?: CSSProperties;
27
+ }
28
+
29
+ /** One pane of a ResizablePanelGroup. Sizing is computed and injected by the
30
+ * group (via defaultSize/minSize/maxSize read off this element's props) —
31
+ * this component only renders the scrollable content box. */
32
+ export function ResizablePanel({ children, className, style }: ResizablePanelProps) {
33
+ return (
34
+ <div className={[styles.panel, className].filter(Boolean).join(' ')} style={style}>
35
+ {children}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export interface ResizableHandleProps {
41
+ 'aria-label'?: string;
42
+ /** Injected by ResizablePanelGroup — not part of the public API. */
43
+ direction?: ResizableDirection;
44
+ onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;
45
+ onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void;
46
+ 'aria-valuenow'?: number;
47
+ 'aria-valuemin'?: number;
48
+ 'aria-valuemax'?: number;
49
+ }
50
+
51
+ /** The draggable divider between two ResizablePanels. Placed directly
52
+ * between the two ResizablePanel elements it resizes — the group pairs each
53
+ * handle with its preceding and following panel by position.
54
+ *
55
+ * A `div` with `role="separator"` and `tabIndex=0` (the WAI-ARIA "window
56
+ * splitter" pattern), not a `<button>` — `separator` is a non-interactive
57
+ * role, and a native button's implicit role can't be overridden to it. */
58
+ export function ResizableHandle({
59
+ 'aria-label': ariaLabel = 'Resize panels',
60
+ direction = 'horizontal',
61
+ onPointerDown,
62
+ onKeyDown,
63
+ 'aria-valuenow': valueNow,
64
+ 'aria-valuemin': valueMin,
65
+ 'aria-valuemax': valueMax,
66
+ }: ResizableHandleProps) {
67
+ return (
68
+ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex -- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable separator is the documented exception to role="separator" being non-interactive (https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/). */
69
+ <div
70
+ className={[styles.handle, direction === 'vertical' ? styles.handleVertical : '']
71
+ .filter(Boolean)
72
+ .join(' ')}
73
+ role="separator"
74
+ tabIndex={0}
75
+ aria-orientation={direction === 'horizontal' ? 'vertical' : 'horizontal'}
76
+ aria-label={ariaLabel}
77
+ aria-valuenow={valueNow !== undefined ? Math.round(valueNow) : undefined}
78
+ aria-valuemin={valueMin}
79
+ aria-valuemax={valueMax}
80
+ onPointerDown={onPointerDown}
81
+ onKeyDown={onKeyDown}
82
+ />
83
+ /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
84
+ );
85
+ }
86
+
87
+ export interface ResizablePanelGroupProps {
88
+ direction: ResizableDirection;
89
+ /** Alternating ResizablePanel / ResizableHandle elements — N panels
90
+ * separated by N-1 handles. */
91
+ children: ReactNode;
92
+ className?: string;
93
+ }
94
+
95
+ interface PanelConstraint {
96
+ defaultSize: number;
97
+ minSize: number;
98
+ maxSize: number;
99
+ }
100
+
101
+ const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
102
+
103
+ /**
104
+ * ResizablePanelGroup — a row or column of resizable panes (editor layouts,
105
+ * multi-pane inspectors). Each handle resizes only its two immediate
106
+ * neighbor panels (drag redistributes size between them, clamped to both
107
+ * panels' own min/max, so their combined size is a fixed invariant for the
108
+ * duration of one drag) — the same handle-local model most resizable-panel
109
+ * implementations use, rather than redistributing across the whole group.
110
+ *
111
+ * Desktop-oriented, like NavigationMenu — a drag handle has no direct touch
112
+ * equivalent; panels render at their default sizes on touch rather than
113
+ * attempting a redesign no consumer has asked for yet. For a two-pane
114
+ * layout that also needs a mobile single-column fallback, use SplitPane
115
+ * instead.
116
+ *
117
+ * `direction="vertical"` needs a definite-height ancestor — percentage
118
+ * flex-basis on a column's children only resolves against one.
119
+ */
120
+ export function ResizablePanelGroup({ direction, children, className }: ResizablePanelGroupProps) {
121
+ const elements = Children.toArray(children).filter(isValidElement) as ReactElement<
122
+ ResizablePanelProps | ResizableHandleProps
123
+ >[];
124
+ const panelCount = elements.filter((el) => el.type === ResizablePanel).length;
125
+
126
+ const constraints: PanelConstraint[] = elements
127
+ .filter((el) => el.type === ResizablePanel)
128
+ .map((el) => {
129
+ const props = el.props as ResizablePanelProps;
130
+ return {
131
+ defaultSize: props.defaultSize ?? 100 / panelCount,
132
+ minSize: props.minSize ?? 10,
133
+ maxSize: props.maxSize ?? 90,
134
+ };
135
+ });
136
+
137
+ const [sizes, setSizes] = useState<number[]>(() => constraints.map((c) => c.defaultSize));
138
+
139
+ // A different set of panels rendered (count changed) — re-derive rather
140
+ // than keep stale entries. Legal "adjust state during render" pattern;
141
+ // sizes.length matches constraints.length again on the next render, so
142
+ // this cannot loop.
143
+ if (sizes.length !== constraints.length) {
144
+ setSizes(constraints.map((c) => c.defaultSize));
145
+ }
146
+
147
+ const containerRef = useRef<HTMLDivElement>(null);
148
+ // Always-latest refs so the window-level pointer listeners (registered
149
+ // once, see below) never read stale values without needing to be
150
+ // re-registered on every render.
151
+ const constraintsRef = useRef(constraints);
152
+ constraintsRef.current = constraints;
153
+ const directionRef = useRef(direction);
154
+ directionRef.current = direction;
155
+
156
+ const dragState = useRef<{
157
+ handleIndex: number;
158
+ pointerId: number;
159
+ startPos: number;
160
+ startA: number;
161
+ combined: number;
162
+ } | null>(null);
163
+
164
+ function applyResize(handleIndex: number, nextA: number, combined: number) {
165
+ const a = constraintsRef.current[handleIndex];
166
+ const b = constraintsRef.current[handleIndex + 1];
167
+ if (!a || !b) return;
168
+ const clampedA = clamp(
169
+ nextA,
170
+ Math.max(a.minSize, combined - b.maxSize),
171
+ Math.min(a.maxSize, combined - b.minSize),
172
+ );
173
+ setSizes((prev) => {
174
+ const next = [...prev];
175
+ next[handleIndex] = clampedA;
176
+ next[handleIndex + 1] = combined - clampedA;
177
+ return next;
178
+ });
179
+ }
180
+
181
+ useEffect(() => {
182
+ function handlePointerMove(event: globalThis.PointerEvent) {
183
+ const drag = dragState.current;
184
+ const rect = containerRef.current?.getBoundingClientRect();
185
+ if (!drag || !rect || event.pointerId !== drag.pointerId) return;
186
+ const size = directionRef.current === 'horizontal' ? rect.width : rect.height;
187
+ if (size === 0) return;
188
+ const pos = directionRef.current === 'horizontal' ? event.clientX : event.clientY;
189
+ const deltaPercent = ((pos - drag.startPos) / size) * 100;
190
+ applyResize(drag.handleIndex, drag.startA + deltaPercent, drag.combined);
191
+ }
192
+
193
+ function stopDragging(event: globalThis.PointerEvent) {
194
+ if (!dragState.current || event.pointerId !== dragState.current.pointerId) return;
195
+ dragState.current = null;
196
+ document.body.style.cursor = '';
197
+ document.body.style.userSelect = '';
198
+ }
199
+
200
+ window.addEventListener('pointermove', handlePointerMove);
201
+ window.addEventListener('pointerup', stopDragging);
202
+ window.addEventListener('pointercancel', stopDragging);
203
+ return () => {
204
+ window.removeEventListener('pointermove', handlePointerMove);
205
+ window.removeEventListener('pointerup', stopDragging);
206
+ window.removeEventListener('pointercancel', stopDragging);
207
+ document.body.style.cursor = '';
208
+ document.body.style.userSelect = '';
209
+ };
210
+ // Registered once — handlePointerMove reads live state via refs instead
211
+ // of closing over props/state that would otherwise need to be deps.
212
+ }, []);
213
+
214
+ function startDragging(handleIndex: number, event: ReactPointerEvent<HTMLDivElement>) {
215
+ const startA = sizes[handleIndex] ?? 0;
216
+ const startB = sizes[handleIndex + 1] ?? 0;
217
+ dragState.current = {
218
+ handleIndex,
219
+ pointerId: event.pointerId,
220
+ startPos: direction === 'horizontal' ? event.clientX : event.clientY,
221
+ startA,
222
+ combined: startA + startB,
223
+ };
224
+ document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
225
+ document.body.style.userSelect = 'none';
226
+ event.currentTarget.setPointerCapture(event.pointerId);
227
+ }
228
+
229
+ function resizeWithKeyboard(handleIndex: number, event: ReactKeyboardEvent<HTMLDivElement>) {
230
+ const step = event.shiftKey ? 10 : 5;
231
+ const forwardKey = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
232
+ const backwardKey = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
233
+ const sizeA = sizes[handleIndex] ?? 0;
234
+ const sizeB = sizes[handleIndex + 1] ?? 0;
235
+ const combined = sizeA + sizeB;
236
+ if (event.key === forwardKey) {
237
+ event.preventDefault();
238
+ applyResize(handleIndex, sizeA + step, combined);
239
+ } else if (event.key === backwardKey) {
240
+ event.preventDefault();
241
+ applyResize(handleIndex, sizeA - step, combined);
242
+ }
243
+ }
244
+
245
+ let panelIndex = -1;
246
+ let handleIndex = -1;
247
+
248
+ return (
249
+ <div
250
+ ref={containerRef}
251
+ className={[styles.group, direction === 'vertical' ? styles.groupVertical : '', className]
252
+ .filter(Boolean)
253
+ .join(' ')}
254
+ >
255
+ {elements.map((element, key) => {
256
+ if (element.type === ResizablePanel) {
257
+ panelIndex += 1;
258
+ const size = sizes[panelIndex] ?? constraints[panelIndex]?.defaultSize ?? 0;
259
+ return (
260
+ <ResizablePanelClone
261
+ key={key}
262
+ element={element as ReactElement<ResizablePanelProps>}
263
+ flexBasis={size}
264
+ />
265
+ );
266
+ }
267
+ if (element.type === ResizableHandle) {
268
+ handleIndex += 1;
269
+ const index = handleIndex;
270
+ const constraint = constraints[index];
271
+ return (
272
+ <ResizableHandleClone
273
+ key={key}
274
+ element={element as ReactElement<ResizableHandleProps>}
275
+ direction={direction}
276
+ aria-valuenow={sizes[index]}
277
+ aria-valuemin={constraint?.minSize}
278
+ aria-valuemax={constraint?.maxSize}
279
+ onPointerDown={(e) => startDragging(index, e)}
280
+ onKeyDown={(e) => resizeWithKeyboard(index, e)}
281
+ />
282
+ );
283
+ }
284
+ return element;
285
+ })}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function ResizablePanelClone({
291
+ element,
292
+ flexBasis,
293
+ }: {
294
+ element: ReactElement<ResizablePanelProps>;
295
+ flexBasis: number;
296
+ }) {
297
+ const style: CSSProperties = {
298
+ ...element.props.style,
299
+ flexBasis: `${flexBasis}%`,
300
+ flexGrow: 0,
301
+ flexShrink: 0,
302
+ };
303
+ return <ResizablePanel {...element.props} style={style} />;
304
+ }
305
+
306
+ function ResizableHandleClone({
307
+ element,
308
+ ...injected
309
+ }: {
310
+ element: ReactElement<ResizableHandleProps>;
311
+ } & ResizableHandleProps) {
312
+ return <ResizableHandle {...element.props} {...injected} />;
313
+ }
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../Resizable';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ResizablePanelGroup', () => {
9
+ it('renders each panel and a separator between them', () => {
10
+ render(
11
+ <ResizablePanelGroup direction="horizontal">
12
+ <ResizablePanel>
13
+ <p>Left</p>
14
+ </ResizablePanel>
15
+ <ResizableHandle aria-label="Resize sidebar" />
16
+ <ResizablePanel>
17
+ <p>Right</p>
18
+ </ResizablePanel>
19
+ </ResizablePanelGroup>,
20
+ );
21
+
22
+ expect(screen.getByText('Left')).toBeDefined();
23
+ expect(screen.getByText('Right')).toBeDefined();
24
+ expect(screen.getByRole('separator', { name: 'Resize sidebar' })).toBeDefined();
25
+ });
26
+
27
+ it('splits panels evenly by default', () => {
28
+ render(
29
+ <ResizablePanelGroup direction="horizontal">
30
+ <ResizablePanel>
31
+ <p>Left</p>
32
+ </ResizablePanel>
33
+ <ResizableHandle aria-label="Resize" />
34
+ <ResizablePanel>
35
+ <p>Right</p>
36
+ </ResizablePanel>
37
+ </ResizablePanelGroup>,
38
+ );
39
+
40
+ const separator = screen.getByRole('separator', { name: 'Resize' });
41
+ expect(separator.getAttribute('aria-valuenow')).toBe('50');
42
+ });
43
+
44
+ it('resizes the pair of neighboring panels with the keyboard, redistributing their combined size', () => {
45
+ render(
46
+ <ResizablePanelGroup direction="horizontal">
47
+ <ResizablePanel defaultSize={50} minSize={20} maxSize={80}>
48
+ <p>Left</p>
49
+ </ResizablePanel>
50
+ <ResizableHandle aria-label="Resize" />
51
+ <ResizablePanel defaultSize={50} minSize={20} maxSize={80}>
52
+ <p>Right</p>
53
+ </ResizablePanel>
54
+ </ResizablePanelGroup>,
55
+ );
56
+
57
+ const separator = screen.getByRole('separator', { name: 'Resize' });
58
+ fireEvent.keyDown(separator, { key: 'ArrowRight' });
59
+ expect(separator.getAttribute('aria-valuenow')).toBe('55');
60
+
61
+ fireEvent.keyDown(separator, { key: 'ArrowLeft', shiftKey: true });
62
+ expect(separator.getAttribute('aria-valuenow')).toBe('45');
63
+ });
64
+
65
+ it('clamps keyboard resizing to minSize/maxSize', () => {
66
+ render(
67
+ <ResizablePanelGroup direction="horizontal">
68
+ <ResizablePanel defaultSize={50} minSize={45} maxSize={55}>
69
+ <p>Left</p>
70
+ </ResizablePanel>
71
+ <ResizableHandle aria-label="Resize" />
72
+ <ResizablePanel defaultSize={50} minSize={45} maxSize={55}>
73
+ <p>Right</p>
74
+ </ResizablePanel>
75
+ </ResizablePanelGroup>,
76
+ );
77
+
78
+ const separator = screen.getByRole('separator', { name: 'Resize' });
79
+ fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
80
+ fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
81
+ fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
82
+ expect(separator.getAttribute('aria-valuenow')).toBe('55');
83
+ });
84
+
85
+ it('only affects the pair of panels adjacent to the handle that moved', () => {
86
+ render(
87
+ <ResizablePanelGroup direction="horizontal">
88
+ <ResizablePanel defaultSize={34}>
89
+ <p>A</p>
90
+ </ResizablePanel>
91
+ <ResizableHandle aria-label="Resize A-B" />
92
+ <ResizablePanel defaultSize={33}>
93
+ <p>B</p>
94
+ </ResizablePanel>
95
+ <ResizableHandle aria-label="Resize B-C" />
96
+ <ResizablePanel defaultSize={33}>
97
+ <p>C</p>
98
+ </ResizablePanel>
99
+ </ResizablePanelGroup>,
100
+ );
101
+
102
+ const first = screen.getByRole('separator', { name: 'Resize A-B' });
103
+ const second = screen.getByRole('separator', { name: 'Resize B-C' });
104
+ fireEvent.keyDown(first, { key: 'ArrowRight' });
105
+ expect(second.getAttribute('aria-valuenow')).toBe('28');
106
+ });
107
+
108
+ it('uses ArrowDown/ArrowUp for vertical groups instead of ArrowRight/ArrowLeft', () => {
109
+ render(
110
+ <ResizablePanelGroup direction="vertical">
111
+ <ResizablePanel defaultSize={50}>
112
+ <p>Top</p>
113
+ </ResizablePanel>
114
+ <ResizableHandle aria-label="Resize" />
115
+ <ResizablePanel defaultSize={50}>
116
+ <p>Bottom</p>
117
+ </ResizablePanel>
118
+ </ResizablePanelGroup>,
119
+ );
120
+
121
+ const separator = screen.getByRole('separator', { name: 'Resize' });
122
+ expect(separator.getAttribute('aria-orientation')).toBe('horizontal');
123
+ fireEvent.keyDown(separator, { key: 'ArrowRight' });
124
+ expect(separator.getAttribute('aria-valuenow')).toBe('50');
125
+ fireEvent.keyDown(separator, { key: 'ArrowDown' });
126
+ expect(separator.getAttribute('aria-valuenow')).toBe('55');
127
+ });
128
+ });
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useResponsiveLayout } from '../../hooks/useResponsiveLayout';
3
+
4
+ export interface ResponsiveSurfaceProps {
5
+ web: ReactNode;
6
+ mobile: ReactNode;
7
+ breakpointPx?: number;
8
+ }
9
+
10
+ /**
11
+ * ResponsiveSurface — thin JSX convenience wrapper around
12
+ * `useResponsiveLayout` for the common two-tree case: render an entirely
13
+ * different component tree below the breakpoint, not a CSS squeeze of the
14
+ * same one. Renders only whichever side is active — the other tree is never
15
+ * mounted.
16
+ *
17
+ * This does not replace a plugin's own shell component (e.g. a
18
+ * `MobileAwareShell` that also threads plugin-specific props like a refresh
19
+ * signal) — those stay a thin per-plugin wrapper around this.
20
+ */
21
+ export function ResponsiveSurface({ web, mobile, breakpointPx }: ResponsiveSurfaceProps) {
22
+ const { value } = useResponsiveLayout({ web, mobile, breakpointPx });
23
+ return <>{value}</>;
24
+ }
@@ -0,0 +1,73 @@
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 { ResponsiveSurface } from '../ResponsiveSurface';
5
+
6
+ let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
7
+ let matches = false;
8
+
9
+ function installMatchMedia() {
10
+ changeHandler = null;
11
+ vi.stubGlobal(
12
+ 'matchMedia',
13
+ vi.fn().mockImplementation((query: string) => ({
14
+ matches,
15
+ media: query,
16
+ addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
17
+ changeHandler = cb;
18
+ },
19
+ removeEventListener: vi.fn(),
20
+ addListener: vi.fn(),
21
+ removeListener: vi.fn(),
22
+ dispatchEvent: vi.fn(),
23
+ })),
24
+ );
25
+ }
26
+
27
+ describe('ResponsiveSurface', () => {
28
+ beforeEach(() => {
29
+ matches = false;
30
+ installMatchMedia();
31
+ });
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ cleanup();
35
+ });
36
+
37
+ it('renders the web tree by default and never mounts the mobile tree', () => {
38
+ render(
39
+ <ResponsiveSurface
40
+ web={<div data-testid="web">web</div>}
41
+ mobile={<div data-testid="mobile">mobile</div>}
42
+ />,
43
+ );
44
+ expect(screen.getByTestId('web')).toBeDefined();
45
+ expect(screen.queryByTestId('mobile')).toBeNull();
46
+ });
47
+
48
+ it('renders only the mobile tree below the breakpoint', () => {
49
+ matches = true;
50
+ installMatchMedia();
51
+ render(
52
+ <ResponsiveSurface
53
+ web={<div data-testid="web">web</div>}
54
+ mobile={<div data-testid="mobile">mobile</div>}
55
+ />,
56
+ );
57
+ expect(screen.getByTestId('mobile')).toBeDefined();
58
+ expect(screen.queryByTestId('web')).toBeNull();
59
+ });
60
+
61
+ it('swaps trees live when the viewport crosses the breakpoint', () => {
62
+ render(
63
+ <ResponsiveSurface
64
+ web={<div data-testid="web">web</div>}
65
+ mobile={<div data-testid="mobile">mobile</div>}
66
+ />,
67
+ );
68
+ expect(screen.getByTestId('web')).toBeDefined();
69
+ act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
70
+ expect(screen.getByTestId('mobile')).toBeDefined();
71
+ expect(screen.queryByTestId('web')).toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,20 @@
1
+ .root {
2
+ overflow: auto;
3
+ overscroll-behavior: contain;
4
+ scrollbar-width: thin;
5
+ scrollbar-color: var(--sv-color-border-strong) transparent;
6
+ }
7
+
8
+ .root::-webkit-scrollbar {
9
+ width: 8px;
10
+ height: 8px;
11
+ }
12
+
13
+ .root::-webkit-scrollbar-track {
14
+ background: transparent;
15
+ }
16
+
17
+ .root::-webkit-scrollbar-thumb {
18
+ background: var(--sv-color-border-strong);
19
+ border-radius: var(--sv-radius-full);
20
+ }
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ScrollArea } from './ScrollArea';
3
+
4
+ const meta = {
5
+ title: 'Components/ScrollArea',
6
+ component: ScrollArea,
7
+ parameters: { layout: 'padded' },
8
+ args: { children: null },
9
+ } satisfies Meta<typeof ScrollArea>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const Vertical: Story = {
17
+ render: () => (
18
+ <div style={{ width: 280 }}>
19
+ <ScrollArea maxHeight={160}>
20
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 4 }}>
21
+ {Array.from({ length: 20 }, (_, i) => (
22
+ <div key={i} style={{ fontSize: 14 }}>
23
+ Row {i + 1}
24
+ </div>
25
+ ))}
26
+ </div>
27
+ </ScrollArea>
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const Horizontal: Story = {
33
+ render: () => (
34
+ <ScrollArea maxWidth={320}>
35
+ <div style={{ display: 'flex', gap: 8, padding: 4, width: 'max-content' }}>
36
+ {Array.from({ length: 12 }, (_, i) => (
37
+ <div
38
+ key={i}
39
+ style={{
40
+ width: 80,
41
+ height: 60,
42
+ flexShrink: 0,
43
+ background: 'var(--sv-color-surface-sunken)',
44
+ borderRadius: 'var(--sv-radius-md)',
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ fontSize: 12,
49
+ }}
50
+ >
51
+ {i + 1}
52
+ </div>
53
+ ))}
54
+ </div>
55
+ </ScrollArea>
56
+ ),
57
+ };
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import styles from './ScrollArea.module.css';
3
+
4
+ export interface ScrollAreaProps {
5
+ children: ReactNode;
6
+ /** Constrains the scrollable direction; content taller/wider than this scrolls. */
7
+ maxHeight?: string | number;
8
+ maxWidth?: string | number;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * ScrollArea — a styled scrollable container (thin, token-colored scrollbar
14
+ * instead of the OS default). Native overflow scrolling underneath — no
15
+ * virtualization, no custom scroll physics.
16
+ */
17
+ export function ScrollArea({ children, maxHeight, maxWidth, className }: ScrollAreaProps) {
18
+ const style: CSSProperties = {};
19
+ if (maxHeight !== undefined) style.maxHeight = maxHeight;
20
+ if (maxWidth !== undefined) style.maxWidth = maxWidth;
21
+
22
+ return (
23
+ <div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
24
+ {children}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { ScrollArea } from '../ScrollArea';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ScrollArea', () => {
9
+ it('renders children', () => {
10
+ render(
11
+ <ScrollArea>
12
+ <div>content</div>
13
+ </ScrollArea>,
14
+ );
15
+ expect(screen.getByText('content')).toBeDefined();
16
+ });
17
+
18
+ it('applies maxHeight/maxWidth as inline styles when given', () => {
19
+ render(
20
+ <ScrollArea maxHeight={200} maxWidth="100%">
21
+ <div data-testid="child">content</div>
22
+ </ScrollArea>,
23
+ );
24
+ const root = screen.getByTestId('child').parentElement;
25
+ expect(root?.style.maxHeight).toBe('200px');
26
+ expect(root?.style.maxWidth).toBe('100%');
27
+ });
28
+
29
+ it('does not set maxHeight/maxWidth styles when omitted', () => {
30
+ render(
31
+ <ScrollArea>
32
+ <div data-testid="child">content</div>
33
+ </ScrollArea>,
34
+ );
35
+ const root = screen.getByTestId('child').parentElement;
36
+ expect(root?.style.maxHeight).toBe('');
37
+ expect(root?.style.maxWidth).toBe('');
38
+ });
39
+ });