From 4f11234159c1aa25d12d3e7521371d4e1a2bb6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Tue, 29 Oct 2024 08:04:16 +0100 Subject: [PATCH] New DateInput component (#2645) Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> --- .changeset/chilly-dodos-end.md | 4 +- .changeset/tiny-jars-knock.md | 5 + .eslintrc.js | 3 + package-lock.json | 31 +- .../CurrencyInput/CurrencyInput.tsx | 11 +- .../components/DateInput/DateInput.mdx | 55 ++ .../components/DateInput/DateInput.module.css | 177 +++++- .../components/DateInput/DateInput.spec.tsx | 454 ++++++++++++++- .../DateInput/DateInput.stories.tsx | 141 ++++- .../components/DateInput/DateInput.tsx | 531 ++++++++++++++++-- .../DateInput/DateInputService.spec.ts | 72 +++ .../components/DateInput/DateInputService.ts | 39 ++ .../components/DateSegment.module.css | 49 ++ .../DateInput/components/DateSegment.spec.tsx | 230 ++++++++ .../DateInput/components/DateSegment.tsx | 197 +++++++ .../DateInput/components/Dialog.module.css | 65 +++ .../DateInput/components/Dialog.spec.tsx | 144 +++++ .../DateInput/components/Dialog.tsx | 122 ++++ .../DateInput/hooks/usePlainDateState.ts | 212 +++++++ .../DateInput/hooks/useSegmentFocus.spec.tsx | 89 +++ .../DateInput/hooks/useSegmentFocus.ts | 70 +++ .../components/Field/Field.module.css | 1 + .../PhoneNumberInput/PhoneNumberInput.tsx | 6 +- .../SidePanel/SidePanelContext.spec.tsx | 2 +- .../SidePanel/useSidePanel.spec.tsx | 2 +- .../circuit-ui/components/Step/Step.spec.tsx | 2 +- .../useComponentSize/useComponentSize.spec.ts | 2 +- .../hooks/useFocusList/useFocusList.spec.tsx | 14 +- .../hooks/useFocusList/useFocusList.ts | 14 +- packages/circuit-ui/package.json | 3 +- packages/circuit-ui/util/date.spec.ts | 45 +- packages/circuit-ui/util/date.ts | 27 +- packages/circuit-ui/util/helpers.spec.ts | 42 +- packages/circuit-ui/util/helpers.ts | 30 + packages/circuit-ui/util/key-codes.spec.tsx | 36 ++ packages/circuit-ui/util/key-codes.ts | 7 + templates/astro/package.json | 2 +- templates/nextjs/template/package.json | 2 +- templates/remix/package.json | 2 +- 39 files changed, 2823 insertions(+), 117 deletions(-) create mode 100644 .changeset/tiny-jars-knock.md create mode 100644 packages/circuit-ui/components/DateInput/DateInput.mdx create mode 100644 packages/circuit-ui/components/DateInput/DateInputService.spec.ts create mode 100644 packages/circuit-ui/components/DateInput/DateInputService.ts create mode 100644 packages/circuit-ui/components/DateInput/components/DateSegment.module.css create mode 100644 packages/circuit-ui/components/DateInput/components/DateSegment.spec.tsx create mode 100644 packages/circuit-ui/components/DateInput/components/DateSegment.tsx create mode 100644 packages/circuit-ui/components/DateInput/components/Dialog.module.css create mode 100644 packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx create mode 100644 packages/circuit-ui/components/DateInput/components/Dialog.tsx create mode 100644 packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts create mode 100644 packages/circuit-ui/components/DateInput/hooks/useSegmentFocus.spec.tsx create mode 100644 packages/circuit-ui/components/DateInput/hooks/useSegmentFocus.ts diff --git a/.changeset/chilly-dodos-end.md b/.changeset/chilly-dodos-end.md index 3852fafc24..455ebd2d91 100644 --- a/.changeset/chilly-dodos-end.md +++ b/.changeset/chilly-dodos-end.md @@ -1,5 +1,5 @@ --- -"@sumup-oss/circuit-ui": major +'@sumup-oss/circuit-ui': major --- -Upgraded to `@sumup-oss/intl` v2. If your app also depends on `@sumup-oss/intl` (previously called `@sumup/intl`), you need to upgrade it as well. +Upgraded to `@sumup-oss/intl` v3. If your app also depends on `@sumup-oss/intl` (previously called `@sumup/intl`), you need to upgrade it as well. diff --git a/.changeset/tiny-jars-knock.md b/.changeset/tiny-jars-knock.md new file mode 100644 index 0000000000..34f20f7e97 --- /dev/null +++ b/.changeset/tiny-jars-knock.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Rewrote the DateInput component and replaced the native date input with a custom implementation to improve its usability and accessibility. The component now requires additional localized label props. diff --git a/.eslintrc.js b/.eslintrc.js index fec365e33b..6cd4c31fe1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,9 @@ module.exports = require('@sumup-oss/foundry/eslint')({ '@sumup-oss/circuit-ui/no-renamed-props': 'error', '@sumup-oss/circuit-ui/prefer-custom-properties': 'warn', 'react/no-unknown-property': ['error', { ignore: ['css'] }], + // These rules are already covered by Biome + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', }, parserOptions: { project: ['./packages/*/tsconfig.json', './tsconfig.eslint.json'], diff --git a/package-lock.json b/package-lock.json index 2649e73e35..d2a3f60a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31383,6 +31383,13 @@ "ufo": "^1.5.3" } }, + "node_modules/mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -42405,7 +42412,7 @@ "@emotion/styled": "^11.13.0", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", @@ -42417,6 +42424,7 @@ "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "jest-axe": "^9.0.0", + "mockdate": "^3.0.5", "moment": "^2.29.4", "react": "^18.3.1", "react-dates": "^21.8.0", @@ -42612,7 +42620,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "astro": "^4.15.9", @@ -42664,7 +42672,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "next": "^14.2.10", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -42702,7 +42710,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "isbot": "^5.1.13", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -49488,7 +49496,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -49518,7 +49526,7 @@ "@nanostores/react": "^0.7.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", @@ -49530,6 +49538,7 @@ "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "jest-axe": "^9.0.0", + "mockdate": "^3.0.5", "moment": "^2.29.4", "nanostores": "^0.10.3", "react": "^18.3.1", @@ -49657,7 +49666,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -63854,6 +63863,12 @@ "ufo": "^1.5.3" } }, + "mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -64042,7 +64057,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", diff --git a/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx b/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx index 3b4d040334..9006d39ccb 100644 --- a/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx +++ b/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx @@ -20,6 +20,7 @@ import { resolveCurrencyFormat } from '@sumup-oss/intl'; import { NumericFormat, type NumericFormatProps } from 'react-number-format'; import { clsx } from '../../styles/clsx.js'; +import { getBrowserLocale, type Locale } from '../../util/i18n.js'; import { Input, type InputProps } from '../Input/index.js'; import { formatPlaceholder } from './CurrencyInputService.js'; @@ -37,10 +38,12 @@ export interface CurrencyInputProps */ currency: string; /** - * One or more Unicode BCP 47 locale identifiers, such as 'de-DE' or - * ['GB', 'en-US'] (the first supported locale is used). + * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) + * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`. + * When passing an array, the first supported locale is used. + * Defaults to `navigator.language` in supported environments. */ - locale?: string | string[]; + locale?: Locale; /** * A short string that is shown inside the empty input. * If the placeholder is a number, it is formatted in the local @@ -76,7 +79,7 @@ const DUMMY_DELIMITER = '?'; export const CurrencyInput = forwardRef( ( { - locale, + locale = getBrowserLocale(), currency, placeholder, 'aria-describedby': descriptionId, diff --git a/packages/circuit-ui/components/DateInput/DateInput.mdx b/packages/circuit-ui/components/DateInput/DateInput.mdx new file mode 100644 index 0000000000..0971979956 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateInput.mdx @@ -0,0 +1,55 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './DateInput.stories'; + + + +# DateInput + + + +The DateInput component allows users to type or select a specific date. The input value is always a string in the format `YYYY-MM-DD`. + + + + +## Usage + +Use the component whenever asking for a specific individual date such as a birth date, expiry date, or appointment date. The date is in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) format (`YYYY-MM-DD`). + +For selecting a range of dates, we expect to introduce an iteration of this component in the future. Until then, we recommend using a combination of two input fields (one for the start and the other for the end date). + +## Validations + +Use the `validationHint` prop to communicate the expected response to users. The DateInput component ensures that the entered date is a valid date, but does not validate whether it falls within the minimum or maximum date range. + +### Invalid + +The user needs to change the value to proceed. This could be because they entered a date outside of the allowed range. + +### Warning + +The user is recommended to change the value, but can proceed without doing so. Use it when the provided value could have unintended side-effects, such as a date in the far future. + +### Valid + +The user is reassured that the value is valid. Use sparingly. + + + +## Optional + +Use the `optionalLabel` prop to indicate that the field is optional. This can help reduce the cognitive load for the user by clearly indicating which fields are required and which are not. This label is only displayed when the `required` prop is falsy. + + + +## Readonly + +Use the `readOnly` prop to indicate that the field is not currently editable. This can be useful in situations where the user needs to view but not edit the date, such as in a summary or review screen. + + + +## Internationalization + +Pass one or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) locale identifiers such as `'de-DE'` or `['GB', 'en-US']` to the `locale` prop to display the date in the local format. When passing an array, the first supported locale is used. Defaults to `navigator.language` in supported environments. + + diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index a04f6ec087..84174450ce 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -1,4 +1,175 @@ -.base { - min-width: 8ch; - height: 48px; +.wrapper { + display: flex; +} + +.segments { + position: relative; + z-index: var(--cui-z-index-absolute); + display: flex; + gap: 2px; + min-width: 170px; + padding: var(--cui-spacings-byte) var(--cui-spacings-mega); + cursor: text; + background-color: var(--cui-bg-normal); + border: 1px solid var(--cui-border-normal); + border-top-left-radius: var(--cui-border-radius-byte); + border-bottom-left-radius: var(--cui-border-radius-byte); + outline: 0; + box-shadow: none; + transition: + box-shadow var(--cui-transitions-default), + padding var(--cui-transitions-default); +} + +.segments:hover { + border-color: var(--cui-border-normal-hovered); +} + +.segments:focus-within { + border-color: var(--cui-border-accent); + box-shadow: inset 0 0 0 1px var(--cui-border-accent); +} + +.invalid { + border-color: var(--cui-border-danger); +} + +.invalid:hover { + border-color: var(--cui-border-danger-hovered); +} + +.invalid:focus-within { + border-color: var(--cui-border-danger); + box-shadow: inset 0 0 0 1px var(--cui-border-danger); +} + +.invalid:not(:focus-within):not([disabled])::placeholder { + color: var(--cui-fg-danger); +} + +.warning { + border-color: var(--cui-border-warning); +} + +.warning:hover { + border-color: var(--cui-border-warning-hovered); +} + +.warning:focus-within { + border-color: var(--cui-border-warning); + box-shadow: inset 0 0 0 1px var(--cui-border-warning); +} + +.warning:not(:focus-within):not([disabled])::placeholder { + color: var(--cui-fg-warning); +} + +:global([data-disabled="true"]) .input { + color: var(--cui-fg-normal-disabled); + background-color: var(--cui-bg-normal-disabled); + border-color: var(--cui-border-normal-disabled); +} + +.readonly { + background-color: var(--cui-bg-subtle-disabled); +} + +.literal { + padding: var(--cui-spacings-bit) 0; + font-size: var(--cui-body-m-font-size); + line-height: var(--cui-body-m-line-height); +} + +.readonly .literal { + color: var(--cui-fg-subtle); +} + +.calendar-button { + position: relative; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + transform: translateX(-1px); +} + +.calendar-button:focus { + z-index: calc(var(--cui-z-index-absolute) + 1); + border-color: var(--cui-border-focus); + box-shadow: inset 0 0 0 1px var(--cui-border-focus); +} + +.calendar-button:active, +.calendar-button[aria-expanded="true"] { + z-index: calc(var(--cui-z-index-absolute) + 1); +} + +.content { + color: var(--cui-fg-normal); + background-color: var(--cui-bg-elevated); + border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); + border-radius: var(--cui-border-radius-byte); + outline: 0; + box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); +} + +@media (max-width: 479px) { + .content { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--cui-spacings-giga) var(--cui-spacings-mega) + var(--cui-spacings-byte) var(--cui-spacings-mega); +} + +@media (min-width: 480px) { + /* Hide visually */ + .header { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + + .close-button { + display: none; + } +} + +.calendar { + padding: var(--cui-spacings-mega); +} + +.buttons { + display: flex; + flex-wrap: wrap; + gap: var(--cui-spacings-kilo); + justify-content: space-between; + padding: var(--cui-spacings-mega); + border-top: var(--cui-border-width-kilo) solid var(--cui-border-divider); +} + +.apply { + margin-left: auto; +} + +@media (min-width: 480px) { + .apply { + display: none; + } + + .presets { + position: sticky; + bottom: 0; + margin-top: var(--cui-spacings-mega); + } } diff --git a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx index aad82917a0..bd77941205 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -13,25 +13,463 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { createRef } from 'react'; +import MockDate from 'mockdate'; -import { render, axe } from '../../util/test-utils.js'; +import { render, screen, axe, userEvent } from '../../util/test-utils.js'; +import { useMedia } from '../../hooks/useMedia/useMedia.js'; import { DateInput } from './DateInput.js'; +vi.mock('../../hooks/useMedia/useMedia.js'); + describe('DateInput', () => { - const baseProps = { label: 'Date' }; + const props = { + onChange: vi.fn(), + label: 'Date of birth', + yearInputLabel: 'Year', + monthInputLabel: 'Month', + dayInputLabel: 'Day', + openCalendarButtonLabel: 'Change date', + closeCalendarButtonLabel: 'Close calendar', + prevMonthButtonLabel: 'Previous month', + nextMonthButtonLabel: 'Previous month', + applyDateButtonLabel: 'Apply date', + clearDateButtonLabel: 'Clear date', + }; + + beforeEach(() => { + MockDate.set('2000-01-01'); + (useMedia as Mock).mockReturnValue(false); + }); it('should forward a ref', () => { - const ref = createRef(); - const { container } = render(); - const input = container.querySelector('input'); - expect(ref.current).toBe(input); + const ref = createRef(); + const { container } = render(); + // eslint-disable-next-line testing-library/no-container + const wrapper = container.querySelectorAll('div')[0]; + expect(ref.current).toBe(wrapper); + }); + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + const { container } = render( + , + ); + // eslint-disable-next-line testing-library/no-container + const wrapper = container.querySelectorAll('div')[0]; + expect(wrapper?.className).toContain(className); + }); + + describe('semantics', () => { + it('should optionally have an accessible description', () => { + const description = 'Description'; + render(); + const fieldset = screen.getByRole('group'); + const inputs = screen.getAllByRole('spinbutton'); + + expect(fieldset).toHaveAccessibleDescription(description); + expect(inputs[0]).toHaveAccessibleDescription(description); + expect(inputs[1]).not.toHaveAccessibleDescription(); + expect(inputs[2]).not.toHaveAccessibleDescription(); + }); + + it('should accept a custom description via aria-describedby', () => { + const customDescription = 'Custom description'; + const customDescriptionId = 'customDescriptionId'; + render( + <> + , + {customDescription} + , + ); + const fieldset = screen.getByRole('group'); + const inputs = screen.getAllByRole('spinbutton'); + + expect(fieldset).toHaveAccessibleDescription(customDescription); + expect(inputs[0]).toHaveAccessibleDescription(customDescription); + expect(inputs[1]).not.toHaveAccessibleDescription(); + expect(inputs[2]).not.toHaveAccessibleDescription(); + }); + + it('should accept a custom description in addition to a validationHint', () => { + const customDescription = 'Custom description'; + const customDescriptionId = 'customDescriptionId'; + const description = 'Description'; + render( + <> + + {customDescription}, + , + ); + const fieldset = screen.getByRole('group'); + const inputs = screen.getAllByRole('spinbutton'); + + expect(fieldset).toHaveAccessibleDescription( + `${customDescription} ${description}`, + ); + expect(inputs[0]).toHaveAccessibleDescription( + `${customDescription} ${description}`, + ); + expect(inputs[1]).not.toHaveAccessibleDescription(); + expect(inputs[2]).not.toHaveAccessibleDescription(); + }); + + it('should render as disabled', async () => { + render(); + expect(screen.getByLabelText(/day/i)).toBeDisabled(); + expect(screen.getByLabelText(/month/i)).toBeDisabled(); + expect(screen.getByLabelText(/year/i)).toBeDisabled(); + expect( + screen.getByRole('button', { name: props.openCalendarButtonLabel }), + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should render as read-only', async () => { + render(); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); + expect( + screen.getByRole('button', { name: props.openCalendarButtonLabel }), + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should render as invalid', async () => { + render(); + expect(screen.getByLabelText(/day/i)).toBeInvalid(); + expect(screen.getByLabelText(/month/i)).toBeInvalid(); + expect(screen.getByLabelText(/year/i)).toBeInvalid(); + }); + + it('should render as required', async () => { + render(); + expect(screen.getByLabelText(/day/i)).toBeRequired(); + expect(screen.getByLabelText(/month/i)).toBeRequired(); + expect(screen.getByLabelText(/year/i)).toBeRequired(); + }); + + it('should have relevant minimum input values', () => { + render(); + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getByLabelText(/year/i)).toHaveAttribute( + 'aria-valuemin', + '2000', + ); + }); + + it('should have relevant maximum input values', () => { + render(); + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemax', + '31', + ); + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemax', + '12', + ); + expect(screen.getByLabelText(/year/i)).toHaveAttribute( + 'aria-valuemax', + '2001', + ); + }); + + it.todo( + 'should mark the year input as readonly when the minimum and maximum dates have the same year', + () => { + render(); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemin', + '4', + ); + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemax', + '6', + ); + }, + ); + + it.todo( + 'should mark the year and month inputs as readonly when the minimum and maximum dates have the same year and month', + () => { + render(); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemin', + '9', + ); + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemax', + '27', + ); + }, + ); + }); + + describe('state', () => { + it('should display a default value', () => { + render(); + + expect(screen.getByLabelText(/day/i)).toHaveValue('12'); + expect(screen.getByLabelText(/month/i)).toHaveValue('1'); + expect(screen.getByLabelText(/year/i)).toHaveValue('2000'); + }); + + it('should display an initial value', () => { + render(); + + expect(screen.getByLabelText(/day/i)).toHaveValue('12'); + expect(screen.getByLabelText(/month/i)).toHaveValue('1'); + expect(screen.getByLabelText(/year/i)).toHaveValue('2000'); + }); + + it('should ignore an invalid value', () => { + render(); + + expect(screen.getByLabelText(/day/i)).toHaveValue(''); + expect(screen.getByLabelText(/month/i)).toHaveValue(''); + expect(screen.getByLabelText(/year/i)).toHaveValue(''); + }); + + it('should update the displayed value', () => { + const { rerender } = render(); + + rerender(); + + expect(screen.getByLabelText(/day/i)).toHaveValue('15'); + expect(screen.getByLabelText(/month/i)).toHaveValue('1'); + expect(screen.getByLabelText(/year/i)).toHaveValue('2000'); + }); + }); + + describe('user interactions', () => { + it('should focus the first input when clicking the label', async () => { + render(); + + await userEvent.click(screen.getByText('Date of birth')); + + expect(screen.getAllByRole('spinbutton')[0]).toHaveFocus(); + }); + + it('should allow users to type a date', async () => { + const onChange = vi.fn(); + + render(); + + await userEvent.type(screen.getByLabelText('Year'), '2017'); + await userEvent.type(screen.getByLabelText('Month'), '8'); + await userEvent.type(screen.getByLabelText('Day'), '28'); + + expect(onChange).toHaveBeenCalledWith('2017-08-28'); + }); + + it('should update the minimum and maximum input values as the user types', async () => { + render(); + + await userEvent.type(screen.getByLabelText(/year/i), '2001'); + + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getByLabelText(/month/i)).toHaveAttribute( + 'aria-valuemax', + '2', + ); + + await userEvent.type(screen.getByLabelText(/month/i), '2'); + + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getByLabelText(/day/i)).toHaveAttribute( + 'aria-valuemax', + '15', + ); + }); + + it('should allow users to delete the date', async () => { + const onChange = vi.fn(); + + render( + , + ); + + await userEvent.click(screen.getByLabelText(/year/i)); + await userEvent.keyboard(Array(9).fill('{backspace}').join('')); + + expect(screen.getByLabelText(/day/i)).toHaveValue(''); + expect(screen.getByLabelText(/month/i)).toHaveValue(''); + expect(screen.getByLabelText(/year/i)).toHaveValue(''); + + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('should allow users to select a date on a calendar', async () => { + const onChange = vi.fn(); + + render(); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + const dateButton = screen.getByRole('button', { name: /12/ }); + await userEvent.click(dateButton); + + expect(onChange).toHaveBeenCalledWith('2000-01-12'); + }); + + it('should allow users to clear the date', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + const clearButton = screen.getByRole('button', { name: /clear date/i }); + await userEvent.click(clearButton); + + expect(onChange).toHaveBeenCalledWith(''); + }); + + describe('on narrow viewports', () => { + beforeEach(() => { + (useMedia as Mock).mockReturnValue(true); + }); + + it('should allow users to select a date on a calendar', async () => { + (useMedia as Mock).mockReturnValue(true); + const onChange = vi.fn(); + + render(); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + const dateButton = screen.getByRole('button', { name: /12/i }); + await userEvent.click(dateButton); + + expect(onChange).not.toHaveBeenCalled(); + + const applyButton = screen.getByRole('button', { name: /apply/i }); + await userEvent.click(applyButton); + + expect(onChange).toHaveBeenCalledWith('2000-01-12'); + }); + + it('should allow users to clear the date', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + const clearButton = screen.getByRole('button', { name: /clear date/i }); + await userEvent.click(clearButton); + + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('should allow users to close the calendar dialog without selecting a date', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + await userEvent.click(closeButton); + + expect(calendarDialog).not.toBeVisible(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + }); + + describe('status messages', () => { + it('should render an empty live region on mount', () => { + render(); + const liveRegionEl = screen.getByRole('status'); + + expect(liveRegionEl).toBeEmptyDOMElement(); + }); + + it('should render status messages in a live region', () => { + const statusMessage = 'This field is required'; + render(); + const liveRegionEl = screen.getByRole('status'); + + expect(liveRegionEl).toHaveTextContent(statusMessage); + }); + + it('should not render descriptions in a live region', () => { + const statusMessage = 'This field is required'; + render(); + const liveRegionEl = screen.getByRole('status'); + + expect(liveRegionEl).toBeEmptyDOMElement(); + }); }); it('should have no accessibility violations', async () => { - const { container } = render(); + const { container } = render(); const actual = await axe(container); expect(actual).toHaveNoViolations(); }); diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index fe547ab9cb..8f588a339c 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -13,21 +13,158 @@ * limitations under the License. */ +import { useState } from 'react'; + +import { Stack } from '../../../../.storybook/components/index.js'; + import { DateInput, type DateInputProps } from './DateInput.js'; export default { title: 'Forms/DateInput', component: DateInput, + parameters: { + layout: 'padded', + }, argTypes: { disabled: { control: 'boolean' }, + required: { control: 'boolean' }, }, }; +// Fun fact: Circuit UI was created on August 28, 2017 + const baseArgs = { label: 'Date of birth', - validationHint: 'You must be at least 18 years old', + prevMonthButtonLabel: 'Previous month', + nextMonthButtonLabel: 'Next month', + openCalendarButtonLabel: 'Change date', + closeCalendarButtonLabel: 'Close calendar', + applyDateButtonLabel: 'Apply', + clearDateButtonLabel: 'Clear', + yearInputLabel: 'Year', + monthInputLabel: 'Month', + dayInputLabel: 'Day', + autoComplete: 'bday', + locale: 'en-US', }; -export const Base = (args: DateInputProps) => ; +export const Base = (args: DateInputProps) => { + const [value, setValue] = useState(args.defaultValue || args.value || ''); + return ; +}; Base.args = baseArgs; + +export const Validations = (args: DateInputProps) => ( + + + + + + + + + + + +); + +Validations.args = baseArgs; + +export const Optional = (args: DateInputProps) => ; + +Optional.args = { + ...baseArgs, + optionalLabel: 'optional', +}; + +export const Readonly = (args: DateInputProps) => ; + +Readonly.args = { + ...baseArgs, + label: 'Appointment date', + defaultValue: '2017-08-28', + readOnly: true, +}; + +export const Disabled = (args: DateInputProps) => ; + +Disabled.args = { + ...baseArgs, + defaultValue: '2017-08-28', + disabled: true, +}; + +export const Locales = (args: DateInputProps) => ( + + + + + +); + +Locales.args = baseArgs; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index e116066719..44744ac9b1 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2021, SumUp Ltd. + * Copyright 2024, SumUp Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -15,79 +15,502 @@ 'use client'; -import { forwardRef, useState, useEffect } from 'react'; -import { PatternFormat } from 'react-number-format'; +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type HTMLAttributes, +} from 'react'; +import type { Temporal } from 'temporal-polyfill'; +import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; +import { Calendar as CalendarIcon } from '@sumup-oss/icons'; -import { Input, type InputProps } from '../Input/index.js'; +import type { ClickEvent } from '../../types/events.js'; +import { useMedia } from '../../hooks/useMedia/useMedia.js'; +import { + AccessibilityError, + isSufficientlyLabelled, +} from '../../util/errors.js'; import { clsx } from '../../styles/clsx.js'; +import type { InputProps } from '../Input/Input.js'; +import { Calendar, type CalendarProps } from '../Calendar/Calendar.js'; +import { Button } from '../Button/Button.js'; +import { CloseButton } from '../CloseButton/CloseButton.js'; +import { IconButton } from '../Button/IconButton.js'; +import { Headline } from '../Headline/Headline.js'; +import { + FieldLabelText, + FieldLegend, + FieldSet, + FieldValidationHint, + FieldWrapper, +} from '../Field/Field.js'; +import { getBrowserLocale } from '../../util/i18n.js'; +import { Dialog } from './components/Dialog.js'; +import { DateSegment } from './components/DateSegment.js'; +import { usePlainDateState } from './hooks/usePlainDateState.js'; +import { useSegmentFocus } from './hooks/useSegmentFocus.js'; +import { getCalendarButtonLabel, getDateSegments } from './DateInputService.js'; import classes from './DateInput.module.css'; export interface DateInputProps - extends Omit< - InputProps, - 'type' | 'value' | 'defaultValue' | 'placeholder' | 'as' - > { + extends Omit, 'onChange'>, + Pick< + InputProps, + | 'label' + | 'hideLabel' + | 'invalid' + | 'hasWarning' + | 'showValid' + | 'required' + | 'disabled' + | 'readOnly' + | 'validationHint' + | 'optionalLabel' + >, + Pick< + CalendarProps, + | 'locale' + | 'firstDayOfWeek' + | 'prevMonthButtonLabel' + | 'nextMonthButtonLabel' + | 'modifiers' + > { /** - * The value of the input element. + * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). */ value?: string; /** - * The default value of the input element. + * The initially selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). */ - defaultValue?: string | number; + defaultValue?: string; + /** + * Visually hidden label for the year input. + */ + yearInputLabel: string; + /** + * Visually hidden label for the month input. + */ + monthInputLabel: string; + /** + * Visually hidden label for the day input. + */ + dayInputLabel: string; + /** + * Label for the trailing button that opens the calendar dialog. + */ + openCalendarButtonLabel: string; + /** + * Label for the button to close the calendar dialog. + */ + closeCalendarButtonLabel: string; + /** + * Label for the button to apply the selected date and close the calendar dialog. + */ + applyDateButtonLabel: string; + /** + * Label for the button to clear the date value and close the calendar dialog. + */ + clearDateButtonLabel: string; + /** + * Callback when the date changes. Called with the date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`) or an empty string. + * + * @example '2024-10-08' + */ + onChange: (date: string) => void; + /** + * The minimum selectable date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`) (inclusive). + */ + min?: string; + /** + * The maximum selectable date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`) (inclusive). + */ + max?: string; + /** + * A hint to the user agent specifying how to prefill the input. + */ + autoComplete?: 'bday'; } /** - * DateInput component for forms. + * The DateInput component allows users to type or select a specific date. * The input value is always a string in the format `YYYY-MM-DD`. */ -export const DateInput = forwardRef( - ({ inputClassName, ...props }, ref) => { - // When server-side rendering, we assume that the user's browser supports - // the native date input. - const [supportsDate, setSupportsDate] = useState(true); - - // We check the browser support after the first render to avoid React's - // hydration mismatch warning. +export const DateInput = forwardRef( + ( + { + label, + value, + defaultValue, + onChange, + min, + max, + locale = getBrowserLocale(), + firstDayOfWeek, + modifiers, + hideLabel, + required, + disabled, + readOnly, + invalid, + hasWarning, + showValid, + validationHint, + 'aria-describedby': descriptionId, + optionalLabel, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + yearInputLabel, + monthInputLabel, + dayInputLabel, + autoComplete, + ...props + }, + ref, + ) => { + const isMobile = useMedia('(max-width: 479px)'); + + const fieldRef = useRef(null); + const dialogRef = useRef(null); + + const dialogId = useId(); + const headlineId = useId(); + const validationHintId = useId(); + + const descriptionIds = clsx(descriptionId, validationHintId); + + const focus = useSegmentFocus(); + const state = usePlainDateState({ + value, + defaultValue, + onChange, + min, + max, + locale, + }); + + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState(); + + const { floatingStyles, update } = useFloating({ + open, + placement: 'bottom-start', + middleware: [offset(4), flip(), shift()], + elements: { + reference: fieldRef.current, + floating: dialogRef.current, + }, + }); + useEffect(() => { - // Browsers fall back to a text input when the date type isn't supported. - // Adapted from https://stackoverflow.com/questions/10193294/how-can-i-tell-if-a-browser-supports-input-type-date - const input = document.createElement('input'); - input.setAttribute('type', 'date'); - - setSupportsDate(input.type === 'date'); - }, []); - - const placeholder = 'yyyy-mm-dd'; - - // TODO: Fallback explainer, with enforced format - if (!supportsDate) { - return ( - - ); + /** + * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), + * we can look into using Floating UI's `autoUpdate` (but we can't use + * `whileElementIsMounted` because our implementation hides the floating + * element using CSS instead of using conditional rendering. + * See https://floating-ui.com/docs/react-dom#updating + */ + if (open) { + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update); + } else { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update); + } + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update); + }; + }, [open, update]); + + // Focus the first date segment when clicking anywhere on the field... + const handleClick = (event: ClickEvent) => { + const element = event.target as HTMLElement; + // ...except when clicking on a specific segment input. + if (element.getAttribute('role') === 'spinbutton') { + return; + } + focus.next(); + }; + + const openCalendar = () => { + setSelection(state.date); + setOpen(true); + }; + + const closeCalendar = () => { + setOpen(false); + }; + + const handleSelect = (date: Temporal.PlainDate) => { + setSelection(date); + + if (!isMobile) { + const { year, month, day } = date; + state.update({ year, month, day }); + closeCalendar(); + } + }; + + const handleApply = () => { + if (selection) { + const { year, month, day } = selection; + state.update({ year, month, day }); + } + closeCalendar(); + }; + + const handleClear = () => { + state.update({ year: '', month: '', day: '' }); + closeCalendar(); + }; + + const mobileStyles = { + position: 'fixed', + top: 'auto', + right: '0px', + bottom: '0px', + left: '0px', + } as const; + + const dialogStyles = isMobile ? mobileStyles : floatingStyles; + + const segments = getDateSegments(locale); + const calendarButtonLabel = getCalendarButtonLabel( + openCalendarButtonLabel, + state.date, + locale, + ); + + if (process.env.NODE_ENV !== 'production') { + if (!isSufficientlyLabelled(label)) { + throw new AccessibilityError( + 'DateInput', + 'The `label` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(openCalendarButtonLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `openCalendarButtonLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(closeCalendarButtonLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `closeCalendarButtonLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(applyDateButtonLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `applyDateButtonLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(clearDateButtonLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `clearDateButtonLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(yearInputLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `yearInputLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(monthInputLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `monthInputLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(dayInputLabel)) { + throw new AccessibilityError( + 'DateInput', + 'The `dayInputLabel` prop is missing or invalid.', + ); + } } return ( - + +
+ + + +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
+ {segments.map((segment, index) => { + const segmentProps = { + required, + invalid, + disabled, + readOnly, + focus, + // Only the first segment should be associated with the validation hint to reduce verbosity. + 'aria-describedby': index === 0 ? descriptionIds : undefined, + }; + switch (segment.type) { + case 'year': + return ( + + ); + case 'month': + return ( + + ); + case 'day': + return ( + + ); + case 'literal': + return ( + + ); + default: + return null; + } + })} +
+ + {calendarButtonLabel} + +
+ +
+ + {() => ( +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
+ + + + {(!required || isMobile) && ( +
+ {!required && ( + + )} + +
+ )} +
+ )} +
+
); }, ); diff --git a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts new file mode 100644 index 0000000000..baeaed93fd --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts @@ -0,0 +1,72 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { Temporal } from 'temporal-polyfill'; + +import { getCalendarButtonLabel, getDateSegments } from './DateInputService.js'; + +describe('DateInputService', () => { + describe('getDateSegments', () => { + it.each([ + // locale, year, month, day + ['en-US', [4, 0, 2]], + ['de-DE', [4, 2, 0]], + ['pt-BR', [4, 2, 0]], + ])('should order the segments for the %s locale', (locale, indices) => { + const actual = getDateSegments(locale); + const year = actual.findIndex(({ type }) => type === 'year'); + const month = actual.findIndex(({ type }) => type === 'month'); + const day = actual.findIndex(({ type }) => type === 'day'); + expect([year, month, day]).toEqual(indices); + }); + + it.each([ + // locale, literal + ['en-US', '/'], + ['de-DE', '.'], + ['pt-BR', '/'], + ])('should return the literal for the %s locale', (locale, literal) => { + const actual = getDateSegments(locale); + const literalSegment = actual.find(({ type }) => type === 'literal'); + expect(literalSegment?.value).toBe(literal); + }); + }); + + describe('getCalendarButtonLabel', () => { + const label = 'Change date'; + + it('should return the plain label if the date is undefined', () => { + const date = undefined; + const locale = undefined; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(label); + }); + + it('should postfix the formatted date to the label', () => { + const date = new Temporal.PlainDate(2017, 8, 28); + const locale = undefined; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(`${label}, August 28, 2017`); + }); + + it('should format the date for the locale', () => { + const date = new Temporal.PlainDate(2017, 8, 28); + const locale = 'fr-FR'; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(`${label}, 28 août 2017`); + }); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/DateInputService.ts b/packages/circuit-ui/components/DateInput/DateInputService.ts new file mode 100644 index 0000000000..e2162c52a0 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateInputService.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Temporal } from 'temporal-polyfill'; +import { formatDate, formatDateTimeToParts } from '@sumup-oss/intl'; + +import type { Locale } from '../../util/i18n.js'; + +const TEST_VALUE = new Temporal.PlainDate(2024, 3, 8); + +export function getDateSegments(locale?: Locale) { + const parts = formatDateTimeToParts(TEST_VALUE, locale); + return parts.map(({ type, value }) => + type === 'literal' ? { type, value } : { type }, + ); +} + +export function getCalendarButtonLabel( + label: string, + date: Temporal.PlainDate | undefined, + locale: Locale | undefined, +) { + if (!date) { + return label; + } + return [label, formatDate(date, locale, 'long')].join(', '); +} diff --git a/packages/circuit-ui/components/DateInput/components/DateSegment.module.css b/packages/circuit-ui/components/DateInput/components/DateSegment.module.css new file mode 100644 index 0000000000..f8272106ea --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/DateSegment.module.css @@ -0,0 +1,49 @@ +.base { + width: calc(var(--width) + 2 * var(--cui-spacings-bit)); + padding: var(--cui-spacings-bit); + font-size: var(--cui-body-m-font-size); + font-variant-numeric: tabular-nums; + line-height: var(--cui-body-m-line-height); + appearance: textfield; + background-color: transparent; + border: none; + border-radius: var(--cui-border-radius-byte); + transition: background-color var(--cui-transitions-default); +} + +.base::-webkit-outer-spin-button, +.base::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.base::placeholder { + color: var(--cui-fg-placeholder); +} + +.base[aria-invalid="true"]:not(:focus):not([disabled])::placeholder { + color: var(--cui-fg-danger); +} + +.base:focus { + background-color: var(--cui-bg-highlight); + outline: none; +} + +.base:read-only { + color: var(--cui-fg-subtle); +} + +.base:disabled, +.base[disabled] { + color: var(--cui-fg-normal-disabled); +} + +.size { + position: absolute; + font-size: var(--cui-body-m-font-size); + font-variant-numeric: tabular-nums; + line-height: var(--cui-body-m-line-height); + pointer-events: none; + visibility: hidden; +} diff --git a/packages/circuit-ui/components/DateInput/components/DateSegment.spec.tsx b/packages/circuit-ui/components/DateInput/components/DateSegment.spec.tsx new file mode 100644 index 0000000000..7205cce49c --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/DateSegment.spec.tsx @@ -0,0 +1,230 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { axe, render, screen, userEvent } from '../../../util/test-utils.js'; + +import { DateSegment } from './DateSegment.js'; + +describe('DateSegment', () => { + const props = { + 'aria-label': 'Month', + placeholder: 'mm', + value: 5, + defaultValue: 3, + min: 1, + max: 12, + step: 3, + advanceFocusBoundary: 1, + onChange: vi.fn(), + focus: { + previous: vi.fn(), + next: vi.fn(), + props: { 'data-focus-list': 'focus-id' }, + }, + }; + + describe('semantics', () => { + it('should have an accessible name', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAccessibleName('Month'); + }); + + it('should have the required aria attributes for its spinbutton role', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-valuenow', '5'); + expect(input).toHaveAttribute('aria-valuemin', '1'); + expect(input).toHaveAttribute('aria-valuemax', '12'); + }); + + it('should use the numeric keyboard on touchscreen devices', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('inputmode', 'numeric'); + expect(input).toHaveAttribute('enterkeyhint', 'next'); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should change the value when typing a number', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '3'); + expect(props.onChange).toHaveBeenCalledWith(3); + }); + + it('should not change the value when typing a string', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, 'foo'); + expect(props.onChange).toHaveBeenCalledWith(''); + }); + + it('should move the focus to the next segment when typing any other digit would exceed the maximum value', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '2'); + expect(props.focus.next).toHaveBeenCalled(); + }); + + it.each(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'])( + 'should not change the value when pressing the %s key when the input is disabled', + async (key) => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, `{${key}}`); + expect(props.onChange).not.toHaveBeenCalled(); + }, + ); + it.each(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'])( + 'should not change the value when pressing the %s key when the input is read-only', + async (key) => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, `{${key}}`); + expect(props.onChange).not.toHaveBeenCalled(); + }, + ); + + it.each(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'])( + 'should set the default value when pressing the %s key', + async (key) => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, `{${key}}`); + expect(props.onChange).toHaveBeenCalledWith(3); + }, + ); + + it('should increment the value when pressing the ArrowUp key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowUp}'); + expect(props.onChange).toHaveBeenCalledWith(6); + }); + + it('should decrement the value when pressing the ArrowDown key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowDown}'); + expect(props.onChange).toHaveBeenCalledWith(4); + }); + + it('should increment the value by the step amount when pressing the PageUp key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{PageUp}'); + expect(props.onChange).toHaveBeenCalledWith(8); + }); + + it('should decrement the value by the step amount when pressing the PageDown key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{PageDown}'); + expect(props.onChange).toHaveBeenCalledWith(2); + }); + + it('should set the minimum value when pressing the Home key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{Home}'); + expect(props.onChange).toHaveBeenCalledWith(1); + }); + + it('should set the maximum value when pressing the End key', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{End}'); + expect(props.onChange).toHaveBeenCalledWith(12); + }); + + it('should move focus to the previous segment when pressing the ArrowLeft key when the input is empty', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowLeft}'); + expect(props.focus.previous).toHaveBeenCalled(); + }); + + it('should move focus to the previous segment when pressing the ArrowLeft key when the cursor is at the start of the input', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowLeft}{ArrowLeft}'); + expect(props.focus.previous).toHaveBeenCalled(); + }); + + it('should move focus to the next segment when pressing the ArrowLeft key when the input is read-only', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowLeft}'); + expect(props.focus.previous).toHaveBeenCalled(); + }); + + it('should move focus to the next segment when pressing the ArrowRight key when the input is empty', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowRight}'); + expect(props.focus.next).toHaveBeenCalled(); + }); + + it('should move focus to the next segment when pressing the ArrowRight key when the cursor is at the end of the input', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowLeft}{ArrowRight}{ArrowRight}'); + expect(props.focus.next).toHaveBeenCalled(); + }); + + it('should move focus to the next segment when pressing the ArrowRight key when the input is read-only', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{ArrowRight}'); + expect(props.focus.next).toHaveBeenCalled(); + }); + + it('should move focus to the previous segment when pressing the Backspace key when the input is empty', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{Backspace}'); + expect(props.focus.previous).toHaveBeenCalled(); + }); + + it('should move focus to the next segment when pressing the Delete key when the input is empty', async () => { + render(); + const input = screen.getByRole('spinbutton'); + await userEvent.type(input, '{Delete}'); + expect(props.focus.next).toHaveBeenCalled(); + }); + }); + + describe('layout', () => { + it('should adjust the width of the input to its content', async () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveStyle('--width: 1px'); + }); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/components/DateSegment.tsx b/packages/circuit-ui/components/DateInput/components/DateSegment.tsx new file mode 100644 index 0000000000..2cdea957e2 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/DateSegment.tsx @@ -0,0 +1,197 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { + useLayoutEffect, + useRef, + useState, + type ChangeEvent, + type InputHTMLAttributes, + type KeyboardEvent, +} from 'react'; + +import { + isArrowLeft, + isArrowRight, + isBackspace, + isDelete, +} from '../../../util/key-codes.js'; +import { isNumber } from '../../../util/type-check.js'; +import { shiftInRange } from '../../../util/helpers.js'; +import type { DateValue } from '../hooks/usePlainDateState.js'; +import type { SegmentFocus } from '../hooks/useSegmentFocus.js'; + +import classes from './DateSegment.module.css'; + +export interface DateSegmentProps + extends Omit< + InputHTMLAttributes, + 'placeholder' | 'value' | 'defaultValue' | 'min' | 'max' | 'onChange' + > { + placeholder: string; + value: DateValue; + defaultValue: number; + min: number; + max: number; + step: number; + onChange: (value: DateValue) => void; + invalid?: boolean; + hasWarning?: boolean; + showValid?: boolean; + readOnly?: boolean; + focus: SegmentFocus; +} + +export function DateSegment({ + onChange, + invalid, + focus, + defaultValue, + min, + max, + step, + ...props +}: DateSegmentProps) { + const sizeRef = useRef(null); + const [width, setWidth] = useState('4ch'); + + // biome-ignore lint/correctness/useExhaustiveDependencies: The width needs to be recalculated when the value changes + useLayoutEffect(() => { + if (sizeRef.current) { + const cursorWidth = 1; + const { offsetWidth } = sizeRef.current; + setWidth(`${cursorWidth + offsetWidth}px`); + } + }, [props.value]); + + const onKeyDown = (event: KeyboardEvent) => { + const input = event.currentTarget; + const { selectionStart, selectionEnd } = input; + + // Move between segments using arrow keys, but don't interfere with text cursor movement + if (selectionStart === selectionEnd) { + // Move to the previous segment when the cursor is at the start of the input + if (isArrowLeft(event) && (input.readOnly || selectionStart === 0)) { + event.preventDefault(); + focus.previous(); + return; + } + + // Move to the next segment when the cursor is at the end of the input + if ( + isArrowRight(event) && + (input.readOnly || selectionEnd === input.value.length) + ) { + event.preventDefault(); + focus.next(); + return; + } + } + + // Focus the following segment after clearing the current one + if (!input.value) { + if (isBackspace(event)) { + event.preventDefault(); + focus.previous(); + return; + } + + if (isDelete(event)) { + event.preventDefault(); + focus.next(); + return; + } + } + + // Don't allow editing the value when the input is disabled or read-only + if (input.disabled || input.readOnly) { + return; + } + + const value = Number.parseInt(input.value, 10); + let newValue: number; + + const getValue = (offset: number) => + value ? shiftInRange(value, offset, min, max) : defaultValue; + + switch (event.key) { + case 'ArrowUp': + newValue = getValue(1); + break; + case 'ArrowDown': + newValue = getValue(-1); + break; + case 'PageUp': + newValue = getValue(step); + break; + case 'PageDown': + newValue = getValue(-1 * step); + break; + case 'Home': + newValue = min; + break; + case 'End': + newValue = max; + break; + default: + return; + } + + if (isNumber(newValue)) { + event.preventDefault(); + onChange(newValue); + } + }; + + const handleChange = (event: ChangeEvent) => { + const value = Number.parseInt(event.currentTarget.value, 10); + + onChange(value || ''); + + // Focus the next segment if typing any other digit would exceed the + // maximum value + if (value && value > Math.floor(max / 10)) { + focus.next(); + } + }; + + return ( + <> + + + + ); +} diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.module.css b/packages/circuit-ui/components/DateInput/components/Dialog.module.css new file mode 100644 index 0000000000..a109f39298 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.module.css @@ -0,0 +1,65 @@ +.dialog { + position: absolute; + z-index: var(--cui-z-index-popover); + width: max-content; + max-width: 410px; + max-width: min(410px, 100vw); + max-height: 100vh; + padding: 0; + margin: 0; + overflow: scroll; + pointer-events: none; + visibility: hidden; + background: none; + border: none; +} + +.dialog[open] { + pointer-events: auto; + visibility: visible; +} + +@media (max-width: 479px) { + .dialog { + width: 100%; + max-width: 100%; + transition: + transform var(--cui-transitions-default), + visibility var(--cui-transitions-default); + transform: translateY(100%); + } + + .dialog[open] { + transform: translateY(0); + } +} + +.backdrop { + display: none; +} + +@media (max-width: 479px) { + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: block; + width: 100%; + height: 100%; + pointer-events: none; + visibility: hidden; + background-color: var(--cui-bg-overlay); + opacity: 0; + transition: + opacity var(--cui-transitions-default), + visibility var(--cui-transitions-default); + } + + .dialog[open] + .backdrop { + pointer-events: auto; + visibility: visible; + opacity: 1; + } +} diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx new file mode 100644 index 0000000000..f9ba53fa69 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx @@ -0,0 +1,144 @@ +/** + * Copyright 2014, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRef } from 'react'; + +import { render, screen, axe, userEvent } from '../../../util/test-utils.js'; + +import { Dialog } from './Dialog.js'; + +describe('Dialog', () => { + const props = { + onClose: vi.fn(), + open: false, + children: vi.fn(() =>
), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should forward a ref', () => { + const ref = createRef(); + const { container } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog'); + expect(ref.current).toBe(dialog); + }); + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + const { container } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog'); + expect(dialog?.className).toContain(className); + }); + + it('should open the dialog when the open prop becomes truthy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'show'); + rerender(); + expect(dialog.show).toHaveBeenCalledOnce(); + }); + + it('should open the dialog as a modal when the open prop becomes truthy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'showModal'); + rerender(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + }); + + it('should re-open the dialog as a modal when the isModal prop changes', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + vi.spyOn(dialog, 'showModal'); + rerender(); + expect(dialog.close).toHaveBeenCalledOnce(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when the open prop becomes falsy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + rerender(); + expect(dialog.close).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when the component is unmounted', () => { + const { container, unmount } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + unmount(); + expect(dialog.close).toHaveBeenCalledOnce(); + }); + + describe('when the dialog is closed', () => { + it('should not render its children', () => { + render(); + const children = screen.queryByTestId('children'); + expect(props.children).not.toHaveBeenCalled(); + expect(children).not.toBeInTheDocument(); + }); + + it('should do nothing when pressing the Escape key', async () => { + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + it('should do nothing when pressing outside the dialog', async () => { + const { container } = render(); + await userEvent.click(container); + expect(props.onClose).not.toHaveBeenCalled(); + }); + }); + + describe('when the dialog is open', () => { + it('should render its children', () => { + render(); + const children = screen.getByTestId('children'); + expect(props.children).toHaveBeenCalledOnce(); + expect(children).toBeVisible(); + }); + + it('should close the dialog when pressing the Escape key', async () => { + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when pressing outside the dialog', async () => { + const { container } = render(); + await userEvent.click(container); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.tsx new file mode 100644 index 0000000000..5692c94d01 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.tsx @@ -0,0 +1,122 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { + forwardRef, + useEffect, + useRef, + type HTMLAttributes, + type ReactNode, +} from 'react'; + +import dialogPolyfill from '../../../vendor/dialog-polyfill/index.js'; +import { useStackContext } from '../../StackContext/StackContext.js'; +import { applyMultipleRefs } from '../../../util/refs.js'; +import { clsx } from '../../../styles/clsx.js'; +import { useClickOutside } from '../../../hooks/useClickOutside/useClickOutside.js'; +import { useEscapeKey } from '../../../hooks/useEscapeKey/useEscapeKey.js'; + +import classes from './Dialog.module.css'; + +export interface DialogProps + extends Omit, 'children'> { + open: boolean; + isModal?: boolean; + onClose: () => void; + children: () => ReactNode; +} + +export const Dialog = forwardRef( + ({ children, open, onClose, className, style, isModal, ...props }, ref) => { + const zIndex = useStackContext(); + const dialogRef = useRef(null); + + useClickOutside(dialogRef, onClose, open); + useEscapeKey(onClose, open); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The package is bundled incorrectly + dialogPolyfill.registerDialog(dialogElement); + + dialogElement.addEventListener('close', onClose); + + return () => { + dialogElement.removeEventListener('close', onClose); + }; + }, [onClose]); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + if (open) { + if (!dialogElement.open) { + if (isModal) { + dialogElement.showModal(); + } else { + dialogElement.show(); + } + } + } else if (dialogElement.open) { + dialogElement.close(); + } + + return () => { + if (dialogElement.open) { + dialogElement.close(); + } + }; + }, [open, isModal]); + + return ( + <> + {/* @ts-expect-error "Expression produces a union type that is too complex to represent" */} + + {open ? children() : null} + +
+ + ); + }, +); + +Dialog.displayName = 'Dialog'; diff --git a/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts b/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts new file mode 100644 index 0000000000..571e733c32 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts @@ -0,0 +1,212 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Temporal } from 'temporal-polyfill'; + +import { + getMonthName, + getTodaysDate, + MAX_MONTH, + MAX_YEAR, + MIN_DAY, + MIN_MONTH, + MIN_YEAR, + toPlainDate, +} from '../../../util/date.js'; +import { isNumber } from '../../../util/type-check.js'; +import { clamp } from '../../../util/helpers.js'; +import type { Locale } from '../../../util/i18n.js'; +import type { DateSegmentProps } from '../components/DateSegment.js'; + +export type DateValue = number | ''; +type DateValues = { + year: DateValue; + month: DateValue; + day: DateValue; +}; + +type PlainDateState = { + date: Temporal.PlainDate | undefined; + minDate: Temporal.PlainDate | undefined; + maxDate: Temporal.PlainDate | undefined; + update: (values: Partial) => void; + props: { + year: Omit; + month: Omit; + day: Omit; + }; +}; + +export function usePlainDateState({ + value, + defaultValue, + onChange, + min, + max, + locale, +}: { + value: string | undefined; + defaultValue: string | undefined; + onChange: ((date: string) => void) | undefined; + min: string | undefined; + max: string | undefined; + locale: Locale | undefined; +}): PlainDateState { + const [values, setValues] = useState( + parseValue(defaultValue || value), + ); + + useEffect(() => { + if (value) { + setValues(parseValue(value)); + } + }, [value]); + + const update = useCallback( + (newValues: Partial) => { + setValues((prevValues) => { + const year = clampValue( + prevValues.year, + newValues.year, + MIN_YEAR, + MAX_YEAR, + ); + const month = clampValue( + prevValues.month, + newValues.month, + MIN_MONTH, + MAX_MONTH, + ); + + const yearMonth = safePlainYearMonth(year, month); + const maxDay = yearMonth?.daysInMonth || 31; + + // TODO: Special handling for February? + const day = clampValue(prevValues.day, newValues.day, MIN_DAY, maxDay); + + if (onChange) { + const plainDate = safePlainDate(year, month, day); + onChange(plainDate?.toString() || ''); + } + + return { year, month, day }; + }); + }, + [onChange], + ); + + const date = safePlainDate(values.year, values.month, values.day); + const today = getTodaysDate(); + const minDate = toPlainDate(min); + const maxDate = toPlainDate(max); + + const sameYearLimit = minDate && maxDate && minDate.year === maxDate.year; + const sameMonthLimit = sameYearLimit && minDate.month === maxDate.month; + const currentMinYear = minDate && minDate.year === values.year; + const currentMaxYear = maxDate && maxDate.year === values.year; + const currentMinMonth = currentMinYear && minDate.month === values.month; + const currentMaxMonth = currentMaxYear && maxDate.month === values.month; + + const yearMonth = safePlainYearMonth(values.year, values.month); + + const props = { + year: { + value: values.year, + defaultValue: today.year, + placeholder: 'yyyy', + step: 10, + min: minDate ? minDate.year : 1, + max: maxDate ? maxDate.year : 9999, + onChange: (year: DateValue) => update({ year }), + }, + month: { + value: values.month, + 'aria-valuetext': values.month + ? [values.month, getMonthName(values.month, locale)].join(', ') + : '', + defaultValue: today.month, + placeholder: 'mm', + step: 3, + min: sameYearLimit || currentMinYear ? minDate.month : MIN_MONTH, + max: sameYearLimit || currentMaxYear ? maxDate.month : MAX_MONTH, + onChange: (month: DateValue) => update({ month }), + }, + day: { + value: values.day, + defaultValue: today.day, + placeholder: 'dd', + step: 7, + min: sameMonthLimit || currentMinMonth ? minDate.day : 1, + max: + sameMonthLimit || currentMaxMonth + ? maxDate.day + : yearMonth?.daysInMonth || 31, + onChange: (day: DateValue) => update({ day }), + }, + }; + + return { date, minDate, maxDate, update, props }; +} + +function parseValue(value?: string): DateValues { + const plainDate = toPlainDate(value); + if (!plainDate) { + return { day: '', month: '', year: '' }; + } + const { year, month, day } = plainDate; + return { year, month, day }; +} + +function clampValue( + prevValue: DateValue, + newValue: DateValue | undefined, + min: number, + max: number, +) { + if (newValue === '' || !isNumber(newValue || prevValue)) { + return ''; + } + return clamp((newValue || prevValue) as number, min, max); +} + +function safePlainDate( + year: DateValue | undefined, + month: DateValue | undefined, + day: DateValue | undefined, +) { + try { + if (isNumber(year) && isNumber(month) && isNumber(day)) { + return new Temporal.PlainDate(year, month, day); + } + return undefined; + } catch { + return undefined; + } +} + +function safePlainYearMonth( + year: DateValue | undefined, + month: DateValue | undefined, +) { + try { + if (isNumber(year) && isNumber(month)) { + return new Temporal.PlainYearMonth(year, month); + } + return undefined; + } catch { + return undefined; + } +} diff --git a/packages/circuit-ui/components/DateInput/hooks/useSegmentFocus.spec.tsx b/packages/circuit-ui/components/DateInput/hooks/useSegmentFocus.spec.tsx new file mode 100644 index 0000000000..60a6310668 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/hooks/useSegmentFocus.spec.tsx @@ -0,0 +1,89 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { render, screen, userEvent } from '../../../util/test-utils.js'; + +import { useSegmentFocus } from './useSegmentFocus.js'; + +describe('useSegmentFocus', () => { + const list = Array.from(Array(5).keys()); + + function MockComponent({ action }: { action: 'previous' | 'next' }) { + const focus = useSegmentFocus(); + + return ( + <> + {list.map((index: number) => ( +