@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.
- package/dist/{Avatar-P14MVkK1.js → Avatar-CU7B1WsX.js} +61 -1
- package/dist/{Avatar-P14MVkK1.js.map → Avatar-CU7B1WsX.js.map} +1 -1
- package/dist/avatars.js +1 -1
- package/dist/flow.d.ts +1 -1
- package/dist/flow.js +2 -2
- package/dist/{index-6wR5ThnX.d.ts → index-J-7EBkCL.d.ts} +172 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -3
- package/dist/{src-C9whlpC7.js → src-uCyIE-Te.js} +909 -379
- package/dist/src-uCyIE-Te.js.map +1 -0
- package/dist/styles.css +476 -0
- package/package.json +2 -2
- package/src/components/calendar/Calendar.css +260 -0
- package/src/components/calendar/Calendar.test.tsx +178 -0
- package/src/components/calendar/Calendar.tsx +341 -0
- package/src/components/date-range-field/DateRangeField.css +140 -0
- package/src/components/date-range-field/DateRangeField.test.tsx +187 -0
- package/src/components/date-range-field/DateRangeField.tsx +231 -0
- package/src/components/tooltip/Tooltip.css +70 -0
- package/src/components/tooltip/Tooltip.test.tsx +209 -0
- package/src/components/tooltip/Tooltip.tsx +102 -0
- package/src/index.ts +3 -0
- package/src/internal/messages/core-es.ts +17 -0
- package/src/internal/messages.ts +51 -0
- package/src/styles.css +3 -0
- package/dist/src-C9whlpC7.js.map +0 -1
|
@@ -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
|
+
})
|