src/datepicker/datepicker.tsx (549 lines of code) (raw):

/* Copyright (c) Uber Technologies, Inc. This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. */ import * as React from 'react'; import { uid } from 'react-uid'; import { MaskedInput } from '../input'; import { Popover, PLACEMENT, ACCESSIBILITY_TYPE } from '../popover'; import Calendar from './calendar'; import { getOverrides } from '../helpers/overrides'; import getInterpolatedString from '../helpers/i18n-interpolation'; import { LocaleContext } from '../locale'; import { StyledInputWrapper, StyledInputLabel, StyledStartDate, StyledEndDate, } from './styled-components'; import type { DatepickerProps, InputRole } from './types'; import DateHelpers from './utils/date-helpers'; import dateFnsAdapter from './utils/date-fns-adapter'; import type { Locale } from '../locale'; import { INPUT_ROLE, RANGED_CALENDAR_BEHAVIOR } from './constants'; import type { ChangeEvent } from 'react'; export const DEFAULT_DATE_FORMAT = 'yyyy/MM/dd'; const INPUT_DELIMITER = '–'; // @ts-ignore const combineSeparatedInputs = (newInputValue, prevCombinedInputValue = '', inputRole) => { let inputValue = newInputValue; const [prevStartDate = '', prevEndDate = ''] = prevCombinedInputValue.split( ` ${INPUT_DELIMITER} ` ); if (inputRole === INPUT_ROLE.startDate && prevEndDate) { inputValue = `${inputValue} ${INPUT_DELIMITER} ${prevEndDate}`; } if (inputRole === INPUT_ROLE.endDate) { inputValue = `${prevStartDate} ${INPUT_DELIMITER} ${inputValue}`; } return inputValue; }; type DatepickerState = { calendarFocused: boolean; isOpen: boolean; selectedInput: InputRole | undefined | null; isPseudoFocused: boolean; lastActiveElm: HTMLElement | undefined | null; inputValue?: string; ariaDescribedby: string | null; }; export default class Datepicker<T = Date> extends React.Component< DatepickerProps<T>, DatepickerState > { static defaultProps = { 'aria-describedby': null, // @ts-ignore value: null, formatString: DEFAULT_DATE_FORMAT, adapter: dateFnsAdapter, }; calendar: HTMLElement | undefined | null; dateHelpers: DateHelpers<T>; constructor(props: DatepickerProps<T>) { super(props); // @ts-ignore this.dateHelpers = new DateHelpers(props.adapter); this.state = { calendarFocused: false, isOpen: false, selectedInput: null, isPseudoFocused: false, lastActiveElm: null, inputValue: this.formatDisplayValue(props.value) || '', ariaDescribedby: null, // we initialize this post-mount to prevent SSR hydration issue }; } componentDidMount() { this.setState({ ariaDescribedby: uid(this) }); } handleChange: (a: T | undefined | null | Array<T | undefined | null>) => void = (date) => { const onChange = this.props.onChange; const onRangeChange = this.props.onRangeChange; if (Array.isArray(date)) { if (onChange && date.every(Boolean)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange({ date: date as any as Array<T> }); } if (onRangeChange) { onRangeChange({ date: [...date] }); } } else { if (onChange) { onChange({ date }); } if (onRangeChange) { onRangeChange({ date }); } } }; onCalendarSelect: (a: { readonly date: T | undefined | null | Array<T | undefined | null>; }) => void = (data) => { let isOpen = false; let isPseudoFocused = false; let calendarFocused = false; let nextDate = data.date; if (Array.isArray(nextDate) && this.props.range) { if (!nextDate[0] || !nextDate[1]) { isOpen = true; isPseudoFocused = true; // @ts-ignore calendarFocused = null; } else if (nextDate[0] && nextDate[1]) { const [start, end] = nextDate; if (this.dateHelpers.isAfter(start, end)) { if (this.hasLockedBehavior()) { nextDate = this.props.value; isOpen = true; } else { nextDate = [start, start]; } } else if (this.dateHelpers.dateRangeIncludesDates(nextDate, this.props.excludeDates)) { nextDate = this.props.value; isOpen = true; } if (this.state.lastActiveElm) { this.state.lastActiveElm.focus(); } } } else if (this.state.lastActiveElm) { this.state.lastActiveElm.focus(); } // Time selectors previously caused the calendar popover to close. // The check below refrains from closing the popover if only times changed. const onlyTimeChanged = (prev?: T | null, next?: T | null) => { if (!prev || !next) return false; const p = this.dateHelpers.format(prev, 'keyboardDate'); const n = this.dateHelpers.format(next, 'keyboardDate'); if (p === n) { return ( this.dateHelpers.getHours(prev) !== this.dateHelpers.getHours(next) || this.dateHelpers.getMinutes(prev) !== this.dateHelpers.getMinutes(next) ); } return false; }; const prevValue = this.props.value; if (Array.isArray(nextDate) && Array.isArray(prevValue)) { if (nextDate.some((d, i) => onlyTimeChanged(prevValue[i], d))) { isOpen = true; } } else if (!Array.isArray(nextDate) && !Array.isArray(prevValue)) { if (onlyTimeChanged(prevValue, nextDate)) { isOpen = true; } } // If nextDate is an array but the datepicker is not ranged, we assign // nextDate directly to the Date value to avoid formatting issues if (Array.isArray(nextDate) && !this.props.range) { nextDate = nextDate[0]; } this.setState({ isOpen, isPseudoFocused, ...(calendarFocused === null ? {} : { calendarFocused }), inputValue: this.formatDisplayValue(nextDate), } as DatepickerState); this.handleChange(nextDate); }; getNullDatePlaceholder(formatString: string) { return (this.getMask() || formatString).split(INPUT_DELIMITER)[0].replace(/[0-9]|[a-z]/g, ' '); } formatDate(date: T | undefined | null | Array<T | undefined | null>, formatString: string) { const format = (date: T) => { if (formatString === DEFAULT_DATE_FORMAT) { return this.dateHelpers.format(date, 'slashDate', this.props.locale); } return this.dateHelpers.formatDate(date, formatString, this.props.locale); }; if (!date) { return ''; } else if (Array.isArray(date) && !date[0] && !date[1]) { return ''; } else if (Array.isArray(date) && !date[0] && date[1]) { const endDate = format(date[1]); const startDate = this.getNullDatePlaceholder(formatString); return [startDate, endDate].join(` ${INPUT_DELIMITER} `); } else if (Array.isArray(date)) { return date.map((day) => (day ? format(day) : '')).join(` ${INPUT_DELIMITER} `); } else { return format(date); } } formatDisplayValue: (a: T | undefined | null | Array<T | undefined | null>) => string = ( date ) => { const { displayValueAtRangeIndex, formatDisplayValue, range } = this.props; // @ts-ignore const formatString = this.normalizeDashes(this.props.formatString); if (typeof displayValueAtRangeIndex === 'number') { if (__DEV__) { if (!range) { console.error('displayValueAtRangeIndex only applies if range'); } if (range && displayValueAtRangeIndex > 1) { console.error('displayValueAtRangeIndex value must be 0 or 1'); } } if (date && Array.isArray(date)) { const value = date[displayValueAtRangeIndex]; if (formatDisplayValue) { return formatDisplayValue(value, formatString); } return this.formatDate(value, formatString); } } if (formatDisplayValue) { return formatDisplayValue(date, formatString); } return this.formatDate(date, formatString); }; open = (inputRole?: InputRole) => { this.setState( { isOpen: true, isPseudoFocused: true, calendarFocused: false, selectedInput: inputRole, }, this.props.onOpen ); }; close = () => { const isPseudoFocused = false; this.setState( { isOpen: false, selectedInput: null, isPseudoFocused, calendarFocused: false, }, this.props.onClose ); }; handleEsc = () => { if (this.state.lastActiveElm) { this.state.lastActiveElm.focus(); } this.close(); }; handleInputBlur = () => { if (!this.state.isPseudoFocused) { this.close(); } }; getMask = () => { const { formatString, mask, range, separateRangeInputs } = this.props; if (mask === null || (mask === undefined && formatString !== DEFAULT_DATE_FORMAT)) { return null; } if (mask) { return this.normalizeDashes(mask); } if (range && !separateRangeInputs) { return `9999/99/99 ${INPUT_DELIMITER} 9999/99/99`; } return '9999/99/99'; }; handleInputChange = (event: ChangeEvent<HTMLInputElement>, inputRole?: InputRole) => { const inputValue = this.props.range && this.props.separateRangeInputs ? combineSeparatedInputs(event.currentTarget.value, this.state.inputValue, inputRole) : event.currentTarget.value; const mask = this.getMask(); // @ts-ignore const formatString = this.normalizeDashes(this.props.formatString); if ( (typeof mask === 'string' && inputValue === mask.replace(/9/g, ' ')) || inputValue.length === 0 ) { if (this.props.range) { this.handleChange([]); } else { this.handleChange(null); } } this.setState({ inputValue }); // @ts-ignore const parseDateString = (dateString) => { if (formatString === DEFAULT_DATE_FORMAT) { return this.dateHelpers.parse(dateString, 'slashDate', this.props.locale); } return this.dateHelpers.parseString(dateString, formatString, this.props.locale); }; if (this.props.range && typeof this.props.displayValueAtRangeIndex !== 'number') { const [left, right] = this.normalizeDashes(inputValue).split(` ${INPUT_DELIMITER} `); let startDate = this.dateHelpers.date(left); let endDate = this.dateHelpers.date(right); if (formatString) { startDate = parseDateString(left); endDate = parseDateString(right); } const datesValid = this.dateHelpers.isValid(startDate) && this.dateHelpers.isValid(endDate); // added equal case so that times within the same day can be expressed const rangeValid = this.dateHelpers.isAfter(endDate, startDate) || this.dateHelpers.isEqual(startDate, endDate); if (datesValid && rangeValid) { this.handleChange([startDate, endDate]); } } else { const dateString = this.normalizeDashes(inputValue); let date = this.dateHelpers.date(dateString); const formatString = this.props.formatString; // Prevent early parsing of value. // Eg 25.12.2 will be transformed to 25.12.0002 formatted from date to string // @ts-ignore if (dateString.replace(/(\s)*/g, '').length < formatString.replace(/(\s)*/g, '').length) { // @ts-ignore date = null; } else { date = parseDateString(dateString); } const { displayValueAtRangeIndex, range, value } = this.props; if (date && this.dateHelpers.isValid(date)) { if (range && Array.isArray(value) && typeof displayValueAtRangeIndex === 'number') { let [left, right] = value; if (displayValueAtRangeIndex === 0) { left = date; if (!right) { this.handleChange([left]); } else { if (this.dateHelpers.isAfter(right, left) || this.dateHelpers.isEqual(left, right)) { this.handleChange([left, right]); } else { // Is resetting back to previous value appropriate? Invalid range is not // communicated to the user, but if it was not reset the text value would // show one value and date value another. This seems a bit better but clearly // has a downside. this.handleChange([...value]); } } } else if (displayValueAtRangeIndex === 1) { right = date; if (!left) { // If start value is not defined, set start/end to the same day. this.handleChange([right, right]); } else { if (this.dateHelpers.isAfter(right, left) || this.dateHelpers.isEqual(left, right)) { this.handleChange([left, right]); } else { // See comment above about resetting dates on invalid range this.handleChange([...value]); } } } } else { this.handleChange(date); } } } }; handleKeyDown = (event: KeyboardEvent) => { if (!this.state.isOpen && event.keyCode === 40) { this.open(); } else if (this.state.isOpen && (event.key === 'ArrowDown' || event.key === 'Enter')) { // next line prevents the page jump on the initial arrowDown event.preventDefault(); this.focusCalendar(); } else if (this.state.isOpen && event.keyCode === 9) { this.close(); } }; focusCalendar = () => { if (__BROWSER__) { const lastActiveElm = document.activeElement; this.setState({ calendarFocused: true, lastActiveElm, } as DatepickerState); } }; normalizeDashes = (inputValue: string) => { // replacing both hyphens and em-dashes with en-dashes return inputValue.replace(/-/g, INPUT_DELIMITER).replace(/—/g, INPUT_DELIMITER); }; generateAriaDescribedByIds = () => { let idList = this.state.ariaDescribedby; if (this.props['aria-describedby']) { idList = `${this.props['aria-describedby']} ${idList}`; } return idList; }; hasLockedBehavior = () => { return ( this.props.rangedCalendarBehavior === RANGED_CALENDAR_BEHAVIOR.locked && this.props.range && this.props.separateRangeInputs ); }; componentDidUpdate(prevProps: DatepickerProps<T>) { if (prevProps.value !== this.props.value) { this.setState({ inputValue: this.formatDisplayValue(this.props.value), }); } } getPlaceholder = () => this.props.placeholder || this.props.placeholder === '' ? this.props.placeholder : this.props.range && !this.props.separateRangeInputs ? `YYYY/MM/DD ${INPUT_DELIMITER} YYYY/MM/DD` : 'YYYY/MM/DD'; renderInputComponent(locale: Locale, inputRole?: InputRole) { const { overrides = {} } = this.props; const [InputComponent, inputProps] = getOverrides(overrides.Input, MaskedInput); const inputLabel = this.props['aria-label'] || `${this.props.range ? locale.datepicker.ariaLabelRange : locale.datepicker.ariaLabel}`; const [startDate = '', endDate = ''] = (this.state.inputValue || '').split( ` ${INPUT_DELIMITER} ` ); const value = inputRole === INPUT_ROLE.startDate ? startDate : inputRole === INPUT_ROLE.endDate ? endDate : this.state.inputValue; return ( <InputComponent aria-disabled={this.props.disabled} aria-label={inputLabel} error={this.props.error} positive={this.props.positive} aria-describedby={this.generateAriaDescribedByIds()} aria-labelledby={this.props['aria-labelledby']} aria-required={this.props.required || null} disabled={this.props.disabled} size={this.props.size} value={value} onFocus={() => this.open(inputRole)} onBlur={this.handleInputBlur} onKeyDown={this.handleKeyDown} // @ts-ignore onChange={(event) => this.handleInputChange(event, inputRole)} placeholder={this.getPlaceholder()} mask={this.getMask()} required={this.props.required} clearable={this.props.clearable} {...inputProps} /> ); } render() { const { overrides = {}, startDateLabel = 'Start Date', endDateLabel = 'End Date' } = this.props; const [PopoverComponent, popoverProps] = getOverrides(overrides.Popover, Popover); const [InputWrapper, inputWrapperProps] = getOverrides( overrides.InputWrapper, StyledInputWrapper ); const [StartDate, startDateProps] = getOverrides(overrides.StartDate, StyledStartDate); const [EndDate, endDateProps] = getOverrides(overrides.EndDate, StyledEndDate); const [InputLabel, inputLabelProps] = getOverrides(overrides.InputLabel, StyledInputLabel); const singleDateFormatString = this.props.formatString || DEFAULT_DATE_FORMAT; const formatString: string = this.props.range && !this.props.separateRangeInputs ? `${singleDateFormatString} ${INPUT_DELIMITER} ${singleDateFormatString}` : singleDateFormatString; return ( <LocaleContext.Consumer> {(locale) => ( <React.Fragment> <PopoverComponent accessibilityType={ACCESSIBILITY_TYPE.none} focusLock={false} autoFocus={false} mountNode={this.props.mountNode} placement={PLACEMENT.bottom} isOpen={this.state.isOpen} onClickOutside={this.close} onEsc={this.handleEsc} content={ <Calendar adapter={this.props.adapter} autoFocusCalendar={this.state.calendarFocused} trapTabbing={true} value={this.props.value} {...this.props} onChange={this.onCalendarSelect} selectedInput={this.state.selectedInput} hasLockedBehavior={this.hasLockedBehavior()} /> } {...popoverProps} > <InputWrapper {...inputWrapperProps} $separateRangeInputs={this.props.range && this.props.separateRangeInputs} > {this.props.range && this.props.separateRangeInputs ? ( <> <StartDate {...startDateProps}> <InputLabel {...inputLabelProps}>{startDateLabel}</InputLabel> {this.renderInputComponent(locale, INPUT_ROLE.startDate)} </StartDate> <EndDate {...endDateProps}> <InputLabel {...inputLabelProps}>{endDateLabel}</InputLabel> {this.renderInputComponent(locale, INPUT_ROLE.endDate)} </EndDate> </> ) : ( <>{this.renderInputComponent(locale)}</> )} </InputWrapper> </PopoverComponent> <p // @ts-ignore id={this.state.ariaDescribedby} style={{ position: 'fixed', width: '0px', height: '0px', borderLeftWidth: 0, borderRightWidth: 0, borderTopWidth: 0, borderBottomWidth: 0, padding: 0, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(100%)', }} > {getInterpolatedString(locale.datepicker.screenReaderMessageInput, { formatString: formatString, })} </p> <p aria-live="assertive" style={{ position: 'fixed', width: '0px', height: '0px', borderLeftWidth: 0, borderRightWidth: 0, borderTopWidth: 0, borderBottomWidth: 0, padding: 0, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(100%)', }} > { // No date selected !this.props.value || (Array.isArray(this.props.value) && !this.props.value[0] && !this.props.value[1]) ? '' : // Date selected in a non-range picker !Array.isArray(this.props.value) ? getInterpolatedString(locale.datepicker.selectedDate, { date: this.state.inputValue || '', }) : // Start and end dates are selected in a range picker this.props.value[0] && this.props.value[1] ? getInterpolatedString(locale.datepicker.selectedDateRange, { startDate: this.formatDisplayValue(this.props.value[0]), endDate: this.formatDisplayValue(this.props.value[1]), }) : // A single date selected in a range picker `${getInterpolatedString(locale.datepicker.selectedDate, { date: this.formatDisplayValue(this.props.value[0]), })} ${locale.datepicker.selectSecondDatePrompt}` } </p> </React.Fragment> )} </LocaleContext.Consumer> ); } }