diff --git a/hooks/useDynamicLocale/DynamicLocaleRenderer.js b/hooks/useDynamicLocale/DynamicLocaleRenderer.js new file mode 100644 index 000000000..7eb9d2d73 --- /dev/null +++ b/hooks/useDynamicLocale/DynamicLocaleRenderer.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import useDynamicLocale from './useDynamicLocale'; + +const DynamicLocaleRenderer = ({ children, onLoaded }) => { + const { localeLoaded, isEnglishLang } = useDynamicLocale(); + useEffect(() => { + if (localeLoaded) { + onLoaded({ isEnglishLang }); + } + }, [localeLoaded, onLoaded, isEnglishLang]); + return localeLoaded ? children : null; +}; + +DynamicLocaleRenderer.propTypes = { + children: PropTypes.node, + onLoaded: PropTypes.func, +}; + +export default DynamicLocaleRenderer; diff --git a/hooks/useDynamicLocale/index.js b/hooks/useDynamicLocale/index.js new file mode 100644 index 000000000..698dc5b4b --- /dev/null +++ b/hooks/useDynamicLocale/index.js @@ -0,0 +1,3 @@ +export { default as useDynamicLocale } from './useDynamicLocale'; +export { default as DynamicLocaleRenderer } from './DynamicLocaleRenderer'; + diff --git a/hooks/useDynamicLocale/useDynamicLocale.js b/hooks/useDynamicLocale/useDynamicLocale.js new file mode 100644 index 000000000..a7a868bbb --- /dev/null +++ b/hooks/useDynamicLocale/useDynamicLocale.js @@ -0,0 +1,44 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import availableLocales from 'dayjs/locale'; +import { IntlContext } from 'react-intl'; + +const isEnglishLang = (locale) => { + return /^en/.test(locale); +}; + +const useDynamicLocale = ({ locale : localeProp } = {}) => { + const { locale: localeContext } = React.useContext(IntlContext); + const [localeLoaded, setLocaleLoaded] = React.useState( + localeProp ? isEnglishLang(localeProp) : + isEnglishLang(localeContext) + ); + const locale = localeProp || localeContext; + + React.useEffect(() => { + // don't load a dynamic locale if the dayjs locale is already set to it, + // or if it's already been loaded according to state... + if (!localeLoaded && dayjs.locale() !== locale) { + // check if locale is available + const available = availableLocales.findIndex(l => l.key === locale); + if (available !== -1) { + import( + /* webpackChunkName: "dayjs-locale-[request]" */ + /* webpackExclude: /\.d\.ts$/ */ + `dayjs/locale/${locale}` + ).then(() => { + dayjs.locale(locale); + setLocaleLoaded(true); + }); + } + } else if (dayjs.locale() === locale) { + setLocaleLoaded(true); + } + }, [localeLoaded, locale]); + + return { localeLoaded, + isEnglish: localeProp ? localeProp === 'en' : + localeContext === 'en' }; +}; + +export default useDynamicLocale; diff --git a/lib/Datepicker/Calendar.js b/lib/Datepicker/Calendar.js index e013d392d..ccd41967b 100644 --- a/lib/Datepicker/Calendar.js +++ b/lib/Datepicker/Calendar.js @@ -2,48 +2,69 @@ * Display calendar UI for datepicker. * Sync the cursor to the selected date or default to today. * Month is rendered based on cursor date. -* Handles date math via moment. +* Handles date math via dayjs. */ import React from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import Moment from 'moment'; -import { extendMoment } from 'moment-range'; +import { isArray } from 'lodash'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; +import weekday from 'dayjs/plugin/weekday'; +import localeData from 'dayjs/plugin/localeData'; +import arraySupport from 'dayjs/plugin/arraySupport'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; import IconButton from '../IconButton'; import MonthSelect from './MonthSelect'; - +import DynamicLocaleRenderer from '../../hooks/useDynamicLocale/DynamicLocaleRenderer'; import css from './Calendar.css'; import staticFirstWeekday from './staticFirstWeekDay'; import staticRegions from './staticLangCountryCodes'; -const moment = extendMoment(Moment); + +dayjs.extend(isoWeek); +dayjs.extend(localeData); +dayjs.extend(weekOfYear); +dayjs.extend(weekday); +dayjs.extend(arraySupport); +dayjs.extend(customParseFormat); + +const getRange = (start, end) => { + const range = []; + let current = typeof start === 'string' ? dayjs(start) : start; + while (current.isBefore(end)) { + range.push(current.clone()); + current = current.add(1, 'day'); + } + return range; +}; function getCalendar(year, month, offset = 0) { - const startDate = moment([year, month]); - const firstDay = moment(startDate).startOf('month'); - const endDay = moment(startDate).endOf('month'); - const monthRange = moment.range(firstDay, endDay); + const startDate = dayjs([year, month]); + const firstDay = startDate.startOf('month'); + const endDay = startDate.endOf('month'); + const monthRange = getRange(firstDay, endDay); const weeks = []; const calendar = []; const rowStartArray = []; const rowEndArray = []; - const weekdays = Array.from(monthRange.by('days')); - weekdays.forEach((mo) => { + monthRange.forEach((mo) => { const ref = mo.week(); if (weeks.indexOf(ref) < 0) { weeks.push(mo.week()); - const endClone = moment(mo); + const endClone = dayjs(mo); rowStartArray.push(mo.weekday(offset + 0)); - rowEndArray.push(endClone.weekday(offset + 6)); + rowEndArray.push(endClone.weekday(offset + 6).add(1, 'day')); } }); for (let i = 0; i < weeks.length; i += 1) { - const weekRange = moment.range(rowStartArray[i], rowEndArray[i]); + const weekRange = getRange(rowStartArray[i], rowEndArray[i]); calendar.push(weekRange); } @@ -88,7 +109,7 @@ function getFirstWeekday(locale) { } const propTypes = { - dateFormat: PropTypes.string, + dateFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), exclude: PropTypes.func, fillParent: PropTypes.bool, firstFieldRef: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), @@ -115,7 +136,7 @@ const defaultProps = { locale: 'en', onSetDate: () => null, exclude: () => false, - onFocus: () => {}, + onFocus: () => { }, fillParent: false, trapFocus: true, }; @@ -124,19 +145,17 @@ class Calendar extends React.Component { constructor(props) { super(props); - moment.locale(this.props.intl.locale || this.props.locale); - const { selectedDate, dateFormat } = this.props; - this.selectedMoment = new moment(); // eslint-disable-line new-cap + this.selectedDate = dayjs(); let cursorDate; if (!selectedDate) { - cursorDate = new moment(); // eslint-disable-line new-cap - } else if (moment(selectedDate, dateFormat, true).isValid()) { - this.selectedMoment = new moment(selectedDate, dateFormat, true); // eslint-disable-line new-cap - cursorDate = this.selectedMoment; + cursorDate = dayjs(); + } else if (dayjs(selectedDate, dateFormat, true).isValid()) { + this.selectedDate = dayjs(selectedDate, dateFormat, true); + cursorDate = this.selectedDate; } else { // no pre-selected date, datestring invalid, init as 'today'. - cursorDate = new moment(); // eslint-disable-line new-cap + cursorDate = dayjs(); } // if the stripes locale has no region (only 2 letters), it needs to be normalized to a @@ -153,14 +172,9 @@ class Calendar extends React.Component { adjustedLocale = `${adjustedLocale}-${regionDefault}`; } - // if moment doesn't have the requested locale from above (intl/stripes), it falls back to 'en'. If this - // is the case, we need to set an offset value for correct calendar day rendering - - // otherwise, the calendar columns will be off, resulting misaligned weekdays/calendar days. - if (moment.locale() === 'en') { - dayOffset = getFirstWeekday(adjustedLocale); - } + dayOffset = adjustedLocale === 'en' ? 0 : getFirstWeekday(adjustedLocale); - const base = new moment(cursorDate); // eslint-disable-line new-cap + const base = dayjs(cursorDate); const month = base.month(); const year = base.year(); @@ -169,7 +183,7 @@ class Calendar extends React.Component { this.state = { cursorDate, - date: this.selectedMoment, + date: this.selectedDate, month, year, calendar: getCalendar(year, month), @@ -190,8 +204,9 @@ class Calendar extends React.Component { // When the selected date has changed, update the state with it let stateUpdate; - if (nextProps.selectedDate !== prevState.selectedDate) { - const moDate = new moment(nextProps.selectedDate, nextProps.dateFormat, true); // eslint-disable-line new-cap + if (nextProps.selectedDate && + nextProps.selectedDate !== prevState.selectedDate) { + const moDate = dayjs(nextProps.selectedDate, nextProps.dateFormat, true); if (moDate.isValid()) { if (moDate !== prevState.date) { // const moDate = moment(nextProps.selectedDate); @@ -208,7 +223,7 @@ class Calendar extends React.Component { }; } } else { // fix navigation issue on null or invalid date - const fallbackDate = new moment(); // eslint-disable-line new-cap + const fallbackDate = dayjs(); const month = fallbackDate.month(); const year = fallbackDate.year(); stateUpdate = { @@ -283,8 +298,8 @@ class Calendar extends React.Component { moveCursor = (op) => { const curDate = this.state.cursorDate; - op(curDate); // eslint-disable-line new-cap - this.updateCursorDate(curDate); + const newDate = op(curDate); + this.updateCursorDate(newDate); } focusTrap = { @@ -305,8 +320,12 @@ class Calendar extends React.Component { } return newState; }, () => { - const { id, dateFormat } = this.props; + const { id, dateFormat: dateFormatProp } = this.props; const { cursorDate } = this.state; + let dateFormat = dateFormatProp; + if (isArray(dateFormatProp)) { + dateFormat = dateFormatProp[0]; + } const cursorString = cursorDate.format(dateFormat); const nextButtonElem = document.getElementById(`datepicker-choose-date-button-${cursorString}-${id}`); nextButtonElem?.focus(); // eslint-disable-line no-unused-expressions @@ -328,15 +347,19 @@ class Calendar extends React.Component { } isDateSelected = (day) => { + const { + dateFormat + } = this.props; + const format = isArray(dateFormat) ? dateFormat[0] : dateFormat; if (this.props.selectedDate) { - return day.format(this.props.dateFormat) === - new moment(this.props.selectedDate, this.props.dateFormat, true) // eslint-disable-line new-cap - .format(this.props.dateFormat); + return day.format(format) === + new dayjs(this.props.selectedDate, dateFormat, true) // eslint-disable-line new-cap + .format(format); } if (this.state.date) { - return day.format(this.props.dateFormat) === - new moment(this.state.date, this.props.dateFormat, true) // eslint-disable-line new-cap - .format(this.props.dateFormat); + return day.format(format) === + new dayjs(this.state.date, dateFormat, true) // eslint-disable-line new-cap + .format(format); } return false; } @@ -357,10 +380,10 @@ class Calendar extends React.Component { this.setState(curState => { const { dayOffset } = curState; let cursorDate = ''; - if (month === this.selectedMoment?.month()) { - cursorDate = this.selectedMoment; + if (month === this.selectedDate?.month()) { + cursorDate = this.selectedDate; } else { - cursorDate = new moment().month(month).date(1).year(curState.year); // eslint-disable-line new-cap + cursorDate = new dayjs().month(month).date(1).year(curState.year); // eslint-disable-line new-cap } return { month, @@ -373,7 +396,7 @@ class Calendar extends React.Component { updateYear = (e) => { if (e.target.value) { const year = e.target.value; - if (new moment(year, 'YYYY', true).isValid()) { // eslint-disable-line new-cap + if (new dayjs(year, 'YYYY', true).isValid()) { // eslint-disable-line new-cap this.setState(curState => ({ year, calendar: getCalendar(year, curState.month, curState.dayOffset), @@ -403,6 +426,15 @@ class Calendar extends React.Component { }); } + handleLocaleLoaded = ({ isEnglish }) => { + if (!isEnglish) { + this.setState(cur => ({ + calendar: getCalendar(cur.year, cur.month, 0), + dayOffset: 0 + })); + } + } + render() { const { id, rootRef, fillParent, trapFocus } = this.props; @@ -412,16 +444,16 @@ class Calendar extends React.Component { (week) => { weekCount += 1; const dayList = []; - const weekDays = Array.from(week.by('days')); - weekDays.forEach((day) => { dayList.push(day); }); + week.forEach((day) => { dayList.push(day); }); const days = dayList.map( (day) => { const { month, cursorDate } = this.state; - const { intl, exclude, dateFormat } = this.props; - const dayMonth = day.month() + 1; - const isCurrentMonth = dayMonth === month + 1; - const isToday = day.isSame(moment(), 'day'); + const { intl, exclude, dateFormat: dateFormatProp } = this.props; + const dateFormat = isArray(dateFormatProp) ? dateFormatProp[0] : dateFormatProp; + const dayMonth = day.month(); + const isCurrentMonth = dayMonth === month; + const isToday = day.isSame(dayjs(), 'day'); const isSelected = this.isDateSelected(day); const isCursored = day.isSame(cursorDate, 'day'); @@ -448,7 +480,8 @@ class Calendar extends React.Component { numericDay = numericDay.replace('MM', new Intl.NumberFormat( intl.locale, { minimumIntegerDigits: 2 } - ).format(dayMonth)) + // JS months are a 0-based index, so for correctly ISO-parseable dates, 1 needs to be added. + ).format(dayMonth + 1)) .replace('YYYY', new Intl.NumberFormat(intl.locale, { style: 'decimal', useGrouping: false }) @@ -512,112 +545,114 @@ class Calendar extends React.Component { } return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} - { trapFocus &&
} -
-
- - { ([ariaLabel]) => ( - this.moveDate('subtract', 'year')} - data-test-calendar-previous-year - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - this.moveDate('subtract', 'month')} - data-test-calendar-previous-month - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - - )} - - - { ([ariaLabel]) => ( - - )} - - - { ([ariaLabel]) => ( - this.moveDate('add', 'month')} - data-test-calendar-next-month - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - this.moveDate('add', 'year')} - data-test-calendar-next-year - aria-label={ariaLabel} - /> - )} + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} + {trapFocus &&
} +
+
+ + {([ariaLabel]) => ( + this.moveDate('subtract', 'year')} + data-test-calendar-previous-year + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + this.moveDate('subtract', 'month')} + data-test-calendar-previous-month + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + + )} + + + {([ariaLabel]) => ( + + )} + + + {([ariaLabel]) => ( + this.moveDate('add', 'month')} + data-test-calendar-next-month + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + this.moveDate('add', 'year')} + data-test-calendar-next-year + aria-label={ariaLabel} + /> + )} + +
+
    + {daysOfWeek} +
