@utahdts/utah-design-system
Version:
Utah Design System React Library
242 lines (236 loc) • 8.86 kB
JSX
import { useCallback, useRef } from 'react';
import { useFloating, autoUpdate, offset as floatingOffset, shift, flip } from '@floating-ui/react-dom';
import { useImmer } from 'use-immer';
import { popupPlacement } from '../../enums/popupPlacement';
import { useInterval } from '../../hooks/useInterval';
import { joinClassNames } from '../../util/joinClassNames';
import { useOnKeyUp } from '../../util/useOnKeyUp';
import { IconButton } from '../buttons/IconButton';
import { CalendarInput } from './CalendarInput/CalendarInput';
import { TextInput } from './TextInput';
/**
* @param {HTMLDivElement | null} myWrapper
* @returns {boolean}
*/
function isActiveElementInsideCalendarInput(myWrapper) {
return document.activeElement?.closest('.input-wrapper--date-input') === myWrapper;
}
/**
* @param {object} props
* @param {string} [props.ariaLabel]
* @param {string} [props.className]
* @param {string} [props.dateFormat] use `date-fns` modifiers for formatting the date; used for CalendarInput
* @param {string} [props.defaultValue]
* @param {string} [props.errorMessage]
* @param {boolean} [props.hasCalendarPopup] defaults to true so that the calendar popup opens; otherwise entry is only textual keyboard
* @param {string} props.id
* @param {import('react').MutableRefObject<HTMLDivElement | null>} [props.innerRef]
* @param {boolean} [props.isClearable]
* @param {boolean} [props.isDisabled]
* @param {boolean} [props.isRequired]
* @param {string} props.label
* @param {string} [props.labelClassName]
* @param {string} [props.name] defaults to id if not provided
* @param {(newValue: string) => void} [props.onChange] e => {}; can be omitted for uncontrolled
* @param {() => void} [props.onClear]
* @param {(e: React.KeyboardEvent<HTMLInputElement>) => void} [props.onKeyUp]
* @param {string} [props.placeholder]
* @param {boolean} [props.showCalendarTodayButton] on the calendar popup, should the `today` button be shown
* @param {string} [props.value]
* @param {string} [props.wrapperClassName]
* @returns {import('react').JSX.Element}
*/
export function DateInput({
ariaLabel,
className,
dateFormat,
defaultValue,
errorMessage,
hasCalendarPopup = true,
id,
innerRef: draftInnerRef,
isClearable,
isDisabled,
isRequired,
label,
labelClassName,
name,
onChange,
onClear,
onKeyUp,
placeholder,
showCalendarTodayButton,
value,
wrapperClassName,
...rest
}) {
const wrapperInternalRef = useRef(/** @type {HTMLDivElement | null} */(null));
const [isCalendarPopupOpen, setIsCalendarPopupOpen] = useImmer(false);
const popupReferenceElementRef = useRef(/** @type {HTMLDivElement | null} */(null));
const calendarRef = useRef(/** @type {HTMLDivElement | null} */(null));
const { floatingStyles } = useFloating({
elements: {
reference: popupReferenceElementRef.current,
floating: calendarRef.current,
},
middleware: [
floatingOffset({mainAxis: 4, crossAxis: 0, alignmentAxis: 0}),
flip(),
shift(),
],
open: isCalendarPopupOpen,
placement: popupPlacement.BOTTOM,
whileElementsMounted: autoUpdate,
});
// check if no longer have focus when open
useInterval(
() => {
if (!isActiveElementInsideCalendarInput(wrapperInternalRef.current)) {
setIsCalendarPopupOpen(false);
}
},
250,
{ isDisabled: !isCalendarPopupOpen }
);
const onDownArrowPress = useOnKeyUp(
'ArrowDown',
useCallback(
() => setIsCalendarPopupOpen(true),
[]
),
true
);
return (
<div
className={joinClassNames('input-wrapper input-wrapper--date-input', wrapperClassName)}
ref={(ref) => {
if (draftInnerRef) {
draftInnerRef.current = ref;
}
wrapperInternalRef.current = ref;
}}
>
<div className="date-input__inner-wrapper">
<div>
<TextInput
// table date range filter date picker still goes to a calendar on down arrow press even if !hasCalendarPopup
aria-label={joinClassNames(ariaLabel, 'Press down arrow to open a calendar picker')}
className={joinClassNames(className, 'date-input')}
defaultValue={defaultValue}
errorMessage={errorMessage}
id={id}
innerRef={popupReferenceElementRef}
isClearable={isClearable}
isDisabled={isDisabled}
isRequired={isRequired}
label={label}
labelClassName={labelClassName}
name={name}
onChange={(e) => onChange?.(e.target.value)}
onClear={isClearable ? onClear : undefined}
onKeyUp={(e) => {
onDownArrowPress(e);
onKeyUp?.(e);
}}
placeholder={placeholder}
value={value ?? ''}
rightContent={(
hasCalendarPopup
? (
<IconButton
aria-hidden="true"
className="date-input__calendar-icon icon-button--borderless icon-button--small"
icon={<span className="utds-icon-before-calendar " aria-hidden="true" />}
isDisabled={isDisabled}
onClick={(e) => {
e.stopPropagation();
setIsCalendarPopupOpen((isOpen) => {
if (isOpen) {
const textInput = popupReferenceElementRef.current?.querySelector('input[type="text"]');
// @ts-expect-error
textInput?.focus();
}
return !isOpen;
});
}}
title="Open popup calendar"
// prevent closing and reopening the popup
// @ts-expect-error
onMouseDown={(e) => e.preventDefault()}
onFocus={() => setIsCalendarPopupOpen(false)}
/>
)
: (
<div
aria-hidden
className={joinClassNames('date-input__calendar-icon date-input__icon-static', isDisabled && 'date-input__calendar-icon--is-disabled')}
onMouseDown={(e) => {
// without the preventDefault, clicking the calendar was closing the popup instead of focusing in the text input
e.preventDefault();
popupReferenceElementRef.current?.querySelector('input')?.focus();
}}
>
<span className="utds-icon-before-calendar " aria-hidden="true" />
</div>
)
)}
// @ts-expect-error
onBlur={() => {
// give time for new item to become focused
setTimeout(
() => {
// if still active inside the wrapper, don't close the popup
if (!isActiveElementInsideCalendarInput(wrapperInternalRef.current)) {
setIsCalendarPopupOpen(false);
}
},
0
);
}}
onClick={() => setIsCalendarPopupOpen(true)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
</div>
{
hasCalendarPopup
? (
<div
className={joinClassNames('date-input__popup', isCalendarPopupOpen ? '' : 'visually-hidden')}
ref={calendarRef}
style={{
...floatingStyles,
minWidth: popupReferenceElementRef.current?.offsetWidth,
}}
onKeyUp={(e) => {
if (e.key === 'Escape') {
setIsCalendarPopupOpen(false);
}
}}
>
<CalendarInput
dateFormat={dateFormat}
label={label}
labelClassName="visually-hidden"
isDisabled={isDisabled}
isHidden={!isCalendarPopupOpen}
onChange={(newValue) => {
onChange?.(newValue);
setIsCalendarPopupOpen(false);
const textInput = popupReferenceElementRef.current?.querySelector('input[type="text"]');
// @ts-expect-error
textInput?.focus();
}}
id={`calendar-input__${id}`}
shouldSetFocusOnMount
showTodayButton={showCalendarTodayButton}
value={value}
/>
</div>
)
: null
}
</div>
</div>
);
}