From 2ab3bb5408fe41d49d6ffd0494e1b7f653a5831d Mon Sep 17 00:00:00 2001 From: Gabe Scarbrough Date: Thu, 16 May 2024 10:52:04 -0400 Subject: [PATCH] Add DateTimePicker (#1220) Ports the DateTimePicker over from rails-server Converted to TypeScript and simplified the props a little bit in the process --- package.json | 5 + src/DateTimePicker/DateTimePicker.scss | 143 ++++++++++++ src/DateTimePicker/DateTimePicker.stories.tsx | 24 ++ src/DateTimePicker/DateTimePicker.test.tsx | 60 +++++ src/DateTimePicker/DateTimePicker.tsx | 213 ++++++++++++++++++ src/DateTimePicker/PickerEnforcedInput.tsx | 51 +++++ src/DateTimePicker/index.ts | 5 + src/index.ts | 2 + yarn.lock | 113 ++++++++++ 9 files changed, 616 insertions(+) create mode 100644 src/DateTimePicker/DateTimePicker.scss create mode 100644 src/DateTimePicker/DateTimePicker.stories.tsx create mode 100644 src/DateTimePicker/DateTimePicker.test.tsx create mode 100644 src/DateTimePicker/DateTimePicker.tsx create mode 100644 src/DateTimePicker/PickerEnforcedInput.tsx create mode 100644 src/DateTimePicker/index.ts diff --git a/package.json b/package.json index be033211..5c162068 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,11 @@ "@tiptap/pm": "^2.3.1", "@tiptap/react": "^2.3.1", "@tiptap/suggestion": "^2.3.1", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "react-bootstrap": "^2.10.2", "react-currency-input-field": "^3.8.0", + "react-datepicker": "^6.9.0", "react-loading-skeleton": "^3.4.0", "react-router-dom": "^5.3.4", "react-select": "^5.8.0", @@ -67,6 +70,8 @@ "@popperjs/core": "^2.5.3", "bootstrap": "5.1", "classnames": "^2.2.5", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "prop-types": "^15.6.1", "react": "^18.2.0", "react-copy-to-clipboard": "^5.0.2", diff --git a/src/DateTimePicker/DateTimePicker.scss b/src/DateTimePicker/DateTimePicker.scss new file mode 100644 index 00000000..da92e51f --- /dev/null +++ b/src/DateTimePicker/DateTimePicker.scss @@ -0,0 +1,143 @@ +@import 'react-datepicker/dist/react-datepicker.css'; +@import '../../scss/theme.scss'; + +.date-time-picker { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: flex-start; + width: 100%; + + @include media-breakpoint-up(sm) { + align-items: center; + flex-direction: row; + } + + .react-datepicker__header { + background-color: var(--ux-white); + border-bottom: 1px solid var(--ux-gray-200); + } + + .react-datepicker__day-names { + font-weight: var(--synth-font-weight-bold); + } + + .react-datepicker__day--outside-month { + color: var(--ux-gray-300); + } + + + .date-time-picker { + &__input-group { + background-color: var(--ux-white); + border-radius: var(--ux-border-radius); + border: thin solid var(--ux-gray-400); + padding: .46875rem .75rem; + justify-content: space-between; + width: inherit; + } + } + + .react-datepicker { + @include font-type-30; + border: none; + box-shadow: 0 1px 3px var(--ux-gray-400); + } + + .react-datepicker__time { + @include font-type-20; + } + + .react-datepicker__day--selected { + background: var(--ux-blue-500); + } + + .react-datepicker__day--keyboard-selected { + background: none; + color: var(--ux-black); + } + + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected { + background: var(--ux-blue-500); + } + + .react-datepicker__day--today { + color: var(--ux-blue-500); + } + + .react-datepicker__day--selected { + color: var(--ux-white); + } + + .react-datepicker__triangle { + display: none; + } + + // Override form-control's default greying of read only inputs + input:read-only, .form-control[readonly] { + background-color: var(--ux-white); + } + + .form-control:focus { + box-shadow: none; + border: 1px solid $input-focus-border-color; + } + + .react-datepicker { + width: 100%; + + > div:first-child { + width: 100%; + } + + &:not(:first-child) { + margin-top: .5rem; + + @include media-breakpoint-up(sm) { + margin-top: 0; + margin-left: .5rem; + } + } + } +} + + +// override some of the form group invalid styles +.FormGroup--is-invalid +.date-time-picker +select { + border: thin solid var(--ux-gray-400); + border-radius: var(--ux-border-radius); +} + +// override some of the form group invalid styles +// inputs need more specificity to override the above styling +.FormGroup--is-invalid +.date-time-picker +.react-datepicker-wrapper +.react-datepicker__input-container +input { + border: thin solid var(--ux-red); +} + +// Undoing some styles when this is nested within a bootstrap table +.table .date-time-picker { + td, th { + border-top: 0; + padding: 0; + vertical-align: middle; + } + + thead th { + border-bottom: 0; + vertical-align: middle; + } +} + +.react-datepicker-wrapper { + width: 100%; +} diff --git a/src/DateTimePicker/DateTimePicker.stories.tsx b/src/DateTimePicker/DateTimePicker.stories.tsx new file mode 100644 index 00000000..eec693a5 --- /dev/null +++ b/src/DateTimePicker/DateTimePicker.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { DateTimePicker } from '.'; + +export default { + title: 'Components/DateTimePicker', + component: DateTimePicker, +}; + +export const Default = () => ( + + ); + +export const EnforcedInput = () => ( + + ); + +export const ShowMonthAndYearSelects = () => ( + + ); + +export const ShowTimeSelect = () => ( + + ); diff --git a/src/DateTimePicker/DateTimePicker.test.tsx b/src/DateTimePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000..2492a277 --- /dev/null +++ b/src/DateTimePicker/DateTimePicker.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DateTimePicker, { DateTimePickerProps } from './DateTimePicker'; + +const PLACEHOLDER = 'YYYY-MM-DD'; + +const VALID_DATE = '1999-12-31'; +const INVALID_DATE = '99999'; + +describe('DateTimePicker', () => { + function Setup(overrides: DateTimePickerProps) { + return ( + + ); + } + + describe('when initializing', () => { + describe('when passed a (date) prop', () => { + it('sets input value', () => { + render(); + + expect(screen.getByDisplayValue(VALID_DATE)).toBeInTheDocument(); + }); + }); + }); + + describe('interactions', () => { + describe('when typing in date', () => { + describe('with a valid value', () => { + it('keeps value', async () => { + render(); + + const input = screen.getByPlaceholderText(PLACEHOLDER); + userEvent.type(input, `${VALID_DATE}{enter}`); + + await waitFor(() => { + expect(input).toHaveValue(VALID_DATE); + }); + }); + }); + + describe('with an invalid value', () => { + it('clears value', async () => { + render(); + + const input = screen.getByPlaceholderText(PLACEHOLDER); + userEvent.type(input, `${INVALID_DATE}{enter}`); + + await waitFor(() => { + expect(input).toHaveValue(''); + }); + }); + }); + }); + }); +}); diff --git a/src/DateTimePicker/DateTimePicker.tsx b/src/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 00000000..e6f06068 --- /dev/null +++ b/src/DateTimePicker/DateTimePicker.tsx @@ -0,0 +1,213 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import DatePicker, { getDefaultLocale, registerLocale, setDefaultLocale } from 'react-datepicker'; +import { + format, + isDate, + isValid, + parse, + parseISO, +} from 'date-fns'; + +import { + enAU, enCA, enGB, enUS, enZA, fr, frCA, de, +} from 'date-fns/locale'; + +import { PickerEnforcedInput } from './PickerEnforcedInput'; + +import './DateTimePicker.scss'; + +const localeMap = { + 'en-US': enUS, + 'en-CA': enCA, + 'en-GB': enGB, + 'en-AU': enAU, + 'en-ZA': enZA, + de, + 'de-DE': de, + fr, + 'fr-CA': frCA, + 'fr-FR': fr, +}; + +const STANDARD_TIME_FORMAT_FNS = 'hh:mm aa'; +const ISO_DATE_FORMAT_FNS = 'yyyy-MM-dd'; + +export type DateTimePickerProps = { + date?: string; + dateFormat?: string; + disabled?: boolean; + id?: string; + inputClassName?: string; + maxDate?: Date; + minDate?: Date; + name?: string; + showMonthAndYearSelects?: boolean, + showPickerEnforcedInput?: boolean; + showTimeSelect?: boolean; + time?: string; + timeFormat?: string; + onChangeDate?: (...args: unknown[]) => unknown; + onDateParseError?: (...args: unknown[]) => unknown; +}; + +function DateTimePicker({ + date = '', + dateFormat = ISO_DATE_FORMAT_FNS, + disabled = false, + id, + inputClassName = 'form-control', + maxDate, + minDate, + name, + showMonthAndYearSelects = false, + showPickerEnforcedInput = false, + showTimeSelect = false, + time = '', + timeFormat = STANDARD_TIME_FORMAT_FNS, + onChangeDate, + onDateParseError, +}: DateTimePickerProps) { + const [startDate, setStartDate] = useState(date); // string + const [startTime, setStartTime] = useState(time); // string + + const parsedDateFromString = useCallback(() => { + if (dateFormat === ISO_DATE_FORMAT_FNS) { + return parseISO(startDate); + } + + return parse(startDate, dateFormat, new Date()); + }, [dateFormat, startDate]); + + const getDateFormat = useCallback(() => { + // if we are enforcing that users use the picker instead of typing + // we want to use the localized date format (and not mess with times) + // https://date-fns.org/v2.0.0-alpha.18/docs/format + // https://github.com/Hacker0x01/react-datepicker/issues/3447#issuecomment-1034623173 + if (showPickerEnforcedInput) return 'P'; + + if (showTimeSelect) { + return `${dateFormat} ${timeFormat}`; + } + return dateFormat; + }, [dateFormat, showPickerEnforcedInput, showTimeSelect, timeFormat]); + + // converts string values into a date object + const dateFromString = useCallback(() => { + if (typeof startDate === 'string' && startDate !== '') { + if (showTimeSelect && startTime !== undefined) { + return parse(`${startDate} ${startTime}`, getDateFormat(), new Date()); + } + return parsedDateFromString(); + } + return undefined; + }, [getDateFormat, parsedDateFromString, showTimeSelect, startDate, startTime]); + + const resetDate = () => { + setStartDate(''); + setStartTime(''); + }; + + useEffect(() => { + const localeLanguage = navigator.language; + const supportedLocale = localeMap[localeLanguage]; + setStartDate(date); + + // register and set the locale if it is supported + if (supportedLocale) { + registerLocale(localeLanguage, supportedLocale); + setDefaultLocale(localeLanguage); + } + }, [date]); + + const handleOnCalendarClose = () => { + const updated = dateFromString(); + + if (!onChangeDate || !startDate || !updated) return; + + if (!isValid(updated)) { + if (onDateParseError) { + onDateParseError( + new Error( + `bad date parse values in handleOnCalendarClose: date: ${startDate}, time: ${startTime}`, + ), + ); + } + return; + } + + const parsedTime = format(updated, timeFormat); + const parsedDate = format(updated, dateFormat); + + const dateObj = { startDate: date, startTime: time }; + + if (parsedDate !== date) { + dateObj.startDate = parsedDate; + } + if (showTimeSelect && parsedTime !== time) { + dateObj.startTime = parsedTime; + } + onChangeDate(dateObj); + }; + + const handleOnChange = (updatedDate) => { + if (!isValid(updatedDate)) { + resetDate(); + return; + } + + const parsedDate = format(updatedDate, dateFormat); + setStartDate(parsedDate); + + if (showTimeSelect) { + const parsedTime = format(updatedDate, timeFormat); + setStartTime(parsedTime); + } + }; + + useEffect(() => { + const parsedDate = dateFromString(); + + if (isDate(parsedDate) && !isValid(parsedDate)) { + resetDate(); + } + }, [dateFromString, startDate, startTime]); + + return ( +
+ + )} + dateFormat={getDateFormat()} + disabled={disabled} + dropdownMode="select" + id={id} + locale={getDefaultLocale()} + maxDate={maxDate} + minDate={minDate} + name={name} + placeholderText={getDateFormat().toUpperCase()} + selected={dateFromString()} + showMonthDropdown={showMonthAndYearSelects} + showTimeSelect={showTimeSelect} + showYearDropdown={showMonthAndYearSelects} + timeCaption="Time" + timeFormat={timeFormat} + timeIntervals={60} + title={name} + onCalendarClose={handleOnCalendarClose} + onChange={handleOnChange} + /> +
+ ); +} + +export default DateTimePicker; diff --git a/src/DateTimePicker/PickerEnforcedInput.tsx b/src/DateTimePicker/PickerEnforcedInput.tsx new file mode 100644 index 00000000..c7ff035c --- /dev/null +++ b/src/DateTimePicker/PickerEnforcedInput.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react'; + +import classNames from 'classnames'; +import { isValid } from 'date-fns'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCalendarAlt } from '@fortawesome/pro-regular-svg-icons'; + +type PickerEnforcedInputProps = { + disabled?: boolean; + inputClassName?: string; + name?: string; + startDate?: string; + value?: string; + onClick?: (...args: unknown[]) => unknown; +}; + +export const PickerEnforcedInput = forwardRef(({ + disabled = false, + inputClassName = '', + name = '', + onClick, + startDate = '', + value = '', +}, ref) => { + const startDateIsValid = () => isValid(startDate); + + return ( +
+ + +
+ ); +}); diff --git a/src/DateTimePicker/index.ts b/src/DateTimePicker/index.ts new file mode 100644 index 00000000..e12c4f86 --- /dev/null +++ b/src/DateTimePicker/index.ts @@ -0,0 +1,5 @@ +import DateTimePicker from './DateTimePicker'; + +export { + DateTimePicker, +}; diff --git a/src/index.ts b/src/index.ts index f28d2fa9..92f0be0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { Col, Container, Row } from './Container'; import { ORIENTATIONS as BUTTON_GROUP_ORIENTATIONS } from './ControlButtonGroup'; import CopyToClipboard from './CopyToClipboard'; import CopyToClipboardButton from './CopyToClipboardButton'; +import { DateTimePicker } from './DateTimePicker'; import { Drawer, DrawerHeader, @@ -126,6 +127,7 @@ export { CopyToClipboard, CopyToClipboardButton, CreatableSelect, + DateTimePicker, Drawer, DrawerHeader, DrawerBody, diff --git a/yarn.lock b/yarn.lock index 85f27d5e..1c3876d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2730,6 +2730,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.0.0": + version: 1.6.1 + resolution: "@floating-ui/core@npm:1.6.1" + dependencies: + "@floating-ui/utils": "npm:^0.2.0" + checksum: 10c0/7d78b3788d438807d3c1a52477ee1693a29b8a4416dd6e13761427925d9fba1d45c849527752d8fd9776842182d919fddf7ecbc34f3bf2de3bafa1717619a56f + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.4.2": version: 1.5.0 resolution: "@floating-ui/core@npm:1.5.0" @@ -2739,6 +2748,16 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.5 + resolution: "@floating-ui/dom@npm:1.6.5" + dependencies: + "@floating-ui/core": "npm:^1.0.0" + "@floating-ui/utils": "npm:^0.2.0" + checksum: 10c0/ebdc14806f786e60df8e7cc2c30bf9cd4d75fe734f06d755588bbdef2f60d0a0f21dffb14abdc58dea96e5577e2e366feca6d66ba962018efd1bc91a3ece4526 + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.0.1": version: 1.5.3 resolution: "@floating-ui/dom@npm:1.5.3" @@ -2749,6 +2768,32 @@ __metadata: languageName: node linkType: hard +"@floating-ui/react-dom@npm:^2.0.0": + version: 2.0.9 + resolution: "@floating-ui/react-dom@npm:2.0.9" + dependencies: + "@floating-ui/dom": "npm:^1.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/d8cd1fb2b8a5012ca692d6f677a0af923ef81131f69accea8ce8b5413202ab4c3c79e6eda1446f4dad06a2dfd596ece748c562ba28c289678a856755db4f528f + languageName: node + linkType: hard + +"@floating-ui/react@npm:^0.26.2": + version: 0.26.13 + resolution: "@floating-ui/react@npm:0.26.13" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@floating-ui/utils": "npm:^0.2.0" + tabbable: "npm:^6.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/8a6004fc4ef11468bb26a8f5624d74f4cdb9389583c2c7716c61f3a6df2451ce76dfcc73f862320d51bc667b212a336acb303048d4c3906f4e97679ac1de478d + languageName: node + linkType: hard + "@floating-ui/utils@npm:^0.1.3": version: 0.1.4 resolution: "@floating-ui/utils@npm:0.1.4" @@ -2756,6 +2801,13 @@ __metadata: languageName: node linkType: hard +"@floating-ui/utils@npm:^0.2.0": + version: 0.2.2 + resolution: "@floating-ui/utils@npm:0.2.2" + checksum: 10c0/b2becdcafdf395af1641348da0031ff1eaad2bc60c22e14bd3abad4acfe2c8401e03097173d89a2f646a99b75819a78ef21ebb2572cab0042a56dd654b0065cd + languageName: node + linkType: hard + "@fortawesome/fontawesome-common-types@npm:6.5.2": version: 6.5.2 resolution: "@fortawesome/fontawesome-common-types@npm:6.5.2::__archiveUrl=https%3A%2F%2Fnpm.fontawesome.com%2F%40fortawesome%2Ffontawesome-common-types%2F-%2F6.5.2%2Ffontawesome-common-types-6.5.2.tgz" @@ -5369,6 +5421,8 @@ __metadata: chromatic: "npm:^6.24.1" classnames: "npm:^2.5.1" css-loader: "npm:^6.11.0" + date-fns: "npm:^3.6.0" + date-fns-tz: "npm:^3.1.3" eslint: "npm:^7.32.0" eslint-config-airbnb: "npm:^18.2.1" eslint-plugin-babel: "npm:^5.3.1" @@ -5389,6 +5443,7 @@ __metadata: react-bootstrap: "npm:^2.10.2" react-copy-to-clipboard: "npm:^5.1.0" react-currency-input-field: "npm:^3.8.0" + react-datepicker: "npm:^6.9.0" react-dom: "npm:^18.3.1" react-hook-form: "npm:^7.51.4" react-loading-skeleton: "npm:^3.4.0" @@ -5419,6 +5474,8 @@ __metadata: "@popperjs/core": ^2.5.3 bootstrap: 5.1 classnames: ^2.2.5 + date-fns: ^3.6.0 + date-fns-tz: ^3.1.3 prop-types: ^15.6.1 react: ^18.2.0 react-copy-to-clipboard: ^5.0.2 @@ -7190,6 +7247,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.0": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -7654,6 +7718,22 @@ __metadata: languageName: node linkType: hard +"date-fns-tz@npm:^3.1.3": + version: 3.1.3 + resolution: "date-fns-tz@npm:3.1.3" + peerDependencies: + date-fns: ^3.0.0 + checksum: 10c0/dcedf178a96632a798966cf5a881441aefc451cc308336a98919e49c2df8eff7a302d19b9d3903c50838dd0912533f862f0bfb9429e8c28c1c3ce5da19ccb64e + languageName: node + linkType: hard + +"date-fns@npm:^3.3.1, date-fns@npm:^3.6.0": + version: 3.6.0 + resolution: "date-fns@npm:3.6.0" + checksum: 10c0/0b5fb981590ef2f8e5a3ba6cd6d77faece0ea7f7158948f2eaae7bbb7c80a8f63ae30b01236c2923cf89bb3719c33aeb150c715ea4fe4e86e37dcf06bed42fb6 + languageName: node + linkType: hard + "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -14089,6 +14169,22 @@ __metadata: languageName: node linkType: hard +"react-datepicker@npm:^6.9.0": + version: 6.9.0 + resolution: "react-datepicker@npm:6.9.0" + dependencies: + "@floating-ui/react": "npm:^0.26.2" + clsx: "npm:^2.1.0" + date-fns: "npm:^3.3.1" + prop-types: "npm:^15.7.2" + react-onclickoutside: "npm:^6.13.0" + peerDependencies: + react: ^16.9.0 || ^17 || ^18 + react-dom: ^16.9.0 || ^17 || ^18 + checksum: 10c0/aed97871a60071bf00dd1447a7c4a3841a05db6f57bd24b3e499c9ef8c20bc5c46a826b6b0667ae804c4ddd2bb88319bd8115a08bb670c2a57505b0a7a18bec6 + languageName: node + linkType: hard + "react-docgen-typescript@npm:^2.2.2": version: 2.2.2 resolution: "react-docgen-typescript@npm:2.2.2" @@ -14247,6 +14343,16 @@ __metadata: languageName: node linkType: hard +"react-onclickoutside@npm:^6.13.0": + version: 6.13.0 + resolution: "react-onclickoutside@npm:6.13.0" + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + checksum: 10c0/2013471d03965e3c89ead649063bda246a8e9ba72b7be1dbf00642c6c8a34a0142e951b1db3cf73ac53f34f459b772132eff6f7b89f4cde51c35740be6617161 + languageName: node + linkType: hard + "react-popper@npm:^2.3.0": version: 2.3.0 resolution: "react-popper@npm:2.3.0" @@ -15752,6 +15858,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.0.0": + version: 6.2.0 + resolution: "tabbable@npm:6.2.0" + checksum: 10c0/ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898 + languageName: node + linkType: hard + "table@npm:^6.0.9": version: 6.8.1 resolution: "table@npm:6.8.1"