UNPKG

@utahdts/utah-design-system

Version:
343 lines (328 loc) 13.7 kB
import { add, format, isValid, parse } from 'date-fns'; import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useAriaMessaging } from '../../../contexts/UtahDesignSystemContext/hooks/useAriaMessaging'; import { joinClassNames } from '../../../util/joinClassNames'; import { useOnKeyUp } from '../../../util/useOnKeyUp'; import { Button } from '../../buttons/Button'; import { IconButton } from '../../buttons/IconButton'; import { ErrorMessage } from '../ErrorMessage'; import { RequiredStar } from '../RequiredStar'; import { calendarGrid } from './calendarGrid'; let oldMoveCurrentValueFocusTimeoutId = NaN; /** * @param {string} calendarInputId * @param {Date | null} oldDate * @param {string} dateFormat * @param {import('date-fns').Duration} duration * @returns {Date | null} */ function moveCurrentValueFocus(calendarInputId, oldDate, dateFormat, duration) { const newDate = add((oldDate && isValid(oldDate)) ? oldDate : new Date(), duration); clearTimeout(oldMoveCurrentValueFocusTimeoutId); // focus on the next date; delay so that the new month's view draws before it tries to focus on the new date oldMoveCurrentValueFocusTimeoutId = window.setTimeout( () => { document.getElementById(`calendar-input__${calendarInputId}__${format(newDate, dateFormat)}`)?.focus(); }, 0 ); return newDate; } /** * @param {object} props * @param {string} [props.className] * @param {string} [props.dateFormat] use `date-fns` modifiers for formatting the date https://date-fns.org/v3.2.0/docs/format * @param {string} [props.errorMessage] * @param {string} props.id * @param {import('react').RefObject<HTMLDivElement | null>} [props.innerRef] * @param {boolean} [props.isDisabled] * @param {boolean} [props.isHidden] a dateInput will hide its calendar popup when not in use * @param {boolean} [props.isRequired] * @param {string} props.label * @param {string} [props.labelClassName] * @param {(newValue: string) => void} props.onChange * @param {boolean} [props.shouldSetFocusOnMount] if rendered in a popup, then set focus to first focusable element when first shown * @param {boolean} [props.showTodayButton] * @param {string | null} [props.value] expects value to be in format of props.dateFormat * @param {string} [props.wrapperClassName] * @returns {import('react').JSX.Element} */ export function CalendarInput({ className, dateFormat = 'MM/dd/yyyy', errorMessage, id, innerRef, isDisabled, isHidden, isRequired, label, labelClassName, onChange, shouldSetFocusOnMount, showTodayButton, value, wrapperClassName, ...rest }) { const { addPoliteMessage } = useAriaMessaging(); const calendarInputId = useId(); const firstFocusableElementRef = useRef(/** @type {any | null} */(null)); // currentValueDate is the currently selected date const currentValueDate = value ? parse(value, dateFormat, new Date()) : null; // currentValueDateInternal is the currently focused date (not necessarily the selected/value date) const [currentValueDateInternal, setCurrentValueDateInternal] = useState(/** @type {Date | null} */(null)); // if new value passed in, move to that month useEffect( () => { if (currentValueDateInternal?.getTime() !== currentValueDate?.getTime()) { setCurrentValueDateInternal((currentValueDate && isValid(currentValueDate)) ? currentValueDate : new Date()); } }, [currentValueDate?.getTime()] ); // focus on first element when popped open useEffect( () => { if (shouldSetFocusOnMount && !isHidden) { firstFocusableElementRef.current?.focus(); } }, [shouldSetFocusOnMount, isHidden] ); const calendarMonthDate = (currentValueDateInternal && isValid(currentValueDateInternal)) ? currentValueDateInternal : new Date(); const calendarGridValues = useMemo(() => calendarGrid(currentValueDateInternal, currentValueDate), [currentValueDateInternal, value]); const onDownArrowPress = useOnKeyUp( 'ArrowDown', useCallback(() => setCurrentValueDateInternal((date) => moveCurrentValueFocus(calendarInputId, date, dateFormat, { weeks: 1 })), []), true ); const onUpArrowPress = useOnKeyUp( 'ArrowUp', useCallback(() => setCurrentValueDateInternal((date) => moveCurrentValueFocus(calendarInputId, date, dateFormat, { weeks: -1 })), []), true ); const onLeftArrowPress = useOnKeyUp( 'ArrowLeft', useCallback(() => setCurrentValueDateInternal((date) => moveCurrentValueFocus(calendarInputId, date, dateFormat, { days: -1 })), []), true ); const onRightArrowPress = useOnKeyUp( 'ArrowRight', useCallback(() => setCurrentValueDateInternal((date) => moveCurrentValueFocus(calendarInputId, date, dateFormat, { days: 1 })), []), true ); return ( <div className={joinClassNames('input-wrapper input-wrapper--calendar-input', wrapperClassName, className)} ref={innerRef} {...rest} > <label htmlFor={id} className={labelClassName ?? undefined}> {label} {isRequired ? <RequiredStar /> : null} </label> <div className="calendar-input__controls"> <div className="calendar-input__controls-month"> <div> { shouldSetFocusOnMount ? ( <div aria-label="You are in a calendar date picker. Press tab to interact. Use arrow keys on days to navigate." className="calendar-input__first-focusable-element" ref={firstFocusableElementRef} // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex tabIndex={isHidden ? -1 : 0} > {/* First focusable item w/o tooltip */} </div> ) : null } <IconButton className="icon-button--small1x icon-button--borderless" icon={<span className="utds-icon-before-chevron-left" aria-hidden="true" />} innerRef={shouldSetFocusOnMount ? undefined : firstFocusableElementRef} isDisabled={isDisabled} onClick={() => ( setCurrentValueDateInternal((draftDate) => { const newDate = add((draftDate && isValid(draftDate)) ? draftDate : new Date(), { months: -1 }); addPoliteMessage(`Month has changed to ${format(newDate, 'MMMM yyyy')}`); return newDate; }) )} title="Previous Month" // @ts-expect-error tabIndex={isHidden ? -1 : 0} /> </div> <div className="calendar-input__month">{format(calendarMonthDate, 'MMMM')}</div> <div> <IconButton className="icon-button--small1x icon-button--borderless" icon={<span className="utds-icon-before-chevron-right" aria-hidden="true" />} isDisabled={isDisabled} onClick={() => ( setCurrentValueDateInternal((draftDate) => { const newDate = add((draftDate && isValid(draftDate)) ? draftDate : new Date(), { months: 1 }); addPoliteMessage(`Month has changed to ${format(newDate, 'MMMM yyyy')}`); return newDate; }) )} title="Next Month" // @ts-expect-error tabIndex={isHidden ? -1 : 0} /> </div> </div> <div className="calendar-input__controls-year"> <div> <IconButton className="icon-button--small1x icon-button--borderless" icon={<span className="utds-icon-before-double-arrow-left" aria-hidden="true" />} isDisabled={isDisabled} onClick={() => ( setCurrentValueDateInternal((draftDate) => { const newDate = add((draftDate && isValid(draftDate)) ? draftDate : new Date(), { years: -1 }); addPoliteMessage(`Year has changed to ${newDate.getFullYear()}`); return newDate; }) )} title="Last Year" // @ts-expect-error tabIndex={isHidden ? -1 : 0} /> </div> <div className="calendar-input__year">{calendarMonthDate.getFullYear()}</div> <div> <IconButton className="icon-button--small1x icon-button--borderless" icon={<span className="utds-icon-before-double-arrow-right" aria-hidden="true" />} isDisabled={isDisabled} onClick={() => ( setCurrentValueDateInternal((draftDate) => { const newDate = add((draftDate && isValid(draftDate)) ? draftDate : new Date(), { years: 1 }); addPoliteMessage(`Year has changed to ${newDate.getFullYear()}`); return newDate; }) )} title="Next Year" // @ts-expect-error tabIndex={isHidden ? -1 : 0} /> </div> </div> </div> <div className="calendar-input__grid" id={id}> <div className="calendar-input__row" role="row"> <span className="calendar-input__cell-header" role="gridcell">Su</span> <span className="calendar-input__cell-header" role="gridcell">Mo</span> <span className="calendar-input__cell-header" role="gridcell">Tu</span> <span className="calendar-input__cell-header" role="gridcell">We</span> <span className="calendar-input__cell-header" role="gridcell">Th</span> <span className="calendar-input__cell-header" role="gridcell">Fr</span> <span className="calendar-input__cell-header" role="gridcell">Sa</span> </div> { calendarGridValues.map( (weekGridValues, weekGridValuesIndex) => ( <div className="calendar-input__row" // eslint-disable-next-line react/no-array-index-key key={`calendar-input__row__${weekGridValuesIndex}`} role="row" > { weekGridValues.map((cellGridValue) => { const formattedDate = format(cellGridValue.date, dateFormat); return ( <Button className={joinClassNames( 'calendar-input__cell', // if `calendar-input__cell--focused` on change, make sure to check that the TableFilterDateRange down arrow still works cellGridValue.isFocusDate && 'calendar-input__cell--focused', cellGridValue.isNextMonth && 'calendar-input__cell--next-month', cellGridValue.isPreviousMonth && 'calendar-input__cell--previous-month', cellGridValue.isSelectedDate && 'calendar-input__cell--selected', cellGridValue.isTodayDate && 'calendar-input__cell--today' )} id={`calendar-input__${calendarInputId}__${formattedDate}`} isDisabled={isDisabled} key={`calendar-input__cell__${cellGridValue.date.getTime()}`} onClick={() => onChange?.(formattedDate)} type="button" // @ts-expect-error onKeyDown={(e) => { if ( [ 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', ] .includes(e.key) ) { e.preventDefault(); e.stopPropagation(); } }} onKeyUp={ /** @param {import('react').KeyboardEvent<HTMLButtonElement>} e */ (e) => { onDownArrowPress(e); onUpArrowPress(e); onLeftArrowPress(e); onRightArrowPress(e); } } role="gridcell" tabIndex={(isHidden || !cellGridValue.isFocusDate) ? -1 : 0} > <span aria-label={`${format(cellGridValue.date, 'EEEE MMMM do yyyy')}. Press return to select date.`}> {cellGridValue.date.getDate()} </span> </Button> ); }) } </div> ) ) } </div> { showTodayButton ? ( <div className="calendar-input__today" id={id}> <button className="button--small" onClick={() => { setCurrentValueDateInternal(new Date()); onChange?.(format(new Date(), dateFormat)); }} tabIndex={isHidden ? -1 : 0} type="button" > Today </button> </div> ) : null } <ErrorMessage errorMessage={errorMessage} id={id} /> </div> ); }