diff --git a/README.md b/README.md index 9d559dc81..8000f73c8 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,18 @@ Useful recipes for UI patterns appearing in FOLIO modules. * [Show/Hide Columns in MCL](guides/patterns/ColumnSelector.stories.mdx) -- Give users the ability to select only the data they need to see. * [Accessible Routing](guides/patterns/AccessibleRouting.stories.mdx) -- Detail the approaches to implementing accessible focus management. +## Working with dates/times in UI-Modules + +We provide a handful of components and utilities for date/time functionality. + +* **Datepicker, Timepicker, DateRangeWrapper components** - UI-widgets for accepting date/time input. +* **FormattedDate, FormattedUTCDate, FormattedTime** - Cross-browser convenience components for displaying localized representations of system ISO8601 timestamps. +* [dateTimeUtils](util/DateUtils_readme.md) - A handful of utility functions for working with date/time code in application logic. +* **Hooks** + * useFormatDate - presentational date-formatting. + * useFormatTime - presentational time-formatting. + * useDynamicLocale - loading DayJS locale information within functional components (also available in component form, via `DynamicLocaleRenderer`). + ## Testing Stripes Components' tests are automated browser tests powered by [Karma](http://karma-runner.github.io) and written using 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..1c7ffb2e2 --- /dev/null +++ b/hooks/useDynamicLocale/useDynamicLocale.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { IntlContext } from 'react-intl'; +import { loadDayJSLocale } from '../../util/dateTimeUtils'; + +const isEnglishLang = (locale) => { + return /^en/.test(locale); +}; + +/** + * useDynamicLocale - + * React hook that loads a DayJS locale, returns an + * @date 12/15/2023 - 1:58:58 PM + * + * @param {Object} hookParams + * @param {String} hookParams.locale - locale string ex : 'en-SE' + */ +const useDynamicLocale = ({ locale : localeProp } = {}) => { + const { locale: localeContext } = React.useContext(IntlContext); + const [localeLoaded, setLocaleLoaded] = React.useState( + localeProp ? isEnglishLang(localeProp) : + isEnglishLang(localeContext) + ); + const [prevLocale, updatePrevLocale] = React.useState(localeProp || localeContext); + const locale = localeProp || localeContext; + + React.useEffect(() => { + const localeCallback = (loadedLocale, err) => { + if (!err) { + setLocaleLoaded(true); + } + }; + + loadDayJSLocale(locale, localeCallback); + }, [localeLoaded, locale, prevLocale]); + + if (locale !== prevLocale) { + updatePrevLocale(localeProp || localeContext); + setLocaleLoaded(localeProp ? isEnglishLang(localeProp) : isEnglishLang(localeContext)); + } + + return { + localeLoaded, + isEnglish: localeProp ? localeProp === 'en' : localeContext === 'en' + }; +}; + +export default useDynamicLocale; diff --git a/index.js b/index.js index 0a385a345..433c942a2 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,6 @@ export { staticFirstWeekDay, staticLangCountryCodes } from './lib/Datepicker'; -export { getLocaleDateFormat, getLocalizedTimeFormatInfo } from './util/dateTimeUtils'; export { default as DateRangeWrapper } from './lib/DateRangeWrapper'; export { default as FormattedDate } from './lib/FormattedDate'; export { default as FormattedTime } from './lib/FormattedTime'; @@ -142,6 +141,13 @@ export { default as ExportCsv } from './lib/ExportCsv'; export { default as exportToCsv } from './lib/ExportCsv/exportToCsv'; /* utilities */ +export { + getLocaleDateFormat, + getLocalizedTimeFormatInfo, + dayjs, + DayRange, + loadDayJSLocale, +} from './util/dateTimeUtils'; export { default as RootCloseWrapper } from './util/RootCloseWrapper'; export { default as omitProps } from './util/omitProps'; export { diff --git a/lib/Datepicker/Calendar.js b/lib/Datepicker/Calendar.js index e013d392d..d265a5dff 100644 --- a/lib/Datepicker/Calendar.js +++ b/lib/Datepicker/Calendar.js @@ -2,48 +2,55 @@ * 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, DayRange } from '../../util/dateTimeUtils'; 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); +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 = new DayRange(firstDay, endDay).expand(); 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); - rowStartArray.push(mo.weekday(offset + 0)); - rowEndArray.push(endClone.weekday(offset + 6)); + const endClone = dayjs(mo); + rowStartArray.push(mo.day(offset).toISOString()); + rowEndArray.push(endClone.day(offset + 6).add(1, 'day')); } }); for (let i = 0; i < weeks.length; i += 1) { - const weekRange = moment.range(rowStartArray[i], rowEndArray[i]); + const weekRange = new DayRange(rowStartArray[i], rowEndArray[i]).expand(); calendar.push(weekRange); } @@ -87,8 +94,17 @@ function getFirstWeekday(locale) { return dayMap[firstDay]; } +function getAdjustedLocale(locale) { + let adjustedLocale = locale; + if (adjustedLocale.length === 2) { + const regionDefault = staticRegions[adjustedLocale]; + adjustedLocale = `${adjustedLocale}-${regionDefault}`; + } + return adjustedLocale; +} + 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]), @@ -124,19 +140,22 @@ 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()) { + if (selectedDate.isMoment) { + this.selectedDate = dayjs(selectedDate.toISOString(), dateFormat, true); + console.warn('Calendar component passed a MomentJS object for selected date. Migration to Dayjs is required!') + } else { + 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 @@ -146,21 +165,12 @@ class Calendar extends React.Component { // Hopefully it will be implemented in browser Intl API soon.. // but until then... - let dayOffset = 0; - let adjustedLocale = props.intl.locale || props.locale; - if (adjustedLocale.length === 2) { - const regionDefault = staticRegions[adjustedLocale]; - adjustedLocale = `${adjustedLocale}-${regionDefault}`; - } + const adjustedLocale = getAdjustedLocale(props.intl.locale || props.locale); - // 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); - } + this.weekDays = getWeekDays(props.intl); + const dayOffset = 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,14 +179,13 @@ class Calendar extends React.Component { this.state = { cursorDate, - date: this.selectedMoment, + date: this.selectedDate, month, year, - calendar: getCalendar(year, month), + calendar: getCalendar(year, month, dayOffset), dayOffset }; - this.weekDays = getWeekDays(props.intl); this.months = getMonths(props.intl); this.firstWeekDay = getFirstWeekday(adjustedLocale); this.firstField = props.firstFieldRef || React.createRef(); @@ -190,8 +199,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 +218,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 +293,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 +315,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 +342,18 @@ 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) === dayjs(this.props.selectedDate, dateFormat, true) + .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) === + dayjs(this.state.date, dateFormat, true) // eslint-disable-line new-cap + .format(format); } return false; } @@ -357,10 +374,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 = dayjs().month(month).date(1).year(curState.year); // eslint-disable-line new-cap } return { month, @@ -373,7 +390,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 (dayjs(year, 'YYYY', true).isValid()) { // eslint-disable-line new-cap this.setState(curState => ({ year, calendar: getCalendar(year, curState.month, curState.dayOffset), @@ -403,6 +420,20 @@ class Calendar extends React.Component { }); } + handleLocaleLoaded = ({ isEnglish }) => { + const { intl, locale } = this.props; + if (!isEnglish) { + this.setState(cur => { + const adjustedLocale = getAdjustedLocale(intl.locale || locale); + const dayOffset = getFirstWeekday(adjustedLocale); + return { + calendar: getCalendar(cur.year, cur.month, dayOffset), + dayOffset + }; + }); + } + } + render() { const { id, rootRef, fillParent, trapFocus } = this.props; @@ -412,16 +443,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 +479,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 +544,114 @@ class Calendar extends React.Component { } return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -