diff --git a/.changeset/big-carpets-sleep.md b/.changeset/big-carpets-sleep.md new file mode 100644 index 0000000000..256de82ac7 --- /dev/null +++ b/.changeset/big-carpets-sleep.md @@ -0,0 +1,6 @@ +--- +'@lg-tools/meta': minor +--- + +- Adds `exitWithErrorMessage` util +- Fixes recursion in `findPackageJson` diff --git a/.changeset/eighty-kings-mix.md b/.changeset/eighty-kings-mix.md new file mode 100644 index 0000000000..56b8c543ab --- /dev/null +++ b/.changeset/eighty-kings-mix.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/date-picker': minor +--- + +Initial pre-release of `date-picker`. Use DatePicker to allow users to input a date diff --git a/.changeset/eleven-donkeys-tickle.md b/.changeset/eleven-donkeys-tickle.md new file mode 100644 index 0000000000..9a37937d1e --- /dev/null +++ b/.changeset/eleven-donkeys-tickle.md @@ -0,0 +1,5 @@ +--- +'@lg-tools/test': minor +--- + +Adds coverage reporting for untested sub-modules diff --git a/.changeset/famous-timers-eat.md b/.changeset/famous-timers-eat.md new file mode 100644 index 0000000000..a2ac969445 --- /dev/null +++ b/.changeset/famous-timers-eat.md @@ -0,0 +1,8 @@ +--- +'@leafygreen-ui/select': patch +--- + +- Passes `onEnter*` and `onExit*` props to internal `Popover` component +- Adds tests to test `onEnter*` and `onExit*` callbacks +- Adds tests to test `PopoverContext` + diff --git a/.changeset/great-avocados-battle.md b/.changeset/great-avocados-battle.md new file mode 100644 index 0000000000..599e8bd92a --- /dev/null +++ b/.changeset/great-avocados-battle.md @@ -0,0 +1,5 @@ +--- +'@lg-tools/test': minor +--- + +Updates to jest 29 for React 17 testing diff --git a/.changeset/little-melons-beam.md b/.changeset/little-melons-beam.md new file mode 100644 index 0000000000..55c3df3205 --- /dev/null +++ b/.changeset/little-melons-beam.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/lib': minor +--- + +Updates Typescript signature of `createSyntheticEvent` diff --git a/.changeset/old-numbers-serve.md b/.changeset/old-numbers-serve.md new file mode 100644 index 0000000000..c120c794a1 --- /dev/null +++ b/.changeset/old-numbers-serve.md @@ -0,0 +1,5 @@ +--- +'@lg-tools/storybook-decorators': patch +--- + +Adds Null check for args diff --git a/.changeset/proud-doors-smoke.md b/.changeset/proud-doors-smoke.md new file mode 100644 index 0000000000..7345c7fa57 --- /dev/null +++ b/.changeset/proud-doors-smoke.md @@ -0,0 +1,9 @@ +--- +'@leafygreen-ui/lib': minor +--- +- Creates new utility functions + - `rollover` + - `truncateStart` + - `cloneReverse` + - `isDefined` + - `isZeroLike` & `isNotZeroLike` diff --git a/.changeset/rare-apples-deny.md b/.changeset/rare-apples-deny.md new file mode 100644 index 0000000000..9690172af5 --- /dev/null +++ b/.changeset/rare-apples-deny.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/form-field': patch +--- + +Updates disabled icon colors diff --git a/.changeset/rude-tomatoes-hang.md b/.changeset/rude-tomatoes-hang.md new file mode 100644 index 0000000000..8a3da7a455 --- /dev/null +++ b/.changeset/rude-tomatoes-hang.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/input-option': minor +--- + +Renders `aria-disabled` attribute when `disabled` is provided diff --git a/.changeset/seven-stingrays-bake.md b/.changeset/seven-stingrays-bake.md new file mode 100644 index 0000000000..82c4abea30 --- /dev/null +++ b/.changeset/seven-stingrays-bake.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/popover': patch +--- + +Exports `ChildrenFunctionParameters` type diff --git a/.changeset/shaggy-falcons-invite.md b/.changeset/shaggy-falcons-invite.md new file mode 100644 index 0000000000..aae5a58f35 --- /dev/null +++ b/.changeset/shaggy-falcons-invite.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/form-field': minor +--- + +Adds `data-testid` to Label, Description & Error elements diff --git a/.changeset/smart-steaks-care.md b/.changeset/smart-steaks-care.md new file mode 100644 index 0000000000..5489ec9ad3 --- /dev/null +++ b/.changeset/smart-steaks-care.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/date-utils': minor +--- + +Initial pre-release of `date-utils`. DateUtils contains utility functions for managing and manipulating JS Date objects diff --git a/.changeset/soft-berries-obey.md b/.changeset/soft-berries-obey.md new file mode 100644 index 0000000000..b3ab179499 --- /dev/null +++ b/.changeset/soft-berries-obey.md @@ -0,0 +1,8 @@ +--- +'@leafygreen-ui/hooks': minor +--- + +- Extends `useControlledValue` to accept any type. +- Adds `updateValue` function in return value. This method triggers a synthetic event to update the value of a controlled or uncontrolled component. +- Adds `initialValue` argument. Used for setting the initial value for uncontrolled components. Without this we may encounter a React error for switching between controlled/uncontrolled inputs +- The value of `isControlled` is now immutable after the first render diff --git a/.changeset/spotty-wombats-end.md b/.changeset/spotty-wombats-end.md new file mode 100644 index 0000000000..0ee350e5b1 --- /dev/null +++ b/.changeset/spotty-wombats-end.md @@ -0,0 +1,5 @@ +--- +'@lg-tools/validate': patch +--- + +Updates dev file pattern to include entire `testutils/` directories diff --git a/.changeset/stale-jeans-travel.md b/.changeset/stale-jeans-travel.md new file mode 100644 index 0000000000..bbfeb0cf30 --- /dev/null +++ b/.changeset/stale-jeans-travel.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/a11y': patch +--- + +Update `AriaLabelProps` `label` type from `string` to `ReactNode` diff --git a/.changeset/tall-mice-ring.md b/.changeset/tall-mice-ring.md new file mode 100644 index 0000000000..a5ad5202cd --- /dev/null +++ b/.changeset/tall-mice-ring.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/lib': patch +--- + +Updates `target` type in `createSyntheticEvent` to extend `EventTarget` diff --git a/.changeset/tidy-lemons-drop.md b/.changeset/tidy-lemons-drop.md new file mode 100644 index 0000000000..2fcddb251b --- /dev/null +++ b/.changeset/tidy-lemons-drop.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/typography': minor +--- + +Allows `Link` component to accept a ref diff --git a/packages/a11y/src/AriaLabelProps.ts b/packages/a11y/src/AriaLabelProps.ts index d722630357..43ddb79243 100644 --- a/packages/a11y/src/AriaLabelProps.ts +++ b/packages/a11y/src/AriaLabelProps.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + /** * A union interface requiring _either_ `aria-label` or `aria-labelledby` */ @@ -43,7 +45,7 @@ export type AriaLabelPropsWithLabel = * * Optional if `aria-labelledby` or `aria-label` is provided */ - label?: string; + label?: ReactNode; } & AriaLabelProps) | ({ /** @@ -51,5 +53,5 @@ export type AriaLabelPropsWithLabel = * * Optional if `aria-labelledby` or `aria-label` is provided */ - label: string; + label: ReactNode; } & Partial); diff --git a/packages/date-picker/README.md b/packages/date-picker/README.md new file mode 100644 index 0000000000..23e4a1de37 --- /dev/null +++ b/packages/date-picker/README.md @@ -0,0 +1,100 @@ +# Date Picker + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/date-picker.svg) + +#### [View on MongoDB.design](https://www.mongodb.design/component/date-picker/example/) + +## Installation + +### Yarn + +```shell +yarn add @leafygreen-ui/date-picker +``` + +### NPM + +```shell +npm install @leafygreen-ui/date-picker +``` + +## Example + +```js +import { DatePicker } from '@leafygreen-ui/date-picker'; + +const [date, setDate] = useState(); + +; +``` + +## Properties + +| Prop | Type | Description | Default | +| ------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `label` | `ReactNode` | Label shown above the date picker. | | +| `description` | `ReactNode` | A description for the date picker. It's recommended to set a meaningful time zone representation as the description. (e.g. "Coordinated Universal Time") | | +| `locale` | `'iso8601'`\| `'string'` | Sets the _presentation format_ for the displayed date, and localizes month & weekday labels. Defaults to the user’s browser preference (if available), otherwise ISO-8601. | `iso8601` | +| `timeZone` | `string` | A valid IANA timezone string, or UTC offset, used to calculate initial values. Defaults to the user’s browser settings. | +| `min` | `Date` | The earliest date accepted, in UTC | | +| `max` | `Date` | The latest date accepted, in UTC | | +| `value` | `'Date'` \| `'InvalidDate'` \| `'null'` | The selected date. Note that this Date object will be read as UTC time. Providing `Date.now()` could result in the incorrect date being displayed, depending on the system time zone.

To set `value` to today, regardless of timeZone, use `setToUTCMidnight(new Date(Date.now()))`.

e.g. `2023-12-31` at 20:00 in Los Angeles, will be `2024-01-01` at 04:00 in UTC. To set the correct day (`2023-12-31`) as the DatePicker value we must first convert our local timestamp to `2023-12-31` at midnight | | +| `onDateChange` | `(value?: Date \| InvalidDate \| null) => void` | Callback fired when the user makes a value change. Fired on click of a new date in the menu, or on keydown if the input contains a valid date.

_Not_ fired when a date segment changes, but does not create a full date

Callback date argument will be a Date object in UTC time, or `null` | | +| `initialValue` | `'Date'` \| `'InvalidDate'` \| `'null'` | The initial selected date. Ignored if `value` is provided

