import React, { ChangeEvent, ComponentType, FC, ForwardedRef, forwardRef, HTMLAttributes, KeyboardEvent, Ref, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import classnames from 'classnames'; import dayjs from 'dayjs'; import weekday from 'dayjs/plugin/weekday'; import localeData from 'dayjs/plugin/localeData'; import { Button } from './Button'; import { Select, Option } from './Select'; import { getToday, isElInChildren } from './util'; import { ComponentSettingsContext } from './ComponentSettings'; import { useEventCallback, useMergeRefs } from './hooks'; /** * */ dayjs.extend(weekday); dayjs.extend(localeData); /** * */ type CalendarDate = { year: number; month: number; date: number; value: string; }; type Calendar = { year: number; month: number; weeks: CalendarDate[][]; minDate?: CalendarDate; maxDate?: CalendarDate; }; function createCalendarObject(date?: string, mnDate?: string, mxDate?: string) { let minDate; let maxDate; let d = dayjs(date ?? null, 'YYYY-MM-DD'); if (!d.isValid()) { d = dayjs(getToday(), 'YYYY-MM-DD'); } if (mnDate) { const minD = dayjs(mnDate, 'YYYY-MM-DD'); if (minD.isValid()) { minDate = { year: minD.year(), month: minD.month(), date: minD.date(), value: minD.format('YYYY-MM-DD'), }; } } if (mxDate) { const maxD = dayjs(mxDate, 'YYYY-MM-DD'); if (maxD.isValid()) { maxDate = { year: maxD.year(), month: maxD.month(), date: maxD.date(), value: maxD.format('YYYY-MM-DD'), }; } } const year = d.year(); const month = d.month(); const first = dayjs(d).startOf('month').startOf('week'); const last = dayjs(d).endOf('month').endOf('week'); const weeks = []; let days = []; for (let dd = first; dd.isBefore(last); dd = dd.add(1, 'd')) { days.push({ year: dd.year(), month: dd.month(), date: dd.date(), value: dd.format('YYYY-MM-DD'), }); if (days.length === 7) { weeks.push(days); days = []; } } const cal: Calendar = { year, month, weeks }; if (minDate) { cal.minDate = minDate; } if (maxDate) { cal.maxDate = maxDate; } return cal; } function cancelEvent(e: React.FocusEvent) { e.preventDefault(); e.stopPropagation(); } /** * */ export type DatepickerProps = { selectedDate?: string; autoFocus?: boolean; minDate?: string; maxDate?: string; extensionRenderer?: ComponentType; elementRef?: Ref; onSelect?: (date: string) => void; onClose?: () => void; } & Omit, 'onSelect'>; /** * */ type DatepickerFilterProps = { cal: Calendar; onMonthChange: (dm: number) => void; onYearChange: (e: ChangeEvent) => void; }; /** * */ const DatepickerFilter: FC = (props) => { const { cal, onMonthChange, onYearChange } = props; const onPrevMonth = useEventCallback(() => onMonthChange(-1)); const onNextMonth = useEventCallback(() => onMonthChange(1)); return (

{dayjs.monthsShort()[cal.month]}

); }; /** * */ type DatepickerHandlers = { onDateKeyDown: ( date: string, e: React.KeyboardEvent ) => void; onDateClick: (date: string) => void; onDateFocus: (date: string) => void; }; type DatepickerDateProps = { cal: Calendar; selectedDate: string | undefined; today: string; date: CalendarDate; } & DatepickerHandlers; /** * */ const DatepickerDate: FC = (props) => { const { cal, selectedDate, today, date, onDateKeyDown: onDateKeyDown_, onDateClick: onDateClick_, onDateFocus: onDateFocus_, } = props; const onDateKeyDown = useEventCallback((e: KeyboardEvent) => { onDateKeyDown_(date.value, e); }); const onDateClick = useEventCallback(() => { onDateClick_(date.value); }); const onDateFocus = useEventCallback(() => { onDateFocus_(date.value); }); let selectable = true; let enabled = date.year === cal.year && date.month === cal.month; if (cal.minDate) { const min = dayjs(date.value, 'YYYY-MM-DD').isAfter( dayjs(cal.minDate.value, 'YYYY-MM-DD') ); selectable = selectable && min; enabled = enabled && min; } if (cal.maxDate) { const max = dayjs(date.value, 'YYYY-MM-DD').isBefore( dayjs(cal.maxDate.value, 'YYYY-MM-DD') ); selectable = selectable && max; enabled = enabled && max; } const selected = date.value === selectedDate; const isToday = date.value === today; const isAdjacentMonth = date.month !== cal.month; const dateClassName = classnames({ 'slds-is-selected': selected, 'slds-is-today': isToday, 'slds-day_adjacent-month': isAdjacentMonth || !enabled, // Considering the meaning, applying this class to disabled dates isn't necesarrily correct. }); return ( {date.date} ); }; /** * */ type DatepickerMonthProps = { cal: Calendar; selectedDate: string | undefined; today: string; } & DatepickerHandlers; /** * */ const DatepickerMonth = forwardRef( (props: DatepickerMonthProps, ref: ForwardedRef) => { const { cal, selectedDate, today, onDateClick, onDateFocus, onDateKeyDown, } = props; return ( {dayjs.weekdaysMin(true).map((wd, i) => ( ))} {cal.weeks.map((days, i) => ( {days.map((date, dayIndex) => ( ))} ))}
{wd}
); } ); /** * */ export const Datepicker: FC = (props) => { const { autoFocus, className, selectedDate, minDate, maxDate, extensionRenderer: ExtensionRenderer, elementRef: elementRef_, onSelect, onBlur: onBlur_, onClose, ...rprops } = props; const [focusDate, setFocusDate] = useState(); const [targetDate, setTargetDate] = useState( selectedDate ); const elRef = useRef(null); const elementRef = useMergeRefs([elRef, elementRef_]); const monthElRef = useRef(null); const onFocusDate = useEventCallback((date: string | undefined) => { const el = monthElRef.current; if (!el || !date) { return; } const dateEl: HTMLSpanElement | null = el.querySelector( `.slds-day[data-date-value="${date}"]` ); if (dateEl) { dateEl.focus(); } }); const { getActiveElement } = useContext(ComponentSettingsContext); const isFocusedInComponent = useEventCallback(() => { const nodeEl = elRef.current; const targetEl = getActiveElement(); return isElInChildren(nodeEl, targetEl); }); useEffect(() => { setTargetDate(selectedDate); }, [selectedDate]); useEffect(() => { if (autoFocus) { const targetDate = selectedDate || getToday(); setTimeout(() => { onFocusDate(targetDate); }, 10); } }, [autoFocus, selectedDate, onFocusDate]); useEffect(() => { if (focusDate && targetDate) { onFocusDate(targetDate); setFocusDate(false); } }, [focusDate, targetDate, onFocusDate]); const onDateClick = useEventCallback((date: string) => { onSelect?.(date); }); const onDateKeyDown = useEventCallback( (date: string, e: React.KeyboardEvent) => { if (e.keyCode === 13 || e.keyCode === 32) { // return / space onDateClick(date); e.preventDefault(); e.stopPropagation(); } else if (e.keyCode >= 37 && e.keyCode <= 40) { // cursor key let d; if (e.keyCode === 37) { d = dayjs(targetDate).add(-1, e.shiftKey ? 'months' : 'days'); } else if (e.keyCode === 39) { // right arrow key d = dayjs(targetDate).add(1, e.shiftKey ? 'months' : 'days'); } else if (e.keyCode === 38) { // up arrow key d = dayjs(targetDate).add(-1, e.shiftKey ? 'years' : 'weeks'); } else if (e.keyCode === 40) { // down arrow key d = dayjs(targetDate).add(1, e.shiftKey ? 'years' : 'weeks'); } const newTargetDate = d?.format('YYYY-MM-DD') ?? targetDate; setTargetDate(newTargetDate); setFocusDate(true); e.preventDefault(); e.stopPropagation(); } } ); const onDateFocus = useEventCallback((date: string) => { if (targetDate !== date) { setTimeout(() => { setTargetDate(date); }, 10); } }); const onYearChange = useEventCallback( (e: React.ChangeEvent) => { const newTargetDate = dayjs(targetDate) .year(Number(e.target.value)) .format('YYYY-MM-DD'); setTargetDate(newTargetDate); } ); const onMonthChange = useEventCallback((month: number) => { const newTargetDate = dayjs(targetDate) .add(month, 'months') .format('YYYY-MM-DD'); setTargetDate(newTargetDate); }); const onBlur = useEventCallback((e: React.FocusEvent) => { setTimeout(() => { if (!isFocusedInComponent()) { onBlur_?.(e); } }, 10); }); const onKeyDown = useEventCallback( (e: React.KeyboardEvent) => { if (e.keyCode === 27) { // ESC onClose?.(); } } ); const today = getToday(); const cal = useMemo( () => createCalendarObject(targetDate, minDate, maxDate), [targetDate, minDate, maxDate] ); const datepickerClassNames = classnames('slds-datepicker', className); return (
{ExtensionRenderer ? : undefined}
); };