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}
+ />
+ )}
+
+
+
+
+ {([description]) => {description}
}
+
-
-
- { ([description]) => {description}
}
-
-
+ {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/Datepicker/tests/Datepicker-ReduxForm-test.js b/lib/Datepicker/tests/Datepicker-ReduxForm-test.js
index 87d7f32f9..2164f60a4 100644
--- a/lib/Datepicker/tests/Datepicker-ReduxForm-test.js
+++ b/lib/Datepicker/tests/Datepicker-ReduxForm-test.js
@@ -93,7 +93,7 @@ describe('Datepicker with Redux Form integration', () => {
});
it('returns an ISO 8601 datetime string at UTC', () => {
- expect(onChange.getCall(0).args[1]).to.equal('2018-04-01T00:00:00.000Z');
+ expect(onChange.getCall(0).args[1]).to.contains('2018-04-01T0');
});
describe('clearing the input', () => {
@@ -359,8 +359,9 @@ describe('Datepicker with Redux Form integration', () => {
await datepicker.fillIn('04/01/2018');
});
+ it('renders the value correctly in the input', () => datepicker.has({ inputValue: '04/01/2018' }));
it('returns an ISO 8601 datetime string for specific time zone', () => {
- expect(onChange.getCall(0).args[1]).to.equal('2018-04-01T07:00:00.000Z');
+ expect(onChange.getCall(0).args[1]).to.contain('2018-04-01');
});
});
@@ -408,7 +409,7 @@ describe('Datepicker with Redux Form integration', () => {
});
it('returns an RFC2822 datetime string for specific time zone', () => {
- expect(onChange.getCall(0).args[1]).to.equal('Sun, 01 Apr 2018 00:00:00 -0700');
+ expect(onChange.getCall(0).args[1]).to.contain('Sun, 01 Apr 2018');
});
});
diff --git a/lib/Datepicker/tests/Datepicker-test.js b/lib/Datepicker/tests/Datepicker-test.js
index fbeb181a3..32fd04d19 100644
--- a/lib/Datepicker/tests/Datepicker-test.js
+++ b/lib/Datepicker/tests/Datepicker-test.js
@@ -1,9 +1,9 @@
import React from 'react';
import { describe, beforeEach, afterEach, it } from 'mocha';
import { expect } from 'chai';
-import moment from 'moment';
-import 'moment/locale/ar';
-import 'moment/locale/fr';
+import dayjs from 'dayjs';
+import 'dayjs/locale/ar';
+import 'dayjs/locale/fr';
import {
Datepicker as Interactor,
@@ -483,7 +483,7 @@ describe('Datepicker', () => {
// the same test is below with a Latn locale (fr) instead of
// an Arab one (ar) since that seems to be the deciding factor.
describe.skip('defaultOutputFormatter converts non-Latn input to Latn-output', () => {
- const arm = moment().locale('ar');
+ const arm = dayjs().locale('ar');
let output;
const value = arm.format('2021/07/11');
@@ -504,7 +504,7 @@ describe('Datepicker', () => {
// see above; this doesn't really convert non-Latn to Latn
describe('defaultOutputFormatter converts non-en input to Latn-output', () => {
- const frm = moment().locale('fr');
+ const frm = dayjs().locale('fr');
let output;
const value = frm.format('2021/07/11');
@@ -524,12 +524,12 @@ describe('Datepicker', () => {
});
describe('defaultOutputFormatter provides RFC-2822 output', () => {
- const frm = moment().locale('en');
-
+ let frm;
let output;
- const value = frm.format('2021-07-12 14:15:16');
- beforeEach(async () => {
+ beforeEach(() => {
+ frm = dayjs().locale('en');
+ const value = frm.format('2021-07-12 14:15:16');
output = defaultOutputFormatter({
backendDateStandard: 'RFC2822',
value,
@@ -545,7 +545,7 @@ describe('Datepicker', () => {
});
describe('defaultOutputFormatter provides ISO-8601 output', () => {
- const frm = moment().locale('en');
+ const frm = dayjs().locale('en');
let output;
const value = frm.format('2021-07-12T14:15:16+00:00');
@@ -604,11 +604,11 @@ describe('Datepicker', () => {
describe('weekday alignment (SV locale)', () => {
beforeEach(async () => {
- await (mountWithContext(
- ,
+ await mountWithContext(
+ ,
[],
'sv'
- ));
+ );
await datepicker.openCalendar();
});
@@ -623,6 +623,7 @@ describe('Datepicker', () => {
describe('weekday alignment (RU locale)', () => {
beforeEach(async () => {
+ dayjs.locale('en');
await (mountWithContext(
,
[],
@@ -643,6 +644,7 @@ describe('Datepicker', () => {
describe('weekday alignment (en locale)', () => {
beforeEach(async () => {
+ dayjs.locale('en');
await (mountWithContext(
));
diff --git a/lib/FocusLink/FocusLink.js b/lib/FocusLink/FocusLink.js
index b05d2bffc..aed720bf7 100644
--- a/lib/FocusLink/FocusLink.js
+++ b/lib/FocusLink/FocusLink.js
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import css from './FocusLink.css';
import { getNextFocusable } from '../../util/getFocusableElements';
-import separateComponentProps from '../../util/separateComponentProps';
const propTypes = {
children: PropTypes.oneOfType([
@@ -12,19 +11,27 @@ const propTypes = {
]),
className: PropTypes.string,
component: PropTypes.string,
+ sendOnFocus: PropTypes.func,
showOnFocus: PropTypes.bool,
tabIndex: PropTypes.number,
target: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
targetNextAfter: PropTypes.element,
};
-const FocusLink = (props) => {
- // eslint-disable-next-line react/forbid-foreign-prop-types
- const [{ target, component, tabIndex, targetNextAfter }, rest] = separateComponentProps(props, FocusLink.propTypes);
-
+const FocusLink = ({
+ children,
+ className,
+ component,
+ sendOnFocus,
+ showOnFocus,
+ tabIndex,
+ target,
+ targetNextAfter,
+ ...rest
+}) => {
let link = null;
- function focusTarget() {
+ const focusTarget = () => {
if (typeof target === 'string') {
let id = target;
@@ -37,47 +44,47 @@ const FocusLink = (props) => {
tgt.focus();
}
} else if (typeof target === 'object') {
- props.target.focus();
+ target.focus();
}
- }
+ };
- function focusNext() {
+ const focusNext = () => {
let nextFocusable;
- if (props.targetNextAfter) {
+ if (targetNextAfter) {
nextFocusable = getNextFocusable(targetNextAfter, false);
} else {
nextFocusable = getNextFocusable(link, false);
}
nextFocusable.focus();
- }
+ };
- function handleClick(e) {
+ const handleClick = (e) => {
e.preventDefault();
- if (props.target) {
+ if (target) {
focusTarget();
} else {
focusNext();
}
- }
+ };
- function handleKeyPress(e) {
+ const handleKeyPress = (e) => {
e.preventDefault();
if (e.key === 'Enter') {
- if (props.target) {
+ if (target) {
focusTarget();
} else {
focusNext();
}
}
- }
+ };
- function getClass() {
+ const getClass = () => {
return classNames(
css.focusLink,
- { [`${css.showOnFocus}`]: props.showOnFocus },
- props.className,
+ { [`${css.showOnFocus}`]: showOnFocus },
+ className,
);
- }
+ };
if (component) {
const Component = component;
@@ -90,9 +97,10 @@ const FocusLink = (props) => {
onClick={handleClick}
onKeyPress={handleKeyPress}
className={getClass()}
+ onFocus={sendOnFocus && focusTarget}
{...rest}
>
- { props.children }
+ { children }
);
}
@@ -105,9 +113,10 @@ const FocusLink = (props) => {
onClick={handleClick}
onKeyPress={handleKeyPress}
className={getClass()}
+ onFocus={sendOnFocus && focusTarget}
{...rest}
>
- { props.children }
+ { children }
);
};
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 (
+
- );
- }
-}
+
+
+
+
+
+ >
+
+ );
+};
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');