@sovereignfs/ui 0.21.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 (141) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +24 -0
  3. package/dist/Avatar.module.css +38 -0
  4. package/dist/Badge.module.css +88 -0
  5. package/dist/Button.module.css +77 -0
  6. package/dist/Card.module.css +34 -0
  7. package/dist/Dialog.module.css +188 -0
  8. package/dist/Drawer.module.css +30 -0
  9. package/dist/EmptyState.module.css +27 -0
  10. package/dist/FormField.module.css +28 -0
  11. package/dist/Icon.module.css +22 -0
  12. package/dist/Input.module.css +37 -0
  13. package/dist/NavTabs.module.css +48 -0
  14. package/dist/PageHeader.module.css +32 -0
  15. package/dist/Popover.module.css +44 -0
  16. package/dist/SegmentedControl.module.css +53 -0
  17. package/dist/Select.module.css +69 -0
  18. package/dist/Spinner.module.css +39 -0
  19. package/dist/SystemBanner.module.css +67 -0
  20. package/dist/Tabs.module.css +49 -0
  21. package/dist/Toast.module.css +130 -0
  22. package/dist/Toggle.module.css +52 -0
  23. package/dist/Tooltip.module.css +51 -0
  24. package/dist/index.d.ts +327 -0
  25. package/dist/index.js +1370 -0
  26. package/dist/tokens/primitives.css +99 -0
  27. package/dist/tokens/semantic.css +111 -0
  28. package/dist/tokens.css +5 -0
  29. package/package.json +50 -0
  30. package/src/components/Avatar/Avatar.module.css +38 -0
  31. package/src/components/Avatar/Avatar.tsx +42 -0
  32. package/src/components/Badge/Badge.module.css +88 -0
  33. package/src/components/Badge/Badge.stories.tsx +118 -0
  34. package/src/components/Badge/Badge.tsx +59 -0
  35. package/src/components/Badge/__tests__/Badge.test.tsx +64 -0
  36. package/src/components/Button/Button.module.css +77 -0
  37. package/src/components/Button/Button.stories.tsx +76 -0
  38. package/src/components/Button/Button.tsx +36 -0
  39. package/src/components/Button/__tests__/Button.test.tsx +37 -0
  40. package/src/components/Card/Card.module.css +34 -0
  41. package/src/components/Card/Card.tsx +34 -0
  42. package/src/components/Dialog/Dialog.module.css +188 -0
  43. package/src/components/Dialog/Dialog.stories.tsx +97 -0
  44. package/src/components/Dialog/Dialog.tsx +137 -0
  45. package/src/components/Dialog/__tests__/Dialog.test.tsx +73 -0
  46. package/src/components/Drawer/Drawer.module.css +30 -0
  47. package/src/components/Drawer/Drawer.stories.tsx +88 -0
  48. package/src/components/Drawer/Drawer.tsx +103 -0
  49. package/src/components/Drawer/__tests__/Drawer.test.tsx +56 -0
  50. package/src/components/EmptyState/EmptyState.module.css +27 -0
  51. package/src/components/EmptyState/EmptyState.tsx +27 -0
  52. package/src/components/FormField/FormField.module.css +28 -0
  53. package/src/components/FormField/FormField.tsx +53 -0
  54. package/src/components/Icon/Icon.module.css +22 -0
  55. package/src/components/Icon/Icon.stories.tsx +94 -0
  56. package/src/components/Icon/Icon.tsx +43 -0
  57. package/src/components/Icon/__tests__/Icon.test.tsx +40 -0
  58. package/src/components/Icon/icons/activity.tsx +20 -0
  59. package/src/components/Icon/icons/alert-triangle.tsx +22 -0
  60. package/src/components/Icon/icons/bell.tsx +21 -0
  61. package/src/components/Icon/icons/check.tsx +20 -0
  62. package/src/components/Icon/icons/chevron-down.tsx +20 -0
  63. package/src/components/Icon/icons/chevron-left.tsx +20 -0
  64. package/src/components/Icon/icons/chevron-right.tsx +20 -0
  65. package/src/components/Icon/icons/chevron-up.tsx +20 -0
  66. package/src/components/Icon/icons/eye-off.tsx +23 -0
  67. package/src/components/Icon/icons/eye.tsx +21 -0
  68. package/src/components/Icon/icons/grid-2x2.tsx +22 -0
  69. package/src/components/Icon/icons/house.tsx +21 -0
  70. package/src/components/Icon/icons/index.ts +122 -0
  71. package/src/components/Icon/icons/info.tsx +22 -0
  72. package/src/components/Icon/icons/lock.tsx +21 -0
  73. package/src/components/Icon/icons/log-out.tsx +22 -0
  74. package/src/components/Icon/icons/mail.tsx +21 -0
  75. package/src/components/Icon/icons/package.tsx +23 -0
  76. package/src/components/Icon/icons/pencil.tsx +21 -0
  77. package/src/components/Icon/icons/plus.tsx +21 -0
  78. package/src/components/Icon/icons/rotate-ccw.tsx +21 -0
  79. package/src/components/Icon/icons/search.tsx +21 -0
  80. package/src/components/Icon/icons/settings.tsx +21 -0
  81. package/src/components/Icon/icons/shield.tsx +20 -0
  82. package/src/components/Icon/icons/sliders-horizontal.tsx +28 -0
  83. package/src/components/Icon/icons/terminal.tsx +19 -0
  84. package/src/components/Icon/icons/trash-2.tsx +24 -0
  85. package/src/components/Icon/icons/user.tsx +21 -0
  86. package/src/components/Icon/icons/x.tsx +21 -0
  87. package/src/components/Input/Input.module.css +37 -0
  88. package/src/components/Input/Input.stories.tsx +86 -0
  89. package/src/components/Input/Input.tsx +14 -0
  90. package/src/components/Input/__tests__/Input.test.tsx +22 -0
  91. package/src/components/NavTabs/NavTabs.module.css +48 -0
  92. package/src/components/NavTabs/NavTabs.tsx +33 -0
  93. package/src/components/PageHeader/PageHeader.module.css +32 -0
  94. package/src/components/PageHeader/PageHeader.tsx +21 -0
  95. package/src/components/Popover/Popover.module.css +44 -0
  96. package/src/components/Popover/Popover.stories.tsx +232 -0
  97. package/src/components/Popover/Popover.tsx +75 -0
  98. package/src/components/Popover/__tests__/Popover.test.tsx +98 -0
  99. package/src/components/SegmentedControl/SegmentedControl.module.css +53 -0
  100. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +118 -0
  101. package/src/components/SegmentedControl/SegmentedControl.tsx +56 -0
  102. package/src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx +63 -0
  103. package/src/components/Select/Select.module.css +69 -0
  104. package/src/components/Select/Select.stories.tsx +115 -0
  105. package/src/components/Select/Select.tsx +40 -0
  106. package/src/components/Select/__tests__/Select.test.tsx +59 -0
  107. package/src/components/Spinner/Spinner.module.css +39 -0
  108. package/src/components/Spinner/Spinner.tsx +23 -0
  109. package/src/components/SystemBanner/SystemBanner.module.css +67 -0
  110. package/src/components/SystemBanner/SystemBanner.stories.tsx +86 -0
  111. package/src/components/SystemBanner/SystemBanner.tsx +50 -0
  112. package/src/components/SystemBanner/__tests__/SystemBanner.test.tsx +50 -0
  113. package/src/components/Tabs/Tabs.module.css +49 -0
  114. package/src/components/Tabs/Tabs.stories.tsx +100 -0
  115. package/src/components/Tabs/Tabs.tsx +46 -0
  116. package/src/components/Tabs/__tests__/Tabs.test.tsx +45 -0
  117. package/src/components/Toast/Toast.module.css +130 -0
  118. package/src/components/Toast/Toast.stories.tsx +191 -0
  119. package/src/components/Toast/Toast.tsx +139 -0
  120. package/src/components/Toggle/Toggle.module.css +52 -0
  121. package/src/components/Toggle/Toggle.stories.tsx +95 -0
  122. package/src/components/Toggle/Toggle.tsx +39 -0
  123. package/src/components/Toggle/__tests__/Toggle.test.tsx +47 -0
  124. package/src/components/Tooltip/Tooltip.module.css +51 -0
  125. package/src/components/Tooltip/Tooltip.tsx +30 -0
  126. package/src/css-modules.d.ts +4 -0
  127. package/src/index.ts +45 -0
  128. package/src/stories/Avatar.stories.tsx +44 -0
  129. package/src/stories/Card.stories.tsx +35 -0
  130. package/src/stories/DesignSystemOverview.stories.tsx +1683 -0
  131. package/src/stories/EmptyState.stories.tsx +33 -0
  132. package/src/stories/FormField.stories.tsx +47 -0
  133. package/src/stories/MobilePatterns.stories.tsx +1285 -0
  134. package/src/stories/NavTabs.stories.tsx +35 -0
  135. package/src/stories/PageHeader.stories.tsx +31 -0
  136. package/src/stories/Spinner.stories.tsx +25 -0
  137. package/src/stories/TokenGallery.stories.tsx +355 -0
  138. package/src/stories/Tooltip.stories.tsx +59 -0
  139. package/src/tokens/primitives.css +99 -0
  140. package/src/tokens/semantic.css +111 -0
  141. package/src/tokens.css +5 -0
