@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,88 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--sv-space-2);
5
+ }
6
+
7
+ .option {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ gap: var(--sv-space-2);
11
+ cursor: pointer;
12
+ }
13
+
14
+ /* The visual circle — same 18px size and touch-target-expansion technique
15
+ as Checkbox's box, radius-full instead of radius-sm. */
16
+ .circle {
17
+ --radio-circle-size: 18px;
18
+ position: relative;
19
+ flex-shrink: 0;
20
+ width: var(--radio-circle-size);
21
+ height: var(--radio-circle-size);
22
+ border-radius: var(--sv-radius-full);
23
+ border: 1.5px solid var(--sv-color-border-strong);
24
+ background: var(--sv-color-surface);
25
+ transition:
26
+ background-color 0.15s ease,
27
+ border-color 0.15s ease;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ }
32
+
33
+ .checked {
34
+ border-color: var(--sv-color-accent);
35
+ }
36
+
37
+ .dot {
38
+ width: 8px;
39
+ height: 8px;
40
+ border-radius: var(--sv-radius-full);
41
+ background: var(--sv-color-accent);
42
+ pointer-events: none;
43
+ }
44
+
45
+ /* Hide the native input visually but keep it accessible */
46
+ .input {
47
+ position: absolute;
48
+ inset: 0;
49
+ opacity: 0;
50
+ margin: 0;
51
+ width: 100%;
52
+ height: 100%;
53
+ cursor: pointer;
54
+ }
55
+
56
+ .input:disabled {
57
+ cursor: not-allowed;
58
+ }
59
+
60
+ /* Expand the tappable region to the platform's minimum touch target without
61
+ growing the visible circle. Gated to (pointer: coarse), the *primary*
62
+ pointer, so a touchscreen laptop with mouse/trackpad as primary keeps
63
+ desktop density — same reasoning as Checkbox. */
64
+ @media (pointer: coarse) {
65
+ .input {
66
+ inset: calc((var(--radio-circle-size) - var(--sv-touch-target-min, 44px)) / 2);
67
+ }
68
+ }
69
+
70
+ .input:focus-visible {
71
+ outline: none;
72
+ }
73
+
74
+ .circle:has(.input:focus-visible) {
75
+ outline: 2px solid var(--sv-color-focus-ring);
76
+ outline-offset: 2px;
77
+ }
78
+
79
+ .optionLabel {
80
+ font-size: var(--sv-font-size-sm);
81
+ color: var(--sv-color-text-primary);
82
+ user-select: none;
83
+ }
84
+
85
+ .disabled {
86
+ opacity: 0.5;
87
+ cursor: not-allowed;
88
+ }
@@ -0,0 +1,69 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { RadioGroup } from './RadioGroup';
4
+ import { FormField } from '../FormField/FormField';
5
+
6
+ const meta = {
7
+ title: 'Components/RadioGroup',
8
+ component: RadioGroup,
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'Single-select list of options. Renders real `<input type="radio">` elements sharing one `name`, so keyboard arrow-key navigation between options is native browser behavior.',
15
+ },
16
+ },
17
+ },
18
+ args: {
19
+ items: [
20
+ { label: 'Small', value: 'sm' },
21
+ { label: 'Medium', value: 'md' },
22
+ { label: 'Large', value: 'lg' },
23
+ ],
24
+ value: 'md',
25
+ onChange: () => {},
26
+ 'aria-label': 'Size',
27
+ },
28
+ } satisfies Meta<typeof RadioGroup>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const Default: Story = {
36
+ render: (args) => {
37
+ const [value, setValue] = useState(args.value);
38
+ return <RadioGroup {...args} value={value} onChange={setValue} />;
39
+ },
40
+ };
41
+
42
+ export const WithDisabledItem: Story = {
43
+ render: (args) => {
44
+ const [value, setValue] = useState(args.value);
45
+ return (
46
+ <RadioGroup
47
+ {...args}
48
+ items={[...args.items, { label: 'X-Large (out of stock)', value: 'xl', disabled: true }]}
49
+ value={value}
50
+ onChange={setValue}
51
+ />
52
+ );
53
+ },
54
+ };
55
+
56
+ export const Disabled: Story = {
57
+ args: { disabled: true },
58
+ };
59
+
60
+ export const InFormField: Story = {
61
+ render: (args) => {
62
+ const [value, setValue] = useState(args.value);
63
+ return (
64
+ <FormField label="Shirt size" error="Select a size to continue.">
65
+ {(field) => <RadioGroup {...args} {...field} value={value} onChange={setValue} />}
66
+ </FormField>
67
+ );
68
+ },
69
+ };
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
4
+ import styles from './RadioGroup.module.css';
5
+
6
+ export interface RadioGroupItem {
7
+ label: string;
8
+ value: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export interface RadioGroupProps {
13
+ items: RadioGroupItem[];
14
+ value: string;
15
+ onChange: (value: string) => void;
16
+ /** Groups the underlying native radios. Auto-generated when omitted —
17
+ * only pass this to participate in a native, non-React form submission. */
18
+ name?: string;
19
+ /** Disables every item. Use `items[].disabled` to disable one. */
20
+ disabled?: boolean;
21
+ id?: string;
22
+ className?: string;
23
+ 'aria-label'?: string;
24
+ 'aria-labelledby'?: string;
25
+ 'aria-describedby'?: string;
26
+ 'aria-invalid'?: boolean;
27
+ required?: boolean;
28
+ }
29
+
30
+ /**
31
+ * RadioGroup — single-select list of options.
32
+ *
33
+ * Renders real `<input type="radio">` elements sharing one `name`, visually
34
+ * hidden behind a custom circle (same technique as `Checkbox`). Keyboard
35
+ * arrow-key navigation between options is native browser behavior for
36
+ * grouped radio inputs — nothing to hand-roll here.
37
+ */
38
+ export function RadioGroup({
39
+ items,
40
+ value,
41
+ onChange,
42
+ name,
43
+ disabled,
44
+ id,
45
+ className,
46
+ 'aria-label': ariaLabel,
47
+ 'aria-labelledby': ariaLabelledBy,
48
+ 'aria-describedby': ariaDescribedBy,
49
+ 'aria-invalid': ariaInvalid,
50
+ required,
51
+ }: RadioGroupProps) {
52
+ const generatedName = useId();
53
+ const groupName = name ?? generatedName;
54
+
55
+ return (
56
+ <div
57
+ id={id}
58
+ role="radiogroup"
59
+ aria-label={ariaLabel}
60
+ aria-labelledby={ariaLabelledBy}
61
+ aria-describedby={ariaDescribedBy}
62
+ aria-invalid={ariaInvalid}
63
+ aria-required={required}
64
+ className={[styles.root, className].filter(Boolean).join(' ')}
65
+ >
66
+ {items.map((item) => {
67
+ const checked = item.value === value;
68
+ const itemDisabled = disabled || item.disabled;
69
+ const inputId = `${groupName}-${item.value}`;
70
+
71
+ return (
72
+ <label
73
+ key={item.value}
74
+ htmlFor={inputId}
75
+ className={[styles.option, itemDisabled ? styles.disabled : '']
76
+ .filter(Boolean)
77
+ .join(' ')}
78
+ >
79
+ <span className={[styles.circle, checked ? styles.checked : ''].join(' ')}>
80
+ <input
81
+ id={inputId}
82
+ type="radio"
83
+ name={groupName}
84
+ value={item.value}
85
+ checked={checked}
86
+ disabled={itemDisabled}
87
+ required={required}
88
+ className={styles.input}
89
+ onChange={() => onChange(item.value)}
90
+ />
91
+ {checked && <span className={styles.dot} />}
92
+ </span>
93
+ <span className={styles.optionLabel}>{item.label}</span>
94
+ </label>
95
+ );
96
+ })}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,59 @@
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 { RadioGroup } from '../RadioGroup';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const items = [
9
+ { label: 'Small', value: 'sm' },
10
+ { label: 'Medium', value: 'md' },
11
+ { label: 'Large', value: 'lg' },
12
+ ];
13
+
14
+ describe('RadioGroup', () => {
15
+ it('renders all items as radio inputs', () => {
16
+ render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" />);
17
+ expect(screen.getAllByRole('radio').length).toBe(3);
18
+ });
19
+
20
+ it('marks the matching item as checked', () => {
21
+ render(<RadioGroup items={items} value="md" onChange={() => {}} aria-label="Size" />);
22
+ expect(screen.getByRole('radio', { name: 'Medium' })).toHaveProperty('checked', true);
23
+ expect(screen.getByRole('radio', { name: 'Small' })).toHaveProperty('checked', false);
24
+ });
25
+
26
+ it('calls onChange with the selected value', () => {
27
+ const onChange = vi.fn();
28
+ render(<RadioGroup items={items} value="sm" onChange={onChange} aria-label="Size" />);
29
+ fireEvent.click(screen.getByRole('radio', { name: 'Large' }));
30
+ expect(onChange).toHaveBeenCalledWith('lg');
31
+ });
32
+
33
+ it('exposes a radiogroup with the given aria-label', () => {
34
+ render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" />);
35
+ expect(screen.getByRole('radiogroup', { name: 'Size' })).toBeDefined();
36
+ });
37
+
38
+ it('shares one name across all items so native radio semantics apply', () => {
39
+ render(
40
+ <RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" name="size" />,
41
+ );
42
+ const radios = screen.getAllByRole('radio') as HTMLInputElement[];
43
+ expect(new Set(radios.map((r) => r.name)).size).toBe(1);
44
+ });
45
+
46
+ it('disables an individual item via items[].disabled', () => {
47
+ const withDisabled = [...items, { label: 'X-Large', value: 'xl', disabled: true }];
48
+ render(<RadioGroup items={withDisabled} value="sm" onChange={() => {}} aria-label="Size" />);
49
+ expect(screen.getByRole('radio', { name: 'X-Large' })).toHaveProperty('disabled', true);
50
+ expect(screen.getByRole('radio', { name: 'Small' })).toHaveProperty('disabled', false);
51
+ });
52
+
53
+ it('disables every item when the group-level disabled prop is set', () => {
54
+ render(<RadioGroup items={items} value="sm" onChange={() => {}} aria-label="Size" disabled />);
55
+ for (const radio of screen.getAllByRole('radio')) {
56
+ expect(radio).toHaveProperty('disabled', true);
57
+ }
58
+ });
59
+ });
@@ -0,0 +1,70 @@
1
+ .group {
2
+ display: flex;
3
+ flex-direction: row;
4
+ width: 100%;
5
+ height: 100%;
6
+ min-height: 320px;
7
+ border: 1px solid var(--sv-color-border);
8
+ border-radius: var(--sv-radius-lg);
9
+ overflow: hidden;
10
+ background: var(--sv-color-surface);
11
+ }
12
+
13
+ .groupVertical {
14
+ flex-direction: column;
15
+ }
16
+
17
+ .panel {
18
+ min-width: 0;
19
+ min-height: 0;
20
+ overflow: auto;
21
+ background: var(--sv-color-surface);
22
+ }
23
+
24
+ .handle {
25
+ position: relative;
26
+ flex-shrink: 0;
27
+ width: 12px;
28
+ padding: 0;
29
+ border: 0;
30
+ border-inline: 1px solid var(--sv-color-border);
31
+ cursor: col-resize;
32
+ background: var(--sv-color-surface-sunken);
33
+ }
34
+
35
+ .handleVertical {
36
+ width: 100%;
37
+ height: 12px;
38
+ border-inline: 0;
39
+ border-block: 1px solid var(--sv-color-border);
40
+ cursor: row-resize;
41
+ }
42
+
43
+ .handle::before {
44
+ position: absolute;
45
+ inset-block: 50%;
46
+ inset-inline: 50%;
47
+ width: 2px;
48
+ height: 40px;
49
+ content: '';
50
+ background: var(--sv-color-border-strong);
51
+ border-radius: var(--sv-radius-full);
52
+ transform: translate(-50%, -50%);
53
+ }
54
+
55
+ .handleVertical::before {
56
+ width: 40px;
57
+ height: 2px;
58
+ }
59
+
60
+ @media (hover: hover) {
61
+ .handle:hover {
62
+ background: var(--sv-color-accent-subtle);
63
+ }
64
+ }
65
+
66
+ .handle:focus-visible {
67
+ background: var(--sv-color-accent-subtle);
68
+ outline: 2px solid var(--sv-color-focus-ring);
69
+ outline-offset: -2px;
70
+ }
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
3
+
4
+ const meta = {
5
+ title: 'Components/Resizable',
6
+ component: ResizablePanelGroup,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'A row or column of resizable panes. Each handle resizes only its two immediate neighbor panels, clamped to their own min/max. Desktop-oriented — panels render at their default sizes on touch.',
13
+ },
14
+ },
15
+ },
16
+ args: { direction: 'horizontal', children: null },
17
+ } satisfies Meta<typeof ResizablePanelGroup>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const paneStyle: React.CSSProperties = {
25
+ display: 'flex',
26
+ alignItems: 'center',
27
+ justifyContent: 'center',
28
+ height: '100%',
29
+ padding: 'var(--sv-space-4)',
30
+ fontSize: 14,
31
+ color: 'var(--sv-color-text-muted)',
32
+ };
33
+
34
+ export const Horizontal: Story = {
35
+ render: () => (
36
+ <ResizablePanelGroup direction="horizontal">
37
+ <ResizablePanel defaultSize={25} minSize={15} maxSize={50}>
38
+ <div style={paneStyle}>Sidebar</div>
39
+ </ResizablePanel>
40
+ <ResizableHandle aria-label="Resize sidebar" />
41
+ <ResizablePanel defaultSize={75}>
42
+ <div style={paneStyle}>Content</div>
43
+ </ResizablePanel>
44
+ </ResizablePanelGroup>
45
+ ),
46
+ };
47
+
48
+ export const ThreePanels: Story = {
49
+ render: () => (
50
+ <ResizablePanelGroup direction="horizontal">
51
+ <ResizablePanel defaultSize={20} minSize={15} maxSize={40}>
52
+ <div style={paneStyle}>Files</div>
53
+ </ResizablePanel>
54
+ <ResizableHandle aria-label="Resize files panel" />
55
+ <ResizablePanel defaultSize={50}>
56
+ <div style={paneStyle}>Editor</div>
57
+ </ResizablePanel>
58
+ <ResizableHandle aria-label="Resize editor panel" />
59
+ <ResizablePanel defaultSize={30} minSize={15} maxSize={50}>
60
+ <div style={paneStyle}>Preview</div>
61
+ </ResizablePanel>
62
+ </ResizablePanelGroup>
63
+ ),
64
+ };
65
+
66
+ export const Vertical: Story = {
67
+ render: () => (
68
+ // Vertical orientation needs a definite-height ancestor for the
69
+ // percentage-based flex-basis on each panel to resolve.
70
+ <div style={{ height: 400 }}>
71
+ <ResizablePanelGroup direction="vertical">
72
+ <ResizablePanel defaultSize={30}>
73
+ <div style={paneStyle}>Console output</div>
74
+ </ResizablePanel>
75
+ <ResizableHandle aria-label="Resize console" />
76
+ <ResizablePanel defaultSize={70}>
77
+ <div style={paneStyle}>Editor</div>
78
+ </ResizablePanel>
79
+ </ResizablePanelGroup>
80
+ </div>
81
+ ),
82
+ };