@sovereignfs/ui 0.21.3 → 0.33.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/dist/Button.module.css +75 -8
- package/dist/Calendar.module.css +126 -0
- package/dist/Card.module.css +4 -2
- package/dist/Checkbox.module.css +109 -0
- package/dist/CodeTextarea.module.css +53 -0
- package/dist/ConfirmDialog.module.css +109 -0
- package/dist/DatePicker.module.css +57 -0
- package/dist/Dialog.module.css +73 -46
- package/dist/DragHandleRow.module.css +57 -0
- package/dist/Drawer.module.css +61 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Input.module.css +10 -0
- package/dist/Menu.module.css +82 -0
- package/dist/NavTabs.module.css +4 -2
- package/dist/OverlayHeader.module.css +98 -0
- package/dist/Popover.module.css +31 -1
- package/dist/SegmentedControl.module.css +4 -2
- package/dist/Select.module.css +9 -0
- package/dist/Sheet.module.css +55 -0
- package/dist/SplitPane.module.css +85 -0
- package/dist/StatusBadge.module.css +59 -0
- package/dist/SystemBanner.module.css +4 -2
- package/dist/Tabs.module.css +4 -2
- package/dist/TagInput.module.css +141 -0
- package/dist/Textarea.module.css +39 -0
- package/dist/Toast.module.css +7 -5
- package/dist/Toggle.module.css +1 -1
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +549 -20
- package/dist/index.js +1496 -193
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +19 -0
- package/package.json +1 -1
- package/src/__tests__/motion.test.tsx +105 -0
- package/src/components/Button/Button.module.css +75 -8
- package/src/components/Button/Button.stories.tsx +6 -0
- package/src/components/Button/Button.tsx +17 -1
- package/src/components/Button/__tests__/Button.test.tsx +19 -0
- package/src/components/Calendar/Calendar.module.css +126 -0
- package/src/components/Calendar/Calendar.tsx +213 -0
- package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
- package/src/components/Calendar/dateUtils.ts +97 -0
- package/src/components/Card/Card.module.css +4 -2
- package/src/components/Checkbox/Checkbox.module.css +109 -0
- package/src/components/Checkbox/Checkbox.tsx +78 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
- package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
- package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
- package/src/components/DatePicker/DatePicker.module.css +57 -0
- package/src/components/DatePicker/DatePicker.tsx +103 -0
- package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
- package/src/components/Dialog/Dialog.module.css +73 -46
- package/src/components/Dialog/Dialog.stories.tsx +51 -1
- package/src/components/Dialog/Dialog.tsx +86 -22
- package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
- package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- package/src/components/Drawer/Drawer.module.css +61 -0
- package/src/components/Drawer/Drawer.tsx +117 -13
- package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
- package/src/components/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- package/src/components/Icon/Icon.module.css +5 -0
- package/src/components/Icon/Icon.stories.tsx +4 -3
- package/src/components/Icon/Icon.tsx +1 -1
- package/src/components/Icon/icons/calendar.tsx +23 -0
- package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -4
- package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
- package/src/components/Input/Input.module.css +10 -0
- package/src/components/Menu/Menu.module.css +82 -0
- package/src/components/Menu/Menu.tsx +137 -0
- package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
- package/src/components/NavTabs/NavTabs.module.css +4 -2
- package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
- package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
- package/src/components/Popover/Popover.module.css +31 -1
- package/src/components/Popover/Popover.stories.tsx +126 -1
- package/src/components/Popover/Popover.tsx +140 -7
- package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
- package/src/components/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -2
- package/src/components/Sheet/Sheet.module.css +55 -0
- package/src/components/Sheet/Sheet.tsx +151 -0
- package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
- package/src/components/SplitPane/SplitPane.module.css +85 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
- package/src/components/SplitPane/SplitPane.tsx +149 -0
- package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
- package/src/components/StatusBadge/StatusBadge.module.css +59 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.tsx +65 -0
- package/src/components/SystemBanner/SystemBanner.module.css +4 -2
- package/src/components/Tabs/Tabs.module.css +4 -2
- package/src/components/TagInput/TagInput.module.css +141 -0
- package/src/components/TagInput/TagInput.stories.tsx +109 -0
- package/src/components/TagInput/TagInput.tsx +174 -0
- package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
- package/src/components/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +7 -5
- package/src/components/Toggle/Toggle.module.css +1 -1
- package/src/components/Tooltip/Tooltip.module.css +6 -1
- package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
- package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
- package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useDoubleTap.ts +91 -0
- package/src/hooks/useIsMobile.ts +33 -0
- package/src/hooks/useLongPress.ts +185 -0
- package/src/index.ts +37 -2
- package/src/motion.ts +78 -0
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +354 -8
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +87 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +19 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
--sv-font-weight-bold: 700;
|
|
79
79
|
|
|
80
80
|
/* Icon sizes — scale tokens used directly by the Icon component */
|
|
81
|
+
--sv-icon-size-xs: 0.75rem; /* 12px — inline metadata (e.g. a due-date badge) */
|
|
81
82
|
--sv-icon-size-sm: 1rem; /* 16px — inline with body text */
|
|
82
83
|
--sv-icon-size-md: 1.25rem; /* 20px — standard affordance */
|
|
83
84
|
--sv-icon-size-lg: 1.5rem; /* 24px — prominent / standalone */
|
|
@@ -96,4 +97,22 @@
|
|
|
96
97
|
touch without a pointing device. Applied to icon-only controls on mobile
|
|
97
98
|
(avatar, footer button, Drawer nav items). */
|
|
98
99
|
--sv-touch-target-min: 44px;
|
|
100
|
+
|
|
101
|
+
/* Motion scale — durations and easings for overlay enter/exit transitions
|
|
102
|
+
(Dialog, Drawer, and their Phase-B successors: Sheet, ConfirmDialog,
|
|
103
|
+
Menu). Theme-stable like the other scale tokens above — dark mode does
|
|
104
|
+
not change motion, so there is no semantic tier. Components must pair
|
|
105
|
+
every transition with `@media (prefers-reduced-motion: reduce)` collapsing
|
|
106
|
+
it to near-instant; the durations here are not a substitute for that. */
|
|
107
|
+
--sv-motion-duration-fast: 150ms; /* micro-interactions — hover/press feedback */
|
|
108
|
+
--sv-motion-duration-base: 250ms; /* default overlay enter/exit (Dialog, Drawer) */
|
|
109
|
+
--sv-motion-duration-slow: 350ms; /* larger surfaces / bigger travel distance */
|
|
110
|
+
--sv-motion-ease-out: cubic-bezier(0, 0, 0.2, 1); /* entrances — fast start, gentle settle */
|
|
111
|
+
--sv-motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* state changes with no fixed direction */
|
|
112
|
+
--sv-motion-ease-spring: cubic-bezier(
|
|
113
|
+
0.34,
|
|
114
|
+
1.56,
|
|
115
|
+
0.64,
|
|
116
|
+
1
|
|
117
|
+
); /* slight overshoot — playful emphasis, use sparingly */
|
|
99
118
|
}
|
package/dist/tokens/semantic.css
CHANGED
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
--sv-color-accent: var(--sv-grey-900);
|
|
27
27
|
--sv-color-accent-hover: var(--sv-grey-700);
|
|
28
|
+
/* Derived from --sv-color-accent via color-mix, so it re-themes automatically —
|
|
29
|
+
no separate dark-mode override needed. Tinted background for badges/chips
|
|
30
|
+
paired with --sv-color-accent text. */
|
|
31
|
+
--sv-color-accent-subtle: color-mix(in srgb, var(--sv-color-accent) 12%, transparent);
|
|
28
32
|
|
|
29
33
|
--sv-color-focus-ring: var(--sv-grey-900);
|
|
30
34
|
|
|
@@ -41,6 +45,19 @@
|
|
|
41
45
|
--sv-color-success-text: var(--sv-green-800);
|
|
42
46
|
--sv-color-success-border: var(--sv-green-200);
|
|
43
47
|
|
|
48
|
+
/* Status solids — a saturated fill for status affordances that need to
|
|
49
|
+
read as a filled action button rather than a tinted surface (e.g. a
|
|
50
|
+
mobile swipe-to-reveal Delete/Done action). Paired with
|
|
51
|
+
--sv-color-text-on-error/-success (white) for the icon/label on top.
|
|
52
|
+
Intentionally NOT overridden under [data-theme='dark'] below — like
|
|
53
|
+
--sv-color-accent-subtle, a solid action fill reads the same regardless
|
|
54
|
+
of overall page theme; it's the surrounding surface that inverts, not
|
|
55
|
+
this fixed-contrast pairing. */
|
|
56
|
+
--sv-color-error-solid: var(--sv-red-700);
|
|
57
|
+
--sv-color-success-solid: var(--sv-green-800);
|
|
58
|
+
--sv-color-text-on-error: var(--sv-white);
|
|
59
|
+
--sv-color-text-on-success: var(--sv-white);
|
|
60
|
+
|
|
44
61
|
/* Info surfaces — blue palette. Use for informational banners and badges.
|
|
45
62
|
Never hardcode colour hex in components — reference these tokens. */
|
|
46
63
|
--sv-color-info-surface: var(--sv-blue-100);
|
|
@@ -56,6 +73,7 @@
|
|
|
56
73
|
--sv-shadow-popover:
|
|
57
74
|
0 4px 16px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08); /* e2 — floating panels */
|
|
58
75
|
--sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.18), 0 6px 12px rgba(0, 0, 0, 0.12);
|
|
76
|
+
--sv-shadow-control: 0 1px 3px rgba(0, 0, 0, 0.2); /* small interactive-control shadows, e.g. Toggle thumb */
|
|
59
77
|
|
|
60
78
|
/*
|
|
61
79
|
* Instance identity tokens (RFC 0027 / RFC 0032 rename). Distinct namespace from --sv-color-*:
|
|
@@ -108,4 +126,5 @@
|
|
|
108
126
|
--sv-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
109
127
|
--sv-shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
110
128
|
--sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.6), 0 6px 12px rgba(0, 0, 0, 0.5);
|
|
129
|
+
--sv-shadow-control: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
111
130
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useMountTransition, usePrefersReducedMotion } from '../motion';
|
|
5
|
+
|
|
6
|
+
let matches = false;
|
|
7
|
+
function installMatchMedia() {
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
'matchMedia',
|
|
10
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
11
|
+
matches,
|
|
12
|
+
media: query,
|
|
13
|
+
addEventListener: vi.fn(),
|
|
14
|
+
removeEventListener: vi.fn(),
|
|
15
|
+
addListener: vi.fn(),
|
|
16
|
+
removeListener: vi.fn(),
|
|
17
|
+
dispatchEvent: vi.fn(),
|
|
18
|
+
})),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function MountTransitionHarness({ open, durationMs }: { open: boolean; durationMs: number }) {
|
|
23
|
+
const { mounted, phase } = useMountTransition(open, durationMs);
|
|
24
|
+
return <span data-testid="out">{mounted ? phase : 'unmounted'}</span>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('useMountTransition', () => {
|
|
28
|
+
beforeEach(() => vi.useFakeTimers());
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
cleanup();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('animates the entrance even on a fresh mount that starts open (e.g. a route-driven overlay)', () => {
|
|
35
|
+
const { getByTestId } = render(<MountTransitionHarness open durationMs={250} />);
|
|
36
|
+
// Renders 'entering' first — the closed-position styles — so the CSS
|
|
37
|
+
// transition has somewhere to animate from; the effect below flips it to
|
|
38
|
+
// 'open' one frame later, same as a prop-driven open transition would.
|
|
39
|
+
expect(getByTestId('out').textContent).toBe('entering');
|
|
40
|
+
act(() => void vi.advanceTimersByTime(16)); // flush the requestAnimationFrame callback
|
|
41
|
+
expect(getByTestId('out').textContent).toBe('open');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('starts unmounted when closed from the first render', () => {
|
|
45
|
+
const { getByTestId } = render(<MountTransitionHarness open={false} durationMs={250} />);
|
|
46
|
+
expect(getByTestId('out').textContent).toBe('unmounted');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('animates entrance: entering on the same frame, open one frame later', () => {
|
|
50
|
+
const { getByTestId, rerender } = render(
|
|
51
|
+
<MountTransitionHarness open={false} durationMs={250} />,
|
|
52
|
+
);
|
|
53
|
+
rerender(<MountTransitionHarness open durationMs={250} />);
|
|
54
|
+
expect(getByTestId('out').textContent).toBe('entering');
|
|
55
|
+
act(() => {
|
|
56
|
+
vi.advanceTimersByTime(16); // flush the requestAnimationFrame callback
|
|
57
|
+
});
|
|
58
|
+
expect(getByTestId('out').textContent).toBe('open');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('stays mounted through "closing" for durationMs, then unmounts', () => {
|
|
62
|
+
const { getByTestId, rerender } = render(<MountTransitionHarness open durationMs={250} />);
|
|
63
|
+
rerender(<MountTransitionHarness open={false} durationMs={250} />);
|
|
64
|
+
// Still mounted immediately after `open` flips false — this is the whole
|
|
65
|
+
// point: the exit transition needs to actually play, not jump-cut.
|
|
66
|
+
expect(getByTestId('out').textContent).toBe('closing');
|
|
67
|
+
act(() => void vi.advanceTimersByTime(249));
|
|
68
|
+
expect(getByTestId('out').textContent).toBe('closing');
|
|
69
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
70
|
+
expect(getByTestId('out').textContent).toBe('unmounted');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('honours a near-zero durationMs (the reduced-motion path)', () => {
|
|
74
|
+
const { getByTestId, rerender } = render(<MountTransitionHarness open durationMs={0} />);
|
|
75
|
+
rerender(<MountTransitionHarness open={false} durationMs={0} />);
|
|
76
|
+
act(() => void vi.advanceTimersByTime(0));
|
|
77
|
+
expect(getByTestId('out').textContent).toBe('unmounted');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function ReducedMotionHarness() {
|
|
82
|
+
const reduced = usePrefersReducedMotion();
|
|
83
|
+
return <span data-testid="out">{String(reduced)}</span>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('usePrefersReducedMotion', () => {
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.unstubAllGlobals();
|
|
89
|
+
cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('defaults to false and reflects a true matchMedia result after mount', () => {
|
|
93
|
+
matches = true;
|
|
94
|
+
installMatchMedia();
|
|
95
|
+
const { getByTestId } = render(<ReducedMotionHarness />);
|
|
96
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reflects a false matchMedia result', () => {
|
|
100
|
+
matches = false;
|
|
101
|
+
installMatchMedia();
|
|
102
|
+
const { getByTestId } = render(<ReducedMotionHarness />);
|
|
103
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
font-weight: var(--sv-font-weight-medium);
|
|
10
10
|
line-height: 1;
|
|
11
11
|
cursor: pointer;
|
|
12
|
+
/* Removes the ~300ms double-tap-to-zoom delay on touch — this is a tap
|
|
13
|
+
target, never a pinch-zoom surface. */
|
|
14
|
+
touch-action: manipulation;
|
|
12
15
|
transition:
|
|
13
16
|
background-color 0.15s ease,
|
|
14
17
|
border-color 0.15s ease,
|
|
@@ -25,6 +28,15 @@
|
|
|
25
28
|
cursor: not-allowed;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/* Touch target: gated to (pointer: coarse) — the *primary* pointer, not
|
|
32
|
+
(any-pointer: coarse) — so a touchscreen laptop with mouse/trackpad as
|
|
33
|
+
primary keeps desktop density (see mobile design-system plan, Part 4 #5). */
|
|
34
|
+
@media (pointer: coarse) {
|
|
35
|
+
.button {
|
|
36
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/* Sizes */
|
|
29
41
|
.sm {
|
|
30
42
|
padding: var(--sv-space-1) var(--sv-space-3);
|
|
@@ -37,14 +49,51 @@
|
|
|
37
49
|
font-weight: var(--sv-font-weight-semibold);
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/* Loading spinner — a self-contained token-only spinner rather than reusing
|
|
53
|
+
the Spinner component: Spinner's border colors are semantic-fixed
|
|
54
|
+
(`--sv-color-accent`), which would be invisible against a primary button's
|
|
55
|
+
accent background, and overriding a second CSS module's rule on the same
|
|
56
|
+
element is a cross-module cascade-order gamble (see ConfirmDialog's
|
|
57
|
+
`.destructiveConfirm` comment for the same tradeoff). `currentColor` picks
|
|
58
|
+
up each variant's own text color for free. */
|
|
59
|
+
.spinner {
|
|
60
|
+
display: inline-block;
|
|
61
|
+
width: var(--sv-icon-size-sm);
|
|
62
|
+
height: var(--sv-icon-size-sm);
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
border: 2px solid currentColor;
|
|
65
|
+
border-top-color: transparent;
|
|
66
|
+
border-radius: var(--sv-radius-full);
|
|
67
|
+
opacity: 0.7;
|
|
68
|
+
animation: button-spin 0.7s linear infinite;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@keyframes button-spin {
|
|
72
|
+
to {
|
|
73
|
+
transform: rotate(360deg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (prefers-reduced-motion: reduce) {
|
|
78
|
+
.spinner {
|
|
79
|
+
animation-play-state: paused;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
/* Variants */
|
|
41
84
|
.primary {
|
|
42
85
|
background-color: var(--sv-color-accent);
|
|
43
86
|
color: var(--sv-color-text-on-accent);
|
|
44
87
|
}
|
|
45
88
|
|
|
46
|
-
|
|
47
|
-
|
|
89
|
+
@media (hover: hover) {
|
|
90
|
+
.primary:hover:not(:disabled) {
|
|
91
|
+
background-color: var(--sv-color-accent-hover);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.primary:active:not(:disabled) {
|
|
96
|
+
background-color: color-mix(in srgb, var(--sv-color-accent) 80%, black);
|
|
48
97
|
}
|
|
49
98
|
|
|
50
99
|
.secondary {
|
|
@@ -53,8 +102,14 @@
|
|
|
53
102
|
border-color: var(--sv-color-border-strong);
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
|
|
57
|
-
|
|
105
|
+
@media (hover: hover) {
|
|
106
|
+
.secondary:hover:not(:disabled) {
|
|
107
|
+
background-color: var(--sv-color-surface-sunken);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.secondary:active:not(:disabled) {
|
|
112
|
+
background-color: color-mix(in srgb, var(--sv-color-surface-sunken) 85%, black);
|
|
58
113
|
}
|
|
59
114
|
|
|
60
115
|
.ghost {
|
|
@@ -62,8 +117,14 @@
|
|
|
62
117
|
color: var(--sv-color-text-primary);
|
|
63
118
|
}
|
|
64
119
|
|
|
65
|
-
|
|
66
|
-
|
|
120
|
+
@media (hover: hover) {
|
|
121
|
+
.ghost:hover:not(:disabled) {
|
|
122
|
+
background-color: var(--sv-color-surface-sunken);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ghost:active:not(:disabled) {
|
|
127
|
+
background-color: color-mix(in srgb, var(--sv-color-surface-sunken) 85%, black);
|
|
67
128
|
}
|
|
68
129
|
|
|
69
130
|
.destructive {
|
|
@@ -72,6 +133,12 @@
|
|
|
72
133
|
border-color: var(--sv-color-error-border);
|
|
73
134
|
}
|
|
74
135
|
|
|
75
|
-
|
|
76
|
-
|
|
136
|
+
@media (hover: hover) {
|
|
137
|
+
.destructive:hover:not(:disabled) {
|
|
138
|
+
background-color: var(--sv-color-error-surface);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.destructive:active:not(:disabled) {
|
|
143
|
+
background-color: color-mix(in srgb, var(--sv-color-error-surface) 85%, black);
|
|
77
144
|
}
|
|
@@ -18,6 +18,7 @@ const meta = {
|
|
|
18
18
|
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'destructive'] },
|
|
19
19
|
size: { control: 'select', options: ['sm', 'md'] },
|
|
20
20
|
disabled: { control: 'boolean' },
|
|
21
|
+
loading: { control: 'boolean' },
|
|
21
22
|
children: { control: 'text' },
|
|
22
23
|
},
|
|
23
24
|
args: {
|
|
@@ -41,6 +42,11 @@ export const Medium: Story = { args: { size: 'md' } };
|
|
|
41
42
|
export const Disabled: Story = { args: { disabled: true } };
|
|
42
43
|
export const DisabledSecondary: Story = { args: { variant: 'secondary', disabled: true } };
|
|
43
44
|
|
|
45
|
+
export const Loading: Story = { args: { loading: true, children: 'Saving…' } };
|
|
46
|
+
export const LoadingSecondary: Story = {
|
|
47
|
+
args: { variant: 'secondary', loading: true, children: 'Saving…' },
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
export const WithLeadingIcon: Story = {
|
|
45
51
|
args: {
|
|
46
52
|
children: (
|
|
@@ -7,6 +7,8 @@ export type ButtonSize = 'sm' | 'md';
|
|
|
7
7
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
8
|
variant?: ButtonVariant;
|
|
9
9
|
size?: ButtonSize;
|
|
10
|
+
/** Shows a spinner and disables the button while an async action is pending. */
|
|
11
|
+
loading?: boolean;
|
|
10
12
|
children: ReactNode;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -20,6 +22,8 @@ export function Button({
|
|
|
20
22
|
variant = 'primary',
|
|
21
23
|
size = 'md',
|
|
22
24
|
type = 'button',
|
|
25
|
+
loading = false,
|
|
26
|
+
disabled,
|
|
23
27
|
className,
|
|
24
28
|
children,
|
|
25
29
|
...rest
|
|
@@ -29,7 +33,19 @@ export function Button({
|
|
|
29
33
|
.join(' ');
|
|
30
34
|
|
|
31
35
|
return (
|
|
32
|
-
<button
|
|
36
|
+
<button
|
|
37
|
+
type={type}
|
|
38
|
+
className={classes}
|
|
39
|
+
disabled={disabled || loading}
|
|
40
|
+
aria-busy={loading || undefined}
|
|
41
|
+
{...rest}
|
|
42
|
+
>
|
|
43
|
+
{loading && (
|
|
44
|
+
// aria-hidden: the button's own accessible name (its visible children,
|
|
45
|
+
// unchanged below) plus aria-busy already communicate the pending
|
|
46
|
+
// state — an unhidden Spinner-style status region would double-narrate it.
|
|
47
|
+
<span className={styles.spinner} aria-hidden="true" />
|
|
48
|
+
)}
|
|
33
49
|
{children}
|
|
34
50
|
</button>
|
|
35
51
|
);
|
|
@@ -34,4 +34,23 @@ describe('Button', () => {
|
|
|
34
34
|
render(<Button variant="destructive">Delete</Button>);
|
|
35
35
|
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('destructive');
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
it('disables the button and sets aria-busy when loading', () => {
|
|
39
|
+
render(<Button loading>Submit</Button>);
|
|
40
|
+
const button = screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement;
|
|
41
|
+
expect(button.disabled).toBe(true);
|
|
42
|
+
expect(button.getAttribute('aria-busy')).toBe('true');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not set aria-busy when not loading', () => {
|
|
46
|
+
render(<Button>Continue</Button>);
|
|
47
|
+
expect(screen.getByRole('button', { name: 'Continue' }).getAttribute('aria-busy')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('respects an explicit disabled prop independent of loading', () => {
|
|
51
|
+
render(<Button disabled>Archive</Button>);
|
|
52
|
+
const button = screen.getByRole('button', { name: 'Archive' }) as HTMLButtonElement;
|
|
53
|
+
expect(button.disabled).toBe(true);
|
|
54
|
+
expect(button.getAttribute('aria-busy')).toBeNull();
|
|
55
|
+
});
|
|
37
56
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* Calendar — month grid. Tokens only. Grid columns are equal-width via
|
|
2
|
+
* `1fr` repeated 7 times rather than a fixed px size, so the component
|
|
3
|
+
* scales cleanly inside both a Popover panel (desktop) and a full-width
|
|
4
|
+
* Drawer sheet (mobile). */
|
|
5
|
+
|
|
6
|
+
.calendar {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
gap: var(--sv-space-3);
|
|
10
|
+
font-family: var(--sv-font-family);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.header {
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: space-between;
|
|
17
|
+
gap: var(--sv-space-2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.navButton {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
width: var(--sv-touch-target-min, 44px);
|
|
25
|
+
height: var(--sv-touch-target-min, 44px);
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
border: none;
|
|
28
|
+
border-radius: var(--sv-radius-md);
|
|
29
|
+
background: transparent;
|
|
30
|
+
color: var(--sv-color-text-muted);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@media (hover: hover) {
|
|
35
|
+
.navButton:hover {
|
|
36
|
+
background: var(--sv-color-surface-sunken);
|
|
37
|
+
color: var(--sv-color-text-primary);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.navButton:focus-visible {
|
|
42
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
43
|
+
outline-offset: -2px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.monthLabel {
|
|
47
|
+
font-size: var(--sv-font-size-sm);
|
|
48
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
49
|
+
color: var(--sv-color-text-primary);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.grid {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: var(--sv-space-1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.weekdayRow,
|
|
59
|
+
.week {
|
|
60
|
+
display: grid;
|
|
61
|
+
grid-template-columns: repeat(7, 1fr);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.weekday {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
height: var(--sv-space-6);
|
|
69
|
+
font-size: var(--sv-font-size-xs);
|
|
70
|
+
font-weight: var(--sv-font-weight-medium);
|
|
71
|
+
color: var(--sv-color-text-subtle);
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.day {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
width: 100%;
|
|
80
|
+
/* Square-ish cell that also clears the touch-target minimum — height
|
|
81
|
+
drives the grid's row size; width fills its 1fr column. */
|
|
82
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
83
|
+
border: none;
|
|
84
|
+
border-radius: var(--sv-radius-md);
|
|
85
|
+
background: transparent;
|
|
86
|
+
color: var(--sv-color-text-primary);
|
|
87
|
+
font-size: var(--sv-font-size-sm);
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@media (hover: hover) {
|
|
92
|
+
.day:hover:not(:disabled) {
|
|
93
|
+
background: var(--sv-color-surface-sunken);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.day:focus-visible {
|
|
98
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
99
|
+
outline-offset: -2px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.day:disabled {
|
|
103
|
+
color: var(--sv-color-text-subtle);
|
|
104
|
+
cursor: not-allowed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.dayOutside {
|
|
108
|
+
color: var(--sv-color-text-subtle);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.dayToday {
|
|
112
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
113
|
+
box-shadow: inset 0 0 0 1px var(--sv-color-border-strong);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.daySelected,
|
|
117
|
+
.daySelected:disabled {
|
|
118
|
+
background: var(--sv-color-accent);
|
|
119
|
+
color: var(--sv-color-text-on-accent);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@media (hover: hover) {
|
|
123
|
+
.daySelected:hover:not(:disabled) {
|
|
124
|
+
background: var(--sv-color-accent-hover);
|
|
125
|
+
}
|
|
126
|
+
}
|