@@ -0,0 +1,232 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Popover } from './Popover';
4
+ import { Button } from '../Button/Button';
5
+
6
+ const meta = {
7
+ title: 'Components/Popover',
8
+ component: Popover,
9
+ parameters: {
10
+ layout: 'centered',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'Floating panel anchored below a trigger. Non-modal: closes on outside click or Escape. `align` controls which trigger edge the panel aligns to.',
15
+ },
16
+ },
17
+ },
18
+ args: {
19
+ open: false,
20
+ onClose: () => {},
21
+ trigger: <button type="button">Trigger</button>,
22
+ 'aria-label': 'Popover',
23
+ children: null,
24
+ },
25
+ } satisfies Meta<typeof Popover>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const rowStyle: React.CSSProperties = {
33
+ display: 'flex',
34
+ alignItems: 'center',
35
+ gap: 12,
36
+ padding: '10px 16px',
37
+ fontFamily: 'var(--sv-font-family)',
38
+ fontSize: 'var(--sv-font-size-sm)',
39
+ color: 'var(--sv-color-text-primary)',
40
+ borderBottom: '1px solid var(--sv-color-border)',
41
+ cursor: 'pointer',
42
+ };
43
+
44
+ /** Notification bell popover — right-aligned, 288px. */
45
+ export const Notifications: Story = {
46
+ render: (_args) => {
47
+ const [open, setOpen] = useState(false);
48
+
49
+ return (
50
+ <div style={{ padding: 40 }}>
51
+ <Popover
52
+ open={open}
53
+ onClose={() => setOpen(false)}
54
+ align="right"
55
+ aria-label="Notifications"
56
+ trigger={
57
+ <Button variant="ghost" onClick={() => setOpen((o) => !o)} aria-label="Notifications">
58
+ 🔔
59
+ </Button>
60
+ }
61
+ >
62
+ <div
63
+ style={{
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'space-between',
67
+ padding: '12px 16px',
68
+ borderBottom: '1px solid var(--sv-color-border)',
69
+ fontFamily: 'var(--sv-font-family)',
70
+ }}
71
+ >
72
+ <span
73
+ style={{
74
+ fontWeight: 'var(--sv-font-weight-semibold)',
75
+ fontSize: 'var(--sv-font-size-sm)',
76
+ color: 'var(--sv-color-text-primary)',
77
+ }}
78
+ >
79
+ Notifications
80
+ </span>
81
+ <button
82
+ type="button"
83
+ style={{
84
+ background: 'none',
85
+ border: 'none',
86
+ fontSize: 'var(--sv-font-size-caption)',
87
+ color: 'var(--sv-color-text-muted)',
88
+ cursor: 'pointer',
89
+ fontFamily: 'var(--sv-font-family)',
90
+ }}
91
+ >
92
+ Mark all read
93
+ </button>
94
+ </div>
95
+ <div style={rowStyle}>
96
+ <span style={{ fontSize: 20 }}>📦</span>
97
+ <div style={{ flex: 1 }}>
98
+ <div style={{ fontWeight: 'var(--sv-font-weight-medium)' }}>Plugin updated</div>
99
+ <div
100
+ style={{
101
+ fontSize: 'var(--sv-font-size-caption)',
102
+ color: 'var(--sv-color-text-muted)',
103
+ }}
104
+ >
105
+ 1 hour ago
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div style={rowStyle}>
110
+ <span style={{ fontSize: 20 }}>✉️</span>
111
+ <div style={{ flex: 1 }}>
112
+ <div style={{ fontWeight: 'var(--sv-font-weight-medium)' }}>Invite accepted</div>
113
+ <div
114
+ style={{
115
+ fontSize: 'var(--sv-font-size-caption)',
116
+ color: 'var(--sv-color-text-muted)',
117
+ }}
118
+ >
119
+ 2 min ago
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </Popover>
124
+ </div>
125
+ );
126
+ },
127
+ };
128
+
129
+ /** User menu — right-aligned with identity header and nav rows. */
130
+ export const UserMenu: Story = {
131
+ render: (_args) => {
132
+ const [open, setOpen] = useState(false);
133
+
134
+ const menuRowStyle: React.CSSProperties = {
135
+ ...rowStyle,
136
+ gap: 10,
137
+ cursor: 'default',
138
+ };
139
+
140
+ return (
141
+ <div style={{ padding: 40 }}>
142
+ <Popover
143
+ open={open}
144
+ onClose={() => setOpen(false)}
145
+ align="right"
146
+ aria-label="User menu"
147
+ trigger={
148
+ <button
149
+ type="button"
150
+ onClick={() => setOpen((o) => !o)}
151
+ style={{
152
+ width: 32,
153
+ height: 32,
154
+ borderRadius: '50%',
155
+ background: 'var(--sv-color-accent)',
156
+ color: 'var(--sv-color-text-on-accent)',
157
+ border: 'none',
158
+ cursor: 'pointer',
159
+ fontFamily: 'var(--sv-font-family)',
160
+ fontWeight: 600,
161
+ fontSize: 13,
162
+ }}
163
+ aria-label="Open user menu"
164
+ >
165
+ KB
166
+ </button>
167
+ }
168
+ >
169
+ <div
170
+ style={{
171
+ padding: '12px 16px',
172
+ borderBottom: '1px solid var(--sv-color-border)',
173
+ fontFamily: 'var(--sv-font-family)',
174
+ }}
175
+ >
176
+ <div
177
+ style={{
178
+ fontWeight: 'var(--sv-font-weight-semibold)',
179
+ fontSize: 'var(--sv-font-size-sm)',
180
+ color: 'var(--sv-color-text-primary)',
181
+ }}
182
+ >
183
+ Kasun Benthara
184
+ </div>
185
+ <div
186
+ style={{
187
+ fontSize: 'var(--sv-font-size-caption)',
188
+ color: 'var(--sv-color-text-muted)',
189
+ }}
190
+ >
191
+ kasun@openfs.io
192
+ </div>
193
+ </div>
194
+ {['Account', 'Console', 'Sign out'].map((item) => (
195
+ <div key={item} style={menuRowStyle}>
196
+ {item}
197
+ </div>
198
+ ))}
199
+ </Popover>
200
+ </div>
201
+ );
202
+ },
203
+ };
204
+
205
+ /** Left-aligned variant. */
206
+ export const LeftAligned: Story = {
207
+ render: (_args) => {
208
+ const [open, setOpen] = useState(false);
209
+ return (
210
+ <div style={{ padding: 40 }}>
211
+ <Popover
212
+ open={open}
213
+ onClose={() => setOpen(false)}
214
+ align="left"
215
+ aria-label="Options"
216
+ trigger={<Button onClick={() => setOpen((o) => !o)}>Open (left-aligned)</Button>}
217
+ >
218
+ <div
219
+ style={{
220
+ padding: 16,
221
+ fontFamily: 'var(--sv-font-family)',
222
+ fontSize: 'var(--sv-font-size-sm)',
223
+ color: 'var(--sv-color-text-muted)',
224
+ }}
225
+ >
226
+ Panel aligns to the left edge of the trigger.
227
+ </div>
228
+ </Popover>
229
+ </div>
230
+ );
231
+ },
232
+ };
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
4
+ import styles from './Popover.module.css';
5
+
6
+ export interface PopoverProps {
7
+ /** The element that opens the popover — rendered as-is; caller wires its onClick. */
8
+ trigger: ReactElement;
9
+ open: boolean;
10
+ onClose: () => void;
11
+ /** Which edge of the trigger the panel aligns to. Default: 'right'. */
12
+ align?: 'left' | 'right';
13
+ /** Panel width in px. Default: 288. */
14
+ width?: number;
15
+ 'aria-label': string;
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Popover — floating panel anchored below a trigger element.
21
+ *
22
+ * Positioning is `position: absolute` inside a `position: relative` wrapper —
23
+ * no floating-ui dependency. Supports left/right alignment against the trigger edge.
24
+ * Closes on outside click or Escape. Does not trap focus (it is non-modal).
25
+ */
26
+ export function Popover({
27
+ trigger,
28
+ open,
29
+ onClose,
30
+ align = 'right',
31
+ width = 288,
32
+ 'aria-label': ariaLabel,
33
+ children,
34
+ }: PopoverProps) {
35
+ const containerRef = useRef<HTMLDivElement>(null);
36
+
37
+ // Outside-click dismissal
38
+ useEffect(() => {
39
+ if (!open) return;
40
+ function handleMouseDown(e: MouseEvent) {
41
+ if (!containerRef.current?.contains(e.target as Node)) {
42
+ onClose();
43
+ }
44
+ }
45
+ document.addEventListener('mousedown', handleMouseDown);
46
+ return () => document.removeEventListener('mousedown', handleMouseDown);
47
+ }, [open, onClose]);
48
+
49
+ // Escape key dismissal
50
+ useEffect(() => {
51
+ if (!open) return;
52
+ function handleKeyDown(e: KeyboardEvent) {
53
+ if (e.key === 'Escape') onClose();
54
+ }
55
+ document.addEventListener('keydown', handleKeyDown);
56
+ return () => document.removeEventListener('keydown', handleKeyDown);
57
+ }, [open, onClose]);
58
+
59
+ return (
60
+ <div ref={containerRef} className={styles.container}>
61
+ {trigger}
62
+ {open && (
63
+ <div
64
+ role="dialog"
65
+ aria-label={ariaLabel}
66
+ aria-modal={false}
67
+ className={[styles.panel, styles[align]].join(' ')}
68
+ style={{ width }}
69
+ >
70
+ {children}
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,98 @@
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 { Popover } from '../Popover';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const trigger = <button type="button">Open</button>;
9
+
10
+ describe('Popover', () => {
11
+ it('renders the trigger element', () => {
12
+ render(
13
+ <Popover open={false} onClose={() => {}} aria-label="Menu" trigger={trigger}>
14
+ Content
15
+ </Popover>,
16
+ );
17
+ expect(screen.getByRole('button', { name: 'Open' })).toBeDefined();
18
+ });
19
+
20
+ it('does not render the panel when closed', () => {
21
+ render(
22
+ <Popover open={false} onClose={() => {}} aria-label="Menu" trigger={trigger}>
23
+ Content
24
+ </Popover>,
25
+ );
26
+ expect(screen.queryByRole('dialog')).toBeNull();
27
+ });
28
+
29
+ it('renders the panel with children when open', () => {
30
+ render(
31
+ <Popover open={true} onClose={() => {}} aria-label="Menu" trigger={trigger}>
32
+ Panel content
33
+ </Popover>,
34
+ );
35
+ expect(screen.getByRole('dialog', { name: 'Menu' })).toBeDefined();
36
+ expect(screen.getByText('Panel content')).toBeDefined();
37
+ });
38
+
39
+ it('applies the right alignment class by default', () => {
40
+ render(
41
+ <Popover open={true} onClose={() => {}} aria-label="Menu" trigger={trigger}>
42
+ Content
43
+ </Popover>,
44
+ );
45
+ expect(screen.getByRole('dialog').className).toContain('right');
46
+ });
47
+
48
+ it('applies the left alignment class when specified', () => {
49
+ render(
50
+ <Popover open={true} onClose={() => {}} align="left" aria-label="Menu" trigger={trigger}>
51
+ Content
52
+ </Popover>,
53
+ );
54
+ expect(screen.getByRole('dialog').className).toContain('left');
55
+ });
56
+
57
+ it('calls onClose when Escape is pressed', () => {
58
+ const onClose = vi.fn();
59
+ render(
60
+ <Popover open={true} onClose={onClose} aria-label="Menu" trigger={trigger}>
61
+ Content
62
+ </Popover>,
63
+ );
64
+ fireEvent.keyDown(document, { key: 'Escape' });
65
+ expect(onClose).toHaveBeenCalledOnce();
66
+ });
67
+
68
+ it('calls onClose when clicking outside the container', () => {
69
+ const onClose = vi.fn();
70
+ render(
71
+ <Popover open={true} onClose={onClose} aria-label="Menu" trigger={trigger}>
72
+ Content
73
+ </Popover>,
74
+ );
75
+ fireEvent.mouseDown(document.body);
76
+ expect(onClose).toHaveBeenCalledOnce();
77
+ });
78
+
79
+ it('does not call onClose when clicking inside the panel', () => {
80
+ const onClose = vi.fn();
81
+ render(
82
+ <Popover open={true} onClose={onClose} aria-label="Menu" trigger={trigger}>
83
+ <button type="button">Inside</button>
84
+ </Popover>,
85
+ );
86
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Inside' }));
87
+ expect(onClose).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('applies the provided width as inline style', () => {
91
+ render(
92
+ <Popover open={true} onClose={() => {}} aria-label="Menu" width={320} trigger={trigger}>
93
+ Content
94
+ </Popover>,
95
+ );
96
+ expect((screen.getByRole('dialog') as HTMLElement).style.width).toBe('320px');
97
+ });
98
+ });
@@ -0,0 +1,53 @@
1
+ .track {
2
+ display: inline-flex;
3
+ background: var(--sv-color-surface-sunken);
4
+ border-radius: var(--sv-radius-full);
5
+ border: 1px solid var(--sv-color-border);
6
+ padding: 2px;
7
+ gap: 2px;
8
+ }
9
+
10
+ /* Size variants */
11
+ .md .segment {
12
+ padding: var(--sv-space-1) var(--sv-space-3);
13
+ font-size: var(--sv-font-size-sm);
14
+ }
15
+
16
+ .sm .segment {
17
+ padding: 2px var(--sv-space-2);
18
+ font-size: var(--sv-font-size-caption);
19
+ }
20
+
21
+ .segment {
22
+ border: none;
23
+ border-radius: var(--sv-radius-full);
24
+ font-family: var(--sv-font-family);
25
+ font-weight: var(--sv-font-weight-medium);
26
+ line-height: 1;
27
+ cursor: pointer;
28
+ transition:
29
+ background-color 0.12s ease,
30
+ color 0.12s ease,
31
+ box-shadow 0.12s ease;
32
+ white-space: nowrap;
33
+ }
34
+
35
+ .segment:focus-visible {
36
+ outline: 2px solid var(--sv-color-focus-ring);
37
+ outline-offset: 1px;
38
+ }
39
+
40
+ .active {
41
+ background: var(--sv-color-surface);
42
+ color: var(--sv-color-text-primary);
43
+ box-shadow: var(--sv-shadow-card);
44
+ }
45
+
46
+ .inactive {
47
+ background: transparent;
48
+ color: var(--sv-color-text-muted);
49
+ }
50
+
51
+ .inactive:hover {
52
+ color: var(--sv-color-text-primary);
53
+ }
@@ -0,0 +1,118 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { SegmentedControl } from './SegmentedControl';
4
+
5
+ const meta = {
6
+ title: 'Components/SegmentedControl',
7
+ component: SegmentedControl,
8
+ parameters: {
9
+ layout: 'centered',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Pill-based 2–3 option picker for inline use in table rows and dialogs. Renders as `role="radiogroup"` with `role="radio"` segments.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ value: 'a',
19
+ onChange: () => {},
20
+ options: [
21
+ { label: 'A', value: 'a' },
22
+ { label: 'B', value: 'b' },
23
+ ],
24
+ 'aria-label': 'Select option',
25
+ },
26
+ } satisfies Meta<typeof SegmentedControl>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const TwoOption: Story = {
34
+ render: (_args) => {
35
+ const [role, setRole] = useState<'user' | 'admin'>('user');
36
+ return (
37
+ <SegmentedControl
38
+ value={role}
39
+ onChange={setRole}
40
+ options={[
41
+ { label: 'User', value: 'user' },
42
+ { label: 'Admin', value: 'admin' },
43
+ ]}
44
+ aria-label="Role"
45
+ />
46
+ );
47
+ },
48
+ };
49
+
50
+ export const ThreeOption: Story = {
51
+ render: (_args) => {
52
+ const [role, setRole] = useState<'user' | 'admin' | 'owner'>('user');
53
+ return (
54
+ <SegmentedControl
55
+ value={role}
56
+ onChange={setRole}
57
+ options={[
58
+ { label: 'User', value: 'user' },
59
+ { label: 'Admin', value: 'admin' },
60
+ { label: 'Owner', value: 'owner' },
61
+ ]}
62
+ aria-label="Role"
63
+ />
64
+ );
65
+ },
66
+ };
67
+
68
+ export const SmallSize: Story = {
69
+ render: (_args) => {
70
+ const [role, setRole] = useState<'user' | 'admin'>('user');
71
+ return (
72
+ <SegmentedControl
73
+ value={role}
74
+ onChange={setRole}
75
+ size="sm"
76
+ options={[
77
+ { label: 'User', value: 'user' },
78
+ { label: 'Admin', value: 'admin' },
79
+ ]}
80
+ aria-label="Role"
81
+ />
82
+ );
83
+ },
84
+ };
85
+
86
+ /** Table-row context — sm size next to other table content. */
87
+ export const InTableRow: Story = {
88
+ render: (_args) => {
89
+ const [role, setRole] = useState<'user' | 'admin'>('user');
90
+ return (
91
+ <div
92
+ style={{
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: 16,
96
+ padding: '10px 12px',
97
+ border: '1px solid var(--sv-color-border)',
98
+ borderRadius: 'var(--sv-radius-md)',
99
+ fontFamily: 'var(--sv-font-family)',
100
+ fontSize: 'var(--sv-font-size-sm)',
101
+ color: 'var(--sv-color-text-primary)',
102
+ }}
103
+ >
104
+ <span>Test User</span>
105
+ <SegmentedControl
106
+ value={role}
107
+ onChange={setRole}
108
+ size="sm"
109
+ options={[
110
+ { label: 'User', value: 'user' },
111
+ { label: 'Admin', value: 'admin' },
112
+ ]}
113
+ aria-label="User role"
114
+ />
115
+ </div>
116
+ );
117
+ },
118
+ };
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import styles from './SegmentedControl.module.css';
4
+
5
+ export interface SegmentedOption<T extends string = string> {
6
+ label: string;
7
+ value: T;
8
+ }
9
+
10
+ export interface SegmentedControlProps<T extends string = string> {
11
+ value: T;
12
+ onChange: (value: T) => void;
13
+ options: SegmentedOption<T>[];
14
+ size?: 'sm' | 'md';
15
+ 'aria-label': string;
16
+ }
17
+
18
+ /**
19
+ * SegmentedControl — pill-based 2–3 option picker for inline use in table rows
20
+ * and dialogs (e.g. User / Admin / Owner role switcher).
21
+ *
22
+ * Renders as `<div role="radiogroup">` with `<button role="radio">` children —
23
+ * correct semantics without a native `<input type="radio">` (which needs an id/name
24
+ * pair and a `<form>` context to function correctly in all browsers).
25
+ */
26
+ export function SegmentedControl<T extends string = string>({
27
+ value,
28
+ onChange,
29
+ options,
30
+ size = 'md',
31
+ 'aria-label': ariaLabel,
32
+ }: SegmentedControlProps<T>) {
33
+ return (
34
+ <div
35
+ role="radiogroup"
36
+ aria-label={ariaLabel}
37
+ className={[styles.track, styles[size]].join(' ')}
38
+ >
39
+ {options.map((opt) => {
40
+ const active = opt.value === value;
41
+ return (
42
+ <button
43
+ key={opt.value}
44
+ type="button"
45
+ role="radio"
46
+ aria-checked={active}
47
+ className={[styles.segment, active ? styles.active : styles.inactive].join(' ')}
48
+ onClick={() => onChange(opt.value)}
49
+ >
50
+ {opt.label}
51
+ </button>
52
+ );
53
+ })}
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,63 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+
5
+ afterEach(cleanup);
6
+ import { SegmentedControl } from '../SegmentedControl';
7
+
8
+ const options = [
9
+ { label: 'User', value: 'user' as const },
10
+ { label: 'Admin', value: 'admin' as const },
11
+ { label: 'Owner', value: 'owner' as const },
12
+ ];
13
+
14
+ describe('SegmentedControl', () => {
15
+ it('renders all options as radio buttons', () => {
16
+ render(
17
+ <SegmentedControl value="user" onChange={() => {}} options={options} aria-label="Role" />,
18
+ );
19
+ expect(screen.getAllByRole('radio').length).toBe(3);
20
+ });
21
+
22
+ it('marks the active option as checked', () => {
23
+ render(
24
+ <SegmentedControl value="admin" onChange={() => {}} options={options} aria-label="Role" />,
25
+ );
26
+ expect(screen.getByRole('radio', { name: 'Admin' }).getAttribute('aria-checked')).toBe('true');
27
+ expect(screen.getByRole('radio', { name: 'User' }).getAttribute('aria-checked')).toBe('false');
28
+ });
29
+
30
+ it('calls onChange with the selected value on click', () => {
31
+ const onChange = vi.fn();
32
+ render(
33
+ <SegmentedControl value="user" onChange={onChange} options={options} aria-label="Role" />,
34
+ );
35
+ fireEvent.click(screen.getByRole('radio', { name: 'Admin' }));
36
+ expect(onChange).toHaveBeenCalledWith('admin');
37
+ });
38
+
39
+ it('exposes a radiogroup with the given aria-label', () => {
40
+ render(
41
+ <SegmentedControl
42
+ value="user"
43
+ onChange={() => {}}
44
+ options={options}
45
+ aria-label="Select role"
46
+ />,
47
+ );
48
+ expect(screen.getByRole('radiogroup', { name: 'Select role' })).toBeDefined();
49
+ });
50
+
51
+ it('applies size class to the track', () => {
52
+ const { container } = render(
53
+ <SegmentedControl
54
+ value="user"
55
+ onChange={() => {}}
56
+ options={options}
57
+ size="sm"
58
+ aria-label="Role"
59
+ />,
60
+ );
61
+ expect((container.firstChild as HTMLElement).className).toContain('sm');
62
+ });
63
+ });