+ + {([description]) =>
{description}
}
+ + + {weeks} + +
-
    - {daysOfWeek} -
- - { ([description]) =>
{description}
} -
- - - {weeks} - -
+ {trapFocus &&
} + {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
- {trapFocus &&
} - {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} -
+ ); } } diff --git a/lib/Datepicker/Datepicker.js b/lib/Datepicker/Datepicker.js index 54affd971..e9a6b826a 100644 --- a/lib/Datepicker/Datepicker.js +++ b/lib/Datepicker/Datepicker.js @@ -1,7 +1,10 @@ import React, { useState, useRef, useEffect } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; import contains from 'dom-helpers/query/contains'; import uniqueId from 'lodash/uniqueId'; import pick from 'lodash/pick'; @@ -14,24 +17,32 @@ import TextField from '../TextField'; import Calendar from './Calendar'; import css from './Calendar.css'; import { getLocaleDateFormat } from '../../util/dateTimeUtils'; +import { useDynamicLocale } from '../../hooks/useDynamicLocale'; -const pickDataProps = (props) => pick(props, (v, key) => key.indexOf('data-test') !== -1); +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); -// Controls the formatting from the value prop to what displays in the UI. -// need to judge the breakage factor in adopting a spread syntax for these parameters... -const defaultParser = (value, timeZone, uiFormat, outputFormats) => { - if (!value || value === '') { return value; } +const pickDataProps = (props) => pick(props, (v, key) => key.indexOf('data-test') !== -1); +const containsUTCOffset = (value) => { const offsetRegex = /T[\d.:]+[+-][\d]+$/; const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds - let inputMoment; - // if date string contains a utc offset, we can parse it as utc time and convert it to selected timezone. - if (offsetRegex.test(value) || offsetRE2.test(value)) { - inputMoment = moment.tz(value, timeZone); + return offsetRegex.test(value) || offsetRE2.test(value); +}; + +// Controls the formatting from the value prop to what displays in the text input. +// need to judge the breakage factor in adopting a spread syntax for these parameters... +const defaultParser = (value, timeZone, uiFormat, outputFormats) => { // eslint-disable-line + if (!value || value === '') { return value; } + let inputDate; + if (containsUTCOffset(value)) { + inputDate = dayjs.utc(value).tz(timeZone); } else { - inputMoment = moment.tz(value, [uiFormat, ...outputFormats], timeZone); + inputDate = dayjs(value, uiFormat); } - const inputValue = inputMoment.format(uiFormat); + + const inputValue = inputDate.format(uiFormat); return inputValue; }; @@ -56,10 +67,13 @@ const defaultParser = (value, timeZone, uiFormat, outputFormats) => { */ export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, outputFormats, timeZone }) => { if (!value || value === '') { return value; } - const parsed = new moment.tz(value, [uiFormat, ...outputFormats], timeZone); // eslint-disable-line + if (!value || value === '') { + return value; + } + const parsed = dayjs.utc(value); - if (/8601/.test(backendDateStandard)) { - return parsed.toISOString(); + if (parsed.isValid() && /8601/.test(backendDateStandard)) { + return parsed.locale('en').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); } // Use `.locale('en')` before `.format(...)` to get Arabic/"Latn" numerals. @@ -74,16 +88,16 @@ export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, o // https://www.rfc-editor.org/rfc/rfc5646.html // for support of the RFC2822 format (rare thus far and support may soon be deprecated.) - if (/2822/.test(backendDateStandard)) { + if (parsed.isValid() && /2822/.test(backendDateStandard)) { const DATE_RFC2822 = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; - return parsed.locale('en').format(DATE_RFC2822); + return dayjs.tz(value, timeZone).locale('en').format(DATE_RFC2822); } // if a localized string dateformat has been passed, normalize the date first... // otherwise, localized strings could be submitted to the backend. - const normalizedDate = moment.utc(value, [uiFormat, ...outputFormats]); + const normalizedDate = dayjs.utc(value, [uiFormat, ...outputFormats]); - return new moment(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line + return dayjs(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line }; const propTypes = { @@ -123,7 +137,7 @@ const propTypes = { const getBackendDateStandard = (standard, use) => { if (!use) return undefined; - if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.sssZ', 'YYYY-MM-DDTHH:mm:ssZ']; + if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.SSSZ', 'YYYY-MM-DDTHH:mm:ssZ']; if (standard === 'RFC2822') return ['ddd, DD MMM YYYY HH:mm:ss ZZ']; return [standard, 'YYYY-MM-DDTHH:mm:ss.sssZ', 'ddd, DD MMM YYYY HH:mm:ss ZZ']; }; @@ -187,6 +201,7 @@ const Datepicker = ( outputFormats: getBackendDateStandard(backendDateStandard, true) }) : null }); + const { localeLoaded } = useDynamicLocale({ locale }); // since updating the Datepair object isn't quite enough to prompt a re-render when its only partially // updated, need to maintain a 2nd field containing only the displayed value. // this resolves issue with the clearIcon not showing up. @@ -206,7 +221,10 @@ const Datepicker = ( // handle value changes that originate outside of the component. useEffect(() => { - if (typeof valueProp !== 'undefined' && valueProp !== datePair.dateString && valueProp !== datePair.formatted) { + if (input.current + && typeof valueProp !== 'undefined' + && valueProp !== datePair.dateString + && valueProp !== datePair.formatted) { payload.current = Object.assign(payload.current, maybeUpdateValue(valueProp)); nativeChangeField(input, false, payload.current.dateString); } @@ -223,13 +241,20 @@ const Datepicker = ( return blankDates; } - // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + let valueMoment; const backendStandard = getBackendDateStandard(backendDateStandard, outputBackendValue); - const valueMoment = new moment(// eslint-disable-line new-cap - value, - [format, ...backendStandard], // pass array of possible formats () - true - ); + + if (containsUTCOffset(value)) { + valueMoment = dayjs(value); + } else { + // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + valueMoment = dayjs( + value, + [format, ...backendStandard], // pass array of possible formats () + true + ); + } + const isValid = valueMoment.isValid(); let dates; @@ -326,8 +351,8 @@ const Datepicker = ( if (useInput) { onBlur({ target: outputBackendValue ? hiddenInput.current : input.current, - stopPropagation: () => {}, - preventDefault: () => {}, + stopPropagation: () => { }, + preventDefault: () => { }, defaultPrevented: true, }); } else { @@ -374,7 +399,7 @@ const Datepicker = ( - { displayedValue && ( + {displayedValue && ( {([ariaLabel]) => (
); }; diff --git a/lib/Timepicker/TimeDropdown.js b/lib/Timepicker/TimeDropdown.js index 6ad159034..ae624a66a 100644 --- a/lib/Timepicker/TimeDropdown.js +++ b/lib/Timepicker/TimeDropdown.js @@ -2,31 +2,101 @@ * Display picker UI for Timepicker. */ -import React from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; -import Moment from 'moment'; -import classNames from 'classnames'; -import { injectIntl, FormattedMessage } from 'react-intl'; -import { extendMoment } from 'moment-range'; +import { deprecated } from 'prop-types-extra'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import { FormattedMessage, useIntl } from 'react-intl'; import Button from '../Button'; import IconButton from '../IconButton'; import Layout from '../Layout'; import { Row, Col } from '../LayoutGrid'; import TextField from '../TextField'; import css from './TimeDropdown.css'; +import FocusLink from '../FocusLink'; -const moment = extendMoment(Moment); +dayjs.extend(customParseFormat); + +const keepInRange = (value, min, max, loop) => { + if (value > max) { + return loop ? min : max; + } else if (value < min) { + return loop ? max : min; + } + return value; +}; + +const rangedIncrement = (value, amount, min, max, add) => { + let local = value; + if (add) { + local += amount; + keepInRange(local, min, max); + } else { + local -= amount; + keepInRange(local, min, max); + } + return local; +}; + +const padZero = (value) => { + if (typeof value === 'undefined') return undefined; + return parseInt(value, 10) < 10 ? + `0${value}` : + value.toString(); +}; + +// examine the passed time format to determine 12 or 24 hour format. +function deriveHoursFormat(fmt) { + const ampmRE = new RegExp(/A/); + if (ampmRE.test(fmt)) { + return '12'; + } + return '24'; +} + +const getListofDayPeriods = (locale) => { + // Build array of time stamps for convenience. + const dateArray = []; + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDay(); + while (dateArray.length < 24) { + dateArray.push(new Date(year, month, day, dateArray.length)); + } + + const options = { hour: 'numeric' }; + const dpOptions = new Set(); + const df = new Intl.DateTimeFormat(locale, options); + + dateArray.forEach((d) => { + const dateFields = df.formatToParts(d); + dateFields.forEach((f) => { + if (f.type === 'dayPeriod') { + dpOptions.add(f.value); + } + }); + }); + return [...dpOptions] || null; +}; + +const deriveDefaultTimePeriod = (value, hoursFormat, timeFormat, dayPeriods) => { + if (hoursFormat === '24') return null; + if (value) return dayjs(value, timeFormat).format('A'); + return dayPeriods[0]; +}; const propTypes = { hoursFormat: PropTypes.oneOf(['12', '24']), id: PropTypes.string, intl: PropTypes.shape({ - formatMessage: PropTypes.func.isRequired - }).isRequired, - locale: PropTypes.string, - mainControl: PropTypes.object, + formatMessage: PropTypes.func + }), + locale: deprecated(PropTypes.string, 'not necessary/accessed via context'), // eslint-disable-line + mainControl: deprecated(PropTypes.object, 'no longer needed - handle focus management in onSetTime'), // eslint-disable-line minuteIncrement: PropTypes.number, - onBlur: PropTypes.func, + onBlur: deprecated(PropTypes.func, 'focus trapped automatically'), //eslint-disable-line onHide: PropTypes.func, onSetTime: PropTypes.func, rootRef: PropTypes.oneOfType([ @@ -39,308 +109,114 @@ const propTypes = { timeFormat: PropTypes.string.isRequired, }; -const defaultProps = { - hoursFormat: '24', - locale: 'en', - minuteIncrement: 1, - onSetTime: () => null, -}; - -class TimeDropdown extends React.Component { - constructor(props) { - super(props); - - moment.locale(this.props.locale); - - // handle existing value... - let initMoment; - if (typeof props.selectedTime === 'undefined' || props.selectedTime === '') { // handle blank or cleared time... - initMoment = moment(); - } else { - initMoment = moment(props.selectedTime, props.timeFormat, true); - } - - let initialPresentationFormat = 'hh'; - if (this.deriveHoursFormat(props.timeFormat) === '24') { - initialPresentationFormat = 'HH'; - } - - this.state = { - hoursFormat: this.deriveHoursFormat(), - mo: initMoment, - hour: initMoment.format(initialPresentationFormat), - minute: initMoment.format('mm'), - period: initMoment.format('A'), - }; - - this.enterTime = this.enterTime.bind(this); - this.getDOMContainer = this.getDOMContainer.bind(this); - this.incrementTime = this.incrementTime.bind(this); - this.handleChangeperiod = this.handleChangePeriod.bind(this); - this.deriveHoursFormat = this.deriveHoursFormat.bind(this); - this.handleBlur = this.handleBlur.bind(this); - this.getRootClass = this.getRootClass.bind(this); - this.confirmTime = this.confirmTime.bind(this); - this.setTimeHandleKeyDown = this.setTimeHandleKeyDown.bind(this); - this.hoursHandleKeyDown = this.hoursHandleKeyDown.bind(this); - this.buildState = this.buildState.bind(this); - } - - // eslint-disable-next-line camelcase, react/no-deprecated - UNSAFE_componentWillReceiveProps(nextProps) { - // if timeFormat updates, update the state's hours format (12/24hr time) - if (nextProps.timeFormat !== this.props.timeFormat) { - this.setState({ - hoursFormat: this.deriveHoursFormat(nextProps.timeFormat), - }); - this.buildState(nextProps); - } - - if (nextProps.selectedTime !== this.props.selectedTime) { - this.buildState(nextProps); - } - } - - componentWillUnmount() { - // Re-focus the timepicker input when the dropdown closes - this.props.mainControl.focus(); - if (this.blurTO) { - clearTimeout(this.blurTO); - this.blurTO = null; - } - - if (this.hoursBlurTO) { - clearTimeout(this.hoursBlurTO); - this.hoursBlurTO = null; - } - } - - buildState(props) { - let initMoment; - if (props.selectedTime === '') { - initMoment = moment(); - } else if (moment(props.selectedTime, props.timeFormat).isValid()) { - initMoment = moment(props.selectedTime, props.timeFormat, true); - } else if (moment(props.selectedTime, 'hh', true).isValid()) { - initMoment = moment(props.selectedTime, 'hh', true); - } else if (moment(props.selectedTime, 'h', true).isValid()) { - initMoment = moment(props.selectedTime, 'h', true); - } else { - initMoment = this.state.mo; - } - - let initialPresentationFormat = 'hh'; - if (this.deriveHoursFormat(props.timeFormat) === '24') { - initialPresentationFormat = 'HH'; - } - - this.setState({ - mo: initMoment, - hour: initMoment.format(initialPresentationFormat), - minute: initMoment.format('mm'), - period: initMoment.format('A'), - }); - } - - // examine the passed time format to determine 12 or 24 hour format. - deriveHoursFormat(fmt) { - let _fmt; - if (!fmt) { - _fmt = this.props.timeFormat; - } else { - _fmt = fmt; - } - const periodRE = new RegExp(/A/); - if (periodRE.test(_fmt)) { - return '12'; - } - return '24'; - } - - enterTime(e, unit) { +const TimeDropdown = ({ + hoursFormat: hoursFormatProp = 24, + id, + intl: intlProp, + minuteIncrement = 1, + onHide = ()=>{}, // eslint-disable-line + onSetTime, + rootRef, + selectedTime, + timeFormat, +}) => { + const intlContext = useIntl(); + const intl = intlProp || intlContext; + const hoursFormat = hoursFormatProp || deriveHoursFormat(timeFormat); + const hourMax = useRef(hoursFormat === '24' ? 23 : 12).current; + const hourMin = useRef(hoursFormat === '24' ? 0 : 1).current; + const dayPeriods = hoursFormat === '12' && getListofDayPeriods(intl.locale); + const [hour, setHour] = useState(selectedTime && dayjs(selectedTime, timeFormat) + .format(`${hoursFormat === '24' ? 'HH' : 'h'}`)); + const [minute, setMinute] = useState(selectedTime && dayjs(selectedTime, timeFormat).format('mm')); + const [period, setPeriod] = useState(deriveDefaultTimePeriod(selectedTime, hoursFormat, timeFormat, dayPeriods)); + const hoursInput = useRef(null); + const confirmButton = useRef(null); + + const modifyTime = useCallback((amount, unit, add) => { if (unit === 'hour') { - let parsedHours = parseInt(e.target.value, 10); - if (e.target.value.length > 1) { - const twelveHour = (this.state.hoursFormat === '12'); - if (twelveHour) { - if (parsedHours > 12) { - parsedHours = 12; - } - if (parsedHours < 1) { - parsedHours = 1; - } - } else { - if (parsedHours > 23) { - parsedHours = 23; - } - if (parsedHours < 0) { - parsedHours = 0; - } - } - } - this.setState({ - hour: parsedHours, + setHour((curHour) => { + let adjustedHour = curHour; + if (!curHour) adjustedHour = '12'; + let newHour = parseInt(adjustedHour, 10); + newHour = rangedIncrement(newHour, amount, hourMax, hourMin, add); + newHour = padZero(newHour); + return newHour; }); - } - if (unit === 'minute') { - let considered = e.target.value; - if (e.target.value.length > 2) { - /* only accept first 2 characters - * since maxlength doesn't work on number fields in chrome - */ - considered = e.target.value.substring(0, 2); - } - - let parsedMinutes = parseInt(considered, 10); - if (parsedMinutes > 59) { - parsedMinutes = 59; - } - if (parsedMinutes < 0) { - parsedMinutes = 0; - } - this.setState({ - minute: parsedMinutes, + } else if (unit === 'minute') { + setMinute((curMinute) => { + let adjustedMinute = curMinute; + if (!curMinute) adjustedMinute = '0'; + let newMinute = parseInt(adjustedMinute, 10); + newMinute = rangedIncrement(newMinute, amount, 0, 59, add); + newMinute = padZero(newMinute); + return newMinute; }); } - } + }, [hourMax, hourMin]); - // format number with leading 0 if its less than 10... - handleBlur(e, unit) { - let value; - const parsedValue = parseInt(e.target.value, 10); - if (parsedValue < 10) { - value = `0${parsedValue}`; - this.setState({ - [unit]: value, - }); - } - } - - setTimeHandleKeyDown(e) { - if (e.keyCode === 9 && !e.shiftKey) { // tab - // refocus the datepicker textfield if it's tabbed out... - this.props.onBlur(() => { - this.blurTO = setTimeout(() => { - this.props.mainControl.focus(); - }, 20); - }); - } - } + useEffect(() => () => onHide(), []); // eslint-disable-line - hoursHandleKeyDown(e) { - if (e.keyCode === 9 && e.shiftKey) { // tab - // refocus the datepicker textfield if the users shift-tabs out... - this.props.onBlur(() => { - this.hoursBlurTO = setTimeout(() => { - this.props.mainControl.focus(); - }, 20); - }); + const enterTime = (e, unit) => { + if (unit === 'hour') { + let parsedHour = parseInt(e.target.value, 10); + parsedHour = keepInRange(parsedHour, hourMin, hourMax); + setHour(parsedHour); + } else if (unit === 'minute') { + let parsedMinute = parseInt(e.target.value, 10); + parsedMinute = keepInRange(parsedMinute, 0, 59); + setMinute(parsedMinute); } - } + }; - getDOMContainer() { - return this.props.rootRef.current; - } - - incrementTime(increment, unit, add) { + const handleBlur = (e, unit) => { + let parsedValue = parseInt(e.target.value, 10); + parsedValue = padZero(parsedValue); if (unit === 'hour') { - let maxHours; - let minHours; - if (this.deriveHoursFormat() === '12') { - maxHours = 12; - minHours = 1; - } else { - maxHours = 23; - minHours = 0; - } - this.setState((curState) => { - const newState = Object.assign({}, curState); - let newHour = parseInt(newState.hour, 10); - if (add) { - newHour += increment; - if (newHour > maxHours) { - newHour = minHours; - } - } else { - newHour -= increment; - if (newHour < minHours) { - newHour = maxHours; - } - } - - // take care of the leading 0... - if (newHour < 10) { - newState.hour = `0${newHour}`; - } else { - newState.hour = newHour.toString(); - } - - return newState; - }); + setHour(parsedValue); + } else { + setMinute(parsedValue); } + }; - if (unit === 'minute') { - this.setState((curState) => { - const newState = Object.assign({}, curState); - let newMinute = parseInt(newState.minute, 10); - if (add) { - newMinute += increment; - if (newMinute > 59) { - newMinute = 0; - } - } else { - newMinute -= increment; - if (newMinute < 0) { - newMinute = 59; - } - } - - if (newMinute < 10) { - newState.minute = `0${newMinute}`; - } else { - newState.minute = newMinute.toString(); - } + const handleChangePeriod = () => { + setPeriod(prev => { return prev === 'AM' ? 'PM' : 'AM'; }); + }; - return newState; - }); + const confirmTime = (e) => { + e.preventDefault(); + if (!hour || !minute) { + onHide(); + return; } - } - - confirmTime() { - this.props.onSetTime(this.state); - } - - handleChangePeriod() { - this.setState(prevState => ({ period: prevState.period === 'AM' ? 'PM' : 'AM' })); - } - - getRootClass() { - return classNames( - css.timepickerContainer, - ); - } - - render() { - const hourMin = this.deriveHoursFormat() === '12' ? 1 : 0; - const hourMax = hourMin === 1 ? 12 : 23; - const { intl: { formatMessage } } = this.props; - return ( -
- + if (onSetTime) onSetTime({ hour, minute, period, dayPeriods }); + }; + + return ( +
+ <> + + + + + + { this.incrementTime(1, 'hour', true); }} + id={`clickable-timeDD-${id}-next-hour`} + onClick={() => { modifyTime(1, 'hour', true); }} />   @@ -349,72 +225,79 @@ class TimeDropdown extends React.Component { data-test-timepicker-dropdown-increment-hours-button tabIndex="-1" icon="arrow-up" - id={`clickable-timeDD-${this.props.id}-next-minute`} - onClick={() => { this.incrementTime(this.props.minuteIncrement, 'minute', true); }} + id={`clickable-timeDD-${id}-next-minute`} + onClick={() => { modifyTime(minuteIncrement, 'minute', true); }} /> - + - { this.hourField = h; }} - placeholder="HH" - onKeyDown={this.hoursHandleKeyDown} - min={hourMin} - max={hourMax} - type="number" - value={this.state.hour} - onChange={(e) => { this.enterTime(e, 'hour'); }} - onBlur={(e) => { this.handleBlur(e, 'hour'); }} - id={`timeDD-${this.props.id}-hour-input`} - marginBottom0 - /> + + {(ariaLabel) => ( + { enterTime(e, 'hour'); }} + onBlur={(e) => { handleBlur(e, 'hour'); }} + id={`timeDD-${id}-hour-input`} + marginBottom0 + hasClearIcon={false} + /> + )} + : - { this.minuteField = m; }} - placeholder="MM" - type="number" - min="0" - max="59" - value={this.state.minute} - onChange={(e) => { this.enterTime(e, 'minute'); }} - onBlur={(e) => { this.handleBlur(e, 'minute'); }} - id={`timeDD-${this.props.id}-minute-input`} - marginBottom0 - /> + + {(ariaLabel) => ( + { enterTime(e, 'minute'); }} + onBlur={(e) => { handleBlur(e, 'minute'); }} + id={`timeDD-${id}-minute-input`} + marginBottom0 + hasClearIcon={false} + /> + )} + - {this.state.hoursFormat === '12' && + {hoursFormat === '12' && } - + { this.incrementTime(1, 'hour', false); }} + onClick={() => { modifyTime(1, 'hour', false); }} />   @@ -422,39 +305,41 @@ class TimeDropdown extends React.Component { { this.incrementTime(this.props.minuteIncrement, 'minute', false); }} + onClick={() => { modifyTime(minuteIncrement, 'minute', false); }} /> - -
- ); - } -} + + + + + + + + ); +}; TimeDropdown.propTypes = propTypes; -TimeDropdown.defaultProps = defaultProps; -export default injectIntl(TimeDropdown); +export default TimeDropdown; diff --git a/lib/Timepicker/Timepicker.js b/lib/Timepicker/Timepicker.js index c5ef328de..f8cc15e3b 100644 --- a/lib/Timepicker/Timepicker.js +++ b/lib/Timepicker/Timepicker.js @@ -1,641 +1,368 @@ -import React from 'react'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import React, { useEffect, useRef, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import PropTypes from 'prop-types'; import { deprecated } from 'prop-types-extra'; -import moment from 'moment-timezone'; -import contains from 'dom-helpers/query/contains'; -import debounce from 'lodash/debounce'; -import uniqueId from 'lodash/uniqueId'; -import isEmpty from 'lodash/isEmpty'; - -import RootCloseWrapper from '../../util/RootCloseWrapper'; +import dayjs from 'dayjs'; +import localeData from 'dayjs/plugin/localeData'; +import utc from 'dayjs/plugin/utc'; +import timeZone from 'dayjs/plugin/timezone'; +import objectSupport from 'dayjs/plugin/objectSupport'; +import { uniqueId } from 'lodash'; +import { useDynamicLocale } from '../../hooks/useDynamicLocale'; +import FormField from '../FormField'; +import TextField from '../TextField'; +import SRStatus from '../SRStatus'; import Popper, { AVAILABLE_PLACEMENTS } from '../Popper'; +import RootCloseWrapper from '../../util/RootCloseWrapper'; import IconButton from '../IconButton'; -import TextField from '../TextField'; import TimeDropdown from './TimeDropdown'; -import { getLocalizedTimeFormatInfo } from '../../util/dateTimeUtils'; +import parseMeta from '../FormField/parseMeta'; +import nativeChangeFieldValue from '../../util/nativeChangeFieldValue'; +import { getLocaleDateFormat, removeDST } from '../../util/dateTimeUtils'; + +dayjs.extend(localeData); +dayjs.extend(utc); +dayjs.extend(timeZone); +dayjs.extend(objectSupport); + +// supplied a list of formats, dayjs will use the FIRST format in the list +// that can be successfully parsed. +const twelveHourFormats = ['h:mm A', 'h:mm ', 'h:mm', 'h']; +const twentyFourHourFormats = ['HH:mm', 'H:mm', 'H']; + +const containsUTCOffset = (value) => { + const offsetRegex = /T[\d.:]+[+-][\d]+$/; + const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds + const offsetZRE = /\.\d{3}Z$/i; + return offsetRegex.test(value) || offsetRE2.test(value) || offsetZRE.test(value); +}; + +const defaultParser = (value, timezone, timeFormat, passThroughValue, intl) => { + if (!value) return ''; + if (value === passThroughValue) return passThroughValue; + let time; + if (containsUTCOffset(value)) { + // if it has an offset, it probably came from the backend and + // can be expected to be utc (utc offset of 0) - + time = dayjs.utc(value, timeFormat).local(); + } else { + time = dayjs(value, timeFormat); + } + + // entered value that's fewer characters than the expected format will pass through.. + // e.g. '2' in an expected full string of '2:35 AM' + return time.isValid() ? removeDST(time.toISOString(), timeFormat) : value; +}; + +const defaultOutputFormatter = ({ value, passThroughValue, formats, timezone, intl }) => { + if (value === passThroughValue) return passThroughValue; + let isoTime = dayjs.utc(value, formats); + if (isoTime.isValid()) { + isoTime = isoTime.toISOString(); + const timeSplit = isoTime.split('T'); + return timeSplit[1]; + } + return ''; +}; + +const convertTo24hr = (hour, period, dayPeriods) => { + return period === dayPeriods[1] ? parseInt(hour, 10) + 12 : hour; +}; const propTypes = { autoFocus: PropTypes.bool, disabled: PropTypes.bool, id: PropTypes.string, - input: PropTypes.object, - inputRef: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + inputRef: PropTypes.oneOfType([PropTypes.shape({ current: PropTypes.element }), PropTypes.func]), intl: PropTypes.object, label: PropTypes.node, locale: PropTypes.string, marginBottom0: PropTypes.bool, - meta: PropTypes.object, modifiers: PropTypes.object, onChange: PropTypes.func, + outputFormatter: PropTypes.func, + parser: PropTypes.func, passThroughValue: deprecated(PropTypes.string, 'Use alternative design'), placement: PropTypes.oneOf(AVAILABLE_PLACEMENTS), readOnly: PropTypes.bool, required: PropTypes.bool, screenReaderMessage: PropTypes.string, showTimepicker: PropTypes.bool, + timeFormat: PropTypes.string, timeZone: PropTypes.string, + useInput: PropTypes.bool, usePortal: PropTypes.bool, value: PropTypes.string, }; -const defaultProps = { - autoFocus: false, - modifiers: {}, - placement: 'bottom', - screenReaderMessage: '', - marginBottom0: false, - usePortal: false, -}; - -class Timepicker extends React.Component { - constructor(props) { - super(props); - - if (typeof props.inputRef === 'function') { - this.textfieldRef = (ref) => { - props.inputRef(ref); - this.textfieldRef.current = ref; - }; - } else { - this.textfieldRef = props.inputRef || React.createRef(); - } - this.containerRef = React.createRef(); - this.dropdownRef = React.createRef(); - this.picker = null; - this.srSpace = null; - - this.handleSetTime = this.handleSetTime.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.clearTime = this.clearTime.bind(this); - this.showTimepicker = this.showTimepicker.bind(this); - this.handleFieldClick = this.handleFieldClick.bind(this); - this.hideTimepicker = this.hideTimepicker.bind(this); - this.dbhideTimepicker = debounce(this.hideTimepicker, 10); - this.toggleTimepicker = this.toggleTimepicker.bind(this); - this.handleRootClose = this.handleRootClose.bind(this); - this.hideOnBlur = this.hideOnBlur.bind(this); - this.standardizeTime = this.standardizeTime.bind(this); - this.handleFieldChange = this.handleFieldChange.bind(this); - this.pickerIsFocused = this.pickerIsFocused.bind(this); - this.handlePickTime = this.handlePickTime.bind(this); - this.deriveHoursFormat = this.deriveHoursFormat.bind(this); - this.getPresentationValue = this.getPresentationValue.bind(this); - this.getLocalTime = this.getLocalTime.bind(this); - - // Set time zone - this.timeZone = props.timeZone || props.intl.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone; - - // Set locale - this.locale = props.locale || props.intl.locale; - if (moment.locales().includes(this.locale)) { - moment.locale(this.locale); - this._timeFormat = moment.localeData()._longDateFormat.LT; - } else { - this._timeFormat = getLocalizedTimeFormatInfo(this.locale).timeFormat; +const Timepicker = ({ + inputRef, + intl: intlProp, + locale: localeProp, + modifiers, + onChange, + outputFormatter = defaultOutputFormatter, + passThroughValue, + parser = defaultParser, + placement, + screenReaderMessage, + showTimepicker, + timeFormat: timeFormatProp, + timeZone: timeZoneProp, + useInput, + usePortal, + value: valueProp, + ...inputProps +}) => { + const input = useRef(null); + const hiddenInput = useRef(null); + const srStatus = useRef(null); + const container = useRef(null); + const dropdownRef = useRef(null); + const testId = useRef(uniqueId('-timepicker')).current; + const { localeLoaded } = useDynamicLocale(); + const intlContext = useIntl(); + const intl = intlProp || intlContext; + const [showDropdown, setShowDropdown] = useState(showTimepicker || false); + const timeFormat = useRef( + timeFormatProp || + getLocaleDateFormat({ intl, config: { hour: 'numeric', minute: '2-digit' } }) + ).current; + const [timePair, updateTimePair] = useState({ + timeString: valueProp ? parser( + valueProp, // value + timeZoneProp || intl.timeZone, // timezone + timeFormat, // uiFormat + passThroughValue, + intl + ) : null, + formatted: valueProp ? outputFormatter({ + value: valueProp, + timeZone: timeZoneProp || intl.timeZone, + passThroughValue, + formats: timeFormat.includes('A') ? twelveHourFormats : twentyFourHourFormats, + }) : null + }); + const candidate = useRef(timePair); + + let maybeUpdateValue; + + // handle value changes form outside of the component via maybeUpdateValue... + useEffect(() => { + if (input.current + && typeof valueProp !== 'undefined' + && valueProp !== timePair.timeString + && valueProp !== timePair.formatted) { + candidate.current = Object.assign(candidate.current, maybeUpdateValue(valueProp)); + nativeChangeFieldValue(input, false, candidate.dateString); } - - this.formats = [moment.ISO_8601, this._timeFormat, 'YYYY-MM-DD LT', 'YYYY-MM-DD h:mm:ss A', - 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm', 'HH:mm:ssZ']; - // inputValue will eventually be rendered as the value of the TextField. - let inputValue = ''; - let presentedValue = null; - // if we aren't using redux form... - if (typeof this.props.input !== 'undefined') { - if (this.props.input.value !== '') { - if (this.props.input.value === this.props.passThroughValue) { - presentedValue = this.props.passThroughValue; - inputValue = moment.tz(this.timeZone).format(this._timeFormat); - } else { - const dateTime = `${moment().format('YYYY-MM-DD')}T${this.props.input.value}`; - // handle case where input value could be utc time or ISO formatted datetime - inputValue = moment(this.props.input.value, this.formats, true).isValid() ? - moment.tz(this.props.input.value, this._timeFormat, this.timeZone).format(this._timeFormat) : - moment.tz(dateTime, this.timeZone).format(this._timeFormat); - } - } - } else if (this.props.value === this.props.passThroughValue) { - presentedValue = this.props.passThroughValue; - inputValue = moment.tz(this.timeZone).format(this._timeFormat); - } else { - inputValue = moment(this.props.value, this._timeFormat, this.timeZone).format(this._timeFormat); + }, [valueProp, maybeUpdateValue, timePair.timeString, timePair.formatted]); + + /* maybeUpdateValue + * runs on all changes to the main input and value props to parse + * the supplied value and, if it's a valid time, call the onChange handler. + */ + maybeUpdateValue = (value) => { + // handle blank values... + if (value === '') { + const blankTime = { + timeString: '', + formatted: '' + }; + updateTimePair(blankTime); + return blankTime; } - this.state = { - presentedValue, - timeString: inputValue, - showTimepicker: this.props.showTimepicker || false, - srMessage: '', + let valueTimeObject; - timeFormat: this._timeFormat, - timeZone: this.timeZone, - - prevLocale: this.locale, - prevValue: undefined, - prevInputValue: undefined, - }; - - if (props.id) { - this.testId = props.id; + /* values from the backend or values circulating from state will have an offset appended. + * date-time libraries should parse it without requiring any format hints. + */ + if (containsUTCOffset(value)) { + valueTimeObject = dayjs(value); } else { - this.testId = uniqueId('timepick-'); - } - } - - static getDerivedStateFromProps(props, state) { - const newState = {}; - - if (props.value !== state.prevValue) { - newState.prevValue = props.value; - } - - if (props.input) { // if we're using redux-form.... - // if value has changed.... - if (props.input.value !== '' && props.input.value !== state.prevInputValue) { - if (props.input.value === props.passThroughValue) { - newState.timeString = moment.tz(state.timeZone).format(state.timeFormat); - newState.presentedValue = props.passThroughValue; - } - // if value is blank.... - } else if (props.input.value === '' && props.input.value !== state.prevInputValue) { - newState.presentedValue = null; - newState.timeString = ''; - } - - newState.prevInputValue = props.input.value; - } else if (props.value !== '' && props.value !== state.prevValue) { - if (props.value === props.passThroughValue) { - newState.timeString = moment.tz(state.timeZone).format(state.timeFormat); - newState.presentedValue = props.passThroughValue; - } else { - newState.timeString = moment.tz(state.timeZone).format(state.timeFormat); - } + // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + valueTimeObject = dayjs( + value, + [timeFormat, timeFormat.includes('A') ? twelveHourFormats : twentyFourHourFormats], + true + ); } - // adjust displayed time format for a change in locale - if (props.intl.locale !== state.prevLocale) { - moment.locale(props.intl.locale); + let timeValues; - newState.prevLocale = props.intl.locale; - newState.presentedValue = null; - - if (state.timeString !== '') { - newState.timeString = moment.tz( - state.timeString, state.timeFormat, state.timeZone - ).format(state.timeFormat); + // otherwise parse the value and update the timestring and the formatted time... + if (valueTimeObject.isValid()) { + const parsed = parser( + value, // value + timeZoneProp || intl.timeZone, // timezone + timeFormat, // uiFormat + passThroughValue, + intl, + ); + if (parsed !== timePair.timeString) { + const hiddenValue = outputFormatter({ + value, + timeZone: timeZoneProp || intl.timeZone, + passThroughValue, + formats: timeFormat.includes('A') ? twelveHourFormats : twentyFourHourFormats + }); + timeValues = { timeString: parsed, formatted: hiddenValue }; + updateTimePair(current => { + const newTimePair = Object.assign(current, timeValues); + return newTimePair; + }); + return timeValues; } - } - - return isEmpty(newState) ? null : newState; - } + return {}; - // examine the passed time format to determine 12 or 24 hour format. - deriveHoursFormat(fmt) { - let _fmt; - if (!fmt) { - _fmt = this.state.timeFormat; - } else { - _fmt = fmt; - } - const ampmRE = new RegExp(/A/); - if (ampmRE.test(_fmt)) { - return '12'; + // if the date's not valid, we just update the datestring to reflect user input... + } else if (value !== timePair.timeString) { + timeValues = { + timeString: value, + }; + updateTimePair(current => { + const newTimePair = Object.assign(current, timeValues); + return newTimePair; + }); + return timeValues; } - return '24'; - } - - getLocalTime(t, f) { - if (t) { - return moment(t).format(f); + return {}; + }; + + const handleInputRef = (ref) => { + if (typeof inputRef === 'function') { + inputRef(ref); + } else if (inputRef) { + inputRef.current = ref; } - return moment().format(this._timeFormat); - } - - handleFieldClick() { - this.toggleTimepicker(); - } - - handleRootClose() { - this.hideTimepicker(); - } - - showTimepicker() { - this.setState({ - showTimepicker: true, - }); - } - - hideTimepicker(cb) { - this.setState({ - showTimepicker: false, - }, cb); - } - - toggleTimepicker() { - const current = this.state.showTimepicker; - this.setState({ - showTimepicker: !current, - }); - } - - handleKeyDown(e) { - if (this.picker) { - // const formattedDate = curDate.format(this._timeFormat); - // let timeString; - switch (e.keyCode) { - case 40: // down - e.preventDefault(); - break; - case 38: // up + input.current = ref; + }; + + const toggleTimepicker = () => { + setShowDropdown(cur => !cur); + }; + + const hideTimepicker = () => { + setShowDropdown(false); + }; + + const handleKeyDown = (e) => { + if (!dropdownRef.current) { + switch (e.key) { + case 'Enter': + case 'ArrowDown': e.preventDefault(); - break; - case 37: // left - break; - case 39: // right - break; - case 27: // escape - e.preventDefault(); - this.hideTimepicker(); - break; - case 34: // pgDn - e.preventDefault(); - break; - case 33: // pgUp - e.preventDefault(); - break; - case 13: // enter - e.preventDefault(); - if (this.state.showTimepicker) { - // this.handleSetTime(e, curDate, formattedDate); - } else { - this.showTimepicker(); - } - // timeString = `${formattedDate} selected`; - break; - case 9: // tab - this.hideTimepicker(); - break; - default: - } - // this.informScreenReader(`${timeString}`); - } else { - switch (e.keyCode) { - case 13: // enter - case 40: // down - e.preventDefault(); - this.showTimepicker(); + setShowDropdown(true); break; default: } } - } - - handleSetTime(e, hours, minutes, ampm) { - let standardizedTime; - if (e === null || e.type === 'click' || e.type === 'keydown') { - if (e) { e.preventDefault(); } - - if (this.props.onChange) { this.props.onChange(e); } - this.textfieldRef.current.focus(); - - let tString; - if (this.deriveHoursFormat() === '12') { - tString = `${hours}:${minutes} ${ampm}`; - } else { - tString = `${hours}:${minutes}`; - } - - this.setState({ - presentedValue: null, - timeString: tString, - }); - - standardizedTime = this.standardizeTime(`${hours}:${minutes} ${ampm}`); - if (this.props.input) { - this.props.input.onChange(standardizedTime); - if (this.props.onChange) { - this.props.onChange(e, standardizedTime); - } - } else if (this.props.onChange) { - this.props.onChange(e, standardizedTime); - } - } else if (e.type === 'change') { - // if e.target.value is even a substring of props.passThroughValue, pass it through... - - - // be sure that value is parseable as a date in the required format... - // the boolean parameter suppresses moment's deprecation warning, - // preventing it from attempting a parse using independable js Date object. - this.setState({ - timeString: e.target.value, - }); - - if (this.props.onChange) { this.props.onChange(e); } - if (this.props.input) { - if (moment(e.target.value, this._timeFormat, true).isValid()) { - this.setState({ - timeString: e.target.value, - }); - // convert date to ISO 8601-ish format for backend (everything after the 'T') - if (!ampm) { - standardizedTime = this.standardizeTime(`${hours}:${minutes}`); - } else { - standardizedTime = this.standardizeTime(`${hours}:${minutes} ${ampm}`); - } - // redux-form handlers take the value rather than the event... - this.props.input.onChange(standardizedTime); - if (this.props.onChange) { - this.props.onChange(e, standardizedTime); - } - } - } - } - } - - handleFieldChange(e) { - if (e.target.value === '') { - this.clearTime(); - return; - } - const ptREx = new RegExp(`^${e.target.value}`, 'i'); - if (ptREx.test(this.props.passThroughValue)) { - if (this.props.onChange) { this.props.onChange(e); } - if (this.props.input) { - this.props.input.onChange(e.target.value); - } - this.setState({ - presentedValue: e.target.value, - }); - } else { - let hoursMins = e.target.value.split(/[:\s]/g); - hoursMins = hoursMins.filter(val => val); // remove any blank items... - if (hoursMins.length > 0) { - let hours = hoursMins[0]; - const intHours = parseInt(hours, 10); - if (intHours < 10) { - hours = `0${intHours}`; - } - - if (hoursMins.length < 2) { - this.handleSetTime(e, hours, '00'); - return; - } - let mins = hoursMins[1]; - let ampm; - if (hoursMins.length < 3) { - if (mins.length > 2) { // handle 3:00PM (no space between minutes and meridiem) - if (this.deriveHoursFormat() === '12') { - ampm = 'PM'; - if (/[aA]/.test(mins)) { - ampm = 'AM'; - } - const intMins = parseInt(mins, 10); - if (intMins < 10) { - mins = `0${intMins}`; - } else { - mins = intMins.toString(); - } - this.handleSetTime(e, hours, mins, ampm); - } - } - this.handleSetTime(e, hours, mins); - return; - } - ampm = hoursMins[2]; - this.handleSetTime(e, hours, mins, ampm); - } + }; + + const timeDropdownHandleKeyDown = (e) => { + switch (e.key) { + case 'Escape': + input.current?.focus(); + setShowDropdown(false); + break; + default: } - } - - clearTime(e) { - this.setState({ - presentedValue: null, - timeString: '', - }); - if (this.props.onChange) { this.props.onChange(e); } - if (this.props.input) { - this.props.input.onChange(''); + }; + + /* for vanilla react/non-final-form implementations that just get the input value. + * Handles user input from the text input. + */ + const handleChange = (e) => { + candidate.current = Object.assign(candidate.current, maybeUpdateValue(e.target.value)); + if (useInput && onChange) { + onChange(e, e.target.value, candidate.current.timeString); + } else if (typeof candidate.current.formatted === 'string' && + candidate.current.formatted !== hiddenInput.current.value) { + nativeChangeFieldValue(hiddenInput, false, candidate.current.formatted); } - this.textfieldRef.current.focus(); - } + }; - cleanForScreenReader(str) { - const newString = str.replace(/H/g, 'H ').replace(/m/g, 'm ').replace(/s/g, 's '); - return newString; - } - - informScreenReader(str) { - this.setState({ srMessage: str }); - } - - pickerIsFocused() { - if (this.picker && contains(this.containerRef.current, document.activeElement) && - document.activeElement !== document.body) { - return contains(this.picker.getDOMContainer(), document.activeElement); + // for final-form so it can have a native change event rather than a fabricated thing... + const onChangeFormatted = (e) => { + if (!useInput && onChange) { + const { timeString, formatted } = candidate.current; + onChange(e, formatted, timeString); } - return false; + }; + + // setting the time in the input from the values in the time dropdown + const handlePickTime = ({ hour, minute, period, dayPeriods }) => { + const adjustedHour = dayPeriods ? convertTo24hr(hour, period, dayPeriods) : hour; + const displayTime = dayjs({ hour: adjustedHour, minute }).format(timeFormat); + nativeChangeFieldValue(input, true, displayTime); + setShowDropdown(false); + }; + + // the clear button on the text input... + const handleClearTime = () => { + updateTimePair({ timeString: '', formatted: '' }); + nativeChangeFieldValue(input, true, ''); + }; + + const portalElem = usePortal + ? document.getElementById('OverlayContainer') + : null; + + const { readOnly, disabled, label } = inputProps; + const screenReaderFormat = timeFormat.split('').join(' '); + + let ariaLabel; + if (readOnly || disabled) { + ariaLabel = `${label}`; + } else { + // eslint-disable-next-line + ariaLabel = intl.formatMessage({ id: 'stripes-components.Timepicker.ScreenReaderLabel' }, { label, screenReaderFormat, screenReaderMessage }); } - hideOnBlur(e) { - if (this.pickerIsFocused()) { - if (this.props.input) { - this.props.input.onBlur(e); - } - this.hideTimepicker(); - this.setState({ srMessage: '' }); - } - } - - standardizeTime(time) { - const isoTime = moment.tz(time, 'HH:mm A', this.timeZone).toISOString(); - const timeSplit = isoTime.split('T'); - return timeSplit[1]; - } - - handlePickTime(timeObject) { - this.handleSetTime(null, timeObject.hour, timeObject.minute, timeObject.period); - this.hideTimepicker(); - } - - getPresentationValue() { - if (this.state.presentedValue !== null) { - return this.state.presentedValue; - } - return this.state.timeString; - } - - render() { - const { - disabled, - input, - intl, - label, - marginBottom0, - modifiers, - placement, - readOnly, - required, - screenReaderMessage, - usePortal, - ...rest - } = this.props; - - const screenReaderFormat = this.cleanForScreenReader(this._timeFormat); - - let ariaLabel; - if (readOnly || disabled) { - ariaLabel = `${label}`; - } else { - // eslint-disable-next-line - ariaLabel = intl.formatMessage({ id:'stripes-components.Timepicker.ScreenReaderLabel'} ,{ label, screenReaderFormat, screenReaderMessage }); - } - - let endElement; - if (this.state.timeString !== '' || this.state.presentedValue !== null) { - endElement = ( - <> - - {([clearFieldValueAriaLabel]) => ( - - )} - - - {([showOrHideTimepickerAriaLabel]) => ( - - )} - - - ); - } else { - endElement = ( - - {([showOrHideTimepickerAriaLabel]) => ( - - )} - - ); - } - - let textfield; - if (this.props.input && this.props.meta) { - textfield = ( - + {([showOrHideTimepickerAriaLabel]) => ( + - ); - } else { - textfield = ( + )} + + ]; + + return ( + <> +
+ {endElements})} + placeholder={timeFormat.toUpperCase()} + onChange={handleChange} + onClearField={handleClearTime} + ariaLabel={ariaLabel} /> - ); - } - - const renderTextField = ( -
-
{ this.srSpace = ref; }} - > -
{this.state.srMessage}
-
- {textfield} -
- ); - - const renderDropdown = ( - - { this.picker = ref; }} - rootRef={this.dropdownRef} - mainControl={this.textfieldRef.current} - onKeyDown={this.handleKeyDown} - onBlur={this.hideTimepicker} - locale={this.locale} - onNavigation={this.handleDateNavigation} - id={this.testId} - onClose={this.hideTimepicker} + - - ); - - const portalElem = usePortal - ? document.getElementById('OverlayContainer') - : null; - - return ( - <> - {renderTextField} +
+ {localeLoaded && ( - {renderDropdown} + + setShowDropdown(false)} + id={testId} + onClose={() => setShowDropdown(false)} + /> + - - ); - } -} + )} + + ); +}; Timepicker.propTypes = propTypes; -Timepicker.defaultProps = defaultProps; -export default injectIntl(Timepicker); +export default FormField( + Timepicker, + ({ meta }) => ({ + dirty: meta?.dirty, + error: (meta?.touched && meta?.error ? meta.error : ''), + valid: meta?.valid, + warning: (meta?.touched ? parseMeta(meta, 'warning') : ''), + }) +); diff --git a/lib/Timepicker/stories/BasicUsage.js b/lib/Timepicker/stories/BasicUsage.js index 871bc09ba..4ebe50608 100644 --- a/lib/Timepicker/stories/BasicUsage.js +++ b/lib/Timepicker/stories/BasicUsage.js @@ -2,30 +2,45 @@ * Timepicker: Basic Usage */ -import React from 'react'; +import React, { useState } from 'react'; import Timepicker from '../Timepicker'; -const BasicUsage = () => ( -
- -
-
Using aria-label:
- -
- -
- -
-); +const BasicUsage = () => { + const [time, updateTime] = useState(undefined); + const [inputTime, updateInputTime] = useState(undefined); + return ( +
+ state time: {time} + { updateTime(e.target.value); }} + /> +
+ state time: {inputTime} + { updateInputTime(e.target.value); }} + /> + {/*
+
Using aria-label:
+ +
+ +
+ */} +
+ ); +}; export default BasicUsage; diff --git a/lib/Timepicker/tests/Timepicker-test.js b/lib/Timepicker/tests/Timepicker-test.js index ef03ea5be..fc07319be 100644 --- a/lib/Timepicker/tests/Timepicker-test.js +++ b/lib/Timepicker/tests/Timepicker-test.js @@ -170,7 +170,7 @@ describe('Timepicker', () => { }); }); - describe('when clicking the timepicker input', () => { + describe('when clicking the timeDropdown button', () => { const timeDropdown = TimepickerDropdownInteractor(); beforeEach(async () => { await mountWithContext( @@ -182,7 +182,7 @@ describe('Timepicker', () => {
); - await timepicker.clickInput(); + await timepicker.clickDropdownToggle(); }); it('contains no axe errors - Timepicker: open menu', runAxeTest); diff --git a/package.json b/package.json index 46159de35..35b5c0c93 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "jest-mock": "23.2.0", "karma-viewport": "^1.0.4", "mocha": "^9.0.0", - "moment": "^2.29.0", "postcss": "^8.3.9", "postcss-calc": "^8.0.0", "postcss-color-function": "folio-org/postcss-color-function", @@ -105,9 +104,11 @@ "@folio/stripes-react-hotkeys": "^3.0.5", "classnames": "^2.2.5", "currency-codes": "^1.5.0", + "dayjs": "^1.11.6", "dom-helpers": "^3.2.1", "downshift": "^2.0.16", "hoist-non-react-statics": "^3.1.0", + "ignore-loader": "^0.1.2", "json2csv": "^4.2.1", "lodash": "^4.17.4", "memoize-one": "^5.0.0", diff --git a/translations/stripes-components/en.json b/translations/stripes-components/en.json index 2abd47358..b14782f32 100644 --- a/translations/stripes-components/en.json +++ b/translations/stripes-components/en.json @@ -39,7 +39,8 @@ "Timepicker.PeriodLabel": "AM or PM", "Timepicker.SetTimeLabel": "Set time", "Timepicker.ScreenReaderLabel": "{label} format is {screenReaderFormat} use down arrow to enter timepicker dialog. {screenReaderMessage}", - "Timepicker.LeaveDialog": "leave dialog", + "Timepicker.formEnd": "End of form", + "Timepicker.formStart": "Beginning of form", "clearThisField": "Clear this field", "validatingInProcess": "Validating...", "fieldIsDisabled": "Field is disabled", diff --git a/util/dateTimeUtils.js b/util/dateTimeUtils.js index 5f90f7bc0..bca5fadea 100644 --- a/util/dateTimeUtils.js +++ b/util/dateTimeUtils.js @@ -1,21 +1,28 @@ -import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import localeData from 'dayjs/plugin/localeData'; -export function getMomentLocalizedFormat(intl) { - moment.locale(intl.locale); - const format = moment.localeData()._longDateFormat.L; - return format; +dayjs.extend(timezone); +dayjs.extend(localeData); + +export const getLibraryLocalizedFormat = (intl) => { + dayjs.locale(intl.locale); + return dayjs.localeData().longDateFormat('L'); } +// deprecated library-specific exported name. +export const getMomentLocalizedFormat = getLibraryLocalizedFormat; + // Returns a localized format. // Format will be a string similar to YYYY.MM.DD - something that can be -// passed to moment for parsing/formatting purposes. -export const getLocaleDateFormat = ({ intl }) => { +// passed to dayjs for parsing/formatting purposes. +export const getLocaleDateFormat = ({ intl, config }) => { const tempDate = new Date('Thu May 14 2020 14:39:25 GMT-0500'); let format = ''; // set up a locally formatted array of parts... if (Intl?.DateTimeFormat()?.formatToParts) { - const intlFormatter = new Intl.DateTimeFormat(intl.locale, { + const intlFormatter = new Intl.DateTimeFormat(intl.locale, config || { day: '2-digit', year: 'numeric', month: '2-digit', @@ -27,6 +34,16 @@ export const getLocaleDateFormat = ({ intl }) => { formatted.forEach((p) => { switch (p.type) { + case 'dayPeriod': + format += 'A'; + format = format.replace('H', 'h'); + break; + case 'minute': + format += 'mm'; + break; + case 'hour': + format += 'H'; + break; case 'month': format += 'MM'; break; @@ -44,8 +61,8 @@ export const getLocaleDateFormat = ({ intl }) => { } }); } else { - // if INTL api is not available, fall back to moment... - format = getMomentLocalizedFormat(intl); + // if INTL api is not available, fall back to the date library... + format = getLibraryLocalizedFormat(intl); } return format; @@ -132,3 +149,20 @@ export function getLocalizedTimeFormatInfo(locale) { dayPeriods: [...dpOptions], }; } + +// parses time without DST. +// DST moves time forward an hour - so +1 to the utc offset - but thankfully, it's not in use for majority ranges. +// given 2 static sample dates that are far enough apart, you'd get one that wasn't +// in DST if it's observed in your locale. +// so we can use the non-DST date to avoid off-by-1-hour time issues. +export function removeDST(dateTime, timeFormat) { + const julDate = '2022-07-20'; + const janDate = '2022-01-01'; + + const julOffset = dayjs(julDate).utcOffset(); + const janOffset = dayjs(janDate).utcOffset(); + + const offsetDate = janOffset < julOffset ? janDate : julDate; + const timestring = dateTime.includes('T') ? dateTime.split('T')[1] : dateTime; + return dayjs.utc(`${offsetDate}T${timestring}`).local().format(timeFormat); +} diff --git a/util/tests/dateUtils-test.js b/util/tests/dateUtils-test.js index 928524de1..f4636a650 100644 --- a/util/tests/dateUtils-test.js +++ b/util/tests/dateUtils-test.js @@ -1,12 +1,12 @@ import { beforeEach, it, describe } from 'mocha'; import { expect } from 'chai'; -import { getMomentLocalizedFormat, getLocaleDateFormat, getLocalizedTimeFormatInfo } from '../dateTimeUtils'; +import { getLibraryLocalizedFormat, getLocaleDateFormat, getLocalizedTimeFormatInfo } from '../dateTimeUtils'; describe('Date Utilities', () => { describe('get localized format - moment fallback', () => { let format; beforeEach(async () => { - format = getMomentLocalizedFormat({ locale: 'de' }); // eslint-disable-line + format = getLibraryLocalizedFormat({ locale: 'de' }); // eslint-disable-line }); it('returns the long date format according to the passed locale', () => { @@ -17,7 +17,7 @@ describe('Date Utilities', () => { describe('get localized format - moment fallback', () => { let format; beforeEach(async () => { - format = getMomentLocalizedFormat({ locale: 'de' }); // eslint-disable-line + format = getLibraryLocalizedFormat({ locale: 'de' }); // eslint-disable-line }); it('returns the long date format according to the passed locale', () => { expect(format).to.equal('DD.MM.YYYY');