box-ui-elements-mlh
Version:
503 lines (444 loc) • 17.6 kB
Flow
// @flow
import * as React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import type { InjectIntlProvidedProps } from 'react-intl';
import classNames from 'classnames';
import Pikaday from 'pikaday';
import range from 'lodash/range';
import uniqueId from 'lodash/uniqueId';
import { RESIN_TAG_TARGET } from '../../common/variables';
import IconAlert from '../../icons/general/IconAlert';
import IconCalendar from '../../icons/general/IconCalendar';
import IconClear from '../../icons/general/IconClear';
import Label from '../label';
import PlainButton from '../plain-button';
import Tooltip from '../tooltip';
import { convertDateToUnixMidnightTime } from '../../utils/datetime';
import './DatePicker.scss';
const messages = defineMessages({
previousMonth: {
defaultMessage: 'Previous Month',
description: 'Previous month button for a date picker calendar',
id: 'boxui.base.previousMonth',
},
nextMonth: {
defaultMessage: 'Next Month',
description: 'Next month button for a date picker calendar',
id: 'boxui.base.nextMonth',
},
iconAlertText: {
defaultMessage: 'Invalid Date',
description: 'Date entered is invalid',
id: 'boxui.datePicker.iconAlertText',
},
dateClearButton: {
defaultMessage: 'Clear Date',
description: 'Button for clearing date picker',
id: 'boxui.datePicker.dateClearButton',
},
chooseDate: {
defaultMessage: 'Choose Date',
description: 'Button for opening date picker',
id: 'boxui.datePicker.chooseDate',
},
});
const TOGGLE_DELAY_MS = 300;
const ISO_STRING_DATE_FORMAT = 'isoString';
const UTC_TIME_DATE_FORMAT = 'utcTime';
const UNIX_TIME_DATE_FORMAT = 'unixTime';
const UTC_ISO_STRING_DATE_FORMAT = 'utcISOString';
const ENTER_KEY = 'Enter';
const ESCAPE_KEY = 'Escape';
const TAB_KEY = 'Tab';
const stub = () => {};
/**
* Converts date from being relative to GMT, to being relative to browser
* timezone. E.g., Thu Jun 29 2017 00:00:00 GMT =>
* Thu Jun 29 2017 00:00:00 GMT-0700 (PDT)
* @param {Date} date UTC date
* @returns {Date} date Local date
*/
function convertUTCToLocal(date: Date) {
const dateString = date.toUTCString();
// Remove ` GMT` from the timestamp string
const dateStringWithoutTimeZone = dateString.slice(0, -4);
return new Date(dateStringWithoutTimeZone);
}
function getFormattedDate(date, format) {
if (!date) {
return '';
}
let utcDate;
switch (format) {
case ISO_STRING_DATE_FORMAT:
return date.toISOString();
case UTC_TIME_DATE_FORMAT:
return convertDateToUnixMidnightTime(date);
case UTC_ISO_STRING_DATE_FORMAT:
utcDate = new Date(convertDateToUnixMidnightTime(date));
return utcDate.toISOString();
default:
return date.getTime();
}
}
const localesWhereWeekStartsOnSunday = ['en-US', 'en-CA', 'jp-JP'];
type Props = {
/** Add a css class to the component */
className?: string,
/** The format of the date value for form submit */
dateFormat?: string,
/** Some optional description */
description?: React.Node,
/** The format of the date displayed in the input field */
displayFormat?: Object,
/** Error message */
error?: React.Node,
/** Position of error message tooltip */
errorTooltipPosition?:
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
| 'middle-left'
| 'middle-right'
| 'top-center'
| 'top-left'
| 'top-right',
/** Whether to show or hide the field's label */
hideLabel?: boolean,
/** Whether show or hide the 'Optional' label */
hideOptionalLabel?: boolean,
/** Props that will be applied on the input element */
inputProps?: Object,
/** Is input clearable */
isClearable?: boolean,
/** Is input disabled */
isDisabled?: boolean,
/** Is input required */
isRequired?: boolean,
/** Is user allowed to manually input a value (WARNING: this doesn't work with internationalization) */
isTextInputAllowed?: boolean,
/** Label displayed for the text input */
label: React.Node,
/** The maximum date allowed to be selected */
maxDate?: Date,
/** The minimum date allowed to be selected */
minDate?: Date,
/** Name of the text input */
name?: string,
/** Called when input lose focus */
onBlur?: Function,
/** Called when input is changed, passed the selected Date */
onChange?: Function,
/** Called when input receives focus */
onFocus?: Function,
/** Placeholder for the text input */
placeholder?: string,
/** Resin tag */
resinTarget?: string,
/** Date to set the input */
value?: Date,
yearRange?: number | Array<number>,
} & InjectIntlProvidedProps;
class DatePicker extends React.Component<Props> {
static defaultProps = {
className: '',
dateFormat: UNIX_TIME_DATE_FORMAT,
displayFormat: {},
error: '',
errorTooltipPosition: 'bottom-left',
inputProps: {},
isClearable: true,
isTextInputAllowed: false,
yearRange: 10,
};
errorMessageID = uniqueId('errorMessage');
descriptionID = uniqueId('description');
componentDidMount() {
const { dateFormat, intl, maxDate, minDate, value, yearRange, isTextInputAllowed } = this.props;
const { formatDate, formatMessage } = intl;
const { nextMonth, previousMonth } = messages;
let defaultValue = value;
// When date format is utcTime, initial date needs to be converted from being relative to GMT to being
// relative to browser timezone
if (dateFormat === UTC_TIME_DATE_FORMAT && typeof value !== 'undefined') {
defaultValue = convertUTCToLocal(value);
}
// Make sure the DST detection algorithm in browsers is up-to-date
const year = new Date().getFullYear();
const i18n = {
previousMonth: formatMessage(previousMonth),
nextMonth: formatMessage(nextMonth),
months: range(12).map(month => formatDate(new Date(year, month, 15), { month: 'long' })),
// weekdays must start with Sunday, so array of dates below is May 1st-8th, 2016
weekdays: range(1, 8).map(date => formatDate(new Date(2016, 4, date), { weekday: 'long' })),
weekdaysShort: range(1, 8).map(date => formatDate(new Date(2016, 4, date), { weekday: 'narrow' })),
};
this.datePicker = new Pikaday({
blurFieldOnSelect: false, // Available in pikaday > 1.5.1
setDefaultDate: true,
defaultDate: defaultValue,
field: this.dateInputEl,
firstDay: localesWhereWeekStartsOnSunday.includes(intl.locale) ? 0 : 1,
maxDate,
minDate,
position: 'bottom left',
i18n,
showDaysInNextAndPreviousMonths: true,
onSelect: this.onSelectHandler,
yearRange,
toString: this.formatDisplay,
});
if (isTextInputAllowed) {
this.updateDateInputValue(this.formatDisplay(defaultValue));
}
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
const { value: nextValue, minDate: nextMinDate, maxDate: nextMaxDate } = nextProps;
const { value, minDate, maxDate, isTextInputAllowed } = this.props;
// only set date when props change
if (
(nextValue && !value) ||
(!nextValue && value) ||
(nextValue && value && nextValue.getTime() !== value.getTime())
) {
this.datePicker.setDate(nextValue);
}
// If text input is allowed the dateInputEl will act as an uncontrolled input and
// we need to set formatted value manually.
if (isTextInputAllowed) {
this.updateDateInputValue(this.formatDisplay(nextValue));
}
if (
(nextMinDate && !minDate) ||
(!nextMinDate && minDate) ||
(nextMinDate && minDate && nextMinDate.getTime() !== minDate.getTime())
) {
this.datePicker.setMinDate(nextMinDate);
if (this.datePicker.getDate() < nextMinDate) {
this.datePicker.gotoDate(nextMinDate);
}
}
if (
(nextMaxDate && !maxDate) ||
(!nextMaxDate && maxDate) ||
(nextMaxDate && maxDate && nextMaxDate.getTime() !== maxDate.getTime())
) {
this.datePicker.setMaxDate(nextMaxDate);
if (this.datePicker.getDate() > nextMaxDate) {
this.datePicker.gotoDate(nextMaxDate);
}
}
}
componentWillUnmount() {
this.datePicker.destroy();
}
onSelectHandler = (date: ?Date) => {
const { onChange } = this.props;
if (onChange) {
const formattedDate = this.formatValue(date);
onChange(date, formattedDate);
}
};
updateDateInputValue(value: string) {
if (this.dateInputEl) {
this.dateInputEl.value = value;
}
}
dateInputEl: ?HTMLInputElement;
datePicker: Pikaday;
datePickerButtonEl: ?HTMLButtonElement;
// Used to prevent bad sequences of hide/show when toggling the datepicker button
shouldStayClosed = false;
focusDatePicker = () => {
// By default, this will open the datepicker too
if (this.dateInputEl) {
this.dateInputEl.focus();
}
};
handleInputKeyDown = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
const { isTextInputAllowed } = this.props;
if (this.datePicker.isVisible()) {
event.stopPropagation();
}
// Stops up/down arrow & spacebar from moving page scroll position since pikaday does not preventDefault correctly
if (!isTextInputAllowed && event.key !== TAB_KEY) {
event.preventDefault();
}
if (isTextInputAllowed && event.key === ENTER_KEY) {
event.preventDefault();
}
if (event.key === ENTER_KEY || event.key === ESCAPE_KEY || event.key === ' ') {
// Since pikaday auto-selects when you move the select box, enter/space don't do anything but close the date picker
if (this.datePicker.isVisible()) {
this.datePicker.hide();
}
}
};
handleInputBlur = (event: SyntheticFocusEvent<HTMLInputElement>) => {
const { onBlur, isTextInputAllowed } = this.props;
const nextActiveElement = event.relatedTarget || document.activeElement;
// This is mostly here to cancel out the pikaday hide() on blur
if (this.datePicker.isVisible() && nextActiveElement && nextActiveElement === this.datePickerButtonEl) {
this.shouldStayClosed = true;
setTimeout(() => {
this.shouldStayClosed = false;
}, TOGGLE_DELAY_MS);
}
if (onBlur) {
onBlur(event);
}
// Since we Fire parent onChange event if isTextInputAllowed
// fire it on blur if the user typed a correct date format
let inputDate: ?Date = null;
if (this.dateInputEl) {
inputDate = new Date(this.dateInputEl.value);
}
if (isTextInputAllowed && inputDate && inputDate.getDate()) {
this.onSelectHandler(inputDate);
}
};
handleButtonClick = (event: SyntheticEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!this.shouldStayClosed) {
this.focusDatePicker();
}
};
formatDisplay = (date?: Date): string => {
const { displayFormat, intl } = this.props;
return date ? intl.formatDate(date, displayFormat) : '';
};
formatValue = (date: ?Date) => {
const { dateFormat } = this.props;
return getFormattedDate(date, dateFormat);
};
clearDate = (event: SyntheticEvent<HTMLButtonElement>) => {
event.preventDefault(); // so datepicker doesn't open after clearing
this.datePicker.setDate(null);
this.onSelectHandler(null);
};
render() {
const {
className,
description,
error,
errorTooltipPosition,
hideLabel,
hideOptionalLabel,
inputProps,
intl,
isClearable,
isDisabled,
isRequired,
isTextInputAllowed,
label,
name,
onFocus,
placeholder,
resinTarget,
value,
} = this.props;
const { formatMessage } = intl;
const classes = classNames(className, 'date-picker-wrapper', {
'show-clear-btn': !!value,
'show-error': !!error,
});
const hasError = !!error;
const ariaAttrs = {
'aria-invalid': hasError,
'aria-required': isRequired,
'aria-errormessage': this.errorMessageID,
'aria-describedby': description ? this.descriptionID : undefined,
};
const resinTargetAttr = resinTarget ? { [RESIN_TAG_TARGET]: resinTarget } : {};
const valueAttr = isTextInputAllowed
? { defaultValue: this.formatDisplay(value) }
: { value: this.formatDisplay(value) };
const onChangeAttr = isTextInputAllowed
? {}
: {
onChange: stub,
}; /* fixes proptype error about readonly field (not adding readonly so constraint validation works) */
return (
<div className={classes}>
<span className="date-picker-icon-holder">
<Label hideLabel={hideLabel} showOptionalText={!hideOptionalLabel && !isRequired} text={label}>
{!!description && (
<div id={this.descriptionID} className="date-picker-description">
{description}
</div>
)}
<Tooltip
className="date-picker-error-tooltip"
isShown={!!error}
position={errorTooltipPosition}
text={error || ''}
theme="error"
>
<input
ref={ref => {
this.dateInputEl = ref;
}}
className="date-picker-input"
disabled={isDisabled}
onBlur={this.handleInputBlur}
placeholder={placeholder || formatMessage(messages.chooseDate)}
required={isRequired}
type="text"
{...onChangeAttr}
onFocus={onFocus}
onKeyDown={this.handleInputKeyDown}
{...resinTargetAttr}
{...ariaAttrs}
{...inputProps}
{...valueAttr}
/>
</Tooltip>
<span id={this.errorMessageID} className="accessibility-hidden" role="alert">
{error}
</span>
</Label>
{isClearable && !!value && !isDisabled ? (
<PlainButton
aria-label={formatMessage(messages.dateClearButton)}
className="date-picker-clear-btn"
onClick={this.clearDate}
type="button"
>
<IconClear height={12} width={12} />
</PlainButton>
) : null}
{error ? (
<IconAlert
className="date-picker-icon-alert"
height={13}
title={<FormattedMessage {...messages.iconAlertText} />}
width={13}
/>
) : null}
<PlainButton
aria-label={formatMessage(messages.chooseDate)}
className="date-picker-open-btn"
disabled={isDisabled}
getDOMRef={ref => {
this.datePickerButtonEl = ref;
}}
onClick={this.handleButtonClick}
type="button"
>
<IconCalendar height={17} width={16} />
</PlainButton>
<input
className="date-picker-unix-time-input"
name={name}
readOnly
type="hidden"
value={this.formatValue(value)}
/>
</span>
</div>
);
}
}
export { DatePicker as DatePickerBase };
export default injectIntl(DatePicker);