@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,100 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { Tabs } from './Tabs';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Tabs',
|
|
7
|
+
component: Tabs,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Underline tab nav. Stateless — caller owns `value` + `onChange` and renders the active panel. Mobile: scrolls horizontally with the scrollbar hidden.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
items: [{ label: 'Tab', value: 'tab' }],
|
|
19
|
+
value: 'tab',
|
|
20
|
+
onChange: () => {},
|
|
21
|
+
'aria-label': 'Tabs',
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof Tabs>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const CONSOLE_TABS = [
|
|
31
|
+
{ label: 'Overview', value: 'overview' },
|
|
32
|
+
{ label: 'Users', value: 'users' },
|
|
33
|
+
{ label: 'Plugins', value: 'plugins' },
|
|
34
|
+
{ label: 'Settings', value: 'settings' },
|
|
35
|
+
{ label: 'Health', value: 'health' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const ACCOUNT_TABS = [
|
|
39
|
+
{ label: 'Profile', value: 'profile' },
|
|
40
|
+
{ label: 'Security', value: 'security' },
|
|
41
|
+
{ label: 'Appearance', value: 'appearance' },
|
|
42
|
+
{ label: 'Notifications', value: 'notifications' },
|
|
43
|
+
{ label: 'Billing', value: 'billing' },
|
|
44
|
+
{ label: 'Data', value: 'data' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Console panel navigation. */
|
|
48
|
+
export const Console: Story = {
|
|
49
|
+
render: (_args) => {
|
|
50
|
+
const [tab, setTab] = useState('overview');
|
|
51
|
+
return (
|
|
52
|
+
<div style={{ fontFamily: 'var(--sv-font-family)' }}>
|
|
53
|
+
<Tabs items={CONSOLE_TABS} value={tab} onChange={setTab} aria-label="Console" />
|
|
54
|
+
<div
|
|
55
|
+
style={{
|
|
56
|
+
padding: 'var(--sv-space-4)',
|
|
57
|
+
color: 'var(--sv-color-text-muted)',
|
|
58
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
Showing: <strong style={{ color: 'var(--sv-color-text-primary)' }}>{tab}</strong>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Account settings — more tabs, scrolls on narrow viewports. */
|
|
69
|
+
export const Account: Story = {
|
|
70
|
+
render: (_args) => {
|
|
71
|
+
const [tab, setTab] = useState('profile');
|
|
72
|
+
return (
|
|
73
|
+
<div style={{ fontFamily: 'var(--sv-font-family)', maxWidth: 480 }}>
|
|
74
|
+
<Tabs items={ACCOUNT_TABS} value={tab} onChange={setTab} aria-label="Account settings" />
|
|
75
|
+
<div
|
|
76
|
+
style={{
|
|
77
|
+
padding: 'var(--sv-space-4)',
|
|
78
|
+
color: 'var(--sv-color-text-muted)',
|
|
79
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
Showing: <strong style={{ color: 'var(--sv-color-text-primary)' }}>{tab}</strong>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Narrow viewport — confirms horizontal scroll behaviour. */
|
|
90
|
+
export const NarrowScroll: Story = {
|
|
91
|
+
parameters: { viewport: { defaultViewport: 'mobile1' } },
|
|
92
|
+
render: (_args) => {
|
|
93
|
+
const [tab, setTab] = useState('profile');
|
|
94
|
+
return (
|
|
95
|
+
<div style={{ fontFamily: 'var(--sv-font-family)' }}>
|
|
96
|
+
<Tabs items={ACCOUNT_TABS} value={tab} onChange={setTab} aria-label="Account settings" />
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import styles from './Tabs.module.css';
|
|
4
|
+
|
|
5
|
+
export interface TabItem {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TabsProps {
|
|
11
|
+
items: TabItem[];
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
'aria-label': string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tabs — underline tab nav for section switching.
|
|
19
|
+
*
|
|
20
|
+
* Stateless: the active tab is fully controlled via `value` + `onChange`, so the
|
|
21
|
+
* caller owns state and controls the rendered panel content. This keeps the
|
|
22
|
+
* component RSC-compatible when wrapped by a client boundary upstream.
|
|
23
|
+
*
|
|
24
|
+
* Mobile: the tab bar scrolls horizontally with the native scrollbar hidden.
|
|
25
|
+
*/
|
|
26
|
+
export function Tabs({ items, value, onChange, 'aria-label': ariaLabel }: TabsProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div role="tablist" aria-label={ariaLabel} className={styles.tablist}>
|
|
29
|
+
{items.map((item) => {
|
|
30
|
+
const active = item.value === value;
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
key={item.value}
|
|
34
|
+
type="button"
|
|
35
|
+
role="tab"
|
|
36
|
+
aria-selected={active}
|
|
37
|
+
className={[styles.tab, active ? styles.active : styles.inactive].join(' ')}
|
|
38
|
+
onClick={() => onChange(item.value)}
|
|
39
|
+
>
|
|
40
|
+
{item.label}
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Tabs } from '../Tabs';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
const items = [
|
|
9
|
+
{ label: 'Overview', value: 'overview' },
|
|
10
|
+
{ label: 'Users', value: 'users' },
|
|
11
|
+
{ label: 'Plugins', value: 'plugins' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('Tabs', () => {
|
|
15
|
+
it('renders all items as tab buttons', () => {
|
|
16
|
+
render(<Tabs items={items} value="overview" onChange={() => {}} aria-label="Console" />);
|
|
17
|
+
expect(screen.getAllByRole('tab').length).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('marks the active tab as selected', () => {
|
|
21
|
+
render(<Tabs items={items} value="users" onChange={() => {}} aria-label="Console" />);
|
|
22
|
+
expect(screen.getByRole('tab', { name: 'Users' }).getAttribute('aria-selected')).toBe('true');
|
|
23
|
+
expect(screen.getByRole('tab', { name: 'Overview' }).getAttribute('aria-selected')).toBe(
|
|
24
|
+
'false',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('applies active class to the selected tab', () => {
|
|
29
|
+
render(<Tabs items={items} value="plugins" onChange={() => {}} aria-label="Console" />);
|
|
30
|
+
expect(screen.getByRole('tab', { name: 'Plugins' }).className).toContain('active');
|
|
31
|
+
expect(screen.getByRole('tab', { name: 'Overview' }).className).toContain('inactive');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('calls onChange with the clicked tab value', () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
render(<Tabs items={items} value="overview" onChange={onChange} aria-label="Console" />);
|
|
37
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Users' }));
|
|
38
|
+
expect(onChange).toHaveBeenCalledWith('users');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('exposes a tablist with the given aria-label', () => {
|
|
42
|
+
render(<Tabs items={items} value="overview" onChange={() => {}} aria-label="Nav" />);
|
|
43
|
+
expect(screen.getByRole('tablist', { name: 'Nav' })).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* Toast — transient notification snackbar.
|
|
2
|
+
*
|
|
3
|
+
* Fixed top-right, 320px. Slides in from above. Each toast auto-dismisses
|
|
4
|
+
* after `duration` ms. Leading status icon reinforces category at a glance.
|
|
5
|
+
*
|
|
6
|
+
* Category variants map to the semantic status tokens (info/success/warning/
|
|
7
|
+
* error) so the visual language is consistent with banners and badges.
|
|
8
|
+
* The announcement category uses the accent colour (neutral brand highlight).
|
|
9
|
+
* `security` matches the error palette — draws maximum attention. */
|
|
10
|
+
|
|
11
|
+
.region {
|
|
12
|
+
position: fixed;
|
|
13
|
+
top: var(--sv-space-4);
|
|
14
|
+
right: var(--sv-space-4);
|
|
15
|
+
z-index: 200;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: var(--sv-space-2);
|
|
19
|
+
width: 320px;
|
|
20
|
+
max-width: calc(100vw - var(--sv-space-8));
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.toast {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: flex-start;
|
|
27
|
+
gap: var(--sv-space-3);
|
|
28
|
+
padding: var(--sv-space-3) var(--sv-space-4);
|
|
29
|
+
background: var(--sv-color-surface-raised);
|
|
30
|
+
color: var(--sv-color-text-primary);
|
|
31
|
+
border: 1px solid var(--sv-color-border);
|
|
32
|
+
border-radius: var(--sv-radius-md);
|
|
33
|
+
box-shadow: var(--sv-shadow-overlay);
|
|
34
|
+
pointer-events: all;
|
|
35
|
+
animation: toastIn 200ms ease-out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.toast.exiting {
|
|
39
|
+
animation: toastOut 200ms ease-in forwards;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Leading status icon */
|
|
43
|
+
.icon {
|
|
44
|
+
flex: 0 0 auto;
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
padding-top: 1px;
|
|
48
|
+
color: var(--sv-color-border-strong);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.toast.info .icon,
|
|
52
|
+
.toast.announcement .icon {
|
|
53
|
+
color: var(--sv-color-accent);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.toast.success .icon {
|
|
57
|
+
color: var(--sv-color-success-text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.toast.warning .icon {
|
|
61
|
+
color: var(--sv-color-warning-text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.toast.error .icon,
|
|
65
|
+
.toast.security .icon {
|
|
66
|
+
color: var(--sv-color-error-text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.body {
|
|
70
|
+
flex: 1 1 auto;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.title {
|
|
75
|
+
font-size: var(--sv-font-size-sm);
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
line-height: 1.4;
|
|
78
|
+
color: var(--sv-color-text-primary);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.message {
|
|
82
|
+
font-size: var(--sv-font-size-sm);
|
|
83
|
+
color: var(--sv-color-text-secondary);
|
|
84
|
+
margin-top: var(--sv-space-1);
|
|
85
|
+
line-height: 1.4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.close {
|
|
89
|
+
flex: 0 0 auto;
|
|
90
|
+
background: none;
|
|
91
|
+
border: none;
|
|
92
|
+
padding: 0;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
color: var(--sv-color-text-tertiary);
|
|
95
|
+
line-height: 1;
|
|
96
|
+
font-size: var(--sv-font-size-base);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.close:hover {
|
|
100
|
+
color: var(--sv-color-text-primary);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes toastIn {
|
|
104
|
+
from {
|
|
105
|
+
opacity: 0;
|
|
106
|
+
transform: translateY(-8px);
|
|
107
|
+
}
|
|
108
|
+
to {
|
|
109
|
+
opacity: 1;
|
|
110
|
+
transform: translateY(0);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@keyframes toastOut {
|
|
115
|
+
from {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
transform: translateY(0);
|
|
118
|
+
}
|
|
119
|
+
to {
|
|
120
|
+
opacity: 0;
|
|
121
|
+
transform: translateY(-8px);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@media (prefers-reduced-motion: reduce) {
|
|
126
|
+
.toast,
|
|
127
|
+
.toast.exiting {
|
|
128
|
+
animation: none;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { ToastProvider, useToast } from './Toast';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Toast',
|
|
7
|
+
component: ToastProvider,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Fixed top-right notification stack. Use `<ToastProvider>` at the app root and `useToast()` to imperatively show toasts. Each toast has a leading status icon matched to its category.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
children: null,
|
|
19
|
+
},
|
|
20
|
+
} satisfies Meta<typeof ToastProvider>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function ToastTrigger({ category, label }: { category: string; label: string }) {
|
|
28
|
+
const { show } = useToast();
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() =>
|
|
33
|
+
show({
|
|
34
|
+
title: label,
|
|
35
|
+
message: `This is a ${category} toast notification.`,
|
|
36
|
+
category,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
style={{
|
|
40
|
+
padding: '8px 16px',
|
|
41
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
42
|
+
border: '1px solid var(--sv-color-border)',
|
|
43
|
+
background: 'var(--sv-color-surface)',
|
|
44
|
+
color: 'var(--sv-color-text-primary)',
|
|
45
|
+
fontFamily: 'var(--sv-font-family)',
|
|
46
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
47
|
+
cursor: 'pointer',
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** All toast categories — each button fires a toast with the matching leading icon. */
|
|
56
|
+
export const AllCategories: Story = {
|
|
57
|
+
render: (_args) => (
|
|
58
|
+
<ToastProvider>
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
display: 'flex',
|
|
62
|
+
flexDirection: 'column',
|
|
63
|
+
gap: 8,
|
|
64
|
+
alignItems: 'flex-start',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<ToastTrigger category="info" label="Info toast" />
|
|
68
|
+
<ToastTrigger category="success" label="Success toast" />
|
|
69
|
+
<ToastTrigger category="warning" label="Warning toast" />
|
|
70
|
+
<ToastTrigger category="error" label="Error toast" />
|
|
71
|
+
<ToastTrigger category="security" label="Security toast" />
|
|
72
|
+
<ToastTrigger category="announcement" label="Announcement toast" />
|
|
73
|
+
</div>
|
|
74
|
+
</ToastProvider>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Toast with no body — title only. */
|
|
79
|
+
export const TitleOnly: Story = {
|
|
80
|
+
render: (_args) => {
|
|
81
|
+
function Trigger() {
|
|
82
|
+
const { show } = useToast();
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => show({ title: 'Session expires in 5 minutes', category: 'warning' })}
|
|
87
|
+
style={{
|
|
88
|
+
padding: '8px 16px',
|
|
89
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
90
|
+
border: '1px solid var(--sv-color-border)',
|
|
91
|
+
background: 'var(--sv-color-surface)',
|
|
92
|
+
color: 'var(--sv-color-text-primary)',
|
|
93
|
+
fontFamily: 'var(--sv-font-family)',
|
|
94
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
Show title-only toast
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return (
|
|
103
|
+
<ToastProvider>
|
|
104
|
+
<Trigger />
|
|
105
|
+
</ToastProvider>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Persistent toast — pass `duration: 0` to disable auto-dismiss. */
|
|
111
|
+
export const Persistent: Story = {
|
|
112
|
+
render: (_args) => {
|
|
113
|
+
function Trigger() {
|
|
114
|
+
const { show } = useToast();
|
|
115
|
+
return (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() =>
|
|
119
|
+
show({
|
|
120
|
+
title: 'Maintenance mode active',
|
|
121
|
+
message: 'Platform is in read-only mode. Dismiss when resolved.',
|
|
122
|
+
category: 'error',
|
|
123
|
+
duration: 0,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
style={{
|
|
127
|
+
padding: '8px 16px',
|
|
128
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
129
|
+
border: '1px solid var(--sv-color-border)',
|
|
130
|
+
background: 'var(--sv-color-surface)',
|
|
131
|
+
color: 'var(--sv-color-text-primary)',
|
|
132
|
+
fontFamily: 'var(--sv-font-family)',
|
|
133
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
134
|
+
cursor: 'pointer',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
Show persistent toast
|
|
138
|
+
</button>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<ToastProvider>
|
|
143
|
+
<Trigger />
|
|
144
|
+
</ToastProvider>
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Multiple toasts stack vertically below the first. */
|
|
150
|
+
export const Stacked: Story = {
|
|
151
|
+
render: (_args) => {
|
|
152
|
+
function Trigger() {
|
|
153
|
+
const { show } = useToast();
|
|
154
|
+
const [fired, setFired] = useState(false);
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
disabled={fired}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
show({ title: 'Plugin installed', category: 'success' });
|
|
161
|
+
show({
|
|
162
|
+
title: 'License warning',
|
|
163
|
+
message: 'Expires in 3 days.',
|
|
164
|
+
category: 'warning',
|
|
165
|
+
});
|
|
166
|
+
show({ title: 'Backup failed', message: 'Check storage quota.', category: 'error' });
|
|
167
|
+
setFired(true);
|
|
168
|
+
}}
|
|
169
|
+
style={{
|
|
170
|
+
padding: '8px 16px',
|
|
171
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
172
|
+
border: '1px solid var(--sv-color-border)',
|
|
173
|
+
background: 'var(--sv-color-surface)',
|
|
174
|
+
color: 'var(--sv-color-text-primary)',
|
|
175
|
+
fontFamily: 'var(--sv-font-family)',
|
|
176
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
177
|
+
cursor: fired ? 'default' : 'pointer',
|
|
178
|
+
opacity: fired ? 0.5 : 1,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
Fire 3 toasts
|
|
182
|
+
</button>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return (
|
|
186
|
+
<ToastProvider>
|
|
187
|
+
<Trigger />
|
|
188
|
+
</ToastProvider>
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { Icon, type IconName } from '../Icon/Icon';
|
|
14
|
+
import styles from './Toast.module.css';
|
|
15
|
+
|
|
16
|
+
export interface ToastItem {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
/** `'info'` | `'success'` | `'warning'` | `'error'` | `'security'` | `'announcement'` */
|
|
21
|
+
category?: string;
|
|
22
|
+
/** Auto-dismiss delay in ms. Default 5000. Pass `0` to disable auto-dismiss. */
|
|
23
|
+
duration?: number;
|
|
24
|
+
exiting?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ToastContextValue {
|
|
28
|
+
/** Show a toast notification. Returns the generated toast id. */
|
|
29
|
+
show(item: Omit<ToastItem, 'id'>): string;
|
|
30
|
+
/** Dismiss a toast by id. */
|
|
31
|
+
dismiss(id: string): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
35
|
+
|
|
36
|
+
/** Consume the toast context to imperatively show toasts from any client component. */
|
|
37
|
+
export function useToast(): ToastContextValue {
|
|
38
|
+
const ctx = useContext(ToastContext);
|
|
39
|
+
if (!ctx) throw new Error('useToast() must be used inside <ToastProvider>');
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
44
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
45
|
+
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
46
|
+
const seedId = useId();
|
|
47
|
+
const counter = useRef(0);
|
|
48
|
+
|
|
49
|
+
const dismiss = useCallback((id: string) => {
|
|
50
|
+
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, exiting: true } : t)));
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
53
|
+
}, 220);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const show = useCallback(
|
|
57
|
+
(item: Omit<ToastItem, 'id'>): string => {
|
|
58
|
+
const id = `${seedId}-${++counter.current}`;
|
|
59
|
+
const duration = item.duration ?? 5000;
|
|
60
|
+
setToasts((prev) => [...prev, { ...item, id }]);
|
|
61
|
+
if (duration > 0) {
|
|
62
|
+
const timer = setTimeout(() => dismiss(id), duration);
|
|
63
|
+
timers.current.set(id, timer);
|
|
64
|
+
}
|
|
65
|
+
return id;
|
|
66
|
+
},
|
|
67
|
+
[seedId, dismiss],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const currentTimers = timers.current;
|
|
72
|
+
return () => {
|
|
73
|
+
currentTimers.forEach((t) => clearTimeout(t));
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const value = useMemo(() => ({ show, dismiss }), [show, dismiss]);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<ToastContext.Provider value={value}>
|
|
81
|
+
{children}
|
|
82
|
+
<ol
|
|
83
|
+
className={styles.region}
|
|
84
|
+
aria-label="Notifications"
|
|
85
|
+
aria-live="polite"
|
|
86
|
+
aria-atomic="false"
|
|
87
|
+
>
|
|
88
|
+
{toasts.map((toast) => (
|
|
89
|
+
<Toast key={toast.id} {...toast} onDismiss={dismiss} />
|
|
90
|
+
))}
|
|
91
|
+
</ol>
|
|
92
|
+
</ToastContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Maps toast category to a leading Icon name. */
|
|
97
|
+
const CATEGORY_ICON: Record<string, IconName> = {
|
|
98
|
+
info: 'info',
|
|
99
|
+
announcement: 'info',
|
|
100
|
+
success: 'check',
|
|
101
|
+
warning: 'alert-triangle',
|
|
102
|
+
error: 'alert-triangle',
|
|
103
|
+
security: 'alert-triangle',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function Toast({
|
|
107
|
+
id,
|
|
108
|
+
title,
|
|
109
|
+
message,
|
|
110
|
+
category = 'info',
|
|
111
|
+
exiting,
|
|
112
|
+
onDismiss,
|
|
113
|
+
}: ToastItem & { onDismiss(id: string): void }) {
|
|
114
|
+
const categoryClass = styles[category as keyof typeof styles] ?? styles.info;
|
|
115
|
+
const iconName = CATEGORY_ICON[category] ?? 'info';
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<li
|
|
119
|
+
role="status"
|
|
120
|
+
className={`${styles.toast} ${String(categoryClass)} ${exiting ? styles.exiting : ''}`}
|
|
121
|
+
>
|
|
122
|
+
<span className={styles.icon} aria-hidden="true">
|
|
123
|
+
<Icon name={iconName} size="sm" aria-hidden />
|
|
124
|
+
</span>
|
|
125
|
+
<div className={styles.body}>
|
|
126
|
+
<div className={styles.title}>{title}</div>
|
|
127
|
+
{message && <div className={styles.message}>{message}</div>}
|
|
128
|
+
</div>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className={styles.close}
|
|
132
|
+
aria-label="Dismiss notification"
|
|
133
|
+
onClick={() => onDismiss(id)}
|
|
134
|
+
>
|
|
135
|
+
✕
|
|
136
|
+
</button>
|
|
137
|
+
</li>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
.track {
|
|
2
|
+
/* 38×22px pill — spec dimensions */
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
width: 38px;
|
|
6
|
+
height: 22px;
|
|
7
|
+
border-radius: var(--sv-radius-full);
|
|
8
|
+
border: none;
|
|
9
|
+
padding: 0;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
flex-shrink: 0;
|
|
12
|
+
transition: background-color 0.15s ease;
|
|
13
|
+
position: relative;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.track:focus-visible {
|
|
17
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
18
|
+
outline-offset: 2px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.track:disabled {
|
|
22
|
+
opacity: 0.5;
|
|
23
|
+
cursor: not-allowed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.on {
|
|
27
|
+
background-color: var(--sv-color-accent);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.off {
|
|
31
|
+
background-color: var(--sv-color-border-strong);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* 18px thumb, 2px inset on each side */
|
|
35
|
+
.thumb {
|
|
36
|
+
position: absolute;
|
|
37
|
+
width: 18px;
|
|
38
|
+
height: 18px;
|
|
39
|
+
border-radius: var(--sv-radius-full);
|
|
40
|
+
background: var(--sv-white);
|
|
41
|
+
transition: transform 0.15s ease;
|
|
42
|
+
left: 2px;
|
|
43
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.on .thumb {
|
|
47
|
+
transform: translateX(16px); /* 38 - 18 - 2 - 2 = 16 */
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.off .thumb {
|
|
51
|
+
transform: translateX(0);
|
|
52
|
+
}
|