@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.
Files changed (130) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +109 -0
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. 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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.21.3",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -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
- .primary:hover:not(:disabled) {
47
- background-color: var(--sv-color-accent-hover);
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
- .secondary:hover:not(:disabled) {
57
- background-color: var(--sv-color-surface-sunken);
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
- .ghost:hover:not(:disabled) {
66
- background-color: var(--sv-color-surface-sunken);
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
- .destructive:hover:not(:disabled) {
76
- background-color: var(--sv-color-error-surface);
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 type={type} className={classes} {...rest}>
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
+ }