diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 7013191b14..a6a60d3a1b 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -1,3 +1,36 @@ +.input { + display: flex; + justify-content: space-between; + background-color: var(--cui-bg-normal); + border: none; + border-radius: var(--cui-border-radius-byte); + outline: 0; + box-shadow: 0 0 0 1px var(--cui-border-normal); + transition: + box-shadow var(--cui-transitions-default), + padding var(--cui-transitions-default); +} + +.input:hover { + box-shadow: 0 0 0 1px var(--cui-border-normal-hovered); +} + +.input:focus-within { + box-shadow: 0 0 0 2px var(--cui-border-accent); +} + +.segments { + display: flex; + gap: 2px; + padding: var(--cui-spacings-byte) var(--cui-spacings-mega); +} + +.literal { + padding: var(--cui-spacings-bit) 0; + font-size: var(--cui-typography-body-m-font-size); + line-height: var(--cui-typography-body-m-line-height); +} + .calendar-button { border: none; border-left: 1px solid var(--cui-border-normal); diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index 627126a101..98ffa493ad 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -30,13 +30,18 @@ export default { const baseArgs = { label: 'Date of birth', - validationHint: 'Use the YYYY-MM-DD format', prevMonthButtonLabel: 'Previous month', nextMonthButtonLabel: 'Previous month', openCalendarButtonLabel: 'Change date', closeCalendarButtonLabel: 'Close', applyDateButtonLabel: 'Apply', clearDateButtonLabel: 'Clear', + yearInputLabel: 'Year', + monthInputLabel: 'Month', + dayInputLabel: 'Day', + locale: 'en-US', + // min: '2024-11-14', + // max: '2024-11-24', }; export const Base = (args: DateInputProps) => { diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 5b837131ea..15087eb743 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -15,23 +15,15 @@ 'use client'; -import { - forwardRef, - useEffect, - useId, - useRef, - useState, - type ChangeEvent, -} from 'react'; +import { forwardRef, useEffect, useId, useRef, useState } 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 { formatDate } from '@sumup-oss/intl'; -import { Input, type InputProps } from '../Input/index.js'; +import type { InputProps } from '../Input/index.js'; import { IconButton } from '../Button/IconButton.js'; import { Calendar, type CalendarProps } from '../Calendar/Calendar.js'; -import { applyMultipleRefs } from '../../util/refs.js'; import { useMedia } from '../../hooks/useMedia/useMedia.js'; import { toPlainDate } from '../../util/date.js'; import { @@ -40,11 +32,26 @@ import { } from '../../util/errors.js'; import { Headline } from '../Headline/Headline.js'; import { CloseButton } from '../CloseButton/CloseButton.js'; -import { clsx } from '../../styles/clsx.js'; import { Button } from '../Button/Button.js'; +import { + FieldLabelText, + FieldLegend, + FieldSet, + FieldValidationHint, + FieldWrapper, +} from '../Field/Field.js'; import classes from './DateInput.module.css'; import { Dialog } from './components/Dialog.js'; +import { getDateSegments } from './DateInputService.js'; +import { + usePlainDateState, + useDaySegment, + useMonthSegment, + useYearSegment, + useSegmentFocus, +} from './hooks.js'; +import { Segment } from './components/Segment.js'; export interface DateInputProps extends Omit< @@ -103,6 +110,18 @@ export interface DateInputProps * format (`YYYY-MM-DD`) (inclusive). */ max?: string; + /** + * TODO: + */ + yearInputLabel: string; + /** + * TODO: + */ + monthInputLabel: string; + /** + * TODO: + */ + dayInputLabel: string; } /** @@ -120,26 +139,56 @@ export const DateInput = forwardRef( locale, firstDayOfWeek, modifiers, + hideLabel, + required, + disabled, + readOnly, + invalid, + hasWarning, + showValid, + validationHint, + optionalLabel, openCalendarButtonLabel, closeCalendarButtonLabel, applyDateButtonLabel, clearDateButtonLabel, prevMonthButtonLabel, nextMonthButtonLabel, - ...props + yearInputLabel, + monthInputLabel, + dayInputLabel, + className, + style, + // ...props }, - ref, + // ref ) => { const isMobile = useMedia('(max-width: 479px)'); + + const referenceRef = useRef(null); + const floatingRef = useRef(null); const calendarRef = useRef(null); + const headlineId = useId(); + const validationHintId = useId(); + + const [focusProps, focusNextSegment] = useSegmentFocus(); + const state = usePlainDateState({ value, min, max }); + const yearProps = useYearSegment(state, focusNextSegment); + const monthProps = useMonthSegment(state, focusNextSegment); + const dayProps = useDaySegment(state, focusNextSegment); + const [open, setOpen] = useState(false); const [selection, setSelection] = useState(); - const { refs, floatingStyles, update } = useFloating({ + const { floatingStyles, update } = useFloating({ open, placement: 'bottom-end', middleware: [offset(4), flip(), shift()], + elements: { + reference: referenceRef.current, + floating: floatingRef.current, + }, }); useEffect(() => { @@ -173,8 +222,6 @@ export const DateInput = forwardRef( setOpen(false); }; - const placeholder = 'yyyy-mm-dd'; - const handleSelect = (date: Temporal.PlainDate) => { setSelection(date); @@ -194,10 +241,6 @@ export const DateInput = forwardRef( closeCalendar(); }; - const handleInputChange = (event: ChangeEvent) => { - onChange(event.target.value); - }; - const mobileStyles = { position: 'fixed', bottom: '0px', @@ -232,45 +275,139 @@ export const DateInput = forwardRef( '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.', + ); + } } const plainDate = toPlainDate(value); const calendarButtonLabel = plainDate - ? // @ts-expect-error FIXME: Update @sumup-oss/intl - [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join( + ? [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join( ', ', ) : openCalendarButtonLabel; + const segments = getDateSegments(locale); + + // parts are closely related: + // - max days depends on month and year + // - max month depends on year (and max prop) + // - focus management + + // dispatch onChange when each part has been filled in + // dispatch with empty string when a part is removed + return ( -
- + {/* TODO: Replicate native date input for uncontrolled inputs? */} + {/* ( + required={required} + disabled={disabled} + readOnly={readOnly} + {...props} + /> */} +
+ + + +
+
+ {segments.map((segment, index) => { + switch (segment.type) { + case 'year': + return ( + + ); + case 'month': + return ( + + ); + case 'day': + return ( + + ); + case 'literal': + return ( + + ); + default: + return null; + } + })} +
{calendarButtonLabel} - )} - onChange={handleInputChange} - /> +
+ +
( className={classes.calendar} onSelect={handleSelect} selection={selection} - minDate={toPlainDate(min) || undefined} - maxDate={toPlainDate(max) || undefined} + minDate={state.minDate} + maxDate={state.maxDate} locale={locale} firstDayOfWeek={firstDayOfWeek} modifiers={modifiers} @@ -320,7 +457,7 @@ export const DateInput = forwardRef(
)} - + ); }, ); diff --git a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts new file mode 100644 index 0000000000..5ef0c1fa37 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { getDateSegments } from './DateInputService.js'; + +describe('DateInputService', () => { + describe('getDateSegments', () => { + it('should', () => { + const actual = getDateSegments(); + expect(actual).toBe('TODO:'); + }); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/DateInputService.ts b/packages/circuit-ui/components/DateInput/DateInputService.ts new file mode 100644 index 0000000000..ad75258589 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/DateInputService.ts @@ -0,0 +1,28 @@ +/** + * 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 { formatDateTimeToParts } from '@sumup-oss/intl'; + +import type { Locale } from '../../util/i18n.js'; + +// TODO: Replace with Temporal.PlainDate +const TEST_VALUE = new Date(2024, 3, 8); + +export function getDateSegments(locale?: Locale) { + 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/Segment.module.css b/packages/circuit-ui/components/DateInput/components/Segment.module.css new file mode 100644 index 0000000000..b3d506c0ed --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Segment.module.css @@ -0,0 +1,39 @@ +.input { + width: calc(var(--width) + 2 * var(--cui-spacings-bit)); + padding: var(--cui-spacings-bit); + font-size: var(--cui-typography-body-m-font-size); + font-variant-numeric: tabular-nums; + line-height: var(--cui-typography-body-m-line-height); + text-align: end; + appearance: textfield; + background-color: var(--cui-bg-normal); + border: none; + border-radius: var(--cui-border-radius-bit); + transition: background-color var(--cui-transitions-default); +} + +.input::-webkit-outer-spin-button, +.input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.input:focus { + background-color: var(--cui-bg-highlight); + outline: none; +} + +.input:read-only { + color: var(--cui-fg-normal-disabled); + background-color: var(--cui-bg-normal); +} + +.size { + position: absolute; + font-size: var(--cui-typography-body-m-font-size); + font-variant-numeric: tabular-nums; + line-height: var(--cui-typography-body-m-line-height); + text-align: end; + pointer-events: none; + visibility: hidden; +} diff --git a/packages/circuit-ui/components/DateInput/components/Segment.tsx b/packages/circuit-ui/components/DateInput/components/Segment.tsx new file mode 100644 index 0000000000..54473d0e19 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Segment.tsx @@ -0,0 +1,49 @@ +/** + * 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 { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'; + +import classes from './Segment.module.css'; + +export function Segment(props: InputHTMLAttributes) { + const sizeRef = useRef(null); + const [width, setWidth] = useState('4ch'); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (sizeRef.current) { + setWidth(`${sizeRef.current.offsetWidth}px`); + } + }, [props.value]); + + return ( +
+ + +
+ ); +} diff --git a/packages/circuit-ui/components/DateInput/hooks.ts b/packages/circuit-ui/components/DateInput/hooks.ts new file mode 100644 index 0000000000..6e4c04a20a --- /dev/null +++ b/packages/circuit-ui/components/DateInput/hooks.ts @@ -0,0 +1,220 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + useCallback, + useId, + useState, + type FormEvent, + type InputHTMLAttributes, +} from 'react'; +import { Temporal } from 'temporal-polyfill'; + +import { toPlainDate } from '../../util/date.js'; +import { clamp } from '../../util/helpers.js'; +import { isNumber } from '../../util/type-check.js'; + +/** + * These hooks assume a Gregorian or ISO 8601 calendar: + * + * - The maximum number of days in a month is 31. + */ + +type PartialPlainDate = { + year?: number | ''; + month?: number | ''; + day?: number | ''; +}; + +type PlainDateState = { + date: PartialPlainDate; + update: (date: PartialPlainDate) => void; + minDate?: Temporal.PlainDate; + maxDate?: Temporal.PlainDate; +}; + +export function usePlainDateState({ + value, + min, + max, +}: { + value?: string; + min?: string; + max?: string; +}): PlainDateState { + const [date, setDate] = useState(() => { + const plainDate = toPlainDate(value); + if (!plainDate) { + return { day: '', month: '', year: '' }; + } + const { year, month, day } = plainDate; + return { year, month, day }; + }); + + const update = (newDate: PartialPlainDate) => { + setDate((prevDate) => ({ ...prevDate, ...newDate })); + }; + + const minDate = toPlainDate(min); + const maxDate = toPlainDate(max); + + return { date, update, minDate, maxDate }; +} + +export function useYearSegment( + state: PlainDateState, + focusNextSegment: () => void, +): InputHTMLAttributes { + if ( + state.minDate && + state.maxDate && + state.minDate.year === state.maxDate.year + ) { + return { + value: state.minDate.year, + readOnly: true, + }; + } + + const min = state.minDate ? state.minDate.year : 1; + const max = state.maxDate ? state.maxDate.year : 9999; + + const onChange = (event: FormEvent) => { + const value = Number.parseInt(event.currentTarget.value, 10); + // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 + const year = value ? clamp(value, min, max) : ''; + state.update({ year }); + if (year && year > 999) { + focusNextSegment(); + } + }; + + return { + value: state.date.year, + min, + max, + placeholder: 'yyyy', + onChange, + }; +} + +export function useMonthSegment( + state: PlainDateState, + focusNextSegment: () => void, +): InputHTMLAttributes { + if ( + state.minDate && + state.maxDate && + state.minDate.year === state.maxDate.year && + state.minDate.month === state.maxDate.month + ) { + return { + value: state.minDate.month, + readOnly: true, + }; + } + + let min = 1; + let max = 12; + + if (state.minDate && state.minDate.year === state.date.year) { + min = state.minDate.month; + } + + if (state.maxDate && state.maxDate.year === state.date.year) { + max = state.maxDate.month; + } + + const onChange = (event: FormEvent) => { + const value = Number.parseInt(event.currentTarget.value, 10); + // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 + const month = value ? clamp(value, min, max) : ''; + state.update({ month }); + if (month && month > 1) { + focusNextSegment(); + } + }; + + return { + value: state.date.month, + min, + max, + placeholder: 'mm', + onChange, + }; +} + +export function useDaySegment( + state: PlainDateState, + focusNextSegment: () => void, +): InputHTMLAttributes { + const min = 1; + let max = 31; + + if (isNumber(state.date.year) && isNumber(state.date.month)) { + const plainYearMonth = new Temporal.PlainYearMonth( + state.date.year, + state.date.month, + ); + max = plainYearMonth.daysInMonth; + // TODO: account for min and max dates + } + + const onChange = (event: FormEvent) => { + const value = Number.parseInt(event.currentTarget.value, 10); + // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 + const day = value ? clamp(value, min, max) : ''; + state.update({ day }); + if (day && day > 3) { + focusNextSegment(); + } + }; + + return { + value: state.date.day, + min, + max, + placeholder: 'dd', + onChange, + }; +} + +export function useSegmentFocus(): [Record, () => void] { + const name = useId(); + + const focusNextSegment = useCallback(() => { + const items = document.querySelectorAll( + `[data-focus-list="${name}"]`, + ); + const currentEl = document.activeElement as HTMLElement; + const currentIndex = Array.from(items).indexOf(currentEl); + + if (currentIndex === -1) { + return; + } + + const newIndex = currentIndex + 1; + + if (newIndex >= items.length) { + return; + } + + const newEl = items.item(newIndex); + + newEl.focus(); + }, [name]); + + return [{ 'data-focus-list': name }, focusNextSegment]; +} diff --git a/packages/circuit-ui/util/date.spec.ts b/packages/circuit-ui/util/date.spec.ts index 3b6a4ea80c..5f96d863a7 100644 --- a/packages/circuit-ui/util/date.spec.ts +++ b/packages/circuit-ui/util/date.spec.ts @@ -57,16 +57,16 @@ describe('CalendarService', () => { expect(actual).toEqual(new Temporal.PlainDate(2020, 3, 1)); }); - it('should return null if the date is invalid', () => { + it('should return undefined if the date is invalid', () => { const date = '2020-3-1'; const actual = toPlainDate(date); - expect(actual).toBeNull(); + expect(actual).toBeUndefined(); }); it('should return null if the date is undefined', () => { const date = undefined; const actual = toPlainDate(date); - expect(actual).toBeNull(); + expect(actual).toBeUndefined(); }); }); diff --git a/packages/circuit-ui/util/date.ts b/packages/circuit-ui/util/date.ts index 7df47d30d2..4c22f15322 100644 --- a/packages/circuit-ui/util/date.ts +++ b/packages/circuit-ui/util/date.ts @@ -30,14 +30,14 @@ export function isPlainDate(date: unknown): date is Temporal.PlainDate { return date instanceof Temporal.PlainDate; } -export function toPlainDate(date?: string): Temporal.PlainDate | null { +export function toPlainDate(date?: string): Temporal.PlainDate | undefined { if (!date) { - return null; + return undefined; } try { return Temporal.PlainDate.from(date); } catch (_error) { - return null; + return undefined; } }