diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 8aaef15423..90e0786d3f 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -84,7 +84,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); } @@ -167,10 +174,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 dd39ea3d47..244b3cbcce 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', @@ -174,3 +175,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 4d3c321fb1..e2a660f7f7 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -53,10 +53,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'; export interface DateInputProps @@ -292,7 +292,7 @@ export const DateInput = forwardRef( const dialogStyles = isMobile ? mobileStyles : floatingStyles; - const segments = getDateSegments(locale); + const parts = getDateParts(locale); const calendarButtonLabel = getCalendarButtonLabel( openCalendarButtonLabel, state.date, @@ -389,68 +389,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 ( - - {segment.value} - - ); - default: - return null; - } - })} + ( {clearDateButtonLabel} )} - - {applyDateButtonLabel} - + {isMobile && ( + + {applyDateButtonLabel} + + )} )} 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.tsx b/packages/circuit-ui/components/DateInput/DateRangeInput.tsx new file mode 100644 index 0000000000..ca428e576e --- /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 { getBrowserLocale } 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 = getBrowserLocale(), + 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 && ( + + {clearDateButtonLabel} + + )} + + {applyDateButtonLabel} + + + + )} + + + ); + }, +); + +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 ( + + {part.value} + + ); + 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 };