From 2a891c1b633138f4c7eadc6315f41572c7ed36b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 26 Oct 2024 16:54:52 +0200 Subject: [PATCH 1/4] Extract PlainDateSegments into component --- .../components/DateInput/DateInput.tsx | 82 +++----------- .../DateInput/DateInputService.spec.ts | 8 +- .../components/DateInput/DateInputService.ts | 21 +++- .../components/PlainDateSegments.tsx | 101 ++++++++++++++++++ 4 files changed, 142 insertions(+), 70 deletions(-) create mode 100644 packages/circuit-ui/components/DateInput/components/PlainDateSegments.tsx diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 185eff92b7..25ef14cd1f 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 { PlainDateSegments } from './components/PlainDateSegments.js'; import { 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'; @@ -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; - } - })} + { - 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/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; + } + }); +} From 30b0ce556881684233daae7a7fd9c7a282b6b462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 26 Oct 2024 16:55:51 +0200 Subject: [PATCH 2/4] Initial DateRangeInput --- .../components/DateInput/DateInput.module.css | 13 +- .../DateInput/DateInput.stories.tsx | 10 + .../components/DateInput/DateInput.tsx | 18 +- .../components/DateInput/DateRangeInput.tsx | 505 ++++++++++++++++++ .../DateInput/hooks/usePlainDateState.ts | 4 +- 5 files changed, 536 insertions(+), 14 deletions(-) create mode 100644 packages/circuit-ui/components/DateInput/DateRangeInput.tsx 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 25ef14cd1f..889e3651ae 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -459,14 +459,16 @@ export const DateInput = forwardRef( {clearDateButtonLabel} )} - + {isMobile && ( + + )} )} diff --git a/packages/circuit-ui/components/DateInput/DateRangeInput.tsx b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx new file mode 100644 index 0000000000..794122f383 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx @@ -0,0 +1,505 @@ +/** + * 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 type { Temporal } from 'temporal-polyfill'; +import { flip, offset, shift, useFloating } 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 { getDefaultLocale } from '../../util/i18n.js'; +import { + toPlainDate, + updatePlainDateRange, + type PlainDateRange, +} from '../../util/date.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'; + +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; +} + +/** + * 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( + ( + { + label, + value, + defaultValue, + onChange, + min, + max, + locale = getDefaultLocale(), + firstDayOfWeek, + modifiers, + hideLabel, + required, + disabled, + readOnly, + invalid, + hasWarning, + showValid, + validationHint, + 'aria-describedby': descriptionId, + optionalLabel, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + yearInputLabel, + monthInputLabel, + dayInputLabel, + ...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 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 { floatingStyles, update } = useFloating({ + open, + placement: 'bottom-start', + middleware: [offset(4), flip(), shift()], + elements: { + reference: fieldRef.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) { + 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') { + 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: */} +
+ + + +
+ + {calendarButtonLabel} + +
+ +
+ + {() => ( +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
+ + + +
+ {!required && ( + + )} + +
+
+ )} +
+
+ ); + }, +); + +DateRangeInput.displayName = 'DateRangeInput'; 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 }; From f1adfa8f0d0c749a3034b05280c519978038f9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Fri, 24 Jan 2025 16:49:16 +0100 Subject: [PATCH 3/4] Align DateRangeInput with DateInput --- .../components/DateInput/DateInput.tsx | 4 +- .../components/DateInput/DateRangeInput.tsx | 171 +++++++++--------- 2 files changed, 88 insertions(+), 87 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 889e3651ae..09546c2aad 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -61,7 +61,7 @@ import { changeInputValue } from '../../util/input-value.js'; import { Dialog } from './components/Dialog.js'; import { PlainDateSegments } from './components/PlainDateSegments.js'; -import { usePlainDateState } from './hooks/usePlainDateState.js'; +import { emptyDate, usePlainDateState } from './hooks/usePlainDateState.js'; import { useSegmentFocus } from './hooks/useSegmentFocus.js'; import { getCalendarButtonLabel, getDateParts } from './DateInputService.js'; import classes from './DateInput.module.css'; @@ -301,7 +301,7 @@ export const DateInput = forwardRef( }; const handleClear = () => { - state.update({ year: '', month: '', day: '' }); + state.update(emptyDate); closeCalendar(); }; diff --git a/packages/circuit-ui/components/DateInput/DateRangeInput.tsx b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx index 794122f383..3c8144e150 100644 --- a/packages/circuit-ui/components/DateInput/DateRangeInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx @@ -24,7 +24,14 @@ import { type HTMLAttributes, } from 'react'; import type { Temporal } from 'temporal-polyfill'; -import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; +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'; @@ -47,12 +54,12 @@ import { FieldValidationHint, FieldWrapper, } from '../Field/Field.js'; -import { getDefaultLocale } from '../../util/i18n.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'; @@ -60,6 +67,7 @@ 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< @@ -102,31 +110,31 @@ export interface DateRangeInputProps /** * Visually hidden label for the year input. */ - yearInputLabel: string; + yearInputLabel?: string; /** * Visually hidden label for the month input. */ - monthInputLabel: string; + monthInputLabel?: string; /** * Visually hidden label for the day input. */ - dayInputLabel: string; + dayInputLabel?: string; /** * Label for the trailing button that opens the calendar dialog. */ - openCalendarButtonLabel: string; + openCalendarButtonLabel?: string; /** * Label for the button to close the calendar dialog. */ - closeCalendarButtonLabel: string; + closeCalendarButtonLabel?: string; /** * Label for the button to apply the selected date and close the calendar dialog. */ - applyDateButtonLabel: string; + applyDateButtonLabel?: string; /** * Label for the button to clear the date value and close the calendar dialog. */ - clearDateButtonLabel: string; + 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. @@ -144,6 +152,10 @@ export interface DateRangeInputProps * format (`YYYY-MM-DD`) (inclusive). */ max?: string; + /** + * One of the accepted placement values. Defaults to `bottom-end`. + */ + placement?: Placement; } /** @@ -151,15 +163,15 @@ export interface DateRangeInputProps * 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 = getDefaultLocale(), + locale, firstDayOfWeek, modifiers, hideLabel, @@ -181,13 +193,14 @@ export const DateRangeInput = forwardRef( yearInputLabel, monthInputLabel, dayInputLabel, - ...props - }, - ref, - ) => { + placement = 'bottom-end', + className, + style, + ...rest + } = useI18n(props, translations); const isMobile = useMedia('(max-width: 479px)'); - const fieldRef = useRef(null); + const calendarButtonRef = useRef(null); const dialogRef = useRef(null); const dialogId = useId(); @@ -222,12 +235,24 @@ export const DateRangeInput = forwardRef( end: undefined, }); + const padding = 16; // px + const { floatingStyles, update } = useFloating({ open, - placement: 'bottom-start', - middleware: [offset(4), flip(), shift()], + placement, + middleware: [ + offset(4), + flip({ padding, fallbackAxisSideDirection: 'start' }), + shift({ padding }), + size({ + padding, + apply({ availableHeight, elements }) { + elements.floating.style.maxHeight = `${availableHeight}px`; + }, + }), + ], elements: { - reference: fieldRef.current, + reference: calendarButtonRef.current, floating: dialogRef.current, }, }); @@ -317,59 +342,24 @@ export const DateRangeInput = forwardRef( 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.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'DateRangeInput', + 'The `label` prop is missing or invalid.', + ); } return ( - +
( optionalLabel={optionalLabel} /> -
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: */}
( />
( nextMonthButtonLabel={nextMonthButtonLabel} /> -
- {!required && ( - - )} - -
+ {(!required || isMobile) && ( +
+ {!required && ( + + )} + {isMobile && ( + + )} +
+ )}
)} From fc1ad270d690faaa75994f91761a2d08e7bff715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Fri, 24 Jan 2025 20:02:30 +0100 Subject: [PATCH 4/4] Copy unit tests from DateInput --- .eslintrc.js | 1 + .../DateInput/DateRangeInput.spec.tsx | 543 ++++++++++++++++++ .../components/DateInput/DateRangeInput.tsx | 15 +- 3 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 packages/circuit-ui/components/DateInput/DateRangeInput.spec.tsx 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/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 index 3c8144e150..32288960e1 100644 --- a/packages/circuit-ui/components/DateInput/DateRangeInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx @@ -23,7 +23,7 @@ import { useState, type HTMLAttributes, } from 'react'; -import type { Temporal } from 'temporal-polyfill'; +import { Temporal } from 'temporal-polyfill'; import { flip, offset, @@ -100,13 +100,13 @@ export interface DateRangeInputProps * format (`YYYY-MM-DD`). */ // FIXME: - value?: { start: string; end: string }; + 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 }; + defaultValue?: { start?: string; end?: string }; /** * Visually hidden label for the year input. */ @@ -290,7 +290,14 @@ export const DateRangeInput = forwardRef( }; const openCalendar = () => { - if (startState.date) { + 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 });