diff --git a/.eslintrc.js b/.eslintrc.js index 6cd4c31fe1..4f1a827632 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = require('@sumup-oss/foundry/eslint')({ '@sumup-oss/circuit-ui/no-deprecated-components': 'error', '@sumup-oss/circuit-ui/no-renamed-props': 'error', '@sumup-oss/circuit-ui/prefer-custom-properties': 'warn', + '@typescript-eslint/unbound-method': ['error', { ignoreStatic: true }], 'react/no-unknown-property': ['error', { ignore: ['css'] }], // These rules are already covered by Biome 'jsx-a11y/click-events-have-key-events': 'off', diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index e575c4e941..347e239bd1 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -85,7 +85,14 @@ line-height: var(--cui-body-m-line-height); } -.readonly .literal { +.divider { + padding: var(--cui-spacings-bit); + font-size: var(--cui-body-m-font-size); + line-height: var(--cui-body-m-line-height); +} + +.readonly .literal, +.readonly .divider { color: var(--cui-fg-subtle); } @@ -168,10 +175,6 @@ } @media (min-width: 480px) { - .apply { - display: none; - } - .presets { position: sticky; bottom: 0; diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index f911191ef2..5562e81939 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -18,6 +18,7 @@ import { useState } from 'react'; import { Stack } from '../../../../.storybook/components/index.js'; import { DateInput, type DateInputProps } from './DateInput.js'; +import { DateRangeInput, type DateRangeInputProps } from './DateRangeInput.js'; export default { title: 'Forms/DateInput', @@ -127,3 +128,12 @@ export const Locales = (args: DateInputProps) => ( ); Locales.args = baseArgs; + +export const Range = (args: DateRangeInputProps) => ( + +); + +Range.args = { + ...baseArgs, + label: 'Trip dates', +}; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 185eff92b7..09546c2aad 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -60,10 +60,10 @@ import { applyMultipleRefs } from '../../util/refs.js'; import { changeInputValue } from '../../util/input-value.js'; import { Dialog } from './components/Dialog.js'; -import { DateSegment } from './components/DateSegment.js'; -import { usePlainDateState } from './hooks/usePlainDateState.js'; +import { PlainDateSegments } from './components/PlainDateSegments.js'; +import { emptyDate, usePlainDateState } from './hooks/usePlainDateState.js'; import { useSegmentFocus } from './hooks/useSegmentFocus.js'; -import { getCalendarButtonLabel, getDateSegments } from './DateInputService.js'; +import { getCalendarButtonLabel, getDateParts } from './DateInputService.js'; import classes from './DateInput.module.css'; import { translations } from './translations/index.js'; @@ -301,7 +301,7 @@ export const DateInput = forwardRef( }; const handleClear = () => { - state.update({ year: '', month: '', day: '' }); + state.update(emptyDate); closeCalendar(); }; @@ -315,7 +315,7 @@ export const DateInput = forwardRef( const dialogStyles = isMobile ? mobileStyles : floatingStyles; - const segments = getDateSegments(locale); + const parts = getDateParts(locale); const calendarButtonLabel = getCalendarButtonLabel( openCalendarButtonLabel, state.date, @@ -371,68 +371,20 @@ export const DateInput = forwardRef( readOnly && classes.readonly, )} > - {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; - } - })} + ( {clearDateButtonLabel} )} - + {isMobile && ( + + )} )} diff --git a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts index baeaed93fd..a21eaf7941 100644 --- a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts +++ b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts @@ -16,17 +16,17 @@ import { describe, expect, it } from 'vitest'; import { Temporal } from 'temporal-polyfill'; -import { getCalendarButtonLabel, getDateSegments } from './DateInputService.js'; +import { getCalendarButtonLabel, getDateParts } from './DateInputService.js'; describe('DateInputService', () => { - describe('getDateSegments', () => { + describe('getDateParts', () => { 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 actual = getDateParts(locale); const year = actual.findIndex(({ type }) => type === 'year'); const month = actual.findIndex(({ type }) => type === 'month'); const day = actual.findIndex(({ type }) => type === 'day'); @@ -39,7 +39,7 @@ describe('DateInputService', () => { ['de-DE', '.'], ['pt-BR', '/'], ])('should return the literal for the %s locale', (locale, literal) => { - const actual = getDateSegments(locale); + const actual = getDateParts(locale); const literalSegment = actual.find(({ type }) => type === 'literal'); expect(literalSegment?.value).toBe(literal); }); diff --git a/packages/circuit-ui/components/DateInput/DateInputService.ts b/packages/circuit-ui/components/DateInput/DateInputService.ts index e2162c52a0..0529e82764 100644 --- a/packages/circuit-ui/components/DateInput/DateInputService.ts +++ b/packages/circuit-ui/components/DateInput/DateInputService.ts @@ -20,7 +20,26 @@ import type { Locale } from '../../util/i18n.js'; const TEST_VALUE = new Temporal.PlainDate(2024, 3, 8); -export function getDateSegments(locale?: Locale) { +export type DatePart = + | { type: 'literal'; value: string } + | { + type: + | 'day' + | 'dayPeriod' + | 'era' + | 'hour' + | 'minute' + | 'month' + | 'second' + | 'timeZoneName' + | 'weekday' + | 'year' + | 'unknown' + | 'date'; + value?: never; + }; + +export function getDateParts(locale?: Locale): DatePart[] { const parts = formatDateTimeToParts(TEST_VALUE, locale); return parts.map(({ type, value }) => type === 'literal' ? { type, value } : { type }, diff --git a/packages/circuit-ui/components/DateInput/DateRangeInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateRangeInput.spec.tsx new file mode 100644 index 0000000000..c949af839d --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateRangeInput.spec.tsx @@ -0,0 +1,543 @@ +/** + * Copyright 2025, 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, type Mock } from 'vitest'; +import { createRef } from 'react'; +import MockDate from 'mockdate'; + +import { render, screen, axe, userEvent } from '../../util/test-utils.js'; +import { useMedia } from '../../hooks/useMedia/useMedia.js'; + +import { DateRangeInput } from './DateRangeInput.js'; + +vi.mock('../../hooks/useMedia/useMedia.js'); + +function getInputs() { + return [ + screen.getAllByLabelText(/day/i), + screen.getAllByLabelText(/month/i), + screen.getAllByLabelText(/year/i), + ].flat(); +} + +describe('DateRangeInput', () => { + const props = { + onChange: vi.fn(), + label: 'Travel dates', + }; + + beforeEach(() => { + MockDate.set('2000-01-01'); + (useMedia as Mock).mockReturnValue(false); + }); + + it('should forward a ref', () => { + const ref = createRef(); + const { container } = render(); + + const wrapper = container.firstElementChild; + expect(ref.current).toBe(wrapper); + }); + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + const { container } = render( + , + ); + const wrapper = container.firstElementChild; + 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(); + expect(inputs[3]).toHaveAccessibleDescription(description); + expect(inputs[4]).not.toHaveAccessibleDescription(); + expect(inputs[5]).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(); + expect(inputs[3]).toHaveAccessibleDescription(customDescription); + expect(inputs[4]).not.toHaveAccessibleDescription(); + expect(inputs[5]).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(); + expect(inputs[3]).toHaveAccessibleDescription( + `${customDescription} ${description}`, + ); + expect(inputs[4]).not.toHaveAccessibleDescription(); + expect(inputs[5]).not.toHaveAccessibleDescription(); + }); + + it('should render as disabled', async () => { + render(); + const inputs = getInputs(); + + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('should render as read-only', async () => { + render(); + getInputs().forEach((input) => { + expect(input).toHaveAttribute('readonly'); + }); + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('should render as invalid', async () => { + render(); + getInputs().forEach((input) => { + expect(input).toBeInvalid(); + }); + }); + + it('should render as required', async () => { + render(); + getInputs().forEach((input) => { + expect(input).toBeRequired(); + }); + }); + + it('should have relevant minimum input values', () => { + render(); + screen.getAllByLabelText(/day/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemin', '1'); + }); + screen.getAllByLabelText(/month/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemin', '1'); + }); + screen.getAllByLabelText(/year/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemin', '2000'); + }); + }); + + it('should have relevant maximum input values', () => { + render(); + screen.getAllByLabelText(/day/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemax', '31'); + }); + screen.getAllByLabelText(/month/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemax', '12'); + }); + screen.getAllByLabelText(/year/i).forEach((input) => { + expect(input).toHaveAttribute('aria-valuemax', '2001'); + }); + }); + }); + + describe('state', () => { + it('should display a default start value', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getAllByLabelText(/day/i)[0]).toHaveValue('12'); + expect(screen.getAllByLabelText(/month/i)[0]).toHaveValue('1'); + expect(screen.getAllByLabelText(/year/i)[0]).toHaveValue('2000'); + }); + + it('should display a default end value', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getAllByLabelText(/day/i)[1]).toHaveValue('12'); + expect(screen.getAllByLabelText(/month/i)[1]).toHaveValue('1'); + expect(screen.getAllByLabelText(/year/i)[1]).toHaveValue('2000'); + }); + + it('should display an initial start value', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getAllByLabelText(/day/i)[0]).toHaveValue('12'); + expect(screen.getAllByLabelText(/month/i)[0]).toHaveValue('1'); + expect(screen.getAllByLabelText(/year/i)[0]).toHaveValue('2000'); + }); + + it('should display an initial end value', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getAllByLabelText(/day/i)[1]).toHaveValue('12'); + expect(screen.getAllByLabelText(/month/i)[1]).toHaveValue('1'); + expect(screen.getAllByLabelText(/year/i)[1]).toHaveValue('2000'); + }); + + it('should ignore an invalid value', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getAllByLabelText(/day/i)[0]).toHaveValue(''); + expect(screen.getAllByLabelText(/month/i)[0]).toHaveValue(''); + expect(screen.getAllByLabelText(/year/i)[0]).toHaveValue(''); + }); + + it('should update the displayed value', () => { + const ref = createRef(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(screen.getAllByLabelText(/day/i)[0]).toHaveValue('15'); + expect(screen.getAllByLabelText(/month/i)[0]).toHaveValue('1'); + expect(screen.getAllByLabelText(/year/i)[0]).toHaveValue('2000'); + }); + }); + + describe('user interactions', () => { + it('should focus the first input when clicking the label', async () => { + render(); + + await userEvent.click(screen.getByText('Travel dates')); + + expect(screen.getAllByRole('spinbutton')[0]).toHaveFocus(); + }); + + it('should allow users to type a start date', async () => { + const onChange = vi.fn(); + + render(); + + await userEvent.type(screen.getAllByLabelText(/year/i)[0], '2017'); + await userEvent.type(screen.getAllByLabelText(/month/i)[0], '8'); + await userEvent.type(screen.getAllByLabelText(/day/i)[0], '28'); + + expect(onChange).toHaveBeenCalled(); + }); + + it('should allow users to type an end date', async () => { + const onChange = vi.fn(); + + render(); + + await userEvent.type(screen.getAllByLabelText(/year/i)[1], '2017'); + await userEvent.type(screen.getAllByLabelText(/month/i)[1], '8'); + await userEvent.type(screen.getAllByLabelText(/day/i)[1], '28'); + + expect(onChange).toHaveBeenCalled(); + }); + + it('should update the minimum and maximum input values as the user types', async () => { + render(); + + await userEvent.type(screen.getAllByLabelText(/year/i)[0], '2001'); + + expect(screen.getAllByLabelText(/month/i)[0]).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getAllByLabelText(/month/i)[0]).toHaveAttribute( + 'aria-valuemax', + '2', + ); + + await userEvent.type(screen.getAllByLabelText(/month/i)[0], '2'); + + expect(screen.getAllByLabelText(/day/i)[0]).toHaveAttribute( + 'aria-valuemin', + '1', + ); + expect(screen.getAllByLabelText(/day/i)[0]).toHaveAttribute( + 'aria-valuemax', + '15', + ); + }); + + it('should allow users to delete the date', async () => { + const onChange = vi.fn(); + + render( + , + ); + + const inputs = screen.getAllByRole('spinbutton'); + + await userEvent.click(inputs[inputs.length - 1]); + await userEvent.keyboard(Array(19).fill('{backspace}').join('')); + + inputs.forEach((input) => { + expect(input).toHaveValue(''); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + 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, + }); + + expect(openCalendarButton).toHaveAttribute('type', 'button'); + + 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).toHaveBeenCalled(); + + // FIXME: + // expect(openCalendarButton).toHaveFocus(); + }); + + 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).toHaveBeenCalled(); + expect(openCalendarButton).toHaveFocus(); + }); + + it('should close calendar on outside click', async () => { + render( + , + ); + + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + await userEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + expect(calendarDialog).toBeVisible(); + + await userEvent.click(screen.getAllByLabelText(/year/i)[0]); + expect(calendarDialog).not.toBeVisible(); + expect(openCalendarButton).not.toHaveFocus(); + }); + + 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).toHaveBeenCalled(); + }); + + 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).toHaveBeenCalled(); + }); + + 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 actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/DateRangeInput.tsx b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx new file mode 100644 index 0000000000..32288960e1 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx @@ -0,0 +1,513 @@ +/** + * 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, + useId, + useRef, + useState, + type HTMLAttributes, +} from 'react'; +import { Temporal } from 'temporal-polyfill'; +import { + flip, + offset, + shift, + size, + useFloating, + type Placement, +} from '@floating-ui/react-dom'; +import { Calendar as CalendarIcon } from '@sumup-oss/icons'; + +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 { + toPlainDate, + updatePlainDateRange, + type PlainDateRange, +} from '../../util/date.js'; +import { useI18n } from '../../hooks/useI18n/useI18n.js'; + +import { Dialog } from './components/Dialog.js'; +import { emptyDate, usePlainDateState } from './hooks/usePlainDateState.js'; +import { useSegmentFocus } from './hooks/useSegmentFocus.js'; +import { getCalendarButtonLabel, getDateParts } from './DateInputService.js'; +import { PlainDateSegments } from './components/PlainDateSegments.js'; +import classes from './DateInput.module.css'; +import { translations } from './translations/index.js'; + +export interface DateRangeInputProps + extends Omit< + HTMLAttributes, + 'onChange' | 'value' | 'defaultValue' + >, + Pick< + InputProps, + | 'label' + | 'hideLabel' + | 'invalid' + | 'hasWarning' + | 'showValid' + | 'required' + | 'disabled' + | 'readOnly' + | 'validationHint' + | 'optionalLabel' + >, + Pick< + CalendarProps, + | 'locale' + | 'firstDayOfWeek' + | 'prevMonthButtonLabel' + | 'nextMonthButtonLabel' + | 'modifiers' + > { + /** + * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + // FIXME: + value?: { start?: string; end?: string }; + /** + * The initially selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + // FIXME: + defaultValue?: { start?: string; end?: 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; + /** + * One of the accepted placement values. Defaults to `bottom-end`. + */ + placement?: Placement; +} + +/** + * The DateRangeInput 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 DateRangeInput = forwardRef( + (props, ref) => { + const { + label, + value, + defaultValue, + onChange, + min, + max, + locale, + firstDayOfWeek, + modifiers, + hideLabel, + required, + disabled, + readOnly, + invalid, + hasWarning, + showValid, + validationHint, + 'aria-describedby': descriptionId, + optionalLabel, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + yearInputLabel, + monthInputLabel, + dayInputLabel, + placement = 'bottom-end', + className, + style, + ...rest + } = useI18n(props, translations); + const isMobile = useMedia('(max-width: 479px)'); + + const calendarButtonRef = useRef(null); + const dialogRef = useRef(null); + + const dialogId = useId(); + const headlineId = useId(); + const validationHintId = useId(); + + const descriptionIds = clsx(descriptionId, validationHintId); + const minDate = toPlainDate(min); + const maxDate = toPlainDate(max); + + const focus = useSegmentFocus(); + const startState = usePlainDateState({ + value: value?.start, + defaultValue: defaultValue?.start, + onChange, + minDate, + maxDate, + locale, + }); + const endState = usePlainDateState({ + value: value?.end, + defaultValue: defaultValue?.end, + onChange, + minDate, + maxDate, + locale, + }); + + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState({ + start: undefined, + end: undefined, + }); + + const padding = 16; // px + + const { floatingStyles, update } = useFloating({ + open, + placement, + middleware: [ + offset(4), + flip({ padding, fallbackAxisSideDirection: 'start' }), + shift({ padding }), + size({ + padding, + apply({ availableHeight, elements }) { + elements.floating.style.maxHeight = `${availableHeight}px`; + }, + }), + ], + elements: { + reference: calendarButtonRef.current, + floating: dialogRef.current, + }, + }); + + useEffect(() => { + /** + * 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 = () => { + if (startState.date && endState.date) { + // Technically, a start date after the end date is invalid, however, + // + const [start, end] = [startState.date, endState.date].sort( + Temporal.PlainDate.compare, + ); + setSelection({ start, end }); + } else if (startState.date) { + setSelection({ start: startState.date, end: endState.date }); + } else { + setSelection({ start: undefined, end: undefined }); + } + setOpen(true); + }; + + const closeCalendar = () => { + setOpen(false); + }; + + const handleSelect = (date: Temporal.PlainDate) => { + const updatedSelection = updatePlainDateRange(selection, date); + setSelection(updatedSelection); + + if (!isMobile) { + startState.update(updatedSelection.start || emptyDate); + endState.update(updatedSelection.end || emptyDate); + } + }; + + const handleApply = () => { + startState.update(selection.start || emptyDate); + endState.update(selection.end || emptyDate); + closeCalendar(); + }; + + const handleClear = () => { + startState.update(emptyDate); + endState.update(emptyDate); + closeCalendar(); + }; + + const mobileStyles = { + position: 'fixed', + top: 'auto', + right: '0px', + bottom: '0px', + left: '0px', + } as const; + + const dialogStyles = isMobile ? mobileStyles : floatingStyles; + + const parts = getDateParts(locale); + const calendarButtonLabel = getCalendarButtonLabel( + openCalendarButtonLabel, + // FIXME: + startState.date, + locale, + ); + + if ( + process.env.NODE_ENV !== 'production' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'DateRangeInput', + 'The `label` prop is missing or invalid.', + ); + } + + return ( + +
+ + + +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
+ + + +
+ + {calendarButtonLabel} + +
+ +
+ + {() => ( +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
+ + + + {(!required || isMobile) && ( +
+ {!required && ( + + )} + {isMobile && ( + + )} +
+ )} +
+ )} +
+
+ ); + }, +); + +DateRangeInput.displayName = 'DateRangeInput'; diff --git a/packages/circuit-ui/components/DateInput/components/PlainDateSegments.tsx b/packages/circuit-ui/components/DateInput/components/PlainDateSegments.tsx new file mode 100644 index 0000000000..6624e19a4a --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/PlainDateSegments.tsx @@ -0,0 +1,101 @@ +/** + * 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 type { DatePart } from '../DateInputService.js'; +import type { PlainDateState } from '../hooks/usePlainDateState.js'; +import classes from '../DateInput.module.css'; + +import { DateSegment, type DateSegmentProps } from './DateSegment.js'; + +export interface PlainDateSegmentsProps + extends Pick< + DateSegmentProps, + | 'focus' + | 'required' + | 'invalid' + | 'disabled' + | 'readOnly' + | 'aria-describedby' + > { + parts: DatePart[]; + state: PlainDateState; + yearInputLabel: string; + monthInputLabel: string; + dayInputLabel: string; + autoComplete?: 'bday'; +} + +export function PlainDateSegments({ + parts, + state, + yearInputLabel, + monthInputLabel, + dayInputLabel, + 'aria-describedby': descriptionId, + autoComplete, + ...props +}: PlainDateSegmentsProps) { + return parts.map((part, index) => { + switch (part.type) { + case 'year': + return ( + + ); + case 'month': + return ( + + ); + case 'day': + return ( + + ); + case 'literal': + return ( + + ); + default: + return null; + } + }); +} diff --git a/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts b/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts index ee25427eb8..02899dc253 100644 --- a/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts +++ b/packages/circuit-ui/components/DateInput/hooks/usePlainDateState.ts @@ -48,6 +48,8 @@ export type PlainDateState = { }; }; +export const emptyDate: DateValues = { year: '', month: '', day: '' }; + export function usePlainDateState({ value, defaultValue, @@ -160,7 +162,7 @@ export function usePlainDateState({ function parseValue(value?: string): DateValues { const plainDate = toPlainDate(value); if (!plainDate) { - return { day: '', month: '', year: '' }; + return emptyDate; } const { year, month, day } = plainDate; return { year, month, day };