1 | import React, { useCallback, useMemo } from "react";
|
2 | import type {
|
3 | ChangeEventHandler,
|
4 | MouseEvent,
|
5 | FocusEvent,
|
6 | KeyboardEvent
|
7 | } from "react";
|
8 |
|
9 | import { UI, DayFlag, SelectionState } from "./UI.js";
|
10 | import type { CalendarDay } from "./classes/CalendarDay.js";
|
11 | import { getClassNamesForModifiers } from "./helpers/getClassNamesForModifiers.js";
|
12 | import { getComponents } from "./helpers/getComponents.js";
|
13 | import { getDataAttributes } from "./helpers/getDataAttributes.js";
|
14 | import { getDateLib } from "./helpers/getDateLib.js";
|
15 | import { getDefaultClassNames } from "./helpers/getDefaultClassNames.js";
|
16 | import { getFormatters } from "./helpers/getFormatters.js";
|
17 | import { getMonthOptions } from "./helpers/getMonthOptions.js";
|
18 | import { getStyleForModifiers } from "./helpers/getStyleForModifiers.js";
|
19 | import { getWeekdays } from "./helpers/getWeekdays.js";
|
20 | import { getYearOptions } from "./helpers/getYearOptions.js";
|
21 | import * as defaultLabels from "./labels/index.js";
|
22 | import type { FormatOptions, LabelOptions } from "./lib/dateLib.js";
|
23 | import { enUS } from "./lib/locales.js";
|
24 | import type {
|
25 | DayPickerProps,
|
26 | Modifiers,
|
27 | MoveFocusBy,
|
28 | MoveFocusDir,
|
29 | SelectedValue,
|
30 | SelectHandler
|
31 | } from "./types/index.js";
|
32 | import { useCalendar } from "./useCalendar.js";
|
33 | import { type DayPickerContext, dayPickerContext } from "./useDayPicker.js";
|
34 | import { useFocus } from "./useFocus.js";
|
35 | import { useGetModifiers } from "./useGetModifiers.js";
|
36 | import { useSelection } from "./useSelection.js";
|
37 | import { rangeIncludesDate } from "./utils/rangeIncludesDate.js";
|
38 | import { isDateRange } from "./utils/typeguards.js";
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 | export function DayPicker(props: DayPickerProps) {
|
47 | const { components, formatters, labels, dateLib, locale, classNames } =
|
48 | useMemo(
|
49 | () => ({
|
50 | dateLib: getDateLib(props.dateLib),
|
51 | components: getComponents(props.components),
|
52 | formatters: getFormatters(props.formatters),
|
53 | labels: { ...defaultLabels, ...props.labels },
|
54 | locale: { ...enUS, ...props.locale },
|
55 | classNames: { ...getDefaultClassNames(), ...props.classNames }
|
56 | }),
|
57 | [
|
58 | props.classNames,
|
59 | props.components,
|
60 | props.dateLib,
|
61 | props.formatters,
|
62 | props.labels,
|
63 | props.locale
|
64 | ]
|
65 | );
|
66 |
|
67 | const {
|
68 | captionLayout,
|
69 | firstWeekContainsDate,
|
70 | mode,
|
71 | onDayBlur,
|
72 | onDayClick,
|
73 | onDayFocus,
|
74 | onDayKeyDown,
|
75 | onDayMouseEnter,
|
76 | onDayMouseLeave,
|
77 | onNextClick,
|
78 | onPrevClick,
|
79 | showWeekNumber,
|
80 | styles,
|
81 | useAdditionalDayOfYearTokens,
|
82 | useAdditionalWeekYearTokens,
|
83 | weekStartsOn
|
84 | } = props;
|
85 |
|
86 | const formatOptions: FormatOptions = {
|
87 | locale,
|
88 | weekStartsOn,
|
89 | firstWeekContainsDate,
|
90 | useAdditionalWeekYearTokens,
|
91 | useAdditionalDayOfYearTokens
|
92 | };
|
93 |
|
94 | const labelOptions: LabelOptions = formatOptions;
|
95 |
|
96 | const {
|
97 | formatCaption,
|
98 | formatDay,
|
99 | formatMonthDropdown,
|
100 | formatWeekNumber,
|
101 | formatWeekNumberHeader,
|
102 | formatWeekdayName,
|
103 | formatYearDropdown
|
104 | } = formatters;
|
105 |
|
106 | const calendar = useCalendar(props, dateLib);
|
107 |
|
108 | const {
|
109 | days,
|
110 | months,
|
111 | navStart,
|
112 | navEnd,
|
113 | previousMonth,
|
114 | nextMonth,
|
115 | goToMonth
|
116 | } = calendar;
|
117 |
|
118 | const getModifiers = useGetModifiers(days, props, dateLib);
|
119 |
|
120 | const {
|
121 | isSelected,
|
122 | select,
|
123 | selected: selectedValue
|
124 | } = useSelection(props, dateLib) ?? {};
|
125 |
|
126 | const { blur, focused, isFocusTarget, moveFocus, setFocused } = useFocus(
|
127 | props,
|
128 | calendar,
|
129 | getModifiers,
|
130 | isSelected ?? (() => false),
|
131 | dateLib
|
132 | );
|
133 |
|
134 | const {
|
135 | labelDayButton,
|
136 | labelGridcell,
|
137 | labelGrid,
|
138 | labelMonthDropdown,
|
139 | labelNav,
|
140 | labelNext,
|
141 | labelPrevious,
|
142 | labelWeekday,
|
143 | labelWeekNumber,
|
144 | labelWeekNumberHeader,
|
145 | labelYearDropdown
|
146 | } = labels;
|
147 |
|
148 | const weekdays = useMemo(
|
149 | () => getWeekdays(locale, props.weekStartsOn, props.ISOWeek, dateLib),
|
150 | [dateLib, locale, props.ISOWeek, props.weekStartsOn]
|
151 | );
|
152 |
|
153 | const isInteractive = mode !== undefined || onDayClick !== undefined;
|
154 |
|
155 | const handlePreviousClick = useCallback(() => {
|
156 | if (!previousMonth) return;
|
157 | goToMonth(previousMonth);
|
158 | onPrevClick?.(previousMonth);
|
159 | }, [previousMonth, goToMonth, onPrevClick]);
|
160 |
|
161 | const handleNextClick = useCallback(() => {
|
162 | if (!nextMonth) return;
|
163 | goToMonth(nextMonth);
|
164 | onNextClick?.(nextMonth);
|
165 | }, [goToMonth, nextMonth, onNextClick]);
|
166 |
|
167 | const handleDayClick = useCallback(
|
168 | (day: CalendarDay, m: Modifiers) => (e: MouseEvent) => {
|
169 | e.preventDefault();
|
170 | e.stopPropagation();
|
171 | setFocused(day);
|
172 | select?.(day.date, m, e);
|
173 | onDayClick?.(day.date, m, e);
|
174 | },
|
175 | [select, onDayClick, setFocused]
|
176 | );
|
177 |
|
178 | const handleDayFocus = useCallback(
|
179 | (day: CalendarDay, m: Modifiers) => (e: FocusEvent) => {
|
180 | setFocused(day);
|
181 | onDayFocus?.(day.date, m, e);
|
182 | },
|
183 | [onDayFocus, setFocused]
|
184 | );
|
185 |
|
186 | const handleDayBlur = useCallback(
|
187 | (day: CalendarDay, m: Modifiers) => (e: FocusEvent) => {
|
188 | blur();
|
189 | onDayBlur?.(day.date, m, e);
|
190 | },
|
191 | [blur, onDayBlur]
|
192 | );
|
193 |
|
194 | const handleDayKeyDown = useCallback(
|
195 | (day: CalendarDay, modifiers: Modifiers) => (e: KeyboardEvent) => {
|
196 | const keyMap: Record<string, [MoveFocusBy, MoveFocusDir]> = {
|
197 | ArrowLeft: ["day", props.dir === "rtl" ? "after" : "before"],
|
198 | ArrowRight: ["day", props.dir === "rtl" ? "before" : "after"],
|
199 | ArrowDown: ["week", "after"],
|
200 | ArrowUp: ["week", "before"],
|
201 | PageUp: [e.shiftKey ? "year" : "month", "before"],
|
202 | PageDown: [e.shiftKey ? "year" : "month", "after"],
|
203 | Home: ["startOfWeek", "before"],
|
204 | End: ["endOfWeek", "after"]
|
205 | };
|
206 | if (keyMap[e.key]) {
|
207 | e.preventDefault();
|
208 | e.stopPropagation();
|
209 | const [moveBy, moveDir] = keyMap[e.key];
|
210 | moveFocus(moveBy, moveDir);
|
211 | }
|
212 | onDayKeyDown?.(day.date, modifiers, e);
|
213 | },
|
214 | [moveFocus, onDayKeyDown, props.dir]
|
215 | );
|
216 |
|
217 | const handleDayMouseEnter = useCallback(
|
218 | (day: CalendarDay, modifiers: Modifiers) => (e: MouseEvent) => {
|
219 | onDayMouseEnter?.(day.date, modifiers, e);
|
220 | },
|
221 | [onDayMouseEnter]
|
222 | );
|
223 |
|
224 | const handleDayMouseLeave = useCallback(
|
225 | (day: CalendarDay, modifiers: Modifiers) => (e: MouseEvent) => {
|
226 | onDayMouseLeave?.(day.date, modifiers, e);
|
227 | },
|
228 | [onDayMouseLeave]
|
229 | );
|
230 |
|
231 | const { className, style } = useMemo(
|
232 | () => ({
|
233 | className: [classNames[UI.Root], props.className]
|
234 | .filter(Boolean)
|
235 | .join(" "),
|
236 | style: { ...styles?.[UI.Root], ...props.style }
|
237 | }),
|
238 | [classNames, props.className, props.style, styles]
|
239 | );
|
240 |
|
241 | const dataAttributes = getDataAttributes(props);
|
242 |
|
243 | const contextValue: DayPickerContext<DayPickerProps> = {
|
244 | selected: selectedValue as SelectedValue<DayPickerProps>,
|
245 | select: select as SelectHandler<DayPickerProps>,
|
246 | isSelected,
|
247 | months,
|
248 | nextMonth,
|
249 | previousMonth,
|
250 | goToMonth,
|
251 | getModifiers
|
252 | };
|
253 |
|
254 | return (
|
255 | <dayPickerContext.Provider value={contextValue}>
|
256 | <components.Root
|
257 | className={className}
|
258 | style={style}
|
259 | dir={props.dir}
|
260 | id={props.id}
|
261 | lang={props.lang}
|
262 | nonce={props.nonce}
|
263 | title={props.title}
|
264 | {...dataAttributes}
|
265 | >
|
266 | <components.Months
|
267 | className={classNames[UI.Months]}
|
268 | style={styles?.[UI.Months]}
|
269 | >
|
270 | {!props.hideNavigation && (
|
271 | <components.Nav
|
272 | role="navigation"
|
273 | className={classNames[UI.Nav]}
|
274 | style={styles?.[UI.Nav]}
|
275 | aria-label={labelNav()}
|
276 | >
|
277 | <components.Button
|
278 | type="button"
|
279 | className={classNames[UI.ButtonPrevious]}
|
280 | tabIndex={previousMonth ? undefined : -1}
|
281 | disabled={previousMonth ? undefined : true}
|
282 | aria-label={labelPrevious(previousMonth, labelOptions)}
|
283 | onClick={handlePreviousClick}
|
284 | >
|
285 | <components.Chevron
|
286 | disabled={previousMonth ? undefined : true}
|
287 | className={classNames[UI.Chevron]}
|
288 | orientation="left"
|
289 | />
|
290 | </components.Button>
|
291 | <components.Button
|
292 | type="button"
|
293 | className={classNames[UI.ButtonNext]}
|
294 | tabIndex={nextMonth ? undefined : -1}
|
295 | disabled={nextMonth ? undefined : true}
|
296 | aria-label={labelNext(nextMonth, labelOptions)}
|
297 | onClick={handleNextClick}
|
298 | >
|
299 | <components.Chevron
|
300 | disabled={previousMonth ? undefined : true}
|
301 | orientation="right"
|
302 | className={classNames[UI.Chevron]}
|
303 | />
|
304 | </components.Button>
|
305 | </components.Nav>
|
306 | )}
|
307 | {months.map((calendarMonth, displayIndex) => {
|
308 | const handleMonthChange: ChangeEventHandler<HTMLSelectElement> = (
|
309 | e
|
310 | ) => {
|
311 | const selectedMonth = Number(
|
312 | (e.target as HTMLSelectElement).value
|
313 | );
|
314 | const month = dateLib.setMonth(
|
315 | dateLib.startOfMonth(calendarMonth.date),
|
316 | selectedMonth
|
317 | );
|
318 | goToMonth(month);
|
319 | };
|
320 |
|
321 | const handleYearChange: ChangeEventHandler<HTMLSelectElement> = (
|
322 | e
|
323 | ) => {
|
324 | const month = dateLib.setYear(
|
325 | dateLib.startOfMonth(calendarMonth.date),
|
326 | Number(e.target.value)
|
327 | );
|
328 | goToMonth(month);
|
329 | };
|
330 |
|
331 | const dropdownMonths = getMonthOptions(
|
332 | calendarMonth.date,
|
333 | navStart,
|
334 | navEnd,
|
335 | formatters,
|
336 | locale,
|
337 | dateLib
|
338 | );
|
339 |
|
340 | const dropdownYears = getYearOptions(
|
341 | months[0].date,
|
342 | navStart,
|
343 | navEnd,
|
344 | formatters,
|
345 | dateLib
|
346 | );
|
347 |
|
348 | return (
|
349 | <components.Month
|
350 | className={classNames[UI.Month]}
|
351 | style={styles?.[UI.Month]}
|
352 | key={displayIndex}
|
353 | displayIndex={displayIndex}
|
354 | calendarMonth={calendarMonth}
|
355 | >
|
356 | <components.MonthCaption
|
357 | className={classNames[UI.MonthCaption]}
|
358 | style={styles?.[UI.MonthCaption]}
|
359 | calendarMonth={calendarMonth}
|
360 | displayIndex={displayIndex}
|
361 | >
|
362 | {captionLayout?.startsWith("dropdown") ? (
|
363 | <components.DropdownNav
|
364 | className={classNames[UI.Dropdowns]}
|
365 | style={styles?.[UI.Dropdowns]}
|
366 | >
|
367 | {captionLayout === "dropdown" ||
|
368 | captionLayout === "dropdown-months" ? (
|
369 | <components.Dropdown
|
370 | aria-label={labelMonthDropdown()}
|
371 | classNames={classNames}
|
372 | components={components}
|
373 | disabled={Boolean(props.disableNavigation)}
|
374 | onChange={handleMonthChange}
|
375 | options={dropdownMonths}
|
376 | style={styles?.[UI.Dropdown]}
|
377 | value={calendarMonth.date.getMonth()}
|
378 | />
|
379 | ) : (
|
380 | <span role="status" aria-live="polite">
|
381 | {formatMonthDropdown(calendarMonth.date.getMonth())}
|
382 | </span>
|
383 | )}
|
384 | {captionLayout === "dropdown" ||
|
385 | captionLayout === "dropdown-years" ? (
|
386 | <components.Dropdown
|
387 | aria-label={labelYearDropdown(labelOptions)}
|
388 | classNames={classNames}
|
389 | components={components}
|
390 | disabled={Boolean(props.disableNavigation)}
|
391 | onChange={handleYearChange}
|
392 | options={dropdownYears}
|
393 | style={styles?.[UI.Dropdown]}
|
394 | value={calendarMonth.date.getFullYear()}
|
395 | />
|
396 | ) : (
|
397 | <span role="status" aria-live="polite">
|
398 | {formatYearDropdown(calendarMonth.date.getFullYear())}
|
399 | </span>
|
400 | )}
|
401 | </components.DropdownNav>
|
402 | ) : (
|
403 | <components.CaptionLabel
|
404 | className={classNames[UI.CaptionLabel]}
|
405 | role="status"
|
406 | aria-live="polite"
|
407 | >
|
408 | {formatCaption(
|
409 | calendarMonth.date,
|
410 | formatOptions,
|
411 | dateLib
|
412 | )}
|
413 | </components.CaptionLabel>
|
414 | )}
|
415 | </components.MonthCaption>
|
416 | <components.MonthGrid
|
417 | role="grid"
|
418 | aria-multiselectable={mode === "multiple" || mode === "range"}
|
419 | aria-label={
|
420 | labelGrid(calendarMonth.date, labelOptions, dateLib) ||
|
421 | undefined
|
422 | }
|
423 | className={classNames[UI.MonthGrid]}
|
424 | style={styles?.[UI.MonthGrid]}
|
425 | >
|
426 | {!props.hideWeekdays && (
|
427 | <components.Weekdays
|
428 | className={classNames[UI.Weekdays]}
|
429 | role="row"
|
430 | style={styles?.[UI.Weekdays]}
|
431 | >
|
432 | {showWeekNumber && (
|
433 | <components.WeekNumberHeader
|
434 | aria-label={labelWeekNumberHeader(labelOptions)}
|
435 | className={classNames[UI.WeekNumberHeader]}
|
436 | role="columnheader"
|
437 | style={styles?.[UI.WeekNumberHeader]}
|
438 | >
|
439 | {formatWeekNumberHeader()}
|
440 | </components.WeekNumberHeader>
|
441 | )}
|
442 | {weekdays.map((weekday, i) => (
|
443 | <components.Weekday
|
444 | aria-label={labelWeekday(
|
445 | weekday,
|
446 | labelOptions,
|
447 | dateLib
|
448 | )}
|
449 | className={classNames[UI.Weekday]}
|
450 | key={i}
|
451 | role="columnheader"
|
452 | style={styles?.[UI.Weekday]}
|
453 | >
|
454 | {formatWeekdayName(weekday, formatOptions, dateLib)}
|
455 | </components.Weekday>
|
456 | ))}
|
457 | </components.Weekdays>
|
458 | )}
|
459 | <components.Weeks
|
460 | className={classNames[UI.Weeks]}
|
461 | role="rowgroup"
|
462 | style={styles?.[UI.Weeks]}
|
463 | >
|
464 | {calendarMonth.weeks.map((week, weekIndex) => {
|
465 | return (
|
466 | <components.Week
|
467 | className={classNames[UI.Week]}
|
468 | key={week.weekNumber}
|
469 | role="row"
|
470 | style={styles?.[UI.Week]}
|
471 | week={week}
|
472 | >
|
473 | {showWeekNumber && (
|
474 | <components.WeekNumber
|
475 | week={week}
|
476 | role="rowheader"
|
477 | style={styles?.[UI.WeekNumber]}
|
478 | aria-label={labelWeekNumber(week.weekNumber, {
|
479 | locale
|
480 | })}
|
481 | className={classNames[UI.WeekNumber]}
|
482 | >
|
483 | {formatWeekNumber(week.weekNumber)}
|
484 | </components.WeekNumber>
|
485 | )}
|
486 | {week.days.map((day: CalendarDay) => {
|
487 | const { date } = day;
|
488 | const modifiers = getModifiers(day);
|
489 |
|
490 | modifiers[DayFlag.focused] =
|
491 | !modifiers.hidden &&
|
492 | Boolean(focused?.isEqualTo(day));
|
493 |
|
494 | modifiers[SelectionState.selected] =
|
495 | !modifiers.disabled &&
|
496 | (isSelected?.(date) || modifiers.selected);
|
497 |
|
498 | if (isDateRange(selectedValue)) {
|
499 |
|
500 | const { from, to } = selectedValue;
|
501 | modifiers[SelectionState.range_start] = Boolean(
|
502 | from && to && dateLib.isSameDay(date, from)
|
503 | );
|
504 | modifiers[SelectionState.range_end] = Boolean(
|
505 | from && to && dateLib.isSameDay(date, to)
|
506 | );
|
507 | modifiers[SelectionState.range_middle] =
|
508 | rangeIncludesDate(
|
509 | selectedValue,
|
510 | date,
|
511 | true,
|
512 | dateLib
|
513 | );
|
514 | }
|
515 |
|
516 | const style = getStyleForModifiers(
|
517 | modifiers,
|
518 | styles,
|
519 | props.modifiersStyles
|
520 | );
|
521 |
|
522 | const className = getClassNamesForModifiers(
|
523 | modifiers,
|
524 | classNames,
|
525 | props.modifiersClassNames
|
526 | );
|
527 |
|
528 | const ariaLabel = !isInteractive
|
529 | ? labelGridcell(
|
530 | date,
|
531 | modifiers,
|
532 | labelOptions,
|
533 | dateLib
|
534 | )
|
535 | : undefined;
|
536 |
|
537 | return (
|
538 | <components.Day
|
539 | key={`${dateLib.format(date, "yyyy-MM-dd")}_${dateLib.format(day.displayMonth, "yyyy-MM")}`}
|
540 | day={day}
|
541 | modifiers={modifiers}
|
542 | role="gridcell"
|
543 | className={className.join(" ")}
|
544 | style={style}
|
545 | aria-hidden={modifiers.hidden || undefined}
|
546 | aria-selected={modifiers.selected || undefined}
|
547 | aria-label={ariaLabel}
|
548 | data-day={dateLib.format(date, "yyyy-MM-dd")}
|
549 | data-month={
|
550 | day.outside
|
551 | ? dateLib.format(date, "yyyy-MM")
|
552 | : undefined
|
553 | }
|
554 | data-selected={modifiers.selected || undefined}
|
555 | data-disabled={modifiers.disabled || undefined}
|
556 | data-hidden={modifiers.hidden || undefined}
|
557 | data-outside={day.outside || undefined}
|
558 | data-focused={modifiers.focused || undefined}
|
559 | data-today={modifiers.today || undefined}
|
560 | >
|
561 | {isInteractive ? (
|
562 | <components.DayButton
|
563 | className={classNames[UI.DayButton]}
|
564 | style={styles?.[UI.DayButton]}
|
565 | day={day}
|
566 | modifiers={modifiers}
|
567 | disabled={modifiers.disabled || undefined}
|
568 | tabIndex={isFocusTarget(day) ? 0 : -1}
|
569 | aria-label={labelDayButton(
|
570 | date,
|
571 | modifiers,
|
572 | labelOptions,
|
573 | dateLib
|
574 | )}
|
575 | onClick={handleDayClick(day, modifiers)}
|
576 | onBlur={handleDayBlur(day, modifiers)}
|
577 | onFocus={handleDayFocus(day, modifiers)}
|
578 | onKeyDown={handleDayKeyDown(day, modifiers)}
|
579 | onMouseEnter={handleDayMouseEnter(
|
580 | day,
|
581 | modifiers
|
582 | )}
|
583 | onMouseLeave={handleDayMouseLeave(
|
584 | day,
|
585 | modifiers
|
586 | )}
|
587 | >
|
588 | {formatDay(date, formatOptions, dateLib)}
|
589 | </components.DayButton>
|
590 | ) : (
|
591 | formatDay(day.date, formatOptions, dateLib)
|
592 | )}
|
593 | </components.Day>
|
594 | );
|
595 | })}
|
596 | </components.Week>
|
597 | );
|
598 | })}
|
599 | </components.Weeks>
|
600 | </components.MonthGrid>
|
601 | </components.Month>
|
602 | );
|
603 | })}
|
604 | </components.Months>
|
605 | {props.footer && (
|
606 | <components.Footer
|
607 | className={classNames[UI.Footer]}
|
608 | style={styles?.[UI.Footer]}
|
609 | role="status"
|
610 | aria-live="polite"
|
611 | >
|
612 | {props.footer}
|
613 | </components.Footer>
|
614 | )}
|
615 | </components.Root>
|
616 | </dayPickerContext.Provider>
|
617 | );
|
618 | }
|
619 |
|
\ | No newline at end of file |