@utahdts/utah-design-system
Version:
Utah Design System React Library
343 lines (328 loc) • 13.7 kB
JSX
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>
);
}