Note that this Date object will be read as UTC time. See `value` prop documentation for more details | | +| `handleValidation` | `(value?: Date \| InvalidDate \| null) => void` | A callback fired when validation should run, based on [form validation guidelines](https://www.mongodb.design/foundation/forms/#form-validation-error-handling). Use this callback to compute the correct `state` and `errorMessage` value.

Callback date argument will be a Date object in UTC time, or `null` | | +| `onChange` | `(event: ChangeEvent) => void` | Callback fired when any segment changes, (but not necessarily a full value) | | +| `baseFontSize` | `'13'` \| `'16'` | The base font size of the input. Inherits from the nearest LeafyGreenProvider | | +| `disabled` | `boolean` | Whether the input is disabled. _Note_: will not set the `disabled` attribute on an input and the calendar menu will not open if disabled is set to true. | `false` | +| `size` | `'small'` \| `'xsmall'` \| `'default'` \| `'large'` | Whether the input is disabled. Note: will not set the `disabled` attribute on an input and the calendar menu will not open if disabled is set to true. | `default` | +| `state` | `'none'` \| `'error'` | Whether to show an error message | `none` | +| `errorMessage` | `string` | A message to show in red underneath the input when state is `Error` | | +| `initialOpen` | `boolean` | Whether the calendar menu is initially open. _Note_: The calendar menu will not open if disabled is set to `true`. | `false` | +| `autoComplete` | `'off'` \| `'on'` \| `'bday'` | Whether the input should autofill | `off` | +| `darkMode` | `boolean` | Render the component in dark mode. | `false` | + +## 🔎 Glossary + +### Date format + +The pattern in which a string stores date (& time) information. E.g. `“YYYY-DD-MM”`, `“MM/DD/YYYY”`, `“YYYY-MM-DDTHH:mm:ss.sssZ”` + +### Wire format (or Data format) + +The format of the date string passed into the component. This will typically be [ISO-8601](https://www.iso.org/iso-8601-date-and-time-format.html), but could be any format accepted by the [Date constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date). + +### Presentation format + +The format in which the date is presented to the user. By default, the HTML date input element presents this in the format of the user’s Locale (as defined in browser or OS settings). + +### Locale + +Language, script, & region information. Can also include [other data](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale). + +### Time Zone + +A string representing a user’s local time zone (e.g. “America/New_York”) or UTC offset. Valid time zones are defined by IANA, and [listed on Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). A UTC offset can be [provided in a DateTime string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). + +### UTC offset + +The offset of a time zone vs UTC. E.g. The UTC offset for `“America/New_York”` is -5:00, (or -4:00 depending on daylight savings). + +### Wire time zone (or Data time zone) + +The time zone information contained in the date string/object passed into the component. + +### Presentation time zone + +The time zone relative to which we present date information to the user. Can result in a different day than the wire time zone. E.g. `“2023-08-08T00:00:00Z”` (Aug. 8/2023 at midnight UTC) => `“2023-08-07T20:00:00-04:00”` (Aug. 7 at 8pm EDT) + +## Special Case + +### Aria Labels + +Either `label` or `aria-labelledby` or `aria-label` must be provided, or there will be a console error. This is to ensure that screenreaders have a description for what the DatePicker does. diff --git a/packages/date-picker/package.json b/packages/date-picker/package.json new file mode 100644 index 0000000000..e6dc619952 --- /dev/null +++ b/packages/date-picker/package.json @@ -0,0 +1,60 @@ +{ + "name": "@leafygreen-ui/date-picker", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Date Picker", + "license": "Apache-2.0", + "main": "./dist/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "lg build-package", + "tsc": "lg build-ts", + "docs": "lg build-tsdoc" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/a11y": "^1.4.11", + "@leafygreen-ui/date-utils": "^0.0.1", + "@leafygreen-ui/emotion": "^4.0.7", + "@leafygreen-ui/form-field": "^0.2.0", + "@leafygreen-ui/hooks": "^8.0.0", + "@leafygreen-ui/icon": "^11.23.0", + "@leafygreen-ui/icon-button": "^15.0.17", + "@leafygreen-ui/lib": "^13.0.0", + "@leafygreen-ui/palette": "^4.0.7", + "@leafygreen-ui/popover": "^11.1.0", + "@leafygreen-ui/select": "^11.0.0", + "@leafygreen-ui/tokens": "^2.2.0", + "@leafygreen-ui/typography": "^18.0.0", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "polished": "^4.2.2", + "weekstart": "^2.0.0" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "^3.1.6" + }, + "devDependencies": { + "@leafygreen-ui/button": "^21.0.7", + "@leafygreen-ui/modal":"^16.0.3", + "@leafygreen-ui/testing-lib": "^0.3.4", + "mockdate": "^3.0.5", + "storybook-mock-date-decorator": "^1.0.1", + "@jest/globals": "^29.6.2" + }, + "resolutions": { + "storybook-mock-date-decorator/@babel/runtime": "7.22.10", + "date-fns/@babel/runtime": "7.22.10", + "date-fns-tz/@babel/runtime": "7.22.10" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/date-picker", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/PD/summary" + } +} diff --git a/packages/date-picker/src/DatePicker.stories.tsx b/packages/date-picker/src/DatePicker.stories.tsx new file mode 100644 index 0000000000..e6e5bdff42 --- /dev/null +++ b/packages/date-picker/src/DatePicker.stories.tsx @@ -0,0 +1,163 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import { StoryFn } from '@storybook/react'; +import { isNull, isUndefined } from 'lodash'; + +import Button from '@leafygreen-ui/button'; +import { + DateType, + isValidDate, + Month, + newUTC, + testLocales, + testTimeZoneLabels, +} from '@leafygreen-ui/date-utils'; +import { css } from '@leafygreen-ui/emotion'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; +import Modal from '@leafygreen-ui/modal'; +import { Size } from '@leafygreen-ui/tokens'; +import { Overline } from '@leafygreen-ui/typography'; + +import { MAX_DATE, MIN_DATE } from './shared/constants'; +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from './shared/context'; +import { getProviderPropsFromStoryContext } from './shared/testutils/getProviderPropsFromStoryContext'; +import { AutoComplete } from './shared/types'; +import { DatePicker } from './DatePicker'; + +const ProviderWrapper = (Story: StoryFn, ctx: any) => { + const { leafyGreenProviderProps, datePickerProviderProps, storyProps } = + getProviderPropsFromStoryContext(ctx?.args); + + return ( + + + + + + ); +}; + +const meta: StoryMetaType = { + title: 'Components/DatePicker/DatePicker', + component: DatePicker, + decorators: [ProviderWrapper], + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + 'handleValidation', + 'initialValue', + 'onChange', + 'onDateChange', + 'onSegmentChange', + 'value', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + value: [newUTC(2023, Month.December, 26)], + locale: ['iso8601', 'en-US', 'en-UK', 'de-DE'], + timeZone: ['UTC', 'Europe/London', 'America/New_York', 'Asia/Seoul'], + disabled: [false, true], + }, + decorator: ProviderWrapper, + }, + }, + args: { + locale: 'iso8601', + label: 'Pick a date', + description: 'description', + size: Size.Default, + autoComplete: AutoComplete.Off, + min: MIN_DATE, + max: MAX_DATE, + }, + argTypes: { + baseFontSize: { control: 'select' }, + locale: { control: 'select', options: testLocales }, + description: { control: 'text' }, + label: { control: 'text' }, + min: { control: 'date' }, + max: { control: 'date' }, + size: { control: 'select' }, + state: { control: 'select' }, + timeZone: { + control: 'select', + options: [undefined, ...testTimeZoneLabels], + }, + autoComplete: { control: 'select', options: Object.values(AutoComplete) }, + }, +}; + +export default meta; + +export const LiveExample: StoryFn = props => { + const [value, setValue] = useState(); + + return ( +
+ { + // eslint-disable-next-line no-console + console.log('Storybook: onDateChange', { v }); + setValue(v); + }} + handleValidation={date => + // eslint-disable-next-line no-console + console.log('Storybook: handleValidation', { date }) + } + /> +
+ Current value + + {isValidDate(value) + ? value.toISOString() + : isNull(value) || isUndefined(value) + ? String(value) + : value.toDateString()} + +
+ ); +}; + +export const Uncontrolled: StoryFn = props => { + return ; +}; +Uncontrolled.parameters = { + chromatic: { + disableSnapshots: true, + }, +}; + +export const InModal: StoryFn = props => { + const [value, setValue] = useState(); + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + Inside the modal + + + + ); +}; +InModal.parameters = { + chromatic: { + disableSnapshots: true, + }, +}; + +export const Generated = () => {}; diff --git a/packages/date-picker/src/DatePicker/DatePicker.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx new file mode 100644 index 0000000000..7cd1afade7 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx @@ -0,0 +1,3608 @@ +import React from 'react'; +import { + fireEvent, + // prettyDOM, + render, + waitFor, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { addDays, subDays } from 'date-fns'; + +import { getISODate, Month, newUTC } from '@leafygreen-ui/date-utils'; +import { + mockTimeZone, + testTimeZones, + undefinedTZ, +} from '@leafygreen-ui/date-utils/src/testing'; +import { + eventContainingTargetValue, + tabNTimes, +} from '@leafygreen-ui/testing-lib'; +import { transitionDuration } from '@leafygreen-ui/tokens'; + +import { DateSegment } from '../shared'; +import { defaultMax, defaultMin } from '../shared/constants'; +import { + getFormattedDateString, + getFormattedSegmentsFromDate, + getValueFormatter, +} from '../shared/utils'; + +import { + expectedTabStopLabels, + findTabStopElementMap, + renderDatePicker, + RenderDatePickerResult, + RenderMenuResult, +} from './DatePicker.testutils'; +import { DatePicker } from '.'; + +/** + * There are HUNDREDS of tests for this component. + * To keep things organized we've attempted to adopt the following testing philosophy. + * + * Rendering Tests: + * Tests that assert that certain elements are rendered to the DOM. + * These tests should not have any user interaction (except when absolutely necessary to arrive in a certain state) + * These tests should exist on each sub-component to simplify test suites + * + * Interaction tests: + * Tests that assert some behavior following user interaction. + * Generally, this type of tests should _only_ exist in a test file for user-facing components. + */ + +// Set the current time to noon UTC on 2023-12-25 +const testToday = newUTC(2023, Month.December, 25, 12); + +describe('packages/date-picker', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + beforeEach(() => { + jest.setSystemTime(testToday); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Rendering', () => { + /// Note: Many rendering tests should be handled by Chromatic + + describe('Input', () => { + test('renders label', () => { + const { getByText } = render(); + const label = getByText('Label'); + expect(label).toBeInTheDocument(); + }); + + test('warn when no labels are passed in', () => { + const consoleSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + /* @ts-expect-error - needs label/aria-label/aria-labelledby */ + render(); + expect(consoleSpy).toHaveBeenCalledWith( + 'For screen-reader accessibility, label, aria-labelledby, or aria-label must be provided to DatePicker component', + ); + }); + + test('renders description', () => { + const { getByText } = render( + , + ); + const description = getByText('Description'); + expect(description).toBeInTheDocument(); + }); + + test('spreads rest to formField', () => { + const { getByTestId } = render( + , + ); + const formField = getByTestId('lg-date-picker'); + expect(formField).toBeInTheDocument(); + }); + + // TODO: Test a11y linking of label & input + test('formField contains label & input elements', () => { + const { getByTestId, getByRole } = render( + , + ); + const formField = getByTestId('lg-date-picker'); + const inputContainer = getByRole('combobox'); + expect(formField.querySelector('label')).toBeInTheDocument(); + expect(formField.querySelector('label')).toHaveTextContent('Label'); + expect(inputContainer).toBeInTheDocument(); + }); + + test('formField contains aria-label when a label is not provided', () => { + const { getByRole } = render( + , + ); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-label', 'Label'); + }); + + test('formField contains aria-labelledby when a label is not provided', () => { + const { getByRole } = render( + , + ); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-labelledby', 'Label'); + }); + + test('formField only contains a label if label, aria-label, and aria-labelledby are passes', () => { + const { getByRole, getByTestId } = render( + , + ); + const formField = getByTestId('lg-date-picker'); + const inputContainer = getByRole('combobox'); + expect(formField.querySelector('label')).toBeInTheDocument(); + expect(formField.querySelector('label')).toHaveTextContent('Label'); + expect(inputContainer).not.toHaveAttribute( + 'aria-labelledby', + 'AriaLabelledby', + ); + expect(inputContainer).not.toHaveAttribute('aria-label', 'AriaLabel'); + }); + + test('renders 3 inputs', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker(); + expect(dayInput).toBeInTheDocument(); + expect(monthInput).toBeInTheDocument(); + expect(yearInput).toBeInTheDocument(); + }); + + describe('rendering values', () => { + test('renders `value` prop', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + value: newUTC(2023, Month.December, 25), + }); + expect(dayInput.value).toEqual('25'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('2023'); + }); + + test('renders `initialValue` prop', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + initialValue: newUTC(2023, Month.December, 25), + }); + expect(dayInput.value).toEqual('25'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('2023'); + }); + + test('renders nothing when `value` is null', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + value: null, + }); + expect(dayInput.value).toEqual(''); + expect(monthInput.value).toEqual(''); + expect(yearInput.value).toEqual(''); + }); + + test('renders nothing when `initialValue` is null', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + initialValue: null, + }); + expect(dayInput.value).toEqual(''); + expect(monthInput.value).toEqual(''); + expect(yearInput.value).toEqual(''); + }); + + test('renders nothing when `value` is an invalid date', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + value: new Date('invalid'), + }); + expect(dayInput.value).toEqual(''); + expect(monthInput.value).toEqual(''); + expect(yearInput.value).toEqual(''); + }); + + test('renders nothing when `initialValue` is an invalid date', () => { + const { dayInput, monthInput, yearInput } = renderDatePicker({ + initialValue: new Date('invalid'), + }); + expect(dayInput.value).toEqual(''); + expect(monthInput.value).toEqual(''); + expect(yearInput.value).toEqual(''); + }); + + describe('re-rendering with a new value', () => { + test('updates inputs with new valid value', () => { + const { dayInput, monthInput, yearInput, rerenderDatePicker } = + renderDatePicker({ + value: newUTC(2023, Month.December, 25), + }); + + rerenderDatePicker({ value: newUTC(2024, Month.September, 10) }); + + expect(dayInput.value).toEqual('10'); + expect(monthInput.value).toEqual('09'); + expect(yearInput.value).toEqual('2024'); + }); + + test('clears inputs when value is `null`', () => { + const { dayInput, monthInput, yearInput, rerenderDatePicker } = + renderDatePicker({ + value: newUTC(2023, Month.December, 25), + }); + + rerenderDatePicker({ value: null }); + + expect(dayInput.value).toEqual(''); + expect(monthInput.value).toEqual(''); + expect(yearInput.value).toEqual(''); + }); + + test('renders previous input if value is invalid', () => { + const { dayInput, monthInput, yearInput, rerenderDatePicker } = + renderDatePicker({ + value: newUTC(2023, Month.December, 25), + }); + + rerenderDatePicker({ value: new Date('invalid') }); + + expect(dayInput.value).toEqual('25'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('2023'); + }); + }); + }); + + describe('Error states', () => { + test('renders error state when `state` is "error"', () => { + const { getByRole } = render( + , + ); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + }); + + test('renders with `errorMessage` when provided', () => { + const { queryByTestId } = render( + , + ); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent('Custom error message'); + }); + + test('does not render `errorMessage` when state is not set', () => { + const { getByRole, queryByTestId } = render( + , + ); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-invalid', 'false'); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).not.toBeInTheDocument(); + }); + + test('renders with internal error state when value is out of range', () => { + const { queryByTestId, getByRole } = render( + , + ); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + 'Date must be before 2038-01-19', + ); + }); + + test('external error message overrides internal error message', () => { + const { queryByTestId, getByRole } = renderDatePicker({ + value: newUTC(2100, 1, 1), + state: 'error', + errorMessage: 'Custom error message', + }); + const inputContainer = getByRole('combobox'); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent('Custom error message'); + }); + + test('renders internal message if external error message is not set', () => { + const { inputContainer, queryByTestId } = renderDatePicker({ + value: newUTC(2100, 1, 1), + state: 'error', + errorMessage: undefined, + }); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + expect( + queryByTestId('lg-form_field-error_message'), + ).toHaveTextContent('Date must be before 2038-01-19'); + }); + + test('removing an external error displays an internal error when applicable', () => { + const { inputContainer, rerenderDatePicker, queryByTestId } = + renderDatePicker({ + value: newUTC(2100, Month.January, 1), + }); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + expect( + queryByTestId('lg-form_field-error_message'), + ).toHaveTextContent('Date must be before 2038-01-19'); + + rerenderDatePicker({ errorMessage: 'Some error', state: 'error' }); + + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + expect( + queryByTestId('lg-form_field-error_message'), + ).toHaveTextContent('Some error'); + + rerenderDatePicker({ state: 'none' }); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + expect( + queryByTestId('lg-form_field-error_message'), + ).toHaveTextContent('Date must be before 2038-01-19'); + }); + + test('internal error message updates when min value changes', () => { + const { inputContainer, rerenderDatePicker, queryByTestId } = + renderDatePicker({ + value: newUTC(1967, Month.March, 10), + }); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + 'Date must be after 1970-01-01', + ); + + rerenderDatePicker({ min: newUTC(1968, Month.July, 5) }); + + expect(errorElement).toHaveTextContent( + 'Date must be after 1968-07-05', + ); + }); + + test('internal error message updates when max value changes', () => { + const { inputContainer, rerenderDatePicker, queryByTestId } = + renderDatePicker({ + value: newUTC(2050, Month.January, 1), + }); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + 'Date must be before 2038-01-19', + ); + + rerenderDatePicker({ max: newUTC(2048, Month.July, 5) }); + + expect(errorElement).toHaveTextContent( + 'Date must be before 2048-07-05', + ); + }); + }); + }); + + describe('Menu', () => { + test('menu is initially closed', async () => { + const { findMenuElements } = renderDatePicker(); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('menu is initially open when rendered with `initialOpen`', async () => { + const { findMenuElements } = renderDatePicker({ initialOpen: true }); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('menu is initially closed when rendered with `initialOpen` and `disabled`', async () => { + const { findMenuElements } = renderDatePicker({ + initialOpen: true, + disabled: true, + }); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('renders the appropriate number of cells', async () => { + const { openMenu } = renderDatePicker({ + value: new Date(Date.UTC(2024, Month.February, 14)), + }); + const { calendarCells } = await openMenu(); + expect(calendarCells).toHaveLength(29); + }); + + describe('when disabled is toggled to `true`', () => { + test('menu closes if open', async () => { + const { findMenuElements, rerenderDatePicker } = renderDatePicker({ + initialOpen: true, + }); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + rerenderDatePicker({ disabled: true }); + await waitFor(() => { + expect(menuContainerEl).not.toBeInTheDocument(); + }); + }); + + test('validation handler fires', async () => { + const handleValidation = jest.fn(); + const { findMenuElements, rerenderDatePicker } = renderDatePicker({ + initialOpen: true, + handleValidation, + }); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + rerenderDatePicker({ disabled: true }); + await waitFor(() => { + expect(handleValidation).toHaveBeenCalled(); + }); + }); + }); + + describe('Chevrons', () => { + describe('left', () => { + describe('is disabled', () => { + test('when the value is before the min', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 1)), + value: new Date(Date.UTC(2022, Month.December, 1)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'true'); + }); + test('when the value is the same as the min', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 10)), + value: new Date(Date.UTC(2023, Month.December, 1)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'true'); + }); + test('min and max are in the same month', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 1)), + max: new Date(Date.UTC(2023, Month.December, 20)), + value: new Date(Date.UTC(2023, Month.December, 5)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'true'); + }); + }); + describe('is not disabled', () => { + test('when the year and month is after the max', async () => { + const { openMenu } = renderDatePicker({ + max: new Date(Date.UTC(2024, Month.January, 2)), + value: new Date(Date.UTC(2025, Month.December, 1)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'false'); + }); + test('when the year and month is the same as the max', async () => { + const { openMenu } = renderDatePicker({ + max: new Date(Date.UTC(2024, Month.January, 2)), + value: new Date(Date.UTC(2024, Month.January, 1)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'false'); + }); + test('when the year is the same as the max and the month is after the max', async () => { + const { openMenu } = renderDatePicker({ + max: new Date(Date.UTC(2024, Month.January, 2)), + value: new Date(Date.UTC(2024, Month.February, 1)), + }); + + const { leftChevron } = await openMenu(); + expect(leftChevron).toHaveAttribute('aria-disabled', 'false'); + }); + }); + }); + describe('right', () => { + describe('is disabled', () => { + test('when the value is after the max', async () => { + const { openMenu } = renderDatePicker({ + max: new Date(Date.UTC(2024, Month.January, 2)), + value: new Date(Date.UTC(2025, Month.December, 1)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'true'); + }); + test('when the value is the same as the max', async () => { + const { openMenu } = renderDatePicker({ + max: new Date(Date.UTC(2024, Month.January, 2)), + value: new Date(Date.UTC(2024, Month.January, 1)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'true'); + }); + test('min and max are in the same month', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 1)), + max: new Date(Date.UTC(2023, Month.December, 20)), + value: new Date(Date.UTC(2023, Month.December, 5)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'true'); + }); + }); + describe('is not disabled', () => { + test('when the year and month is before the min', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 1)), + value: new Date(Date.UTC(2022, Month.December, 1)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'false'); + }); + test('when the year and month is the same as the min', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 10)), + value: new Date(Date.UTC(2023, Month.December, 1)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'false'); + }); + test('when the year is the same as the min and the month before the min', async () => { + const { openMenu } = renderDatePicker({ + min: new Date(Date.UTC(2023, Month.December, 1)), + value: new Date(Date.UTC(2023, Month.November, 1)), + }); + + const { rightChevron } = await openMenu(); + expect(rightChevron).toHaveAttribute('aria-disabled', 'false'); + }); + }); + }); + }); + + describe('when menu opens', () => {}); + }); + }); + + describe('Interaction', () => { + describe('Mouse interaction', () => { + describe('Clicking the input', () => { + test('opens the menu', async () => { + const { inputContainer, findMenuElements } = renderDatePicker(); + userEvent.click(inputContainer); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('focuses a specific segment when clicked', async () => { + const { monthInput, waitForMenuToOpen } = renderDatePicker(); + userEvent.click(monthInput); + await waitForMenuToOpen(); + expect(monthInput).toHaveFocus(); + }); + + test('focuses the first segment when all are empty', async () => { + const { inputContainer, yearInput, waitForMenuToOpen } = + renderDatePicker(); + userEvent.click(inputContainer); + await waitForMenuToOpen(); + expect(yearInput).toHaveFocus(); + }); + + test('focuses the first empty segment when some are empty', async () => { + const { inputContainer, yearInput, monthInput, waitForMenuToOpen } = + renderDatePicker(); + yearInput.value = '2023'; + yearInput.blur(); + userEvent.click(inputContainer); + await waitForMenuToOpen(); + expect(monthInput).toHaveFocus(); + }); + + test('focuses the last segment when all are filled', async () => { + const { inputContainer, dayInput, waitForMenuToOpen } = + renderDatePicker({ + value: new Date(), + }); + userEvent.click(inputContainer); + await waitForMenuToOpen(); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Clicking the Calendar button', () => { + test('toggles the menu open and closed', async () => { + const { calendarButton, findMenuElements } = renderDatePicker(); + userEvent.click(calendarButton); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + userEvent.click(calendarButton); + await waitFor(() => expect(menuContainerEl).not.toBeInTheDocument()); + }); + + test('closes the menu when "initialOpen: true"', async () => { + const { calendarButton, findMenuElements } = renderDatePicker({ + initialOpen: true, + }); + const { menuContainerEl } = await findMenuElements(); + await waitFor(() => expect(menuContainerEl).toBeInTheDocument()); + userEvent.click(calendarButton); + await waitFor(() => expect(menuContainerEl).not.toBeInTheDocument()); + }); + + describe.each([testTimeZones])( + 'when system time is in $tz', + ({ tz, UTCOffset }) => { + describe.each([undefinedTZ, ...testTimeZones])( + 'and timeZone prop is $tz', + props => { + const offset = props.UTCOffset ?? UTCOffset; + const dec24Local = newUTC( + 2023, + Month.December, + 24, + 23 - offset, + 59, + ); + const dec24ISO = '2023-12-24'; + + beforeEach(() => { + jest.setSystemTime(dec24Local); + mockTimeZone(tz, UTCOffset); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('if no value is set', () => { + test('default focus (highlight) is on `today`', async () => { + const { calendarButton, waitForMenuToOpen } = + renderDatePicker({ + timeZone: props.tz, + }); + userEvent.click(calendarButton); + const { queryCellByISODate } = await waitForMenuToOpen(); + expect(queryCellByISODate(dec24ISO)).toHaveFocus(); + }); + + test('menu opens to current month', async () => { + const { calendarButton, waitForMenuToOpen } = + renderDatePicker({ + timeZone: props.tz, + }); + userEvent.click(calendarButton); + const { calendarGrid, monthSelect, yearSelect } = + await waitForMenuToOpen(); + expect(calendarGrid).toHaveAttribute( + 'aria-label', + 'December 2023', + ); + expect(monthSelect).toHaveTextContent('Dec'); + expect(yearSelect).toHaveTextContent('2023'); + }); + }); + + describe('when `value` is set', () => { + test('focus (highlight) starts on current value', async () => { + const testValue = newUTC(2024, Month.September, 10); + const { calendarButton, waitForMenuToOpen } = + renderDatePicker({ + value: testValue, + timeZone: props.tz, + }); + userEvent.click(calendarButton); + const { queryCellByISODate } = await waitForMenuToOpen(); + expect(queryCellByISODate('2024-09-10')).toHaveFocus(); + }); + + test('menu opens to the month of that `value`', async () => { + const testValue = newUTC(2024, Month.September, 10); + const { calendarButton, waitForMenuToOpen } = + renderDatePicker({ + value: testValue, + timeZone: props.tz, + }); + userEvent.click(calendarButton); + const { calendarGrid, monthSelect, yearSelect } = + await waitForMenuToOpen(); + + expect(calendarGrid).toHaveAttribute( + 'aria-label', + 'September 2024', + ); + expect(monthSelect).toHaveTextContent('Sep'); + expect(yearSelect).toHaveTextContent('2024'); + }); + }); + }, + ); + }, + ); + + describe('if `value` is not valid', () => { + test('focus (highlight) starts on chevron button', async () => { + const testValue = newUTC(2100, Month.July, 4); + const { calendarButton, waitForMenuToOpen } = renderDatePicker({ + value: testValue, + }); + userEvent.click(calendarButton); + const { leftChevron } = await waitForMenuToOpen(); + expect(leftChevron).toHaveFocus(); + }); + + test('menu opens to the month of that `value`', async () => { + const testValue = newUTC(2100, Month.July, 4); + const { calendarButton, waitForMenuToOpen } = renderDatePicker({ + value: testValue, + }); + userEvent.click(calendarButton); + const { calendarGrid, monthSelect, yearSelect } = + await waitForMenuToOpen(); + + expect(calendarGrid).toHaveAttribute('aria-label', 'July 2100'); + expect(monthSelect).toHaveTextContent('Jul'); + expect(yearSelect).toHaveTextContent('2100'); + }); + }); + }); + + describe('Clicking a Calendar cell', () => { + test('closes the menu', async () => { + const { openMenu } = renderDatePicker({}); + const { calendarCells, menuContainerEl } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + await waitForElementToBeRemoved(menuContainerEl); + }); + + describe.each(testTimeZones)( + 'when system time is in $tz', + ({ tz, UTCOffset }) => { + beforeEach(() => { + mockTimeZone(tz, UTCOffset); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('fires a change handler', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ + onDateChange, + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + expect(onDateChange).toHaveBeenCalledWith( + newUTC(2023, Month.December, 1), + ); + }); + + test('fires a validation handler', async () => { + const handleValidation = jest.fn(); + const { openMenu } = renderDatePicker({ + handleValidation, + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + expect(handleValidation).toHaveBeenCalledWith( + newUTC(2023, Month.December, 1), + ); + }); + + test('updates the input', async () => { + const { openMenu, dayInput, monthInput, yearInput } = + renderDatePicker({}); + const { todayCell } = await openMenu(); + userEvent.click(todayCell!); + await waitFor(() => { + expect(dayInput.value).toBe(testToday.getUTCDate().toString()); + expect(monthInput.value).toBe( + (testToday.getUTCMonth() + 1).toString(), + ); + expect(yearInput.value).toBe( + testToday.getUTCFullYear().toString(), + ); + }); + }); + + test('does nothing if the cell is out-of-range', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ + onDateChange, + value: new Date(Date.UTC(2023, Month.September, 15)), + min: new Date(Date.UTC(2023, Month.September, 10)), + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + expect(firstCell).toHaveAttribute('aria-disabled', 'true'); + userEvent.click(firstCell!, {}, { skipPointerEventsCheck: true }); + expect(onDateChange).not.toHaveBeenCalled(); + }); + }, + ); + }); + + describe('Clicking a Chevron', () => { + describe('Left', () => { + test('does not close the menu', async () => { + const { openMenu } = renderDatePicker(); + const { leftChevron, menuContainerEl } = await openMenu(); + userEvent.click(leftChevron!); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('updates the displayed month to the previous', async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2023, Month.December, 25), + }); + const { leftChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + userEvent.click(leftChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'November 2023'); + expect(monthSelect).toHaveValue(Month.November.toString()); + expect(yearSelect).toHaveValue('2023'); + }); + + test('updates the displayed month to the previous, and updates year', async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2023, Month.January, 5), + }); + const { leftChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + userEvent.click(leftChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'December 2022'); + expect(monthSelect).toHaveValue(Month.December.toString()); + expect(yearSelect).toHaveValue('2022'); + }); + + test('updates the displayed month to the max month and year when the value is after the max', async () => { + const { openMenu } = renderDatePicker({ + max: newUTC(2022, Month.January, 5), + value: newUTC(2023, Month.January, 5), + }); + const { leftChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + expect(calendarGrid).toHaveAttribute('aria-label', 'January 2023'); + userEvent.click(leftChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'January 2022'); + expect(monthSelect).toHaveValue(Month.January.toString()); + expect(yearSelect).toHaveValue('2022'); + }); + + test('keeps focus on chevron button', async () => { + const { openMenu } = renderDatePicker(); + const { leftChevron } = await openMenu(); + userEvent.click(leftChevron!); + expect(leftChevron).toHaveFocus(); + }); + }); + + describe('Right', () => { + test('does not close the menu', async () => { + const { openMenu } = renderDatePicker(); + const { rightChevron, menuContainerEl } = await openMenu(); + userEvent.click(rightChevron!); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('updates the displayed month to the next', async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2023, Month.January, 5), + }); + const { rightChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + userEvent.click(rightChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'February 2023'); + expect(monthSelect).toHaveValue(Month.February.toString()); + expect(yearSelect).toHaveValue('2023'); + }); + + test('updates the displayed month to the next and updates year', async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2023, Month.December, 26), + }); + const { rightChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + userEvent.click(rightChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'January 2024'); + expect(monthSelect).toHaveValue(Month.January.toString()); + expect(yearSelect).toHaveValue('2024'); + }); + + test('updates the displayed month to the min month and year when the value is before the min ', async () => { + const { openMenu } = renderDatePicker({ + min: newUTC(2023, Month.December, 26), + value: newUTC(2022, Month.November, 26), + }); + const { rightChevron, monthSelect, yearSelect, calendarGrid } = + await openMenu(); + expect(calendarGrid).toHaveAttribute('aria-label', 'November 2022'); + userEvent.click(rightChevron!); + expect(calendarGrid).toHaveAttribute('aria-label', 'December 2023'); + expect(monthSelect).toHaveValue(Month.December.toString()); + expect(yearSelect).toHaveValue('2023'); + }); + }); + }); + + describe('Month select menu', () => { + test('menu opens over the calendar menu', async () => { + const { openMenu, queryAllByRole } = renderDatePicker(); + const { monthSelect, menuContainerEl } = await openMenu(); + userEvent.click(monthSelect!); + expect(menuContainerEl).toBeInTheDocument(); + const listBoxes = queryAllByRole('listbox'); + expect(listBoxes).toHaveLength(2); + }); + + test('selecting the month updates the calendar', async () => { + const { openMenu, findAllByRole } = renderDatePicker(); + const { monthSelect, calendarGrid } = await openMenu(); + userEvent.click(monthSelect!); + const options = await findAllByRole('option'); + const Jan = options[0]; + userEvent.click(Jan); + expect(calendarGrid).toHaveAttribute('aria-label', 'January 2023'); + }); + + test('making a selection with enter does not close the datePicker menu', async () => { + const { openMenu, findAllByRole } = renderDatePicker(); + const { monthSelect, menuContainerEl } = await openMenu(); + userEvent.click(monthSelect!); + await findAllByRole('option'); + userEvent.keyboard('{arrowdown}'); + userEvent.keyboard('[Enter]'); + await waitFor(() => { + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + }); + + describe('Year select menu', () => { + test('menu opens over the calendar menu', async () => { + const { openMenu, queryAllByRole } = renderDatePicker(); + const { yearSelect, menuContainerEl } = await openMenu(); + userEvent.click(yearSelect!); + expect(menuContainerEl).toBeInTheDocument(); + const listBoxes = queryAllByRole('listbox'); + expect(listBoxes).toHaveLength(2); + }); + + test('selecting the year updates the calendar', async () => { + const { openMenu, findAllByRole } = renderDatePicker({ + value: new Date(Date.UTC(2023, Month.December, 26)), + }); + const { yearSelect, calendarGrid } = await openMenu(); + userEvent.click(yearSelect!); + const options = await findAllByRole('option'); + const _1970 = options[0]; + + userEvent.click(_1970); + expect(calendarGrid).toHaveAttribute('aria-label', 'December 1970'); + }); + + test('making a selection with enter does not close the datePicker menu', async () => { + const { openMenu, findAllByRole } = renderDatePicker(); + const { yearSelect, menuContainerEl } = await openMenu(); + userEvent.click(yearSelect!); + await findAllByRole('option'); + userEvent.keyboard('{arrowdown}'); + userEvent.keyboard('[Enter]'); + await waitFor(() => { + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + }); + + describe('Clicking backdrop', () => { + test('closes the menu', async () => { + const { openMenu, container } = renderDatePicker(); + const { menuContainerEl } = await openMenu(); + userEvent.click(container.parentElement!); + await waitForElementToBeRemoved(menuContainerEl); + }); + + test('does not fire a change handler', async () => { + const onDateChange = jest.fn(); + const { openMenu, container } = renderDatePicker({ onDateChange }); + await openMenu(); + userEvent.click(container.parentElement!); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + test('returns focus to the calendar button', async () => { + const { openMenu, container, calendarButton } = renderDatePicker(); + await openMenu(); + userEvent.click(container.parentElement!); + await waitFor(() => expect(calendarButton).toHaveFocus()); + }); + + describe('when select is open', () => { + describe('Year select menu', () => { + test('keeps the menu open', async () => { + const { openMenu, container } = renderDatePicker(); + const { yearSelect, menuContainerEl } = await openMenu(); + userEvent.click(yearSelect!); + userEvent.click(container.parentElement!); + await waitFor(() => { + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + + test('closes the month/year select', async () => { + const { openMenu, container } = renderDatePicker(); + const { yearSelect } = await openMenu(); + userEvent.click(yearSelect!); + userEvent.click(container.parentElement!); + await waitForElementToBeRemoved(yearSelect); + }); + }); + + describe('Month select menu', () => { + test('keeps the menu open', async () => { + const { openMenu, container } = renderDatePicker(); + const { monthSelect, menuContainerEl } = await openMenu(); + userEvent.click(monthSelect!); + userEvent.click(container.parentElement!); + await waitFor(() => { + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + + test('closes the month/year select', async () => { + const { openMenu, container } = renderDatePicker(); + const { monthSelect } = await openMenu(); + userEvent.click(monthSelect!); + userEvent.click(container.parentElement!); + await waitForElementToBeRemoved(monthSelect); + }); + }); + }); + }); + }); + + describe('Keyboard navigation', () => { + describe('Tab', () => { + test('menu does not open on keyboard focus', async () => { + const { findMenuElements } = renderDatePicker(); + userEvent.tab(); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('menu does not open on subsequent keyboard focus', async () => { + const { findMenuElements } = renderDatePicker(); + tabNTimes(3); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('calls validation handler when last segment is unfocused', () => { + const handleValidation = jest.fn(); + renderDatePicker({ handleValidation }); + tabNTimes(5); + expect(handleValidation).toHaveBeenCalled(); + }); + + test('todayCell does not call validation handler when changing segment focus', () => { + const handleValidation = jest.fn(); + renderDatePicker({ handleValidation }); + tabNTimes(2); + expect(handleValidation).not.toHaveBeenCalled(); + }); + + describe('Tab order', () => { + describe('when menu is closed', () => { + const tabStops = expectedTabStopLabels['closed']; + + test('Tab order proceeds as expected', async () => { + const renderResult = renderDatePicker(); + + for (const label of tabStops) { + const elementMap = await findTabStopElementMap(renderResult); + const element = elementMap[label]; + + if (element !== null) { + expect(element).toHaveFocus(); + } else { + expect( + renderResult.inputContainer.contains( + document.activeElement, + ), + ).toBeFalsy(); + } + + const errorElement = renderResult.queryByTestId( + 'lg-form_field-error_message', + ); + + await waitFor(() => + expect(errorElement).not.toBeInTheDocument(), + ); + userEvent.tab(); + } + }); + }); + + describe('when menu is open', () => { + const tabStops = expectedTabStopLabels['open']; + + test(`Tab order proceeds as expected`, async () => { + const renderResult = renderDatePicker({ + initialOpen: true, + }); + + for (const label of tabStops) { + const elementMap = await findTabStopElementMap(renderResult); + const element = elementMap[label]; + + if (element !== null) { + expect(element).toHaveFocus(); + } else { + expect( + renderResult.inputContainer.contains( + document.activeElement, + ), + ).toBeFalsy(); + } + + const errorElement = renderResult.queryByTestId( + 'lg-form_field-error_message', + ); + + await waitFor(() => + expect(errorElement).not.toBeInTheDocument(), + ); + + userEvent.tab(); + // There are side-effects triggered on CSS transition-end events. + // Fire this event here to ensure these side-effects don't impact Tab order + if (element) fireEvent.transitionEnd(element); + } + }); + }); + }); + }); + + describe.each(['Enter', 'Space'])('%p key', key => { + test('opens menu if calendar button is focused', async () => { + const { findMenuElements } = renderDatePicker(); + tabNTimes(4); + userEvent.keyboard(`[${key}]`); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('if month select is focused, opens the select menu', async () => { + const { openMenu, findAllByRole } = renderDatePicker(); + const { monthSelect } = await openMenu(); + tabNTimes(2); + expect(monthSelect).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + const options = await findAllByRole('option'); + expect(options.length).toBeGreaterThan(0); + }); + + test('if a cell is focused, fires a change handler', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ onDateChange }); + const { todayCell } = await openMenu(); + expect(todayCell).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + expect(onDateChange).toHaveBeenCalled(); + }); + + test('if a cell is focused, closes the menu', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, menuContainerEl } = await openMenu(); + expect(todayCell).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + await waitForElementToBeRemoved(menuContainerEl); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('if a cell is focused on current value, closes the menu, but does not fire a change handler', async () => { + const onDateChange = jest.fn(); + const value = newUTC(2023, Month.September, 10); + const { openMenu } = renderDatePicker({ value, onDateChange }); + const { menuContainerEl, queryCellByDate } = await openMenu(); + expect(queryCellByDate(value)).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + await waitForElementToBeRemoved(menuContainerEl); + expect(menuContainerEl).not.toBeInTheDocument(); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + describe('chevron', () => { + test('if left chevron is focused, does not close the menu', async () => { + const { openMenu, findMenuElements } = renderDatePicker(); + const { leftChevron } = await openMenu(); + tabNTimes(1); + expect(leftChevron).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + + test('if right chevron is focused, does not close the menu', async () => { + const { openMenu, findMenuElements } = renderDatePicker(); + const { rightChevron } = await openMenu(); + tabNTimes(4); + expect(rightChevron).toHaveFocus(); + userEvent.keyboard(`[${key}]`); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + }); + + describe('Enter key only', () => { + test('does not open the menu if input is focused', async () => { + const { findMenuElements } = renderDatePicker(); + userEvent.tab(); + userEvent.keyboard(`[Enter]`); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('calls validation handler', () => { + const handleValidation = jest.fn(); + renderDatePicker({ handleValidation }); + userEvent.tab(); + userEvent.keyboard(`[Enter]`); + expect(handleValidation).toHaveBeenCalledWith(undefined); + }); + test.todo('within a form, does not submit form'); + }); + + describe('Escape key', () => { + test('closes the menu', async () => { + const { openMenu } = renderDatePicker(); + const { menuContainerEl } = await openMenu(); + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('does not fire a change handler', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ onDateChange }); + await openMenu(); + userEvent.keyboard('{escape}'); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + test('returns focus to the calendar button', async () => { + const { openMenu, calendarButton } = renderDatePicker(); + await openMenu(); + userEvent.keyboard('{escape}'); + await waitFor(() => expect(calendarButton).toHaveFocus()); + }); + + test('fires a validation handler', async () => { + const handleValidation = jest.fn(); + const { openMenu } = renderDatePicker({ handleValidation }); + await openMenu(); + userEvent.keyboard('{escape}'); + expect(handleValidation).toHaveBeenCalledWith(undefined); + }); + + test('closes the menu regardless of which element is focused', async () => { + const { openMenu } = renderDatePicker(); + const { menuContainerEl, leftChevron } = await openMenu(); + userEvent.tab(); + expect(leftChevron).toHaveFocus(); + + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + test('does not close the main menu if a select menu is open', async () => { + const { openMenu, queryAllByRole, findAllByRole } = + renderDatePicker(); + const { monthSelect, menuContainerEl } = await openMenu(); + + tabNTimes(2); + expect(monthSelect).toHaveFocus(); + + userEvent.keyboard('[Enter]'); + await waitFor(() => + jest.advanceTimersByTime(transitionDuration.default), + ); + + const options = await findAllByRole('option'); + const firstOption = options[0]; + userEvent.keyboard('{arrowdown}'); + expect(firstOption).toHaveFocus(); + + const listBoxes = queryAllByRole('listbox'); + expect(listBoxes).toHaveLength(2); + + const selectMenu = listBoxes[1]; + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(selectMenu); + expect(menuContainerEl).toBeInTheDocument(); + expect(monthSelect).toHaveFocus(); + }); + }); + + describe('Backspace key', () => { + test('deletes any value in the input', () => { + const { dayInput } = renderDatePicker(); + userEvent.type(dayInput, '26{backspace}'); + expect(dayInput.value).toBe('2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('deletes the whole value on multiple presses', () => { + const { monthInput } = renderDatePicker(); + userEvent.type(monthInput, '11'); + userEvent.type(monthInput, '{backspace}{backspace}'); + expect(monthInput.value).toBe(''); + }); + + test('focuses the previous segment if current segment is empty', () => { + const { yearInput, monthInput } = renderDatePicker(); + userEvent.type(monthInput, '{backspace}'); + expect(yearInput).toHaveFocus(); + }); + }); + + /** + * Arrow Keys behavior changes based on whether the input or menu is focused + */ + describe('Arrow key', () => { + describe('Input', () => { + describe('Left Arrow', () => { + test('focuses the previous segment when the segment is empty', () => { + const { yearInput, monthInput } = renderDatePicker(); + userEvent.click(monthInput); + userEvent.keyboard('{arrowleft}'); + expect(yearInput).toHaveFocus(); + }); + + test('moves the cursor when the segment has a value', () => { + const { monthInput } = renderDatePicker({ + value: testToday, + }); + userEvent.click(monthInput); + userEvent.keyboard('{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('moves the cursor when the value starts with 0', () => { + const { monthInput } = renderDatePicker({}); + userEvent.type(monthInput, '04{arrowleft}{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('moves the cursor when the value is 0', () => { + const { monthInput } = renderDatePicker({}); + userEvent.type(monthInput, '0{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('moves the cursor to the previous segment when the value is 0', () => { + const { yearInput, monthInput } = renderDatePicker({}); + userEvent.type(monthInput, '0{arrowleft}{arrowleft}'); + expect(yearInput).toHaveFocus(); + }); + + test('focuses the previous segment if the cursor is at the start of the input text', () => { + const { yearInput, monthInput } = renderDatePicker({ + value: testToday, + }); + userEvent.click(monthInput); + userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}'); + expect(yearInput).toHaveFocus(); + }); + }); + + describe('Right Arrow', () => { + test('focuses the next segment when the segment is empty', () => { + const { yearInput, monthInput } = renderDatePicker(); + userEvent.click(yearInput); + userEvent.keyboard('{arrowright}'); + expect(monthInput).toHaveFocus(); + }); + + test('focuses the next segment if the cursor is at the start of the input text', () => { + const { yearInput, monthInput } = renderDatePicker({ + value: testToday, + }); + userEvent.click(yearInput); + userEvent.keyboard('{arrowright}'); + expect(monthInput).toHaveFocus(); + }); + + test('moves the cursor when the segment has a value', () => { + const { yearInput } = renderDatePicker({ + value: testToday, + }); + userEvent.click(yearInput); + userEvent.keyboard('{arrowleft}{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + }); + + const segmentCases = ['year', 'month', 'day'] as Array; + describe.each(segmentCases)('%p segment', segment => { + const formatter = getValueFormatter(segment); + /** Utility only for this suite. Returns the day|month|year element from the render result */ + const getRelevantInput = (renderResult: RenderDatePickerResult) => + segment === 'year' + ? renderResult.yearInput + : segment === 'month' + ? renderResult.monthInput + : renderResult.dayInput; + + describe('Up Arrow', () => { + describe('when no value has been set', () => { + test('keeps the focus in the current segment', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + expect(input).toHaveFocus(); + }); + + test('updates segment value to the default min', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + const expectedValue = formatter(defaultMin[segment]); + expect(input).toHaveValue(expectedValue); + }); + + test('updates segment value to the provided min year', () => { + const result = renderDatePicker({ + min: newUTC(1967, Month.March, 10), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + const expectedValue = formatter( + segment === 'year' ? 1967 : defaultMin[segment], + ); + expect(input).toHaveValue(expectedValue); + }); + + test('keeps the focus in the current segment even if the value is valid', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowup}{arrowup}{arrowup}'); + expect(input).toHaveFocus(); + const expectedValue = formatter(defaultMin[segment] + 2); + expect(input).toHaveValue(expectedValue); + }); + + test(`fires segment change handler`, () => { + const onChange = jest.fn(); + const result = renderDatePicker({ onChange }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + const expectedValue = formatter(defaultMin[segment]); + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue(expectedValue), + ); + }); + + test(`does not fire value change handler`, () => { + const onDateChange = jest.fn(); + const result = renderDatePicker({ onDateChange }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + describe('when segment value is max', () => { + if (segment === 'year') { + test('does not roll over to the min value', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + const initialValue = formatter(defaultMax[segment]); + const expectedValue = formatter(defaultMax[segment] + 1); + userEvent.type(input, initialValue); + expect(input).toHaveValue(initialValue); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + expect(input).toHaveValue(expectedValue); + }); + } else { + test('rolls over to the min value', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + const initialValue = formatter(defaultMax[segment]); + const expectedValue = formatter(defaultMin[segment]); + userEvent.type(input, initialValue); + expect(input).toHaveValue(initialValue); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + expect(input).toHaveValue(expectedValue); + }); + } + }); + }); + + describe('when a value is set', () => { + describe('when the value is valid', () => { + const onDateChange = jest.fn(); + const handleValidation = jest.fn(); + const initialValue = newUTC(2023, Month.September, 10); + const expectedValue = { + year: newUTC(2024, Month.September, 10), + month: newUTC(2023, Month.October, 10), + day: newUTC(2023, Month.September, 11), + }[segment]; + + beforeEach(() => { + const result = renderDatePicker({ + onDateChange, + handleValidation, + value: initialValue, + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + }); + + test('fires value change handler', () => { + expect(onDateChange).toHaveBeenCalledWith(expectedValue); + }); + + test('fires validation handler', () => { + expect(handleValidation).toHaveBeenCalledWith( + expectedValue, + ); + }); + }); + + describe('if the new value would be invalid', () => { + // E.g. Feb 30 2020 or Feb 29 2021 + switch (segment) { + case 'year': { + test('changing year sets error state', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.February, 29), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2021'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('29'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + '2021-02-29 is not a valid date', + ); + }); + }); + + break; + } + + case 'month': { + test('changing month sets error state', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.January, 31), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2020'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('31'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + '2020-02-31 is not a valid date', + ); + }); + }); + + break; + } + + case 'day': { + test('changing date rolls value over sooner', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.February, 29), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowup}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2020'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('01'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'false', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).not.toBeInTheDocument(); + }); + }); + break; + } + + default: + break; + } + }); + + describe('if new value would be out of range', () => { + const onDateChange = jest.fn(); + const onSegmentChange = jest.fn(); + const handleValidation = jest.fn(); + const max = newUTC(2020, Month.August, 1); + const startValue = newUTC(2020, Month.August, 1); + const incrementedValues = { + year: newUTC(2021, Month.August, 1), + month: newUTC(2020, Month.September, 1), + day: newUTC(2020, Month.August, 2), + }; + const expectedMessage = `Date must be before ${getFormattedDateString( + max, + 'iso8601', + )}`; + + let renderResult: RenderDatePickerResult; + let input: HTMLInputElement; + + beforeEach(() => { + onDateChange.mockReset(); + onSegmentChange.mockReset(); + handleValidation.mockReset(); + + renderResult = renderDatePicker({ + max, + value: startValue, + onDateChange, + onChange: onSegmentChange, + handleValidation, + }); + + input = getRelevantInput(renderResult); + + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + }); + + test('fires the segment change handler', () => { + const expectedInputValue = getFormattedSegmentsFromDate( + incrementedValues[segment], + )[segment]; + + expect(onSegmentChange).toHaveBeenCalledWith( + eventContainingTargetValue(expectedInputValue), + ); + }); + + test('updates the input', () => { + const expectedInputValue = getFormattedSegmentsFromDate( + incrementedValues[segment], + )[segment]; + + expect(input).toHaveValue(expectedInputValue); + }); + + test('fires the change handler', () => { + expect(onDateChange).toHaveBeenCalledWith( + incrementedValues[segment], + ); + }); + + test('fires the validation handler', () => { + expect(handleValidation).toHaveBeenCalledWith( + incrementedValues[segment], + ); + }); + + test('sets aria-invalid', () => { + expect(renderResult.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + }); + + test('sets error message', () => { + const errorMessageElement = within( + renderResult.formField, + ).queryByText(expectedMessage); + expect(errorMessageElement).toBeInTheDocument(); + }); + }); + }); + }); + + describe('Down Arrow', () => { + describe('when no value has been set', () => { + test('keeps the focus in the current segment', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + expect(input).toHaveFocus(); + }); + + test('updates segment value to the default max', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowdown}`); + const expectedValue = formatter(defaultMax[segment]); + expect(input).toHaveValue(expectedValue); + }); + + test('updates segment value to the provided max year', () => { + const result = renderDatePicker({ + max: newUTC(2067, Month.March, 10), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowdown}`); + const expectedValue = formatter( + segment === 'year' ? 2067 : defaultMax[segment], + ); + expect(input).toHaveValue(expectedValue); + }); + + test('keeps the focus in the current segment even if the value is valid', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowdown}{arrowdown}{arrowdown}'); + expect(input).toHaveFocus(); + const expectedValue = formatter(defaultMax[segment] - 2); + expect(input).toHaveValue(expectedValue); + }); + + test(`fires segment change handler`, () => { + const onChange = jest.fn(); + const result = renderDatePicker({ onChange }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowdown}`); + const expectedValue = formatter(defaultMax[segment]); + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue(expectedValue), + ); + }); + + test(`does not fire value change handler`, () => { + const onDateChange = jest.fn(); + const result = renderDatePicker({ onDateChange }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowdown}`); + expect(onDateChange).not.toHaveBeenCalled(); + }); + + describe('when segment value is min', () => { + if (segment === 'year') { + test('does not roll over to the max value', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + const initialValue = formatter(defaultMin[segment]); + const expectedValue = formatter(defaultMin[segment] - 1); + userEvent.type(input, initialValue); + expect(input).toHaveValue(initialValue); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + expect(input).toHaveValue(expectedValue); + }); + } else { + test('rolls over to the max value', () => { + const result = renderDatePicker(); + const input = getRelevantInput(result); + const initialValue = formatter(defaultMin[segment]); + const expectedValue = formatter(defaultMax[segment]); + userEvent.type(input, initialValue); + expect(input).toHaveValue(initialValue); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + expect(input).toHaveValue(expectedValue); + }); + } + }); + }); + + describe('when a value is set', () => { + describe('when the value is valid', () => { + const onDateChange = jest.fn(); + const handleValidation = jest.fn(); + const initialValue = newUTC(2023, Month.September, 10); + const expectedValue = { + year: newUTC(2022, Month.September, 10), + month: newUTC(2023, Month.August, 10), + day: newUTC(2023, Month.September, 9), + }[segment]; + + beforeEach(() => { + const result = renderDatePicker({ + onDateChange, + handleValidation, + value: initialValue, + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard(`{arrowdown}`); + }); + + test('fires value change handler', () => { + expect(onDateChange).toHaveBeenCalledWith(expectedValue); + }); + + test('fires validation handler', () => { + expect(handleValidation).toHaveBeenCalledWith( + expectedValue, + ); + }); + }); + + describe('if the new value would be invalid', () => { + // E.g. Feb 30 2020 or Feb 29 2021 + switch (segment) { + case 'year': { + test('changing year sets error state', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.February, 29), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2019'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('29'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + '2019-02-29 is not a valid date', + ); + }); + }); + + break; + } + + case 'month': { + test('changing month sets error state', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.March, 31), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2020'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('31'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + '2020-02-31 is not a valid date', + ); + }); + }); + + break; + } + + case 'day': { + test('changing date rolls over to number of days-in-month', async () => { + const result = renderDatePicker({ + value: newUTC(2020, Month.February, 1), + }); + const input = getRelevantInput(result); + userEvent.click(input); + userEvent.keyboard('{arrowdown}'); + + await waitFor(() => { + expect(result.yearInput).toHaveValue('2020'); + expect(result.monthInput).toHaveValue('02'); + expect(result.dayInput).toHaveValue('29'); + expect(result.inputContainer).toHaveAttribute( + 'aria-invalid', + 'false', + ); + const errorElement = result.queryByTestId( + 'lg-form_field-error_message', + ); + expect(errorElement).not.toBeInTheDocument(); + }); + }); + break; + } + + default: + break; + } + }); + + describe('if new value would be out of range', () => { + const onDateChange = jest.fn(); + const onSegmentChange = jest.fn(); + const handleValidation = jest.fn(); + const max = newUTC(2020, Month.August, 1); + const startValue = newUTC(2020, Month.August, 1); + const incrementedValues = { + year: newUTC(2021, Month.August, 1), + month: newUTC(2020, Month.September, 1), + day: newUTC(2020, Month.August, 2), + }; + const expectedMessage = `Date must be before ${getFormattedDateString( + max, + 'iso8601', + )}`; + + let renderResult: RenderDatePickerResult; + let input: HTMLInputElement; + + beforeEach(() => { + onDateChange.mockReset(); + onSegmentChange.mockReset(); + handleValidation.mockReset(); + + renderResult = renderDatePicker({ + max, + value: startValue, + onDateChange, + onChange: onSegmentChange, + handleValidation, + }); + + input = getRelevantInput(renderResult); + + userEvent.click(input); + userEvent.keyboard(`{arrowup}`); + }); + + test('fires the segment change handler', () => { + const expectedInputValue = getFormattedSegmentsFromDate( + incrementedValues[segment], + )[segment]; + + expect(onSegmentChange).toHaveBeenCalledWith( + eventContainingTargetValue(expectedInputValue), + ); + }); + + test('updates the input', () => { + const expectedInputValue = getFormattedSegmentsFromDate( + incrementedValues[segment], + )[segment]; + + expect(input).toHaveValue(expectedInputValue); + }); + + test('fires the change handler', () => { + expect(onDateChange).toHaveBeenCalledWith( + incrementedValues[segment], + ); + }); + + test('fires the validation handler', () => { + expect(handleValidation).toHaveBeenCalledWith( + incrementedValues[segment], + ); + }); + + test('sets aria-invalid', () => { + expect(renderResult.inputContainer).toHaveAttribute( + 'aria-invalid', + 'true', + ); + }); + + test('sets error message', () => { + const errorMessageElement = within( + renderResult.formField, + ).queryByText(expectedMessage); + expect(errorMessageElement).toBeInTheDocument(); + }); + }); + }); + }); + }); + }); + + describe('Menu', () => { + beforeEach(() => { + jest.setSystemTime(testToday); + mockTimeZone('America/New_York', -5); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('basic arrow key behavior', () => { + let menuResult: RenderMenuResult; + + beforeEach(async () => { + const renderResult = renderDatePicker({ + value: newUTC(2023, Month.September, 10), + }); + menuResult = await renderResult.openMenu(); + }); + + test('left arrow moves focus to the previous day', async () => { + const { queryCellByISODate } = menuResult; + userEvent.keyboard('{arrowleft}'); + const prevDay = queryCellByISODate('2023-09-09'); + await waitFor(() => expect(prevDay).toHaveFocus()); + }); + + test('right arrow moves focus to the next day', async () => { + const { queryCellByISODate } = menuResult; + userEvent.keyboard('{arrowright}'); + const nextDay = queryCellByISODate('2023-09-11'); + await waitFor(() => expect(nextDay).toHaveFocus()); + }); + + test('up arrow moves focus to the previous week', async () => { + const { queryCellByISODate } = menuResult; + userEvent.keyboard('{arrowup}'); + const prevWeek = queryCellByISODate('2023-09-03'); + await waitFor(() => expect(prevWeek).toHaveFocus()); + }); + + test('down arrow moves focus to the next week', async () => { + const { queryCellByISODate } = menuResult; + userEvent.keyboard('{arrowdown}'); + const nextWeek = queryCellByISODate('2023-09-17'); + await waitFor(() => expect(nextWeek).toHaveFocus()); + }); + }); + + describe('when switching between daylight savings and standard time', () => { + // DST: Sun, Mar 12, 2023 – Sun, Nov 5, 2023 + + const standardTimeEndDate = newUTC(2023, Month.March, 11, 22); + const weekBeforeDTStart = newUTC(2023, Month.March, 5, 22); + const daylightTimeStartDate = newUTC(2023, Month.March, 12, 22); + const daylightTimeEndDate = newUTC(2023, Month.November, 5, 22); + const weekAfterDTEnd = newUTC(2023, Month.November, 12, 22); + const standardTimeStartDate = newUTC(2023, Month.November, 6, 22); + + describe('DST start (Mar 12 2023)', () => { + test('left arrow moves focus to prev day', async () => { + jest.setSystemTime(daylightTimeStartDate); // Mar 12 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + const currentDayCell = queryCellByISODate('2023-03-12'); // Mar 12 + await waitFor(() => expect(currentDayCell).toHaveFocus()); + + userEvent.keyboard('{arrowleft}'); + const prevDayCell = queryCellByISODate('2023-03-11'); // Mar 11 + await waitFor(() => expect(prevDayCell).toHaveFocus()); + }); + + test('right arrow moves focus to next day', async () => { + jest.setSystemTime(standardTimeEndDate); // Mar 11 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + const currentDayCell = queryCellByISODate('2023-03-11'); // Mar 11 + await waitFor(() => expect(currentDayCell).toHaveFocus()); + + userEvent.keyboard('{arrowright}'); + const nextDayCell = queryCellByISODate('2023-03-12'); // Mar 12 + await waitFor(() => expect(nextDayCell).toHaveFocus()); + }); + + test('up arrow moves focus to the previous week', async () => { + jest.setSystemTime(daylightTimeStartDate); // Mar 12 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowup}'); + const prevWeekCell = queryCellByISODate('2023-03-05'); // Mar 5 + await waitFor(() => expect(prevWeekCell).toHaveFocus()); + }); + + test('down arrow moves focus to the next week', async () => { + jest.setSystemTime(weekBeforeDTStart); // Mar 5 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowdown}'); + const nextWeekCell = queryCellByISODate('2023-03-12'); // Mar 12 + await waitFor(() => expect(nextWeekCell).toHaveFocus()); + }); + }); + + describe('DST end (Nov 5 2023)', () => { + test('left arrow moves focus to prev day', async () => { + jest.setSystemTime(standardTimeStartDate); // Nov 6 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowleft}'); + const prevDayCell = queryCellByISODate('2023-11-05'); // Nov 5 + + await waitFor(() => expect(prevDayCell).toHaveFocus()); + }); + + test('right arrow moves focus to next day', async () => { + jest.setSystemTime(daylightTimeEndDate); // Nov 5 + + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowright}'); + + const nextDayCell = queryCellByISODate('2023-11-06'); // Nov 6 + await waitFor(() => expect(nextDayCell).toHaveFocus()); + }); + + test('up arrow moves focus to the previous week', async () => { + jest.setSystemTime(weekAfterDTEnd); // Nov 12 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowup}'); + + const prevWeekCell = queryCellByISODate('2023-11-05'); // Nov 5 + await waitFor(() => expect(prevWeekCell).toHaveFocus()); + }); + + test('down arrow moves focus to the next week', async () => { + jest.setSystemTime(daylightTimeEndDate); // Nov 5 + const { openMenu } = renderDatePicker(); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowdown}'); + + const nextWeekCell = queryCellByISODate('2023-11-12'); // Nov 12 + await waitFor(() => expect(nextWeekCell).toHaveFocus()); + }); + }); + }); + + describe('when next day would be out of range', () => { + const testValue = newUTC(2023, Month.September, 10); + const isoString = '2023-09-10'; + + test('left arrow does nothing', async () => { + const { openMenu } = renderDatePicker({ + value: testValue, + min: testValue, + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowleft}'); + await waitFor(() => + expect(queryCellByISODate(isoString)).toHaveFocus(), + ); + }); + + test('right arrow does nothing', async () => { + const { openMenu } = renderDatePicker({ + value: testValue, + max: testValue, + }); + const { queryCellByISODate } = await openMenu(); + + userEvent.keyboard('{arrowright}'); + await waitFor(() => + expect(queryCellByISODate(isoString)).toHaveFocus(), + ); + }); + + test('up arrow does nothing', async () => { + const { openMenu } = renderDatePicker({ + value: testValue, + min: addDays(testValue, -6), + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowup}'); + await waitFor(() => + expect(queryCellByISODate(isoString)).toHaveFocus(), + ); + }); + test('down arrow does nothing', async () => { + const { openMenu } = renderDatePicker({ + value: testValue, + max: addDays(testValue, 6), + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowdown}'); + await waitFor(() => + expect(queryCellByISODate(isoString)).toHaveFocus(), + ); + }); + }); + + describe('update the displayed month', () => { + test('left arrow updates displayed month to previous', async () => { + const value = new Date(Date.UTC(2023, Month.September, 1)); + const { openMenu } = renderDatePicker({ value }); + const { calendarGrid } = await openMenu(); + + userEvent.keyboard('{arrowleft}'); + expect(calendarGrid).toHaveAttribute('aria-label', 'August 2023'); + }); + + test('right arrow updates displayed month to next', async () => { + const value = new Date(Date.UTC(2023, Month.September, 30)); + const { openMenu } = renderDatePicker({ value }); + const { calendarGrid } = await openMenu(); + userEvent.keyboard('{arrowright}'); + expect(calendarGrid).toHaveAttribute( + 'aria-label', + 'October 2023', + ); + }); + + test('up arrow updates displayed month to previous', async () => { + const value = new Date(Date.UTC(2023, Month.September, 6)); + const { openMenu } = renderDatePicker({ value }); + const { calendarGrid } = await openMenu(); + + userEvent.keyboard('{arrowup}'); + expect(calendarGrid).toHaveAttribute('aria-label', 'August 2023'); + }); + + test('down arrow updates displayed month to next', async () => { + const value = new Date(Date.UTC(2023, Month.September, 25)); + const { openMenu } = renderDatePicker({ value }); + const { calendarGrid } = await openMenu(); + + userEvent.keyboard('{arrowdown}'); + expect(calendarGrid).toHaveAttribute( + 'aria-label', + 'October 2023', + ); + }); + + test('does not update month when month does not need to change', async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2023, Month.September, 10), + }); + const { calendarGrid } = await openMenu(); + + userEvent.tab(); + userEvent.keyboard('{arrowleft}{arrowright}{arrowup}{arrowdown}'); + expect(calendarGrid).toHaveAttribute( + 'aria-label', + 'September 2023', + ); + }); + }); + + describe('when month should be updated', () => { + test('left arrow focuses the previous day', async () => { + const value = newUTC(2023, Month.September, 1); + const { openMenu } = renderDatePicker({ + value, + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowleft}'); + const highlightedCell = queryCellByISODate('2023-08-31'); + await waitFor(() => expect(highlightedCell).toHaveFocus()); + }); + test('right arrow focuses the next day', async () => { + const value = newUTC(2023, Month.September, 30); + const { openMenu } = renderDatePicker({ + value, + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowright}'); + const highlightedCell = queryCellByISODate('2023-10-01'); + await waitFor(() => expect(highlightedCell).toHaveFocus()); + }); + test('up arrow focuses the previous week', async () => { + const value = newUTC(2023, Month.September, 7); + const { openMenu } = renderDatePicker({ + value, + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowup}'); + const highlightedCell = queryCellByISODate('2023-08-31'); + await waitFor(() => expect(highlightedCell).toHaveFocus()); + }); + test('down arrow focuses the next week', async () => { + const value = newUTC(2023, Month.September, 24); + const { openMenu } = renderDatePicker({ + value, + }); + const { queryCellByISODate } = await openMenu(); + userEvent.keyboard('{arrowdown}'); + const highlightedCell = queryCellByISODate('2023-10-01'); + await waitFor(() => expect(highlightedCell).toHaveFocus()); + }); + }); + + describe('focus-trap', () => { + test('when a cell is focused, pressing tab moves the focus to the left chevron', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, leftChevron } = await openMenu(); + expect(todayCell).toHaveFocus(); + userEvent.tab(); + expect(leftChevron).toHaveFocus(); + }); + + test('when a cell is focused, pressing tab + shift moves the focus to the right chevron', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, rightChevron } = await openMenu(); + expect(todayCell).toHaveFocus(); + userEvent.tab({ shift: true }); + expect(rightChevron).toHaveFocus(); + }); + + test('when the left chevron is focused, pressing tab + shift moves the focus to todays cell', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, leftChevron } = await openMenu(); + userEvent.tab(); + expect(leftChevron).toHaveFocus(); + userEvent.tab({ shift: true }); + expect(todayCell).toHaveFocus(); + }); + + test('when the right chevron is focused, pressing tab moves the focus to todays cell', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, rightChevron } = await openMenu(); + userEvent.tab({ shift: true }); + expect(rightChevron).toHaveFocus(); + userEvent.tab(); + expect(todayCell).toHaveFocus(); + }); + }); + }); + }); + }); + + describe('Typing', () => { + test('does not open the menu', async () => { + const { yearInput, findMenuElements } = renderDatePicker(); + userEvent.tab(); + expect(yearInput).toHaveFocus(); + userEvent.keyboard('2'); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).not.toBeInTheDocument(); + }); + + describe('into a single segment', () => { + test('does not fire a value change handler', async () => { + const onDateChange = jest.fn(); + const { yearInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2023'); + await waitFor(() => expect(onDateChange).not.toHaveBeenCalled()); + }); + + test('does not fire a validation handler', async () => { + const handleValidation = jest.fn(); + const { yearInput } = renderDatePicker({ + handleValidation, + }); + userEvent.type(yearInput, '2023'); + await waitFor(() => expect(handleValidation).not.toHaveBeenCalled()); + }); + + test('fires a segment change handler', async () => { + const onChange = jest.fn(); + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '2023'); + await waitFor(() => + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('2023'), + ), + ); + }); + + test('does not immediately format the segment (year)', async () => { + const onChange = jest.fn(); + const { yearInput } = renderDatePicker({ onChange }); + userEvent.type(yearInput, '20'); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('20'), + ); + expect(yearInput.value).toBe('20'); + }); + }); + + test('does not immediately format the segment (day)', async () => { + const onChange = jest.fn(); + const { dayInput } = renderDatePicker({ onChange }); + userEvent.type(dayInput, '2'); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('2'), + ); + expect(dayInput.value).toBe('2'); + }); + }); + + test('allows typing multiple digits', async () => { + const onChange = jest.fn(); + const { dayInput } = renderDatePicker({ onChange }); + userEvent.type(dayInput, '26'); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('26'), + ); + expect(dayInput.value).toBe('26'); + }); + }); + + describe('typing space', () => { + describe('single space', () => { + describe('does not fire a segment value change', () => { + test('when the value prop is set', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + value: newUTC(2023, Month.December, 25), + }); + userEvent.type(yearInput, '{space}'); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('when typing another digit', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}2'); + expect(onChange).not.toHaveBeenCalledWith( + expect.objectContaining({ value: ' 2' }), + ); + }); + + test('when there is no value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}'); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('renders the correct value when the space is', () => { + test('at the start of a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}2023'); + expect(yearInput.value).toBe('2023'); + }); + + test('at the end of a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '2023{space}'); + expect(yearInput.value).toBe('2023'); + }); + + test('between a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '202{space}3'); + expect(yearInput.value).toBe('2023'); + }); + + test('in multiple spots', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '2{space}0{space}2{space}3{space}'); + expect(yearInput.value).toBe('2023'); + }); + }); + + test('opens the menu', async () => { + const { yearInput, findMenuElements } = renderDatePicker({}); + userEvent.type(yearInput, '{space}'); + const { menuContainerEl } = await findMenuElements(); + expect(menuContainerEl).toBeInTheDocument(); + }); + }); + + describe('double space', () => { + describe('does not fire a segment value change', () => { + test('when the value prop is set', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + value: newUTC(2023, Month.December, 25), + }); + userEvent.type(yearInput, '{space}{space}'); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('when typing another digit', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}{space}2'); + expect(onChange).not.toHaveBeenCalledWith( + expect.objectContaining({ value: ' 2' }), + ); + }); + + test('when there is no value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}{space}'); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('in multiple spots', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type( + yearInput, + '2{space}{space}0{space}{space}2{space}{space}3{space}{space}', + ); + expect(yearInput.value).toBe('2023'); + }); + }); + + describe('renders the correct value when the space is', () => { + test('at the start of a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '{space}{space}2023'); + expect(yearInput.value).toBe('2023'); + }); + + test('at the end of a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '2023{space}{space}'); + expect(yearInput.value).toBe('2023'); + }); + + test('between a value', () => { + const onChange = jest.fn(); + + const { yearInput } = renderDatePicker({ + onChange, + }); + userEvent.type(yearInput, '202{space}{space}3'); + expect(yearInput.value).toBe('2023'); + }); + }); + }); + }); + + describe('auto-formatting & auto-focus', () => { + describe('for ISO format', () => { + const locale = 'iso8601'; + + test('when year value is explicit, focus advances to month', () => { + const { yearInput, monthInput } = renderDatePicker({ + locale, + }); + userEvent.type(yearInput, '1999'); + expect(monthInput).toHaveFocus(); + }); + test('when year value is before MIN, focus still advances', () => { + const { yearInput, monthInput } = renderDatePicker({ + locale, + }); + userEvent.type(yearInput, '1944'); + expect(monthInput).toHaveFocus(); + }); + test('when year value is after MAX, focus still advances', () => { + const { yearInput, monthInput } = renderDatePicker({ + locale, + }); + userEvent.type(yearInput, '2048'); + expect(monthInput).toHaveFocus(); + }); + test('when month value is explicit, focus advances to day', () => { + const { monthInput, dayInput } = renderDatePicker({ + locale, + }); + userEvent.type(monthInput, '5'); + expect(dayInput).toHaveFocus(); + }); + test('when day value is explicit, format the day', async () => { + const { dayInput } = renderDatePicker({ + locale, + }); + userEvent.type(dayInput, '5'); + expect(dayInput).toHaveFocus(); + await waitFor(() => expect(dayInput).toHaveValue('05')); + }); + + test('when year value is ambiguous, focus does NOT advance', () => { + const { yearInput } = renderDatePicker({ locale }); + userEvent.type(yearInput, '200'); + expect(yearInput).toHaveFocus(); + }); + test('when month value is ambiguous, focus does NOT advance', () => { + const { monthInput } = renderDatePicker({ + locale, + }); + userEvent.type(monthInput, '1'); + expect(monthInput).toHaveFocus(); + }); + test('when day value is ambiguous, segment is NOT formatted', async () => { + const { dayInput } = renderDatePicker({ + locale, + }); + userEvent.type(dayInput, '2'); + expect(dayInput).toHaveFocus(); + await waitFor(() => expect(dayInput).toHaveValue('2')); + }); + + test('when day value is explicit, segment is formatted', async () => { + const { dayInput } = renderDatePicker({ + locale, + }); + userEvent.type(dayInput, '26'); + expect(dayInput).toHaveFocus(); + await waitFor(() => expect(dayInput).toHaveValue('26')); + }); + }); + + describe('for en-US format', () => { + const locale = 'en-US'; + + test('when month value is explicit, focus advances to day', () => { + const { monthInput, dayInput } = renderDatePicker({ + locale, + }); + userEvent.type(monthInput, '5'); + expect(dayInput).toHaveFocus(); + }); + test('when day value is explicit, focus advances to year', () => { + const { dayInput, yearInput } = renderDatePicker({ + locale, + }); + userEvent.type(dayInput, '5'); + expect(yearInput).toHaveFocus(); + }); + test('when year value is explicit, segment value is set', () => { + const { yearInput } = renderDatePicker({ + locale, + }); + userEvent.type(yearInput, '1999'); + expect(yearInput).toHaveFocus(); + expect(yearInput).toHaveValue('1999'); + }); + + test('when month value is ambiguous, focus does NOT advance', () => { + const { monthInput } = renderDatePicker({ locale }); + userEvent.type(monthInput, '1'); + expect(monthInput).toHaveFocus(); + }); + test('when day value is ambiguous, focus does NOT advance', () => { + const { dayInput } = renderDatePicker({ locale }); + userEvent.type(dayInput, '2'); + expect(dayInput).toHaveFocus(); + }); + test('when year value is ambiguous, segment does not format', async () => { + const { yearInput } = renderDatePicker({ + locale, + }); + userEvent.type(yearInput, '200'); + expect(yearInput).toHaveFocus(); + await waitFor(() => expect(yearInput).toHaveValue('200')); + }); + }); + }); + }); + + describe('typing a full date value', () => { + describe('if the date is valid', () => { + test('fires value change handler for explicit values', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2003'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + + await waitFor(() => + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2003, Month.December, 26)), + ), + ); + }); + + test('does not fire value change handler for ambiguous values', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2003'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '2'); + + await waitFor(() => expect(onDateChange).not.toHaveBeenCalled()); + }); + + test('properly renders the input', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2003'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + await waitFor(() => { + expect(yearInput).toHaveValue('2003'); + expect(monthInput).toHaveValue('12'); + expect(dayInput).toHaveValue('26'); + }); + }); + }); + + describe('if the value is not a valid date', () => { + // E.g. Feb 31 2020 + test('the input is rendered with the typed date', async () => { + const { yearInput, monthInput, dayInput } = renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '02'); + userEvent.type(dayInput, '31'); + await waitFor(() => { + expect(yearInput).toHaveValue('2020'); + expect(monthInput).toHaveValue('02'); + expect(dayInput).toHaveValue('31'); + }); + }); + + test('an error is displayed', () => { + const { + yearInput, + monthInput, + dayInput, + inputContainer, + queryByTestId, + } = renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '02'); + userEvent.type(dayInput, '31'); + expect(inputContainer).toHaveAttribute('aria-invalid', 'true'); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent( + '2020-02-31 is not a valid date', + ); + }); + }); + + describe('if value is out of range', () => { + test('still fires a value change handler if value is after MAX', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2048'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + await waitFor(() => + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2048, Month.December, 26)), + ), + ); + }); + + test('properly renders input if value is after MAX', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2048'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '23'); + + await waitFor(() => { + expect(yearInput).toHaveValue('2048'); + expect(monthInput).toHaveValue('12'); + expect(dayInput).toHaveValue('23'); + }); + }); + + test('fire a value change handler if value is before MIN', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '1969'); + userEvent.type(monthInput, '7'); + userEvent.type(dayInput, '20'); + await waitFor(() => + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(1969, Month.July, 20)), + ), + ); + }); + + test('properly renders input if value is before MIN', async () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '1969'); + userEvent.type(monthInput, '7'); + userEvent.type(dayInput, '20'); + await waitFor(() => { + expect(yearInput).toHaveValue('1969'); + expect(monthInput).toHaveValue('07'); + expect(dayInput).toHaveValue('20'); + }); + }); + }); + }); + + describe('updating a segment', () => { + describe('backspace', () => { + test('clearing the segment updates the input', () => { + const { yearInput, monthInput, dayInput } = renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '7'); + userEvent.type(dayInput, '4'); + + yearInput.setSelectionRange(0, 4); + userEvent.type(yearInput, '{backspace}'); + expect(yearInput).toHaveValue(''); + }); + + test('keeps the focus inside the segment if it is not empty', () => { + const { monthInput } = renderDatePicker({}); + + userEvent.type(monthInput, '0'); + userEvent.type(monthInput, '{backspace}'); + + expect(monthInput).toHaveValue(''); + expect(monthInput).toHaveFocus(); + }); + + test('moves the focus to the next segment', () => { + const { yearInput, monthInput } = renderDatePicker({}); + + userEvent.type(monthInput, '0'); + userEvent.type(monthInput, '{backspace}{backspace}'); + + expect(monthInput).toHaveValue(''); + expect(yearInput).toHaveFocus(); + }); + + test('clearing and typing a new value does not format the input', () => { + const { yearInput, monthInput, dayInput } = renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '7'); + userEvent.type(dayInput, '4'); + + yearInput.setSelectionRange(0, 4); + userEvent.type(yearInput, '{backspace}'); + userEvent.type(yearInput, '2'); + expect(yearInput).toHaveValue('2'); + }); + + test('deleting characters does not format the segment', () => { + const { yearInput, monthInput, dayInput } = renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '7'); + userEvent.type(dayInput, '4'); + + userEvent.type(yearInput, '{backspace}{backspace}'); + expect(yearInput).toHaveValue('20'); + }); + }); + + describe('typing new characters', () => { + test('even if the resulting value is valid, keeps the input as-is', async () => { + const { monthInput } = renderDatePicker({}); + userEvent.type(monthInput, '1'); + userEvent.tab(); + await waitFor(() => expect(monthInput).toHaveValue('01')); + userEvent.type(monthInput, '2'); + await waitFor(() => expect(monthInput).toHaveValue('01')); + }); + + test('if the resulting value is not valid, keeps the input as-is', async () => { + const { monthInput } = renderDatePicker({}); + userEvent.type(monthInput, '6'); + await waitFor(() => expect(monthInput).toHaveValue('06')); + userEvent.type(monthInput, '9'); + await waitFor(() => expect(monthInput).toHaveValue('06')); + }); + }); + }); + + describe('on segment un-focus/blur', () => { + test('fires a segment change handler', () => { + const onChange = jest.fn(); + const { yearInput } = renderDatePicker({ onChange }); + userEvent.type(yearInput, '2023'); + userEvent.tab(); + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('2023'), + ); + }); + + test('formats the segment', () => { + const onChange = jest.fn(); + const { dayInput } = renderDatePicker({ onChange }); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(onChange).toHaveBeenCalledWith( + eventContainingTargetValue('02'), + ); + expect(dayInput.value).toBe('02'); + }); + + describe('if the date value is incomplete', () => { + test('does not fire a value change handler', () => { + const onDateChange = jest.fn(); + const { yearInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2023'); + userEvent.tab(); + expect(onDateChange).not.toHaveBeenCalled(); + }); + }); + + describe('if the date value is valid', () => { + test('fires a value change handler', () => { + const onDateChange = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + onDateChange, + }); + userEvent.type(yearInput, '2023'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + userEvent.tab(); + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2023, Month.December, 26)), + ); + }); + + test('fires a validation handler when the value is first set', () => { + const handleValidation = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDatePicker({ + handleValidation, + }); + userEvent.type(yearInput, '2023'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + userEvent.tab(); + expect(handleValidation).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2023, Month.December, 26)), + ); + }); + + test('fires a validation handler any time the value is updated', () => { + const handleValidation = jest.fn(); + const { dayInput } = renderDatePicker({ + value: new Date(), + handleValidation, + }); + userEvent.type(dayInput, '05'); + userEvent.tab(); + expect(handleValidation).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2023, Month.December, 5)), + ); + }); + }); + }); + }); + + // TODO: Move these suites to Cypress (or other e2e/integration platform) + describe('User flows', () => { + test('month is updated when value changes externally', async () => { + const value = newUTC(2023, Month.September, 10); + const { calendarButton, waitForMenuToOpen, rerenderDatePicker } = + renderDatePicker(); + rerenderDatePicker({ value }); + userEvent.click(calendarButton); + const { calendarGrid } = await waitForMenuToOpen(); + await waitFor(() => + expect(calendarGrid).toHaveAttribute('aria-label', 'September 2023'), + ); + }); + + describe('setting the date to an out-of-range value', () => { + describe('with initial value', () => { + let menuElements: RenderMenuResult; + + beforeEach(async () => { + const { openMenu } = renderDatePicker({ + value: newUTC(2038, Month.December, 25), + }); + menuElements = await openMenu(); + }); + + test('sets displayed month to that month', () => { + expect(menuElements.calendarGrid).toHaveAttribute( + 'aria-label', + 'December 2038', + ); + }); + + test.todo('sets the error state'); + }); + + describe('with arrow keys', () => { + const onDateChange = jest.fn(); + let menuElements: RenderMenuResult; + + beforeEach(async () => { + const { yearInput, waitForMenuToOpen, findMenuElements } = + renderDatePicker({ + value: newUTC(2037, Month.December, 25), + onDateChange, + }); + userEvent.click(yearInput); + await waitForMenuToOpen(); + userEvent.keyboard('{arrowup}'); + menuElements = await findMenuElements(); + }); + + test('fires onDateChange handler', async () => { + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2038, Month.December, 25)), + ); + }); + + test('sets displayed month to that month', async () => { + expect(menuElements.calendarGrid).toHaveAttribute( + 'aria-label', + 'December 2038', + ); + }); + + test.todo('sets the error state'); + }); + + describe('by typing', () => { + let menuElements: RenderMenuResult; + const onDateChange = jest.fn(); + + beforeEach(async () => { + const { + yearInput, + monthInput, + dayInput, + calendarButton, + waitForMenuToOpen, + } = renderDatePicker({ onDateChange }); + userEvent.type(yearInput, '2037'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '25'); + userEvent.click(calendarButton); + menuElements = await waitForMenuToOpen(); + }); + + test('fires onDateChange handler', () => { + expect(onDateChange).toHaveBeenCalledWith( + expect.objectContaining(newUTC(2037, Month.December, 25)), + ); + }); + + test('sets displayed month to that month', () => { + expect(menuElements.calendarGrid).toHaveAttribute( + 'aria-label', + 'December 2037', + ); + }); + + test('focuses the correct date in the calendar', () => { + const value = new Date(Date.UTC(2037, Month.December, 25)); + userEvent.tab(); + + const valueCell = menuElements.calendarGrid!.querySelector( + `[data-iso="${getISODate(value)}"]`, + ); + expect(valueCell).toHaveFocus(); + }); + + test.todo('sets the error state'); + }); + }); + + describe('When closing and re-opening the menu', () => { + test('month is reset to today by default', async () => { + const { openMenu } = renderDatePicker(); + const { calendarGrid, menuContainerEl } = await openMenu(); + + expect(calendarGrid).toHaveAttribute('aria-label', 'December 2023'); + + userEvent.keyboard('{arrowdown}'); + expect(calendarGrid).toHaveAttribute('aria-label', 'January 2024'); + + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + + await openMenu(); + expect(calendarGrid).toHaveAttribute('aria-label', 'December 2023'); + }); + + test('month is reset to value', async () => { + const value = newUTC(2023, Month.September, 10); + + const { openMenu } = renderDatePicker({ + value, + }); + const { calendarGrid, menuContainerEl } = await openMenu(); + + expect(calendarGrid).toHaveAttribute('aria-label', 'September 2023'); + + userEvent.keyboard('{arrowup}{arrowup}'); + expect(calendarGrid).toHaveAttribute('aria-label', 'August 2023'); + + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + + await openMenu(); + expect(calendarGrid).toHaveAttribute('aria-label', 'September 2023'); + }); + + test('highlight returns to today by default', async () => { + const { openMenu } = renderDatePicker(); + const { todayCell, menuContainerEl, queryCellByDate } = + await openMenu(); + expect(todayCell).toHaveFocus(); + + userEvent.keyboard('{arrowdown}'); + const jan2 = addDays(testToday, 7); + const jan2Cell = queryCellByDate(jan2); + await waitFor(() => expect(jan2Cell).toHaveFocus()); + + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + + const { todayCell: newTodayCell } = await openMenu(); + expect(newTodayCell).toHaveFocus(); + }); + + test('highlight returns to value', async () => { + const value = newUTC(2023, Month.September, 10); + const { openMenu, findMenuElements } = renderDatePicker({ + value, + }); + let queryCellByDate = (await openMenu()).queryCellByDate; + const { menuContainerEl } = await findMenuElements(); + let valueCell = queryCellByDate(value); + expect(valueCell).not.toBeNull(); + await waitFor(() => expect(valueCell).toHaveFocus()); + + userEvent.keyboard('{arrowup}{arrowup}'); + const aug27 = subDays(value, 14); + const aug27Cell = queryCellByDate(aug27); + await waitFor(() => expect(aug27Cell).toHaveFocus()); + + userEvent.keyboard('{escape}'); + await waitForElementToBeRemoved(menuContainerEl); + + queryCellByDate = (await openMenu()).queryCellByDate; + valueCell = queryCellByDate(value); + expect(valueCell).not.toBeNull(); + await waitFor(() => expect(valueCell).toHaveFocus()); + }); + }); + + describe('Changing the month', () => { + test.todo('is announced in an aria-live region'); + + describe('updates the highlighted cell...', () => { + test('to the end of the month if we went backwards', async () => { + const { openMenu, findAllByRole } = renderDatePicker({ + value: newUTC(2023, Month.July, 5), + }); + const { monthSelect, queryCellByDate } = await openMenu(); + userEvent.click(monthSelect!); + const options = await findAllByRole('option'); + const Jan = options[0]; + userEvent.click(Jan); + tabNTimes(3); + const jan31Cell = queryCellByDate(newUTC(2023, Month.January, 31)); + await waitFor(() => expect(jan31Cell).toHaveFocus()); + }); + test('to the beginning of the month if we went forwards', async () => { + const { openMenu, findAllByRole } = renderDatePicker({ + value: newUTC(2023, Month.July, 5), + }); + const { monthSelect, queryCellByDate } = await openMenu(); + userEvent.click(monthSelect!); + const options = await findAllByRole('option'); + const Dec = options[11]; + userEvent.click(Dec); + tabNTimes(3); + const dec1Cell = queryCellByDate(newUTC(2023, Month.December, 1)); + await waitFor(() => expect(dec1Cell).toHaveFocus()); + }); + }); + + describe('shows the correct date in the input', () => { + test('after selecting a month and clicking a cell', async () => { + const { openMenu, findAllByRole, dayInput, monthInput, yearInput } = + renderDatePicker({ initialValue: new Date() }); + const { monthSelect, queryCellByDate } = await openMenu(); + userEvent.click(monthSelect!); + const options = await findAllByRole('option'); + const Jan = options[0]; + userEvent.click(Jan); + + const jan1Cell = queryCellByDate(newUTC(2023, Month.January, 1)); + userEvent.click(jan1Cell!); + + await waitFor(() => { + expect(dayInput.value).toEqual('01'); + expect(monthInput.value).toEqual('01'); + expect(yearInput.value).toEqual('2023'); + }); + }); + + test('after selecting a month and clicking a cell a second time', async () => { + const { openMenu, findAllByRole, dayInput, monthInput, yearInput } = + renderDatePicker({ initialValue: new Date() }); + const { monthSelect, queryCellByDate } = await openMenu(); + userEvent.click(monthSelect!); + const options = await findAllByRole('option'); + const Jan = options[0]; + userEvent.click(Jan); + + const jan1Cell = queryCellByDate(newUTC(2023, Month.January, 1)); + userEvent.click(jan1Cell!); + + const Feb = options[1]; + userEvent.click(Feb); + + const feb1Cell = queryCellByDate(newUTC(2023, Month.February, 1)); + userEvent.click(feb1Cell!); + + await waitFor(() => { + expect(dayInput.value).toEqual('01'); + expect(monthInput.value).toEqual('02'); + expect(yearInput.value).toEqual('2023'); + }); + }); + }); + }); + + describe('Changing the year', () => { + test.todo('is announced in an aria-live region'); + + describe('displays the same month', () => { + test('when the month is in range', async () => { + const { openMenu, findAllByRole } = renderDatePicker({ + value: newUTC(2006, Month.July, 4), + min: newUTC(1996, Month.January, 1), + }); + + const { yearSelect, calendarGrid } = await openMenu(); + userEvent.click(yearSelect!); + const options = await findAllByRole('option'); + const firstYear = options[0]; // 1996 + userEvent.click(firstYear); + + expect(calendarGrid).toHaveAttribute('aria-label', 'July 1996'); + }); + + test('when the month is not in range', async () => { + const { openMenu, findAllByRole } = renderDatePicker({ + value: newUTC(2006, Month.July, 4), + min: newUTC(1996, Month.September, 10), + }); + + const { yearSelect, calendarGrid } = await openMenu(); + userEvent.click(yearSelect!); + const options = await findAllByRole('option'); + const firstYear = options[0]; // 1996 + userEvent.click(firstYear); + + expect(calendarGrid).toHaveAttribute('aria-label', 'July 1996'); + }); + }); + + describe('shows the correct date in the input', () => { + test('after selecting a year and clicking a cell', async () => { + const { openMenu, findAllByRole, dayInput, monthInput, yearInput } = + renderDatePicker({ + initialValue: new Date(), // dec 26 2023 + min: newUTC(1996, Month.January, 1), + max: newUTC(2026, Month.January, 1), + }); + const { yearSelect, queryCellByDate } = await openMenu(); + userEvent.click(yearSelect!); + const options = await findAllByRole('option'); + const firstYear = options[0]; // 1996 + + userEvent.click(firstYear); + + const dec1Cell = queryCellByDate(newUTC(1996, Month.December, 1)); + userEvent.click(dec1Cell!); + + await waitFor(() => { + expect(dayInput.value).toEqual('01'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('1996'); + }); + }); + + test('after selecting a year and clicking a cell a second time', async () => { + const { openMenu, dayInput, monthInput, yearInput } = + renderDatePicker({ + initialValue: new Date(), // dec 26 2023 + min: newUTC(1996, Month.January, 1), + max: newUTC(2026, Month.January, 1), + }); + + // Open the menu the first time + { + const { yearSelect, queryCellByDate, menuContainerEl } = + await openMenu(); + userEvent.click(yearSelect!); + const options = await within(menuContainerEl!).findAllByRole( + 'option', + ); + const yearOption1996 = options[0]; // 1996 + + userEvent.click(yearOption1996); + + const dec1_96Cell = queryCellByDate( + newUTC(1996, Month.December, 1), + ); + userEvent.click(dec1_96Cell!); + await waitForElementToBeRemoved(menuContainerEl); + } + + // Re-open the menu + { + const { yearSelect, menuContainerEl, queryCellByDate } = + await openMenu(); + userEvent.click(yearSelect!); + expect(menuContainerEl).toBeInTheDocument(); + const options = await within(menuContainerEl!).findAllByRole( + 'option', + ); + + const yearOption1997 = options[1]; // 1997 + userEvent.click(yearOption1997); + + const dec2_97Cell = queryCellByDate( + newUTC(1997, Month.December, 2), + ); + expect(dec2_97Cell).toBeInTheDocument(); + userEvent.click(dec2_97Cell!); + } + + await waitFor(() => { + expect(dayInput.value).toEqual('02'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('1997'); + }); + }); + }); + }); + + describe('Error messages', () => { + test('Updating the input to a still-invalid date updates the error message', () => { + const { yearInput, monthInput, dayInput, queryByTestId } = + renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '02'); + userEvent.type(dayInput, '31'); + userEvent.tab(); + let errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + '2020-02-31 is not a valid date', + ); + + userEvent.type(dayInput, '{backspace}0'); + userEvent.tab(); + errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + '2020-02-30 is not a valid date', + ); + + userEvent.type(dayInput, '{backspace}{backspace}'); + userEvent.tab(); + errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + '2020-02- is not a valid date', + ); + }); + + test('Clearing the input after an invalid date error message is displayed removes the message', () => { + const { yearInput, monthInput, dayInput, queryByTestId } = + renderDatePicker({}); + userEvent.type(yearInput, '2020'); + userEvent.type(monthInput, '02'); + userEvent.type(dayInput, '31'); + const errorElement = queryByTestId('lg-form_field-error_message'); + expect(errorElement).toHaveTextContent( + '2020-02-31 is not a valid date', + ); + + userEvent.type(dayInput, '{backspace}{backspace}'); + userEvent.type(monthInput, '{backspace}{backspace}'); + userEvent.type( + yearInput, + '{backspace}{backspace}{backspace}{backspace}', + ); + const errorElement2 = queryByTestId('lg-form_field-error_message'); + expect(errorElement2).not.toBeInTheDocument(); + }); + }); + + // JSDOM does not support layout: https://github.com/testing-library/react-testing-library/issues/671 + test.todo('page does not scroll when arrow keys are pressed'); + }); + }); + + describe('Controlled vs Uncontrolled', () => { + test('(Controlled) Cell click fires a change handler if `value` is provided', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ + value: new Date(), + onDateChange, + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + await waitFor(() => expect(onDateChange).toHaveBeenCalled()); + }); + + test('(Controlled) Cell click does not change the value if `value` is provided', async () => { + const onDateChange = jest.fn(); + const { openMenu, dayInput, monthInput, yearInput } = renderDatePicker({ + value: new Date(), + onDateChange, + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + await waitFor(() => { + expect(dayInput.value).toEqual('25'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('2023'); + }); + }); + + test('(Uncontrolled) Cell click fires a change handler', async () => { + const onDateChange = jest.fn(); + const { openMenu } = renderDatePicker({ + onDateChange, + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + await waitFor(() => expect(onDateChange).toHaveBeenCalled()); + }); + + test('(Uncontrolled) Cell click changes the input value if `value` is not provided', async () => { + const onDateChange = jest.fn(); + const { openMenu, dayInput, monthInput, yearInput } = renderDatePicker({ + onDateChange, + initialValue: new Date(), + }); + const { calendarCells } = await openMenu(); + const firstCell = calendarCells?.[0]; + userEvent.click(firstCell!); + await waitFor(() => { + expect(dayInput.value).toEqual('01'); + expect(monthInput.value).toEqual('12'); + expect(yearInput.value).toEqual('2023'); + }); + }); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('Types behave as expected', () => { + <> + {/* @ts-expect-error - needs label/aria-label/aria-labelledby */} + + + + + + {}} + initialValue={new Date()} + handleValidation={() => {}} + onChange={() => {}} + locale="iso8601" + timeZone="utc" + baseFontSize={13} + disabled={false} + size="default" + state="none" + errorMessage="?" + initialOpen={false} + autoComplete="off" + darkMode={false} + /> + ; + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePicker.testutils.tsx b/packages/date-picker/src/DatePicker/DatePicker.testutils.tsx new file mode 100644 index 0000000000..d8309869d5 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePicker.testutils.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { + fireEvent, + queryByRole, + render, + RenderResult, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getISODate } from '@leafygreen-ui/date-utils'; + +import { DateSegment } from '../shared/types'; + +import { DatePickerProps } from './DatePicker.types'; +import { DatePicker } from '.'; + +const withinElement = (element: HTMLElement | null) => { + return element ? within(element) : null; +}; + +export interface RenderDatePickerResult extends RenderResult { + formField: HTMLElement; + inputContainer: HTMLElement; + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + calendarButton: HTMLButtonElement; + getInputByName: (name: DateSegment) => HTMLInputElement; + + /** + * Asynchronously query for menu elements + */ + findMenuElements: () => Promise; + + /** + * Wait for the menu element to finish opening. + * When this method resolves, the appropriate calendar cell will be focused + */ + waitForMenuToOpen: () => Promise; + + /** + * Opens the menu by clicking the calendar button. + */ + openMenu: () => Promise; + + /** + * Rerender the Date Picker with new props + */ + rerenderDatePicker: (newProps: Partial) => void; +} + +export interface RenderMenuResult { + menuContainerEl: HTMLElement | null; + leftChevron: HTMLButtonElement | null; + rightChevron: HTMLButtonElement | null; + monthSelect: HTMLButtonElement | null; + yearSelect: HTMLButtonElement | null; + calendarGrid: HTMLTableElement | null; + calendarCells: Array; + todayCell: HTMLTableCellElement | null; + /** Query for a cell with a given date value */ + queryCellByDate: (date: Date) => HTMLTableCellElement | null; + /** Query for a cell with a given ISO date string */ + queryCellByISODate: (isoString: string) => HTMLTableCellElement | null; +} + +/** + * Renders a date picker for jest environments + */ +export const renderDatePicker = ( + props?: Partial, +): RenderDatePickerResult => { + const defaultProps = { label: 'label' }; + const result = render( + , + ); + + /** Rerender the Date Picker with new props */ + const rerenderDatePicker = (newProps: Partial) => { + result.rerender( + , + ); + }; + + const inputElements = { + formField: result.getByTestId('lg-date-picker'), + inputContainer: result.getByRole('combobox'), + dayInput: result.getByLabelText('day') as HTMLInputElement, + monthInput: result.getByLabelText('month') as HTMLInputElement, + yearInput: result.getByLabelText('year') as HTMLInputElement, + calendarButton: within(result.getByRole('combobox')).getByRole( + 'button', + ) as HTMLButtonElement, + }; + + const getInputByName = (name: DateSegment) => + result.getByLabelText(name) as HTMLInputElement; + + /** + * Asynchronously query for menu elements. + */ + async function findMenuElements(): Promise { + const menuContainerEl = await waitFor(() => + queryByRole(document.body, 'listbox'), + ); + + const calendarGrid = withinElement(menuContainerEl)?.queryByRole('grid'); + const calendarCells = + withinElement(menuContainerEl)?.getAllByRole('gridcell'); + const leftChevron = + withinElement(menuContainerEl)?.queryByLabelText('Previous month') || + withinElement(menuContainerEl)?.queryByLabelText('Previous valid month'); + const rightChevron = + withinElement(menuContainerEl)?.queryByLabelText('Next month') || + withinElement(menuContainerEl)?.queryByLabelText('Next valid month'); + const monthSelect = + withinElement(menuContainerEl)?.queryByLabelText('Select month'); + const yearSelect = + withinElement(menuContainerEl)?.queryByLabelText('Select year'); + + const queryCellByDate = (date: Date): HTMLTableCellElement | null => { + const cell = calendarGrid?.querySelector( + `[data-iso="${getISODate(date)}"]`, + ); + + return cell as HTMLTableCellElement | null; + }; + + const queryCellByISODate = ( + isoString: string, + ): HTMLTableCellElement | null => { + const cell = calendarGrid?.querySelector(`[data-iso="${isoString}"]`); + + return cell as HTMLTableCellElement | null; + }; + + const todayCell = queryCellByDate(new Date(Date.now())); + + return { + menuContainerEl, + calendarGrid: calendarGrid as HTMLTableElement | null, + calendarCells: calendarCells as Array, + leftChevron: leftChevron as HTMLButtonElement | null, + rightChevron: rightChevron as HTMLButtonElement | null, + monthSelect: monthSelect as HTMLButtonElement | null, + yearSelect: yearSelect as HTMLButtonElement | null, + todayCell, + queryCellByDate, + queryCellByISODate, + }; + } + + async function waitForMenuToOpen(): Promise { + const menuElements = await findMenuElements(); + + fireEvent.transitionEnd(menuElements.menuContainerEl!); + + return menuElements; + } + + async function openMenu(): Promise { + userEvent.click(inputElements.calendarButton); + return await waitForMenuToOpen(); + } + + return { + ...result, + ...inputElements, + getInputByName, + findMenuElements, + waitForMenuToOpen, + openMenu, + rerenderDatePicker, + }; +}; + +/** Labels used for Tab stop testing */ +export const expectedTabStopLabels = { + closed: [ + 'none', + 'input > year', + 'input > month', + 'input > day', + 'input > open menu button', + 'none', + ], + open: [ + 'none', + 'input > year', + 'input > month', + 'input > day', + 'input > open menu button', + 'menu > today cell', + 'menu > left chevron', + 'menu > month select', + 'menu > year select', + 'menu > right chevron', + 'menu > today cell', + ], +}; + +type TabStopLabel = + (typeof expectedTabStopLabels)[keyof typeof expectedTabStopLabels][number]; + +export const findTabStopElementMap = async ( + renderResult: RenderDatePickerResult, +): Promise> => { + const { yearInput, monthInput, dayInput, calendarButton, findMenuElements } = + renderResult; + const { todayCell, monthSelect, yearSelect, leftChevron, rightChevron } = + await findMenuElements(); + + return { + none: null, + 'input > year': yearInput, + 'input > month': monthInput, + 'input > day': dayInput, + 'input > open menu button': calendarButton, + 'menu > today cell': todayCell, + 'menu > left chevron': leftChevron, + 'menu > month select': monthSelect, + 'menu > year select': yearSelect, + 'menu > right chevron': rightChevron, + }; +}; diff --git a/packages/date-picker/src/DatePicker/DatePicker.tsx b/packages/date-picker/src/DatePicker/DatePicker.tsx new file mode 100644 index 0000000000..43da9e94b0 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePicker.tsx @@ -0,0 +1,99 @@ +import React, { forwardRef } from 'react'; +import PropTypes from 'prop-types'; + +import LeafyGreenProvider, { + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; +import { pickAndOmit } from '@leafygreen-ui/lib'; +import { BaseFontSize, Size } from '@leafygreen-ui/tokens'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { AutoComplete, DatePickerState } from '../shared'; +import { + ContextPropKeys, + contextPropNames, + SharedDatePickerProvider, +} from '../shared/context'; +import { useControlledValue } from '../shared/hooks'; + +import { DatePickerProps } from './DatePicker.types'; +import { DatePickerContent } from './DatePickerContent'; +import { DatePickerProvider } from './DatePickerContext'; + +/** + * LeafyGreen Date Picker component + */ +export const DatePicker = forwardRef( + ( + { + value: valueProp, + initialValue: initialProp, + onDateChange: onChangeProp, + handleValidation, + darkMode: darkModeProp, + baseFontSize: basefontSizeProp, + ...props + }: DatePickerProps, + fwdRef, + ) => { + const { darkMode } = useDarkMode(darkModeProp); + const baseFontSize = useUpdatedBaseFontSize(basefontSizeProp); + const [contextProps, componentProps] = pickAndOmit< + DatePickerProps, + ContextPropKeys + >({ ...props }, contextPropNames); + + const { value, setValue } = useControlledValue( + valueProp, + onChangeProp, + initialProp, + ); + + return ( + + + + + + + + ); + }, +); + +DatePicker.displayName = 'DatePicker'; + +DatePicker.propTypes = { + value: PropTypes.instanceOf(Date), + onDateChange: PropTypes.func, + initialValue: PropTypes.instanceOf(Date), + handleValidation: PropTypes.func, + onChange: PropTypes.func, + label: PropTypes.node, + description: PropTypes.node, + locale: PropTypes.string, + timeZone: PropTypes.string, + min: PropTypes.instanceOf(Date), + max: PropTypes.instanceOf(Date), + baseFontSize: PropTypes.oneOf(Object.values(BaseFontSize)), + disabled: PropTypes.bool, + size: PropTypes.oneOf(Object.values(Size)), + state: PropTypes.oneOf(Object.values(DatePickerState)), + errorMessage: PropTypes.string, + initialOpen: PropTypes.bool, + autoComplete: PropTypes.oneOf(Object.values(AutoComplete)), + darkMode: PropTypes.bool, +}; diff --git a/packages/date-picker/src/DatePicker/DatePicker.types.ts b/packages/date-picker/src/DatePicker/DatePicker.types.ts new file mode 100644 index 0000000000..0378f399ee --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePicker.types.ts @@ -0,0 +1,53 @@ +import { ChangeEvent } from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; + +import { BaseDatePickerProps } from '../shared/types'; + +export type DatePickerProps = { + /** + * The selected date. + * + * Note that this Date object will be read as UTC time. + * Providing `Date.now()` could result in the incorrect date being displayed, + * depending on the system time zone. + * + * To set `value` to today, regardless of timeZone, use `setToUTCMidnight(new Date(Date.now()))`. + * + * e.g. `2023-12-31` at 20:00 in Los Angeles, will be `2024-01-01` at 04:00 in UTC. + * To set the correct day (`2023-12-31`) as the DatePicker value + * we must first convert our local timestamp to `2023-12-31` at midnight + */ + value?: DateType; + + /** + * Callback fired when the user makes a value change. + * Fired on click of a new date in the menu, or on keydown if the input contains a valid date. + * + * _Not_ fired when a date segment changes, but does not create a full date + * + * Callback date argument will be a Date object in UTC time, or `null` + */ + onDateChange?: (value?: DateType) => void; + + /** + * The initial selected date. Ignored if `value` is provided + * + * Note that this Date object will be read as UTC time. + * See `value` prop documentation for more details + */ + initialValue?: DateType; + + /** + * A callback fired when validation should run, based on [form validation guidelines](https://www.mongodb.design/foundation/forms/#form-validation-error-handling). + * Use this callback to compute the correct `state` and `errorMessage` value. + * + * Callback date argument will be a Date object in UTC time, or `null` + */ + handleValidation?: (value?: DateType) => void; + + /** + * Callback fired when any segment changes, (but not necessarily a full value) + */ + onChange?: (event: ChangeEvent) => void; +} & BaseDatePickerProps; diff --git a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx new file mode 100644 index 0000000000..ae36cd5ecb --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx @@ -0,0 +1,122 @@ +import React, { + forwardRef, + KeyboardEventHandler, + useEffect, + useRef, +} from 'react'; +import isEqual from 'lodash/isEqual'; + +import { isSameUTCDay } from '@leafygreen-ui/date-utils'; +import { + useBackdropClick, + useForwardedRef, + usePrevious, +} from '@leafygreen-ui/hooks'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { useSharedDatePickerContext } from '../../shared/context'; +import { useDatePickerContext } from '../DatePickerContext'; +import { DatePickerInput } from '../DatePickerInput'; +import { DatePickerMenu } from '../DatePickerMenu'; + +import { DatePickerContentProps } from './DatePickerContent.types'; + +export const DatePickerContent = forwardRef< + HTMLDivElement, + DatePickerContentProps +>(({ ...rest }: DatePickerContentProps, fwdRef) => { + const { min, max, isOpen, menuId, disabled, isSelectOpen } = + useSharedDatePickerContext(); + const { value, closeMenu, handleValidation } = useDatePickerContext(); + + const prevValue = usePrevious(value); + const prevMin = usePrevious(min); + const prevMax = usePrevious(max); + + const formFieldRef = useForwardedRef(fwdRef, null); + const menuRef = useRef(null); + const prevDisabledValue = usePrevious(disabled); + + useBackdropClick(closeMenu, [formFieldRef, menuRef], isOpen && !isSelectOpen); + + /** + * This listens to when the disabled prop changes to true and closes the menu + */ + useEffect(() => { + // if disabled is true but was previously false. This prevents this effect from rerunning multiple times since other states are updated when the menu closes. + if (disabled && !prevDisabledValue) { + closeMenu(); + handleValidation?.(value); + } + }, [closeMenu, disabled, handleValidation, value, prevDisabledValue]); + + /** + * Handle key down events that should be fired regardless of target. + */ + const handleDatePickerKeyDown: KeyboardEventHandler = e => { + const { key } = e; + + switch (key) { + case keyMap.Escape: + // Ensure that the menu will not close when a select menu is open and the ESC key is pressed. + if (!isSelectOpen) { + closeMenu(e); + handleValidation?.(value); + } + + break; + + case keyMap.Enter: + handleValidation?.(value); + break; + + case keyMap.ArrowDown: + case keyMap.ArrowUp: { + e.preventDefault(); // prevent page from scrolling + break; + } + + default: + break; + } + }; + + /** + * SIDE EFFECTS + */ + + /** When value changes, validate it */ + useEffect(() => { + if (!isEqual(prevValue, value) && !isSameUTCDay(value, prevValue)) { + handleValidation(value); + } + }, [handleValidation, prevValue, value]); + + /** If min/max changes, re-validate the value */ + useEffect(() => { + if ( + (prevMin && !isSameUTCDay(min, prevMin)) || + (prevMax && !isSameUTCDay(max, prevMax)) + ) { + handleValidation(value); + } + }, [min, max, value, prevMin, prevMax, handleValidation]); + + return ( + <> + + + + ); +}); + +DatePickerContent.displayName = 'DatePickerContent'; diff --git a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.types.ts b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.types.ts new file mode 100644 index 0000000000..d90a68a86f --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.types.ts @@ -0,0 +1,13 @@ +import { ContextPropKeys } from '../../shared/context'; +import { DatePickerProps } from '../DatePicker.types'; + +/** + * Extends {@link DatePickerProps}, + * but omits props that are added to the context. + * Replaces `onDateChange` with a `setValue` setter function + */ +export interface DatePickerContentProps + extends Omit< + DatePickerProps, + ContextPropKeys | 'value' | 'handleValidation' | 'onDateChange' + > {} diff --git a/packages/date-picker/src/DatePicker/DatePickerContent/index.ts b/packages/date-picker/src/DatePicker/DatePickerContent/index.ts new file mode 100644 index 0000000000..16c56433d3 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContent/index.ts @@ -0,0 +1,2 @@ +export { DatePickerContent } from './DatePickerContent'; +export { DatePickerContentProps } from './DatePickerContent.types'; diff --git a/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.tsx b/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.tsx new file mode 100644 index 0000000000..06e1cf2948 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.tsx @@ -0,0 +1,251 @@ +import React, { + createContext, + PropsWithChildren, + SyntheticEvent, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { isNull } from 'lodash'; + +import { + DateType, + getFirstOfUTCMonth, + getISODate, + isOnOrBefore, + isSameUTCDay, + isValidDate, +} from '@leafygreen-ui/date-utils'; +import { usePrevious } from '@leafygreen-ui/hooks'; + +import { useSharedDatePickerContext } from '../../shared/context'; +import { getFormattedDateString } from '../../shared/utils'; +import { getInitialHighlight } from '../utils/getInitialHighlight'; + +import { + DatePickerContextProps, + DatePickerProviderProps, +} from './DatePickerContext.types'; +import { useDateRangeComponentRefs } from './useDatePickerComponentRefs'; + +export const DatePickerContext = createContext( + {} as DatePickerContextProps, +); + +/** + * A provider for context values in a single DatePicker + */ +export const DatePickerProvider = ({ + children, + value, + setValue: _setValue, + handleValidation: _handleValidation, +}: PropsWithChildren) => { + const refs = useDateRangeComponentRefs(); + const { + isOpen, + setOpen, + disabled, + min, + max, + locale, + timeZone, + setInternalErrorMessage, + clearInternalErrorMessage, + isInRange, + } = useSharedDatePickerContext(); + const prevValue = usePrevious(value); + + const hour = new Date(Date.now()).getHours(); + + // Update this value every hour + const today = useMemo( + () => new Date(Date.now()), + // eslint-disable-next-line react-hooks/exhaustive-deps + [hour], + ); + + /** Internal callback to get a valid `month` from a given date value */ + const getMonthFromValue = useCallback( + (val?: DateType) => getFirstOfUTCMonth(isValidDate(val) ? val : today), + [today], + ); + + /** + * Keep track of the displayed month + */ + const [month, _setMonth] = useState(getMonthFromValue(value)); + + /** + * Keep track of the element the user is highlighting with the keyboard + */ + const [highlight, _setHighlight] = useState( + getInitialHighlight(value, today, timeZone), + ); + + /*********** + * SETTERS * + ***********/ + + /** + * Set the value and run side effects here + */ + const setValue = (newVal?: DateType) => { + _setValue(newVal ?? null); + setMonth(getMonthFromValue(newVal)); + }; + + /** + * Set the displayed month and handle side effects + */ + const setMonth = useCallback((newMonth: Date) => { + _setMonth(newMonth); + }, []); + + /** + * Set the `highlight` value & handle side effects + */ + const setHighlight = useCallback((newHighlight: Date) => { + _setHighlight(newHighlight); + }, []); + + /** + * Handles internal validation, + * and calls the provided `handleValidation` callback + */ + const handleValidation = (val?: DateType): void => { + // Set an internal error state if necessary + if (isValidDate(val)) { + if (isInRange(val)) { + clearInternalErrorMessage(); + } else { + if (isOnOrBefore(val, min)) { + setInternalErrorMessage( + `Date must be after ${getFormattedDateString(min, locale)}`, + ); + } else { + setInternalErrorMessage( + `Date must be before ${getFormattedDateString(max, locale)}`, + ); + } + } + } else if (isNull(val)) { + // This could still be an error, but it's not defined internally + clearInternalErrorMessage(); + } + + _handleValidation?.(val); + }; + + /** + * Track the event that last triggered the menu to open/close + */ + const [menuTriggerEvent, setMenuTriggerEvent] = useState(); + + /** + * Opens the menu and handles side effects + */ + const openMenu = (triggerEvent?: SyntheticEvent) => { + setMenuTriggerEvent(triggerEvent); + setOpen(true); + }; + + /** Closes the menu and handles side effects */ + const closeMenu = (triggerEvent?: SyntheticEvent) => { + setMenuTriggerEvent(triggerEvent); + setOpen(false); + + // Perform side effects once the state has settled + requestAnimationFrame(() => { + if (!disabled) { + // Return focus to the calendar button + refs.calendarButtonRef.current?.focus(); + } + // update month to something valid + setMonth(getMonthFromValue(value)); + // update highlight to something valid + setHighlight(getInitialHighlight(value, today, timeZone)); + }); + }; + + /** Toggles the menu and handles appropriate side effects */ + const toggleMenu = (triggerEvent?: SyntheticEvent) => { + if (isOpen) { + closeMenu(triggerEvent); + } else { + openMenu(triggerEvent); + } + }; + + /*********** + * GETTERS * + ***********/ + + /** + * Returns the cell element with the provided value + */ + const getCellWithValue = (date: DateType): HTMLTableCellElement | null => { + if (isInRange(date)) { + const highlightKey = getISODate(date); + const cell = highlightKey + ? refs.calendarCellRefs(highlightKey)?.current + : null; + return cell; + } + + return null; + }; + + /** + * Returns the cell element with the current highlight value + */ + const getHighlightedCell = (): HTMLTableCellElement | null => { + return getCellWithValue(highlight); + }; + + /**************** + * SIDE EFFECTS * + ****************/ + + /** + * If `value` prop changes, update the month + */ + useEffect(() => { + if (!isSameUTCDay(value, prevValue)) { + setMonth(getMonthFromValue(value)); + } + }, [getMonthFromValue, prevValue, setMonth, today, value]); + + return ( + + {children} + + ); +}; + +/** + * Access single date picker context values + */ +export const useDatePickerContext = () => { + return useContext(DatePickerContext); +}; diff --git a/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.types.ts b/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.types.ts new file mode 100644 index 0000000000..30c412685e --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContext/DatePickerContext.types.ts @@ -0,0 +1,101 @@ +import { SyntheticEvent } from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +import { SegmentRefs } from '../../shared'; +import { DatePickerProps } from '../DatePicker.types'; + +export interface DatePickerComponentRefs { + segmentRefs: SegmentRefs; + calendarCellRefs: DynamicRefGetter; + calendarButtonRef: React.RefObject; + chevronButtonRefs: { + left: React.RefObject; + right: React.RefObject; + }; +} + +export interface DatePickerContextProps { + /** + * Ref objects for important date picker elements + */ + refs: DatePickerComponentRefs; + + /** The current value of the date picker */ + value: DateType | undefined; + + /** + * Dispatches a setter for the date picker value. + * Performs common side-effects + */ + setValue: (newVal: DateType | undefined) => void; + + /** + * Performs internal validation, and + * calls the `handleValidation` function provided by the consumer + */ + handleValidation: Required['handleValidation']; + + /** + * The current date, in the browser's time zone + */ + today: Date; + + /** + * The currently displayed month in the menu. + */ + month: Date; + + /** + * Sets the current month in the menu, + * and performs any side-effects + */ + setMonth: (newMonth: Date) => void; + + /** + * The Date value for the calendar cell in the menu that has, or should have focus. + */ + highlight: Date; + + /** + * Sets the value of the calendar cell that should have focus, + * and performs any side-effects + */ + setHighlight: (newHighlight: Date) => void; + + /** + * Opens the menu and handles side effects + */ + openMenu: (triggerEvent?: SyntheticEvent) => void; + + /** + * Closes the menu and handles side effects + */ + closeMenu: (triggerEvent?: SyntheticEvent) => void; + + /** + * Toggles the menu and handles appropriate side effects + */ + toggleMenu: (triggerEvent?: SyntheticEvent) => void; + + /** The event that triggered the last menu toggle */ + menuTriggerEvent?: SyntheticEvent; + + /** + * Returns the calendar cell element that has, or should have focus + */ + getHighlightedCell: () => HTMLTableCellElement | null | undefined; + + /** + * Returns the calendar cell with the provided value + */ + getCellWithValue: (date: DateType) => HTMLTableCellElement | null | undefined; +} + +/** Props passed into the provider component */ +export interface DatePickerProviderProps { + value: DateType | undefined; + setValue: (newVal: DateType) => void; + handleValidation?: DatePickerProps['handleValidation']; +} diff --git a/packages/date-picker/src/DatePicker/DatePickerContext/index.ts b/packages/date-picker/src/DatePicker/DatePickerContext/index.ts new file mode 100644 index 0000000000..fe01ef1b34 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContext/index.ts @@ -0,0 +1,9 @@ +export { + DatePickerContext, + DatePickerProvider, + useDatePickerContext, +} from './DatePickerContext'; +export { + type DatePickerContextProps, + type DatePickerProviderProps, +} from './DatePickerContext.types'; diff --git a/packages/date-picker/src/DatePicker/DatePickerContext/useDatePickerComponentRefs.ts b/packages/date-picker/src/DatePicker/DatePickerContext/useDatePickerComponentRefs.ts new file mode 100644 index 0000000000..b7bc6f0bde --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerContext/useDatePickerComponentRefs.ts @@ -0,0 +1,33 @@ +import { useRef } from 'react'; + +import { useDynamicRefs } from '@leafygreen-ui/hooks'; + +import { useSegmentRefs } from '../../shared/hooks'; + +import { DatePickerComponentRefs } from './DatePickerContext.types'; + +/** Creates `ref` objects for any & all relevant component elements */ +export const useDateRangeComponentRefs = (): DatePickerComponentRefs => { + const segmentRefs = useSegmentRefs(); + + // TODO: https://jira.mongodb.org/browse/LG-3666 + // useDynamicRefs may overflow if a user navigates to too many months. + // consider purging the refs map within the hook + const calendarCellRefs = useDynamicRefs(); + + const calendarButtonRef = useRef(null); + + const leftChevronRef = useRef(null); + const rightChevronRef = useRef(null); + const chevronButtonRefs = { + left: leftChevronRef, + right: rightChevronRef, + }; + + return { + segmentRefs, + calendarCellRefs, + calendarButtonRef, + chevronButtonRefs, + }; +}; diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx new file mode 100644 index 0000000000..6b456d95d5 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Month, newUTC } from '@leafygreen-ui/date-utils'; + +import { + defaultSharedDatePickerContext, + SharedDatePickerProvider, + SharedDatePickerProviderProps, +} from '../../shared/context'; +import { + DatePickerProvider, + DatePickerProviderProps, +} from '../DatePickerContext'; + +import { DatePickerInput, DatePickerInputProps } from '.'; + +const renderDatePickerInput = ( + props?: Omit | null, + singleDateContext?: Partial, + context?: Partial, +) => { + const result = render( + + {}} + {...singleDateContext} + > + + + , + ); + + const inputContainer = result.getByRole('combobox'); + const dayInput = result.getByLabelText('day') as HTMLInputElement; + const monthInput = result.getByLabelText('month') as HTMLInputElement; + const yearInput = result.getByLabelText('year') as HTMLInputElement; + + return { + ...result, + inputContainer, + dayInput, + monthInput, + yearInput, + }; +}; + +const testDate = newUTC(2023, Month.December, 26); + +describe('packages/date-picker/date-picker-input', () => { + beforeEach(() => { + // Set the current time to midnight UTC on 2023-12-26 + jest.useFakeTimers().setSystemTime(testDate); + }); + + describe('Typing', () => { + test('typing into a segment updates the segment value', () => { + const { dayInput } = renderDatePickerInput(); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is not immediately formatted', () => { + const { dayInput } = renderDatePickerInput(); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + const { dayInput } = renderDatePickerInput(); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + describe('allows only 2 characters', () => { + test('in day input', () => { + const { dayInput } = renderDatePickerInput(); + userEvent.type(dayInput, '22222222'); + expect(dayInput.value.length).toBe(2); + }); + + test('in month input', () => { + const { monthInput } = renderDatePickerInput(); + userEvent.type(monthInput, '22222222'); + expect(monthInput.value.length).toBe(2); + }); + }); + + test('allows only 4 characters in year input', () => { + const { yearInput } = renderDatePickerInput(); + userEvent.type(yearInput, '22222222'); + expect(yearInput.value.length).toBe(4); + }); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.stories.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.stories.tsx new file mode 100644 index 0000000000..8b3bf00f78 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.stories.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../shared/context'; +import { getProviderPropsFromStoryContext } from '../../shared/testutils'; +import { DatePickerProps } from '../DatePicker.types'; +import { + DatePickerContextProps, + DatePickerProvider, +} from '../DatePickerContext'; + +import { DatePickerInput } from './DatePickerInput'; + +const ProviderWrapper = (Story: StoryFn, ctx: any) => { + const { leafyGreenProviderProps, datePickerProviderProps, storyProps } = + getProviderPropsFromStoryContext(ctx?.args); + + return ( + + + {}}> + + + + + ); +}; + +const meta: StoryMetaType< + typeof DatePickerInput, + DatePickerContextProps & SharedDatePickerContextProps +> = { + title: 'Components/DatePicker/DatePicker/DatePickerInput', + component: DatePickerInput, + decorators: [ProviderWrapper], + parameters: { + default: null, + controls: { + exclude: ['segmentRefs', 'onChange', 'onSegmentChange', 'onClick'], + }, + generate: { + combineArgs: { + darkMode: [false, true], + value: [null, new Date('1993-12-26')], + locale: ['iso8601', 'en-US', 'en-UK', 'de-DE'], + size: Object.values(Size), + }, + decorator: ProviderWrapper, + }, + }, + args: { + label: 'Label', + locale: 'en-UK', + timeZone: 'Europe/London', + }, + argTypes: { + value: { control: 'date' }, + }, +}; + +export default meta; + +export const Basic: StoryFn = () => ; + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Generated: StoryFn = () => <>; diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx new file mode 100644 index 0000000000..8f24c10f5f --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx @@ -0,0 +1,245 @@ +import React, { + ChangeEvent, + FocusEventHandler, + forwardRef, + KeyboardEventHandler, + MouseEventHandler, +} from 'react'; +import { isNull } from 'lodash'; + +import { isInvalidDateObject, isSameUTCDay } from '@leafygreen-ui/date-utils'; +import { createSyntheticEvent, keyMap } from '@leafygreen-ui/lib'; + +import { + DateFormField, + DateInputBox, + DateInputChangeEventHandler, +} from '../../shared/components/DateInput'; +import { DateInputSegmentChangeEventHandler } from '../../shared/components/DateInput/DateInputSegment'; +import { useSharedDatePickerContext } from '../../shared/context'; +import { + getFormattedDateStringFromSegments, + getRelativeSegmentRef, + isElementInputSegment, +} from '../../shared/utils'; +import { useDatePickerContext } from '../DatePickerContext'; +import { getSegmentToFocus } from '../utils/getSegmentToFocus'; + +import { DatePickerInputProps } from './DatePickerInput.types'; + +export const DatePickerInput = forwardRef( + ( + { + onClick, + onKeyDown, + onChange: onSegmentChange, + ...rest + }: DatePickerInputProps, + fwdRef, + ) => { + const { + formatParts, + disabled, + locale, + setIsDirty, + setInternalErrorMessage, + } = useSharedDatePickerContext(); + const { + refs: { segmentRefs, calendarButtonRef }, + value, + setValue, + openMenu, + toggleMenu, + handleValidation, + } = useDatePickerContext(); + + /** Called when the input's Date value has changed */ + const handleInputValueChange: DateInputChangeEventHandler = ({ + value: newVal, + segments, + }) => { + if (!isSameUTCDay(newVal, value)) { + handleValidation?.(newVal); + setValue(newVal); + } + + if (!isNull(newVal) && isInvalidDateObject(newVal)) { + const dateString = getFormattedDateStringFromSegments(segments, locale); + setInternalErrorMessage(`${dateString} is not a valid date`); + } + }; + + /** + * Called when the input, or any of its children, is clicked. + * Opens the menu and focuses the appropriate segment + */ + const handleInputClick: MouseEventHandler = e => { + if (!disabled) { + openMenu(e); + const { target } = e; + const segmentToFocus = getSegmentToFocus({ + target, + formatParts, + segmentRefs, + }); + + segmentToFocus?.focus(); + } + }; + + /** + * Called when the calendar button is clicked. + * Opens the menu & focuses the appropriate cell + */ + const handleIconButtonClick: MouseEventHandler = e => { + // Prevent the parent click handler from being called since clicks on the parent always opens the dropdown + e.stopPropagation(); + toggleMenu(e); + }; + + /** Called on any keydown within the input element */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // if target is not a segment, do nothing + if (!isSegment) return; + + const isSegmentEmpty = !target.value; + + const { selectionStart, selectionEnd } = target; + + switch (key) { + case keyMap.ArrowLeft: { + // if input is empty, + // or the cursor is at the beginning of the input + // set focus to prev. input (if it exists) + if (selectionStart === 0) { + const segmentToFocus = getRelativeSegmentRef('prev', { + segment: target, + formatParts, + segmentRefs, + }); + + segmentToFocus?.current?.focus(); + } + // otherwise, use default behavior + + break; + } + + case keyMap.ArrowRight: { + // if input is empty, + // or the cursor is at the end of the input + // set focus to next. input (if it exists) + if (selectionEnd === target.value.length) { + const segmentToFocus = getRelativeSegmentRef('next', { + segment: target, + formatParts, + segmentRefs, + }); + + segmentToFocus?.current?.focus(); + } + // otherwise, use default behavior + + break; + } + + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + // increment/decrement logic implemented by DateInputSegment + break; + } + + case keyMap.Backspace: { + if (isSegmentEmpty) { + // prevent the backspace in the previous segment + e.preventDefault(); + const segmentToFocus = getRelativeSegmentRef('prev', { + segment: target, + formatParts, + segmentRefs, + }); + segmentToFocus?.current?.focus(); + } + break; + } + + case keyMap.Space: { + openMenu(); + break; + } + + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + /** + * Called when any child of DatePickerInput is blurred. + * Calls the validation handler. + */ + const handleInputBlur: FocusEventHandler = e => { + const nextFocus = e.relatedTarget as HTMLInputElement; + const isNextFocusElementASegment = Object.values(segmentRefs) + .map(ref => ref.current) + .includes(nextFocus); + + if (!isNextFocusElementASegment) { + setIsDirty(true); + handleValidation?.(value); + } + }; + + /** + * Called when any segment changes + * If up/down arrows are pressed, don't move to the next segment + */ + const handleSegmentChange: DateInputSegmentChangeEventHandler = + segmentChangeEvent => { + const { segment } = segmentChangeEvent; + + /** + * Fire a simulated `change` event + */ + const target = segmentRefs[segment].current; + + if (target) { + const changeEvent = new Event('change'); + const reactEvent = createSyntheticEvent< + ChangeEvent + >(changeEvent, target); + onSegmentChange?.(reactEvent); + } + }; + + return ( + + + + ); + }, +); + +DatePickerInput.displayName = 'DatePickerInput'; diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.types.ts b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.types.ts new file mode 100644 index 0000000000..a898f53755 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.types.ts @@ -0,0 +1,14 @@ +import { MouseEventHandler } from 'react'; + +import { HTMLElementProps } from '@leafygreen-ui/lib'; + +import { DatePickerContentProps } from '../DatePickerContent'; + +export interface DatePickerInputProps + extends Omit, 'onChange'>, + Pick { + /** + * Click handler + */ + onClick?: MouseEventHandler; +} diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/index.ts b/packages/date-picker/src/DatePicker/DatePickerInput/index.ts new file mode 100644 index 0000000000..a0dc419422 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerInput/index.ts @@ -0,0 +1,2 @@ +export { DatePickerInput } from './DatePickerInput'; +export { DatePickerInputProps } from './DatePickerInput.types'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx new file mode 100644 index 0000000000..088c4e73f7 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + getISODate, + getISODateTZ, + Month, + newUTC, +} from '@leafygreen-ui/date-utils'; +import { + mockTimeZone, + testTimeZones, +} from '@leafygreen-ui/date-utils/src/testing'; + +import { + SharedDatePickerProvider, + SharedDatePickerProviderProps, +} from '../../shared/context'; +import { + DatePickerProvider, + DatePickerProviderProps, +} from '../DatePickerContext'; + +import { DatePickerMenu, DatePickerMenuProps } from '.'; + +const testToday = newUTC(2023, Month.September, 10); + +const renderDatePickerMenu = ( + props?: Partial | null, + singleContext?: Partial | null, + context?: Partial | null, +) => { + const result = render( + + {}} + handleValidation={undefined} + {...singleContext} + > + , + + , + ); + + const rerenderDatePickerMenu = ( + newProps?: Partial | null, + newSingleContext?: Partial | null, + ) => + result.rerender( + + {}} + handleValidation={undefined} + {...singleContext} + {...newSingleContext} + > + )} + /> + + , + ); + + const calendarGrid = result.getByRole('grid'); + + const calendarCells = result.queryAllByRole( + 'gridcell', + ) as Array; + + const todayCell = calendarGrid.querySelector( + `[data-iso="${getISODateTZ( + new Date(Date.now()), + context?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + )}"]`, + ); + + const getCellWithValue = (date: Date) => + calendarGrid.querySelector(`[data-iso="${getISODate(date)}"]`); + + const getCellWithISOString = (isoStr: string) => + calendarGrid.querySelector(`[data-iso="${isoStr}"]`); + + const getCurrentCell = () => + calendarGrid.querySelector('[aria-current="true"]'); + + const leftChevron = + result.queryByLabelText('Previous month') || + result.queryByLabelText('Previous valid month'); + const rightChevron = + result.queryByLabelText('Next month') || + result.queryByLabelText('Next valid month'); + const monthSelect = result.queryByLabelText('Select month'); + const yearSelect = result.queryByLabelText('Select year'); + + return { + ...result, + rerenderDatePickerMenu, + calendarGrid, + calendarCells, + todayCell, + getCellWithValue, + getCellWithISOString, + getCurrentCell, + leftChevron, + rightChevron, + monthSelect, + yearSelect, + }; +}; + +describe('packages/date-picker/date-picker-menu', () => { + beforeEach(() => { + // Set the current time to midnight UTC on 2023-09-10 + jest.useFakeTimers().setSystemTime(testToday); + }); + + describe('Rendering', () => { + test('renders calendar grid', () => { + const result = renderDatePickerMenu(); + expect(result.getByRole('grid')).toBeInTheDocument(); + }); + test('grid is labelled as the current month', () => { + const result = renderDatePickerMenu(); + const grid = result.getByRole('grid'); + expect(grid).toHaveAttribute('aria-label', 'September 2023'); + }); + test('chevrons have aria labels', () => { + const result = renderDatePickerMenu(); + const leftChevron = result.getByLabelText('Previous month'); + const rightChevron = result.getByLabelText('Next month'); + expect(leftChevron).toBeInTheDocument(); + expect(rightChevron).toBeInTheDocument(); + }); + test('select menu triggers have aria labels', () => { + const result = renderDatePickerMenu(); + const monthSelect = result.getByLabelText('Select month'); + const yearSelect = result.getByLabelText('Select year'); + expect(monthSelect).toBeInTheDocument(); + expect(yearSelect).toBeInTheDocument(); + }); + test('select menus have correct values', () => { + const result = renderDatePickerMenu(); + const monthSelect = result.getByLabelText('Select month'); + const yearSelect = result.getByLabelText('Select year'); + expect(monthSelect).toHaveValue(Month.September.toString()); + expect(yearSelect).toHaveValue('2023'); + }); + + test('sets `aria-current` on today cell', () => { + jest.setSystemTime( + newUTC(2023, Month.September, 10, 0, testToday.getTimezoneOffset()), + ); + const { getCellWithISOString } = renderDatePickerMenu(); + expect(getCellWithISOString('2023-09-10')).toHaveAttribute( + 'aria-current', + 'true', + ); + }); + + describe('when value is updated', () => { + test('grid is labelled as the current month', () => { + const { getByRole, rerenderDatePickerMenu } = renderDatePickerMenu(); + rerenderDatePickerMenu(null, { + value: newUTC(2024, Month.March, 10), + }); + const grid = getByRole('grid'); + expect(grid).toHaveAttribute('aria-label', 'March 2024'); + }); + test('select menus have correct values', () => { + const { getByLabelText, rerenderDatePickerMenu } = + renderDatePickerMenu(); + rerenderDatePickerMenu(null, { + value: newUTC(2024, Month.March, 10), + }); + + const monthSelect = getByLabelText('Select month'); + const yearSelect = getByLabelText('Select year'); + expect(monthSelect).toHaveValue(Month.March.toString()); + expect(yearSelect).toHaveValue('2024'); + }); + }); + + describe('when value is out of range', () => { + test('grid is labelled', () => { + const { calendarGrid } = renderDatePickerMenu(null, { + value: newUTC(2048, Month.December, 25), + }); + expect(calendarGrid).toHaveAttribute('aria-label', 'December 2048'); + }); + test('all cells disabled', () => { + const { calendarCells } = renderDatePickerMenu(null, { + value: newUTC(2048, Month.December, 25), + }); + const isEveryCellDisabled = calendarCells.every( + cell => cell?.getAttribute('aria-disabled') === 'true', + ); + expect(isEveryCellDisabled).toBe(true); + }); + + test('does not highlight a cell', () => { + const { calendarCells } = renderDatePickerMenu(null, { + value: newUTC(2048, Month.December, 25), + }); + const isSomeCellHighlighted = calendarCells.some( + cell => cell?.getAttribute('aria-selected') === 'true', + ); + expect(isSomeCellHighlighted).toBe(false); + }); + }); + + // TODO: Test in multiple time zones with a properly mocked Date object + describe('rendered cells', () => { + test('have correct text content and `aria-label`', () => { + const { calendarCells } = renderDatePickerMenu(); + + calendarCells.forEach((cell, i) => { + const date = String(i + 1); + expect(cell).toHaveTextContent(date); + + expect(cell).toHaveAttribute( + 'aria-label', + expect.stringContaining(`September ${date}, 2023`), + ); + }); + }); + }); + + describe.each(testTimeZones)( + 'when system time is in $tz', + ({ tz, UTCOffset }) => { + describe.each([ + { tz: undefined, UTCOffset: undefined }, + ...testTimeZones, + ])('and timeZone prop is $tz', prop => { + const elevenLocal = 23 - (prop.UTCOffset ?? UTCOffset); + const midnightLocal = 0 - (prop.UTCOffset ?? UTCOffset); + const dec24Local = newUTC(2023, Month.December, 24, elevenLocal, 59); + const dec25Local = newUTC(2023, Month.December, 25, midnightLocal, 0); + const dec24ISO = '2023-12-24'; + const dec25ISO = '2023-12-25'; + const ctx = { + timeZone: prop?.tz, + }; + + beforeEach(() => { + jest.setSystemTime(dec24Local); + mockTimeZone(tz, UTCOffset); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('when date changes, cell marked as `current` updates', () => { + const { getCellWithISOString, rerenderDatePickerMenu } = + renderDatePickerMenu(null, null, ctx); + const dec24Cell = getCellWithISOString(dec24ISO); + expect(dec24Cell).toHaveAttribute('aria-current', 'true'); + + jest.setSystemTime(dec25Local); + + rerenderDatePickerMenu(); + const dec25LocalCell = getCellWithISOString(dec25ISO); + expect(dec25LocalCell).toHaveAttribute('aria-current', 'true'); + }); + }); + }, + ); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.stories.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.stories.tsx new file mode 100644 index 0000000000..04dfec8fa6 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.stories.tsx @@ -0,0 +1,296 @@ +/* eslint-disable react-hooks/rules-of-hooks, react/prop-types */ +import React, { useRef, useState } from 'react'; +import { Decorator, StoryFn, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/testing-library'; +import { last, omit } from 'lodash'; +import MockDate from 'mockdate'; + +import { + DateType, + Month, + newUTC, + testLocales, + testTimeZoneLabels, +} from '@leafygreen-ui/date-utils'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { type StoryMetaType } from '@leafygreen-ui/lib'; +import { transitionDuration } from '@leafygreen-ui/tokens'; +import { InlineCode } from '@leafygreen-ui/typography'; + +import { + contextPropNames, + type SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../shared/context'; +import { getProviderPropsFromStoryContext } from '../../shared/testutils'; +import { + type DatePickerContextProps, + DatePickerProvider, +} from '../DatePickerContext'; + +import { DatePickerMenu } from './DatePickerMenu'; +import { DatePickerMenuProps } from './DatePickerMenu.types'; + +const mockToday = newUTC(2023, Month.September, 14); +type DecoratorArgs = DatePickerMenuProps & + DatePickerContextProps & + SharedDatePickerContextProps; + +const MenuDecorator: Decorator = (Story: StoryFn, ctx: any) => { + const { leafyGreenProviderProps, datePickerProviderProps, storyProps } = + getProviderPropsFromStoryContext(ctx.args); + + return ( + + + + + + ); +}; + +const meta: StoryMetaType = { + title: 'Components/DatePicker/DatePicker/DatePickerMenu', + component: DatePickerMenu, + decorators: [MenuDecorator], + parameters: { + default: null, + chromatic: { + delay: transitionDuration.slower, + }, + }, + args: { + isOpen: true, + min: new Date('1996-10-14'), + max: new Date('2026-10-14'), + }, + argTypes: { + value: { control: 'date' }, + locale: { control: 'select', options: testLocales }, + timeZone: { + control: 'select', + options: [undefined, ...testTimeZoneLabels], + }, + }, +}; + +export default meta; + +type DatePickerMenuStoryType = StoryObj; + +export const Basic: DatePickerMenuStoryType = { + render: args => { + MockDate.reset(); + const [value, setValue] = useState(null); + + const date = new Date(Date.now()); + const props = omit(args, [...contextPropNames, 'isOpen']); + const refEl = useRef(null); + return ( + +
+ + Today:{' '} + {new Intl.DateTimeFormat('en-GB', { + dateStyle: 'full', + }).format(date)} + + +
+
+ ); + }, +}; + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const WithValue: DatePickerMenuStoryType = { + render: args => { + MockDate.reset(); + + const props = omit(args, [...contextPropNames, 'isOpen']); + const refEl = useRef(null); + const date = new Date(Date.now()); + const withValueDate = new Date(2023, Month.September, 10); + return ( + {}} + > +
+ + Today:{' '} + {new Intl.DateTimeFormat('en-GB', { + dateStyle: 'full', + }).format(date)} + +

+ + Value:{' '} + {new Intl.DateTimeFormat('en-GB', { + dateStyle: 'full', + }).format(withValueDate)} + + +
+
+ ); + }, +}; + +export const WithValueDarkMode: DatePickerMenuStoryType = { + ...WithValue, + args: { + // @ts-expect-error - DatePickerMenuStoryType does not include Context props + darkMode: true, + }, +}; + +export const MockedToday: DatePickerMenuStoryType = { + render: args => { + // Force `new Date()` to return `mockToday` + MockDate.set(mockToday); + const [value, setValue] = useState(null); + + const props = omit(args, [...contextPropNames, 'isOpen']); + const refEl = useRef(null); + const date = new Date(Date.now()); + return ( + +
+ + Today:{' '} + {new Intl.DateTimeFormat('en-GB', { + dateStyle: 'full', + }).format(date)} + + +
+
+ ); + }, +}; + +export const MockedTodayDarkMode: DatePickerMenuStoryType = { + ...MockedToday, + args: { + // @ts-expect-error - DatePickerMenuStoryType does not include Context props + darkMode: true, + }, +}; + +type DatePickerMenuInteractionTestType = Omit & + Required>; + +/** + * Chromatic Interaction tests + */ + +export const InitialFocusToday: DatePickerMenuInteractionTestType = { + ...Basic, + play: async ctx => { + const { findByRole } = within(ctx.canvasElement.parentElement!); + await findByRole('listbox'); + userEvent.tab(); + }, +}; +export const InitialFocusValue: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + const { findByRole } = within(ctx.canvasElement.parentElement!); + await findByRole('listbox'); + userEvent.tab(); + }, +}; + +export const LeftArrowKey: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowleft}'); + }, +}; + +export const RightArrowKey: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowright}'); + }, +}; + +export const UpArrowKey: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowup}'); + }, +}; + +export const DownArrowKey: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowdown}'); + }, +}; + +export const UpToPrevMonth: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowup}{arrowup}'); + }, +}; + +export const DownToNextMonth: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await InitialFocusToday.play(ctx); + userEvent.keyboard('{arrowdown}{arrowdown}{arrowdown}'); + }, +}; + +export const OpenMonthMenu: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + const canvas = within(ctx.canvasElement.parentElement!); + await canvas.findByRole('listbox'); + const monthMenu = await canvas.findByLabelText('Select month'); + userEvent.click(monthMenu); + }, +}; + +export const SelectJanuary: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await OpenMonthMenu.play(ctx); + const { findAllByRole } = within(ctx.canvasElement.parentElement!); + const options = await findAllByRole('option'); + const Jan = options[0]; + userEvent.click(Jan); + }, +}; + +export const OpenYearMenu: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + const canvas = within(ctx.canvasElement.parentElement!); + await canvas.findByRole('listbox'); + const monthMenu = await canvas.findByLabelText('Select year'); + userEvent.click(monthMenu); + }, +}; + +export const Select2026: DatePickerMenuInteractionTestType = { + ...WithValue, + play: async ctx => { + await OpenYearMenu.play(ctx); + const { findAllByRole } = within(ctx.canvasElement.parentElement!); + const options = await findAllByRole('option'); + const _2026 = last(options); + userEvent.click(_2026!); + }, +}; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.styles.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.styles.ts new file mode 100644 index 0000000000..92237c7dc6 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.styles.ts @@ -0,0 +1,57 @@ +import { css } from '@leafygreen-ui/emotion'; +import { contentClassName } from '@leafygreen-ui/popover'; +import { spacing } from '@leafygreen-ui/tokens'; + +export const menuWrapperStyles = css` + width: 244px; + + & > .${contentClassName} { + width: 100%; + } +`; + +export const menuContentStyles = css` + display: grid; + grid-auto-flow: row; + grid-template-areas: 'header' 'calendar'; + align-items: center; +`; + +export const menuHeaderStyles = css` + grid-area: header; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-bottom: ${spacing[3]}px; + z-index: 1; +`; + +export const menuHeaderSelectContainerStyles = css` + display: flex; + align-items: center; + gap: ${spacing[1]}px; +`; + +export const menuCalendarGridStyles = css` + grid-area: calendar; + margin: auto; +`; + +export const selectTruncateStyles = css` + button { + > div { + &:last-of-type { + div { + overflow: unset; + } + } + } + } +`; + +// Hardcoding the width +export const selectInputWidthStyles = css` + width: 68px; +`; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx new file mode 100644 index 0000000000..4537871f66 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx @@ -0,0 +1,347 @@ +import React, { + forwardRef, + KeyboardEventHandler, + TransitionEventHandler, + useEffect, + useRef, +} from 'react'; +import { ExitHandler } from 'react-transition-group/Transition'; + +import { + addDaysUTC, + getFirstOfUTCMonth, + getFullMonthLabel, + getISODate, + getUTCDateString, + isSameTZDay, + isSameUTCDay, + isSameUTCMonth, + isValidDate, +} from '@leafygreen-ui/date-utils'; +import { useForwardedRef, usePrevious } from '@leafygreen-ui/hooks'; +import { keyMap } from '@leafygreen-ui/lib'; +import { spacing } from '@leafygreen-ui/tokens'; + +import { + CalendarCell, + CalendarCellState, + CalendarGrid, +} from '../../shared/components/Calendar'; +import { MenuWrapper } from '../../shared/components/MenuWrapper'; +import { useSharedDatePickerContext } from '../../shared/context'; +import { useDatePickerContext } from '../DatePickerContext'; + +import { shouldChevronBeDisabled } from './DatePickerMenuHeader/utils'; +import { getNewHighlight } from './utils/getNewHighlight'; +import { + menuCalendarGridStyles, + menuContentStyles, + menuWrapperStyles, +} from './DatePickerMenu.styles'; +import { DatePickerMenuProps } from './DatePickerMenu.types'; +import { DatePickerMenuHeader } from './DatePickerMenuHeader'; + +export const DatePickerMenu = forwardRef( + ({ onKeyDown, ...rest }: DatePickerMenuProps, fwdRef) => { + const { min, max, isInRange, isOpen, setIsDirty, timeZone } = + useSharedDatePickerContext(); + const { + refs, + today, + value, + setValue, + handleValidation, + month, + setMonth: setDisplayMonth, + highlight, + closeMenu, + setHighlight, + getCellWithValue, + getHighlightedCell, + menuTriggerEvent, + } = useDatePickerContext(); + + const ref = useForwardedRef(fwdRef, null); + const cellRefs = refs.calendarCellRefs; + const headerRef = useRef(null); + const calendarRef = useRef(null); + + const prevValue = usePrevious(value); + + const monthLabel = getFullMonthLabel(month); + + /** Returns the current state of the cell */ + const getCellState = (cellDay: Date | null): CalendarCellState => { + if (isInRange(cellDay)) { + if (isSameUTCDay(cellDay, value)) { + return CalendarCellState.Active; + } + + return CalendarCellState.Default; + } + + return CalendarCellState.Disabled; + }; + + /** + * SETTERS + */ + + /** setDisplayMonth with side effects */ + const updateMonth = (newMonth: Date) => { + if (isSameUTCMonth(newMonth, month)) { + return; + } + setDisplayMonth(newMonth); + const newHighlight = getNewHighlight(highlight, month, newMonth); + const shouldUpdateHighlight = !isSameUTCDay(highlight, newHighlight); + + if (newHighlight && shouldUpdateHighlight) { + setHighlight(newHighlight); + } + }; + + /** setHighlight with side effects */ + const updateHighlight = (newHighlight: Date) => { + // change month if nextHighlight is different than `month` + if (!isSameUTCMonth(month, newHighlight)) { + setDisplayMonth(newHighlight); + } + setHighlight(newHighlight); + requestAnimationFrame(() => { + const highlightedCell = getCellWithValue(newHighlight); + highlightedCell?.focus(); + }); + }; + + /** + * SIDE EFFECTS + */ + + /** Set the highlighted cell when the value changes in the input */ + useEffect(() => { + if ( + isValidDate(value) && + !isSameUTCDay(value, prevValue) && + isInRange(value) + ) { + setHighlight(value); + } + }, [value, isInRange, setHighlight, prevValue]); + + /** + * If the new value is not the current month, update the month + */ + useEffect(() => { + if ( + isValidDate(value) && + !isSameUTCDay(value, prevValue) && + !isSameUTCMonth(value, month) + ) { + setDisplayMonth(getFirstOfUTCMonth(value)); + } + }, [month, prevValue, setDisplayMonth, value]); + + /** + * EVENT HANDLERS + */ + + /** + * Fired when the CSS transition to open the menu is finished. + * Sets the initial focus on the highlighted cell + */ + const handleMenuTransitionEntered: TransitionEventHandler = e => { + // Whether this event is firing in response to the menu transition + const isTransitionedElementMenu = e.target === ref.current; + + // Whether the latest openMenu event was triggered by the calendar button + const isTriggeredByButton = + menuTriggerEvent && + refs.calendarButtonRef.current?.contains( + menuTriggerEvent.target as HTMLElement, + ); + + // Only move focus to input when opened via button click + if (isOpen && isTransitionedElementMenu && isTriggeredByButton) { + // When the menu opens, set focus to the `highlight` cell + const highlightedCell = getHighlightedCell(); + + if (highlightedCell) { + highlightedCell.focus(); + } else if (!shouldChevronBeDisabled('left', month, min)) { + refs.chevronButtonRefs.left.current?.focus(); + } else if (!shouldChevronBeDisabled('right', month, max)) { + refs.chevronButtonRefs.right.current?.focus(); + } + } + }; + + /** + * Fired when the Transform element for the menu has exited. + * Fires side effects when the menu closes + */ + const handleMenuTransitionExited: ExitHandler = () => { + if (!isOpen) { + closeMenu(); + } + }; + + /** Called when any calendar cell is clicked */ + const handleCalendarCellClick = (cellValue: Date) => { + if (!isSameUTCDay(cellValue, value)) { + // when the value is changed via cell, + // we trigger validation every time + handleValidation?.(cellValue); + setIsDirty(true); + // finally we update the component value + setValue(cellValue); + } + // and close the menu + closeMenu(); + }; + + /** Creates a click handler for a specific cell date */ + const cellClickHandlerForDay = (day: Date) => () => { + if (isInRange(day)) { + handleCalendarCellClick(day); + } + }; + + /** + * Fired on any key press. + * Handles custom focus trap logic + */ + const handleMenuKeyPress: KeyboardEventHandler = e => { + const { key } = e; + + // Implementing custom focus-trap logic, + // since focus-trap-react focuses the first element immediately on mount + if (key === keyMap.Tab) { + const currentFocus = document.activeElement; + + const highlightedCellElement = getHighlightedCell(); + const rightChevronElement = headerRef.current?.lastElementChild; + + const isFocusOnRightChevron = currentFocus === rightChevronElement; + const isFocusOnCell = currentFocus === highlightedCellElement; + + if (!e.shiftKey) { + // If the Date Picker is nested inside a component that uses focus-trap-react, e.g. Modal, this prevents the focus-trap-react package from hijacking the focus when tabbing + if (!ref.current?.contains(currentFocus) || isFocusOnRightChevron) { + (highlightedCellElement as HTMLElement)?.focus(); + e.preventDefault(); + } + } else if (e.shiftKey && isFocusOnCell) { + (rightChevronElement as HTMLElement)?.focus(); + e.preventDefault(); + } + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + /** + * Called on any keydown within the CalendarGrid element. + * Responsible for updating the highlight +/- 1 day or 1 week + */ + const handleCalendarKeyDown: KeyboardEventHandler = e => { + const { key } = e; + + const currentHighlight = + highlight || (isValidDate(value) ? value : today); + let nextHighlight = currentHighlight; + + switch (key) { + case keyMap.ArrowLeft: { + e.preventDefault(); + nextHighlight = addDaysUTC(currentHighlight, -1); + break; + } + + case keyMap.ArrowRight: { + e.preventDefault(); + nextHighlight = addDaysUTC(currentHighlight, 1); + break; + } + + case keyMap.ArrowUp: { + e.preventDefault(); + nextHighlight = addDaysUTC(currentHighlight, -7); + break; + } + + case keyMap.ArrowDown: { + e.preventDefault(); + nextHighlight = addDaysUTC(currentHighlight, 7); + break; + } + + default: + break; + } + + // if nextHighlight is in range + if (isInRange(nextHighlight) && !isSameUTCDay(nextHighlight, highlight)) { + updateHighlight(nextHighlight); + + // Prevent the parent keydown handler from being called + e.stopPropagation(); + } + }; + + return ( + +
+ {/** + * Calendar & Header are reversed in the DOM, + * and visually updated with CSS grid + * in order to achieve the correct tab-order + * (i.e. calendar is focused first) + */} + + {(day, i) => { + return ( + // TODO: Test highlight rendering in different time zones + + {day.getUTCDate()} + + ); + }} + + +
+
+ ); + }, +); + +DatePickerMenu.displayName = 'DatePickerMenu'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts new file mode 100644 index 0000000000..1a21370038 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts @@ -0,0 +1,7 @@ +import { HTMLElementProps } from '@leafygreen-ui/lib'; +import { PopoverProps } from '@leafygreen-ui/popover'; +import { PortalControlProps } from '@leafygreen-ui/popover'; + +export type DatePickerMenuProps = PortalControlProps & + Omit & + HTMLElementProps<'div'>; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx new file mode 100644 index 0000000000..7224921344 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx @@ -0,0 +1,334 @@ +import React, { createRef, PropsWithChildren, useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Month, newUTC } from '@leafygreen-ui/date-utils'; +import { transitionDuration } from '@leafygreen-ui/tokens'; + +import {} from '../../../shared/components'; +import { + defaultSharedDatePickerContext, + SharedDatePickerContext, +} from '../../../shared/context'; +import { + DatePickerContext, + DatePickerContextProps, +} from '../../DatePickerContext'; + +import { DatePickerMenuHeader } from '.'; + +const MockSharedDatePickerProvider = SharedDatePickerContext.Provider; +const MockDatePickerProvider = DatePickerContext.Provider; +const mockRefs = { + chevronButtonRefs: { + left: createRef(), + right: createRef(), + }, +}; + +describe('packages/date-picker/menu/header', () => { + describe('Rendering', () => { + describe('Some month options are disabled', () => { + test('When `month` and `min` are the same year, earlier month options are disabled', async () => { + const { getByLabelText, findAllByRole } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + + userEvent.click(monthSelect); + + const options = await findAllByRole('option'); + + for (const element of options) { + expect(element).toBeInTheDocument(); + const monthIndex = Number(element.getAttribute('value')); + + if (monthIndex < Month.March) { + expect(element).toHaveAttribute('aria-disabled', 'true'); + } + } + }); + + test('When `month` and `max` are the same year, later month options are disabled', async () => { + const { getByLabelText, findAllByRole } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + + userEvent.click(monthSelect); + + const options = await findAllByRole('option'); + + for (const element of options) { + expect(element).toBeInTheDocument(); + const monthIndex = Number(element.getAttribute('value')); + + if (monthIndex > Month.September) { + expect(element).toHaveAttribute('aria-disabled', 'true'); + } + } + }); + + test('When `month` and `max`/`min` are different years, no month options are disabled', async () => { + const { getByLabelText, findAllByRole } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + + userEvent.click(monthSelect); + + const options = await findAllByRole('option'); + + for (const element of options) { + expect(element).toBeInTheDocument(); + + expect(element).not.toHaveAttribute('aria-disabled', 'true'); + } + }); + + describe('When `year` is after `max`', () => { + test('all options are disabled', async () => { + const { getByLabelText, findAllByRole } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + + userEvent.click(monthSelect); + + const options = await findAllByRole('option'); + + for (const element of options) { + expect(element).toBeInTheDocument(); + + expect(element).toHaveAttribute('aria-disabled', 'true'); + } + }); + + test('placeholder text renders the invalid month/year', async () => { + const { getByLabelText } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + const yearSelect = getByLabelText('Select year'); + + expect(monthSelect).toHaveTextContent('Jul'); + expect(yearSelect).toHaveTextContent('2025'); + }); + }); + + describe('When `year` is before `min`', () => { + test('all options are disabled', async () => { + const { getByLabelText, findAllByRole } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + + userEvent.click(monthSelect); + + const options = await findAllByRole('option'); + + for (const element of options) { + expect(element).toBeInTheDocument(); + + expect(element).toHaveAttribute('aria-disabled', 'true'); + } + }); + + test('placeholder text renders the invalid month/year', async () => { + const { getByLabelText } = render( + + + {}} /> + + , + ); + + const monthSelect = getByLabelText('Select month'); + const yearSelect = getByLabelText('Select year'); + + expect(monthSelect).toHaveTextContent('Jul'); + expect(yearSelect).toHaveTextContent('2021'); + }); + }); + }); + }); + + describe('Interaction', () => { + const mockSetIsSelectOpen = jest.fn(); + + beforeEach(() => { + mockSetIsSelectOpen.mockClear(); + jest.useFakeTimers(); + }); + + const AllMockProviders = ({ children }: PropsWithChildren<{}>) => { + const [isSelectOpen, _setIsSelectOpen] = useState(false); + + const setIsSelectOpen = (action: React.SetStateAction) => { + mockSetIsSelectOpen(action); + _setIsSelectOpen(action); + }; + + return ( + + + {children} + + + ); + }; + + test('opening & closing a select menu calls `setIsSelectOpen` in SharedDatePickerContext', async () => { + const { getByLabelText } = render( + + {}} /> + , + ); + + const monthSelect = getByLabelText('Select month'); + userEvent.click(monthSelect); + await waitFor(() => { + jest.advanceTimersByTime(transitionDuration.default); + expect(mockSetIsSelectOpen).toHaveBeenCalledWith(true); + }); + userEvent.click(monthSelect); + + await waitFor(() => { + jest.advanceTimersByTime(transitionDuration.default); + expect(mockSetIsSelectOpen).toHaveBeenCalledWith(false); + }); + }); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.tsx new file mode 100644 index 0000000000..b4ed2b53cf --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.tsx @@ -0,0 +1,180 @@ +import React, { forwardRef, MouseEventHandler, useCallback } from 'react'; +import range from 'lodash/range'; + +import { + getLocaleMonths, + isSameUTCMonth, + setUTCMonth, + setUTCYear, +} from '@leafygreen-ui/date-utils'; +import { cx } from '@leafygreen-ui/emotion'; +import Icon from '@leafygreen-ui/icon'; +import IconButton from '@leafygreen-ui/icon-button'; +import { Option, Select } from '@leafygreen-ui/select'; + +import { selectElementProps } from '../../../shared/constants'; +import { useSharedDatePickerContext } from '../../../shared/context'; +import { useDatePickerContext } from '../../DatePickerContext'; +import { + menuHeaderSelectContainerStyles, + menuHeaderStyles, + selectInputWidthStyles, + selectTruncateStyles, +} from '../DatePickerMenu.styles'; + +import { shouldChevronBeDisabled, shouldMonthBeEnabled } from './utils'; + +interface DatePickerMenuHeaderProps { + setMonth: (newMonth: Date) => void; +} + +/** + * A helper component for DatePickerMenu. + * Tests for this component are in DatePickerMenu + * @internal + */ +export const DatePickerMenuHeader = forwardRef< + HTMLDivElement, + DatePickerMenuHeaderProps +>(({ setMonth, ...rest }: DatePickerMenuHeaderProps, fwdRef) => { + const { min, max, setIsSelectOpen, locale, isInRange } = + useSharedDatePickerContext(); + const { refs, month } = useDatePickerContext(); + + const monthOptions = getLocaleMonths(locale); + const yearOptions = range(min.getUTCFullYear(), max.getUTCFullYear() + 1); + + const updateMonth = (newMonth: Date) => { + // We don't do any checks here. + // If the month is out of range, we still display it + setMonth(newMonth); + }; + + /** + * If the month is not in range and is not the last valid month + * e.g. + * This is not in range and is not the last valid month + * min: new Date(Date.UTC(2038, Month.March, 19)); + * current month date: new Date(Date.UTC(2038, Month.Feburary, 19)); + * + * This is not in range but it is the last valid month + * min: new Date(Date.UTC(2038, Month.March, 19)); + * current month date: new Date(Date.UTC(2038, Month.March, 18)); + */ + const isMonthInValid = (dir: 'left' | 'right') => { + const isOnLastValidMonth = isSameUTCMonth( + month, + dir === 'left' ? max : min, + ); + const isDateInRange = isInRange(month); + + return !isDateInRange && !isOnLastValidMonth; + }; + + /** + * Calls the `updateMonth` helper with the appropriate month when a Chevron is clicked + */ + const handleChevronClick = + (dir: 'left' | 'right'): MouseEventHandler => + e => { + e.stopPropagation(); + e.preventDefault(); + + // e.g. + // max: new Date(Date.UTC(2038, Month.January, 19)); + // current month date: new Date(Date.UTC(2038, Month.March, 19)); + // left chevron will change the month back to January 2038 + // e.g. + // min: new Date(Date.UTC(1970, Month.January, 1)); + // current month date: new Date(Date.UTC(1969, Month.November, 19)); + // right chevron will change the month back to January 1970 + if (isMonthInValid(dir)) { + const closestValidDate = dir === 'left' ? max : min; + const newMonthIndex = closestValidDate.getUTCMonth(); + const newMonth = setUTCMonth(closestValidDate, newMonthIndex); + updateMonth(newMonth); + } else { + const increment = dir === 'left' ? -1 : 1; + const newMonthIndex = month.getUTCMonth() + increment; + const newMonth = setUTCMonth(month, newMonthIndex); + updateMonth(newMonth); + } + }; + + /** Returns whether the provided month should be enabled */ + const isMonthEnabled = useCallback( + (monthName: string) => + shouldMonthBeEnabled(monthName, { month, min, max, locale }), + [locale, max, min, month], + ); + + return ( +
+ + + +
+ + +
+ + + +
+ ); +}); + +DatePickerMenuHeader.displayName = 'DatePickerMenuHeader'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/index.ts new file mode 100644 index 0000000000..c1b41ba02b --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/index.ts @@ -0,0 +1 @@ +export { DatePickerMenuHeader } from './DatePickerMenuHeader'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/index.ts new file mode 100644 index 0000000000..de83ccca8a --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/index.ts @@ -0,0 +1,2 @@ +export { shouldChevronBeDisabled } from './shouldChevronBeDisabled'; +export { shouldMonthBeEnabled } from './shouldMonthBeEnabled'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/index.ts new file mode 100644 index 0000000000..78bd1dbfbd --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/index.ts @@ -0,0 +1,23 @@ +import { isSameUTCMonth } from '@leafygreen-ui/date-utils'; + +/** + * Checks if chevron should be disabled + * + * @param direction left or right chevron + * @param day1 the full date of the current month shown in the menu (month) + * @param day2 the full date that current menu date is compared against (min/max) + * @returns + */ +export const shouldChevronBeDisabled = ( + direction: 'left' | 'right', + day1: Date, + day2: Date, +): boolean => { + if (!day1 || !day2) return false; + + if (direction === 'right') { + return day1 >= day2 || isSameUTCMonth(day1, day2); + } + + return day1 <= day2 || isSameUTCMonth(day1, day2); +}; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/isChevronDisabled.spec.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/isChevronDisabled.spec.ts new file mode 100644 index 0000000000..a19486324c --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldChevronBeDisabled/isChevronDisabled.spec.ts @@ -0,0 +1,146 @@ +import { Month } from '@leafygreen-ui/date-utils'; + +import { shouldChevronBeDisabled } from '.'; + +const testMinDate = new Date(Date.UTC(1970, Month.February, 20)); +const testMaxDate = new Date(Date.UTC(2037, Month.February, 20)); + +const beforeMinDateDiffYear = new Date(Date.UTC(1969, Month.February, 20)); +const beforeMinDateSameMonth = new Date(Date.UTC(1970, Month.February, 19)); +const beforeMinDateSameYearDiffMonth = new Date( + Date.UTC(1970, Month.January, 20), +); + +const afterMinDateSameMonth = new Date(Date.UTC(1970, Month.February, 21)); +const afterMinDateSameYear = new Date(Date.UTC(1970, Month.March, 20)); +const afterMinDateDifferentYear = testMaxDate; + +const afterMaxDateSameMonth = new Date(Date.UTC(2037, Month.February, 21)); +const afterMaxDateDiffYear = new Date(Date.UTC(2038, Month.February, 20)); +const afterMaxDateSameYearDiffMonth = new Date(Date.UTC(2037, Month.March, 20)); + +const beforeMaxDateSameMonth = new Date(Date.UTC(2037, Month.February, 19)); +const beforeMaxDateSameYear = new Date(Date.UTC(2037, Month.January, 20)); +const beforeMaxDateDiffYear = testMinDate; + +describe('packages/date-picker/menu/utils/shouldMonthBeEnabled', () => { + describe('left chevron', () => { + describe('returns true', () => { + describe('when the menu date is before the minDate', () => { + test('but is in a month that has both valid and invalid dates', () => { + expect( + shouldChevronBeDisabled( + 'left', + beforeMinDateSameMonth, + testMinDate, + ), + ).toBeTruthy(); + }); + test('and is in a different year', () => { + expect( + shouldChevronBeDisabled('left', beforeMinDateDiffYear, testMinDate), + ).toBeTruthy(); + }); + test('and is in the same year and different month', () => { + expect( + shouldChevronBeDisabled( + 'left', + beforeMinDateSameYearDiffMonth, + testMinDate, + ), + ).toBeTruthy(); + }); + }); + describe('when the menu date is after the minDate', () => { + test('but is in a month that has both valid and invalid dates', () => { + expect( + shouldChevronBeDisabled('left', afterMinDateSameMonth, testMinDate), + ).toBeTruthy(); + }); + }); + }); + + describe('returns false', () => { + describe('when the menu date is after the minDate', () => { + test('and is in the same year', () => { + expect( + shouldChevronBeDisabled('left', afterMinDateSameYear, testMinDate), + ).toBeFalsy(); + }); + test('and is in a different year', () => { + expect( + shouldChevronBeDisabled( + 'left', + afterMinDateDifferentYear, + testMinDate, + ), + ).toBeFalsy(); + }); + }); + }); + }); + + describe('right chevron', () => { + describe('returns true', () => { + describe('when the menu date is after the maxDate', () => { + test('but is in a month that has both valid and invalid dates', () => { + expect( + shouldChevronBeDisabled( + 'right', + afterMaxDateSameMonth, + testMaxDate, + ), + ).toBeTruthy(); + }); + test('and is in a different year', () => { + expect( + shouldChevronBeDisabled('right', afterMaxDateDiffYear, testMaxDate), + ).toBeTruthy(); + }); + test('and is in the same year and different month', () => { + expect( + shouldChevronBeDisabled( + 'right', + afterMaxDateSameYearDiffMonth, + testMaxDate, + ), + ).toBeTruthy(); + }); + }); + describe('when the menu date is before the maxDate', () => { + test('but is in a month that has both valid and invalid dates', () => { + expect( + shouldChevronBeDisabled( + 'right', + beforeMaxDateSameMonth, + testMaxDate, + ), + ).toBeTruthy(); + }); + }); + }); + + describe('returns false', () => { + describe('when the menu date is before the maxDate', () => { + test('and is in the same year', () => { + expect( + shouldChevronBeDisabled( + 'right', + beforeMaxDateSameYear, + testMaxDate, + ), + ).toBeFalsy(); + }); + test('and is in a different year', () => { + expect( + shouldChevronBeDisabled( + 'right', + beforeMaxDateDiffYear, + testMaxDate, + ), + ).toBeFalsy(); + }); + }); + }); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/index.ts new file mode 100644 index 0000000000..e5002e632a --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/index.ts @@ -0,0 +1,42 @@ +import isNull from 'lodash/isNull'; + +import { DateType, getMonthIndex } from '@leafygreen-ui/date-utils'; + +export const shouldMonthBeEnabled = ( + monthName: string, + context?: { + month?: DateType; + min?: DateType; + max?: DateType; + locale?: string; + }, +): boolean => { + const monthIndex = getMonthIndex(monthName, context?.locale); + + if (isNull(monthIndex)) return false; + + if (!context) { + return true; + } + + const { month, min, max } = context; + + const year = context.month?.getUTCFullYear(); + const minYear = context.min?.getUTCFullYear(); + const maxYear = context.max?.getUTCFullYear(); + + if (year && minYear && year < minYear) return false; + if (year && maxYear && year > maxYear) return false; + + if (month && min && year === minYear) { + if (monthIndex < min.getUTCMonth()) return false; + return true; + } + + if (month && max && year === maxYear) { + if (monthIndex > max.getUTCMonth()) return false; + return true; + } + + return true; +}; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/shouldMonthBeEnabled.spec.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/shouldMonthBeEnabled.spec.ts new file mode 100644 index 0000000000..6e4cd5ee92 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/utils/shouldMonthBeEnabled/shouldMonthBeEnabled.spec.ts @@ -0,0 +1,107 @@ +import { Month, newUTC } from '@leafygreen-ui/date-utils'; + +import { shouldMonthBeEnabled } from '.'; + +describe('packages/date-picker/menu/utils/shouldMonthBeEnabled', () => { + test('returns true by default', () => { + expect(shouldMonthBeEnabled('July')).toBeTruthy(); + }); + + test('returns false if given an invalid month', () => { + expect(shouldMonthBeEnabled('Quintember')).toBeFalsy(); + }); + + test('returns all 12 months when only month is provided', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2023, Month.July, 15), + }), + ).toBeTruthy(); + }); + + test('returns true when month, min & max are different years', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2023, Month.July, 15), + min: newUTC(2000, Month.January, 15), + max: newUTC(2050, Month.December, 15), + }), + ).toBeTruthy(); + }); + + describe('when month & min are the same year', () => { + test('returns false when month is before min month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.October, 14), + min: newUTC(2024, Month.September, 10), + }), + ).toBeFalsy(); + }); + + test('returns true when month is same min month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.October, 14), + min: newUTC(2024, Month.July, 5), + }), + ).toBeTruthy(); + }); + + test('returns true when month is after min month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.October, 14), + min: newUTC(2024, Month.March, 10), + }), + ).toBeTruthy(); + }); + }); + + describe('when month & max are the same year', () => { + test('returns false when month is after max month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.February, 14), + max: newUTC(2024, Month.March, 10), + }), + ).toBeFalsy(); + }); + + test('returns true when month is same as max month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.March, 10), + max: newUTC(2024, Month.July, 5), + }), + ).toBeTruthy(); + }); + + test('returns true when month is before max month', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.March, 10), + max: newUTC(2024, Month.September, 10), + }), + ).toBeTruthy(); + }); + + test('returns false when the year is before the min year', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2024, Month.March, 10), + min: newUTC(2025, Month.September, 10), + }), + ).toBeFalsy(); + }); + + test('returns false when the year is after the max year', () => { + expect( + shouldMonthBeEnabled('July', { + month: newUTC(2026, Month.March, 10), + max: newUTC(2025, Month.September, 10), + }), + ).toBeFalsy(); + }); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/index.ts new file mode 100644 index 0000000000..e5aad2fee0 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/index.ts @@ -0,0 +1,2 @@ +export { DatePickerMenu } from './DatePickerMenu'; +export { DatePickerMenuProps } from './DatePickerMenu.types'; diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/getNewHighlight.spec.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/getNewHighlight.spec.ts new file mode 100644 index 0000000000..189879fab8 --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/getNewHighlight.spec.ts @@ -0,0 +1,43 @@ +import { getNewHighlight } from '.'; + +describe('packages/date-picker/date-picker-menu/utils/getNewHighlight', () => { + test('new highlight is the first day when new month is after current month', () => { + const newHighlight = getNewHighlight( + new Date(Date.UTC(2023, 6, 5)), + new Date(Date.UTC(2023, 6, 1)), + new Date(Date.UTC(2023, 11, 5)), + ); + + expect(newHighlight).toEqual(new Date(Date.UTC(2023, 11, 1))); + }); + + test('new highlight is the last day when new month is before current month', () => { + const newHighlight = getNewHighlight( + new Date(Date.UTC(2023, 6, 5)), + new Date(Date.UTC(2023, 6, 1)), + new Date(Date.UTC(2023, 1, 5)), + ); + + expect(newHighlight).toEqual(new Date(Date.UTC(2023, 1, 28))); + }); + + test('returns undefined when the new month is the same as current month', () => { + const newHighlight = getNewHighlight( + new Date(Date.UTC(2023, 1, 5)), + new Date(Date.UTC(2023, 6, 5)), + new Date(Date.UTC(2023, 6, 15)), + ); + + expect(newHighlight).toBeUndefined(); + }); + + test('returns undefined when the new month is the same as current highlight', () => { + const newHighlight = getNewHighlight( + new Date(Date.UTC(2023, 1, 5)), + new Date(Date.UTC(2023, 6, 5)), + new Date(Date.UTC(2023, 1, 15)), + ); + + expect(newHighlight).toBeUndefined(); + }); +}); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/index.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/index.ts new file mode 100644 index 0000000000..fefa68a54a --- /dev/null +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/utils/getNewHighlight/index.ts @@ -0,0 +1,30 @@ +import { isAfter } from 'date-fns'; + +import { + getFirstOfUTCMonth, + getLastOfMonth, + isSameUTCMonth, +} from '@leafygreen-ui/date-utils'; + +export const getNewHighlight = ( + currentHighlight: Date | null, + currentMonth: Date, + newMonth: Date, +) => { + if ( + isSameUTCMonth(newMonth, currentMonth) || + isSameUTCMonth(newMonth, currentHighlight) + ) { + return; + } + + let newHighlight: Date; + + if (isAfter(newMonth, currentMonth)) { + newHighlight = getFirstOfUTCMonth(newMonth); + } else { + newHighlight = getLastOfMonth(newMonth); + } + + return newHighlight; +}; diff --git a/packages/date-picker/src/DatePicker/index.ts b/packages/date-picker/src/DatePicker/index.ts new file mode 100644 index 0000000000..359dc98784 --- /dev/null +++ b/packages/date-picker/src/DatePicker/index.ts @@ -0,0 +1,2 @@ +export { DatePicker } from './DatePicker'; +export { type DatePickerProps } from './DatePicker.types'; diff --git a/packages/date-picker/src/DatePicker/utils/getInitialHighlight/getInitialHighlight.spec.ts b/packages/date-picker/src/DatePicker/utils/getInitialHighlight/getInitialHighlight.spec.ts new file mode 100644 index 0000000000..a938b87e35 --- /dev/null +++ b/packages/date-picker/src/DatePicker/utils/getInitialHighlight/getInitialHighlight.spec.ts @@ -0,0 +1,27 @@ +import { Month, newUTC } from '@leafygreen-ui/date-utils'; +import { testTimeZones } from '@leafygreen-ui/date-utils'; + +import { getInitialHighlight } from '.'; + +describe('packages/date-picker/utils/getInitialHighlight', () => { + const value = newUTC(2023, Month.September, 10); + const today = newUTC(2023, Month.December, 25, 1); + + describe.each(testTimeZones)('for timeZone $tz', ({ tz, UTCOffset }) => { + test('returns `value` when provided', () => { + const highlight = getInitialHighlight(value, today, tz); + expect(highlight).toEqual(value); + }); + + test('returns `today` if no value is provided', () => { + const highlight = getInitialHighlight(null, today, tz); + const expectedDate = newUTC( + 2023, + Month.December, + UTCOffset < -1 ? 24 : 25, + 0, + ); + expect(highlight).toEqual(expectedDate); + }); + }); +}); diff --git a/packages/date-picker/src/DatePicker/utils/getInitialHighlight/index.ts b/packages/date-picker/src/DatePicker/utils/getInitialHighlight/index.ts new file mode 100644 index 0000000000..c98d697fb6 --- /dev/null +++ b/packages/date-picker/src/DatePicker/utils/getInitialHighlight/index.ts @@ -0,0 +1,21 @@ +import { + DateType, + getSimulatedTZDate, + isValidDate, + setToUTCMidnight, +} from '@leafygreen-ui/date-utils'; + +/** Returns the initial highlight value when the date picker is opened */ +export const getInitialHighlight = ( + value: DateType | undefined, + today: Date, + timeZone: string, +) => { + if (isValidDate(value)) return value; + + // return the UTC-midnight representation of the local `today` + // e.g. given `today` = "2023-12-24T12:00:00Z", and `timeZone` = 'Pacific/Auckland' (UTC+13) + // Locally, the date is `2023-12-25`, and so we should return that date + const simulatedToday = getSimulatedTZDate(today, timeZone); + return setToUTCMidnight(simulatedToday); +}; diff --git a/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/getSegmentToFocus.spec.ts b/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/getSegmentToFocus.spec.ts new file mode 100644 index 0000000000..142e0fab86 --- /dev/null +++ b/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/getSegmentToFocus.spec.ts @@ -0,0 +1,94 @@ +import { createRef } from 'react'; + +import { SegmentRefs } from '../../../shared/hooks'; +import { getFormatParts } from '../../../shared/utils'; + +import { getSegmentToFocus } from '.'; + +describe('packages/date-picker/utils/getSegmentToFocus', () => { + const formatParts = getFormatParts('iso8601'); + + test('if target is a segment, return target', () => { + const target = document.createElement('input'); + + const segmentRefs: SegmentRefs = { + year: createRef(), + month: createRef(), + day: { current: target }, + }; + + const segment = getSegmentToFocus({ + target, + formatParts, + segmentRefs, + }); + + expect(segment).toBe(target); + }); + + test('if all inputs are filled, return the last input', () => { + const target = document.createElement('div'); + + const yearEl = document.createElement('input'); + yearEl.value = '1993'; + yearEl.id = 'year'; + const monthEl = document.createElement('input'); + monthEl.value = '12'; + monthEl.id = 'month'; + const dayEl = document.createElement('input'); + dayEl.value = '26'; + dayEl.id = 'day'; + + const segmentRefs: SegmentRefs = { + year: { current: yearEl }, + month: { current: monthEl }, + day: { current: dayEl }, + }; + + const segment = getSegmentToFocus({ + target, + formatParts, + segmentRefs, + }); + + expect(segment).toBe(dayEl); + }); + + test('if first input is filled, return second input', () => { + const target = document.createElement('div'); + + const yearEl = document.createElement('input'); + yearEl.value = '1993'; + yearEl.id = 'year'; + const monthEl = document.createElement('input'); + monthEl.id = 'month'; + const dayEl = document.createElement('input'); + dayEl.id = 'day'; + + const segmentRefs: SegmentRefs = { + year: { current: yearEl }, + month: { current: monthEl }, + day: { current: dayEl }, + }; + + const segment = getSegmentToFocus({ + target, + formatParts, + segmentRefs, + }); + + expect(segment).toBe(monthEl); + }); + + test('returns undefined for undefined input', () => { + const segment = getSegmentToFocus({ + // @ts-expect-error + target: undefined, + formatParts: undefined, + // @ts-expect-error + segmentRefs: undefined, + }); + + expect(segment).toBeUndefined(); + }); +}); diff --git a/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/index.ts b/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/index.ts new file mode 100644 index 0000000000..cb5efb645d --- /dev/null +++ b/packages/date-picker/src/DatePicker/utils/getSegmentToFocus/index.ts @@ -0,0 +1,67 @@ +import isUndefined from 'lodash/isUndefined'; +import last from 'lodash/last'; + +import { SharedDatePickerContextProps } from '../../../shared/context'; +import { SegmentRefs } from '../../../shared/hooks'; +import { DateSegment } from '../../../shared/types'; +import { getFirstEmptySegment } from '../../../shared/utils'; + +interface GetSegmentToFocusProps { + target: EventTarget; + formatParts: SharedDatePickerContextProps['formatParts']; + segmentRefs: SegmentRefs; +} + +/** + * Helper function that focuses the appropriate input segment + * given an event target and segment refs. + * + * 1) if the target was a segment, focus that segment + * 2) otherwise, if all segments are filled, focus the last one + * 3) but, if some segments are empty, focus the first empty one + */ +export const getSegmentToFocus = ({ + target, + formatParts, + segmentRefs, +}: GetSegmentToFocusProps): HTMLElement | undefined | null => { + if ( + isUndefined(target) || + isUndefined(formatParts) || + isUndefined(segmentRefs) + ) { + return; + } + + const segmentRefsArray = Object.values(segmentRefs).map(r => r.current); + + const isTargetASegment = segmentRefsArray.includes( + target as HTMLInputElement, + ); + + // If we didn't explicitly click on an input segment... + if (!isTargetASegment) { + const allSegmentsFilled = segmentRefsArray.every(el => el?.value); + // filter out the literals from the format parts + const formatSegments = formatParts.filter(part => part.type !== 'literal'); + + // Check which segments are filled, + if (allSegmentsFilled) { + // if all are filled, focus the last one, + const lastSegmentPart = last(formatSegments) as Intl.DateTimeFormatPart; + const keyOfLastSegment = lastSegmentPart.type as DateSegment; + const lastSegmentRef = segmentRefs[keyOfLastSegment]; + return lastSegmentRef.current; + } else { + // if 1+ are empty, focus the first empty one + return getFirstEmptySegment({ + formatParts, + segmentRefs, + }); + } + } + + // otherwise, we clicked a specific segment, + // so we focus on that segment (default behavior) + return target as HTMLInputElement; +}; diff --git a/packages/date-picker/src/index.ts b/packages/date-picker/src/index.ts new file mode 100644 index 0000000000..d63a67e382 --- /dev/null +++ b/packages/date-picker/src/index.ts @@ -0,0 +1,6 @@ +export { DatePicker, type DatePickerProps } from './DatePicker'; +export * from './shared/components'; +export * from './shared/constants'; +export * from './shared/hooks'; +export * from './shared/types'; +export * from './shared/utils'; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.spec.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.spec.tsx new file mode 100644 index 0000000000..c5bb886c14 --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.spec.tsx @@ -0,0 +1,118 @@ +import React, { PropsWithChildren } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CalendarCell, CalendarCellState } from '.'; + +/** Ensures valid DOM nesting when testing CalendarCells */ +const TestCellWrapper = ({ children }: PropsWithChildren) => ( + + + {children} + +
+); + +describe('packages/date-picker/shared/calendar-cell', () => { + describe('Rendering', () => { + test('has `gridcell` role', () => { + const { queryByRole, getByTestId } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + expect(gridcell).toBeInTheDocument(); + expect(getByTestId('tr').firstChild).toEqual(gridcell); + }); + + test('renders as aria-disabled', () => { + const clickHandler = jest.fn(); + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + expect(gridcell).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + test('triggers click handler on click', () => { + const clickHandler = jest.fn(); + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + userEvent.click(gridcell!, {}, { skipPointerEventsCheck: true }); + expect(clickHandler).toHaveBeenCalled(); + }); + + describe('Interaction', () => { + const stateCases = [CalendarCellState.Default, CalendarCellState.Active]; + describe.each(stateCases)('when cell is in %p state', state => { + const keypressCases = ['Enter', 'Space']; + test.each(keypressCases)('%p key triggers click handler', key => { + const clickHandler = jest.fn(); + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + gridcell!.focus(); + userEvent.keyboard(`[${key}]`); + expect(clickHandler).toHaveBeenCalled(); + }); + }); + + test('Does not fire click handler when disabled', () => { + const clickHandler = jest.fn(); + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + userEvent.click(gridcell!, {}, { skipPointerEventsCheck: true }); + expect(clickHandler).not.toHaveBeenCalled(); + }); + + test('is focusable when highlighted', () => { + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + userEvent.click(gridcell!, {}, { skipPointerEventsCheck: true }); + expect(gridcell).toHaveFocus(); + }); + + test('is not focusable when disabled', () => { + const { queryByRole } = render( + + + , + ); + const gridcell = queryByRole('gridcell'); + userEvent.click(gridcell!, {}, { skipPointerEventsCheck: true }); + expect(gridcell).not.toHaveFocus(); + }); + }); +}); diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.stories.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.stories.tsx new file mode 100644 index 0000000000..1b5a6b492d --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.stories.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../../context'; + +import { CalendarCell } from './CalendarCell'; +import { + CalendarCellRangeState, + CalendarCellState, +} from './CalendarCell.types'; + +const meta: StoryMetaType = { + title: 'Components/DatePicker/Shared/CalendarCell', + component: CalendarCell, + parameters: { + default: null, + generate: { + storyNames: ['DefaultCells', 'ActiveCells', 'DisabledCells'], + combineArgs: { + darkMode: [false, true], + 'data-hover': [false, true], + isHighlighted: [false, true], + isCurrent: [false, true], + rangeState: Object.values(CalendarCellRangeState), + }, + decorator: (Instance, ctx) => { + const { + args: { darkMode, size, ...props }, + } = ctx ?? { args: {} }; + + return ( + + {/* @ts-expect-error - incomplete context value */} + + + + + ); + }, + args: { + 'data-highlight': true, + }, + }, + }, + args: { children: '26' }, + argTypes: {}, +}; + +export default meta; + +const Template: StoryFn = props => ( + + + +); + +export const Basic = Template.bind({}); + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const DefaultCells: StoryFn = () => <>; +DefaultCells.parameters = { + generate: { + args: { + state: CalendarCellState.Default, + }, + + excludeCombinations: [ + { + 'data-hover': false, + rangeState: CalendarCellRangeState.Start, + }, + { + 'data-hover': false, + rangeState: CalendarCellRangeState.End, + }, + { + 'data-hover': true, + rangeState: CalendarCellRangeState.Range, + }, + ], + }, +}; + +export const ActiveCells: StoryFn = () => <>; +ActiveCells.parameters = { + generate: { + args: { + state: CalendarCellState.Active, + }, + excludeCombinations: [ + { + rangeState: CalendarCellRangeState.Range, + }, + ], + }, +}; + +export const DisabledCells: StoryFn = () => <>; +DisabledCells.parameters = { + generate: { + args: { + state: CalendarCellState.Disabled, + }, + excludeCombinations: [ + { + 'data-hover': true, + }, + { + isHighlighted: true, + }, + ], + }, +}; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.styles.ts b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.styles.ts new file mode 100644 index 0000000000..f2c7b4c50c --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.styles.ts @@ -0,0 +1,331 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { + fontFamilies, + fontWeights, + spacing, + typeScales, +} from '@leafygreen-ui/tokens'; + +import { + CalendarCellRangeState, + CalendarCellState, +} from './CalendarCell.types'; + +const CELL_SIZE = 28; +export const indicatorClassName = createUniqueClassName('calendar-cell'); + +const calendarCellFocusRing: Record = { + [Theme.Light]: `0 0 0 1px ${palette.white}, 0 0 0 3px ${palette.blue.light1}`, + [Theme.Dark]: `0 0 0 1px ${palette.black}, 0 0 0 3px ${palette.blue.light1}`, +}; + +export const calendarCellStyles = css` + position: relative; + font-family: ${fontFamilies.default}; + font-size: ${typeScales.body1.fontSize}px; + font-variant: tabular-nums; + height: ${CELL_SIZE}px; + width: ${CELL_SIZE}px; + cursor: pointer; + text-align: center; + padding: 0; + z-index: 0; +`; + +type ThemedStateStyles = Record>; + +const _baseActiveCellStyles: Record = { + [Theme.Light]: css` + color: ${palette.white}; + + & > .${indicatorClassName} { + background-color: ${palette.blue.dark1}; + } + `, + [Theme.Dark]: css` + color: ${palette.black}; + + & > .${indicatorClassName} { + background-color: ${palette.blue.light1}; + } + `, +}; + +/** + * Base styles for each state + */ +export const calendarCellStateStyles: ThemedStateStyles = { + [Theme.Light]: { + [CalendarCellState.Default]: css` + color: ${palette.black}; + `, + [CalendarCellState.Disabled]: css` + color: ${palette.gray.light1}; + cursor: not-allowed; + `, + [CalendarCellState.Active]: _baseActiveCellStyles[Theme.Light], + }, + [Theme.Dark]: { + [CalendarCellState.Default]: css` + color: ${palette.gray.light2}; + `, + [CalendarCellState.Disabled]: css` + color: ${palette.gray.dark1}; + cursor: not-allowed; + `, + [CalendarCellState.Active]: _baseActiveCellStyles[Theme.Dark], + }, +}; + +/** + * Styles for the current date + */ +export const calendarCellCurrentStyles: ThemedStateStyles = { + [Theme.Light]: { + [CalendarCellState.Default]: css` + color: ${palette.blue.base}; + `, + [CalendarCellState.Active]: css` + color: ${palette.white}; + `, + [CalendarCellState.Disabled]: css``, // No additional styles + }, + [Theme.Dark]: { + [CalendarCellState.Default]: css` + color: ${palette.blue.light1}; + `, + [CalendarCellState.Active]: css` + color: ${palette.black}; + `, + [CalendarCellState.Disabled]: css``, // No additional styles + }, +}; + +/** + * Range styles + */ + +const _baseRangeStartStyles = css` + &:after { + content: ''; + position: absolute; + width: 50%; + right: 0; + top: 0; + bottom: 0; + z-index: 0; + } +`; + +const _baseRangeEndStyles = css` + &:before { + content: ''; + position: absolute; + width: 50%; + left: 0; + top: 0; + bottom: 0; + z-index: 0; + } +`; + +export const calendarCellRangeStyles: Record< + Theme, + Record +> = { + [Theme.Light]: { + [CalendarCellRangeState.Start]: cx( + _baseRangeStartStyles, + css` + &:after { + background-color: ${palette.blue.light3}; + } + `, + ), + [CalendarCellRangeState.End]: cx( + _baseRangeEndStyles, + css` + &:before { + background-color: ${palette.blue.light3}; + } + `, + ), + [CalendarCellRangeState.Range]: cx( + _baseRangeStartStyles, + _baseRangeEndStyles, + css` + color: ${palette.black}; + &:before, + &:after { + background-color: ${palette.blue.light3}; + } + `, + ), + [CalendarCellRangeState.None]: css``, + }, + [Theme.Dark]: { + [CalendarCellRangeState.Start]: cx( + _baseRangeStartStyles, + css` + &:after { + background-color: ${palette.blue.dark3}; + } + `, + ), + [CalendarCellRangeState.End]: cx( + _baseRangeEndStyles, + css` + &:before { + background-color: ${palette.blue.dark3}; + } + `, + ), + [CalendarCellRangeState.Range]: cx( + _baseRangeStartStyles, + _baseRangeEndStyles, + css` + color: ${palette.blue.light3}; + &:before, + &:after { + background-color: ${palette.blue.dark3}; + } + `, + ), + [CalendarCellRangeState.None]: css``, + }, +}; + +/** + * Highlighted / Focus styles + */ +const highlightSelector = '&:focus, &[data-highlight="true"]'; // using a data selector lets us easily test these states + +export const calendarCellHighlightStyles: Record = { + [Theme.Light]: css` + ${highlightSelector} { + outline: none; + z-index: 1; + + & > .${indicatorClassName} { + box-shadow: ${calendarCellFocusRing.light}; + } + } + `, + [Theme.Dark]: css` + ${highlightSelector} { + outline: none; + z-index: 1; + + & > .${indicatorClassName} { + box-shadow: ${calendarCellFocusRing.dark}; + } + } + `, +}; + +/** + * Hover Styles + */ +const hoverSelector = '&:hover, &[data-hover="true"]'; + +export const calendarCellHoverStyles: ThemedStateStyles = { + [Theme.Light]: { + [CalendarCellState.Default]: css` + ${hoverSelector} { + color: ${palette.black}; + + & > .${indicatorClassName} { + background-color: ${palette.gray.light2}; + } + } + `, + [CalendarCellState.Active]: css` + ${hoverSelector} { + & > .${indicatorClassName} { + background-color: ${palette.blue.dark2}; + } + } + `, + [CalendarCellState.Disabled]: css``, + }, + [Theme.Dark]: { + [CalendarCellState.Default]: css` + ${hoverSelector} { + color: ${palette.white}; + + & > .${indicatorClassName} { + background-color: ${palette.gray.dark2}; + } + } + `, + [CalendarCellState.Active]: css` + ${hoverSelector} { + & > .${indicatorClassName} { + background-color: ${palette.blue.light2}; + } + } + `, + [CalendarCellState.Disabled]: css``, + }, +}; + +export const calendarCellRangeHoverStyles: Record = { + [Theme.Light]: css` + ${hoverSelector} { + & > .${indicatorClassName} { + background-color: ${palette.blue.light2}; + } + } + `, + [Theme.Dark]: css` + ${hoverSelector} { + & > .${indicatorClassName} { + background-color: ${palette.blue.dark2}; + } + } + `, +}; + +export const currentStyles: Record = { + [Theme.Light]: css` + color: ${palette.blue.base}; + font-weight: ${fontWeights.bold}; + `, + [Theme.Dark]: css` + color: ${palette.blue.light1}; + font-weight: ${fontWeights.bold}; + `, +}; + +export const indicatorBaseStyles = css` + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 1; + border-radius: 100%; + outline-offset: -1px; +`; + +export const cellTextStyles = css` + position: relative; + z-index: 1; +`; +export const cellTextCurrentStyles = css` + font-weight: ${fontWeights.medium}; + + &:after { + position: absolute; + content: ''; + bottom: 0; + left: 50%; + transform: translate(-50%); + height: 1px; + width: ${spacing[2]}px; + background-color: currentColor; + border-radius: 1px; + } +`; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.tsx new file mode 100644 index 0000000000..4b51979bc9 --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.tsx @@ -0,0 +1,123 @@ +import React, { + FocusEventHandler, + KeyboardEventHandler, + MouseEventHandler, +} from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useForwardedRef } from '@leafygreen-ui/hooks'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { + calendarCellCurrentStyles, + calendarCellHighlightStyles, + calendarCellHoverStyles, + calendarCellRangeHoverStyles, + calendarCellRangeStyles, + calendarCellStateStyles, + calendarCellStyles, + cellTextCurrentStyles, + cellTextStyles, + indicatorBaseStyles, + indicatorClassName, +} from './CalendarCell.styles'; +import { + CalendarCellProps, + CalendarCellRangeState, + CalendarCellState, +} from './CalendarCell.types'; + +/** + * A single calendar cell. + * + * Renders the appropriate styles based on + * the provided state, current & highlight props + */ +export const CalendarCell = React.forwardRef< + HTMLTableCellElement, + CalendarCellProps +>( + ( + { + children, + state = CalendarCellState.Default, + rangeState = CalendarCellRangeState.None, + isCurrent, + isHighlighted, + className, + onClick, + ...rest + }: CalendarCellProps, + fwdRef, + ) => { + const ref = useForwardedRef(fwdRef, null); + const { theme } = useDarkMode(); + + const isDisabled = state === CalendarCellState.Disabled; + const isFocusable = isHighlighted && !isDisabled; + const isActive = state === CalendarCellState.Active; + const isInRange = rangeState !== CalendarCellRangeState.None; + + const handleClick: MouseEventHandler = e => { + if (!isDisabled) { + (onClick as MouseEventHandler)?.(e); + } + }; + + // td does not trigger `onClick` on enter/space so we have to listen on key down + const handleKeyDown: KeyboardEventHandler = e => { + if (!isDisabled && (e.key === keyMap.Enter || e.key === keyMap.Space)) { + (onClick as KeyboardEventHandler)?.(e); + } + }; + + const handleFocus: FocusEventHandler = e => { + // not checking `isHighlighted` since this event is triggered + // before the prop changes + if (state === CalendarCellState.Disabled) { + e.currentTarget.blur(); + } + }; + + return ( + +
+ + {children} + + + ); + }, +); + +CalendarCell.displayName = 'CalendarCell'; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.types.ts b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.types.ts new file mode 100644 index 0000000000..2861e33731 --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/CalendarCell.types.ts @@ -0,0 +1,45 @@ +export const CalendarCellState = { + Default: 'default', + Active: 'active', + Disabled: 'disabled', +} as const; +export type CalendarCellState = + (typeof CalendarCellState)[keyof typeof CalendarCellState]; + +export const CalendarCellRangeState = { + None: 'none', + Start: 'start', + End: 'end', + Range: 'range', +} as const; +export type CalendarCellRangeState = + (typeof CalendarCellRangeState)[keyof typeof CalendarCellRangeState]; + +export interface CalendarCellProps + extends Omit, 'onClick'> { + /** The label for the calendar cell */ + ['aria-label']: string; + + /** + * The current state of the cell + */ + state?: CalendarCellState; + + /** + * Whether the cell is in a selected range + */ + rangeState?: CalendarCellRangeState; + + /** Whether the cell represents the current date */ + isCurrent?: boolean; + + /** + * Whether the cell should display hovered/highlighted styles. + * This is used to programmatically set highlight when using keyboard navigation + */ + isHighlighted?: boolean; + + onClick?: + | React.MouseEventHandler + | React.KeyboardEventHandler; +} diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarCell/index.ts b/packages/date-picker/src/shared/components/Calendar/CalendarCell/index.ts new file mode 100644 index 0000000000..a9622017ac --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarCell/index.ts @@ -0,0 +1,6 @@ +export { CalendarCell } from './CalendarCell'; +export { + type CalendarCellProps, + CalendarCellRangeState, + CalendarCellState, +} from './CalendarCell.types'; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.spec.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.spec.tsx new file mode 100644 index 0000000000..68229bf7ce --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.spec.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { CalendarGrid } from '.'; + +describe('packages/date-picker/shared/calendar-grid', () => { + test('has `grid` role', () => { + const { container, queryByRole } = render( + {() => <>}, + ); + const grid = queryByRole('grid'); + expect(grid).toBeInTheDocument(); + expect(container.firstChild).toEqual(grid); + }); + + test('day name headers have `columnheader` role', () => { + const { queryAllByRole } = render( + {() => <>}, + ); + const headerCells = queryAllByRole('columnheader'); + expect(headerCells).toHaveLength(7); + }); + + test('day name headers have `abbr` attribute', () => { + const { queryAllByRole } = render( + {() => <>}, + ); + const headerCells = queryAllByRole('columnheader'); + headerCells.forEach(cell => { + expect(cell).toHaveAttribute('abbr'); + }); + }); +}); diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.stories.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.stories.tsx new file mode 100644 index 0000000000..1d268731f2 --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.stories.tsx @@ -0,0 +1,119 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import { StoryFn } from '@storybook/react'; + +import { + getISODate, + isTodayTZ, + Month, + newUTC, + testLocales, + testTimeZoneLabels, +} from '@leafygreen-ui/date-utils'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, + useSharedDatePickerContext, +} from '../../../context'; +import { CalendarCell } from '../CalendarCell/CalendarCell'; + +import { CalendarGrid } from './CalendarGrid'; + +const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => ( + + + + + +); + +const meta: StoryMetaType = { + title: 'Components/DatePicker/Shared/CalendarGrid', + component: CalendarGrid, + parameters: { + default: 'Demo', + generate: { + combineArgs: { + darkMode: [false, true], + locale: testLocales, + }, + decorator: ProviderWrapper, + }, + }, + decorators: [ProviderWrapper], + args: { + locale: 'en-US', + timeZone: 'UTC', + }, + argTypes: { + darkMode: { control: 'boolean' }, + locale: { + control: 'select', + options: testLocales, + }, + timeZone: { + control: 'select', + options: testTimeZoneLabels, + }, + }, +}; + +export default meta; + +export const Basic: StoryFn = ({ ...props }) => { + const { timeZone } = useSharedDatePickerContext(); + const [month] = useState(newUTC(2023, Month.August, 1)); + + const [hovered, setHovered] = useState(); + + const handleHover = (id?: string) => () => { + setHovered(id); + }; + + return ( + + {(day, i) => ( + + {day?.getUTCDate()} + + )} + + ); +}; + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Generated: StoryFn = () => <>; +Generated.parameters = { + generate: { + args: { + month: newUTC(2023, Month.August, 1), + children: (day: Date, i: number) => ( + + {day?.getUTCDate()} + + ), + }, + }, +}; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.styles.ts b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.styles.ts new file mode 100644 index 0000000000..9786b3596c --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@leafygreen-ui/emotion'; +import { fontWeights } from '@leafygreen-ui/tokens'; + +export const calendarGridStyles = css` + height: max-content; + border-collapse: collapse; +`; + +export const calendarHeaderCellStyles = css` + font-weight: ${fontWeights.regular}; + text-transform: capitalize; +`; + +export const calendarThStyles = css` + padding: 0; +`; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.tsx b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.tsx new file mode 100644 index 0000000000..33ef117b6c --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.tsx @@ -0,0 +1,96 @@ +import React, { forwardRef, useMemo } from 'react'; +import range from 'lodash/range'; +import { getWeekStartByLocale } from 'weekstart'; + +import { + daysPerWeek, + getLocaleWeekdays, + getWeeksArray, +} from '@leafygreen-ui/date-utils'; +import { cx } from '@leafygreen-ui/emotion'; +import { Disclaimer } from '@leafygreen-ui/typography'; + +import { useSharedDatePickerContext } from '../../../context'; + +import { + calendarGridStyles, + calendarHeaderCellStyles, + calendarThStyles, +} from './CalendarGrid.styles'; +import { CalendarGridProps } from './CalendarGrid.types'; + +/** + * A simple table that renders the `CalendarCell` components passed as children + * + * Accepts a mapped render function as children. + * + * Example usage: + * ```tsx + * // Renders the current month + * + * {(day) => ( + * + * {day.getUTCDate()} + * + * )} + * + * ``` + * + */ +export const CalendarGrid = forwardRef( + ({ month, children, className, ...rest }: CalendarGridProps, fwdRef) => { + const { locale } = useSharedDatePickerContext(); + const weekStartsOn = getWeekStartByLocale(locale); + const weeks = useMemo( + () => getWeeksArray(month, { locale }), + [locale, month], + ); + + return ( + + + + {range(daysPerWeek).map(i => { + const dayIndex = (i + weekStartsOn) % daysPerWeek; + const weekday = getLocaleWeekdays(locale)[dayIndex]; + return ( + + ); + })} + + + + {weeks.map((week, w) => ( + + {week.map((day, d) => { + const index: number = w * daysPerWeek + d; + return day ? ( + children(day, index) + ) : ( + // eslint-disable-next-line jsx-a11y/no-interactive-element-to-noninteractive-role + + ); + })} + + ))} + +
+ + {weekday.short ?? weekday.abbr} + +
+ ); + }, +); + +CalendarGrid.displayName = 'CalendarGrid'; diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.types.ts b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.types.ts new file mode 100644 index 0000000000..0fd3d3ff3c --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/CalendarGrid.types.ts @@ -0,0 +1,10 @@ +export interface CalendarGridProps + extends Omit, 'children'> { + /** + * The month to display in the calendar grid + * @default {current month} + */ + month: Date; + + children: (day: Date, index: number) => React.ReactNode; +} diff --git a/packages/date-picker/src/shared/components/Calendar/CalendarGrid/index.ts b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/index.ts new file mode 100644 index 0000000000..e5e190618e --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/CalendarGrid/index.ts @@ -0,0 +1,2 @@ +export { CalendarGrid } from './CalendarGrid'; +export { CalendarGridProps } from './CalendarGrid.types'; diff --git a/packages/date-picker/src/shared/components/Calendar/index.ts b/packages/date-picker/src/shared/components/Calendar/index.ts new file mode 100644 index 0000000000..0150c00993 --- /dev/null +++ b/packages/date-picker/src/shared/components/Calendar/index.ts @@ -0,0 +1,7 @@ +export { + CalendarCell, + type CalendarCellProps, + CalendarCellRangeState, + CalendarCellState, +} from './CalendarCell'; +export { CalendarGrid, type CalendarGridProps } from './CalendarGrid'; diff --git a/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.styles.ts b/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.styles.ts new file mode 100644 index 0000000000..f11767d057 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.styles.ts @@ -0,0 +1,3 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const iconButtonStyles = css``; diff --git a/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.tsx b/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.tsx new file mode 100644 index 0000000000..d784b16ec9 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/CalendarButton/CalendarButton.tsx @@ -0,0 +1,30 @@ +import React, { forwardRef } from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import Icon from '@leafygreen-ui/icon'; +import IconButton, { BaseIconButtonProps } from '@leafygreen-ui/icon-button'; + +import { iconButtonStyles } from './CalendarButton.styles'; + +/** + * The icon button on the right of the DatePicker form field + */ +export const CalendarButton = forwardRef< + HTMLButtonElement, + BaseIconButtonProps +>(({ className, ...rest }: BaseIconButtonProps, fwdRef) => { + return ( + + + + ); +}); + +CalendarButton.displayName = 'CalendarButton'; diff --git a/packages/date-picker/src/shared/components/DateInput/CalendarButton/index.ts b/packages/date-picker/src/shared/components/DateInput/CalendarButton/index.ts new file mode 100644 index 0000000000..b4b4375473 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/CalendarButton/index.ts @@ -0,0 +1 @@ +export { CalendarButton } from './CalendarButton'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.stories.tsx b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.stories.tsx new file mode 100644 index 0000000000..f945f25cef --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.stories.tsx @@ -0,0 +1,116 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { StoryFn } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../../context'; +import { DatePickerState } from '../../../types'; + +import { DateFormField } from './DateFormField'; + +const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => { + return ( + + + + + + ); +}; + +const meta: StoryMetaType< + typeof DateFormField, + Partial +> = { + title: 'Components/DatePicker/Shared/DateFormField', + component: DateFormField, + parameters: { + default: null, + controls: { + exclude: ['inputId', 'descriptionId', 'errorId'], + }, + generate: { + combineArgs: { + darkMode: [false, true], + label: ['Label', undefined], + description: [undefined, 'Description'], + state: Object.values(DatePickerState), + disabled: [false, true], + size: Object.values(Size), + }, + excludeCombinations: [ + { + label: undefined, + description: 'Description', + }, + ], + decorator: ProviderWrapper, + args: { + children: ( + + ), + }, + }, + }, + args: { + label: 'Label', + description: 'Description', + errorMessage: 'This is an error message', + }, + argTypes: { + darkMode: { control: 'boolean' }, + }, +}; + +export default meta; + +const Template: StoryFn = () => { + return ( + + + + + + ); +}; + +export const Basic = Template.bind({}); + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Generated = () => {}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.styles.ts b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.styles.ts new file mode 100644 index 0000000000..2b9fb94b0e --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.styles.ts @@ -0,0 +1,7 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const iconButtonStyles = css` + svg + button { + margin-left: -8px; + } +`; diff --git a/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.tsx b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.tsx new file mode 100644 index 0000000000..624f7c600d --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { FormField, FormFieldInputContainer } from '@leafygreen-ui/form-field'; + +import { useSharedDatePickerContext } from '../../../context'; +import { DatePickerState } from '../../../types'; +import { CalendarButton } from '../CalendarButton'; + +import { iconButtonStyles } from './DateFormField.styles'; +import { DateFormFieldProps } from './DateFormField.types'; + +/** + * A wrapper around `FormField` that sets the relevant + * attributes, styling & icon button + */ +export const DateFormField = React.forwardRef< + HTMLDivElement, + DateFormFieldProps +>( + ( + { + children, + onInputClick, + onIconButtonClick, + buttonRef, + ...rest + }: DateFormFieldProps, + fwdRef, + ) => { + const { + label, + description, + stateNotification: { state, message: errorMessage }, + disabled, + isOpen, + menuId, + size, + ariaLabelProp, + ariaLabelledbyProp, + } = useSharedDatePickerContext(); + + return ( + + + } + > + {children} + + + ); + }, +); + +DateFormField.displayName = 'DateFormField'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.types.ts b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.types.ts new file mode 100644 index 0000000000..97090026eb --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateFormField/DateFormField.types.ts @@ -0,0 +1,15 @@ +import { MouseEventHandler } from 'react'; + +import { FormFieldProps } from '@leafygreen-ui/form-field'; +import { HTMLElementProps } from '@leafygreen-ui/lib'; + +export type DateFormFieldProps = HTMLElementProps<'div'> & { + children: FormFieldProps['children']; + /** Callback fired when the input is clicked */ + onInputClick?: MouseEventHandler; + /** Fired then the calendar icon button is clicked */ + onIconButtonClick?: MouseEventHandler; + + /** A ref for the content end button */ + buttonRef: React.RefObject; +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateFormField/index.ts b/packages/date-picker/src/shared/components/DateInput/DateFormField/index.ts new file mode 100644 index 0000000000..6c423345b5 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateFormField/index.ts @@ -0,0 +1,2 @@ +export { DateFormField } from './DateFormField'; +export { type DateFormFieldProps } from './DateFormField.types'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.spec.tsx new file mode 100644 index 0000000000..157f27b7d4 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.spec.tsx @@ -0,0 +1,398 @@ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Month, newUTC } from '@leafygreen-ui/date-utils'; + +import { + SharedDatePickerProvider, + SharedDatePickerProviderProps, +} from '../../../context'; +import { segmentRefsMock } from '../../../testutils'; +import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; + +import { DateInputBox, type DateInputBoxProps } from '.'; + +const renderDateInputBox = ( + props?: Omit, + context?: Partial, +) => { + const result = render( + + + , + ); + + const rerenderDateInputBox = ( + newProps?: Omit, + ) => { + result.rerender( + + + , + ); + }; + + const dayInput = result.container.querySelector( + 'input[aria-label="day"]', + ) as HTMLInputElement; + const monthInput = result.container.querySelector( + 'input[aria-label="month"]', + ) as HTMLInputElement; + const yearInput = result.container.querySelector( + 'input[aria-label="year"]', + ) as HTMLInputElement; + + if (!(dayInput && monthInput && yearInput)) { + throw new Error('Some or all input segments are missing'); + } + + return { ...result, rerenderDateInputBox, dayInput, monthInput, yearInput }; +}; + +describe('packages/date-picker/shared/date-input-box', () => { + const onSegmentChange = jest.fn(); + + const testContext: Partial = { + locale: 'iso8601', + timeZone: 'UTC', + }; + + afterEach(() => { + onSegmentChange.mockClear(); + }); + + describe('Rendering', () => { + describe.each(['day', 'month', 'year'])('%p', segment => { + test('renders the correct aria attributes', () => { + const result = renderDateInputBox(); + const input = result.getByLabelText(segment); + + // each segment has appropriate aria label + expect(input).toHaveAttribute('aria-label', segment); + }); + }); + + describe('renders segments in the correct order', () => { + test('iso8601', () => { + const result = renderDateInputBox(undefined, { locale: 'iso8601' }); + const segments = result.getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('aria-label', 'year'); + expect(segments[1]).toHaveAttribute('aria-label', 'month'); + expect(segments[2]).toHaveAttribute('aria-label', 'day'); + }); + + test('en-US', () => { + const result = renderDateInputBox(undefined, { locale: 'en-US' }); + const segments = result.getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('aria-label', 'month'); + expect(segments[1]).toHaveAttribute('aria-label', 'day'); + expect(segments[2]).toHaveAttribute('aria-label', 'year'); + }); + + test('en-UK', () => { + const result = renderDateInputBox(undefined, { locale: 'en-UK' }); + const segments = result.getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('aria-label', 'day'); + expect(segments[1]).toHaveAttribute('aria-label', 'month'); + expect(segments[2]).toHaveAttribute('aria-label', 'year'); + }); + }); + + test('renders empty segments when no props are passed', () => { + const { dayInput, monthInput, yearInput } = renderDateInputBox( + undefined, + testContext, + ); + expect(dayInput).toHaveValue(''); + expect(monthInput).toHaveValue(''); + expect(yearInput).toHaveValue(''); + }); + + test('renders empty segments when value is null', () => { + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { value: null }, + testContext, + ); + expect(dayInput).toHaveValue(''); + expect(monthInput).toHaveValue(''); + expect(yearInput).toHaveValue(''); + }); + + test('renders filled segments when a value is passed', () => { + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { value: newUTC(1993, Month.December, 26) }, + testContext, + ); + + expect(dayInput.value).toBe('26'); + expect(monthInput.value).toBe('12'); + expect(yearInput.value).toBe('1993'); + }); + + test('renders empty segments when an invalid value is passed', () => { + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { value: new Date('invalid') }, + testContext, + ); + + expect(dayInput.value).toBe(''); + expect(monthInput.value).toBe(''); + expect(yearInput.value).toBe(''); + }); + + describe('re-rendering', () => { + test('with new value updates the segments', () => { + const { rerenderDateInputBox, dayInput, monthInput, yearInput } = + renderDateInputBox( + { value: newUTC(1993, Month.December, 26) }, + testContext, + ); + + rerenderDateInputBox({ value: newUTC(1994, Month.September, 10) }); + + expect(dayInput.value).toBe('10'); + expect(monthInput.value).toBe('09'); + expect(yearInput.value).toBe('1994'); + }); + + test('with null clears the segments', () => { + const { rerenderDateInputBox, dayInput, monthInput, yearInput } = + renderDateInputBox( + { value: newUTC(1993, Month.December, 26) }, + testContext, + ); + + rerenderDateInputBox({ value: null }); + + expect(dayInput.value).toBe(''); + expect(monthInput.value).toBe(''); + expect(yearInput.value).toBe(''); + }); + + test('with invalid value does not update the segments', () => { + const { rerenderDateInputBox, dayInput, monthInput, yearInput } = + renderDateInputBox( + { value: newUTC(1993, Month.December, 26) }, + testContext, + ); + + rerenderDateInputBox({ value: new Date('invalid') }); + + expect(dayInput.value).toBe('26'); + expect(monthInput.value).toBe('12'); + expect(yearInput.value).toBe('1993'); + }); + }); + }); + + describe('Typing', () => { + describe('single segment', () => { + test('updates the rendered segment value', () => { + const { dayInput } = renderDateInputBox(undefined, testContext); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is not immediately formatted', () => { + const { dayInput } = renderDateInputBox(undefined, testContext); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + const { dayInput } = renderDateInputBox(undefined, testContext); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('backspace deletes characters', () => { + const { dayInput, yearInput } = renderDateInputBox( + { value: null }, + testContext, + ); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe('2'); + + userEvent.type(yearInput, '1993'); + userEvent.type(yearInput, '{backspace}'); + expect(yearInput.value).toBe('199'); + }); + + test('segment change handler is called when typing into a segment', () => { + const { yearInput } = renderDateInputBox( + { onSegmentChange }, + testContext, + ); + userEvent.type(yearInput, '1993'); + + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '1993' }), + ); + }); + + test('value setter is not called when typing into a segment', () => { + const setValue = jest.fn(); + const { dayInput } = renderDateInputBox({ setValue }, testContext); + + userEvent.type(dayInput, '26'); + }); + + test('segment change handler is called when deleting from a single segment', () => { + const { dayInput } = renderDateInputBox( + { onSegmentChange }, + testContext, + ); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('value setter is not called when deleting from a single segment', () => { + const setValue = jest.fn(); + + const { dayInput } = renderDateInputBox({ setValue }, testContext); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(setValue).not.toHaveBeenCalled(); + }); + }); + + describe('with no initial value', () => { + test('value setter is not called when an ambiguous date is entered', () => { + const setValue = jest.fn(); + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { + value: null, + setValue, + }, + testContext, + ); + userEvent.type(yearInput, '1993'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '2'); + expect(setValue).not.toHaveBeenCalled(); + }); + + test('value setter is called when an explicit date is entered', () => { + const setValue = jest.fn(); + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { + value: null, + setValue, + }, + testContext, + ); + userEvent.type(yearInput, '1993'); + userEvent.type(monthInput, '12'); + userEvent.type(dayInput, '26'); + expect(setValue).toHaveBeenCalledWith( + expect.objectContaining(newUTC(1993, Month.December, 26)), + ); + }); + }); + + describe('with an initial value', () => { + test('value setter is called when a new date is typed', () => { + const setValue = jest.fn(); + const { dayInput } = renderDateInputBox( + { + value: newUTC(1993, Month.December, 26), + setValue, + }, + testContext, + ); + userEvent.type(dayInput, '{backspace}5'); + expect(setValue).toHaveBeenCalledWith( + expect.objectContaining(newUTC(1993, Month.December, 25)), + ); + expect(dayInput).toHaveValue('25'); + }); + + test('value setter is _not_ called when new input is ambiguous', () => { + const setValue = jest.fn(); + const { dayInput } = renderDateInputBox( + { + value: newUTC(1993, Month.December, 26), + setValue, + }, + testContext, + ); + userEvent.type(dayInput, '{backspace}'); + expect(setValue).not.toHaveBeenCalled(); + expect(dayInput).toHaveValue('2'); + }); + + test('value setter is called when the input is cleared', () => { + const setValue = jest.fn(); + const { dayInput, monthInput, yearInput } = renderDateInputBox( + { + value: newUTC(1993, Month.December, 26), + setValue, + }, + testContext, + ); + userEvent.type(dayInput, '{backspace}{backspace}'); + userEvent.type(monthInput, '{backspace}{backspace}'); + userEvent.type( + yearInput, + '{backspace}{backspace}{backspace}{backspace}', + ); + expect(setValue).toHaveBeenCalledWith(expect.objectContaining(null)); + expect(dayInput).toHaveValue(''); + expect(monthInput).toHaveValue(''); + expect(yearInput).toHaveValue(''); + }); + + test('value setter is called when new date is invalid', () => { + const setValue = jest.fn(); + const { yearInput, monthInput, dayInput } = renderDateInputBox( + { + value: newUTC(1993, Month.December, 26), + setValue, + }, + testContext, + ); + + userEvent.type(monthInput, '{backspace}{backspace}'); + // TODO: with InvalidDate + expect(setValue).toHaveBeenCalled(); + expect(dayInput).toHaveValue('26'); + expect(monthInput).toHaveValue(''); + expect(yearInput).toHaveValue('1993'); + }); + }); + }); + + describe('Mouse interaction', () => { + test('click on segment focuses it', () => { + const { dayInput } = renderDateInputBox(undefined, { + locale: 'iso8601', + }); + userEvent.click(dayInput); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Keyboard interaction', () => { + test('Tab moves focus to next segment', () => { + const { dayInput, monthInput, yearInput } = renderDateInputBox( + undefined, + { locale: 'iso8601' }, + ); + userEvent.click(yearInput); + userEvent.tab(); + expect(monthInput).toHaveFocus(); + userEvent.tab(); + expect(dayInput).toHaveFocus(); + }); + + // Arrow key interaction tested in DateInputSegment + // & in relevant DatePicker/RangePicker input component + }); +}); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.stories.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.stories.tsx new file mode 100644 index 0000000000..b18f073813 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.stories.tsx @@ -0,0 +1,126 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useState } from 'react'; +import { StoryFn } from '@storybook/react'; + +import { + DateType, + isValidDate, + Month, + newUTC, + testLocales, +} from '@leafygreen-ui/date-utils'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType, StoryType } from '@leafygreen-ui/lib'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../../context'; +import { + getProviderPropsFromStoryContext, + segmentRefsMock, +} from '../../../testutils'; + +import { DateInputBox } from './DateInputBox'; +import { DateInputChangeEventHandler } from './DateInputBox.types'; + +const testDate = newUTC(1993, Month.December, 26); + +const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => { + const { leafyGreenProviderProps, datePickerProviderProps, storyProps } = + getProviderPropsFromStoryContext(ctx?.args); + + return ( + + + + + + ); +}; + +const meta: StoryMetaType = { + title: 'Components/DatePicker/Shared/DateInputBox', + component: DateInputBox, + decorators: [ProviderWrapper], + parameters: { + controls: { + exclude: ['onSegmentChange', 'setValue', 'segmentRefs'], + }, + default: null, + generate: { + storyNames: ['Formats'], + combineArgs: { + darkMode: [false, true], + value: [null, testDate], + }, + decorator: ProviderWrapper, + }, + }, + args: { + label: 'Label', + locale: 'iso8601', + timeZone: 'Europe/London', + }, + argTypes: { + value: { control: 'date' }, + locale: { control: 'select', options: testLocales }, + }, +}; + +export default meta; + +export const Basic: StoryFn = props => { + const [date, setDate] = useState(null); + + useEffect(() => { + if (props.value && isValidDate(props.value)) { + setDate(props.value); + } + }, [props.value]); + + const updateDate: DateInputChangeEventHandler = ({ value }) => { + setDate(value); + }; + + return ( +
+ + + {isValidDate(date) + ? date.toISOString() + : date + ? 'Invalid' + : 'undefined'} + +
+ ); +}; + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Static: StoryFn = () => { + return ; +}; + +Static.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Formats: StoryType< + typeof DateInputBox, + SharedDatePickerContextProps +> = () => <>; +Formats.parameters = { + generate: { + combineArgs: { + locale: ['iso8601', 'en-US', 'en-UK', 'de-DE'], + }, + }, +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.styles.ts b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.styles.ts new file mode 100644 index 0000000000..00cdcea518 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.styles.ts @@ -0,0 +1,22 @@ +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; + +export const segmentPartsWrapperStyles = css` + display: flex; + align-items: center; + gap: 1px; +`; + +export const separatorLiteralStyles = css` + user-select: none; +`; + +export const separatorLiteralDisabledStyles: Record = { + [Theme.Dark]: css` + color: ${palette.gray.dark2}; + `, + [Theme.Light]: css` + color: ${palette.gray.base}; + `, +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx new file mode 100644 index 0000000000..883fa43f63 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -0,0 +1,210 @@ +import React, { FocusEventHandler, useEffect } from 'react'; +import { isNull } from 'lodash'; +import isEqual from 'lodash/isEqual'; + +import { + isDateObject, + isInvalidDateObject, + isValidDate, +} from '@leafygreen-ui/date-utils'; +import { cx } from '@leafygreen-ui/emotion'; +import { useForwardedRef } from '@leafygreen-ui/hooks'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { useSharedDatePickerContext } from '../../../context'; +import { useDateSegments } from '../../../hooks'; +import { + DateSegment, + DateSegmentsState, + DateSegmentValue, + isDateSegment, +} from '../../../types'; +import { + getMaxSegmentValue, + getMinSegmentValue, + getRelativeSegment, + getValueFormatter, + isEverySegmentFilled, + isEverySegmentValueExplicit, + isExplicitSegmentValue, + newDateFromSegments, +} from '../../../utils'; +import { DateInputSegment } from '../DateInputSegment'; +import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; + +import { + segmentPartsWrapperStyles, + separatorLiteralDisabledStyles, + separatorLiteralStyles, +} from './DateInputBox.styles'; +import { DateInputBoxProps } from './DateInputBox.types'; + +/** + * Renders a styled date input with appropriate segment order & separator characters. + * + * Depends on {@link DateInputSegment} + * + * Uses parameters `value` & `locale` along with {@link Intl.DateTimeFormat.prototype.formatToParts} + * to determine the segment order and separator characters. + * + * Provided value is assumed to be UTC. + * + * Argument passed into `setValue` callback is also in UTC + * @internal + */ +export const DateInputBox = React.forwardRef( + ( + { + value, + setValue, + className, + labelledBy, + segmentRefs, + onSegmentChange, + ...rest + }: DateInputBoxProps, + fwdRef, + ) => { + const { isDirty, formatParts, disabled, min, max, setIsDirty } = + useSharedDatePickerContext(); + const { theme } = useDarkMode(); + + const containerRef = useForwardedRef(fwdRef, null); + + /** Formats and sets the segment value */ + const getFormattedSegmentValue = ( + segmentName: DateSegment, + segmentValue: DateSegmentValue, + ): DateSegmentValue => { + const formatter = getValueFormatter(segmentName); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** if the value is a `Date` the component is dirty */ + useEffect(() => { + if (isDateObject(value) && !isDirty) { + setIsDirty(true); + } + }, [isDirty, setIsDirty, value]); + + /** + * When a segment is updated, + * trigger a `change` event for the segment, and + * update the external Date value if necessary + */ + const handleSegmentUpdate = ( + newSegments: DateSegmentsState, + prevSegments?: DateSegmentsState, + ) => { + const hasAnySegmentChanged = !isEqual(newSegments, prevSegments); + + if (hasAnySegmentChanged) { + const newDate = newDateFromSegments(newSegments); + + const shouldSetValue = + isNull(newDate) || + (isValidDate(newDate) && isEverySegmentValueExplicit(newSegments)) || + (isInvalidDateObject(newDate) && + (isDirty || isEverySegmentFilled(newSegments))); + + if (shouldSetValue) { + setValue?.({ + value: newDate, + segments: newSegments, + }); + } + } + }; + + /** Keep track of each date segment */ + const { segments, setSegment } = useDateSegments(value, { + onUpdate: handleSegmentUpdate, + }); + + /** Fired when an individual segment value changes */ + const handleSegmentInputChange: DateInputSegmentChangeEventHandler = + segmentChangeEvent => { + let segmentValue = segmentChangeEvent.value; + const { segment: segmentName, meta } = segmentChangeEvent; + const changedViaArrowKeys = + meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; + + // Auto-format the segment if it is explicit and was not changed via arrow-keys + if ( + !changedViaArrowKeys && + isExplicitSegmentValue(segmentName, segmentValue) + ) { + segmentValue = getFormattedSegmentValue(segmentName, segmentValue); + + // Auto-advance focus (if possible) + const nextSegmentName = getRelativeSegment('next', { + segment: segmentName, + formatParts, + }); + + if (nextSegmentName) { + const nextSegmentRef = segmentRefs[nextSegmentName]; + nextSegmentRef?.current?.focus(); + } + } + + setSegment(segmentName, segmentValue); + onSegmentChange?.(segmentChangeEvent); + }; + + /** Triggered when a segment is blurred */ + const handleSegmentInputBlur: FocusEventHandler = e => { + const segmentName = e.target.getAttribute('id'); + const segmentValue = e.target.value; + + if (isDateSegment(segmentName)) { + const formattedValue = getFormattedSegmentValue( + segmentName, + segmentValue, + ); + setSegment(segmentName, formattedValue); + } + }; + + return ( +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isDateSegment(part.type)) { + return ( + + ); + } + })} +
+ ); + }, +); + +DateInputBox.displayName = 'DateInputBox'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.types.ts b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.types.ts new file mode 100644 index 0000000000..4d131eb8d5 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.types.ts @@ -0,0 +1,42 @@ +import { DateType } from '@leafygreen-ui/date-utils'; +import { HTMLElementProps } from '@leafygreen-ui/lib'; + +import { SegmentRefs } from '../../../hooks'; +import { DateSegmentsState } from '../../../types'; +import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; + +export interface DateInputChangeEvent { + value: DateType; + segments: DateSegmentsState; +} + +export type DateInputChangeEventHandler = ( + changeEvent: DateInputChangeEvent, +) => void; + +export interface DateInputBoxProps + extends Omit, 'onChange'> { + /** + * Date value passed into the component, in UTC time + */ + value?: DateType; + + /** + * Value setter callback. + * Date object is in UTC time + */ + setValue?: DateInputChangeEventHandler; + + /** + * Callback fired when any segment changes, but not necessarily a full value + */ + onSegmentChange?: DateInputSegmentChangeEventHandler; + + /** + * id of the labelling element + */ + labelledBy?: string; + + /** Refs */ + segmentRefs: SegmentRefs; +} diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/index.ts b/packages/date-picker/src/shared/components/DateInput/DateInputBox/index.ts new file mode 100644 index 0000000000..d027909952 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/index.ts @@ -0,0 +1,5 @@ +export { DateInputBox } from './DateInputBox'; +export type { + DateInputBoxProps, + DateInputChangeEventHandler, +} from './DateInputBox.types'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx new file mode 100644 index 0000000000..3997c91569 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx @@ -0,0 +1,731 @@ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { defaultMax, defaultMin } from '../../../constants'; +import { + SharedDatePickerProvider, + SharedDatePickerProviderProps, +} from '../../../context'; +import { DateSegment } from '../../../types'; +import { getValueFormatter } from '../../../utils'; + +import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; +import { DateInputSegment, type DateInputSegmentProps } from '.'; + +const renderSegment = ( + props?: Partial, + ctx?: Partial, +) => { + const defaultProps = { + value: '', + onChange: () => {}, + segment: 'day' as DateSegment, + }; + + const result = render( + + + , + ); + + const rerenderSegment = (newProps: Partial) => + result.rerender( + + , + , + ); + + const getInput = () => + result.getByTestId('lg-date_picker_input-segment') as HTMLInputElement; + + return { + ...result, + rerenderSegment, + getInput, + input: getInput(), + }; +}; + +describe('packages/date-picker/shared/date-input-segment', () => { + const onChangeHandler = jest.fn(); + + afterEach(() => { + onChangeHandler.mockClear(); + }); + + describe('rendering', () => { + describe('aria attributes', () => { + test('has `spinbutton` role', () => { + const { input } = renderSegment({ segment: 'day' }); + expect(input).toHaveAttribute('role', 'spinbutton'); + }); + test('day segment has aria-label', () => { + const { input } = renderSegment({ segment: 'day' }); + expect(input).toHaveAttribute('aria-label', 'day'); + }); + test('month segment has aria-label', () => { + const { input } = renderSegment({ segment: 'month' }); + expect(input).toHaveAttribute('aria-label', 'month'); + }); + test('year segment has aria-label', () => { + const { input } = renderSegment({ segment: 'year' }); + expect(input).toHaveAttribute('aria-label', 'year'); + }); + }); + + describe('day segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'day' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'day', value: '12' }); + expect(input.value).toBe('12'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + segment: 'day', + value: '12', + }); + + rerenderSegment({ value: '08' }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('month segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'month' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'month', value: '26' }); + expect(input.value).toBe('26'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + segment: 'month', + value: '26', + }); + + rerenderSegment({ value: '08' }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('year segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'year' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'year', value: '2023' }); + expect(input.value).toBe('2023'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + segment: 'year', + value: '2023', + }); + rerenderSegment({ value: '1993' }); + expect(getInput().value).toBe('1993'); + }); + }); + }); + + describe('Typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '8'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '8' }), + ); + }); + + test('allows zero character', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '0'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('allows typing leading zeroes', async () => { + const { input, rerenderSegment } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '0'); + rerenderSegment({ value: '0' }); + + userEvent.type(input, '2'); + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '02' }), + ); + }); + }); + + test('does not allow non-number characters', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, 'aB$/'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('into a segment with a value', () => { + test('allows typing additional characters if the current value is incomplete', () => { + const { input } = renderSegment({ + value: '2', + onChange: onChangeHandler, + }); + + userEvent.type(input, '6'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '26' }), + ); + }); + + test('does not allow additional characters that create an invalid value', () => { + const { input } = renderSegment({ + value: '26', + onChange: onChangeHandler, + }); + + userEvent.type(input, '6'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Keyboard', () => { + describe('Backspace', () => { + test('deletes value in the input', () => { + const { input } = renderSegment({ + value: '26', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('fully clears the input', () => { + const { input } = renderSegment({ + value: '2', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('Arrow Keys', () => { + describe('day input', () => { + const formatter = getValueFormatter('day'); + + describe('Up arrow', () => { + test('calls handler with value +1', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(16), + }), + ); + }); + + test('calls handler with default `min` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(defaultMin['day']) }), + ); + }); + + test('rolls value over to default `min` value if value exceeds `max`', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMax['day']), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(defaultMin['day']) }), + ); + }); + + test('calls handler with provided `min` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + min: 5, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(5) }), + ); + }); + + test('rolls value over to provided `min` value if value exceeds `max`', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMax['day']), + min: 5, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(5) }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value -1', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(14), + }), + ); + }); + + test('calls handler with default `max` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(defaultMax['day']) }), + ); + }); + + test('rolls value over to default `max` value if value exceeds `min`', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMin['day']), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(defaultMax['day']) }), + ); + }); + + test('calls handler with provided `max` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + max: 25, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(25) }), + ); + }); + + test('rolls value over to provided `max` value if value exceeds `min`', () => { + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMin['day']), + max: 25, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(25) }), + ); + }); + }); + }); + + describe('month input', () => { + const formatter = getValueFormatter('month'); + + describe('Up arrow', () => { + test('calls handler with value +1', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(6), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(7), + }), + ); + }); + + test('calls handler with default `min` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMin['month']), + }), + ); + }); + + test('rolls value over to default `min` value if value exceeds `max`', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(defaultMax['month']), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMin['month']), + }), + ); + }); + + test('calls handler with provided `min` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: '', + min: 5, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(5), + }), + ); + }); + + test('rolls value over to provided `min` value if value exceeds `max`', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(defaultMax['month']), + min: 5, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(5), + }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value -1', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(6), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(5), + }), + ); + }); + + test('calls handler with default `max` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMax['month']), + }), + ); + }); + + test('rolls value over to default `max` value if value exceeds `min`', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(defaultMin['month']), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMax['month']), + }), + ); + }); + + test('calls handler with provided `max` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: '', + max: 10, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(10), + }), + ); + }); + + test('rolls value over to provided `max` value if value exceeds `min`', () => { + const { input } = renderSegment({ + segment: 'month', + onChange: onChangeHandler, + value: formatter(defaultMin['month']), + max: 10, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(10), + }), + ); + }); + }); + }); + + describe('year input', () => { + const formatter = getValueFormatter('year'); + + describe('Up arrow', () => { + test('calls handler with value +1', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: formatter(1993), + }); + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(1994), + }), + ); + }); + + test('calls handler with default `min` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMin['year']), + }), + ); + }); + + test('does _not_ rollover if value exceeds max', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: formatter(defaultMax['year']), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMax['year'] + 1), + }), + ); + }); + + test('calls handler with provided `min` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: '', + min: 1969, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(1969), + }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value -1', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: formatter(1993), + }); + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(1992), + }), + ); + }); + + test('calls handler with default `max` if initially undefined', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMax['year']), + }), + ); + }); + + test('does _not_ rollover if value exceeds min', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: formatter(defaultMin['year']), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMin['year'] - 1), + }), + ); + }); + + test('calls handler with provided `max` prop if initially undefined', () => { + const { input } = renderSegment({ + segment: 'year', + onChange: onChangeHandler, + value: '', + max: 2000, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(2000), + }), + ); + }); + }); + }); + }); + describe('Space Key', () => { + describe('on a single SPACE', () => { + describe('does not call the onChangeHandler ', () => { + test('when the input is initially empty', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('when the input has a value', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + value: '12', + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + }); + + describe('on a double SPACE', () => { + describe('does not call the onChangeHandler ', () => { + test('when the input is initially empty', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('when the input has a value', () => { + const { input } = renderSegment({ + onChange: onChangeHandler, + value: '12', + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx new file mode 100644 index 0000000000..2f3964d88c --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { StoryMetaType } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, +} from '../../../context'; +import { DateSegmentValue } from '../../../types'; + +import { DateInputSegment } from './DateInputSegment'; + +const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => ( + + + + + +); + +const meta: StoryMetaType< + typeof DateInputSegment, + SharedDatePickerContextProps +> = { + title: 'Components/DatePicker/Shared/DateInputSegment', + component: DateInputSegment, + parameters: { + default: null, + generate: { + combineArgs: { + darkMode: [false, true], + value: [undefined, '6', '2023'], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + }, + decorator: ProviderWrapper, + excludeCombinations: [ + { + value: '6', + segment: 'year', + }, + { + value: '2023', + segment: ['day', 'month'], + }, + ], + }, + }, + args: { + segment: 'day', + }, + argTypes: { + segment: { + control: 'select', + options: ['day', 'month', 'year'], + }, + }, +}; + +export default meta; + +const Template: StoryFn = props => { + const [value, setValue] = useState(''); + + return ( + + { + setValue(value); + }} + /> + + ); +}; + +export const Basic = Template.bind({}); + +Basic.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Generated = () => {}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts new file mode 100644 index 0000000000..207fde92d3 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts @@ -0,0 +1,96 @@ +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { + BaseFontSize, + fontFamilies, + Size, + typeScales, +} from '@leafygreen-ui/tokens'; + +import { characterWidth, charsPerSegment } from '../../../constants'; +import { DateSegment } from '../../../types'; + +export const baseStyles = css` + font-family: ${fontFamilies.default}; + font-size: ${BaseFontSize.Body1}px; + font-variant: tabular-nums; + text-align: center; + border: none; + border-radius: 0; + padding: 0; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + -moz-appearance: textfield; /* Firefox */ + + &:focus { + outline: none; + } +`; + +export const segmentThemeStyles: Record = { + [Theme.Light]: css` + background-color: transparent; + color: ${palette.black}; + + &::placeholder { + color: ${palette.gray.light1}; + } + + &:focus { + background-color: ${palette.blue.light3}; + } + `, + [Theme.Dark]: css` + background-color: transparent; + color: ${palette.gray.light2}; + + &::placeholder { + color: ${palette.gray.dark1}; + } + + &:focus { + background-color: ${palette.blue.dark3}; + } + `, +}; + +export const fontSizeStyles: Record = { + [BaseFontSize.Body1]: css` + --base-font-size: ${BaseFontSize.Body1}px; + `, + [BaseFontSize.Body2]: css` + --base-font-size: ${BaseFontSize.Body2}px; + `, +}; + +export const segmentSizeStyles: Record = { + [Size.XSmall]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Small]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Default]: css` + font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); + `, + [Size.Large]: css` + font-size: ${18}px; // Intentionally off-token + `, +}; + +export const segmentWidthStyles: Record = { + day: css` + width: ${charsPerSegment.day * characterWidth.D}ch; + `, + month: css` + width: ${charsPerSegment.month * characterWidth.M}ch; + `, + year: css` + width: ${charsPerSegment.year * characterWidth.Y}ch; + `, +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx new file mode 100644 index 0000000000..c722fe5515 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -0,0 +1,189 @@ +import React, { ChangeEventHandler, KeyboardEventHandler } from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useForwardedRef } from '@leafygreen-ui/hooks'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { + charsPerSegment, + defaultMax, + defaultMin, + defaultPlaceholder, +} from '../../../constants'; +import { useSharedDatePickerContext } from '../../../context'; +import { getAutoComplete, getValueFormatter } from '../../../utils'; + +import { getNewSegmentValueFromArrowKeyPress } from './utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +import { + baseStyles, + fontSizeStyles, + segmentSizeStyles, + segmentThemeStyles, + segmentWidthStyles, +} from './DateInputSegment.styles'; +import { DateInputSegmentProps } from './DateInputSegment.types'; +import { getNewSegmentValueFromInputValue } from './utils'; + +/** + * Renders a single date segment with the + * appropriate character padding/truncation. + * + * Only fires a change handler when the input is blurred + */ +export const DateInputSegment = React.forwardRef< + HTMLInputElement, + DateInputSegmentProps +>( + ( + { + segment, + value, + min: minProp, + max: maxProp, + onChange, + onBlur, + onKeyDown, + ...rest + }: DateInputSegmentProps, + fwdRef, + ) => { + const min = minProp ?? defaultMin[segment]; + const max = maxProp ?? defaultMax[segment]; + + const inputRef = useForwardedRef(fwdRef, null); + + const { theme } = useDarkMode(); + const baseFontSize = useUpdatedBaseFontSize(); + const { + size, + disabled, + autoComplete: autoCompleteProp, + } = useSharedDatePickerContext(); + const formatter = getValueFormatter(segment); + const autoComplete = getAutoComplete(autoCompleteProp, segment); + const pattern = `[0-9]{${charsPerSegment[segment]}}`; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `DateInputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue( + segment, + value, + target.value, + ); + + const hasValueChanged = newValue !== value; + + if (hasValueChanged) { + onChange({ + segment, + value: newValue, + }); + } else { + // If the value has not changed, ensure the input value is reset + target.value = value; + } + }; + + /** Handle keydown presses that don't natively fire a change event */ + const handleKeyDown: KeyboardEventHandler = e => { + const { key } = e as React.KeyboardEvent & { + target: HTMLInputElement; + }; + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + segment, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key }, + }); + break; + } + + case keyMap.Backspace: { + const numChars = value.length; + + // If we've cleared the input with backspace, + // fire the custom change event + if (numChars === 1) { + onChange({ + segment, + value: '', + meta: { key }, + }); + } + break; + } + + case keyMap.Space: { + e.preventDefault(); + break; + } + + default: { + break; + } + } + + onKeyDown?.(e); + }; + + // Note: Using a text input with pattern attribute due to Firefox + // stripping leading zeros on number inputs - Thanks @matt-d-rat + // Number inputs also don't support the `selectionStart`/`End` API + return ( + + ); + }, +); + +DateInputSegment.displayName = 'DateInputSegment'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts new file mode 100644 index 0000000000..9ff34a1482 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts @@ -0,0 +1,34 @@ +import { DarkModeProps, HTMLElementProps, keyMap } from '@leafygreen-ui/lib'; + +import { DateSegment, DateSegmentValue } from '../../../types'; + +export interface DateInputSegmentChangeEvent { + segment: DateSegment; + value: DateSegmentValue; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + [key: string]: any; + }; +} + +export type DateInputSegmentChangeEventHandler = ( + dateSegmentChangeEvent: DateInputSegmentChangeEvent, +) => void; + +export interface DateInputSegmentProps + extends DarkModeProps, + Omit, 'onChange'> { + /** Which date segment this input represents. Determines the aria-label, and min/max values where relevant */ + segment: DateSegment; + + /** The value of the date segment */ + value: DateSegmentValue; + + /** Optional minimum value. Defaults to 0 for day/month segments, and 1970 for year segments */ + min?: number; + + /** Optional maximum value. Defaults to 31 for day, 12 for month, 2038 for year */ + max?: number; + + onChange: DateInputSegmentChangeEventHandler; +} diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/index.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/index.ts new file mode 100644 index 0000000000..61a5b9de3e --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/index.ts @@ -0,0 +1,5 @@ +export { DateInputSegment } from './DateInputSegment'; +export { + type DateInputSegmentChangeEventHandler, + type DateInputSegmentProps, +} from './DateInputSegment.types'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts new file mode 100644 index 0000000000..832c7c978a --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,36 @@ +import { keyMap, rollover } from '@leafygreen-ui/lib'; + +import { DateSegment, DateSegmentValue } from '../../../../../types'; + +interface DateSegmentKeypressContext { + value: DateSegmentValue; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + segment: DateSegment; + min: number; + max: number; +} + +/** + * Returns a new segment value given the current state + */ +export const getNewSegmentValueFromArrowKeyPress = ({ + value, + key, + segment, + min, + max, +}: DateSegmentKeypressContext): number => { + const valueDiff = key === keyMap.ArrowUp ? 1 : -1; + const defaultVal = key === keyMap.ArrowUp ? min : max; + + const incrementedValue: number = value + ? Number(value) + valueDiff + : defaultVal; + + const newValue = + segment === 'year' + ? incrementedValue + : rollover(incrementedValue, min, max); + + return newValue; +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts new file mode 100644 index 0000000000..cdfedfb522 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,173 @@ +import { range } from 'lodash'; + +import { defaultMax, defaultMin } from '../../../../../constants'; +import { DateSegment } from '../../../../../types'; +import { getValueFormatter } from '../../../../../utils'; + +import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; + +describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { + describe.each(['day', 'month', 'year'])( + 'For segment %p', + (segment: DateSegment) => { + describe('when current value is empty', () => { + test.each(range(10))('accepts %i character as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${i}`, + ); + expect(newValue).toEqual(`${i}`); + }); + + const validValues = [defaultMin[segment], defaultMax[segment]]; + test.each(validValues)(`accepts value "%i" as input`, v => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${v}`, + ); + expect(newValue).toEqual(`${v}`); + }); + + test('does not accept non-numeric characters', () => { + const newValue = getNewSegmentValueFromInputValue(segment, '', `b`); + expect(newValue).toEqual(''); + }); + + test('does not accept input with a period/decimal', () => { + const newValue = getNewSegmentValueFromInputValue(segment, '', `2.`); + expect(newValue).toEqual(''); + }); + }); + + describe('when current value is 0', () => { + if (segment !== 'year') { + test('rejects additional 0 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `00`, + ); + expect(newValue).toEqual(`0`); + }); + } + test.each(range(1, 10))('accepts 0%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0${i}`, + ); + expect(newValue).toEqual(`0${i}`); + }); + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue(segment, '0', ``); + expect(newValue).toEqual(``); + }); + }); + + describe('when current value is 1', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue(segment, '1', ``); + expect(newValue).toEqual(``); + }); + + if (segment === 'month') { + test.each(range(0, 3))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + ); + expect(newValue).toEqual(`1${i}`); + }); + describe.each(range(3, 10))('rejects 1%i', i => { + test(`and sets input "${i}"`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + } else { + test.each(range(10))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + ); + expect(newValue).toEqual(`1${i}`); + }); + } + }); + + describe('when current value is 3', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue(segment, '3', ``); + expect(newValue).toEqual(``); + }); + + switch (segment) { + case 'day': { + test.each(range(0, 2))('accepts 3%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + ); + expect(newValue).toEqual(`3${i}`); + }); + describe.each(range(3, 10))('rejects 3%i', i => { + test(`and sets input to ${i}`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + case 'month': { + describe.each(range(10))('rejects 3%i', i => { + test(`and sets input "${i}"`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + default: + break; + } + }); + + describe('when current value is a full formatted value', () => { + const formatter = getValueFormatter(segment); + const testValues = [defaultMin[segment], defaultMax[segment]].map( + formatter, + ); + test.each(testValues)( + 'when current value is %p, rejects additional input', + val => { + const newValue = getNewSegmentValueFromInputValue( + segment, + val, + `${val}1`, + ); + expect(newValue).toEqual(val); + }, + ); + }); + }, + ); +}); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts new file mode 100644 index 0000000000..1aff779713 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,56 @@ +import last from 'lodash/last'; + +import { truncateStart } from '@leafygreen-ui/lib'; + +import { charsPerSegment } from '../../../../../constants'; +import { DateSegment, DateSegmentValue } from '../../../../../types'; +import { isValidValueForSegment } from '../../../../../utils'; + +/** + * Calculates the new value for the segment given an incoming change. + * + * Does not allow incoming values that + * - are not valid numbers + * - include a period + * - would cause the segment to overflow + */ +export const getNewSegmentValueFromInputValue = ( + segmentName: DateSegment, + currentValue: DateSegmentValue, + incomingValue: DateSegmentValue, +): DateSegmentValue => { + // If the incoming value is not a valid number + const isIncomingValueNumber = !isNaN(Number(incomingValue)); + // macOS adds a period when pressing SPACE twice inside a text input. + const doesIncomingValueContainPeriod = /\./.test(incomingValue); + + // if the current value is "full", do not allow any additional characters to be entered + const wouldCauseOverflow = + currentValue.length === charsPerSegment[segmentName] && + incomingValue.length > charsPerSegment[segmentName]; + + if ( + !isIncomingValueNumber || + doesIncomingValueContainPeriod || + wouldCauseOverflow + ) { + return currentValue; + } + + const isIncomingValueValid = isValidValueForSegment( + segmentName, + incomingValue, + ); + + if (isIncomingValueValid || segmentName === 'year') { + const newValue = truncateStart(incomingValue, { + length: charsPerSegment[segmentName], + }); + + return newValue; + } + + const typedChar = last(incomingValue.split('')); + const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + return newValue; +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts new file mode 100644 index 0000000000..f71520a27c --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts @@ -0,0 +1 @@ +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; diff --git a/packages/date-picker/src/shared/components/DateInput/index.ts b/packages/date-picker/src/shared/components/DateInput/index.ts new file mode 100644 index 0000000000..8ba05e3866 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/index.ts @@ -0,0 +1,8 @@ +export { CalendarButton } from './CalendarButton'; +export { DateFormField } from './DateFormField'; +export { + DateInputBox, + type DateInputBoxProps, + type DateInputChangeEventHandler, +} from './DateInputBox'; +export { DateInputSegment } from './DateInputSegment'; diff --git a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.spec.tsx b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.spec.tsx new file mode 100644 index 0000000000..9a1d52d70a --- /dev/null +++ b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.spec.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { MenuWrapper } from '.'; + +describe('packages/date-picker/shared/menu-wrapper', () => { + test('components', () => {}); +}); diff --git a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.styles.ts b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.styles.ts new file mode 100644 index 0000000000..8ae6e041d5 --- /dev/null +++ b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.styles.ts @@ -0,0 +1,32 @@ +import { transparentize } from 'polished'; + +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { spacing } from '@leafygreen-ui/tokens'; + +const baseStyles = css` + padding: ${spacing[3]}px; + padding-top: ${spacing[4]}px; + border-radius: ${spacing[2] + spacing[1]}px; + outline: 1px solid; + outline-offset: -1px; + box-shadow: 0 4px 7px ${transparentize(0.85, palette.black)}; +`; + +export const menuStyles: Record = { + [Theme.Light]: cx( + baseStyles, + css` + background-color: ${palette.white}; + outline-color: ${palette.gray.light2}; + `, + ), + [Theme.Dark]: cx( + baseStyles, + css` + background-color: ${palette.gray.dark3}; + outline-color: ${palette.gray.dark2}; + `, + ), +}; diff --git a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx new file mode 100644 index 0000000000..e7859e4cee --- /dev/null +++ b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef } from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { + PopoverProvider, + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; +import { HTMLElementProps } from '@leafygreen-ui/lib'; +import Popover, { PopoverProps } from '@leafygreen-ui/popover'; + +import { menuStyles } from './MenuWrapper.styles'; + +export type MenuWrapperProps = PopoverProps & HTMLElementProps<'div'>; + +/** + * A simple styled popover component + */ +export const MenuWrapper = forwardRef( + ({ className, children, ...props }: MenuWrapperProps, fwdRef) => { + const { theme } = useDarkMode(); + + return ( + + {/* Prevents the opening and closing state of a select dropdown from propagating up to other PopoverProviders in parent components. E.g. Modal */} + {children} + + ); + }, +); + +MenuWrapper.displayName = 'MenuWrapper'; diff --git a/packages/date-picker/src/shared/components/MenuWrapper/index.ts b/packages/date-picker/src/shared/components/MenuWrapper/index.ts new file mode 100644 index 0000000000..792d4a3521 --- /dev/null +++ b/packages/date-picker/src/shared/components/MenuWrapper/index.ts @@ -0,0 +1,2 @@ +export { MenuWrapper } from './MenuWrapper'; +export { type MenuWrapperProps } from './MenuWrapper'; diff --git a/packages/date-picker/src/shared/components/index.ts b/packages/date-picker/src/shared/components/index.ts new file mode 100644 index 0000000000..1b3d59912e --- /dev/null +++ b/packages/date-picker/src/shared/components/index.ts @@ -0,0 +1,9 @@ +export { + CalendarCell, + type CalendarCellProps, + CalendarCellState, + CalendarGrid, + type CalendarGridProps, +} from './Calendar'; +export { DateInputBox, type DateInputBoxProps } from './DateInput'; +export { MenuWrapper, type MenuWrapperProps } from './MenuWrapper'; diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts new file mode 100644 index 0000000000..96f78df967 --- /dev/null +++ b/packages/date-picker/src/shared/constants.ts @@ -0,0 +1,84 @@ +import { Month } from '@leafygreen-ui/date-utils'; +import { DropdownWidthBasis } from '@leafygreen-ui/select'; + +/** + * The default earliest selectable date + * (Unix epoch start: https://en.wikipedia.org/wiki/Unix_time) + * */ +export const MIN_DATE = new Date(Date.UTC(1970, Month.January, 1)); + +/** + * The default latest selectable date + * (Unix 32-bit rollover date: https://en.wikipedia.org/wiki/Year_2038_problem) + */ +export const MAX_DATE = new Date(Date.UTC(2038, Month.January, 19)); + +// TODO: Update how defaultMin & defaultMax are defined, +// since day/month are constants, +// but year is consumer-defined + +/** + * The minimum number for each segment + */ +export const defaultMin = { + day: 1, + month: 1, + year: MIN_DATE.getUTCFullYear(), +} as const; + +/** + * The maximum number for each segment + */ +export const defaultMax = { + day: 31, + month: 12, + year: MAX_DATE.getUTCFullYear(), +} as const; + +/** + * The shorthand for each char + */ +export const placeholderChar = { + day: 'D', + month: 'M', + year: 'Y', +}; + +/** + * The number of characters per input segment + */ +export const charsPerSegment = { + day: 2, + month: 2, + year: 4, +}; + +const _makePlaceholder = (n: number, s: string) => + new Array(n).fill(s).join('\u200B'); + +/** + * The default placeholders for each segment + */ +export const defaultPlaceholder = { + day: _makePlaceholder(charsPerSegment.day, placeholderChar.day), + month: _makePlaceholder(charsPerSegment.month, placeholderChar.month), + year: _makePlaceholder(charsPerSegment.year, placeholderChar.year), +} as const; + +/** The percentage of 1ch these specific characters take up */ +export const characterWidth = { + // // Standard font + D: 46 / 40, + M: 55 / 40, + Y: 50 / 40, +} as const; + +/** Default props for the month & year select menus */ +export const selectElementProps = { + size: 'xsmall', + allowDeselect: false, + dropdownWidthBasis: DropdownWidthBasis.Option, + // using no portal so the select menus are included in the backdrop "foreground" + // there is currently no way to pass a ref into the Select portal to use in backdrop "foreground" + usePortal: false, +} as const; diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx b/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx new file mode 100644 index 0000000000..6ea620c425 --- /dev/null +++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { act, waitFor } from '@testing-library/react'; + +import { Month, newUTC } from '@leafygreen-ui/date-utils'; +import { consoleOnce } from '@leafygreen-ui/lib'; +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { MAX_DATE, MIN_DATE } from '../constants'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProvider, + SharedDatePickerProviderProps, + useSharedDatePickerContext, +} from '.'; + +const renderSharedDatePickerProvider = ( + props?: Partial, +) => { + const { result, rerender } = renderHook( + useSharedDatePickerContext, + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + return { result, rerender }; +}; + +describe('packages/date-picker-context', () => { + describe('useSharedDatePickerContext', () => { + describe('min/max', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + test('uses default min/max values when not provided', () => { + const { result } = renderSharedDatePickerProvider(); + expect(result.current.min).toEqual(MIN_DATE); + expect(result.current.max).toEqual(MAX_DATE); + }); + + test('uses provided min/max values', () => { + const testMin = newUTC(1999, Month.September, 2); + const testMax = newUTC(2011, Month.June, 22); + + const { result } = renderSharedDatePickerProvider({ + min: testMin, + max: testMax, + }); + expect(result.current.min).toEqual(testMin); + expect(result.current.max).toEqual(testMax); + }); + + test('if min is after max, uses default & console errors', () => { + const errorSpy = jest.spyOn(consoleOnce, 'error'); + + const testMax = newUTC(1999, Month.September, 2); + const testMin = newUTC(2011, Month.June, 22); + + const { result } = renderSharedDatePickerProvider({ + min: testMin, + max: testMax, + }); + expect(result.current.min).toEqual(MIN_DATE); + expect(result.current.max).toEqual(MAX_DATE); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('if max is before default min, uses default & console errors', () => { + const errorSpy = jest.spyOn(consoleOnce, 'error'); + const testMax = newUTC(1967, Month.March, 10); + + const { result } = renderSharedDatePickerProvider({ + max: testMax, + }); + expect(result.current.min).toEqual(MIN_DATE); + expect(result.current.max).toEqual(MAX_DATE); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('if min is after default max, uses default & console errors', () => { + const errorSpy = jest.spyOn(consoleOnce, 'error'); + const testMin = newUTC(2067, Month.March, 10); + + const { result } = renderSharedDatePickerProvider({ + min: testMin, + }); + expect(result.current.min).toEqual(MIN_DATE); + expect(result.current.max).toEqual(MAX_DATE); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('isOpen', () => { + test('is `false` by default', () => { + const { result } = renderSharedDatePickerProvider(); + expect(result.current.isOpen).toBeFalsy(); + }); + + test('setter updates the value to `true`', async () => { + const { result, rerender } = renderSharedDatePickerProvider(); + + act(() => result.current.setOpen(true)); + rerender(); + await waitFor(() => { + expect(result.current.isOpen).toBe(true); + }); + }); + }); + + describe('isDirty', () => { + test('is `false` by default', () => { + const { result } = renderSharedDatePickerProvider(); + expect(result.current.isDirty).toBeFalsy(); + }); + + test('setter updates the value to `true`', async () => { + const { result, rerender } = renderSharedDatePickerProvider(); + + act(() => result.current.setIsDirty(true)); + rerender(); + await waitFor(() => { + expect(result.current.isDirty).toBe(true); + }); + }); + }); + + describe('isSelectOpen', () => { + test('is `false` by default', () => { + const { result } = renderSharedDatePickerProvider(); + expect(result.current.isSelectOpen).toBeFalsy(); + }); + + test('setter updates the value to `true`', async () => { + const { result, rerender } = renderSharedDatePickerProvider(); + + act(() => result.current.setIsSelectOpen(true)); + rerender(); + await waitFor(() => { + expect(result.current.isSelectOpen).toBe(true); + }); + }); + }); + }); +}); diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.tsx b/packages/date-picker/src/shared/context/SharedDatePickerContext.tsx new file mode 100644 index 0000000000..a9c38ac2f6 --- /dev/null +++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { createContext, PropsWithChildren, useContext } from 'react'; + +import { useIdAllocator } from '@leafygreen-ui/hooks'; + +import { AutoComplete } from '../types'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProviderProps, +} from './SharedDatePickerContext.types'; +import { + defaultSharedDatePickerContext, + getContextProps, +} from './SharedDatePickerContext.utils'; +import { useDatePickerErrorNotifications } from './useDatePickerErrorNotifications'; + +/** Create the SharedDatePickerContext */ +export const SharedDatePickerContext = + createContext(defaultSharedDatePickerContext); + +/** The Provider component for SharedDatePickerContext */ +export const SharedDatePickerProvider = ({ + children, + initialOpen = false, + disabled = false, + errorMessage, + state, + autoComplete = AutoComplete.Off, + label = '', + 'aria-label': ariaLabelProp = '', + 'aria-labelledby': ariaLabelledbyProp = '', + ...rest +}: PropsWithChildren) => { + const isInitiallyOpen = disabled ? false : initialOpen; + + const [isOpen, setOpen] = useState(isInitiallyOpen); + const [isDirty, setIsDirty] = useState(false); + const [isSelectOpen, setIsSelectOpen] = useState(false); + const menuId = useIdAllocator({ prefix: 'lg-date-picker-menu' }); + const contextValue = getContextProps(rest); + + /** Error state handling */ + const { + stateNotification, + setInternalErrorMessage, + clearInternalErrorMessage, + } = useDatePickerErrorNotifications(state, errorMessage); + + if (!label && !ariaLabelledbyProp && !ariaLabelProp) { + console.warn( + 'For screen-reader accessibility, label, aria-labelledby, or aria-label must be provided to DatePicker component', + ); + } + + return ( + + {children} + + ); +}; + +/** A hook to access {@link SharedDatePickerContextProps} */ +export const useSharedDatePickerContext = () => + useContext(SharedDatePickerContext); diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.types.ts b/packages/date-picker/src/shared/context/SharedDatePickerContext.types.ts new file mode 100644 index 0000000000..0581d08379 --- /dev/null +++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.types.ts @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; + +import { AriaLabelPropsWithLabel } from '@leafygreen-ui/a11y'; +import { DateType } from '@leafygreen-ui/date-utils'; + +import { BaseDatePickerProps, DatePickerState } from '../types'; + +import { UseDatePickerErrorNotificationsReturnObject } from './useDatePickerErrorNotifications'; + +export interface StateNotification { + state: DatePickerState; + message: string; +} +type AriaLabelKeys = keyof AriaLabelPropsWithLabel; + +/** The props expected to pass int the provider */ +export type SharedDatePickerProviderProps = Omit< + BaseDatePickerProps, + AriaLabelKeys +> & { + label?: ReactNode; + 'aria-label'?: string; + 'aria-labelledby'?: string; +}; + +type AriaLabelKeysWithoutLabel = Exclude; + +/** + * The values in context + */ +export interface SharedDatePickerContextProps + extends Omit< + Required, + 'state' | AriaLabelKeysWithoutLabel + >, + UseDatePickerErrorNotificationsReturnObject { + /** The earliest date accepted */ + min: Date; + + /** The latest date accepted */ + max: Date; + + /** + * Returns whether the given date is within the component's min/max dates + */ + isInRange: (d?: DateType) => boolean; + + /** + * An array of {@link Intl.DateTimeFormatPart}, + * used to determine the order of segments + */ + formatParts?: Array; + + /** a unique id for the menu element */ + menuId: string; + + /** Whether the menu is open */ + isOpen: boolean; + + /** + * Setter to open or close the menu + * @internal - Prefer using `open/close/toggleMenu` + * from single/range component context + */ + setOpen: React.Dispatch>; + + /** Identifies whether the component has been interacted with */ + isDirty: boolean; + + /** Setter for whether the component has been interacted with */ + setIsDirty: React.Dispatch>; + + /** Identifies whether the select menus are open inside the menu */ + isSelectOpen: boolean; + + /** Setter for whether the select menus are open inside the menu */ + setIsSelectOpen: React.Dispatch>; + + /** aria-label */ + ariaLabelProp: string; + + /** aria-labelledby */ + ariaLabelledbyProp: string; +} diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts b/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts new file mode 100644 index 0000000000..e7d4981ae5 --- /dev/null +++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts @@ -0,0 +1,180 @@ +import { isBefore, isWithinInterval } from 'date-fns'; +import defaults from 'lodash/defaults'; +import defaultTo from 'lodash/defaultTo'; + +import { + DateType, + getISODate, + isValidDate, + toDate, +} from '@leafygreen-ui/date-utils'; +import { consoleOnce } from '@leafygreen-ui/lib'; +import { BaseFontSize, Size } from '@leafygreen-ui/tokens'; + +import { MAX_DATE, MIN_DATE } from '../constants'; +import { AutoComplete, BaseDatePickerProps, DatePickerState } from '../types'; +import { getFormatParts } from '../utils'; + +import { + SharedDatePickerContextProps, + SharedDatePickerProviderProps, +} from './SharedDatePickerContext.types'; + +export type ContextPropKeys = keyof SharedDatePickerProviderProps & + keyof BaseDatePickerProps; + +/** + * Prop names that are in both DatePickerProps and SharedDatePickerProviderProps + * */ +export const contextPropNames: Array = [ + 'aria-label', + 'aria-labelledby', + 'label', + 'description', + 'locale', + 'timeZone', + 'min', + 'max', + 'baseFontSize', + 'disabled', + 'size', + 'errorMessage', + 'initialOpen', + 'state', + 'autoComplete', + 'darkMode', +]; + +/** The default context value */ +export const defaultSharedDatePickerContext: SharedDatePickerContextProps = { + ariaLabelProp: '', + ariaLabelledbyProp: '', + label: '', + description: '', + locale: 'iso8601', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + min: MIN_DATE, + max: MAX_DATE, + isOpen: false, + initialOpen: false, + setOpen: () => {}, + isDirty: false, + setIsDirty: () => {}, + isInRange: () => true, + disabled: false, + size: Size.Default, + errorMessage: '', + baseFontSize: BaseFontSize.Body1, + darkMode: false, + menuId: '', + isSelectOpen: false, + setIsSelectOpen: () => {}, + stateNotification: { + state: DatePickerState.None, + message: '', + }, + setInternalErrorMessage: () => {}, + clearInternalErrorMessage: () => {}, + autoComplete: AutoComplete.Off, +}; + +/** + * Returns an `isInRange` function, + * with `min` and `max` values in the closure + */ +export const getIsInRange = + (min: Date, max: Date) => + (d?: DateType): boolean => + !!( + isValidDate(d) && + isWithinInterval(d, { + start: min, + end: max, + }) + ); + +/** + * Returns a valid `Context` value given optional provider props + */ +export const getContextProps = ( + providerProps: SharedDatePickerProviderProps, +): SharedDatePickerContextProps => { + const { + min: minProp, + max: maxProp, + timeZone: tzProp, + ...rest + } = providerProps; + + const timeZone = defaultTo( + tzProp, + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + + const [min, max] = getMinMax(toDate(minProp), toDate(maxProp)); + + const providerValue: SharedDatePickerContextProps = { + ...defaults(rest, defaultSharedDatePickerContext), + timeZone, + min, + max, + }; + + const isInRange = getIsInRange(providerValue.min, providerValue.max); + + // Only used to track the _order_ of segments, not the value itself + const formatParts = getFormatParts(providerValue.locale); + + return { ...providerValue, isInRange, formatParts }; +}; + +const getMinMax = (min: Date | null, max: Date | null): [Date, Date] => { + const defaultRange: [Date, Date] = [ + defaultSharedDatePickerContext.min, + defaultSharedDatePickerContext.max, + ]; + + // if both are defined + if (min && max) { + if (isBefore(max, min)) { + consoleOnce.error( + `LeafyGreen DatePicker: Provided max date (${getISODate( + max, + )}) is before provided min date (${getISODate( + min, + )}). Using default values.`, + ); + return defaultRange; + } + + return [min, max]; + } else if (min) { + if (isBefore(defaultSharedDatePickerContext.max, min)) { + consoleOnce.error( + `LeafyGreen DatePicker: Provided min date (${getISODate( + min, + )}) is after the default max date (${getISODate( + defaultSharedDatePickerContext.max, + )}). Using default values.`, + ); + return defaultRange; + } + + return [min, defaultSharedDatePickerContext.max]; + } else if (max) { + if (isBefore(max, defaultSharedDatePickerContext.min)) { + consoleOnce.error( + `LeafyGreen DatePicker: Provided max date (${getISODate( + max, + )}) is before the default min date (${getISODate( + defaultSharedDatePickerContext.min, + )}). Using default values.`, + ); + return defaultRange; + } + + return [defaultSharedDatePickerContext.min, max]; + } + + return defaultRange; +}; diff --git a/packages/date-picker/src/shared/context/index.ts b/packages/date-picker/src/shared/context/index.ts new file mode 100644 index 0000000000..e05fe34680 --- /dev/null +++ b/packages/date-picker/src/shared/context/index.ts @@ -0,0 +1,14 @@ +export { + SharedDatePickerContext, + SharedDatePickerProvider, + useSharedDatePickerContext, +} from './SharedDatePickerContext'; +export { + type SharedDatePickerContextProps, + type SharedDatePickerProviderProps, +} from './SharedDatePickerContext.types'; +export { + type ContextPropKeys, + contextPropNames, + defaultSharedDatePickerContext, +} from './SharedDatePickerContext.utils'; diff --git a/packages/date-picker/src/shared/context/useDatePickerErrorNotifications.ts b/packages/date-picker/src/shared/context/useDatePickerErrorNotifications.ts new file mode 100644 index 0000000000..c38ef8f0ee --- /dev/null +++ b/packages/date-picker/src/shared/context/useDatePickerErrorNotifications.ts @@ -0,0 +1,85 @@ +import { useMemo, useState } from 'react'; + +import { DatePickerState } from '../types'; + +import { StateNotification } from './SharedDatePickerContext.types'; + +export interface UseDatePickerErrorNotificationsReturnObject { + stateNotification: StateNotification; + setInternalErrorMessage: (msg: string) => void; + clearInternalErrorMessage: () => void; +} + +export const useDatePickerErrorNotifications = ( + externalState?: DatePickerState, + externalErrorMessage?: string, +): UseDatePickerErrorNotificationsReturnObject => { + /** + * An external state notification object, + * updated when the external message or state prop changes + */ + const externalStateNotification = useMemo(() => { + const state = externalState ?? DatePickerState.None; + const message = + externalState === DatePickerState.Error ? externalErrorMessage ?? '' : ''; + + return { + state, + message, + }; + }, [externalErrorMessage, externalState]); + + /** + * An internal state notification used to handle internal validation (e.g. if date is in range) + */ + const [internalStateNotification, setInternalStateNotification] = + useState({ + state: DatePickerState.None, + message: '', + }); + + /** + * Removes the internal error message + */ + const clearInternalErrorMessage = () => { + setInternalStateNotification({ + state: DatePickerState.None, + message: '', + }); + }; + + /** + * Sets an internal error message + */ + const setInternalErrorMessage = (msg: string) => { + setInternalStateNotification({ + state: DatePickerState.Error, + message: msg, + }); + }; + + /** + * Calculate the stateNotification to use based on external & internal states. + * External errors take precedence over internal errors. + */ + const stateNotification = useMemo(() => { + if (externalStateNotification.state === DatePickerState.Error) { + if ( + !externalStateNotification.message && + internalStateNotification.state === DatePickerState.Error + ) { + return internalStateNotification; + } else { + return externalStateNotification; + } + } else { + return internalStateNotification; + } + }, [externalStateNotification, internalStateNotification]); + + return { + stateNotification, + setInternalErrorMessage, + clearInternalErrorMessage, + }; +}; diff --git a/packages/date-picker/src/shared/hooks/index.ts b/packages/date-picker/src/shared/hooks/index.ts new file mode 100644 index 0000000000..4e540ce144 --- /dev/null +++ b/packages/date-picker/src/shared/hooks/index.ts @@ -0,0 +1,3 @@ +export { useControlledValue } from './useControlledValue'; +export { useDateSegments } from './useDateSegments'; +export { type SegmentRefs, useSegmentRefs } from './useSegmentRefs'; diff --git a/packages/date-picker/src/shared/hooks/useControlledValue/index.ts b/packages/date-picker/src/shared/hooks/useControlledValue/index.ts new file mode 100644 index 0000000000..b515757d26 --- /dev/null +++ b/packages/date-picker/src/shared/hooks/useControlledValue/index.ts @@ -0,0 +1 @@ +export { useControlledValue } from './useControlledValue'; diff --git a/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx new file mode 100644 index 0000000000..c380814afa --- /dev/null +++ b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { ChangeEventHandler } from 'react'; +import { render } from '@testing-library/react'; +import { RenderHookResult } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { act, renderHook } from '@leafygreen-ui/testing-lib'; + +import { useControlledValue } from './useControlledValue'; + +const errorSpy = jest.spyOn(console, 'error'); + +const renderUseControlledValueHook = ( + ...[valueProp, callback, initial]: Parameters> +): RenderHookResult< + ReturnType>, + typeof valueProp +> => { + const result = renderHook(v => useControlledValue(v, callback, initial), { + initialProps: valueProp, + }); + + return { ...result }; +}; + +describe('packages/date-picker/hooks/useControlledValue', () => { + beforeEach(() => { + errorSpy.mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockReset(); + }); + + test('rendering without any arguments sets hook to uncontrolled', () => { + const { result } = renderUseControlledValueHook(); + expect(result.current.isControlled).toEqual(false); + }); + + describe('accepts various value types', () => { + test('accepts number values', () => { + const { result } = renderUseControlledValueHook(5); + expect(result.current.value).toBe(5); + }); + + test('accepts boolean values', () => { + const { result } = renderUseControlledValueHook(false); + expect(result.current.value).toBe(false); + }); + + test('accepts array values', () => { + const arr = ['foo', 'bar']; + const { result } = renderUseControlledValueHook(arr); + expect(result.current.value).toBe(arr); + }); + + test('accepts object values', () => { + const obj = { foo: 'foo', bar: 'bar' }; + const { result } = renderUseControlledValueHook(obj); + expect(result.current.value).toBe(obj); + }); + + test('accepts date values', () => { + const date = new Date('2023-08-23'); + const { result } = renderUseControlledValueHook(date); + expect(result.current.value).toBe(date); + }); + + test('accepts multiple/union types', () => { + const { result, rerender } = renderUseControlledValueHook< + string | number + >(5); + expect(result.current.value).toBe(5); + rerender('foo'); + expect(result.current.value).toBe('foo'); + }); + }); + + describe('Controlled', () => { + test('rendering with a value sets value and isControlled', () => { + const { result } = renderUseControlledValueHook('apple'); + expect(result.current.isControlled).toBe(true); + expect(result.current.value).toBe('apple'); + }); + + test('rerendering from initial undefined sets value and isControlled', async () => { + const { rerender, result } = renderUseControlledValueHook(); + rerender('apple'); + expect(result.current.isControlled).toBe(true); + expect(result.current.value).toEqual('apple'); + }); + + test('rerendering with a new value changes the value', () => { + const { rerender, result } = renderUseControlledValueHook('apple'); + expect(result.current.value).toBe('apple'); + rerender('banana'); + expect(result.current.value).toBe('banana'); + }); + + test('provided handler is called within `setValue`', () => { + const handler = jest.fn(); + const { result } = renderUseControlledValueHook('apple', handler); + result.current.setValue('banana'); + expect(handler).toHaveBeenCalledWith('banana'); + }); + + test('hook value does not change when `setValue` is called', () => { + const { result } = renderUseControlledValueHook('apple'); + result.current.setValue('banana'); + // value doesn't change unless we explicitly change it + expect(result.current.value).toBe('apple'); + }); + + test('setting value to undefined should keep the component controlled', () => { + const { rerender, result } = renderUseControlledValueHook('apple'); + expect(result.current.isControlled).toBe(true); + act(() => rerender(undefined)); + expect(result.current.isControlled).toBe(true); + }); + + test('initial value is ignored when controlled', () => { + const { result } = renderUseControlledValueHook( + 'apple', + () => {}, + 'banana', + ); + expect(result.current.value).toBe('apple'); + }); + }); + + describe('Uncontrolled', () => { + test('calling without a value sets value to `initialValue`', () => { + const { + result: { current }, + } = renderUseControlledValueHook(undefined, () => {}, 'apple'); + + expect(current.isControlled).toBe(false); + expect(current.value).toBe('apple'); + }); + + test('provided handler is called within `setValue`', () => { + const handler = jest.fn(); + const { + result: { current }, + } = renderUseControlledValueHook(undefined, handler); + + current.setValue('apple'); + expect(handler).toHaveBeenCalledWith('apple'); + }); + + test('setValue updates the value', () => { + const { result, rerender } = + renderUseControlledValueHook(undefined); + result.current.setValue('banana'); + rerender(); + expect(result.current.value).toBe('banana'); + }); + }); + + describe('Within test component', () => { + const TestComponent = ({ + valueProp, + handlerProp, + }: { + valueProp?: string; + handlerProp?: (val?: string) => void; + }) => { + const initialVal = ''; + // eslint-disable-next-line react-hooks/rules-of-hooks + const { value, setValue } = useControlledValue( + valueProp, + handlerProp, + initialVal, + ); + + const handleChange: ChangeEventHandler = e => { + setValue(e.target.value); + }; + + return ( + <> + +