import React, { useEffect, useId, useRef, useState, type RefObject } from 'react' import { useIntl } from '@cultureamp/i18n-react-intl' import { type DayEventHandler } from 'react-day-picker' import { FocusOn } from 'react-focus-on' import { CalendarSingle, calculateDisabledDays, formatDateAsNumeral, formatDateAsText, isDisabledDate, isInvalidDate, isSelectingDayInCalendar, parseDateAsTextOrNumeral, setFocusInCalendar, type CalendarSingleElement, type CalendarSingleProps, type DisabledDayMatchers, } from '~components/Calendar' import { CalendarPopover } from '~components/Calendar/CalendarPopover' import { VisuallyHidden } from '~components/VisuallyHidden' import { DateInputField, type DateInputFieldProps } from './subcomponents/DateInputField' import type { ValidationResponse } from './types' import { getLocale, type DatePickerSupportedLocales } from './utils/getLocale' import { validateDate } from './utils/validateDate' type OmittedDateInputFieldProps = | 'onClick' | 'onFocus' | 'onChange' | 'onBlur' | 'onButtonClick' | 'value' | 'locale' | 'id' export type DatePickerProps = { id?: string buttonRef?: RefObject onInputClick?: DateInputFieldProps['onClick'] onInputFocus?: DateInputFieldProps['onFocus'] onInputChange?: DateInputFieldProps['onChange'] onInputBlur?: DateInputFieldProps['onBlur'] onButtonClick?: DateInputFieldProps['onButtonClick'] locale?: DatePickerSupportedLocales /** * Accepts a DayOfWeek value to start the week on that day. * By default it adapts to the provided locale. */ weekStartsOn?: CalendarSingleProps['weekStartsOn'] /** * Accepts a date to display that month on first render. */ defaultMonth?: CalendarSingleProps['defaultMonth'] /** * The date passed in from the consumer that renders in the input and calendar. */ selectedDay: Date | undefined /** * Callback when date is updated either by the calendar picker or by typing and bluring. * Date will return as undefined if invalid or disabled. */ onDayChange: (date: Date | undefined) => void /** * Callback when a date is selected. Utilises internal validation if not set. */ onValidate?: (validationResponse: ValidationResponse) => void /** * Updates the styling of the validation FieldMessage. */ status?: DateInputFieldProps['status'] | undefined /** * A descriptive message for the 'status' states. */ validationMessage?: DateInputFieldProps['validationMessage'] | undefined } & DisabledDayMatchers & Omit /** * {@link https://cultureamp.atlassian.net/wiki/spaces/DesignSystem/pages/3082061174/Date+Picker Guidance} | * {@link https://cultureamp.design/?path=/docs/components-date-controls-datepicker--docs Storybook} */ export const DatePicker = ({ id: propsId, buttonRef: propsButtonRef, locale: propsLocale = 'en-AU', disabledDates, disabledDaysOfWeek, disabledRange, disabledBeforeAfter, disabledBefore, disabledAfter, weekStartsOn, defaultMonth, selectedDay, status, validationMessage, onInputClick, onInputFocus, onInputChange, onInputBlur, onButtonClick, onDayChange, onValidate, ...restDateInputFieldProps }: DatePickerProps): JSX.Element => { const { formatMessage } = useIntl() const calendarLabelDesc = formatMessage({ id: 'datePicker.calendarLabelDescription', defaultMessage: 'Select date from calendar for:', description: 'Label for the search input', }) const reactId = useId() const id = propsId ?? reactId const containerRef = useRef(null) const inputRef = useRef(null) const fallbackButtonRef = useRef(null) const buttonRef = propsButtonRef ?? fallbackButtonRef const dateInputRefs = useRef({ inputRef, buttonRef, }) const [inputValue, setInputValue] = useState('') const [isOpen, setIsOpen] = useState(false) const [lastTrigger, setLastTrigger] = useState<'inputFocus' | 'inputKeydown' | 'calendarButton'>() const [inbuiltStatus, setInbuiltStatus] = useState() const [inbuiltValidationMessage, setInbuiltValidationMessage] = useState() const shouldUseInbuiltValidation = onValidate === undefined const locale = getLocale(propsLocale) const disabledDays = calculateDisabledDays({ disabledDates, disabledDaysOfWeek, disabledRange, disabledBeforeAfter, disabledBefore, disabledAfter, }) const handleValidation = (validationResponse: ValidationResponse): void => { if (shouldUseInbuiltValidation) { setInbuiltStatus(validationResponse.status) setInbuiltValidationMessage(validationResponse.validationMessage) } else { onValidate(validationResponse) } } const handleDayChange = (date: Date | undefined, newInputValue?: string): void => { const { validationResponse, newDate } = validateDate({ date, inputValue: newInputValue, disabledDays, }) handleValidation(validationResponse) onDayChange(newDate) } const handleCalendarDayChange: DayEventHandler = (date) => { if (!isDisabledDate(date, disabledDays)) { const newInputValue = lastTrigger === 'calendarButton' ? formatDateAsText(date, disabledDays, locale) : formatDateAsNumeral(date, locale) setInputValue(newInputValue) handleDayChange(date) setIsOpen(false) } } const handleInputClick: React.MouseEventHandler = (e) => { setIsOpen(true) onInputClick?.(e) } const handleInputFocus: React.FocusEventHandler = (e) => { setLastTrigger('inputFocus') if (selectedDay) { const newInputValue = formatDateAsNumeral(selectedDay, locale) setInputValue(newInputValue) } onInputFocus?.(e) } const handleInputChange: React.ChangeEventHandler = (e) => { setInputValue(e.target.value) onInputChange?.(e) } const handleInputBlur: React.FocusEventHandler = (e) => { if (isSelectingDayInCalendar(e.relatedTarget)) return if (inputValue !== '') { const parsedDate = parseDateAsTextOrNumeral(inputValue, locale) if (!isInvalidDate(parsedDate)) { setInputValue(formatDateAsText(parsedDate, disabledDays, locale)) } handleDayChange(parsedDate, inputValue) onInputBlur?.(e) return } handleDayChange(undefined, inputValue) onInputBlur?.(e) } const handleKeyDown: React.KeyboardEventHandler = (e) => { if (e.key === 'Enter') { setIsOpen(false) const parsedDate = parseDateAsTextOrNumeral(inputValue, locale) handleDayChange(parsedDate, e.currentTarget.value) } if (e.key === 'ArrowDown' || (e.key === 'ArrowDown' && e.altKey === true)) { e.preventDefault() setIsOpen(true) setLastTrigger('inputKeydown') } } const handleButtonClick: React.MouseEventHandler = (e) => { setIsOpen(!isOpen) setLastTrigger('calendarButton') onButtonClick?.(e) } const handleCalendarMount = (calendarElement: CalendarSingleElement): void => { if (lastTrigger === 'inputFocus') return setFocusInCalendar(calendarElement, selectedDay) } const handleReturnFocus = (): void => { if (lastTrigger === 'inputKeydown' || lastTrigger === 'inputFocus') { return inputRef.current?.focus() } buttonRef.current?.focus() } useEffect(() => { if (selectedDay) { setInputValue(formatDateAsText(selectedDay, disabledDays, locale)) const formattedDate = formatDateAsNumeral(selectedDay, locale) const { validationResponse } = validateDate({ date: selectedDay, inputValue: formattedDate, disabledDays, }) if (!validationResponse.isValidDate && !validationResponse.isEmpty) { handleValidation(validationResponse) } } // @todo: Fix if possible - avoiding breaking in eslint upgrade // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDay]) const calendarId = `${id}-calendar-dialog` return ( setIsOpen(false)} onEscapeKey={(): void => setIsOpen(false)} enabled={isOpen} >
{isOpen && ( <> {calendarLabelDesc} )}
) } DatePicker.displayName = 'DatePicker'