@dashforge/tw 0.9.0-beta → 0.9.1-beta
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/CHANGELOG.md +29 -0
- package/dist/index.esm.js +629 -67
- package/dist/src/components/Calendar/Calendar.d.ts +14 -0
- package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
- package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
- package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
- package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
- package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
- package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
- package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
- package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
- package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
- package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/components/Calendar/Calendar.test.tsx +75 -0
- package/src/components/Calendar/Calendar.tsx +264 -0
- package/src/components/Calendar/calendar.types.ts +65 -0
- package/src/components/Calendar/calendar.variants.ts +64 -0
- package/src/components/DatePicker/DatePicker.test.tsx +76 -0
- package/src/components/DatePicker/DatePicker.tsx +305 -0
- package/src/components/DatePicker/datePicker.types.ts +77 -0
- package/src/components/DatePicker/datePicker.variants.ts +50 -0
- package/src/index.ts +21 -1
- package/tsconfig.lib.json +7 -4
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { KeyboardEvent } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
formatLongDate,
|
|
5
|
+
resolveCalendarKey,
|
|
6
|
+
useCalendar,
|
|
7
|
+
} from '@dashforge/calendar-core';
|
|
8
|
+
import type { ISODate } from '@dashforge/calendar-core';
|
|
9
|
+
import { cn } from '../../utils/cn.js';
|
|
10
|
+
import { calendarDayVariants, calendarVariants } from './calendar.variants.js';
|
|
11
|
+
import type { CalendarProps } from './calendar.types.js';
|
|
12
|
+
|
|
13
|
+
// Inline 16×16 stroke chevrons — no icon dependency (tw convention).
|
|
14
|
+
function ChevronLeftIcon() {
|
|
15
|
+
return (
|
|
16
|
+
<svg
|
|
17
|
+
width="1em"
|
|
18
|
+
height="1em"
|
|
19
|
+
viewBox="0 0 16 16"
|
|
20
|
+
fill="none"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
>
|
|
23
|
+
<path
|
|
24
|
+
d="M10 4 6 8l4 4"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth="1.5"
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
/>
|
|
30
|
+
</svg>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ChevronRightIcon() {
|
|
35
|
+
return (
|
|
36
|
+
<svg
|
|
37
|
+
width="1em"
|
|
38
|
+
height="1em"
|
|
39
|
+
viewBox="0 0 16 16"
|
|
40
|
+
fill="none"
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
>
|
|
43
|
+
<path
|
|
44
|
+
d="m6 4 4 4-4 4"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
strokeWidth="1.5"
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
strokeLinejoin="round"
|
|
49
|
+
/>
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `<Calendar>` — a standalone, inline month-grid date picker.
|
|
56
|
+
*
|
|
57
|
+
* The Tailwind presentation layer over the headless `useCalendar` engine
|
|
58
|
+
* from `@dashforge/calendar-core` (the same engine the MUI `@dashforge/ui`
|
|
59
|
+
* calendar consumes — only the skin differs). Implements the WCAG grid
|
|
60
|
+
* pattern with roving tab-index focus.
|
|
61
|
+
*
|
|
62
|
+
* It is a primitive: no form-bridge / RBAC integration — use `<DatePicker>`
|
|
63
|
+
* for a form-bound date field.
|
|
64
|
+
*/
|
|
65
|
+
export function Calendar(props: CalendarProps) {
|
|
66
|
+
const {
|
|
67
|
+
value,
|
|
68
|
+
defaultValue,
|
|
69
|
+
onChange,
|
|
70
|
+
month,
|
|
71
|
+
year,
|
|
72
|
+
defaultMonth,
|
|
73
|
+
defaultYear,
|
|
74
|
+
onMonthChange,
|
|
75
|
+
minDate,
|
|
76
|
+
maxDate,
|
|
77
|
+
disabledDates,
|
|
78
|
+
isDateDisabled,
|
|
79
|
+
weekStartDay,
|
|
80
|
+
locale,
|
|
81
|
+
today,
|
|
82
|
+
disabled = false,
|
|
83
|
+
autoFocus = false,
|
|
84
|
+
sx,
|
|
85
|
+
slotProps,
|
|
86
|
+
testId,
|
|
87
|
+
'aria-label': ariaLabel = 'Calendar',
|
|
88
|
+
} = props;
|
|
89
|
+
|
|
90
|
+
const isValueControlled = value !== undefined;
|
|
91
|
+
const [internalValue, setInternalValue] = useState<ISODate | null>(
|
|
92
|
+
defaultValue ?? null,
|
|
93
|
+
);
|
|
94
|
+
const selectedValue: ISODate | null = isValueControlled
|
|
95
|
+
? value
|
|
96
|
+
: internalValue;
|
|
97
|
+
|
|
98
|
+
const handleSelect = useCallback(
|
|
99
|
+
(iso: ISODate) => {
|
|
100
|
+
if (!isValueControlled) {
|
|
101
|
+
setInternalValue(iso);
|
|
102
|
+
}
|
|
103
|
+
onChange?.(iso);
|
|
104
|
+
},
|
|
105
|
+
[isValueControlled, onChange],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const calendar = useCalendar({
|
|
109
|
+
selected: selectedValue,
|
|
110
|
+
month,
|
|
111
|
+
year,
|
|
112
|
+
defaultMonth,
|
|
113
|
+
defaultYear,
|
|
114
|
+
onMonthChange,
|
|
115
|
+
minDate,
|
|
116
|
+
maxDate,
|
|
117
|
+
disabledDates,
|
|
118
|
+
isDateDisabled,
|
|
119
|
+
weekStartDay,
|
|
120
|
+
locale,
|
|
121
|
+
today,
|
|
122
|
+
onSelect: handleSelect,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Roving focus: the active day cell holds DOM focus; it is moved only
|
|
126
|
+
// after a keyboard interaction (or on mount when `autoFocus`). `focus`
|
|
127
|
+
// uses `preventScroll` so opening the calendar inside a popup never
|
|
128
|
+
// scroll-jumps the page before the popup is positioned.
|
|
129
|
+
const focusedCellRef = useRef<HTMLButtonElement | null>(null);
|
|
130
|
+
const shouldFocusRef = useRef(autoFocus);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (shouldFocusRef.current && focusedCellRef.current) {
|
|
134
|
+
focusedCellRef.current.focus({ preventScroll: true });
|
|
135
|
+
shouldFocusRef.current = false;
|
|
136
|
+
}
|
|
137
|
+
}, [calendar.focusedDate]);
|
|
138
|
+
|
|
139
|
+
const handleKeyDown = useCallback(
|
|
140
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
141
|
+
if (disabled) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const action = resolveCalendarKey(event.key);
|
|
145
|
+
if (action === null) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
shouldFocusRef.current = true;
|
|
150
|
+
switch (action) {
|
|
151
|
+
case 'select':
|
|
152
|
+
calendar.selectDate(calendar.focusedDate);
|
|
153
|
+
break;
|
|
154
|
+
case 'monthForward':
|
|
155
|
+
calendar.goToNextMonth();
|
|
156
|
+
break;
|
|
157
|
+
case 'monthBackward':
|
|
158
|
+
calendar.goToPreviousMonth();
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
calendar.navigate(action);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
[disabled, calendar],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const resolvedLocale = locale ?? 'en-US';
|
|
168
|
+
const v = calendarVariants({ disabled });
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
data-testid={testId}
|
|
173
|
+
className={cn(v.root(), sx, slotProps?.root?.className)}
|
|
174
|
+
>
|
|
175
|
+
{/* Header: month navigation + label */}
|
|
176
|
+
<div className={cn(v.header(), slotProps?.header?.className)}>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className={v.navButton()}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
aria-label="Previous month"
|
|
182
|
+
onClick={() => {
|
|
183
|
+
calendar.goToPreviousMonth();
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<ChevronLeftIcon />
|
|
187
|
+
</button>
|
|
188
|
+
<span aria-live="polite" className={v.monthLabel()}>
|
|
189
|
+
{calendar.monthLabel}
|
|
190
|
+
</span>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className={v.navButton()}
|
|
194
|
+
disabled={disabled}
|
|
195
|
+
aria-label="Next month"
|
|
196
|
+
onClick={() => {
|
|
197
|
+
calendar.goToNextMonth();
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<ChevronRightIcon />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Grid */}
|
|
205
|
+
<div
|
|
206
|
+
role="grid"
|
|
207
|
+
aria-label={ariaLabel}
|
|
208
|
+
onKeyDown={handleKeyDown}
|
|
209
|
+
className={cn(v.grid(), slotProps?.grid?.className)}
|
|
210
|
+
>
|
|
211
|
+
<div role="row" className={v.weekdayRow()}>
|
|
212
|
+
{calendar.weekdayNames.map((dayName, index) => (
|
|
213
|
+
<span
|
|
214
|
+
key={`weekday-${String(index)}`}
|
|
215
|
+
role="columnheader"
|
|
216
|
+
className={v.weekday()}
|
|
217
|
+
>
|
|
218
|
+
{dayName}
|
|
219
|
+
</span>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{calendar.weeks.map((week) => (
|
|
224
|
+
<div key={week.key} role="row" className={v.weekRow()}>
|
|
225
|
+
{week.days.map((day) => {
|
|
226
|
+
const isActiveCell = day.iso === calendar.focusedDate;
|
|
227
|
+
return (
|
|
228
|
+
<span key={day.iso} role="gridcell" className="flex">
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
ref={isActiveCell ? focusedCellRef : null}
|
|
232
|
+
tabIndex={isActiveCell ? 0 : -1}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
aria-label={formatLongDate(
|
|
235
|
+
{ year: day.year, month: day.month, day: day.day },
|
|
236
|
+
resolvedLocale,
|
|
237
|
+
)}
|
|
238
|
+
aria-current={day.isToday ? 'date' : undefined}
|
|
239
|
+
aria-pressed={day.isSelected}
|
|
240
|
+
aria-disabled={day.isDisabled}
|
|
241
|
+
onClick={() => {
|
|
242
|
+
calendar.selectDate(day.iso);
|
|
243
|
+
}}
|
|
244
|
+
className={cn(
|
|
245
|
+
calendarDayVariants({
|
|
246
|
+
siblingMonth: day.isSiblingMonth,
|
|
247
|
+
today: day.isToday,
|
|
248
|
+
selected: day.isSelected,
|
|
249
|
+
disabled: day.isDisabled,
|
|
250
|
+
}),
|
|
251
|
+
slotProps?.day?.className,
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
{day.day}
|
|
255
|
+
</button>
|
|
256
|
+
</span>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</div>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ISODate, WeekDay } from '@dashforge/calendar-core';
|
|
2
|
+
|
|
3
|
+
/** Per-slot `className` overrides for `<Calendar>`. */
|
|
4
|
+
export interface CalendarSlotProps {
|
|
5
|
+
root?: { className?: string };
|
|
6
|
+
header?: { className?: string };
|
|
7
|
+
grid?: { className?: string };
|
|
8
|
+
/** Applied to every day-cell button. */
|
|
9
|
+
day?: { className?: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Props for the `<Calendar>` primitive — a standalone, inline month grid.
|
|
14
|
+
*
|
|
15
|
+
* `Calendar` is NOT a form field (no bridge / RBAC integration). It is the
|
|
16
|
+
* low-level building block used inside `<DatePicker>`. For a form-bound date
|
|
17
|
+
* input use `<DatePicker>`.
|
|
18
|
+
*
|
|
19
|
+
* Dates are ISO `YYYY-MM-DD` strings throughout. The headless date engine is
|
|
20
|
+
* `@dashforge/calendar-core` — shared with the MUI `@dashforge/ui` calendar.
|
|
21
|
+
*/
|
|
22
|
+
export interface CalendarProps {
|
|
23
|
+
/** Selected date (controlled). */
|
|
24
|
+
value?: ISODate | null;
|
|
25
|
+
/** Initial selected date (uncontrolled). */
|
|
26
|
+
defaultValue?: ISODate | null;
|
|
27
|
+
/** Fired when a day is chosen. */
|
|
28
|
+
onChange?: (value: ISODate) => void;
|
|
29
|
+
/** Controlled displayed month (1-indexed). Pair with `year`. */
|
|
30
|
+
month?: number;
|
|
31
|
+
/** Controlled displayed year. Pair with `month`. */
|
|
32
|
+
year?: number;
|
|
33
|
+
/** Initial displayed month (1-indexed, uncontrolled). */
|
|
34
|
+
defaultMonth?: number;
|
|
35
|
+
/** Initial displayed year (uncontrolled). */
|
|
36
|
+
defaultYear?: number;
|
|
37
|
+
/** Fired when the displayed month changes. */
|
|
38
|
+
onMonthChange?: (month: number, year: number) => void;
|
|
39
|
+
/** Earliest selectable date (inclusive). */
|
|
40
|
+
minDate?: ISODate;
|
|
41
|
+
/** Latest selectable date (inclusive). */
|
|
42
|
+
maxDate?: ISODate;
|
|
43
|
+
/** Explicit list of disabled dates. */
|
|
44
|
+
disabledDates?: readonly ISODate[];
|
|
45
|
+
/** Predicate marking arbitrary dates disabled. */
|
|
46
|
+
isDateDisabled?: (date: ISODate) => boolean;
|
|
47
|
+
/** Weekday of the grid's first column (`0` = Sunday). Default `0`. */
|
|
48
|
+
weekStartDay?: WeekDay;
|
|
49
|
+
/** BCP-47 locale for month/weekday names. Default `"en-US"`. */
|
|
50
|
+
locale?: string;
|
|
51
|
+
/** Overrides "today" (ISO `YYYY-MM-DD`), mainly for testing. */
|
|
52
|
+
today?: ISODate;
|
|
53
|
+
/** Disables the whole calendar. */
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
/** Moves DOM focus to the active day cell on mount. */
|
|
56
|
+
autoFocus?: boolean;
|
|
57
|
+
/** Accessible label for the grid. Default `"Calendar"`. */
|
|
58
|
+
'aria-label'?: string;
|
|
59
|
+
/** Root-level Tailwind class override. */
|
|
60
|
+
sx?: string;
|
|
61
|
+
/** Per-slot `className` overrides. */
|
|
62
|
+
slotProps?: CalendarSlotProps;
|
|
63
|
+
/** Test id applied to the root element. */
|
|
64
|
+
testId?: string;
|
|
65
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structural slots for `<Calendar>`.
|
|
5
|
+
*
|
|
6
|
+
* Theme identity: every class is a bare neutral / brand token-backed
|
|
7
|
+
* Tailwind class. The `dashforgePreset()` CSS-variable swap auto-inverts
|
|
8
|
+
* the neutral palette, so there are NO `dark:` variants here (a `dark:` on
|
|
9
|
+
* a neutral class would double-invert — see `_shared/themeIdentity.test.ts`).
|
|
10
|
+
*/
|
|
11
|
+
export const calendarVariants = tv({
|
|
12
|
+
slots: {
|
|
13
|
+
root: 'inline-flex w-fit flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 select-none',
|
|
14
|
+
header: 'flex items-center justify-between gap-1',
|
|
15
|
+
navButton:
|
|
16
|
+
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[1rem] text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:pointer-events-none disabled:opacity-40',
|
|
17
|
+
monthLabel: 'flex-1 text-center text-sm font-semibold text-neutral-900',
|
|
18
|
+
grid: 'flex flex-col gap-1 focus:outline-none',
|
|
19
|
+
weekdayRow: 'grid grid-cols-7 gap-1',
|
|
20
|
+
weekday:
|
|
21
|
+
'flex h-8 items-center justify-center text-xs font-semibold text-neutral-500',
|
|
22
|
+
weekRow: 'grid grid-cols-7 gap-1',
|
|
23
|
+
},
|
|
24
|
+
variants: {
|
|
25
|
+
disabled: {
|
|
26
|
+
true: { root: 'opacity-60' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
disabled: false,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-day-cell recipe. Each of the 42 grid cells is rendered with its own
|
|
36
|
+
* state (`siblingMonth` / `today` / `selected` / `disabled`).
|
|
37
|
+
*/
|
|
38
|
+
export const calendarDayVariants = tv({
|
|
39
|
+
base: 'flex h-9 w-9 items-center justify-center rounded-md text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500',
|
|
40
|
+
variants: {
|
|
41
|
+
siblingMonth: {
|
|
42
|
+
true: 'text-neutral-400',
|
|
43
|
+
},
|
|
44
|
+
today: {
|
|
45
|
+
true: 'font-bold ring-1 ring-inset ring-primary-400',
|
|
46
|
+
},
|
|
47
|
+
selected: {
|
|
48
|
+
true: 'bg-primary-600 text-white hover:bg-primary-600',
|
|
49
|
+
},
|
|
50
|
+
disabled: {
|
|
51
|
+
true: 'cursor-default text-neutral-300 opacity-60 hover:bg-transparent',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
compoundVariants: [
|
|
55
|
+
// A selected cell drops the "today" ring — the solid fill is enough.
|
|
56
|
+
{ selected: true, today: true, class: 'ring-0' },
|
|
57
|
+
],
|
|
58
|
+
defaultVariants: {
|
|
59
|
+
siblingMonth: false,
|
|
60
|
+
today: false,
|
|
61
|
+
selected: false,
|
|
62
|
+
disabled: false,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { DatePicker } from './DatePicker.js';
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
// Radix Popover (Floating UI) needs ResizeObserver — jsdom lacks it.
|
|
9
|
+
if (!('ResizeObserver' in globalThis)) {
|
|
10
|
+
class ResizeObserverStub {
|
|
11
|
+
observe(): void {
|
|
12
|
+
/* no-op */
|
|
13
|
+
}
|
|
14
|
+
unobserve(): void {
|
|
15
|
+
/* no-op */
|
|
16
|
+
}
|
|
17
|
+
disconnect(): void {
|
|
18
|
+
/* no-op */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
|
|
22
|
+
ResizeObserverStub;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('DatePicker', () => {
|
|
27
|
+
it('renders a labelled trigger with the placeholder', () => {
|
|
28
|
+
render(
|
|
29
|
+
<DatePicker name="birthday" label="Birthday" placeholder="Pick one" />,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.queryByText('Birthday')).not.toBeNull();
|
|
32
|
+
expect(screen.getByRole('button').textContent).toContain('Pick one');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows a localized controlled value', () => {
|
|
36
|
+
render(<DatePicker name="d" value="2026-05-20" />);
|
|
37
|
+
expect(screen.getByRole('button').textContent).toContain('May 20, 2026');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('opens and toggles the calendar popover from the trigger', () => {
|
|
41
|
+
render(<DatePicker name="d" defaultValue="2026-05-20" />);
|
|
42
|
+
const trigger = screen.getByRole('button');
|
|
43
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
44
|
+
fireEvent.click(trigger);
|
|
45
|
+
expect(screen.queryByRole('dialog')).not.toBeNull();
|
|
46
|
+
fireEvent.click(trigger);
|
|
47
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('selects a date from the popover, updates the trigger and closes', () => {
|
|
51
|
+
const onChange = vi.fn();
|
|
52
|
+
render(
|
|
53
|
+
<DatePicker name="d" defaultValue="2026-05-20" onChange={onChange} />,
|
|
54
|
+
);
|
|
55
|
+
fireEvent.click(screen.getByRole('button'));
|
|
56
|
+
fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
|
|
57
|
+
expect(onChange).toHaveBeenCalledWith('2026-05-15');
|
|
58
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
59
|
+
expect(screen.getByRole('button').textContent).toContain('May 15, 2026');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps a controlled value fixed but still reports the change', () => {
|
|
63
|
+
const onChange = vi.fn();
|
|
64
|
+
render(<DatePicker name="d" value="2026-05-20" onChange={onChange} />);
|
|
65
|
+
fireEvent.click(screen.getByRole('button'));
|
|
66
|
+
fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
|
|
67
|
+
expect(onChange).toHaveBeenCalledWith('2026-05-15');
|
|
68
|
+
expect(screen.getByRole('button').textContent).toContain('May 20, 2026');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not open the popover when disabled', () => {
|
|
72
|
+
render(<DatePicker name="d" disabled />);
|
|
73
|
+
fireEvent.click(screen.getByRole('button'));
|
|
74
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|