import { clsx } from 'clsx'; import { createRef, PureComponent, KeyboardEvent } from 'react'; import { Size, MonthFormat, Position, Breakpoint, type SizeSmall, type SizeMedium, type SizeLarge, } from '../common'; import { isWithinRange, moveToWithinRange, returnDateView } from '../common/dateUtils'; import ResponsivePanel from '../common/responsivePanel'; import { WithInputAttributesProps, withInputAttributes } from '../inputs/contexts'; import { OverlayIdProvider } from '../provider/overlay/OverlayIdProvider'; import DateTrigger from './dateTrigger'; import DayCalendar from './dayCalendar'; import { getStartOfDay } from './getStartOfDay'; import MonthCalendar from './monthCalendar'; import YearCalendar from './yearCalendar'; export interface DateLookupProps { id?: string; value: Date | null; min?: Date | null; max?: Date | null; size?: SizeSmall | SizeMedium | SizeLarge; placeholder?: string; label?: string; 'aria-labelledby'?: string; monthFormat?: `${MonthFormat}`; disabled?: boolean; clearable?: boolean; onChange: (date: Date | null) => void; onFocus?: () => void; onBlur?: () => void; } type DateLookupPropsWithInputAttributes = DateLookupProps & Partial; interface DateLookupState { selectedDate: Date | null; originalDate: Date | null; min: Date | null; max: Date | null; viewMonth: number; viewYear: number; open: boolean; mode: 'day' | 'month' | 'year'; isMobile: boolean; } class DateLookup extends PureComponent { declare props: DateLookupPropsWithInputAttributes & Required>; static defaultProps = { value: null, min: null, max: null, size: Size.MEDIUM, placeholder: '', label: '', monthFormat: MonthFormat.LONG, disabled: false, clearable: false, } satisfies Partial; element = createRef(); dropdown = createRef(); constructor(props: DateLookup['props']) { super(props); const dateView = returnDateView(props.value, props.min, props.max); this.state = { selectedDate: getStartOfDay(props.value), originalDate: null, min: getStartOfDay(props.min), max: getStartOfDay(props.max), viewMonth: dateView.getMonth(), viewYear: dateView.getFullYear(), open: false, mode: 'day', isMobile: false, }; } static getDerivedStateFromProps(props: DateLookup['props'], state: DateLookupState) { const propsSelected = getStartOfDay(props.value); const propsMin = getStartOfDay(props.min); const propsMax = getStartOfDay(props.max); const hasSelectedChanged = state.selectedDate?.getTime() !== propsSelected?.getTime(); const hasMinChanged = state.min?.getTime() !== propsMin?.getTime(); const hasMaxChanged = state.max?.getTime() !== propsMax?.getTime(); if (hasSelectedChanged || hasMinChanged || hasMaxChanged) { const selectedDate = hasSelectedChanged ? propsSelected : state.selectedDate; const min = hasMinChanged ? propsMin : state.min; const max = hasMaxChanged ? propsMax : state.max; if (selectedDate && !isWithinRange(selectedDate, min, max)) { props.onChange(moveToWithinRange(selectedDate, min, max)); return null; } const viewDateThatIsWithinRange: Date = returnDateView(selectedDate, min, max); const viewMonth = viewDateThatIsWithinRange.getMonth(); const viewYear = viewDateThatIsWithinRange.getFullYear(); return { selectedDate, min, max, viewMonth, viewYear }; } return null; } componentDidUpdate(previousProps: DateLookupPropsWithInputAttributes) { if (this.props.value?.getTime() !== previousProps.value?.getTime() && this.state.open) { this.focusOn('.active'); } const mediaQuery = window.matchMedia(`(max-width: ${Breakpoint.SMALL}px)`); this.setState({ isMobile: mediaQuery.matches }); } componentWillUnmount() { // Prevents memory leak by cleaning state. this.setState = () => {}; } open = () => { const { onFocus } = this.props; this.setState({ open: true, mode: 'day' }); if (onFocus) { onFocus(); } }; discard = () => { const { originalDate } = this.state; if (originalDate !== null) { this.props.onChange(originalDate); } this.close(); }; close = () => { const { onBlur } = this.props; this.setState({ open: false, originalDate: null }); if (onBlur) { onBlur(); } }; handleKeyDown = (event: KeyboardEvent) => { const { open, originalDate } = this.state; switch (event.key) { case 'ArrowLeft': if (open) { this.adjustDate(-1, -1, -1); } else { this.open(); } event.preventDefault(); break; case 'ArrowUp': if (open) { this.adjustDate(-7, -4, -4); } else { this.open(); } event.preventDefault(); break; case 'ArrowRight': if (open) { this.adjustDate(1, 1, 1); } else { this.open(); } event.preventDefault(); break; case 'ArrowDown': if (open) { this.adjustDate(7, 4, 4); } else { this.open(); } event.preventDefault(); break; case 'Escape': if (originalDate !== null) { this.props.onChange(originalDate); } this.close(); event.preventDefault(); break; default: break; } }; adjustDate = (daysToAdd: number, monthsToAdd: number, yearsToAdd: number) => { const { selectedDate, min, max, mode, originalDate } = this.state; if (originalDate === null) { this.setState({ originalDate: selectedDate }); } let date: Date | null; if (selectedDate) { date = new Date( mode === 'year' ? selectedDate.getFullYear() + yearsToAdd : selectedDate.getFullYear(), mode === 'month' ? selectedDate.getMonth() + monthsToAdd : selectedDate.getMonth(), mode === 'day' ? selectedDate.getDate() + daysToAdd : selectedDate.getDate(), ); } else { date = getStartOfDay(new Date()); } date &&= moveToWithinRange(date, min, max); if (date?.getTime() !== selectedDate?.getTime()) { this.props.onChange(date); } }; focusOn = (preferredElement: string, fallbackElement?: string) => { const element = this.element.current?.querySelector(preferredElement) as HTMLElement | null; if (element) { element.focus(); } else if (fallbackElement) { this.focusOn(fallbackElement); } }; switchMode = (mode: 'day' | 'month' | 'year') => { this.setState({ mode }, () => { this.focusOn('.active', '.today'); }); }; switchToDays = () => this.switchMode('day'); switchToMonths = () => this.switchMode('month'); switchToYears = () => this.switchMode('year'); handleSelectedDateUpdate = (selectedDate: Date) => { this.setState({ selectedDate }, () => { this.props.onChange(selectedDate); this.close(); this.focusOn('.btn'); }); }; handleViewDateUpdate = ({ month = this.state.viewMonth, year = this.state.viewYear }) => { this.setState({ viewMonth: month, viewYear: year }); }; getCalendar = () => { const { selectedDate, min, max, viewMonth, viewYear, mode, isMobile } = this.state; const { placeholder, monthFormat } = this.props; return (
{mode === 'day' && ( )} {mode === 'month' && ( )} {mode === 'year' && ( )}
); }; handleClear = () => { this.props.onChange(null); this.focusOn('.np-date-trigger'); }; render() { const { selectedDate, open } = this.state; const { inputAttributes, id: idProp, 'aria-labelledby': ariaLabelledByProp, size, placeholder, label, monthFormat, disabled, clearable, value, } = this.props; const id = idProp ?? inputAttributes?.id; const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes?.['aria-labelledby']; return (
{this.getCalendar()}
); } } export const DateLookupWithoutInputAttributes = DateLookup; const WrappedDateLookup = withInputAttributes( DateLookup as React.ComponentType, { nonLabelable: true }, ); WrappedDateLookup.displayName = 'DateLookup'; export default WrappedDateLookup;