@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.
- package/LICENSE +661 -0
- package/NOTICE +24 -0
- package/dist/Avatar.module.css +38 -0
- package/dist/Badge.module.css +88 -0
- package/dist/Button.module.css +77 -0
- package/dist/Card.module.css +34 -0
- package/dist/Dialog.module.css +188 -0
- package/dist/Drawer.module.css +30 -0
- package/dist/EmptyState.module.css +27 -0
- package/dist/FormField.module.css +28 -0
- package/dist/Icon.module.css +22 -0
- package/dist/Input.module.css +37 -0
- package/dist/NavTabs.module.css +48 -0
- package/dist/PageHeader.module.css +32 -0
- package/dist/Popover.module.css +44 -0
- package/dist/SegmentedControl.module.css +53 -0
- package/dist/Select.module.css +69 -0
- package/dist/Spinner.module.css +39 -0
- package/dist/SystemBanner.module.css +67 -0
- package/dist/Tabs.module.css +49 -0
- package/dist/Toast.module.css +130 -0
- package/dist/Toggle.module.css +52 -0
- package/dist/Tooltip.module.css +51 -0
- package/dist/index.d.ts +327 -0
- package/dist/index.js +1370 -0
- package/dist/tokens/primitives.css +99 -0
- package/dist/tokens/semantic.css +111 -0
- package/dist/tokens.css +5 -0
- package/package.json +50 -0
- package/src/components/Avatar/Avatar.module.css +38 -0
- package/src/components/Avatar/Avatar.tsx +42 -0
- package/src/components/Badge/Badge.module.css +88 -0
- package/src/components/Badge/Badge.stories.tsx +118 -0
- package/src/components/Badge/Badge.tsx +59 -0
- package/src/components/Badge/__tests__/Badge.test.tsx +64 -0
- package/src/components/Button/Button.module.css +77 -0
- package/src/components/Button/Button.stories.tsx +76 -0
- package/src/components/Button/Button.tsx +36 -0
- package/src/components/Button/__tests__/Button.test.tsx +37 -0
- package/src/components/Card/Card.module.css +34 -0
- package/src/components/Card/Card.tsx +34 -0
- package/src/components/Dialog/Dialog.module.css +188 -0
- package/src/components/Dialog/Dialog.stories.tsx +97 -0
- package/src/components/Dialog/Dialog.tsx +137 -0
- package/src/components/Dialog/__tests__/Dialog.test.tsx +73 -0
- package/src/components/Drawer/Drawer.module.css +30 -0
- package/src/components/Drawer/Drawer.stories.tsx +88 -0
- package/src/components/Drawer/Drawer.tsx +103 -0
- package/src/components/Drawer/__tests__/Drawer.test.tsx +56 -0
- package/src/components/EmptyState/EmptyState.module.css +27 -0
- package/src/components/EmptyState/EmptyState.tsx +27 -0
- package/src/components/FormField/FormField.module.css +28 -0
- package/src/components/FormField/FormField.tsx +53 -0
- package/src/components/Icon/Icon.module.css +22 -0
- package/src/components/Icon/Icon.stories.tsx +94 -0
- package/src/components/Icon/Icon.tsx +43 -0
- package/src/components/Icon/__tests__/Icon.test.tsx +40 -0
- package/src/components/Icon/icons/activity.tsx +20 -0
- package/src/components/Icon/icons/alert-triangle.tsx +22 -0
- package/src/components/Icon/icons/bell.tsx +21 -0
- package/src/components/Icon/icons/check.tsx +20 -0
- package/src/components/Icon/icons/chevron-down.tsx +20 -0
- package/src/components/Icon/icons/chevron-left.tsx +20 -0
- package/src/components/Icon/icons/chevron-right.tsx +20 -0
- package/src/components/Icon/icons/chevron-up.tsx +20 -0
- package/src/components/Icon/icons/eye-off.tsx +23 -0
- package/src/components/Icon/icons/eye.tsx +21 -0
- package/src/components/Icon/icons/grid-2x2.tsx +22 -0
- package/src/components/Icon/icons/house.tsx +21 -0
- package/src/components/Icon/icons/index.ts +122 -0
- package/src/components/Icon/icons/info.tsx +22 -0
- package/src/components/Icon/icons/lock.tsx +21 -0
- package/src/components/Icon/icons/log-out.tsx +22 -0
- package/src/components/Icon/icons/mail.tsx +21 -0
- package/src/components/Icon/icons/package.tsx +23 -0
- package/src/components/Icon/icons/pencil.tsx +21 -0
- package/src/components/Icon/icons/plus.tsx +21 -0
- package/src/components/Icon/icons/rotate-ccw.tsx +21 -0
- package/src/components/Icon/icons/search.tsx +21 -0
- package/src/components/Icon/icons/settings.tsx +21 -0
- package/src/components/Icon/icons/shield.tsx +20 -0
- package/src/components/Icon/icons/sliders-horizontal.tsx +28 -0
- package/src/components/Icon/icons/terminal.tsx +19 -0
- package/src/components/Icon/icons/trash-2.tsx +24 -0
- package/src/components/Icon/icons/user.tsx +21 -0
- package/src/components/Icon/icons/x.tsx +21 -0
- package/src/components/Input/Input.module.css +37 -0
- package/src/components/Input/Input.stories.tsx +86 -0
- package/src/components/Input/Input.tsx +14 -0
- package/src/components/Input/__tests__/Input.test.tsx +22 -0
- package/src/components/NavTabs/NavTabs.module.css +48 -0
- package/src/components/NavTabs/NavTabs.tsx +33 -0
- package/src/components/PageHeader/PageHeader.module.css +32 -0
- package/src/components/PageHeader/PageHeader.tsx +21 -0
- package/src/components/Popover/Popover.module.css +44 -0
- package/src/components/Popover/Popover.stories.tsx +232 -0
- package/src/components/Popover/Popover.tsx +75 -0
- package/src/components/Popover/__tests__/Popover.test.tsx +98 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +53 -0
- package/src/components/SegmentedControl/SegmentedControl.stories.tsx +118 -0
- package/src/components/SegmentedControl/SegmentedControl.tsx +56 -0
- package/src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx +63 -0
- package/src/components/Select/Select.module.css +69 -0
- package/src/components/Select/Select.stories.tsx +115 -0
- package/src/components/Select/Select.tsx +40 -0
- package/src/components/Select/__tests__/Select.test.tsx +59 -0
- package/src/components/Spinner/Spinner.module.css +39 -0
- package/src/components/Spinner/Spinner.tsx +23 -0
- package/src/components/SystemBanner/SystemBanner.module.css +67 -0
- package/src/components/SystemBanner/SystemBanner.stories.tsx +86 -0
- package/src/components/SystemBanner/SystemBanner.tsx +50 -0
- package/src/components/SystemBanner/__tests__/SystemBanner.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +49 -0
- package/src/components/Tabs/Tabs.stories.tsx +100 -0
- package/src/components/Tabs/Tabs.tsx +46 -0
- package/src/components/Tabs/__tests__/Tabs.test.tsx +45 -0
- package/src/components/Toast/Toast.module.css +130 -0
- package/src/components/Toast/Toast.stories.tsx +191 -0
- package/src/components/Toast/Toast.tsx +139 -0
- package/src/components/Toggle/Toggle.module.css +52 -0
- package/src/components/Toggle/Toggle.stories.tsx +95 -0
- package/src/components/Toggle/Toggle.tsx +39 -0
- package/src/components/Toggle/__tests__/Toggle.test.tsx +47 -0
- package/src/components/Tooltip/Tooltip.module.css +51 -0
- package/src/components/Tooltip/Tooltip.tsx +30 -0
- package/src/css-modules.d.ts +4 -0
- package/src/index.ts +45 -0
- package/src/stories/Avatar.stories.tsx +44 -0
- package/src/stories/Card.stories.tsx +35 -0
- package/src/stories/DesignSystemOverview.stories.tsx +1683 -0
- package/src/stories/EmptyState.stories.tsx +33 -0
- package/src/stories/FormField.stories.tsx +47 -0
- package/src/stories/MobilePatterns.stories.tsx +1285 -0
- package/src/stories/NavTabs.stories.tsx +35 -0
- package/src/stories/PageHeader.stories.tsx +31 -0
- package/src/stories/Spinner.stories.tsx +25 -0
- package/src/stories/TokenGallery.stories.tsx +355 -0
- package/src/stories/Tooltip.stories.tsx +59 -0
- package/src/tokens/primitives.css +99 -0
- package/src/tokens/semantic.css +111 -0
- 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
|
+
});
|