@datatechsolutions/tympan 0.3.1 → 0.4.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.
@@ -0,0 +1,341 @@
1
+ import {
2
+ CalendarDate,
3
+ endOfMonth,
4
+ getWeeksInMonth,
5
+ startOfMonth,
6
+ startOfWeek,
7
+ today as todayIn,
8
+ getLocalTimeZone,
9
+ type DateValue,
10
+ } from '@internationalized/date'
11
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
12
+ import { useContext, useEffect, useId, useRef, useState, type ReactNode } from 'react'
13
+ import {
14
+ Button as AriaButton,
15
+ Calendar as AriaCalendar,
16
+ CalendarCell,
17
+ CalendarGrid,
18
+ CalendarGridBody,
19
+ CalendarGridHeader,
20
+ CalendarHeaderCell,
21
+ RangeCalendar as AriaRangeCalendar,
22
+ RangeCalendarStateContext,
23
+ I18nProvider,
24
+ useLocale,
25
+ } from 'react-aria-components'
26
+ import { cx } from '../../internal/cx'
27
+ import { useMessages } from '../../internal/provider'
28
+ import { ListboxSelect } from '../listbox-select/ListboxSelect'
29
+
30
+ /** A calendar date without time or time zone, exchanged as a local `Date`. */
31
+ export type CalendarDay = Date
32
+
33
+ export interface CalendarRangeValue {
34
+ start: CalendarDay
35
+ end: CalendarDay
36
+ }
37
+
38
+ /** State of one day cell, passed to `renderDay`. */
39
+ export interface CalendarDayState {
40
+ isToday: boolean
41
+ isSelected: boolean
42
+ isSelectionStart: boolean
43
+ isSelectionEnd: boolean
44
+ isUnavailable: boolean
45
+ isDisabled: boolean
46
+ isOutsideMonth: boolean
47
+ isFocused: boolean
48
+ }
49
+
50
+ export interface CalendarProps {
51
+ /** Accessible name of the calendar grid group. */
52
+ label: string
53
+ /** Single date or start-to-end range. */
54
+ mode?: 'single' | 'range'
55
+ value: CalendarDay | CalendarRangeValue | null
56
+ defaultValue?: CalendarDay | CalendarRangeValue | null
57
+ onChange: (value: CalendarDay | CalendarRangeValue | null) => void
58
+ minValue?: CalendarDay
59
+ maxValue?: CalendarDay
60
+ /** Days that cannot be chosen (booked, holidays); they stay visible. */
61
+ isDateUnavailable?: (date: CalendarDay) => boolean
62
+ /** Range mode: allow a range spanning unavailable days (they are excluded, not selected). */
63
+ allowsNonContiguousRanges?: boolean
64
+ /** Months shown side by side; paging moves by the whole set. */
65
+ visibleMonths?: 1 | 2 | 3
66
+ /** Paging moves by the visible set or by one month. */
67
+ pageBehavior?: 'visible' | 'single'
68
+ /** Title text, or month and year pickers for far jumps. */
69
+ headerMode?: 'title' | 'pickers'
70
+ /** Adds an ISO week-number column. */
71
+ showWeekNumbers?: boolean
72
+ /** Overrides the first day of the week from the locale. */
73
+ firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
74
+ /** Shows days of neighbouring months (not selectable with several visible months). */
75
+ showOutsideDays?: boolean
76
+ /** Which day holds focus and which month shows. */
77
+ focusedValue?: CalendarDay
78
+ onFocusChange?: (date: CalendarDay) => void
79
+ /** Adds content under the day number (a price, a dot); never replaces it. */
80
+ renderDay?: (date: CalendarDay, state: CalendarDayState) => ReactNode
81
+ /** Footer slot: presets or a note. */
82
+ footer?: ReactNode
83
+ disabled?: boolean
84
+ readOnly?: boolean
85
+ /** Marks the selection invalid (for example a range over unavailable days). */
86
+ isInvalid?: boolean
87
+ /** Error text shown when the selection is invalid. */
88
+ errorMessage?: string
89
+ /** Overrides the locale from the provider. */
90
+ locale?: string
91
+ className?: string
92
+ }
93
+
94
+ const fromDate = (d: Date) => new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate())
95
+ const toDate = (c: CalendarDate) => new Date(c.year, c.month - 1, c.day)
96
+
97
+ /** ISO-8601 week number: week 1 is the week holding the year's first Thursday. */
98
+ function isoWeekNumber(date: CalendarDate): number {
99
+ const target = new Date(Date.UTC(date.year, date.month - 1, date.day))
100
+ const weekday = target.getUTCDay() || 7
101
+ target.setUTCDate(target.getUTCDate() + 4 - weekday)
102
+ const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
103
+ return Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
104
+ }
105
+
106
+ /**
107
+ * Cancels a pending range start on Escape (spec: wave-5-general/calendar.md,
108
+ * keyboard section). Listens on the calendar root so the key bubbles from the
109
+ * focused day cell, and stops propagation so an enclosing popover stays open.
110
+ */
111
+ function RangeEscapeCancel() {
112
+ const state = useContext(RangeCalendarStateContext)
113
+ const ref = useRef<HTMLSpanElement>(null)
114
+ useEffect(() => {
115
+ const root = ref.current?.parentElement
116
+ if (!root || !state) return
117
+ const onKey = (e: KeyboardEvent) => {
118
+ if (e.key !== 'Escape' || !state.anchorDate) return
119
+ e.stopPropagation()
120
+ state.setAnchorDate(null)
121
+ }
122
+ root.addEventListener('keydown', onKey)
123
+ return () => root.removeEventListener('keydown', onKey)
124
+ }, [state])
125
+ return <span ref={ref} aria-hidden="true" hidden />
126
+ }
127
+
128
+ /**
129
+ * A month grid for picking a date or a date range directly on the page
130
+ * (spec: wave-5-general/calendar.md). Single mode rides RAC Calendar, range
131
+ * mode RAC RangeCalendar; both follow the APG Date Picker Dialog grid.
132
+ */
133
+ export function Calendar(props: CalendarProps) {
134
+ const t = useMessages().calendar
135
+ const { locale: contextLocale } = useLocale()
136
+ const locale = props.locale ?? contextLocale
137
+ const mode = props.mode ?? 'single'
138
+ const months = props.visibleMonths ?? 1
139
+ const now = todayIn(getLocalTimeZone())
140
+
141
+ const [innerValue, setInnerValue] = useState<CalendarDay | CalendarRangeValue | null>(
142
+ () => props.value ?? props.defaultValue ?? null,
143
+ )
144
+ const value = props.value !== undefined ? props.value : innerValue
145
+ const valueStart = value && 'start' in value ? value.start : (value as CalendarDay | null)
146
+ const [innerFocused, setInnerFocused] = useState<CalendarDate>(() => (valueStart ? fromDate(valueStart) : now))
147
+ const focused = props.focusedValue ? fromDate(props.focusedValue) : innerFocused
148
+ const setFocused = (d: CalendarDate) => {
149
+ if (!props.focusedValue) setInnerFocused(d)
150
+ props.onFocusChange?.(toDate(d))
151
+ }
152
+ const report = (next: CalendarDay | CalendarRangeValue | null) => {
153
+ if (props.value === undefined) setInnerValue(next)
154
+ props.onChange(next)
155
+ }
156
+
157
+ const min = props.minValue ? fromDate(props.minValue) : undefined
158
+ const max = props.maxValue ? fromDate(props.maxValue) : undefined
159
+ const baseUnavailable = (d: DateValue) => (props.isDateUnavailable ? props.isDateUnavailable(toDate(d as CalendarDate)) : false)
160
+
161
+ const monthYearFmt = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
162
+ const monthFmt = new Intl.DateTimeFormat(locale, { month: 'long' })
163
+ const headings = Array.from({ length: months }, (_, i) => toDate(startOfMonth(focused).add({ months: i })))
164
+ const heading = headings
165
+ .map((d, i) => (i === 0 || d.getFullYear() !== headings[i - 1]!.getFullYear() ? monthYearFmt.format(d) : monthFmt.format(d)))
166
+ .join(' – ')
167
+
168
+ const step = props.pageBehavior === 'single' ? 1 : months
169
+ const prevTarget = startOfMonth(focused).subtract({ months: step })
170
+ const nextTarget = startOfMonth(focused).add({ months: step })
171
+ const baseId = useId()
172
+ const headingId = `${baseId}-heading`
173
+
174
+ const pickerYears = (() => {
175
+ const from = Math.max(focused.year - 30, min?.year ?? focused.year - 30)
176
+ const to = Math.min(focused.year + 30, max?.year ?? focused.year + 30)
177
+ const list: number[] = []
178
+ for (let y = from; y <= to; y++) list.push(y)
179
+ return list
180
+ })()
181
+ const monthAllowed = (monthIndex: number) => {
182
+ const first = new CalendarDate(focused.year, monthIndex + 1, 1)
183
+ const last = endOfMonth(first)
184
+ return (!max || first.compare(max) <= 0) && (!min || last.compare(min) >= 0)
185
+ }
186
+
187
+ const invalid = !!props.isInvalid || !!props.errorMessage
188
+ const errorId = invalid && props.errorMessage ? `${baseId}-error` : undefined
189
+
190
+ const renderGrid = (offsetMonths: number) => {
191
+ const anchor = startOfMonth(focused).add({ months: offsetMonths })
192
+ const weeks = getWeeksInMonth(anchor, locale)
193
+ const weekStarts = Array.from({ length: weeks }, (_, w) => startOfWeek(anchor.add({ weeks: w }), locale))
194
+ return (
195
+ <div className="ty-calendar__month">
196
+ {props.showWeekNumbers ? (
197
+ <div className="ty-calendar__weeks" aria-hidden="true">
198
+ {weekStarts.map((week) => (
199
+ <span key={week.toString()} className="ty-calendar__week">
200
+ {isoWeekNumber(week)}
201
+ </span>
202
+ ))}
203
+ </div>
204
+ ) : null}
205
+ <CalendarGrid className="ty-calendar__grid" weekdayStyle="short" offset={offsetMonths ? { months: offsetMonths } : undefined}>
206
+ <CalendarGridHeader>
207
+ {(day) => <CalendarHeaderCell className="ty-calendar__weekday">{day}</CalendarHeaderCell>}
208
+ </CalendarGridHeader>
209
+ <CalendarGridBody>
210
+ {(date) => (
211
+ <CalendarCell date={date} className="ty-calendar__day">
212
+ {({ formattedDate, isToday, isSelected, isSelectionStart, isSelectionEnd, isUnavailable, isDisabled, isOutsideMonth, isFocused }) => (
213
+ <span className="ty-calendar__day-box" data-today={isToday || undefined}>
214
+ <span className="ty-calendar__day-number">{formattedDate}</span>
215
+ {props.renderDay
216
+ ? props.renderDay(toDate(date), {
217
+ isToday,
218
+ isSelected,
219
+ isSelectionStart,
220
+ isSelectionEnd,
221
+ isUnavailable,
222
+ isDisabled,
223
+ isOutsideMonth,
224
+ isFocused,
225
+ })
226
+ : null}
227
+ </span>
228
+ )}
229
+ </CalendarCell>
230
+ )}
231
+ </CalendarGridBody>
232
+ </CalendarGrid>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ const header =
238
+ props.headerMode === 'pickers' ? (
239
+ <div className="ty-calendar__pickers">
240
+ <ListboxSelect
241
+ accessibleLabel={t.monthPicker}
242
+ className="ty-calendar__picker"
243
+ value={String(focused.month)}
244
+ onChange={(key) => setFocused(focused.set({ month: Number(key) }))}
245
+ options={Array.from({ length: 12 }, (_, m) => ({
246
+ value: String(m + 1),
247
+ label: monthFmt.format(new Date(2020, m, 15)),
248
+ disabled: !monthAllowed(m),
249
+ }))}
250
+ />
251
+ <ListboxSelect
252
+ accessibleLabel={t.yearPicker}
253
+ className="ty-calendar__picker"
254
+ value={String(focused.year)}
255
+ onChange={(key) => setFocused(focused.set({ year: Number(key), day: 1 }))}
256
+ options={pickerYears.map((y) => ({ value: String(y), label: String(y) }))}
257
+ />
258
+ </div>
259
+ ) : (
260
+ <span className="ty-calendar__heading" id={headingId} aria-live="polite">
261
+ {heading}
262
+ </span>
263
+ )
264
+
265
+ const stepper = (
266
+ // Lives inside the AriaCalendar/AriaRangeCalendar: the slot="previous" and
267
+ // slot="next" buttons only receive their wiring from the calendar context.
268
+ <div className="ty-calendar__stepper">
269
+ <AriaButton slot="previous" className="ty-calendar__nav" aria-label={t.previousMonth(monthYearFmt.format(toDate(prevTarget)))}>
270
+ <ChevronLeft className="ty-icon ty-mirror-rtl" aria-hidden="true" focusable="false" />
271
+ </AriaButton>
272
+ {header}
273
+ <AriaButton slot="next" className="ty-calendar__nav" aria-label={t.nextMonth(monthYearFmt.format(toDate(nextTarget)))}>
274
+ <ChevronRight className="ty-icon ty-mirror-rtl" aria-hidden="true" focusable="false" />
275
+ </AriaButton>
276
+ </div>
277
+ )
278
+
279
+ const shared = {
280
+ 'aria-label': props.label,
281
+ minValue: min,
282
+ maxValue: max,
283
+ isDisabled: props.disabled,
284
+ isReadOnly: props.readOnly,
285
+ pageBehavior: props.pageBehavior ?? 'visible',
286
+ firstDayOfWeek: props.firstDayOfWeek,
287
+ visibleDuration: { months },
288
+ focusedValue: focused,
289
+ onFocusChange: setFocused,
290
+ isInvalid: invalid || undefined,
291
+ } as const
292
+
293
+ return (
294
+ <I18nProvider locale={locale}>
295
+ <div
296
+ className={cx('ty-calendar', props.className)}
297
+ data-mode={mode}
298
+ data-months={months}
299
+ data-outside-days={props.showOutsideDays === false ? undefined : ''}
300
+ data-invalid={invalid || undefined}
301
+ >
302
+ <div className="ty-calendar__body">
303
+ {mode === 'range' ? (
304
+ <AriaRangeCalendar
305
+ {...shared}
306
+ className="ty-calendar__rac"
307
+ value={value && 'start' in value ? { start: fromDate(value.start), end: fromDate(value.end) } : null}
308
+ onChange={(range) => {
309
+ if (!range) return report(null)
310
+ report({ start: toDate(range.start as CalendarDate), end: toDate(range.end as CalendarDate) })
311
+ }}
312
+ isDateUnavailable={baseUnavailable}
313
+ allowsNonContiguousRanges={props.allowsNonContiguousRanges}
314
+ >
315
+ <RangeEscapeCancel />
316
+ {stepper}
317
+ {Array.from({ length: months }, (_, i) => renderGrid(i))}
318
+ </AriaRangeCalendar>
319
+ ) : (
320
+ <AriaCalendar
321
+ {...shared}
322
+ className="ty-calendar__rac"
323
+ value={value && 'start' in value ? null : (value ? fromDate(value as CalendarDay) : null)}
324
+ onChange={(d) => report(d ? toDate(d as CalendarDate) : null)}
325
+ isDateUnavailable={baseUnavailable}
326
+ >
327
+ {stepper}
328
+ {Array.from({ length: months }, (_, i) => renderGrid(i))}
329
+ </AriaCalendar>
330
+ )}
331
+ </div>
332
+ {errorId ? (
333
+ <p className="ty-calendar__error" id={errorId} role="alert">
334
+ {props.errorMessage}
335
+ </p>
336
+ ) : null}
337
+ {props.footer ? <div className="ty-calendar__footer">{props.footer}</div> : null}
338
+ </div>
339
+ </I18nProvider>
340
+ )
341
+ }
@@ -0,0 +1,140 @@
1
+ /* DateRangeField (spec: wave-5-general/date-range-field.md; tokens as DateField and Calendar). */
2
+ @layer tympan.components {
3
+ .ty-date-range-field {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: var(--ty-space-1);
7
+ min-inline-size: 0;
8
+ }
9
+ .ty-date-range-field__picker {
10
+ min-inline-size: 0;
11
+ }
12
+ .ty-date-range-field__group {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: var(--ty-space-2);
16
+ min-block-size: var(--ty-control-height);
17
+ padding-inline: var(--ty-space-3);
18
+ border: 1px solid var(--ty-line);
19
+ border-radius: var(--ty-radius-control);
20
+ background: var(--ty-input);
21
+ color: var(--ty-ink);
22
+ font-size: var(--ty-font-size-body);
23
+ cursor: text;
24
+ }
25
+ .ty-date-range-field__group[data-focus-within] {
26
+ border-color: var(--ty-accent);
27
+ }
28
+ .ty-date-range-field__group[data-focus-visible] {
29
+ outline: var(--ty-focus-width) solid var(--ty-focus-ring);
30
+ outline-offset: 1px;
31
+ }
32
+ .ty-date-range-field[data-invalid] .ty-date-range-field__group {
33
+ border-color: var(--ty-danger);
34
+ }
35
+ .ty-date-range-field__group[data-disabled] {
36
+ background: var(--ty-surface-sunken);
37
+ color: var(--ty-ink-3);
38
+ cursor: not-allowed;
39
+ opacity: 0.6;
40
+ }
41
+ .ty-date-range-field__input {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 1px;
45
+ min-block-size: var(--ty-control-target);
46
+ padding-inline: var(--ty-space-1);
47
+ font-variant-numeric: tabular-nums;
48
+ outline: none;
49
+ }
50
+ .ty-date-range-field__segment {
51
+ padding-inline: 1px;
52
+ border-radius: calc(var(--ty-radius-control) / 2);
53
+ color: var(--ty-ink);
54
+ font-variant-numeric: tabular-nums;
55
+ outline: none;
56
+ cursor: inherit;
57
+ }
58
+ .ty-date-range-field__segment[data-placeholder] {
59
+ color: var(--ty-ink-3);
60
+ }
61
+ .ty-date-range-field__segment[data-focus-visible] {
62
+ outline: var(--ty-focus-width) solid var(--ty-focus-ring);
63
+ outline-offset: 1px;
64
+ }
65
+ .ty-date-range-field__dash {
66
+ color: var(--ty-ink-3);
67
+ }
68
+ .ty-date-range-field__trigger {
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ flex: none;
73
+ inline-size: var(--ty-control-target);
74
+ block-size: var(--ty-control-target);
75
+ margin-inline-end: calc((var(--ty-control-target) - var(--ty-control-height)) / -2);
76
+ border: 0;
77
+ border-radius: var(--ty-radius-control);
78
+ background: transparent;
79
+ color: var(--ty-ink-2);
80
+ font: inherit;
81
+ cursor: pointer;
82
+ outline: none;
83
+ }
84
+ .ty-date-range-field__trigger[data-hovered] {
85
+ background: var(--ty-surface-sunken);
86
+ color: var(--ty-ink);
87
+ }
88
+ .ty-date-range-field__trigger[data-focus-visible] {
89
+ outline: var(--ty-focus-width) solid var(--ty-focus-ring);
90
+ outline-offset: 2px;
91
+ }
92
+
93
+ /* Popover panel: presets and calendar, apply actions at the end. */
94
+ .ty-date-range-field__surface {
95
+ display: block;
96
+ }
97
+ .ty-date-range-field__panel {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: var(--ty-space-3);
101
+ min-inline-size: 0;
102
+ }
103
+ .ty-date-range-field__presets {
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ gap: var(--ty-space-2);
107
+ }
108
+ .ty-date-range-field__actions {
109
+ display: flex;
110
+ justify-content: flex-end;
111
+ gap: var(--ty-space-2);
112
+ padding-block-start: var(--ty-space-2);
113
+ border-block-start: 1px solid var(--ty-line-soft);
114
+ }
115
+
116
+ /* Narrow screens: the popover is a tray; presets become a chip row that scrolls. */
117
+ .ty-date-range-field__surface[data-presentation='tray'] .ty-date-range-field__presets {
118
+ flex-wrap: nowrap;
119
+ overflow-x: auto;
120
+ scroll-snap-type: x proximity;
121
+ padding-block-end: var(--ty-space-1);
122
+ }
123
+
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .ty-date-range-field__surface {
126
+ animation-duration: 0s;
127
+ transition-duration: 0s;
128
+ }
129
+ }
130
+
131
+ @media (forced-colors: active) {
132
+ .ty-date-range-field__group {
133
+ border-color: CanvasText;
134
+ forced-color-adjust: none;
135
+ }
136
+ .ty-date-range-field__trigger[data-focus-visible] {
137
+ outline: 2px solid Highlight;
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,187 @@
1
+ import { render, screen, waitFor, within } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { useState } from 'react'
5
+ import { expectNoAxeViolations } from '../../../test/axe'
6
+ import { cssOf, mediaBlock } from '../../../test/css'
7
+ import { renderRtl } from '../../../test/rtl'
8
+ import { setViewportWidth } from '../../../test/media'
9
+ import { ThemeScope } from '../../internal/ThemeScope'
10
+ import { DateRangeField, type DateRangeFieldValue } from './DateRangeField'
11
+
12
+ const day = (y: number, m: number, d: number) => new Date(y, m - 1, d)
13
+ const rangeValue = (sy: number, sm: number, sd: number, ey: number, em: number, ed: number): DateRangeFieldValue => ({
14
+ start: day(sy, sm, sd),
15
+ end: day(ey, em, ed),
16
+ })
17
+ const sameDay = (a: Date | null | undefined, y: number, m: number, d: number) =>
18
+ !!a && a.getFullYear() === y && a.getMonth() === m - 1 && a.getDate() === d
19
+
20
+ // jsdom has no contenteditable support; react-aria date segments rely on
21
+ // beforeinput events, which userEvent only dispatches to editable elements.
22
+ Object.defineProperty(HTMLElement.prototype, 'isContentEditable', {
23
+ configurable: true,
24
+ get(this: HTMLElement) {
25
+ return this.getAttribute('contenteditable') === 'true'
26
+ },
27
+ })
28
+
29
+ describe('DateRangeField', () => {
30
+ afterEach(() => {
31
+ vi.useRealTimers()
32
+ })
33
+
34
+ it('types start and end dates into the segments', async () => {
35
+ const onChange = vi.fn()
36
+ function Harness() {
37
+ const [value, setValue] = useState<DateRangeFieldValue | null>(null)
38
+ return (
39
+ <DateRangeField
40
+ label="Period"
41
+ value={value}
42
+ onChange={(next) => {
43
+ setValue(next)
44
+ onChange(next)
45
+ }}
46
+ locale="en-GB"
47
+ />
48
+ )
49
+ }
50
+ render(<Harness />)
51
+ const startDay = document.querySelector('.ty-date-range-field__segment[data-type="day"]')!
52
+ expect(startDay).toBeTruthy()
53
+ await userEvent.type(startDay, '01032026')
54
+ // Completing the start date moves focus into the end input.
55
+ const inputs = document.querySelectorAll('.ty-date-range-field__input')
56
+ expect(inputs[1]!.contains(document.activeElement)).toBe(true)
57
+ await userEvent.type(document.activeElement as HTMLElement, '05032026')
58
+ await waitFor(() => {
59
+ const reported = onChange.mock.calls.at(-1)?.[0] as DateRangeFieldValue | undefined
60
+ expect(reported?.start && sameDay(reported.start, 2026, 3, 1)).toBe(true)
61
+ expect(reported?.end && sameDay(reported.end, 2026, 3, 5)).toBe(true)
62
+ })
63
+ })
64
+
65
+ it('reports end-before-start as invalid with the computed message', () => {
66
+ render(<DateRangeField label="Period" value={rangeValue(2026, 3, 10, 2026, 3, 5)} onChange={() => {}} locale="en-US" />)
67
+ expect(screen.getByText(/end date is before the start date/i)).toBeVisible()
68
+ expect(document.querySelector('.ty-date-range-field')).toHaveAttribute('data-invalid', 'true')
69
+ })
70
+
71
+ it('reports a range longer than maxDays as invalid', () => {
72
+ render(<DateRangeField label="Window" value={rangeValue(2026, 1, 1, 2026, 2, 9)} onChange={() => {}} maxDays={31} locale="en-US" />)
73
+ expect(screen.getByText(/longer than 31 days/i)).toBeVisible()
74
+ })
75
+
76
+ it('commits two picks in immediate mode and closes the popover', async () => {
77
+ const onChange = vi.fn()
78
+ render(
79
+ <DateRangeField label="Period" value={rangeValue(2026, 3, 1, 2026, 3, 2)} onChange={onChange} visibleMonths={1} locale="en-US" />,
80
+ )
81
+ await userEvent.click(screen.getByRole('button', { name: /Period/ }))
82
+ const grid = await screen.findByRole('grid')
83
+ await userEvent.click(within(grid).getByRole('button', { name: /March 10/ }))
84
+ await userEvent.click(within(grid).getByRole('button', { name: /March 12/ }))
85
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
86
+ const reported = onChange.mock.calls.at(-1)![0] as DateRangeFieldValue
87
+ expect(sameDay(reported.start, 2026, 3, 10)).toBe(true)
88
+ expect(sameDay(reported.end, 2026, 3, 12)).toBe(true)
89
+ })
90
+
91
+ it('keeps a draft in apply mode and restores it on Cancel', async () => {
92
+ const onChange = vi.fn()
93
+ const original = rangeValue(2026, 3, 2, 2026, 3, 4)
94
+ render(
95
+ <DateRangeField label="Period" value={original} onChange={onChange} confirmation="apply" visibleMonths={1} locale="en-US" />,
96
+ )
97
+ await userEvent.click(screen.getByRole('button', { name: /Period/ }))
98
+ const grid = await screen.findByRole('grid')
99
+ await userEvent.click(within(grid).getByRole('button', { name: /March 10/ }))
100
+ await userEvent.click(within(grid).getByRole('button', { name: /March 12/ }))
101
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
102
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
103
+ expect(onChange).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('applies a preset relative to today', async () => {
107
+ const onChange = vi.fn()
108
+ const today = new Date()
109
+ const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6)
110
+ render(
111
+ <DateRangeField
112
+ label="Period"
113
+ value={null}
114
+ onChange={onChange}
115
+ presets={[{ id: 'last-7', label: 'Last 7 days', range: { start, end: today } }]}
116
+ visibleMonths={1}
117
+ locale="en-US"
118
+ />,
119
+ )
120
+ await userEvent.click(screen.getByRole('button', { name: /Period/ }))
121
+ await screen.findByRole('grid')
122
+ await userEvent.click(screen.getByRole('button', { name: 'Last 7 days' }))
123
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
124
+ const reported = onChange.mock.calls.at(-1)![0] as DateRangeFieldValue
125
+ expect(sameDay(reported.end, today.getFullYear(), today.getMonth() + 1, today.getDate())).toBe(true)
126
+ expect(sameDay(reported.start, start.getFullYear(), start.getMonth() + 1, start.getDate())).toBe(true)
127
+ })
128
+
129
+ it('returns focus to the calendar button on Escape', async () => {
130
+ render(<DateRangeField label="Period" value={null} onChange={() => {}} locale="en-US" />)
131
+ const trigger = screen.getByRole('button', { name: /Period/ })
132
+ await userEvent.click(trigger)
133
+ await screen.findByRole('dialog')
134
+ await userEvent.keyboard('{Escape}')
135
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
136
+ await waitFor(() => expect(trigger).toHaveFocus())
137
+ })
138
+
139
+ it('submits both dates in ISO format through named inputs', () => {
140
+ render(
141
+ <DateRangeField label="Period" value={rangeValue(2026, 3, 1, 2026, 3, 12)} onChange={() => {}} startName="from" endName="to" locale="en-US" />,
142
+ )
143
+ expect(document.querySelector('input[name="from"]')).toHaveValue('2026-03-01')
144
+ expect(document.querySelector('input[name="to"]')).toHaveValue('2026-03-12')
145
+ })
146
+
147
+ it('opens one month as a tray on a narrow viewport', async () => {
148
+ setViewportWidth(400)
149
+ render(<DateRangeField label="Period" value={null} onChange={() => {}} locale="en-US" />)
150
+ await userEvent.click(screen.getByRole('button', { name: /Period/ }))
151
+ const dialog = await screen.findByRole('dialog')
152
+ expect(dialog.closest('[data-presentation]')).toHaveAttribute('data-presentation', 'tray')
153
+ expect(screen.getAllByRole('grid')).toHaveLength(1)
154
+ expect(screen.queryByText(/–\s*\w+ 2026/)).toBeNull()
155
+ })
156
+
157
+ it('keeps hit areas, tray chips and forced-colour borders in the stylesheet', () => {
158
+ const css = cssOf('components/date-range-field/DateRangeField.css')
159
+ expect(css).toMatch(/var\(--ty-control-target\)/)
160
+ expect(css).toMatch(/\[data-presentation='tray'\] \.ty-date-range-field__presets/)
161
+ expect(mediaBlock(css, /\(forced-colors:\s*active\)/)).toMatch(/CanvasText/)
162
+ expect(mediaBlock(css, /\(prefers-reduced-motion:\s*reduce\)/)).toMatch(/0s/)
163
+ })
164
+
165
+ it('has no axe violations, closed and open, light and dark', async () => {
166
+ const { container } = render(
167
+ <>
168
+ {(['light', 'dark'] as const).map((scheme) => (
169
+ <ThemeScope key={scheme} scheme={scheme}>
170
+ <DateRangeField label={`Period ${scheme}`} value={rangeValue(2026, 3, 1, 2026, 3, 12)} onChange={() => {}} hint="Reporting window." />
171
+ </ThemeScope>
172
+ ))}
173
+ </>,
174
+ )
175
+ await expectNoAxeViolations(container)
176
+ await userEvent.click(screen.getByRole('button', { name: /Period light/ }))
177
+ await expectNoAxeViolations(document.body)
178
+ })
179
+
180
+ it('renders and passes axe in right-to-left', async () => {
181
+ const { container } = renderRtl(<DateRangeField label="الفترة" value={rangeValue(2026, 9, 1, 2026, 9, 12)} onChange={() => {}} />)
182
+ await expectNoAxeViolations(container)
183
+ await userEvent.click(screen.getByRole('button', { name: /الفترة/ }))
184
+ await screen.findByRole('dialog')
185
+ await expectNoAxeViolations(document.body)
186
+ })
187
+ })