From ea996c81287623110963bcaad3067cfb73d95144 Mon Sep 17 00:00:00 2001 From: Suzanne Rozier Date: Fri, 12 Feb 2021 11:47:11 -0600 Subject: [PATCH] feat: DatePicker component (#803) * Initial setup for DatePicker * Render initial elements * Add DatePicker utils and constants, set default value * Handle calendar toggle * Build Calendar UI, handle toggle * Implement select date handler, focus on selected day, add tests * Add date validation * Handle min, max dates * Update status text on toggle of calendar * Added month/year navigation * Handle external input, pass selectedDate prop to calendar * Working on MonthPicker * Add month selection * Working on YearPicker * Add year chunk navigation * Handle date range styling * Use const instead of enum * Manage focus in navigation * Clean up, add TODOs * Upgrade to React 17 - Keep React 16 peerDependency for now * Update storybook (for React 17 compatibility) * Update Storybook names, add focusOut handler * Handle Day keyboard events * Add month picker keyboard navigation * Add year picker keyboard navigation * Handle escape key * Implement tab navigation * Add mousemove handler to date picker * Add remaining mousemove events * handle keydown/keyup on calendar element * Pass in input props, add label intrinsic props to Label, use storybook actions * Fix status text behavior * Status text is sr only * Fix tests * Add the DatePicker export * Move deps to dev or peer * Use prepare script * Comment out internal component stories * Clean up, eslint fixes/annotations * Add some additional edge test cases * handle some events (: * Update storybook addons in dependabot config * Clean up DatePicker stories * Use readOnly attr on internal input Co-authored-by: Brandon Lenz --- .github/dependabot.yml | 7 +- .storybook/main.ts | 2 +- example/package.json | 4 +- example/src/index.tsx | 18 +- example/src/pages/Forms.tsx | 29 +- example/tsconfig.json | 2 +- example/yarn.lock | 30 +- package.json | 3 +- .../forms/DatePicker/Calendar.stories.tsx | 70 ++ .../forms/DatePicker/Calendar.test.tsx | 566 ++++++++++++++++ src/components/forms/DatePicker/Calendar.tsx | 433 ++++++++++++ .../forms/DatePicker/DatePicker.stories.tsx | 110 +++ .../forms/DatePicker/DatePicker.test.tsx | 641 ++++++++++++++++++ .../forms/DatePicker/DatePicker.tsx | 340 ++++++++++ .../forms/DatePicker/Day.stories.tsx | 133 ++++ src/components/forms/DatePicker/Day.test.tsx | 176 +++++ src/components/forms/DatePicker/Day.tsx | 107 +++ .../forms/DatePicker/MonthPicker.stories.tsx | 32 + .../forms/DatePicker/MonthPicker.test.tsx | 215 ++++++ .../forms/DatePicker/MonthPicker.tsx | 161 +++++ .../forms/DatePicker/YearPicker.stories.tsx | 59 ++ .../forms/DatePicker/YearPicker.test.tsx | 420 ++++++++++++ .../forms/DatePicker/YearPicker.tsx | 276 ++++++++ src/components/forms/DatePicker/constants.ts | 36 + src/components/forms/DatePicker/utils.test.ts | 143 ++++ src/components/forms/DatePicker/utils.tsx | 566 ++++++++++++++++ src/components/forms/Label/Label.tsx | 20 +- src/index.ts | 1 + yarn.lock | 500 ++++++++++++-- 29 files changed, 5021 insertions(+), 79 deletions(-) create mode 100644 src/components/forms/DatePicker/Calendar.stories.tsx create mode 100644 src/components/forms/DatePicker/Calendar.test.tsx create mode 100644 src/components/forms/DatePicker/Calendar.tsx create mode 100644 src/components/forms/DatePicker/DatePicker.stories.tsx create mode 100644 src/components/forms/DatePicker/DatePicker.test.tsx create mode 100644 src/components/forms/DatePicker/DatePicker.tsx create mode 100644 src/components/forms/DatePicker/Day.stories.tsx create mode 100644 src/components/forms/DatePicker/Day.test.tsx create mode 100644 src/components/forms/DatePicker/Day.tsx create mode 100644 src/components/forms/DatePicker/MonthPicker.stories.tsx create mode 100644 src/components/forms/DatePicker/MonthPicker.test.tsx create mode 100644 src/components/forms/DatePicker/MonthPicker.tsx create mode 100644 src/components/forms/DatePicker/YearPicker.stories.tsx create mode 100644 src/components/forms/DatePicker/YearPicker.test.tsx create mode 100644 src/components/forms/DatePicker/YearPicker.tsx create mode 100644 src/components/forms/DatePicker/constants.ts create mode 100644 src/components/forms/DatePicker/utils.test.ts create mode 100644 src/components/forms/DatePicker/utils.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d52f575b2c..b7045a6174 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,12 +28,9 @@ updates: - dependency-name: '@storybook/react' versions: - '^6.0.21' - - dependency-name: '@storybook/addon-docs' + - dependency-name: '@storybook/addon-essentials' versions: - - '^6.0.21' - - dependency-name: '@storybook/addon-viewport' - versions: - - '^6.0.21' + - '^6.1.5' - package-ecosystem: npm directory: '/example/' diff --git a/.storybook/main.ts b/.storybook/main.ts index 2421d3d169..868c38a0da 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -39,7 +39,7 @@ const webpackConfig = (config) => { module.exports = { stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-docs', '@storybook/addon-viewport'], + addons: ['@storybook/addon-essentials'], typescript: { check: false, checkOptions: {}, diff --git a/example/package.json b/example/package.json index e63a386e55..39f15a44fa 100644 --- a/example/package.json +++ b/example/package.json @@ -8,8 +8,8 @@ "@fortawesome/react-fontawesome": "^0.1.14", "@trussworks/react-uswds": "file:./../", "formik": "^2.2.6", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "react": "^17.0.1", + "react-dom": "^17.0.1", "react-redux": "^7.2.2", "react-router-dom": "^5.2.0", "redux": "^4.0.5", diff --git a/example/src/index.tsx b/example/src/index.tsx index 47814d7430..88f21ba999 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import './index.css'; -import App from './App'; -import * as serviceWorker from './serviceWorker'; -import { createStore } from 'redux'; -import { Provider } from 'react-redux'; +import React from 'react' +import ReactDOM from 'react-dom' +import './index.css' +import App from './App' +import * as serviceWorker from './serviceWorker' +import { createStore } from 'redux' +import { Provider } from 'react-redux' import { rootReducer } from './redux/reducers' @@ -17,9 +17,9 @@ ReactDOM.render( , document.getElementById('root') -); +) // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); +serviceWorker.unregister() diff --git a/example/src/pages/Forms.tsx b/example/src/pages/Forms.tsx index 81e954e258..aacdf22373 100644 --- a/example/src/pages/Forms.tsx +++ b/example/src/pages/Forms.tsx @@ -6,10 +6,12 @@ import { Button, ComboBox, Form, + FormGroup, Label, TextInput, ValidationChecklist, ValidationItem, + DatePicker, } from '@trussworks/react-uswds' type FormValues = { @@ -48,7 +50,12 @@ const FormsPage = (): React.ReactElement => {

Formik

{ setTimeout(() => { @@ -124,6 +131,26 @@ const FormsPage = (): React.ReactElement => { options={fruitOptions} onChange={(val: string) => setFieldValue('fruit', val, true)} defaultValue="avocado"> + + + +
+ mm/dd/yyyy +
+ + setFieldValue('appointmentDate', val, true) + } + /> +
+ diff --git a/example/tsconfig.json b/example/tsconfig.json index fbce9be234..e18c413ebf 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -17,7 +17,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react", + "jsx": "react-jsx", "noFallthroughCasesInSwitch": true }, "include": [ diff --git a/example/yarn.lock b/example/yarn.lock index 49237683e4..3ae820654c 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -1755,7 +1755,7 @@ "@babel/runtime" "^7.12.5" "@trussworks/react-uswds@file:./..": - version "1.10.0" + version "1.11.0" "@types/anymatch@*": version "1.3.1" @@ -9249,15 +9249,14 @@ react-dev-utils@^11.0.1: strip-ansi "6.0.0" text-table "0.2.0" -react-dom@^16.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== +react-dom@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" + integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "^0.20.1" react-error-overlay@^6.0.8: version "6.0.8" @@ -9390,14 +9389,13 @@ react-scripts@^4.0.1: optionalDependencies: fsevents "^2.1.3" -react@^16.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" - integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== +react@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" + integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" read-pkg-up@^2.0.0: version "2.0.0" @@ -9916,10 +9914,10 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== +scheduler@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" + integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" diff --git a/package.json b/package.json index 312eb8bc88..119f2a01dd 100644 --- a/package.json +++ b/package.json @@ -66,8 +66,7 @@ "devDependencies": { "@babel/core": "^7.10.5", "@babel/preset-react": "^7.10.4", - "@storybook/addon-docs": "^6.1.5", - "@storybook/addon-viewport": "^6.1.5", + "@storybook/addon-essentials": "^6.1.5", "@storybook/react": "^6.1.5", "@storybook/storybook-deployer": "^2.8.6", "@testing-library/dom": "^7.22.3", diff --git a/src/components/forms/DatePicker/Calendar.stories.tsx b/src/components/forms/DatePicker/Calendar.stories.tsx new file mode 100644 index 0000000000..ebf8ad3738 --- /dev/null +++ b/src/components/forms/DatePicker/Calendar.stories.tsx @@ -0,0 +1,70 @@ +import React from 'react' + +import { Calendar } from './Calendar' +import { FocusMode } from './DatePicker' +import { parseDateString } from './utils' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY + +export default { + title: 'Components/Form controls/Date picker/Calendar', + component: Calendar, + argTypes: { + handleSelectDate: { action: 'select date' }, + setStatuses: { action: 'set statuses' }, + }, +} +*/ + +const defaultProps = { + minDate: new Date('0000-01-01'), + focusMode: FocusMode.None, +} + +export const defaultCalendar = (argTypes): React.ReactElement => ( + +) + +export const givenDate = (argTypes): React.ReactElement => ( + +) + +export const selectedDate = (argTypes): React.ReactElement => ( + +) + +export const minAndMax = (argTypes): React.ReactElement => ( + +) + +export const rangeDate = (argTypes): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/Calendar.test.tsx b/src/components/forms/DatePicker/Calendar.test.tsx new file mode 100644 index 0000000000..f3c8e7b506 --- /dev/null +++ b/src/components/forms/DatePicker/Calendar.test.tsx @@ -0,0 +1,566 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Calendar } from './Calendar' +import { parseDateString, today } from './utils' +import { MONTH_LABELS } from './constants' +import { FocusMode } from './DatePicker' + +describe('Calendar', () => { + const mockSelectDate = jest.fn() + const testProps = { + date: new Date('January 2021'), + handleSelectDate: mockSelectDate, + minDate: parseDateString('0000-01-01') as Date, + setStatuses: jest.fn(), + focusMode: FocusMode.None, + } + + it('renders calendar navigation', () => { + const { getByTestId } = render() + expect(getByTestId('previous-year')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('previous-month')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('next-month')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('next-year')).toBeInstanceOf(HTMLButtonElement) + }) + + it('renders the selected month and year', () => { + const { getByTestId } = render() + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2021') + }) + + it('defaults to the current month and year', () => { + const todayDate = today() + const { getByTestId } = render() + expect(getByTestId('select-month')).toHaveTextContent( + MONTH_LABELS[todayDate.getMonth()] + ) + expect(getByTestId('select-year')).toHaveTextContent( + `${todayDate.getFullYear()}` + ) + }) + + it('disables previous buttons if the min date is in the displayed month and year', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('previous-year')).toBeDisabled() + expect(getByTestId('previous-month')).toBeDisabled() + }) + + it('disables next buttons if the max date is in the displayed month and year', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('next-year')).toBeDisabled() + expect(getByTestId('next-month')).toBeDisabled() + }) + + it('disables select date buttons that are outside the min and max dates', () => { + const { getByLabelText } = render( + + ) + + const disabledDates = [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + ] + const enabledDates = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + disabledDates.forEach((date) => { + // eslint-disable-next-line security/detect-non-literal-regexp + const datePattern = new RegExp(`^${date} January 2021`) + expect(getByLabelText(datePattern)).toBeDisabled() + }) + enabledDates.forEach((date) => { + // eslint-disable-next-line security/detect-non-literal-regexp + const datePattern = new RegExp(`^${date} January 2021`) + expect(getByLabelText(datePattern)).toBeEnabled() + }) + }) + + it('highlights a range of dates if there is a rangeDate prop', () => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText(/^8 January 2021/)).toHaveClass( + 'usa-date-picker__calendar__date--range-date-start' + ) + expect(getByLabelText(/^20 January 2021/)).toHaveClass( + 'usa-date-picker__calendar__date--range-date-end' + ) + + const rangeDates = [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] + rangeDates.forEach((date) => { + // eslint-disable-next-line security/detect-non-literal-regexp + const datePattern = new RegExp(`^${date} January 2021`) + expect(getByLabelText(datePattern)).toHaveClass( + 'usa-date-picker__calendar__date--within-range' + ) + }) + }) + + describe('focusing on hover', () => { + it('focuses on a date in the current month when hovered over', () => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + }) + + it('does not focus on a date not in the current month when hovered over', () => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + fireEvent.mouseMove(getByLabelText(/^2 February 2021/)) + expect(getByLabelText(/^2 February 2021/)).not.toHaveFocus() + }) + + it('does not focus on a disabled date in the current month when hovered over', () => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + expect(getByLabelText(/^13 January 2021/)).toBeDisabled() + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + expect(getByLabelText(/^13 January 2021/)).not.toHaveFocus() + }) + + it('does not focus on a date when hovered over if on an iOS device', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'iPhone') + + const { getByLabelText } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + expect(getByLabelText(/^13 January 2021/)).not.toHaveFocus() + jest.restoreAllMocks() + }) + }) + + describe('navigation', () => { + it('clicking previous year navigates the calendar back one year', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('previous-year')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2020') + expect(getByTestId('previous-year')).toHaveFocus() + }) + + it('clicking previous year focuses the date picker if previous year becomes disabled', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('previous-year')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2020') + expect(getByTestId('previous-year')).toBeDisabled() + expect(getByTestId('calendar-date-picker')).toHaveFocus() + }) + + it('clicking next year navigates the calendar forward one year', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('next-year')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2022') + expect(getByTestId('next-year')).toHaveFocus() + }) + + it('clicking next year focuses the date picker if next year becomes disabled', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('next-year')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2022') + expect(getByTestId('next-year')).toBeDisabled() + expect(getByTestId('calendar-date-picker')).toHaveFocus() + }) + + it('clicking previous month navigates the calendar back one month', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('previous-month')) + expect(getByTestId('select-month')).toHaveTextContent('December') + expect(getByTestId('select-year')).toHaveTextContent('2020') + expect(getByTestId('previous-month')).toHaveFocus() + }) + + it('clicking previous month focuses the date picker if previous month becomes disabled', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('previous-month')) + expect(getByTestId('select-month')).toHaveTextContent('December') + expect(getByTestId('select-year')).toHaveTextContent('2020') + expect(getByTestId('previous-month')).toBeDisabled() + expect(getByTestId('calendar-date-picker')).toHaveFocus() + }) + + it('clicking next month navigates the calendar forward one month', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('next-month')) + expect(getByTestId('select-month')).toHaveTextContent('February') + expect(getByTestId('select-year')).toHaveTextContent('2021') + expect(getByTestId('next-month')).toHaveFocus() + }) + + it('clicking next month focuses the date picker if next month becomes disabled', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('next-month')) + expect(getByTestId('select-month')).toHaveTextContent('February') + expect(getByTestId('select-year')).toHaveTextContent('2021') + expect(getByTestId('next-month')).toBeDisabled() + expect(getByTestId('calendar-date-picker')).toHaveFocus() + }) + }) + + describe('keyboard navigation', () => { + it('pressing the up arrow key from a day navigates to the same day in the previous week', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'ArrowUp', + }) + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + }) + + it('pressing the down arrow key from a day navigates to the same day in the next week', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'ArrowDown', + }) + expect(getByLabelText(/^27 January 2021/)).toHaveFocus() + }) + + it('pressing the left arrow key from a day navigates to the previous day', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'ArrowLeft', + }) + expect(getByLabelText(/^19 January 2021/)).toHaveFocus() + }) + + it('pressing the right arrow key from a day navigates to the next day', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'ArrowRight', + }) + expect(getByLabelText(/^21 January 2021/)).toHaveFocus() + }) + + it('pressing the home key from a day navigates to the first day of the selected week', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'Home', + }) + expect(getByLabelText(/^17 January 2021/)).toHaveFocus() + }) + + it('pressing the end key from a day navigates to the last day of the selected week', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'End', + }) + expect(getByLabelText(/^23 January 2021/)).toHaveFocus() + }) + + it('pressing the page down key from a day navigates to the same day in the next month', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'PageDown', + }) + expect(getByLabelText(/^20 February 2021/)).toHaveFocus() + }) + + it('pressing the page down key from the last day of a longer month navigates to the last day in the shorter next month', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^31 January 2020/), { + key: 'PageDown', + }) + expect(getByLabelText(/^29 February 2020/)).toHaveFocus() + }) + + it('pressing the page up key from a day navigates to the same day in the previous month', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'PageUp', + }) + expect(getByLabelText(/^20 December 2020/)).toHaveFocus() + }) + + it('pressing the page up key from the last day of a longer month navigates to the last day in the shorter previous month', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^31 December 2019/), { + key: 'PageUp', + }) + expect(getByLabelText(/^30 November 2019/)).toHaveFocus() + }) + + it('pressing the shift + page down keys from a day navigates to the same day in the next year', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'PageDown', + shiftKey: true, + }) + expect(getByLabelText(/^20 January 2022/)).toHaveFocus() + }) + + it('pressing the shift + page down keys from February 29th of a leap year navigates to February 28th in the next year', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^29 February 2020/), { + key: 'PageDown', + shiftKey: true, + }) + expect(getByLabelText(/^28 February 2021/)).toHaveFocus() + }) + + it('pressing the shift + page up keys from a day navigates to the same day in the previous year', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^20 January 2021/), { + key: 'PageUp', + shiftKey: true, + }) + expect(getByLabelText(/^20 January 2020/)).toHaveFocus() + }) + + it('pressing the shift + page up keys from February 29th of a leap year navigates to February 28th in the previous year', () => { + const { getByLabelText } = render( + + ) + + fireEvent.keyDown(getByLabelText(/^29 February 2020/), { + key: 'PageUp', + shiftKey: true, + }) + expect(getByLabelText(/^28 February 2019/)).toHaveFocus() + }) + + it('pressing tab cycles through the focusable elements within the date picker', () => { + const { getByLabelText, getByTestId } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + userEvent.tab() + expect(getByTestId('previous-year')).toHaveFocus() + userEvent.tab() + expect(getByTestId('previous-month')).toHaveFocus() + userEvent.tab() + expect(getByTestId('select-month')).toHaveFocus() + userEvent.tab() + expect(getByTestId('select-year')).toHaveFocus() + userEvent.tab() + expect(getByTestId('next-month')).toHaveFocus() + userEvent.tab() + expect(getByTestId('next-year')).toHaveFocus() + userEvent.tab() + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + }) + + it('pressing tab+shift cycles backwards through the focusable elements within the date picker', () => { + const { getByLabelText, getByTestId } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('next-year')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('next-month')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('select-year')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('select-month')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('previous-month')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('previous-year')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + }) + + it('pressing tab only cycles through elements that are not disabled', () => { + const { getByLabelText, getByTestId } = render( + + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + expect(getByTestId('previous-year')).toBeDisabled() + expect(getByTestId('previous-month')).toBeDisabled() + + userEvent.tab() + expect(getByTestId('select-month')).toHaveFocus() + userEvent.tab() + expect(getByTestId('select-year')).toHaveFocus() + userEvent.tab() + expect(getByTestId('next-month')).toHaveFocus() + userEvent.tab() + expect(getByTestId('next-year')).toHaveFocus() + userEvent.tab() + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + }) + }) + + describe('month selection', () => { + it('clicking the selected month shows month selection', () => { + const { queryByTestId, getByTestId } = render() + userEvent.click(getByTestId('select-month')) + expect(queryByTestId('calendar-date-picker')).not.toBeInTheDocument() + expect(getByTestId('calendar-month-picker')).toBeInTheDocument() + }) + + it('clicking a select month button selects that month and hides month selection', () => { + const { getByTestId, queryByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('select-month')) + userEvent.click(getByText('May')) + expect(getByTestId('select-month')).toHaveTextContent('May') + expect(getByTestId('select-year')).toHaveTextContent('2021') + expect(getByTestId('calendar-date-picker')).toBeInTheDocument() + expect(queryByTestId('calendar-month-picker')).not.toBeInTheDocument() + expect(getByText('20')).toHaveFocus() + }) + }) + + describe('year selection', () => { + it('clicking the selected year shows year selection', () => { + const { queryByTestId, getByTestId } = render() + userEvent.click(getByTestId('select-year')) + expect(queryByTestId('calendar-date-picker')).not.toBeInTheDocument() + expect(getByTestId('calendar-year-picker')).toBeInTheDocument() + }) + + it('clicking a select year button selects that year and hides year selection', () => { + const { getByTestId, queryByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('select-year')) + userEvent.click(getByText('2017')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2017') + expect(getByTestId('calendar-date-picker')).toBeInTheDocument() + expect(queryByTestId('calendar-year-picker')).not.toBeInTheDocument() + expect(getByText('20')).toHaveFocus() + }) + }) +}) diff --git a/src/components/forms/DatePicker/Calendar.tsx b/src/components/forms/DatePicker/Calendar.tsx new file mode 100644 index 0000000000..9faaa1723f --- /dev/null +++ b/src/components/forms/DatePicker/Calendar.tsx @@ -0,0 +1,433 @@ +import React, { useState, useRef, useEffect, KeyboardEvent } from 'react' + +import { + DAY_OF_WEEK_LABELS, + DAY_OF_WEEK_SHORT_LABELS, + MONTH_LABELS, +} from './constants' +import { + today, + addDays, + subMonths, + addMonths, + startOfMonth, + startOfWeek, + isSameDay, + isSameMonth, + isDateWithinMinAndMax, + subYears, + keepDateBetweenMinAndMax, + addYears, + listToTable, + setMonth, + setYear, + min, + max, + subDays, + subWeeks, + addWeeks, + endOfWeek, + handleTabKey, +} from './utils' + +const CalendarModes = { + DATE_PICKER: 'DATE_PICKER', + MONTH_PICKER: 'MONTH_PICKER', + YEAR_PICKER: 'YEAR_PICKER', +} as const + +type CalendarMode = typeof CalendarModes[keyof typeof CalendarModes] + +import { Day } from './Day' +import { MonthPicker } from './MonthPicker' +import { YearPicker } from './YearPicker' +import { FocusMode } from './DatePicker' + +export const Calendar = ({ + date, + selectedDate, + handleSelectDate, + minDate, + maxDate, + rangeDate, + setStatuses, + focusMode, +}: { + date?: Date + selectedDate?: Date + handleSelectDate: (value: string) => void + minDate: Date + maxDate?: Date + rangeDate?: Date + setStatuses: (statuses: string[]) => void + focusMode: FocusMode +}): React.ReactElement => { + const prevYearEl = useRef(null) + const prevMonthEl = useRef(null) + const nextMonthEl = useRef(null) + const nextYearEl = useRef(null) + const selectMonthEl = useRef(null) + const selectYearEl = useRef(null) + const focusedDayEl = useRef(null) + const datePickerEl = useRef(null) + + const [dateToDisplay, setDateToDisplay] = useState(date || today()) + const [mode, setMode] = useState(CalendarModes.DATE_PICKER) + const [nextToFocus, setNextToFocus] = useState< + [HTMLButtonElement | null, HTMLDivElement | null] + >([null, null]) + + let calendarWasHidden = true + + const handleSelectMonth = (monthIndex: number): void => { + let newDate = setMonth(dateToDisplay, monthIndex) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setMode(CalendarModes.DATE_PICKER) + } + + const handleSelectYear = (year: number): void => { + let newDate = setYear(dateToDisplay, year) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setMode(CalendarModes.DATE_PICKER) + } + + const focusedDate = addDays(dateToDisplay, 0) + const focusedMonth = dateToDisplay.getMonth() + const focusedYear = dateToDisplay.getFullYear() + + const monthLabel = MONTH_LABELS[parseInt(`${focusedMonth}`)] + + useEffect(() => { + calendarWasHidden = false + }, []) + + useEffect(() => { + // Update displayed date when input changes (only if viewing date picker - otherwise an effect loop will occur) + if (date && mode === CalendarModes.DATE_PICKER) { + setDateToDisplay(date) + } + }, [date]) + + useEffect(() => { + if (focusMode !== FocusMode.Input) { + const [focusEl, fallbackFocusEl] = nextToFocus + + if (focusEl && fallbackFocusEl) { + if (focusEl.disabled) { + fallbackFocusEl.focus() + } else { + focusEl.focus() + } + setNextToFocus([null, null]) + } else { + // Focus on new date when it changes + const focusedDateEl = + datePickerEl.current && + datePickerEl.current.querySelector( + '.usa-date-picker__calendar__date--focused' + ) + + if (focusedDateEl) { + focusedDateEl.focus() + } + } + } + + if (calendarWasHidden) { + const newStatuses = [`${monthLabel} ${focusedYear}`] + if (selectedDate && isSameDay(focusedDate, selectedDate)) + newStatuses.unshift('Selected date') + setStatuses(newStatuses) + } + }, [dateToDisplay]) + + if (mode === CalendarModes.MONTH_PICKER) { + return ( + + ) + } else if (mode === CalendarModes.YEAR_PICKER) { + return ( + + ) + } + + const prevMonth = subMonths(dateToDisplay, 1) + const nextMonth = addMonths(dateToDisplay, 1) + + const firstOfMonth = startOfMonth(dateToDisplay) + const prevButtonsDisabled = isSameMonth(dateToDisplay, minDate) + const nextButtonsDisabled = maxDate && isSameMonth(dateToDisplay, maxDate) + + const rangeConclusionDate = selectedDate || dateToDisplay + const rangeStartDate = rangeDate && min(rangeConclusionDate, rangeDate) + const rangeEndDate = rangeDate && max(rangeConclusionDate, rangeDate) + + const withinRangeStartDate = rangeStartDate && addDays(rangeStartDate, 1) + const withinRangeEndDate = rangeEndDate && subDays(rangeEndDate, 1) + + const handleDatePickerTab = (event: KeyboardEvent): void => { + handleTabKey(event, [ + prevYearEl?.current, + prevMonthEl?.current, + selectMonthEl?.current, + selectYearEl?.current, + nextMonthEl?.current, + nextYearEl?.current, + focusedDayEl?.current, + ]) + } + + const handleKeyDownFromDay = (event: KeyboardEvent): void => { + let newDisplayDate + switch (event.key) { + case 'ArrowUp': + case 'Up': + newDisplayDate = subWeeks(dateToDisplay, 1) + break + case 'ArrowDown': + case 'Down': + newDisplayDate = addWeeks(dateToDisplay, 1) + break + case 'ArrowLeft': + case 'Left': + newDisplayDate = subDays(dateToDisplay, 1) + break + case 'ArrowRight': + case 'Right': + newDisplayDate = addDays(dateToDisplay, 1) + break + case 'Home': + newDisplayDate = startOfWeek(dateToDisplay) + break + case 'End': + newDisplayDate = endOfWeek(dateToDisplay) + break + case 'PageDown': + if (event.shiftKey) { + newDisplayDate = addYears(dateToDisplay, 1) + } else { + newDisplayDate = addMonths(dateToDisplay, 1) + } + break + case 'PageUp': + if (event.shiftKey) { + newDisplayDate = subYears(dateToDisplay, 1) + } else { + newDisplayDate = subMonths(dateToDisplay, 1) + } + break + default: + return + } + + if (newDisplayDate !== undefined) { + const cappedDate = keepDateBetweenMinAndMax( + newDisplayDate, + minDate, + maxDate + ) + if (!isSameDay(dateToDisplay, cappedDate)) { + setDateToDisplay(newDisplayDate) + } + } + + event.preventDefault() + } + + const handleMouseMoveFromDay = (hoverDate: Date): void => { + if (hoverDate === dateToDisplay) return + setDateToDisplay(hoverDate) + } + + const handlePreviousYearClick = (): void => { + let newDate = subYears(dateToDisplay, 1) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setNextToFocus([prevYearEl.current, datePickerEl.current]) + } + + const handlePreviousMonthClick = (): void => { + let newDate = subMonths(dateToDisplay, 1) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setNextToFocus([prevMonthEl.current, datePickerEl.current]) + } + + const handleNextMonthClick = (): void => { + let newDate = addMonths(dateToDisplay, 1) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setNextToFocus([nextMonthEl.current, datePickerEl.current]) + } + + const handleNextYearClick = (): void => { + let newDate = addYears(dateToDisplay, 1) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setDateToDisplay(newDate) + setNextToFocus([nextYearEl.current, datePickerEl.current]) + } + + const handleToggleMonthSelection = (): void => { + setMode(CalendarModes.MONTH_PICKER) + setStatuses(['Select a month.']) + } + + const handleToggleYearSelection = (): void => { + setMode(CalendarModes.YEAR_PICKER) + } + + const days = [] + + let dateIterator = startOfWeek(firstOfMonth) + while ( + days.length < 28 || + dateIterator.getMonth() === focusedMonth || + days.length % 7 !== 0 + ) { + const isFocused = isSameDay(dateIterator, focusedDate) + + days.push( + + ) + dateIterator = addDays(dateIterator, 1) + } + + return ( + // Ignoring error: "Static HTML elements with event handlers require a role." + // Ignoring because this element does not have a role in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1042) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + {DAY_OF_WEEK_SHORT_LABELS.map((d, i) => ( + + ))} + + + {listToTable(days, 7)} +
+ {d} +
+
+ ) +} + +Calendar.displayName = 'Calendar' diff --git a/src/components/forms/DatePicker/DatePicker.stories.tsx b/src/components/forms/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000000..9738a330f7 --- /dev/null +++ b/src/components/forms/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,110 @@ +import React from 'react' + +import { DatePicker } from './DatePicker' +import { Form } from '../Form/Form' +import { FormGroup } from '../FormGroup/FormGroup' +import { Label } from '../Label/Label' +import { TextInput } from '../TextInput/TextInput' + +export default { + title: 'Components/Form controls/Date picker', + component: DatePicker, + argTypes: { + onSubmit: { action: 'submitted' }, + disabled: { control: { type: 'boolean' } }, + }, + parameters: { + docs: { + description: { + component: ` +### USWDS 2.0 DatePicker component +Source: https://designsystem.digital.gov/components/form-controls/#DatePicker + +**Note:** There is one small difference in functionality between this component and the USWDS implementation, related to validating the input. The USWDS implementation validates when: +- setting the initial value based on the default value passed in +- clicking on a date in the calendar UI +- typing the Enter key in the external text input +- on focusout (blur) of the external text input + +Because this component uses the useEffect hook to trigger validation whenever the date value changes (regardless of how), the React DatePicker will validate when: +- setting the initial value based on the default value passed in (same as above) +- clicking on a date in the calendar UI (same as above) +- on input (change) of the external text input + +It's also worth mentioning that validation in this case is just calling [setCustomValidity](https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity) on the external text input, and library users should be able to determine how & when they want invalid UI to display by inspecting the [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) of the external input. + +We may find that we want to expose props for custom event handlers or even a ref to the component for better integration with 3rd party form libraries. If you are running into this, please [file an issue](https://github.com/trussworks/react-uswds/issues/new/choose) describing your use case. +`, + }, + }, + }, +} + +export const completeDatePicker = (argTypes): React.ReactElement => ( +
+ + +
+ mm/dd/yyyy +
+ + + +
+
+) + +export const defaultDatePicker = (): React.ReactElement => ( + +) + +export const disabled = (): React.ReactElement => ( + +) + +export const withDefaultValue = (): React.ReactElement => ( + +) + +export const withDefaultInvalidValue = (): React.ReactElement => ( + +) + +export const withMinMaxInSameMonth = (): React.ReactElement => ( + +) + +export const withMinMax = (): React.ReactElement => ( + +) + +export const withRangeDate = (): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/DatePicker.test.tsx b/src/components/forms/DatePicker/DatePicker.test.tsx new file mode 100644 index 0000000000..e8143f6f4e --- /dev/null +++ b/src/components/forms/DatePicker/DatePicker.test.tsx @@ -0,0 +1,641 @@ +import React from 'react' +import { render, fireEvent, createEvent } from '@testing-library/react' +import userEvent, { specialChars } from '@testing-library/user-event' + +import { DatePicker } from './DatePicker' +import { today } from './utils' +import { + DAY_OF_WEEK_LABELS, + MONTH_LABELS, + VALIDATION_MESSAGE, +} from './constants' + +describe('DatePicker component', () => { + const testProps = { + id: 'birthdate', + name: 'birthdate', + } + + it('renders witout errors', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker')).toBeInTheDocument() + }) + + it('renders a hidden "internal" input with the name prop', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-internal-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'type', + 'text' + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + expect(getByTestId('date-picker-internal-input')).toHaveAttribute( + 'name', + testProps.name + ) + }) + + it('renders a visible "external" input with the id prop', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-external-input')).toBeInstanceOf( + HTMLInputElement + ) + expect(getByTestId('date-picker-external-input')).toHaveAttribute( + 'type', + 'text' + ) + expect(getByTestId('date-picker-external-input')).toBeVisible() + expect(getByTestId('date-picker-external-input')).toHaveAttribute( + 'id', + testProps.id + ) + }) + + it('renders a toggle button', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-button')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('date-picker-button')).toHaveAttribute( + 'aria-label', + 'Toggle calendar' + ) + }) + + it('renders a hidden calendar dialog element', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-calendar')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('date-picker-calendar')).toHaveAttribute( + 'role', + 'dialog' + ) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + }) + + it('renders a screen reader status element', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-status')).toBeInstanceOf(HTMLDivElement) + expect(getByTestId('date-picker-status')).toHaveAttribute('role', 'status') + expect(getByTestId('date-picker-status')).toHaveTextContent('') + }) + + // https://github.com/uswds/uswds/blob/develop/spec/unit/date-picker/date-picker.spec.js#L933 + it('prevents default action if keyup doesn’t originate within the calendar', () => { + const { getByTestId } = render( + + ) + + const calendarEl = getByTestId('date-picker-calendar') + userEvent.click(getByTestId('date-picker-button')) + expect(calendarEl).toBeVisible() + const keyUpEvent = createEvent.keyUp(calendarEl, { + key: 'Enter', + bubbles: true, + keyCode: 13, + }) + const preventDefaultSpy = jest.spyOn(keyUpEvent, 'preventDefault') + fireEvent(calendarEl, keyUpEvent) + expect(preventDefaultSpy).toHaveBeenCalled() + }) + + describe('toggling the calendar', () => { + it('the calendar is hidden on mount', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + }) + + it('shows the calendar when the toggle button is clicked and focuses on the selected date', () => { + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + expect(getByText('20')).toHaveFocus() + expect(getByText('20')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + }) + + it('hides the calendar when the escape key is pressed', () => { + const { getByTestId } = render() + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + fireEvent.keyDown(getByTestId('date-picker'), { key: 'Escape' }) + + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + expect(getByTestId('date-picker-external-input')).toHaveFocus() + }) + + it('hides the calendar when the toggle button is clicked a second time', () => { + const { getByTestId } = render() + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent('') + }) + + it('focus defaults to today if there is no value', () => { + const todayDate = today() + const todayLabel = `${todayDate.getDate()} ${ + MONTH_LABELS[todayDate.getMonth()] + } ${todayDate.getFullYear()} ${DAY_OF_WEEK_LABELS[todayDate.getDay()]}` + + const { getByTestId, getByLabelText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByLabelText(todayLabel)).toHaveFocus() + }) + + it('adds Selected date to the status text if the selected date and the focused date are the same', () => { + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + expect(getByText('20')).toHaveFocus() + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + }) + + it('coerces the display date to a valid value', () => { + const { getByTestId, getByLabelText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByLabelText('6 January 2021 Wednesday')).not.toHaveFocus() + expect(getByLabelText('10 January 2021 Sunday')).toHaveFocus() + }) + + it('hides the calendar if focus moves to another element', () => { + const mockOnBlur = jest.fn() + const { getByTestId } = render( + <> + + + + ) + + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + getByTestId('test-external-element').focus() + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(mockOnBlur).toHaveBeenCalled() + }) + }) + + describe('status text', () => { + it('shows instructions in the status text when the calendar is opened', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + }) + + it('removes instructions from the status text when the calendar is already open and the displayed date changes', () => { + const { getByTestId, getByLabelText } = render( + + ) + + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'January 2021' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'January 2021' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'You can navigate by day using left and right arrows' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Weeks by using up and down arrows' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Months by using page up and page down keys' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Years by using shift plus page up and shift plus page down' + ) + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Home and end keys navigate to the beginning and end of a week' + ) + }) + + it('does not add Selected date to the status text if the selected date and the focused date are not the same', () => { + const { getByTestId, getByLabelText } = render( + + ) + + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + expect(getByTestId('date-picker')).toHaveClass('usa-date-picker--active') + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + + fireEvent.mouseMove(getByLabelText(/^13 January 2021/)) + + expect(getByLabelText(/^13 January 2021/)).toHaveFocus() + expect(getByTestId('date-picker-status')).not.toHaveTextContent( + 'Selected date' + ) + + fireEvent.mouseMove(getByLabelText(/^20 January 2021/)) + + expect(getByLabelText(/^20 January 2021/)).toHaveFocus() + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Selected date' + ) + }) + }) + + describe('with the required prop', () => { + it('the external input is required, and the internal input is not required', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-external-input')).toBeRequired() + expect(getByTestId('date-picker-internal-input')).not.toBeRequired() + }) + }) + + describe('with the disabled prop', () => { + it('the toggle button and external inputs are disabled, and the internal input is not disabled', () => { + const { getByTestId } = render() + expect(getByTestId('date-picker-button')).toBeDisabled() + expect(getByTestId('date-picker-external-input')).toBeDisabled() + expect(getByTestId('date-picker-internal-input')).not.toBeDisabled() + }) + + it('does not show the calendar when the toggle button is clicked', () => { + const { getByTestId } = render() + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker')).not.toHaveClass( + 'usa-date-picker--active' + ) + }) + }) + + describe('with a default value prop', () => { + it('the internal input value is the date string, and the external input value is the formatted date', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('date-picker-external-input')).toHaveValue( + '05/16/1988' + ) + expect(getByTestId('date-picker-internal-input')).toHaveValue( + '1988-05-16' + ) + }) + + it('validates a valid default value', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('date-picker-external-input')).toBeValid() + }) + + it('validates an invalid default value', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('date-picker-external-input')).toBeInvalid() + }) + }) + + describe('selecting a date', () => { + it('clicking a date button selects that date and closes the calendar and focuses the external input', () => { + const mockOnChange = jest.fn() + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + const dateButton = getByText('15') + expect(dateButton).toHaveClass('usa-date-picker__calendar__date') + userEvent.click(dateButton) + expect(getByTestId('date-picker-external-input')).toHaveValue( + '01/15/2021' + ) + expect(getByTestId('date-picker-internal-input')).toHaveValue( + '2021-01-15' + ) + expect(getByTestId('date-picker-calendar')).not.toBeVisible() + expect(getByTestId('date-picker-external-input')).toHaveFocus() + expect(mockOnChange).toHaveBeenCalledWith('01/15/2021') + }) + + it('selecting a date and opening the calendar focuses on the selected date', () => { + const { getByTestId, getByText } = render() + + // open calendar + userEvent.click(getByTestId('date-picker-button')) + + // select date + const dateButton = getByText('12') + userEvent.click(dateButton) + + // open calendar again + userEvent.click(getByTestId('date-picker-button')) + expect(getByText('12')).toHaveFocus() + expect(getByText('12')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + }) + }) + + describe('typing in a date', () => { + it('typing a date in the external input updates the selected date', () => { + const mockOnChange = jest.fn() + const { getByTestId, getByText } = render( + + ) + userEvent.type(getByTestId('date-picker-external-input'), '05/16/1988') + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('May') + expect(getByTestId('select-year')).toHaveTextContent('1988') + expect(getByText('16')).toHaveFocus() + expect(getByText('16')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + expect(mockOnChange).toHaveBeenCalledWith('05/16/1988') + }) + + it('typing a date with a 2-digit year in the external input focuses that year in the current century', () => { + const { getByTestId, getByLabelText } = render( + + ) + userEvent.type(getByTestId('date-picker-external-input'), '2/29/20') + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('February') + expect(getByTestId('select-year')).toHaveTextContent('2020') + expect(getByLabelText(/^29 February 2020/)).toHaveFocus() + }) + + it('typing a date with the calendar open updates the calendar to the entered date', () => { + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('select-month')).toHaveTextContent('January') + expect(getByTestId('select-year')).toHaveTextContent('2021') + userEvent.clear(getByTestId('date-picker-external-input')) + userEvent.type(getByTestId('date-picker-external-input'), '05/16/1988') + expect(getByTestId('select-month')).toHaveTextContent('May') + expect(getByTestId('select-year')).toHaveTextContent('1988') + expect(getByText('16')).toHaveClass( + 'usa-date-picker__calendar__date--selected' + ) + }) + + it('implements a custom onBlur handler', () => { + const mockOnBlur = jest.fn() + const { getByTestId } = render( + + ) + + userEvent.type(getByTestId('date-picker-external-input'), '05/16/1988') + getByTestId('date-picker-external-input').blur() + expect(mockOnBlur).toHaveBeenCalled() + }) + + // TODO - this is an outstanding difference in behavior from USWDS. Fails because validation happens onChange. + it.skip('typing in the external input does not validate until blurring', () => { + const { getByTestId } = render( + + ) + + const externalInput = getByTestId('date-picker-external-input') + expect(externalInput).toBeValid() + userEvent.type(externalInput, '05/16/1988') + expect(externalInput).toBeValid() + externalInput.blur() + expect(externalInput).toBeInvalid() + }) + + // TODO - this can be implemented if the above test case is implemented + it.skip('pressing the Enter key in the external input validates the date', () => { + const { getByTestId } = render( + + ) + + const externalInput = getByTestId('date-picker-external-input') + expect(externalInput).toBeValid() + userEvent.type(externalInput, '05/16/1988') + expect(externalInput).toBeValid() + userEvent.type(externalInput, specialChars.enter) + expect(externalInput).toBeInvalid() + }) + }) + + describe('validation', () => { + it('entering an empty value is valid', () => { + const { getByTestId } = render() + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + userEvent.type(externalInput, '') + externalInput.blur() + expect(externalInput).toBeValid() + expect(externalInput.validationMessage).toEqual('') + }) + + it('entering a non-date value sets a validation message', () => { + const mockOnChange = jest.fn() + const { getByTestId } = render( + + ) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + userEvent.type(externalInput, 'abcdefg... That means the convo is done') + expect(mockOnChange).toHaveBeenCalledWith( + 'abcdefg... That means the convo is done' + ) + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + }) + + it('entering a non-date value sets a validation message', () => { + const mockOnChange = jest.fn() + const { getByTestId } = render( + + ) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + userEvent.type(externalInput, 'ab/cd/efg') + expect(mockOnChange).toHaveBeenCalledWith('ab/cd/efg') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + }) + + it('entering an invalid date sets a validation message and becomes valid when selecting a date in the calendar', () => { + const mockOnChange = jest.fn() + const { getByTestId, getByLabelText } = render( + + ) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + userEvent.type(externalInput, '2/31/2019') + expect(mockOnChange).toHaveBeenCalledWith('2/31/2019') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + userEvent.click(getByTestId('date-picker-button')) + expect(getByTestId('date-picker-calendar')).toBeVisible() + userEvent.click(getByLabelText(/^10 February 2019/)) + expect(mockOnChange).toHaveBeenCalledWith('02/10/2019') + + expect(externalInput).toBeValid() + expect(externalInput.validationMessage).toEqual('') + }) + + it('entering a valid date outside of the min/max date sets a validation message', () => { + const mockOnChange = jest.fn() + const { getByTestId } = render( + + ) + const externalInput = getByTestId( + 'date-picker-external-input' + ) as HTMLInputElement + userEvent.type(externalInput, '05/16/1988') + expect(mockOnChange).toHaveBeenCalledWith('05/16/1988') + + expect(externalInput).toBeInvalid() + expect(externalInput.validationMessage).toEqual(VALIDATION_MESSAGE) + }) + }) + + describe('month selection', () => { + it('clicking the selected month updates the status text', () => { + const { getByTestId } = render() + userEvent.click(getByTestId('date-picker-button')) + userEvent.click(getByTestId('select-month')) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Select a month' + ) + }) + }) + + describe('year selection', () => { + it('clicking the selected year updates the status text', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + userEvent.click(getByTestId('select-year')) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2016 to 2027. Select a year.' + ) + }) + + it('clicking previous year chunk updates the status text', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + userEvent.click(getByTestId('select-year')) + userEvent.click(getByTestId('previous-year-chunk')) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2004 to 2015. Select a year.' + ) + }) + + it('clicking next year chunk navigates the year picker forward one chunk', () => { + const { getByTestId } = render( + + ) + userEvent.click(getByTestId('date-picker-button')) + userEvent.click(getByTestId('select-year')) + userEvent.click(getByTestId('next-year-chunk')) + expect(getByTestId('date-picker-status')).toHaveTextContent( + 'Showing years 2028 to 2039. Select a year.' + ) + }) + }) +}) diff --git a/src/components/forms/DatePicker/DatePicker.tsx b/src/components/forms/DatePicker/DatePicker.tsx new file mode 100644 index 0000000000..7df9ec4c4e --- /dev/null +++ b/src/components/forms/DatePicker/DatePicker.tsx @@ -0,0 +1,340 @@ +import React, { + useState, + useEffect, + useRef, + FocusEvent, + FormEvent, + KeyboardEvent, +} from 'react' +import classnames from 'classnames' + +import { + DEFAULT_EXTERNAL_DATE_FORMAT, + VALIDATION_MESSAGE, + DEFAULT_MIN_DATE, +} from './constants' +import { + formatDate, + parseDateString, + isDateInvalid, + today, + keepDateBetweenMinAndMax, + isSameDay, + addDays, +} from './utils' +import { Calendar } from './Calendar' + +interface DatePickerProps { + id: string + name: string + className?: string + disabled?: boolean + required?: boolean + defaultValue?: string + minDate: string + maxDate?: string + rangeDate?: string + onChange?: (val?: string) => void + onBlur?: ( + event: React.FocusEvent | React.FocusEvent + ) => void +} + +export enum FocusMode { + None, + Input, +} + +export const DatePicker = ( + props: DatePickerProps & JSX.IntrinsicElements['input'] +): React.ReactElement => { + const { + id, + name, + defaultValue, + disabled, + required, + minDate = DEFAULT_MIN_DATE, + maxDate, + rangeDate, + onChange, + onBlur, + ...inputProps + } = props + + const datePickerEl = useRef(null) + const externalInputEl = useRef(null) + + const [internalValue, setInternalValue] = useState('') + const [externalValue, setExternalValue] = useState('') + const [showCalendar, setShowCalendar] = useState(false) + const [calendarDisplayValue, setCalendarDisplayValue] = useState< + Date | undefined + >(undefined) + const [calendarPosY, setCalendarPosY] = useState(0) + const [statuses, setStatuses] = useState([]) + const [focusMode, setFocusMode] = useState(FocusMode.None) + const [keydownKeyCode, setKeydownKeyCode] = useState( + undefined + ) + + const parsedMinDate = parseDateString(minDate) as Date + const parsedMaxDate = maxDate ? parseDateString(maxDate) : undefined + const parsedRangeDate = rangeDate ? parseDateString(rangeDate) : undefined + + const validateInput = (): void => { + const isInvalid = isDateInvalid(externalValue, parsedMinDate, parsedMaxDate) + + if (isInvalid && !externalInputEl?.current?.validationMessage) { + externalInputEl?.current?.setCustomValidity(VALIDATION_MESSAGE) + } + + if ( + !isInvalid && + externalInputEl?.current?.validationMessage === VALIDATION_MESSAGE + ) { + externalInputEl?.current?.setCustomValidity('') + } + } + + const handleSelectDate = (dateString: string, closeCalendar = true): void => { + const parsedValue = parseDateString(dateString) + const formattedValue = + parsedValue && formatDate(parsedValue, DEFAULT_EXTERNAL_DATE_FORMAT) + + if (parsedValue) setInternalValue(dateString) + if (formattedValue) setExternalValue(formattedValue) + if (onChange) onChange(formattedValue) + + if (closeCalendar) { + setShowCalendar(false) + setStatuses([]) + externalInputEl?.current?.focus() + } + } + + const handleExternalInput = (event: FormEvent): void => { + // Keep external & internal input values in sync + const value = (event.target as HTMLInputElement).value + setExternalValue(value) + if (onChange) onChange(value) + + const inputDate = parseDateString(value, DEFAULT_EXTERNAL_DATE_FORMAT, true) + let newValue = '' + if (inputDate && !isDateInvalid(value, parsedMinDate, parsedMaxDate)) { + newValue = formatDate(inputDate) + } + + if (internalValue !== newValue) { + setInternalValue(newValue) + } + + if (inputDate && showCalendar) { + const newCalendarDate = keepDateBetweenMinAndMax( + inputDate, + parsedMinDate, + parsedMaxDate + ) + setCalendarDisplayValue(newCalendarDate) + } + } + + useEffect(() => { + if (defaultValue) { + handleSelectDate(defaultValue, false) + } + }, []) + + useEffect(() => { + // focus on selected date when open + if (showCalendar) { + const focusedDate = + datePickerEl.current && + datePickerEl.current.querySelector( + '.usa-date-picker__calendar__date--focused' + ) + + if (focusedDate) { + focusedDate.focus() + } + } + }, [showCalendar]) + + useEffect(() => { + validateInput() + }, [externalValue, minDate, maxDate]) + + const handleToggleClick = (): void => { + if (showCalendar) { + // calendar is open, hide it + setStatuses([]) + } else { + // calendar is closed, show it + const inputDate = parseDateString( + externalValue, + DEFAULT_EXTERNAL_DATE_FORMAT, + true + ) + + const displayDate = keepDateBetweenMinAndMax( + inputDate || (defaultValue && parseDateString(defaultValue)) || today(), + parsedMinDate, + parsedMaxDate + ) + + setCalendarDisplayValue(displayDate) + setCalendarPosY(datePickerEl?.current?.offsetHeight) + + const statuses = [ + 'You can navigate by day using left and right arrows', + 'Weeks by using up and down arrows', + 'Months by using page up and page down keys', + 'Years by using shift plus page up and shift plus page down', + 'Home and end keys navigate to the beginning and end of a week', + ] + + const selectedDate = parseDateString(internalValue) + if (selectedDate && isSameDay(selectedDate, addDays(displayDate, 0))) { + statuses.unshift('Selected date') + } + + setStatuses(statuses) + } + + setShowCalendar(!showCalendar) + } + + // This is why the DatePicker requires React 17 + const handleFocusOut = (event: FocusEvent): void => { + if (!datePickerEl.current?.contains(event?.relatedTarget as Element)) { + if (showCalendar) { + setShowCalendar(false) + setStatuses([]) + } + + if (onBlur) onBlur(event) + } + } + + const handleEscapeKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + if (showCalendar) { + setShowCalendar(false) + setStatuses([]) + externalInputEl?.current?.focus() + } + event.preventDefault() + } + } + + const handleCalendarKeydown = (event: KeyboardEvent): void => { + setKeydownKeyCode(event.keyCode) + } + + const handleCalendarKeyup = (event: KeyboardEvent): void => { + if (event.keyCode !== keydownKeyCode) event.preventDefault() + } + + const datePickerClasses = classnames( + 'usa-date-picker', + 'usa-date-picker--initialized', + { + 'usa-date-picker--active': showCalendar, + } + ) + + return ( + // Ignoring error: "Static HTML elements with event handlers require a role." + // Ignoring because this element does not have a role in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L828) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
+ { + setFocusMode(FocusMode.Input) + }} + onBlur={(e): void => { + setFocusMode(FocusMode.None) + onBlur && onBlur(e) + }} + /> + + {/* Ignoring error: "Non-interactive elements should not be assigned mouse or keyboard event listeners." */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + +
+ {statuses.join('. ')} +
+
+
+ ) +} + +DatePicker.displayName = 'DatePicker' + +DatePicker.defaultProps = { + minDate: DEFAULT_MIN_DATE, +} diff --git a/src/components/forms/DatePicker/Day.stories.tsx b/src/components/forms/DatePicker/Day.stories.tsx new file mode 100644 index 0000000000..0eba9f6cb3 --- /dev/null +++ b/src/components/forms/DatePicker/Day.stories.tsx @@ -0,0 +1,133 @@ +import React from 'react' + +import { Day } from './Day' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY +export default { + title: 'Components/Form controls/Date picker/Day', + component: Day, + argTypes: { + onClick: { action: 'on click' }, + onKeyDown: { action: 'on keydown' }, + onMouseMove: { action: 'on mouse move' }, + }, +} +*/ + +const testDate = new Date('January 20 2021') + +const defaultProps = { + date: testDate, +} + +export const defaultDay = (argTypes): React.ReactElement => ( + +) + +export const disabled = (argTypes): React.ReactElement => ( + +) +export const selected = (argTypes): React.ReactElement => ( + +) +export const focused = (argTypes): React.ReactElement => ( + +) +export const previousMonth = (argTypes): React.ReactElement => ( + +) +export const sameMonth = (argTypes): React.ReactElement => ( + +) +export const nextMonth = (argTypes): React.ReactElement => ( + +) +export const today = (argTypes): React.ReactElement => ( + +) + +export const isRangeDate = (argTypes): React.ReactElement => ( + +) + +export const isRangeStart = (argTypes): React.ReactElement => ( + +) +export const isRangeEnd = (argTypes): React.ReactElement => ( + +) +export const isWithinRange = (argTypes): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/Day.test.tsx b/src/components/forms/DatePicker/Day.test.tsx new file mode 100644 index 0000000000..0574c6c4e8 --- /dev/null +++ b/src/components/forms/DatePicker/Day.test.tsx @@ -0,0 +1,176 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Day } from './Day' + +describe('Day', () => { + const testProps = { + date: new Date('January 20 2021'), + onClick: jest.fn(), + onKeyDown: jest.fn(), + onMouseMove: jest.fn(), + } + + it('renders a date selection button', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveClass('usa-date-picker__calendar__date') + expect(button).toHaveAttribute('data-day', '20') + expect(button).toHaveAttribute('data-month', '1') + expect(button).toHaveAttribute('data-year', '2021') + expect(button).toHaveAttribute('data-value', '2021-01-20') + expect(button).toHaveAttribute('aria-label', '20 January 2021 Wednesday') + expect(button).toHaveTextContent('20') + }) + + it('defaults to not disabled', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).not.toHaveAttribute('disabled') + }) + + it('defaults to not focused', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('tabIndex', '-1') + }) + + it('defaults to not selected', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('aria-selected', 'false') + }) + + it('can be clicked to select the date', () => { + const mockSelectDate = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + userEvent.click(button) + expect(mockSelectDate).toHaveBeenCalledWith('2021-01-20') + }) + + it('implements the onKeyDown handler', () => { + const mockKeyDown = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + userEvent.click(button) + fireEvent.keyDown(button) + expect(mockKeyDown).toHaveBeenCalled() + }) + + describe('when isFocused is true', () => { + it('is focused', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('tabIndex', '0') + expect(button).toHaveClass('usa-date-picker__calendar__date--focused') + }) + }) + + describe('when isSelected is true', () => { + it('is selected', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('aria-selected', 'true') + expect(button).toHaveClass('usa-date-picker__calendar__date--selected') + }) + }) + + describe('when isDisabled is true', () => { + it('is disabled', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveAttribute('disabled') + }) + + it('cannot be clicked to select the date', () => { + const mockSelectDate = jest.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('select-date') + userEvent.click(button) + expect(mockSelectDate).not.toHaveBeenCalled() + }) + }) + + describe('when in the previous month', () => { + it('has the previous month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--previous-month' + ) + }) + }) + + describe('when in the next month', () => { + it('has the next month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--next-month') + }) + }) + + describe('when in the current month', () => { + it('has the current month class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--current-month' + ) + }) + }) + + describe('when is today’s date', () => { + it('has the today class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--today') + }) + }) + + describe('when is the range date', () => { + it('has the range class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass('usa-date-picker__calendar__date--range-date') + }) + }) + + describe('when is the range start date', () => { + it('has the range start class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--range-date-start' + ) + }) + }) + + describe('when is the range end date', () => { + it('has the range end class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--range-date-end' + ) + }) + }) + + describe('when is within the range', () => { + it('has the within range class', () => { + const { getByTestId } = render() + const button = getByTestId('select-date') + expect(button).toHaveClass( + 'usa-date-picker__calendar__date--within-range' + ) + }) + }) +}) diff --git a/src/components/forms/DatePicker/Day.tsx b/src/components/forms/DatePicker/Day.tsx new file mode 100644 index 0000000000..50bb7f3e29 --- /dev/null +++ b/src/components/forms/DatePicker/Day.tsx @@ -0,0 +1,107 @@ +import React, { forwardRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { DAY_OF_WEEK_LABELS, MONTH_LABELS } from './constants' +import { formatDate, isIosDevice } from './utils' + +export const Day = forwardRef( + ( + { + date, + onClick, + onKeyDown, + onMouseMove, + isDisabled = false, + isSelected = false, + isFocused = false, + isPrevMonth = false, + isFocusedMonth = false, + isNextMonth = false, + isToday = false, + isRangeDate = false, + isRangeStart = false, + isRangeEnd = false, + isWithinRange = false, + }: { + date: Date + onClick: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onMouseMove: (hoverDate: Date) => void + isDisabled?: boolean + isSelected?: boolean + isFocused?: boolean + isPrevMonth?: boolean + isFocusedMonth?: boolean + isNextMonth?: boolean + isToday?: boolean + isRangeDate?: boolean + isRangeStart?: boolean + isRangeEnd?: boolean + isWithinRange?: boolean + }, + ref: React.ForwardedRef + ): React.ReactElement => { + const day = date.getDate() + const month = date.getMonth() + const year = date.getFullYear() + const dayOfWeek = date.getDay() + + const formattedDate = formatDate(date) + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__date', { + 'usa-date-picker__calendar__date--previous-month': isPrevMonth, + 'usa-date-picker__calendar__date--current-month': isFocusedMonth, + 'usa-date-picker__calendar__date--next-month': isNextMonth, + 'usa-date-picker__calendar__date--selected': isSelected, + 'usa-date-picker__calendar__date--today': isToday, + 'usa-date-picker__calendar__date--focused': isFocused, + 'usa-date-picker__calendar__date--range-date': isRangeDate, + 'usa-date-picker__calendar__date--range-date-start': isRangeStart, + 'usa-date-picker__calendar__date--range-date-end': isRangeEnd, + 'usa-date-picker__calendar__date--within-range': isWithinRange, + }) + + const monthStr = MONTH_LABELS[parseInt(`${month}`)] + const dayStr = DAY_OF_WEEK_LABELS[parseInt(`${dayOfWeek}`)] + + const handleClick = (): void => { + onClick(formattedDate) + } + + const handleKeyDown = (e: KeyboardEvent): void => { + onKeyDown(e) + } + + const handleMouseMove = (): void => { + if (isDisabled || isIosDevice()) return + onMouseMove(date) + } + + return ( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1017) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) + } +) + +Day.displayName = 'Day' diff --git a/src/components/forms/DatePicker/MonthPicker.stories.tsx b/src/components/forms/DatePicker/MonthPicker.stories.tsx new file mode 100644 index 0000000000..7208a1a38a --- /dev/null +++ b/src/components/forms/DatePicker/MonthPicker.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +import { MonthPicker } from './MonthPicker' +import { parseDateString } from './utils' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY + +export default { + title: 'Components/Form controls/Date picker/Month picker', + component: MonthPicker, + argTypes: { handleSelectMonth: { action: 'handle select month' } }, +} +*/ + +const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, +} + +export const monthPicker = (argTypes): React.ReactElement => ( + +) + +export const withMinAndMax = (argTypes): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/MonthPicker.test.tsx b/src/components/forms/DatePicker/MonthPicker.test.tsx new file mode 100644 index 0000000000..44d1b1e3df --- /dev/null +++ b/src/components/forms/DatePicker/MonthPicker.test.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { MonthPicker } from './MonthPicker' +import { MONTH_LABELS } from './constants' +import { parseDateString } from './utils' + +describe('MonthPicker', () => { + const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, + handleSelectMonth: jest.fn(), + } + + it('renders a button for each month', () => { + const { getByText } = render() + + MONTH_LABELS.forEach((month, index) => { + const button = getByText(month) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${index}`) + expect(button).toHaveAttribute('data-label', month) + }) + }) + + it('each button implements an onClick handler to select the month', () => { + const mockSelectMonth = jest.fn() + const { getByText } = render( + + ) + + MONTH_LABELS.forEach((month, index) => { + const button = getByText(month) + expect(button).toBeInstanceOf(HTMLButtonElement) + userEvent.click(button) + expect(mockSelectMonth).toHaveBeenCalledWith(index) + }) + }) + + it('the currently displayed month has the selected class', () => { + const { getByText } = render() + const button = getByText('January') + expect(button).toHaveClass('usa-date-picker__calendar__month--selected') + expect(button).toHaveAttribute('aria-selected', 'true') + }) + + it('focus defaults to the currently displayed month', () => { + const { getByText } = render() + const button = getByText('January') + expect(button).toHaveClass('usa-date-picker__calendar__month--focused') + expect(button).toHaveFocus() + expect(button).toHaveAttribute('tabIndex', '0') + }) + + it('disables month buttons that are outside the min and max dates', () => { + const { getByText } = render( + + ) + + MONTH_LABELS.forEach((month, index) => { + if (index < 3 || index > 7) { + expect(getByText(month)).toBeDisabled() + } else { + expect(getByText(month)).not.toBeDisabled() + } + }) + }) + + describe('focusing on hover', () => { + it('focuses on a month when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + fireEvent.mouseMove(getByText('March')) + expect(getByText('March')).toHaveFocus() + }) + + it('does not focus on a disabled month when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + expect(getByText('May')).toBeDisabled() + fireEvent.mouseMove(getByText('May')) + expect(getByText('May')).not.toHaveFocus() + }) + + it('does not focus on a month when hovered over if on an iOS device', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'iPhone') + + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + fireEvent.mouseMove(getByText('March')) + expect(getByText('March')).not.toHaveFocus() + jest.restoreAllMocks() + }) + }) + + describe('keyboard navigation', () => { + it('pressing the up arrow key from a month navigates to 3 months before', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowUp', + }) + expect(getByText('February')).toHaveFocus() + }) + + it('pressing the down arrow key from a month navigates to 3 months later', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowDown', + }) + expect(getByText('August')).toHaveFocus() + }) + + it('pressing the left arrow key from a month navigates to the previous month', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowLeft', + }) + expect(getByText('April')).toHaveFocus() + }) + + it('pressing the right arrow key from a month navigates to the next month', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'ArrowRight', + }) + expect(getByText('June')).toHaveFocus() + }) + + it('pressing the home key from a month navigates to the first month of the selected row', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'Home', + }) + expect(getByText('April')).toHaveFocus() + }) + + it('pressing the end key from a month navigates to the last month of the selected row', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'End', + }) + expect(getByText('June')).toHaveFocus() + }) + + it('pressing the page down key from a month navigates to December', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'PageDown', + }) + expect(getByText('December')).toHaveFocus() + }) + + it('pressing the page up key from a month navigates to January', () => { + const { getByText } = render( + + ) + + fireEvent.keyDown(getByText('May'), { + key: 'PageUp', + }) + expect(getByText('January')).toHaveFocus() + }) + + it('pressing tab cycles through the focusable elements within the month picker', () => { + const { getByText } = render( + + ) + + expect(getByText('January')).toHaveFocus() + userEvent.tab() + expect(getByText('January')).toHaveFocus() + }) + }) +}) diff --git a/src/components/forms/DatePicker/MonthPicker.tsx b/src/components/forms/DatePicker/MonthPicker.tsx new file mode 100644 index 0000000000..5a066867c0 --- /dev/null +++ b/src/components/forms/DatePicker/MonthPicker.tsx @@ -0,0 +1,161 @@ +import React, { useState, useEffect, useRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { MONTH_LABELS } from './constants' +import { + isDatesMonthOutsideMinOrMax, + isSameMonth, + keepDateBetweenMinAndMax, + listToTable, + setMonth, + handleTabKey, + isIosDevice, +} from './utils' + +export const MonthPicker = ({ + date, + minDate, + maxDate, + handleSelectMonth, +}: { + date: Date + minDate: Date + maxDate?: Date + handleSelectMonth: (value: number) => void +}): React.ReactElement => { + const selectedMonth = date.getMonth() + const [monthToDisplay, setMonthToDisplay] = useState(selectedMonth) + const monthPickerEl = useRef(null) + const focusedMonthEl = useRef(null) + + useEffect(() => { + const monthToFocus = + monthPickerEl.current && + monthPickerEl.current.querySelector( + `[data-value="${monthToDisplay}"]` + ) + if (monthToFocus) monthToFocus.focus() + }, [monthToDisplay]) + + const handleMonthPickerTab = (event: KeyboardEvent): void => { + handleTabKey(event, [focusedMonthEl?.current]) + } + + const handleKeyDownFromMonth = (event: KeyboardEvent): void => { + let newDisplayMonth + const target = event.target as HTMLButtonElement + const selectedMonth = parseInt(target.dataset?.value || '', 10) + const currentDate = setMonth(date, selectedMonth) + + switch (event.key) { + case 'ArrowUp': + case 'Up': + newDisplayMonth = selectedMonth - 3 + break + case 'ArrowDown': + case 'Down': + newDisplayMonth = selectedMonth + 3 + break + case 'ArrowLeft': + case 'Left': + newDisplayMonth = selectedMonth - 1 + break + case 'ArrowRight': + case 'Right': + newDisplayMonth = selectedMonth + 1 + break + case 'Home': + newDisplayMonth = selectedMonth - (selectedMonth % 3) + break + case 'End': + newDisplayMonth = selectedMonth + 2 - (selectedMonth % 3) + break + case 'PageDown': + newDisplayMonth = 11 + break + case 'PageUp': + newDisplayMonth = 0 + break + default: + return + } + + if (newDisplayMonth !== undefined) { + newDisplayMonth = Math.max(0, Math.min(11, newDisplayMonth)) + const newDate = setMonth(date, newDisplayMonth) + const cappedDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + if (!isSameMonth(currentDate, cappedDate)) { + setMonthToDisplay(cappedDate.getMonth()) + } + } + + event.preventDefault() + } + + const months = MONTH_LABELS.map((month, index) => { + const monthToCheck = setMonth(date, index) + const isDisabled = isDatesMonthOutsideMinOrMax( + monthToCheck, + minDate, + maxDate + ) + const isSelected = index === selectedMonth + const isFocused = index === monthToDisplay + + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__month', { + 'usa-date-picker__calendar__month--selected': isSelected, + 'usa-date-picker__calendar__month--focused': isFocused, + }) + + const onClick = (): void => { + handleSelectMonth(index) + } + + const handleMouseMoveFromMonth = (): void => { + if (isDisabled || isIosDevice()) return + if (index === monthToDisplay) return + setMonthToDisplay(index) + } + + return ( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1340) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) + }) + + return ( + // Ignoring error: "Static HTML elements with event handlers require a role." + // Ignoring because this element does not have a role in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1345) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + {listToTable(months, 3)} +
+
+ ) +} + +MonthPicker.displayName = 'MonthPicker' diff --git a/src/components/forms/DatePicker/YearPicker.stories.tsx b/src/components/forms/DatePicker/YearPicker.stories.tsx new file mode 100644 index 0000000000..32ffddced9 --- /dev/null +++ b/src/components/forms/DatePicker/YearPicker.stories.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +import { YearPicker } from './YearPicker' +import { parseDateString } from './utils' + +/* +// THIS STORY FOR INTERNAL DEVELOPMENT ONLY + +export default { + title: 'Components/Form controls/Date picker/Year picker', + component: YearPicker, + argTypes: { + handleSelectYear: { action: 'handle select year' }, + setStatuses: { action: 'set statuses' }, + }, +} +*/ + +const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, +} + +export const yearPicker = (argTypes): React.ReactElement => ( + +) + +export const withMinAndMaxInCurrentChunk = (argTypes): React.ReactElement => ( + +) + +export const withMinInCurrentChunk = (argTypes): React.ReactElement => ( + +) + +export const withMaxInCurrentChunk = (argTypes): React.ReactElement => ( + +) diff --git a/src/components/forms/DatePicker/YearPicker.test.tsx b/src/components/forms/DatePicker/YearPicker.test.tsx new file mode 100644 index 0000000000..84de5c5ce0 --- /dev/null +++ b/src/components/forms/DatePicker/YearPicker.test.tsx @@ -0,0 +1,420 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { YearPicker } from './YearPicker' +import { parseDateString } from './utils' + +describe('YearPicker', () => { + const testProps = { + date: new Date('January 20 2021'), + minDate: parseDateString('0000-01-01') as Date, + handleSelectYear: jest.fn(), + setStatuses: jest.fn(), + } + + it('renders a button for each year in the current chunk', () => { + const { getByText } = render() + + const years = [ + 2016, + 2017, + 2018, + 2019, + 2020, + 2021, + 2022, + 2023, + 2024, + 2025, + 2026, + 2027, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + }) + + it('each button implements an onClick handler to select the year', () => { + const mockSelectYear = jest.fn() + const { getByText } = render( + + ) + + const years = [ + 2016, + 2017, + 2018, + 2019, + 2020, + 2021, + 2022, + 2023, + 2024, + 2025, + 2026, + 2027, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + userEvent.click(button) + expect(mockSelectYear).toHaveBeenCalledWith(year) + }) + }) + + it('renders a button to navigate to the previous and next chunks of years', () => { + const { getByTestId } = render() + expect(getByTestId('previous-year-chunk')).toBeInstanceOf(HTMLButtonElement) + expect(getByTestId('next-year-chunk')).toBeInstanceOf(HTMLButtonElement) + }) + + it('disables the previous button if the min date is in the displayed year chunk', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('previous-year-chunk')).toBeDisabled() + expect(getByTestId('next-year-chunk')).not.toBeDisabled() + }) + + it('disables the next button if the max date is in the displayed year chunk', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('next-year-chunk')).toBeDisabled() + expect(getByTestId('previous-year-chunk')).not.toBeDisabled() + }) + + it('the currently displayed year has the selected class', () => { + const { getByText } = render() + const button = getByText('2021') + expect(button).toHaveClass('usa-date-picker__calendar__year--selected') + expect(button).toHaveAttribute('aria-selected', 'true') + }) + + it('focus defaults to the currently displayed year', () => { + const { getByText } = render() + const button = getByText('2021') + expect(button).toHaveClass('usa-date-picker__calendar__year--focused') + expect(button).toHaveFocus() + expect(button).toHaveAttribute('tabIndex', '0') + }) + + it('disables year buttons that are outside the min and max dates', () => { + const { getByText } = render( + + ) + const years = [ + 2016, + 2017, + 2018, + 2019, + 2020, + 2021, + 2022, + 2023, + 2024, + 2025, + 2026, + 2027, + ] + + years.forEach((year, index) => { + if (index < 5 || index > 9) { + expect(getByText(year)).toBeDisabled() + } else { + expect(getByText(year)).not.toBeDisabled() + } + }) + }) + + describe('navigation', () => { + it('clicking previous year chunk navigates the year picker back one chunk', () => { + const { getByTestId, getByText } = render() + userEvent.click(getByTestId('previous-year-chunk')) + + const years = [ + 2004, + 2005, + 2006, + 2007, + 2008, + 2009, + 2010, + 2011, + 2012, + 2013, + 2014, + 2015, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('previous-year-chunk')).toHaveFocus() + }) + + it('clicking previous year chunk focuses on the year picker if the previous year chunk becomes disabled', () => { + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('previous-year-chunk')) + + const years = [ + 2004, + 2005, + 2006, + 2007, + 2008, + 2009, + 2010, + 2011, + 2012, + 2013, + 2014, + 2015, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('previous-year-chunk')).toBeDisabled() + expect(getByTestId('calendar-year-picker')).toHaveFocus() + }) + + it('clicking next year chunk navigates the year picker forward one chunk', () => { + const { getByTestId, getByText } = render() + userEvent.click(getByTestId('next-year-chunk')) + + const years = [ + 2028, + 2029, + 2030, + 2031, + 2032, + 2033, + 2034, + 2035, + 2036, + 2037, + 2038, + 2039, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('next-year-chunk')).toHaveFocus() + }) + + it('clicking next year chunk focuses on the year picker if the next year chunk becomes disabled', () => { + const { getByTestId, getByText } = render( + + ) + userEvent.click(getByTestId('next-year-chunk')) + + const years = [ + 2028, + 2029, + 2030, + 2031, + 2032, + 2033, + 2034, + 2035, + 2036, + 2037, + 2038, + 2039, + ] + + years.forEach((year) => { + const button = getByText(year) + expect(button).toBeInstanceOf(HTMLButtonElement) + expect(button).toHaveAttribute('data-value', `${year}`) + }) + + expect(getByTestId('next-year-chunk')).toBeDisabled() + expect(getByTestId('calendar-year-picker')).toHaveFocus() + }) + }) + + describe('focusing on hover', () => { + it('focuses on a year when hovered over', () => { + const { getByText } = render() + + expect(getByText('2021')).toHaveFocus() + fireEvent.mouseMove(getByText('2017')) + expect(getByText('2017')).toHaveFocus() + }) + + it('does not focus on a disabled year when hovered over', () => { + const { getByText } = render( + + ) + + expect(getByText('2021')).toHaveFocus() + expect(getByText('2024')).toBeDisabled() + fireEvent.mouseMove(getByText('2024')) + expect(getByText('2024')).not.toHaveFocus() + }) + + it('does not focus on a year when hovered over if on an iOS device', () => { + jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'iPhone') + + const { getByText } = render() + + expect(getByText('2021')).toHaveFocus() + fireEvent.mouseMove(getByText('2017')) + expect(getByText('2017')).not.toHaveFocus() + jest.restoreAllMocks() + }) + }) + + describe('keyboard navigation', () => { + it('pressing the up arrow key from a year navigates to 3 years before', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowUp', + }) + expect(getByText('2018')).toHaveFocus() + }) + + it('pressing the down arrow key from a year navigates to 3 years later', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowDown', + }) + expect(getByText('2024')).toHaveFocus() + }) + + it('pressing the left arrow key from a year navigates to the previous year', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowLeft', + }) + expect(getByText('2020')).toHaveFocus() + }) + + it('pressing the right arrow key from a year navigates to the next year', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'ArrowRight', + }) + expect(getByText('2022')).toHaveFocus() + }) + + it('pressing the home key from a year navigates to the first year of the selected row', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'Home', + }) + expect(getByText('2019')).toHaveFocus() + }) + + it('pressing the end key from a year navigates to the last year of the selected row', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'End', + }) + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing the page down key from a year navigates forward a year chunk', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'PageDown', + }) + expect(getByText('2033')).toHaveFocus() + }) + + it('pressing the page up key from a year navigates back a year chunk', () => { + const { getByText } = render() + + fireEvent.keyDown(getByText('2021'), { + key: 'PageUp', + }) + expect(getByText('2009')).toHaveFocus() + }) + + it('pressing tab cycles through the focusable elements within the year picker', () => { + const { getByText, getByTestId } = render() + + expect(getByText('2021')).toHaveFocus() + userEvent.tab() + expect(getByTestId('next-year-chunk')).toHaveFocus() + userEvent.tab() + expect(getByTestId('previous-year-chunk')).toHaveFocus() + userEvent.tab() + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing tab+shift cycles backwards through the focusable elements within the year picker', () => { + const { getByText, getByTestId } = render() + + expect(getByText('2021')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('previous-year-chunk')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByTestId('next-year-chunk')).toHaveFocus() + userEvent.tab({ shift: true }) + expect(getByText('2021')).toHaveFocus() + }) + + it('pressing tab only cycles through elements that are not disabled', () => { + const { getByText, getByTestId } = render( + + ) + + expect(getByText('2021')).toHaveFocus() + expect(getByTestId('next-year-chunk')).toBeDisabled() + userEvent.tab() + expect(getByTestId('previous-year-chunk')).toHaveFocus() + userEvent.tab() + expect(getByText('2021')).toHaveFocus() + }) + }) +}) diff --git a/src/components/forms/DatePicker/YearPicker.tsx b/src/components/forms/DatePicker/YearPicker.tsx new file mode 100644 index 0000000000..7501573773 --- /dev/null +++ b/src/components/forms/DatePicker/YearPicker.tsx @@ -0,0 +1,276 @@ +import React, { useEffect, useState, useRef, KeyboardEvent } from 'react' +import classnames from 'classnames' + +import { YEAR_CHUNK } from './constants' +import { + isDatesYearOutsideMinOrMax, + keepDateBetweenMinAndMax, + listToTable, + setYear, + isSameYear, + handleTabKey, + isIosDevice, +} from './utils' + +export const YearPicker = ({ + date, + minDate, + maxDate, + handleSelectYear, + setStatuses, +}: { + date: Date + minDate: Date + maxDate?: Date + handleSelectYear: (year: number) => void + setStatuses: (statuses: string[]) => void +}): React.ReactElement => { + const prevYearChunkEl = useRef(null) + const nextYearChunkEl = useRef(null) + const focusedYearEl = useRef(null) + const yearPickerEl = useRef(null) + + const selectedYear = date.getFullYear() + + const [yearToDisplay, setYearToDisplay] = useState(selectedYear) + const [nextToFocus, setNextToFocus] = useState< + [HTMLButtonElement | null, HTMLDivElement | null] + >([null, null]) + + let yearToChunk = yearToDisplay + yearToChunk -= yearToChunk % YEAR_CHUNK + yearToChunk = Math.max(0, yearToChunk) + + const prevYearChunkDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearToChunk - 1), + minDate, + maxDate + ) + const nextYearChunkDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearToChunk + YEAR_CHUNK), + minDate, + maxDate + ) + + useEffect(() => { + // update status text when year chunk changes + const statusStr = `Showing years ${yearToChunk} to ${ + yearToChunk + YEAR_CHUNK - 1 + }. Select a year.` + setStatuses([statusStr]) + + // also focus on next element + const [focusEl, fallbackFocusEl] = nextToFocus + + if (focusEl && fallbackFocusEl) { + if (focusEl.disabled) { + fallbackFocusEl.focus() + } else { + focusEl.focus() + } + setNextToFocus([null, null]) + } else { + // Focus on the new year when it changes + const focusedYear = + yearPickerEl.current && + yearPickerEl.current.querySelector( + '.usa-date-picker__calendar__year--focused' + ) + if (focusedYear) { + focusedYear.focus() + } + } + }, [yearToDisplay]) + + useEffect(() => { + // focus on year button on mount + const yearToFocus = + yearPickerEl.current && + yearPickerEl.current.querySelector( + `[data-value="${yearToDisplay}"]` + ) + if (yearToFocus) yearToFocus.focus() + }, []) + + const handleYearPickerTab = (event: KeyboardEvent): void => { + handleTabKey(event, [ + prevYearChunkEl?.current, + focusedYearEl?.current, + nextYearChunkEl?.current, + ]) + } + + const handleKeyDownFromYear = (event: KeyboardEvent): void => { + let newDisplayYear + const target = event.target as HTMLButtonElement + const focusedYear = parseInt(target.dataset?.value || '', 10) + const currentDate = setYear(date, focusedYear) + + switch (event.key) { + case 'ArrowUp': + case 'Up': + newDisplayYear = focusedYear - 3 + break + case 'ArrowDown': + case 'Down': + newDisplayYear = focusedYear + 3 + break + case 'ArrowLeft': + case 'Left': + newDisplayYear = focusedYear - 1 + break + case 'ArrowRight': + case 'Right': + newDisplayYear = focusedYear + 1 + break + case 'Home': + newDisplayYear = focusedYear - (focusedYear % 3) + break + case 'End': + newDisplayYear = focusedYear + 2 - (focusedYear % 3) + break + case 'PageDown': + newDisplayYear = focusedYear + YEAR_CHUNK + break + case 'PageUp': + newDisplayYear = focusedYear - YEAR_CHUNK + break + default: + return + } + + if (newDisplayYear !== undefined) { + newDisplayYear = Math.max(0, newDisplayYear) + const newDate = setYear(date, newDisplayYear) + const cappedDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + if (!isSameYear(currentDate, cappedDate)) { + setYearToDisplay(cappedDate.getFullYear()) + } + } + + event.preventDefault() + } + + const years = [] + let yearIndex = yearToChunk + while (years.length < YEAR_CHUNK) { + const yearIterator = yearIndex + const isDisabled = isDatesYearOutsideMinOrMax( + setYear(date, yearIterator), + minDate, + maxDate + ) + + const isSelected = yearIterator === selectedYear + const isFocused = yearIterator === yearToDisplay + const tabIndex = isFocused ? 0 : -1 + + const classes = classnames('usa-date-picker__calendar__year', { + 'usa-date-picker__calendar__year--selected': isSelected, + 'usa-date-picker__calendar__year--focused': isFocused, + }) + + const onClick = (): void => { + handleSelectYear(yearIterator) + } + + const handleMouseMoveFromYear = (): void => { + if (isDisabled || isIosDevice()) return + if (yearIterator === yearToDisplay) return + setYearToDisplay(yearIterator) + } + + years.push( + // Ignoring error: "The attribute aria-selected is not supported by the role button. This role is implicit on the element button." + // Ignoring because this attribute is present in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1447) + // eslint-disable-next-line jsx-a11y/role-supports-aria-props + + ) + + yearIndex += 1 + } + + const handlePreviousYearChunkClick = (): void => { + let adjustedYear = yearToDisplay - YEAR_CHUNK + adjustedYear = Math.max(0, adjustedYear) + + let newDate = setYear(date, adjustedYear) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setNextToFocus([prevYearChunkEl.current, yearPickerEl.current]) + setYearToDisplay(newDate.getFullYear()) + } + + const handleNextYearChunkClick = (): void => { + let adjustedYear = yearToDisplay + YEAR_CHUNK + adjustedYear = Math.max(0, adjustedYear) + + let newDate = setYear(date, adjustedYear) + newDate = keepDateBetweenMinAndMax(newDate, minDate, maxDate) + setNextToFocus([nextYearChunkEl.current, yearPickerEl.current]) + setYearToDisplay(newDate.getFullYear()) + } + + return ( + // Ignoring error: "Static HTML elements with event handlers require a role." + // Ignoring because this element does not have a role in the USWDS implementation (https://github.com/uswds/uswds/blob/develop/src/js/components/date-picker.js#L1457) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + + + + + + + +
+ + + + {listToTable(years, 3)} +
+
+ +
+
+ ) +} + +YearPicker.displayName = 'YearPicker' diff --git a/src/components/forms/DatePicker/constants.ts b/src/components/forms/DatePicker/constants.ts new file mode 100644 index 0000000000..3dd7211a54 --- /dev/null +++ b/src/components/forms/DatePicker/constants.ts @@ -0,0 +1,36 @@ +export const VALIDATION_MESSAGE = 'Please enter a valid date' + +export const MONTH_LABELS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] + +export const DAY_OF_WEEK_LABELS = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] + +export const DAY_OF_WEEK_SHORT_LABELS = ['S', 'M', 'T', 'W', 'Th', 'F', 'S'] + +export const ENTER_KEYCODE = 13 + +export const YEAR_CHUNK = 12 + +export const DEFAULT_MIN_DATE = '0000-01-01' +export const DEFAULT_EXTERNAL_DATE_FORMAT = 'MM/DD/YYYY' +export const INTERNAL_DATE_FORMAT = 'YYYY-MM-DD' diff --git a/src/components/forms/DatePicker/utils.test.ts b/src/components/forms/DatePicker/utils.test.ts new file mode 100644 index 0000000000..8b7ee8eefc --- /dev/null +++ b/src/components/forms/DatePicker/utils.test.ts @@ -0,0 +1,143 @@ +import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants' +import { + keepDateWithinMonth, + setDate, + today, + parseDateString, + formatDate, + isDateInvalid, + isDateWithinMinAndMax, +} from './utils' + +describe('keepDateWithinMonth', () => { + it('returns the original date if the month matches', () => { + const testDate = new Date('January 20, 2021') + expect(keepDateWithinMonth(testDate, 0)).toEqual(testDate) + }) + + it('returns the last day of the previous month if the month does not match', () => { + const testDate = new Date('January 20, 2021') + expect(keepDateWithinMonth(testDate, 1)).toEqual( + new Date('December 31, 2020') + ) + }) +}) + +describe('setDate', () => { + it('returns a Date object with the given year, month, and date', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2020, 0, 20) + expect(setDate(2020, 0, 20)).toEqual(expectedDate) + }) +}) + +describe('today', () => { + it('returns a Date object with today’s date', () => { + const todaysDate = new Date() + const expectedDate = new Date(0) + expectedDate.setFullYear( + todaysDate.getFullYear(), + todaysDate.getMonth(), + todaysDate.getDate() + ) + expect(today()).toEqual(expectedDate) + }) +}) + +describe('parseDateString', () => { + it('parses a date string using - syntax and returns a Date object', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 0, 20) + expect(parseDateString('2021-01-20')).toEqual(expectedDate) + }) + + it('parses a date string using / syntax and returns a Date object', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 0, 20) + expect(parseDateString('1/20/2021', DEFAULT_EXTERNAL_DATE_FORMAT)).toEqual( + expectedDate + ) + }) + + it('coerces the date if the string is invalid', () => { + const expectedDate = new Date(0) + expectedDate.setFullYear(2021, 11, 31) + expect(parseDateString('2021-14-38', INTERNAL_DATE_FORMAT, true)).toEqual( + expectedDate + ) + }) +}) + +describe('formatDate', () => { + it('formats a date object to a string using - syntax', () => { + expect(formatDate(new Date('May 16, 1988'))).toEqual('1988-05-16') + }) + + it('formats a date object to a string using / syntax', () => { + expect( + formatDate(new Date('May 16, 1988'), DEFAULT_EXTERNAL_DATE_FORMAT) + ).toEqual('05/16/1988') + }) +}) + +describe('isDateInvalid', () => { + it('returns false if the date is within the min & max', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('05/16/1988', testMin, testMax)).toEqual(false) + }) + + it('returns true if the date is not within the min & max', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('08/16/1988', testMin, testMax)).toEqual(true) + }) + + it('returns true if the date is not valid', () => { + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateInvalid('not a date', testMin, testMax)).toEqual(true) + }) + + describe('with no max date', () => { + it('returns false if the date is after the min', () => { + const testMin = new Date('May 1, 1988') + expect(isDateInvalid('05/16/1988', testMin)).toEqual(false) + }) + + it('returns true if the date is not after the min', () => { + const testMin = new Date('May 1, 1988') + expect(isDateInvalid('02/16/1988', testMin)).toEqual(true) + }) + }) +}) + +describe('isDateWithinMinAndMax', () => { + it('returns true if the date is within the min & max', () => { + const testDate = new Date('January 12, 2021') + const testMin = new Date('January 10, 2021') + const testMax = new Date('January 30, 2021') + expect(isDateWithinMinAndMax(testDate, testMin, testMax)).toEqual(true) + }) + + it('returns false if the date is not within the min & max', () => { + const testDate = new Date('August 16, 1988') + const testMin = new Date('May 1, 1988') + const testMax = new Date('June 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin, testMax)).toEqual(false) + }) + + describe('with no max date', () => { + it('returns true if the date is after the min', () => { + const testDate = new Date('May 16, 1988') + const testMin = new Date('May 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin)).toEqual(true) + }) + + it('returns false if the date is before the min', () => { + const testDate = new Date('February 16, 1988') + const testMin = new Date('May 1, 1988') + expect(isDateWithinMinAndMax(testDate, testMin)).toEqual(false) + }) + }) +}) diff --git a/src/components/forms/DatePicker/utils.tsx b/src/components/forms/DatePicker/utils.tsx new file mode 100644 index 0000000000..73bdc8f3cf --- /dev/null +++ b/src/components/forms/DatePicker/utils.tsx @@ -0,0 +1,566 @@ +import React, { KeyboardEvent } from 'react' + +import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants' + +/** + * This file contains the USWDS DatePicker date manipulation functions converted to TypeScript + */ + +/** + * Keep date within month. Month would only be over by 1 to 3 days + * + * @param {Date} dateToCheck the date object to check + * @param {number} month the correct month + * @returns {Date} the date, corrected if needed + */ +export const keepDateWithinMonth = (dateToCheck: Date, month: number): Date => { + if (month !== dateToCheck.getMonth()) { + dateToCheck.setDate(0) + } + + return dateToCheck +} + +/** + * Set date from month day year + * + * @param {number} year the year to set + * @param {number} month the month to set (zero-indexed) + * @param {number} date the date to set + * @returns {Date} the set date + */ +export const setDate = (year: number, month: number, date: number): Date => { + const newDate = new Date(0) + newDate.setFullYear(year, month, date) + return newDate +} + +/** + * todays date + * + * @returns {Date} todays date + */ +export const today = (): Date => { + const newDate = new Date() + const day = newDate.getDate() + const month = newDate.getMonth() + const year = newDate.getFullYear() + return setDate(year, month, day) +} + +/** + * Set date to first day of the month + * + * @param {Date} date the date to adjust + * @returns {Date} the adjusted date + */ +export const startOfMonth = (date: Date): Date => { + const newDate = new Date(0) + newDate.setFullYear(date.getFullYear(), date.getMonth(), 1) + return newDate +} + +/** + * Set date to last day of the month + * + * @param {number} date the date to adjust + * @returns {Date} the adjusted date + */ +export const lastDayOfMonth = (date: Date): Date => { + const newDate = new Date(0) + newDate.setFullYear(date.getFullYear(), date.getMonth() + 1, 0) + return newDate +} + +/** + * Add days to date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +export const addDays = (date: Date, numDays: number): Date => { + const newDate = new Date(date.getTime()) + newDate.setDate(newDate.getDate() + numDays) + return newDate +} + +/** + * Subtract days from date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +export const subDays = (date: Date, numDays: number): Date => + addDays(date, -numDays) + +/** + * Add weeks to date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const addWeeks = (date: Date, numWeeks: number): Date => + addDays(date, numWeeks * 7) + +/** + * Subtract weeks from date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const subWeeks = (date: Date, numWeeks: number): Date => + addWeeks(date, -numWeeks) + +/** + * Set date to the start of the week (Sunday) + * + * @param {Date} _date the date to adjust + * @returns {Date} the adjusted date + */ +export const startOfWeek = (date: Date): Date => { + const dayOfWeek = date.getDay() + return subDays(date, dayOfWeek) +} + +/** + * Set date to the end of the week (Saturday) + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +export const endOfWeek = (date: Date): Date => { + const dayOfWeek = date.getDay() + return addDays(date, 6 - dayOfWeek) +} + +/** + * Add months to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +export const addMonths = (date: Date, numMonths: number): Date => { + const newDate = new Date(date.getTime()) + const dateMonth = (newDate.getMonth() + 12 + numMonths) % 12 + newDate.setMonth(newDate.getMonth() + numMonths) + keepDateWithinMonth(newDate, dateMonth) + return newDate +} + +/** + * Subtract months from date + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +export const subMonths = (date: Date, numMonths: number): Date => + addMonths(date, -numMonths) + +/** + * Add years to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +export const addYears = (date: Date, numYears: number): Date => + addMonths(date, numYears * 12) + +/** + * Subtract years from date + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +export const subYears = (date: Date, numYears: number): Date => + addYears(date, -numYears) + +/** + * Set months of date + * + * @param {Date} _date the date to adjust + * @param {number} month zero-indexed month to set + * @returns {Date} the adjusted date + */ +export const setMonth = (date: Date, month: number): Date => { + const newDate = new Date(date.getTime()) + newDate.setMonth(month) + keepDateWithinMonth(newDate, month) + return newDate +} + +/** + * Set year of date + * + * @param {Date} _date the date to adjust + * @param {number} year the year to set + * @returns {Date} the adjusted date + */ +export const setYear = (date: Date, year: number): Date => { + const newDate = new Date(date.getTime()) + const month = newDate.getMonth() + newDate.setFullYear(year) + keepDateWithinMonth(newDate, month) + return newDate +} + +/** + * Return the earliest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the earliest date + */ +export const min = (dateA: Date, dateB: Date): Date => { + let newDate = dateA + if (dateB < dateA) { + newDate = dateB + } + return new Date(newDate.getTime()) +} + +/** + * Return the latest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the latest date + */ +export const max = (dateA: Date, dateB: Date): Date => { + let newDate = dateA + if (dateB > dateA) { + newDate = dateB + } + return new Date(newDate.getTime()) +} + +/** + * Check if dates are the in the same year + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same year + */ +export const isSameYear = (dateA: Date, dateB: Date): boolean => { + return dateA && dateB && dateA.getFullYear() === dateB.getFullYear() +} + +/** + * Check if dates are the in the same month + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same month + */ +export const isSameMonth = (dateA: Date, dateB: Date): boolean => { + return isSameYear(dateA, dateB) && dateA.getMonth() === dateB.getMonth() +} + +/** + * Check if dates are the same date + * + * @param {Date} dateA the date to compare + * @param {Date} dateA the date to compare + * @returns {boolean} are dates the same date + */ +export const isSameDay = (dateA: Date, dateB: Date): boolean => { + return isSameMonth(dateA, dateB) && dateA.getDate() === dateB.getDate() +} + +/** + * return a new date within minimum and maximum date + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @returns {Date} the date between min and max + */ +export const keepDateBetweenMinAndMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): Date => { + let newDate = date + + if (date < minDate) { + newDate = minDate + } else if (maxDate && date > maxDate) { + newDate = maxDate + } + + return new Date(newDate.getTime()) +} + +/** + * Check if dates is valid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is there a day within the month within min and max dates + */ +export const isDateWithinMinAndMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => date >= minDate && (!maxDate || date <= maxDate) + +/** + * Check if dates month is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +export const isDatesMonthOutsideMinOrMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => { + return ( + lastDayOfMonth(date) < minDate || + (!!maxDate && startOfMonth(date) > maxDate) + ) +} + +/** + * Check if dates year is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +export const isDatesYearOutsideMinOrMax = ( + date: Date, + minDate: Date, + maxDate?: Date +): boolean => { + return ( + lastDayOfMonth(setMonth(date, 11)) < minDate || + (!!maxDate && startOfMonth(setMonth(date, 0)) > maxDate) + ) +} + +/** + * Parse a date with format M-D-YY + * + * @param {string} dateString the date string to parse + * @param {string} dateFormat the format of the date string + * @param {boolean} adjustDate should the date be adjusted + * @returns {Date} the parsed date + */ +export const parseDateString = ( + dateString: string, + dateFormat: string = INTERNAL_DATE_FORMAT, + adjustDate = false +): Date | undefined => { + let date + let month + let day + let year + let parsed + + if (dateString) { + let monthStr, dayStr, yearStr + + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + ;[monthStr, dayStr, yearStr] = dateString.split('/') + } else { + ;[yearStr, monthStr, dayStr] = dateString.split('-') + } + + if (yearStr) { + parsed = parseInt(yearStr, 10) + if (!Number.isNaN(parsed)) { + year = parsed + if (adjustDate) { + year = Math.max(0, year) + if (yearStr.length < 3) { + const currentYear = today().getFullYear() + const currentYearStub = + currentYear - (currentYear % 10 ** yearStr.length) + year = currentYearStub + parsed + } + } + } + } + + if (monthStr) { + parsed = parseInt(monthStr, 10) + if (!Number.isNaN(parsed)) { + month = parsed + if (adjustDate) { + month = Math.max(1, month) + month = Math.min(12, month) + } + } + } + + if (month && dayStr && year != null) { + parsed = parseInt(dayStr, 10) + if (!Number.isNaN(parsed)) { + day = parsed + if (adjustDate) { + const lastDayOfMonth = setDate(year, month, 0).getDate() + day = Math.max(1, day) + day = Math.min(lastDayOfMonth, day) + } + } + } + + if (month && day && year != null) { + date = setDate(year, month - 1, day) + } + } + + return date +} + +/** + * Format a date to format YYYY-MM-DD + * + * @param {Date} date the date to format + * @param {string} dateFormat the format of the date string + * @returns {string} the formatted date string + */ +export const formatDate = ( + date: Date, + dateFormat: string = INTERNAL_DATE_FORMAT +): string => { + const padZeros = (value: number, length: number): string => { + return `0000${value}`.slice(-length) + } + + const month = date.getMonth() + 1 + const day = date.getDate() + const year = date.getFullYear() + + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + return [padZeros(month, 2), padZeros(day, 2), padZeros(year, 4)].join('/') + } + + return [padZeros(year, 4), padZeros(month, 2), padZeros(day, 2)].join('-') +} + +// VALIDATION + +export const isDateInvalid = ( + dateString: string, + minDate: Date, + maxDate?: Date +): boolean => { + let isInvalid = false + + if (dateString) { + isInvalid = true + + const dateStringParts = dateString.split('/') + const [month, day, year] = dateStringParts.map((str) => { + let value + const parsed = parseInt(str, 10) + if (!Number.isNaN(parsed)) value = parsed + return value + }) + + if (month && day && year != null) { + const checkDate = setDate(year, month - 1, day) + + if ( + checkDate.getMonth() === month - 1 && + checkDate.getDate() === day && + checkDate.getFullYear() === year && + dateStringParts[2].length === 4 && + isDateWithinMinAndMax(checkDate, minDate, maxDate) + ) { + isInvalid = false + } + } + } + + return isInvalid +} + +// RENDERING TABLES + +export const listToTable = ( + list: React.ReactNode[], + rowSize: number +): React.ReactFragment => { + const rows = [] + let i = 0 + + while (i < list.length) { + const row = [] + while (i < list.length && row.length < rowSize) { + row.push(list[parseInt(`${i}`)]) + i += 1 + } + rows.push(row) + } + + return ( + <> + {rows.map((r, rIndex) => ( + + {r.map((cell, cIndex) => ( + {cell} + ))} + + ))} + + ) +} + +export const handleTabKey = ( + event: KeyboardEvent, + focusableEl: Array +): void => { + if (event.key === 'Tab') { + const focusable = focusableEl.filter((el) => el && !el.disabled) + const activeElement = document?.activeElement + + const firstTabIndex = 0 + const lastTabIndex = focusable.length - 1 + const firstTabStop = focusable[parseInt(`${firstTabIndex}`)] + const lastTabStop = focusable[parseInt(`${lastTabIndex}`)] + const focusIndex = + activeElement instanceof HTMLButtonElement + ? focusable.indexOf(activeElement) + : -1 + + const isLastTab = focusIndex === lastTabIndex + const isFirstTab = focusIndex === firstTabIndex + const isNotFound = focusIndex === -1 + + if (event.shiftKey) { + // Tab backwards + if (isFirstTab || isNotFound) { + event.preventDefault() + lastTabStop?.focus() + } + } else { + // Tab forwards + if (isLastTab || isNotFound) { + event.preventDefault() + firstTabStop?.focus() + } + } + } +} + +// iOS detection from: http://stackoverflow.com/a/9039885/177710 +export const isIosDevice = (): boolean => + typeof navigator !== 'undefined' && + (navigator.userAgent.match(/(iPod|iPhone|iPad)/g) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && + !window.MSStream diff --git a/src/components/forms/Label/Label.tsx b/src/components/forms/Label/Label.tsx index bf0bd6bba3..ba888745df 100644 --- a/src/components/forms/Label/Label.tsx +++ b/src/components/forms/Label/Label.tsx @@ -10,8 +10,18 @@ interface LabelProps { srOnly?: boolean } -export const Label = (props: LabelProps): React.ReactElement => { - const { children, htmlFor, className, error, hint, srOnly } = props +export const Label = ( + props: LabelProps & JSX.IntrinsicElements['label'] +): React.ReactElement => { + const { + children, + htmlFor, + className, + error, + hint, + srOnly, + ...labelProps + } = props const classes = classnames( { @@ -23,7 +33,11 @@ export const Label = (props: LabelProps): React.ReactElement => { ) return ( -