@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type KeyboardEvent as ReactKeyboardEvent, useRef, useState } from 'react';
|
|
4
|
+
import { Icon } from '../Icon/Icon';
|
|
5
|
+
import {
|
|
6
|
+
addDays,
|
|
7
|
+
addMonths,
|
|
8
|
+
formatDateLabel,
|
|
9
|
+
formatMonthYear,
|
|
10
|
+
formatWeekdayShort,
|
|
11
|
+
getMonthGrid,
|
|
12
|
+
isSameDay,
|
|
13
|
+
isSameMonth,
|
|
14
|
+
isWithinRange,
|
|
15
|
+
startOfMonth,
|
|
16
|
+
} from './dateUtils';
|
|
17
|
+
import styles from './Calendar.module.css';
|
|
18
|
+
|
|
19
|
+
export interface CalendarProps {
|
|
20
|
+
/** Selected date, or `null` for no selection. */
|
|
21
|
+
value: Date | null;
|
|
22
|
+
onChange: (date: Date) => void;
|
|
23
|
+
/** Dates before this are disabled and unselectable. */
|
|
24
|
+
minDate?: Date;
|
|
25
|
+
/** Dates after this are disabled and unselectable. */
|
|
26
|
+
maxDate?: Date;
|
|
27
|
+
'aria-label'?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A week's worth of dates, used once to derive the weekday header labels —
|
|
31
|
+
// any Sunday-starting week works since only the day-of-week repeats.
|
|
32
|
+
// getMonthGrid always returns 6 weeks (see its own doc comment), so index 0
|
|
33
|
+
// is guaranteed to exist — the `?? []` is purely to satisfy the lint rule
|
|
34
|
+
// against non-null assertions, not a real runtime possibility.
|
|
35
|
+
const WEEKDAY_LABEL_WEEK = getMonthGrid(new Date(2026, 0, 1))[0] ?? [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calendar — a keyboard-navigable month grid. Date-only (no time or range
|
|
39
|
+
* selection — decision D6 in the mobile design-system plan); a `DatePicker`
|
|
40
|
+
* field wraps this in a `Popover` (desktop) or `Drawer` (mobile).
|
|
41
|
+
*
|
|
42
|
+
* Keyboard (WAI-ARIA APG grid pattern, roving tabindex — exactly one day
|
|
43
|
+
* button is ever tab-stoppable): arrow keys move focus by day/week, Home/End
|
|
44
|
+
* jump to the start/end of the focused week, PageUp/PageDown change month,
|
|
45
|
+
* Enter/Space selects the focused date. Navigating into an adjacent month
|
|
46
|
+
* (arrow keys at a week's edge, or PageUp/PageDown) updates the displayed
|
|
47
|
+
* month to follow focus.
|
|
48
|
+
*/
|
|
49
|
+
export function Calendar({
|
|
50
|
+
value,
|
|
51
|
+
onChange,
|
|
52
|
+
minDate,
|
|
53
|
+
maxDate,
|
|
54
|
+
'aria-label': ariaLabel,
|
|
55
|
+
}: CalendarProps) {
|
|
56
|
+
const [displayedMonth, setDisplayedMonth] = useState(() => startOfMonth(value ?? new Date()));
|
|
57
|
+
// The roving-tabindex date. Independent of `value` — you can arrow around
|
|
58
|
+
// without selecting; Enter/Space is what commits a selection.
|
|
59
|
+
const [focusedDate, setFocusedDate] = useState(() => value ?? new Date());
|
|
60
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
61
|
+
|
|
62
|
+
const weeks = getMonthGrid(displayedMonth);
|
|
63
|
+
|
|
64
|
+
function goToMonth(next: Date) {
|
|
65
|
+
setDisplayedMonth(startOfMonth(next));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function moveFocus(next: Date) {
|
|
69
|
+
setFocusedDate(next);
|
|
70
|
+
if (!isSameMonth(next, displayedMonth)) goToMonth(next);
|
|
71
|
+
// Focus is applied after the grid re-renders with the new roving
|
|
72
|
+
// tabindex target — see the ref callback below on each day button,
|
|
73
|
+
// which focuses itself when it becomes the tabIndex=0 cell following a
|
|
74
|
+
// keyboard-driven focus change (queued via requestAnimationFrame so the
|
|
75
|
+
// DOM has committed the new grid first).
|
|
76
|
+
requestAnimationFrame(() => {
|
|
77
|
+
gridRef.current?.querySelector<HTMLButtonElement>('[tabindex="0"]')?.focus();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function selectDate(date: Date) {
|
|
82
|
+
if (!isWithinRange(date, minDate, maxDate)) return;
|
|
83
|
+
onChange(date);
|
|
84
|
+
setFocusedDate(date);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleKeyDown(e: ReactKeyboardEvent) {
|
|
88
|
+
switch (e.key) {
|
|
89
|
+
case 'ArrowLeft':
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
moveFocus(addDays(focusedDate, -1));
|
|
92
|
+
return;
|
|
93
|
+
case 'ArrowRight':
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
moveFocus(addDays(focusedDate, 1));
|
|
96
|
+
return;
|
|
97
|
+
case 'ArrowUp':
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
moveFocus(addDays(focusedDate, -7));
|
|
100
|
+
return;
|
|
101
|
+
case 'ArrowDown':
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
moveFocus(addDays(focusedDate, 7));
|
|
104
|
+
return;
|
|
105
|
+
case 'Home':
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
moveFocus(addDays(focusedDate, -focusedDate.getDay()));
|
|
108
|
+
return;
|
|
109
|
+
case 'End':
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
moveFocus(addDays(focusedDate, 6 - focusedDate.getDay()));
|
|
112
|
+
return;
|
|
113
|
+
case 'PageUp':
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
moveFocus(addMonths(focusedDate, -1));
|
|
116
|
+
return;
|
|
117
|
+
case 'PageDown':
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
moveFocus(addMonths(focusedDate, 1));
|
|
120
|
+
return;
|
|
121
|
+
case 'Enter':
|
|
122
|
+
case ' ':
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
selectDate(focusedDate);
|
|
125
|
+
return;
|
|
126
|
+
default:
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={styles.calendar} aria-label={ariaLabel}>
|
|
132
|
+
<div className={styles.header}>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className={styles.navButton}
|
|
136
|
+
aria-label="Previous month"
|
|
137
|
+
onClick={() => goToMonth(addMonths(displayedMonth, -1))}
|
|
138
|
+
>
|
|
139
|
+
<Icon name="chevron-left" size="sm" aria-hidden />
|
|
140
|
+
</button>
|
|
141
|
+
<span className={styles.monthLabel}>{formatMonthYear(displayedMonth)}</span>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className={styles.navButton}
|
|
145
|
+
aria-label="Next month"
|
|
146
|
+
onClick={() => goToMonth(addMonths(displayedMonth, 1))}
|
|
147
|
+
>
|
|
148
|
+
<Icon name="chevron-right" size="sm" aria-hidden />
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
ref={gridRef}
|
|
154
|
+
role="grid"
|
|
155
|
+
aria-label={formatMonthYear(displayedMonth)}
|
|
156
|
+
className={styles.grid}
|
|
157
|
+
onKeyDown={handleKeyDown}
|
|
158
|
+
// Keyboard handling relies on roving tabindex on the day buttons
|
|
159
|
+
// (real DOM focus + bubbling), not on this container being a tab
|
|
160
|
+
// stop itself — tabIndex={-1} only satisfies jsx-a11y's expectation
|
|
161
|
+
// that an element carrying a keydown handler + interactive role be
|
|
162
|
+
// focusable; it does not change Tab-key reachability.
|
|
163
|
+
tabIndex={-1}
|
|
164
|
+
>
|
|
165
|
+
<div role="row" className={styles.weekdayRow}>
|
|
166
|
+
{WEEKDAY_LABEL_WEEK.map((d) => (
|
|
167
|
+
<span key={d.getDay()} role="columnheader" className={styles.weekday} aria-hidden>
|
|
168
|
+
{formatWeekdayShort(d)}
|
|
169
|
+
</span>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
{weeks.map((week) => {
|
|
173
|
+
const firstDayOfWeek = week[0];
|
|
174
|
+
if (!firstDayOfWeek) return null;
|
|
175
|
+
return (
|
|
176
|
+
<div role="row" className={styles.week} key={firstDayOfWeek.toISOString()}>
|
|
177
|
+
{week.map((date) => {
|
|
178
|
+
const isCurrentMonth = isSameMonth(date, displayedMonth);
|
|
179
|
+
const isSelected = value !== null && isSameDay(date, value);
|
|
180
|
+
const isToday = isSameDay(date, new Date());
|
|
181
|
+
const isFocusTarget = isSameDay(date, focusedDate);
|
|
182
|
+
const disabled = !isWithinRange(date, minDate, maxDate);
|
|
183
|
+
return (
|
|
184
|
+
<div role="gridcell" aria-selected={isSelected} key={date.toISOString()}>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
tabIndex={isFocusTarget ? 0 : -1}
|
|
188
|
+
disabled={disabled}
|
|
189
|
+
aria-current={isToday ? 'date' : undefined}
|
|
190
|
+
aria-label={formatDateLabel(date)}
|
|
191
|
+
className={[
|
|
192
|
+
styles.day,
|
|
193
|
+
!isCurrentMonth ? styles.dayOutside : '',
|
|
194
|
+
isSelected ? styles.daySelected : '',
|
|
195
|
+
isToday && !isSelected ? styles.dayToday : '',
|
|
196
|
+
]
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join(' ')}
|
|
199
|
+
onClick={() => selectDate(date)}
|
|
200
|
+
onFocus={() => setFocusedDate(date)}
|
|
201
|
+
>
|
|
202
|
+
{date.getDate()}
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Calendar } from '../Calendar';
|
|
5
|
+
|
|
6
|
+
// A fixed reference date so tests are deterministic regardless of when they run.
|
|
7
|
+
const JAN_15_2026 = new Date(2026, 0, 15); // Thursday
|
|
8
|
+
|
|
9
|
+
describe('Calendar', () => {
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
it('renders the displayed month and year', () => {
|
|
13
|
+
render(<Calendar value={JAN_15_2026} onChange={() => {}} />);
|
|
14
|
+
expect(screen.getByText('January 2026')).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('marks the selected date', () => {
|
|
18
|
+
render(<Calendar value={JAN_15_2026} onChange={() => {}} />);
|
|
19
|
+
const button = screen.getByRole('button', { name: /Thursday, January 15, 2026/ });
|
|
20
|
+
// aria-selected lives on the gridcell (the button's parent), not the
|
|
21
|
+
// button itself — the button role doesn't support aria-selected.
|
|
22
|
+
expect(button.parentElement?.getAttribute('aria-selected')).toBe('true');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('calls onChange with the clicked date', () => {
|
|
26
|
+
const onChange = vi.fn();
|
|
27
|
+
render(<Calendar value={JAN_15_2026} onChange={onChange} />);
|
|
28
|
+
fireEvent.click(screen.getByRole('button', { name: /January 20, 2026/ }));
|
|
29
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
30
|
+
const called = onChange.mock.calls[0]?.[0] as Date;
|
|
31
|
+
expect(called.getDate()).toBe(20);
|
|
32
|
+
expect(called.getMonth()).toBe(0);
|
|
33
|
+
expect(called.getFullYear()).toBe(2026);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('navigates to the next and previous month', () => {
|
|
37
|
+
render(<Calendar value={JAN_15_2026} onChange={() => {}} />);
|
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
|
39
|
+
expect(screen.getByText('February 2026')).toBeTruthy();
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
|
42
|
+
expect(screen.getByText('December 2025')).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('disables and does not select dates outside minDate/maxDate', () => {
|
|
46
|
+
const onChange = vi.fn();
|
|
47
|
+
render(
|
|
48
|
+
<Calendar
|
|
49
|
+
value={JAN_15_2026}
|
|
50
|
+
onChange={onChange}
|
|
51
|
+
minDate={new Date(2026, 0, 10)}
|
|
52
|
+
maxDate={new Date(2026, 0, 20)}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
const outOfRange = screen.getByRole('button', { name: /January 5, 2026/ });
|
|
56
|
+
expect(outOfRange.hasAttribute('disabled')).toBe(true);
|
|
57
|
+
fireEvent.click(outOfRange);
|
|
58
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('exposes exactly one roving tabIndex=0 day at a time', () => {
|
|
62
|
+
render(<Calendar value={JAN_15_2026} onChange={() => {}} />);
|
|
63
|
+
const zeroTabIndex = document.querySelectorAll('[role="gridcell"] button[tabindex="0"]');
|
|
64
|
+
expect(zeroTabIndex.length).toBe(1);
|
|
65
|
+
expect(zeroTabIndex[0]?.getAttribute('aria-label')).toMatch(/January 15, 2026/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('moves the roving tabIndex with ArrowRight and selects with Enter', () => {
|
|
69
|
+
const onChange = vi.fn();
|
|
70
|
+
render(<Calendar value={JAN_15_2026} onChange={onChange} />);
|
|
71
|
+
const grid = screen.getByRole('grid');
|
|
72
|
+
fireEvent.keyDown(grid, { key: 'ArrowRight' });
|
|
73
|
+
const zeroTabIndex = document.querySelector('[role="gridcell"] button[tabindex="0"]');
|
|
74
|
+
expect(zeroTabIndex?.getAttribute('aria-label')).toMatch(/January 16, 2026/);
|
|
75
|
+
fireEvent.keyDown(grid, { key: 'Enter' });
|
|
76
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
77
|
+
const called = onChange.mock.calls[0]?.[0] as Date;
|
|
78
|
+
expect(called.getDate()).toBe(16);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('PageDown advances the focused date and displayed month by one month', () => {
|
|
82
|
+
render(<Calendar value={JAN_15_2026} onChange={() => {}} />);
|
|
83
|
+
const grid = screen.getByRole('grid');
|
|
84
|
+
fireEvent.keyDown(grid, { key: 'PageDown' });
|
|
85
|
+
expect(screen.getByText('February 2026')).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('marks today distinctly from an ordinary day', () => {
|
|
89
|
+
const today = new Date();
|
|
90
|
+
render(<Calendar value={null} onChange={() => {}} />);
|
|
91
|
+
const todayLabel = screen.getByRole('button', {
|
|
92
|
+
name: new RegExp(
|
|
93
|
+
`${today.toLocaleDateString(undefined, { month: 'long' })} ${today.getDate()}, ${today.getFullYear()}`,
|
|
94
|
+
),
|
|
95
|
+
});
|
|
96
|
+
expect(todayLabel.getAttribute('aria-current')).toBe('date');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Plain-Date helpers for Calendar — deliberately self-contained (no date
|
|
2
|
+
// library dependency, matching @sovereignfs/ui's zero-extra-dependency
|
|
3
|
+
// philosophy). Every function operates on local calendar dates only; there is
|
|
4
|
+
// no timezone or time-of-day handling here — Calendar is date-only (decision
|
|
5
|
+
// D6 in the mobile design-system plan). Time and range selection are future
|
|
6
|
+
// scope.
|
|
7
|
+
|
|
8
|
+
export function startOfMonth(date: Date): Date {
|
|
9
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function addMonths(date: Date, count: number): Date {
|
|
13
|
+
return new Date(date.getFullYear(), date.getMonth() + count, date.getDate());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function addDays(date: Date, count: number): Date {
|
|
17
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + count);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isSameDay(a: Date, b: Date): boolean {
|
|
21
|
+
return (
|
|
22
|
+
a.getFullYear() === b.getFullYear() &&
|
|
23
|
+
a.getMonth() === b.getMonth() &&
|
|
24
|
+
a.getDate() === b.getDate()
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSameMonth(a: Date, b: Date): boolean {
|
|
29
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isBeforeDay(a: Date, b: Date): boolean {
|
|
33
|
+
return (
|
|
34
|
+
a.getFullYear() < b.getFullYear() ||
|
|
35
|
+
(a.getFullYear() === b.getFullYear() &&
|
|
36
|
+
(a.getMonth() < b.getMonth() || (a.getMonth() === b.getMonth() && a.getDate() < b.getDate())))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAfterDay(a: Date, b: Date): boolean {
|
|
41
|
+
return isBeforeDay(b, a);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isWithinRange(date: Date, min?: Date, max?: Date): boolean {
|
|
45
|
+
if (min && isBeforeDay(date, min)) return false;
|
|
46
|
+
if (max && isAfterDay(date, max)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A 6x7 grid of dates covering `month` (any date within the target month),
|
|
52
|
+
* padded with the trailing days of the previous month and the leading days
|
|
53
|
+
* of the next so every week is a full row — Sunday-first. 6 rows always
|
|
54
|
+
* (rather than the 4-6 a month strictly needs) so the grid's own height never
|
|
55
|
+
* changes as the displayed month changes, avoiding a layout jump.
|
|
56
|
+
*/
|
|
57
|
+
export function getMonthGrid(month: Date): Date[][] {
|
|
58
|
+
const first = startOfMonth(month);
|
|
59
|
+
const startOffset = first.getDay(); // 0 (Sun) .. 6 (Sat)
|
|
60
|
+
const gridStart = addDays(first, -startOffset);
|
|
61
|
+
|
|
62
|
+
const weeks: Date[][] = [];
|
|
63
|
+
for (let week = 0; week < 6; week++) {
|
|
64
|
+
const days: Date[] = [];
|
|
65
|
+
for (let day = 0; day < 7; day++) {
|
|
66
|
+
days.push(addDays(gridStart, week * 7 + day));
|
|
67
|
+
}
|
|
68
|
+
weeks.push(days);
|
|
69
|
+
}
|
|
70
|
+
return weeks;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatMonthYear(date: Date): string {
|
|
74
|
+
return new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(date);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatWeekdayShort(date: Date): string {
|
|
78
|
+
return new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(date);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compact form for a trigger/field display — e.g. "Jan 15, 2026". */
|
|
82
|
+
export function formatDateShort(date: Date): string {
|
|
83
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
84
|
+
month: 'short',
|
|
85
|
+
day: 'numeric',
|
|
86
|
+
year: 'numeric',
|
|
87
|
+
}).format(date);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatDateLabel(date: Date): string {
|
|
91
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
92
|
+
weekday: 'long',
|
|
93
|
+
month: 'long',
|
|
94
|
+
day: 'numeric',
|
|
95
|
+
year: 'numeric',
|
|
96
|
+
}).format(date);
|
|
97
|
+
}
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
transition: border-color 0.15s ease;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
@media (hover: hover) {
|
|
28
|
+
.interactive:hover {
|
|
29
|
+
border-color: var(--sv-color-border-strong);
|
|
30
|
+
}
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
.interactive:focus-visible {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--sv-space-2);
|
|
5
|
+
cursor: pointer;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* The visual box */
|
|
9
|
+
.box {
|
|
10
|
+
--checkbox-box-size: 18px;
|
|
11
|
+
position: relative;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
width: var(--checkbox-box-size);
|
|
14
|
+
height: var(--checkbox-box-size);
|
|
15
|
+
border-radius: var(--sv-radius-sm);
|
|
16
|
+
border: 1.5px solid var(--sv-color-border-strong);
|
|
17
|
+
background: var(--sv-color-surface);
|
|
18
|
+
transition:
|
|
19
|
+
background-color 0.15s ease,
|
|
20
|
+
border-color 0.15s ease;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.checked {
|
|
27
|
+
background-color: var(--sv-color-accent);
|
|
28
|
+
border-color: var(--sv-color-accent);
|
|
29
|
+
color: var(--sv-white);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Hide the native input visually but keep it accessible */
|
|
33
|
+
.input {
|
|
34
|
+
position: absolute;
|
|
35
|
+
inset: 0;
|
|
36
|
+
opacity: 0;
|
|
37
|
+
margin: 0;
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.input:disabled {
|
|
44
|
+
cursor: not-allowed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Expand the tappable region to the platform's minimum touch target without
|
|
48
|
+
growing the visible box — consumers (e.g. task rows) align the box to the
|
|
49
|
+
pixel against header icons, so its visual footprint and layout must stay
|
|
50
|
+
identical. Gated to (pointer: coarse), the *primary* pointer, so a
|
|
51
|
+
touchscreen laptop with mouse/trackpad as primary keeps desktop density. */
|
|
52
|
+
@media (pointer: coarse) {
|
|
53
|
+
.input {
|
|
54
|
+
inset: calc((var(--checkbox-box-size) - var(--sv-touch-target-min, 44px)) / 2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.input:focus-visible + .tick,
|
|
59
|
+
.input:focus-visible {
|
|
60
|
+
outline: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.box:has(.input:focus-visible) {
|
|
64
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
65
|
+
outline-offset: 2px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.tick {
|
|
69
|
+
width: 10px;
|
|
70
|
+
height: 8px;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Label */
|
|
75
|
+
.label {
|
|
76
|
+
font-size: var(--sv-font-size-sm);
|
|
77
|
+
color: var(--sv-color-text-primary);
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
user-select: none;
|
|
80
|
+
transition: color 0.15s ease;
|
|
81
|
+
/* Strike-through animates via clip-path on a pseudo-element */
|
|
82
|
+
position: relative;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.label::after {
|
|
86
|
+
content: '';
|
|
87
|
+
position: absolute;
|
|
88
|
+
left: 0;
|
|
89
|
+
top: 50%;
|
|
90
|
+
width: 100%;
|
|
91
|
+
height: 1.5px;
|
|
92
|
+
background: var(--sv-color-text-muted);
|
|
93
|
+
transform: scaleX(0);
|
|
94
|
+
transform-origin: left center;
|
|
95
|
+
transition: transform 0.2s ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.struck {
|
|
99
|
+
color: var(--sv-color-text-muted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.struck::after {
|
|
103
|
+
transform: scaleX(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.disabled {
|
|
107
|
+
opacity: 0.5;
|
|
108
|
+
cursor: not-allowed;
|
|
109
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { InputHTMLAttributes } from 'react';
|
|
4
|
+
import styles from './Checkbox.module.css';
|
|
5
|
+
|
|
6
|
+
export interface CheckboxProps extends Omit<
|
|
7
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
8
|
+
'type' | 'onChange'
|
|
9
|
+
> {
|
|
10
|
+
checked: boolean;
|
|
11
|
+
onChange: (checked: boolean) => void;
|
|
12
|
+
label: string;
|
|
13
|
+
/** Renders the label with a strike-through when checked. */
|
|
14
|
+
strikeThrough?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Checkbox({
|
|
19
|
+
checked,
|
|
20
|
+
onChange,
|
|
21
|
+
label,
|
|
22
|
+
strikeThrough = false,
|
|
23
|
+
disabled,
|
|
24
|
+
className,
|
|
25
|
+
id,
|
|
26
|
+
...rest
|
|
27
|
+
}: CheckboxProps) {
|
|
28
|
+
const inputId = id ?? `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<span className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
32
|
+
<span className={[styles.box, checked ? styles.checked : ''].filter(Boolean).join(' ')}>
|
|
33
|
+
<input
|
|
34
|
+
{...rest}
|
|
35
|
+
id={inputId}
|
|
36
|
+
type="checkbox"
|
|
37
|
+
checked={checked}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
className={styles.input}
|
|
40
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
41
|
+
/>
|
|
42
|
+
{checked && (
|
|
43
|
+
<svg className={styles.tick} viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
44
|
+
<path
|
|
45
|
+
d="M1 4l3 3 5-6"
|
|
46
|
+
stroke="currentColor"
|
|
47
|
+
strokeWidth="1.5"
|
|
48
|
+
strokeLinecap="round"
|
|
49
|
+
strokeLinejoin="round"
|
|
50
|
+
/>
|
|
51
|
+
</svg>
|
|
52
|
+
)}
|
|
53
|
+
</span>
|
|
54
|
+
{/* Callers pass label="" for icon-only/screen-reader-only usage (an
|
|
55
|
+
aria-label on the Checkbox's own root covers accessibility in that
|
|
56
|
+
case) — omitting the element entirely, rather than rendering an
|
|
57
|
+
empty <label>, matters because .root's flex `gap` still applies
|
|
58
|
+
between .box and a rendered-but-empty label, silently adding an
|
|
59
|
+
extra --sv-space-2 of invisible space after the checkbox that
|
|
60
|
+
every icon-only consumer was unknowingly stacking on top of their
|
|
61
|
+
own layout's spacing. */}
|
|
62
|
+
{label && (
|
|
63
|
+
<label
|
|
64
|
+
htmlFor={inputId}
|
|
65
|
+
className={[
|
|
66
|
+
styles.label,
|
|
67
|
+
strikeThrough && checked ? styles.struck : '',
|
|
68
|
+
disabled ? styles.disabled : '',
|
|
69
|
+
]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(' ')}
|
|
72
|
+
>
|
|
73
|
+
{label}
|
|
74
|
+
</label>
|
|
75
|
+
)}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.textarea {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
min-height: 180px;
|
|
5
|
+
max-height: min(70vh, 720px);
|
|
6
|
+
padding: var(--sv-space-3);
|
|
7
|
+
font-family: var(--sv-font-family-mono);
|
|
8
|
+
font-size: var(--sv-font-size-sm);
|
|
9
|
+
line-height: 1.6;
|
|
10
|
+
color: var(--sv-color-text-primary);
|
|
11
|
+
tab-size: 2;
|
|
12
|
+
white-space: pre;
|
|
13
|
+
overflow: auto;
|
|
14
|
+
resize: vertical;
|
|
15
|
+
background: var(--sv-color-surface);
|
|
16
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
17
|
+
border-radius: var(--sv-radius-md);
|
|
18
|
+
transition:
|
|
19
|
+
border-color 0.15s ease,
|
|
20
|
+
box-shadow 0.15s ease;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.textarea::placeholder {
|
|
24
|
+
color: var(--sv-color-text-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.textarea:focus-visible {
|
|
28
|
+
outline: none;
|
|
29
|
+
border-color: var(--sv-color-accent);
|
|
30
|
+
box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.textarea:read-only {
|
|
34
|
+
color: var(--sv-color-text-muted);
|
|
35
|
+
background: var(--sv-color-surface-sunken);
|
|
36
|
+
border-color: var(--sv-color-border);
|
|
37
|
+
cursor: default;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.textarea:disabled {
|
|
41
|
+
opacity: 0.5;
|
|
42
|
+
cursor: not-allowed;
|
|
43
|
+
resize: none;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.invalid {
|
|
47
|
+
border-color: var(--sv-color-error-border);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.invalid:focus-visible {
|
|
51
|
+
border-color: var(--sv-color-error-border);
|
|
52
|
+
box-shadow: 0 0 0 2px var(--sv-color-error-border);
|
|
53
|
+
}
|