From 0176f24abfaca1a38149ff5e7602857255c63722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Fri, 9 Aug 2024 16:41:30 +0200 Subject: [PATCH 01/31] Implement initial DateInput --- .../experimental/DateInput/DateInput.mdx | 20 + .../DateInput/DateInput.module.css | 134 +++++++ .../experimental/DateInput/DateInput.spec.tsx | 46 +++ .../DateInput/DateInput.stories.tsx | 47 +++ .../experimental/DateInput/DateInput.tsx | 375 ++++++++++++++++++ .../experimental/DateInput/index.ts | 18 + 6 files changed, 640 insertions(+) create mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.mdx create mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.module.css create mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx create mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx create mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.tsx create mode 100644 packages/circuit-ui/components/experimental/DateInput/index.ts diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.mdx b/packages/circuit-ui/components/experimental/DateInput/DateInput.mdx new file mode 100644 index 0000000000..e0f4c21400 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.mdx @@ -0,0 +1,20 @@ +import { Meta, Status, Props, Story } from '../../../../../.storybook/components'; +import * as Stories from './DateInput.stories'; + + + +# DateInput + + + + + + +- can't use `input[type="date"]` because of accessibility issues and lack of customization +- date range selection +- closing the popover after date selection +- free-form input +- autocomplete +- context on mobile dialog: field name (and current value?) +- abstract away dialog? -> leave for later once re-implementing the Modal components +- button to open calendar? open on focus? (how to free-form enter on mobile?) (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/ vs https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-datepicker/) diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css b/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css new file mode 100644 index 0000000000..689f39b8c9 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css @@ -0,0 +1,134 @@ +.calendar-button { + border: none; + border-left: 1px solid var(--cui-border-normal); + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.dialog { + position: absolute; + z-index: var(--cui-z-index-popover); + width: max-content; + max-width: 410px; + max-width: min(410px, 100vw); + max-height: 100vh; + padding: 0; + margin: 0; + overflow: scroll; + pointer-events: none; + visibility: hidden; + background: none; + border: none; +} + +.dialog[open] { + pointer-events: auto; + visibility: visible; +} + +@media (max-width: 479px) { + .dialog { + width: 100%; + max-width: 100%; + transition: + transform var(--cui-transitions-default), + visibility var(--cui-transitions-default); + transform: translateY(100%); + } + + .dialog[open] { + transform: translateY(0); + } +} + +.backdrop { + pointer-events: none; + visibility: hidden; + opacity: 0; +} + +@media (max-width: 479px) { + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--cui-bg-overlay); + transition: + opacity var(--cui-transitions-default), + visibility var(--cui-transitions-default); + } + + .dialog[open] + .backdrop { + pointer-events: auto; + visibility: visible; + opacity: 1; + } +} + +.content { + color: var(--cui-fg-normal); + background-color: var(--cui-bg-elevated); + border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); + border-radius: var(--cui-border-radius-byte); + outline: 0; + box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); +} + +@media (max-width: 479px) { + .content { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--cui-spacings-giga) var(--cui-spacings-mega) + var(--cui-spacings-byte) var(--cui-spacings-mega); +} + +@media (min-width: 480px) { + /* Hide visually */ + .header { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } +} + +.calendar { + padding: var(--cui-spacings-mega); +} + +.buttons { + display: flex; + flex-wrap: wrap; + gap: var(--cui-spacings-kilo); + justify-content: space-between; + padding: var(--cui-spacings-mega); + border-top: var(--cui-border-width-kilo) solid var(--cui-border-divider); +} + +@media (min-width: 480px) { + .apply { + display: none; + } + + .presets { + position: sticky; + bottom: 0; + margin-top: var(--cui-spacings-mega); + } +} diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx new file mode 100644 index 0000000000..e078875e88 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx @@ -0,0 +1,46 @@ +/** + * 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, vi } from 'vitest'; +import { createRef } from 'react'; + +import { render, screen, axe } from '../../../util/test-utils.js'; +import type { InputElement } from '../../Input/index.js'; + +import { DateInput } from './DateInput.js'; + +describe('DateInput', () => { + const baseProps = { + onChange: vi.fn(), + label: 'Date', + prevMonthButtonLabel: 'Previous month', + nextMonthButtonLabel: 'Previous month', + openCalendarButtonLabel: 'Change date', + closeCalendarButtonLabel: 'Close', + }; + + it('should forward a ref', () => { + const ref = createRef(); + render(); + const input = screen.getByRole('textbox'); + expect(ref.current).toBe(input); + }); + + 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/experimental/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx new file mode 100644 index 0000000000..74169752a0 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx @@ -0,0 +1,47 @@ +/** + * 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 { useState } from 'react'; + +import { DateInput, type DateInputProps } from './DateInput.js'; + +export default { + title: 'Forms/Input/Experimental/DateInput', + component: DateInput, + parameters: { + layout: 'padded', + }, + argTypes: { + disabled: { control: 'boolean' }, + }, +}; + +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', +}; + +export const Base = (args: DateInputProps) => { + const [value, setValue] = useState(args.value || ''); + return ; +}; + +Base.args = baseArgs; diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx new file mode 100644 index 0000000000..2743d917f2 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx @@ -0,0 +1,375 @@ +/** + * 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, + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, +} from 'react'; +import { Calendar as CalendarIcon } from '@sumup/icons'; +import type { Temporal } from 'temporal-polyfill'; +import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; +import { formatDate } from '@sumup/intl'; + +import dialogPolyfill from '../../../vendor/dialog-polyfill/index.js'; +import { + Input, + type InputElement, + 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 { useStackContext } from '../../StackContext/StackContext.js'; +import { useClickOutside } from '../../../hooks/useClickOutside/useClickOutside.js'; +import { useEscapeKey } from '../../../hooks/useEscapeKey/useEscapeKey.js'; +import { toPlainDate } from '../../../util/date.js'; +import { + AccessibilityError, + isSufficientlyLabelled, +} 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 classes from './DateInput.module.css'; + +export interface DateInputProps + extends Omit< + InputProps, + | 'type' + | 'onChange' + | 'value' + | 'defaultValue' + | 'placeholder' + | 'as' + | 'renderSuffix' + >, + Pick< + CalendarProps, + | 'locale' + | 'firstDayOfWeek' + | 'prevMonthButtonLabel' + | 'nextMonthButtonLabel' + | 'modifiers' + > { + /** + * TODO: + */ + openCalendarButtonLabel: string; + /** + * TODO: + */ + closeCalendarButtonLabel: string; + /** + * TODO: + */ + applyDateButtonLabel: string; + /** + * TODO: + */ + clearDateButtonLabel: string; + /** + * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + value?: string; + /** + * TODO: + * + * date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`) or empty string + */ + 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; +} + +// TODO: Fallback to input[type="date"] without JS? +// TODO: What to do about required calendar labels? -> optional? or separate experimental component? + +/** + * TODO: DateInput component for forms. + * The input value is always a string in the format `YYYY-MM-DD`. + */ +export const DateInput = forwardRef( + ( + { + label, + value, + onChange, + min, + max, + locale, + firstDayOfWeek, + modifiers, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + ...props + }, + ref, + ) => { + const zIndex = useStackContext(); + const isMobile = useMedia('(max-width: 479px)'); + const inputRef = useRef(null); + const dialogRef = useRef(null); + const calendarRef = useRef(null); + const headlineId = useId(); + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState(toPlainDate(value) || undefined); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The package is bundled incorrectly + dialogPolyfill.registerDialog(dialogElement); + + const handleClose = () => { + setOpen(false); + }; + + dialogElement.addEventListener('close', handleClose); + + return () => { + dialogElement.addEventListener('close', handleClose); + }; + }, []); + + const { refs, floatingStyles, update } = useFloating({ + open, + placement: 'bottom-start', + middleware: [offset(4), flip(), shift()], + }); + + useEffect(() => { + /** + * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), + * we can look into using Floating UI's `autoUpdate` (but we can't use + * `whileElementInMounted` 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]); + + const closeCalendar = useCallback(() => { + dialogRef.current?.close(); + }, []); + + useClickOutside(refs.floating, closeCalendar, open); + useEscapeKey(closeCalendar, open); + + const placeholder = 'yyyy-mm-dd'; + + const handleSelect = (date: Temporal.PlainDate) => { + setSelection(date); + + if (!isMobile) { + onChange(date.toString()); + closeCalendar(); + } + }; + + const handleApply = () => { + onChange(selection?.toString() || ''); + closeCalendar(); + }; + + const handleClear = () => { + onChange(''); + closeCalendar(); + }; + + const handleInputChange = (event: ChangeEvent) => { + onChange(event.target.value); + }; + + const openCalendar = () => { + if (dialogRef.current) { + dialogRef.current.show(); + setOpen(true); + } + }; + + const mobileStyles = { + position: 'fixed', + bottom: '0px', + left: '0px', + right: '0px', + } as const; + + const dialogStyles = isMobile ? mobileStyles : floatingStyles; + + if (process.env.NODE_ENV !== 'production') { + 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.', + ); + } + } + + const calendarButtonLabel = value + ? [ + openCalendarButtonLabel, + // FIXME: Don't error on incomplete date + formatDate(new Date(value), locale, 'long'), + ].join(', ') + : openCalendarButtonLabel; + + return ( +
+ ( + + {calendarButtonLabel} + + )} + onChange={handleInputChange} + /> + ( + dialogRef, + refs.setFloating, + )} + aria-labelledby={headlineId} + className={classes.dialog} + style={{ + ...dialogStyles, + // @ts-expect-error z-index can be a string + zIndex: zIndex || 'var(--cui-z-index-modal)', + }} + > +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
+ + + +
+ + +
+
+
+
+
+ ); + }, +); + +DateInput.displayName = 'DateInput'; diff --git a/packages/circuit-ui/components/experimental/DateInput/index.ts b/packages/circuit-ui/components/experimental/DateInput/index.ts new file mode 100644 index 0000000000..6765a4a419 --- /dev/null +++ b/packages/circuit-ui/components/experimental/DateInput/index.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export { DateInput } from './DateInput.js'; + +export type { DateInputProps } from './DateInput.js'; From daa5b98e55d7be82a2987a3cd1d6c09abf85c257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Tue, 8 Oct 2024 12:53:34 +0200 Subject: [PATCH 02/31] Add missing doc comments --- .../PhoneNumberInput/PhoneNumberInput.tsx | 6 +- .../experimental/DateInput/DateInput.spec.tsx | 2 + .../experimental/DateInput/DateInput.tsx | 60 +++++++++++-------- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx index c9f52849f1..69e74ab9b8 100644 --- a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx +++ b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx @@ -60,8 +60,10 @@ export interface PhoneNumberInputProps label: string; /** * Callback when the country code or subscriber number changes. Called with - * the normalized phone number in the [E.164 format](https://en.wikipedia.org/wiki/E.164), - * e.g. `+17024181234`. + * the normalized phone number in the [E.164 format](https://en.wikipedia.org/wiki/E.164). + * + * + * @example '+17024181234' */ onChange?: (phoneNumber: string) => void; /** diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx index e078875e88..cf37979d4d 100644 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx @@ -29,6 +29,8 @@ describe('DateInput', () => { nextMonthButtonLabel: 'Previous month', openCalendarButtonLabel: 'Change date', closeCalendarButtonLabel: 'Close', + applyDateButtonLabel: 'Apply', + clearDateButtonLabel: 'Clear', }; it('should forward a ref', () => { diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx index 2743d917f2..3cb141532a 100644 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx @@ -74,19 +74,19 @@ export interface DateInputProps | 'modifiers' > { /** - * TODO: + * Label for the trailing button that opens the calendar dialog. */ openCalendarButtonLabel: string; /** - * TODO: + * Label for the button to close the calendar dialog. */ closeCalendarButtonLabel: string; /** - * TODO: + * Label for the button to apply the selected date and close the calendar dialog. */ applyDateButtonLabel: string; /** - * TODO: + * Label for the button to clear the date value and close the calendar dialog. */ clearDateButtonLabel: string; /** @@ -95,10 +95,10 @@ export interface DateInputProps */ value?: string; /** - * TODO: + * 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. * - * date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) - * format (`YYYY-MM-DD`) or empty string + * @example '2024-10-08' */ onChange: (date: string) => void; /** @@ -113,11 +113,8 @@ export interface DateInputProps max?: string; } -// TODO: Fallback to input[type="date"] without JS? -// TODO: What to do about required calendar labels? -> optional? or separate experimental component? - /** - * TODO: DateInput component for forms. + * DateInput component for forms. * The input value is always a string in the format `YYYY-MM-DD`. */ export const DateInput = forwardRef( @@ -147,9 +144,17 @@ export const DateInput = forwardRef( const dialogRef = useRef(null); const calendarRef = useRef(null); const headlineId = useId(); + const [isHydrated, setHydrated] = useState(false); const [open, setOpen] = useState(false); const [selection, setSelection] = useState(toPlainDate(value) || undefined); + // Initially, an `input[type="date"]` element is rendered in case + // JavaScript isn't available. Once hydrated, the input is progressively + // enhanced with the custom UI. + useEffect(() => { + setHydrated(true); + }, []); + useEffect(() => { const dialogElement = dialogRef.current; @@ -290,23 +295,28 @@ export const DateInput = forwardRef( ref={applyMultipleRefs(ref, inputRef, refs.setReference)} label={label} value={value} + type={isHydrated ? 'text' : 'date'} min={min} max={max} placeholder={placeholder} - renderSuffix={(suffixProps) => ( - - {calendarButtonLabel} - - )} + renderSuffix={ + isHydrated + ? (suffixProps) => ( + + {calendarButtonLabel} + + ) + : undefined + } onChange={handleInputChange} /> Date: Tue, 8 Oct 2024 13:21:00 +0200 Subject: [PATCH 03/31] Merge experimental DateInput into stable one --- .../DateInput/DateInput.mdx | 5 +- .../components/DateInput/DateInput.module.css | 136 ++++++- .../components/DateInput/DateInput.spec.tsx | 19 +- .../DateInput/DateInput.stories.tsx | 18 +- .../components/DateInput/DateInput.tsx | 374 +++++++++++++++-- .../DateInput/DateInput.module.css | 134 ------ .../experimental/DateInput/DateInput.spec.tsx | 48 --- .../DateInput/DateInput.stories.tsx | 47 --- .../experimental/DateInput/DateInput.tsx | 385 ------------------ .../experimental/DateInput/index.ts | 18 - 10 files changed, 495 insertions(+), 689 deletions(-) rename packages/circuit-ui/components/{experimental => }/DateInput/DateInput.mdx (55%) delete mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.module.css delete mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx delete mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx delete mode 100644 packages/circuit-ui/components/experimental/DateInput/DateInput.tsx delete mode 100644 packages/circuit-ui/components/experimental/DateInput/index.ts diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.mdx b/packages/circuit-ui/components/DateInput/DateInput.mdx similarity index 55% rename from packages/circuit-ui/components/experimental/DateInput/DateInput.mdx rename to packages/circuit-ui/components/DateInput/DateInput.mdx index e0f4c21400..6b05320c2a 100644 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.mdx +++ b/packages/circuit-ui/components/DateInput/DateInput.mdx @@ -1,11 +1,11 @@ -import { Meta, Status, Props, Story } from '../../../../../.storybook/components'; +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; import * as Stories from './DateInput.stories'; # DateInput - + @@ -17,4 +17,3 @@ import * as Stories from './DateInput.stories'; - autocomplete - context on mobile dialog: field name (and current value?) - abstract away dialog? -> leave for later once re-implementing the Modal components -- button to open calendar? open on focus? (how to free-form enter on mobile?) (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/ vs https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-datepicker/) diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index a04f6ec087..689f39b8c9 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -1,4 +1,134 @@ -.base { - min-width: 8ch; - height: 48px; +.calendar-button { + border: none; + border-left: 1px solid var(--cui-border-normal); + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.dialog { + position: absolute; + z-index: var(--cui-z-index-popover); + width: max-content; + max-width: 410px; + max-width: min(410px, 100vw); + max-height: 100vh; + padding: 0; + margin: 0; + overflow: scroll; + pointer-events: none; + visibility: hidden; + background: none; + border: none; +} + +.dialog[open] { + pointer-events: auto; + visibility: visible; +} + +@media (max-width: 479px) { + .dialog { + width: 100%; + max-width: 100%; + transition: + transform var(--cui-transitions-default), + visibility var(--cui-transitions-default); + transform: translateY(100%); + } + + .dialog[open] { + transform: translateY(0); + } +} + +.backdrop { + pointer-events: none; + visibility: hidden; + opacity: 0; +} + +@media (max-width: 479px) { + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--cui-bg-overlay); + transition: + opacity var(--cui-transitions-default), + visibility var(--cui-transitions-default); + } + + .dialog[open] + .backdrop { + pointer-events: auto; + visibility: visible; + opacity: 1; + } +} + +.content { + color: var(--cui-fg-normal); + background-color: var(--cui-bg-elevated); + border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); + border-radius: var(--cui-border-radius-byte); + outline: 0; + box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); +} + +@media (max-width: 479px) { + .content { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--cui-spacings-giga) var(--cui-spacings-mega) + var(--cui-spacings-byte) var(--cui-spacings-mega); +} + +@media (min-width: 480px) { + /* Hide visually */ + .header { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } +} + +.calendar { + padding: var(--cui-spacings-mega); +} + +.buttons { + display: flex; + flex-wrap: wrap; + gap: var(--cui-spacings-kilo); + justify-content: space-between; + padding: var(--cui-spacings-mega); + border-top: var(--cui-border-width-kilo) solid var(--cui-border-divider); +} + +@media (min-width: 480px) { + .apply { + display: none; + } + + .presets { + position: sticky; + bottom: 0; + margin-top: var(--cui-spacings-mega); + } } diff --git a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx index aad82917a0..72c443cc36 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -13,20 +13,29 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createRef } from 'react'; -import { render, axe } from '../../util/test-utils.js'; +import { render, screen, axe } from '../../util/test-utils.js'; import { DateInput } from './DateInput.js'; describe('DateInput', () => { - const baseProps = { label: 'Date' }; + const baseProps = { + onChange: vi.fn(), + label: 'Date', + prevMonthButtonLabel: 'Previous month', + nextMonthButtonLabel: 'Previous month', + openCalendarButtonLabel: 'Change date', + closeCalendarButtonLabel: 'Close', + applyDateButtonLabel: 'Apply', + clearDateButtonLabel: 'Clear', + }; it('should forward a ref', () => { const ref = createRef(); - const { container } = render(); - const input = container.querySelector('input'); + render(); + const input = screen.getByRole('textbox'); expect(ref.current).toBe(input); }); diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index fe547ab9cb..663e167616 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -13,11 +13,16 @@ * limitations under the License. */ +import { useState } from 'react'; + import { DateInput, type DateInputProps } from './DateInput.js'; export default { title: 'Forms/DateInput', component: DateInput, + parameters: { + layout: 'padded', + }, argTypes: { disabled: { control: 'boolean' }, }, @@ -25,9 +30,18 @@ export default { const baseArgs = { label: 'Date of birth', - validationHint: 'You must be at least 18 years old', + validationHint: 'Use the YYYY-MM-DD format', + prevMonthButtonLabel: 'Previous month', + nextMonthButtonLabel: 'Previous month', + openCalendarButtonLabel: 'Change date', + closeCalendarButtonLabel: 'Close', + applyDateButtonLabel: 'Apply', + clearDateButtonLabel: 'Clear', }; -export const Base = (args: DateInputProps) => ; +export const Base = (args: DateInputProps) => { + const [value, setValue] = useState(args.value || ''); + return ; +}; Base.args = baseArgs; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index e116066719..7f17ec0eb4 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2021, SumUp Ltd. + * Copyright 2024, SumUp Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -15,27 +15,98 @@ 'use client'; -import { forwardRef, useState, useEffect } from 'react'; -import { PatternFormat } from 'react-number-format'; +import { + forwardRef, + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, +} from 'react'; +import { Calendar as CalendarIcon } from '@sumup/icons'; +import type { Temporal } from 'temporal-polyfill'; +import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; +import { formatDate } from '@sumup/intl'; +import dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; import { Input, 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 { useStackContext } from '../StackContext/StackContext.js'; +import { useClickOutside } from '../../hooks/useClickOutside/useClickOutside.js'; +import { useEscapeKey } from '../../hooks/useEscapeKey/useEscapeKey.js'; +import { toPlainDate } from '../../util/date.js'; +import { + AccessibilityError, + isSufficientlyLabelled, +} 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 classes from './DateInput.module.css'; export interface DateInputProps extends Omit< - InputProps, - 'type' | 'value' | 'defaultValue' | 'placeholder' | 'as' - > { + InputProps, + | 'type' + | 'onChange' + | 'value' + | 'defaultValue' + | 'placeholder' + | 'as' + | 'renderSuffix' + >, + Pick< + CalendarProps, + | 'locale' + | 'firstDayOfWeek' + | 'prevMonthButtonLabel' + | 'nextMonthButtonLabel' + | 'modifiers' + > { /** - * The value of the input element. + * 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; + /** + * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). */ value?: string; /** - * The default value of the input element. + * 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). */ - defaultValue?: string | number; + max?: string; } /** @@ -43,51 +114,266 @@ export interface DateInputProps * The input value is always a string in the format `YYYY-MM-DD`. */ export const DateInput = forwardRef( - ({ inputClassName, ...props }, ref) => { - // When server-side rendering, we assume that the user's browser supports - // the native date input. - const [supportsDate, setSupportsDate] = useState(true); + ( + { + label, + value, + onChange, + min, + max, + locale, + firstDayOfWeek, + modifiers, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + ...props + }, + ref, + ) => { + const zIndex = useStackContext(); + const isMobile = useMedia('(max-width: 479px)'); + const inputRef = useRef(null); + const dialogRef = useRef(null); + const calendarRef = useRef(null); + const headlineId = useId(); + const [isHydrated, setHydrated] = useState(false); + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState(toPlainDate(value) || undefined); + + // Initially, an `input[type="date"]` element is rendered in case + // JavaScript isn't available. Once hydrated, the input is progressively + // enhanced with the custom UI. + useEffect(() => { + setHydrated(true); + }, []); - // We check the browser support after the first render to avoid React's - // hydration mismatch warning. useEffect(() => { - // Browsers fall back to a text input when the date type isn't supported. - // Adapted from https://stackoverflow.com/questions/10193294/how-can-i-tell-if-a-browser-supports-input-type-date - const input = document.createElement('input'); - input.setAttribute('type', 'date'); + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The package is bundled incorrectly + dialogPolyfill.registerDialog(dialogElement); - setSupportsDate(input.type === 'date'); + const handleClose = () => { + setOpen(false); + }; + + dialogElement.addEventListener('close', handleClose); + + return () => { + dialogElement.addEventListener('close', handleClose); + }; }, []); + const { refs, floatingStyles, update } = useFloating({ + open, + placement: 'bottom-start', + middleware: [offset(4), flip(), shift()], + }); + + useEffect(() => { + /** + * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), + * we can look into using Floating UI's `autoUpdate` (but we can't use + * `whileElementInMounted` 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]); + + const closeCalendar = useCallback(() => { + dialogRef.current?.close(); + }, []); + + useClickOutside(refs.floating, closeCalendar, open); + useEscapeKey(closeCalendar, open); + const placeholder = 'yyyy-mm-dd'; - // TODO: Fallback explainer, with enforced format - if (!supportsDate) { - return ( - - ); + const handleSelect = (date: Temporal.PlainDate) => { + setSelection(date); + + if (!isMobile) { + onChange(date.toString()); + closeCalendar(); + } + }; + + const handleApply = () => { + onChange(selection?.toString() || ''); + closeCalendar(); + }; + + const handleClear = () => { + onChange(''); + closeCalendar(); + }; + + const handleInputChange = (event: ChangeEvent) => { + onChange(event.target.value); + }; + + const openCalendar = () => { + if (dialogRef.current) { + dialogRef.current.show(); + setOpen(true); + } + }; + + const mobileStyles = { + position: 'fixed', + bottom: '0px', + left: '0px', + right: '0px', + } as const; + + const dialogStyles = isMobile ? mobileStyles : floatingStyles; + + if (process.env.NODE_ENV !== 'production') { + 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.', + ); + } } + const calendarButtonLabel = value + ? [ + openCalendarButtonLabel, + // FIXME: Don't error on incomplete date + formatDate(new Date(value), locale, 'long'), + ].join(', ') + : openCalendarButtonLabel; + return ( - +
+ ( + + {calendarButtonLabel} + + ) + : undefined + } + onChange={handleInputChange} + /> + ( + dialogRef, + refs.setFloating, + )} + aria-labelledby={headlineId} + className={classes.dialog} + style={{ + ...dialogStyles, + // @ts-expect-error z-index can be a string + zIndex: zIndex || 'var(--cui-z-index-modal)', + }} + > +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
+ + + +
+ + +
+
+
+
+
); }, ); diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css b/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css deleted file mode 100644 index 689f39b8c9..0000000000 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.module.css +++ /dev/null @@ -1,134 +0,0 @@ -.calendar-button { - border: none; - border-left: 1px solid var(--cui-border-normal); - border-top-left-radius: 0 !important; - border-bottom-left-radius: 0 !important; -} - -.dialog { - position: absolute; - z-index: var(--cui-z-index-popover); - width: max-content; - max-width: 410px; - max-width: min(410px, 100vw); - max-height: 100vh; - padding: 0; - margin: 0; - overflow: scroll; - pointer-events: none; - visibility: hidden; - background: none; - border: none; -} - -.dialog[open] { - pointer-events: auto; - visibility: visible; -} - -@media (max-width: 479px) { - .dialog { - width: 100%; - max-width: 100%; - transition: - transform var(--cui-transitions-default), - visibility var(--cui-transitions-default); - transform: translateY(100%); - } - - .dialog[open] { - transform: translateY(0); - } -} - -.backdrop { - pointer-events: none; - visibility: hidden; - opacity: 0; -} - -@media (max-width: 479px) { - .backdrop { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - background-color: var(--cui-bg-overlay); - transition: - opacity var(--cui-transitions-default), - visibility var(--cui-transitions-default); - } - - .dialog[open] + .backdrop { - pointer-events: auto; - visibility: visible; - opacity: 1; - } -} - -.content { - color: var(--cui-fg-normal); - background-color: var(--cui-bg-elevated); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - border-radius: var(--cui-border-radius-byte); - outline: 0; - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); -} - -@media (max-width: 479px) { - .content { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--cui-spacings-giga) var(--cui-spacings-mega) - var(--cui-spacings-byte) var(--cui-spacings-mega); -} - -@media (min-width: 480px) { - /* Hide visually */ - .header { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - border: 0; - } -} - -.calendar { - padding: var(--cui-spacings-mega); -} - -.buttons { - display: flex; - flex-wrap: wrap; - gap: var(--cui-spacings-kilo); - justify-content: space-between; - padding: var(--cui-spacings-mega); - border-top: var(--cui-border-width-kilo) solid var(--cui-border-divider); -} - -@media (min-width: 480px) { - .apply { - display: none; - } - - .presets { - position: sticky; - bottom: 0; - margin-top: var(--cui-spacings-mega); - } -} diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx deleted file mode 100644 index cf37979d4d..0000000000 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.spec.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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, vi } from 'vitest'; -import { createRef } from 'react'; - -import { render, screen, axe } from '../../../util/test-utils.js'; -import type { InputElement } from '../../Input/index.js'; - -import { DateInput } from './DateInput.js'; - -describe('DateInput', () => { - const baseProps = { - onChange: vi.fn(), - label: 'Date', - prevMonthButtonLabel: 'Previous month', - nextMonthButtonLabel: 'Previous month', - openCalendarButtonLabel: 'Change date', - closeCalendarButtonLabel: 'Close', - applyDateButtonLabel: 'Apply', - clearDateButtonLabel: 'Clear', - }; - - it('should forward a ref', () => { - const ref = createRef(); - render(); - const input = screen.getByRole('textbox'); - expect(ref.current).toBe(input); - }); - - 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/experimental/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx deleted file mode 100644 index 74169752a0..0000000000 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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 { useState } from 'react'; - -import { DateInput, type DateInputProps } from './DateInput.js'; - -export default { - title: 'Forms/Input/Experimental/DateInput', - component: DateInput, - parameters: { - layout: 'padded', - }, - argTypes: { - disabled: { control: 'boolean' }, - }, -}; - -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', -}; - -export const Base = (args: DateInputProps) => { - const [value, setValue] = useState(args.value || ''); - return ; -}; - -Base.args = baseArgs; diff --git a/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx b/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx deleted file mode 100644 index 3cb141532a..0000000000 --- a/packages/circuit-ui/components/experimental/DateInput/DateInput.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/** - * 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, - useCallback, - useEffect, - useId, - useRef, - useState, - type ChangeEvent, -} from 'react'; -import { Calendar as CalendarIcon } from '@sumup/icons'; -import type { Temporal } from 'temporal-polyfill'; -import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; -import { formatDate } from '@sumup/intl'; - -import dialogPolyfill from '../../../vendor/dialog-polyfill/index.js'; -import { - Input, - type InputElement, - 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 { useStackContext } from '../../StackContext/StackContext.js'; -import { useClickOutside } from '../../../hooks/useClickOutside/useClickOutside.js'; -import { useEscapeKey } from '../../../hooks/useEscapeKey/useEscapeKey.js'; -import { toPlainDate } from '../../../util/date.js'; -import { - AccessibilityError, - isSufficientlyLabelled, -} 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 classes from './DateInput.module.css'; - -export interface DateInputProps - extends Omit< - InputProps, - | 'type' - | 'onChange' - | 'value' - | 'defaultValue' - | 'placeholder' - | 'as' - | 'renderSuffix' - >, - Pick< - CalendarProps, - | 'locale' - | 'firstDayOfWeek' - | 'prevMonthButtonLabel' - | 'nextMonthButtonLabel' - | 'modifiers' - > { - /** - * 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; - /** - * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) - * format (`YYYY-MM-DD`). - */ - value?: 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; -} - -/** - * DateInput component for forms. - * The input value is always a string in the format `YYYY-MM-DD`. - */ -export const DateInput = forwardRef( - ( - { - label, - value, - onChange, - min, - max, - locale, - firstDayOfWeek, - modifiers, - openCalendarButtonLabel, - closeCalendarButtonLabel, - applyDateButtonLabel, - clearDateButtonLabel, - prevMonthButtonLabel, - nextMonthButtonLabel, - ...props - }, - ref, - ) => { - const zIndex = useStackContext(); - const isMobile = useMedia('(max-width: 479px)'); - const inputRef = useRef(null); - const dialogRef = useRef(null); - const calendarRef = useRef(null); - const headlineId = useId(); - const [isHydrated, setHydrated] = useState(false); - const [open, setOpen] = useState(false); - const [selection, setSelection] = useState(toPlainDate(value) || undefined); - - // Initially, an `input[type="date"]` element is rendered in case - // JavaScript isn't available. Once hydrated, the input is progressively - // enhanced with the custom UI. - useEffect(() => { - setHydrated(true); - }, []); - - useEffect(() => { - const dialogElement = dialogRef.current; - - if (!dialogElement) { - return undefined; - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore The package is bundled incorrectly - dialogPolyfill.registerDialog(dialogElement); - - const handleClose = () => { - setOpen(false); - }; - - dialogElement.addEventListener('close', handleClose); - - return () => { - dialogElement.addEventListener('close', handleClose); - }; - }, []); - - const { refs, floatingStyles, update } = useFloating({ - open, - placement: 'bottom-start', - middleware: [offset(4), flip(), shift()], - }); - - useEffect(() => { - /** - * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), - * we can look into using Floating UI's `autoUpdate` (but we can't use - * `whileElementInMounted` 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]); - - const closeCalendar = useCallback(() => { - dialogRef.current?.close(); - }, []); - - useClickOutside(refs.floating, closeCalendar, open); - useEscapeKey(closeCalendar, open); - - const placeholder = 'yyyy-mm-dd'; - - const handleSelect = (date: Temporal.PlainDate) => { - setSelection(date); - - if (!isMobile) { - onChange(date.toString()); - closeCalendar(); - } - }; - - const handleApply = () => { - onChange(selection?.toString() || ''); - closeCalendar(); - }; - - const handleClear = () => { - onChange(''); - closeCalendar(); - }; - - const handleInputChange = (event: ChangeEvent) => { - onChange(event.target.value); - }; - - const openCalendar = () => { - if (dialogRef.current) { - dialogRef.current.show(); - setOpen(true); - } - }; - - const mobileStyles = { - position: 'fixed', - bottom: '0px', - left: '0px', - right: '0px', - } as const; - - const dialogStyles = isMobile ? mobileStyles : floatingStyles; - - if (process.env.NODE_ENV !== 'production') { - 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.', - ); - } - } - - const calendarButtonLabel = value - ? [ - openCalendarButtonLabel, - // FIXME: Don't error on incomplete date - formatDate(new Date(value), locale, 'long'), - ].join(', ') - : openCalendarButtonLabel; - - return ( -
- ( - - {calendarButtonLabel} - - ) - : undefined - } - onChange={handleInputChange} - /> - ( - dialogRef, - refs.setFloating, - )} - aria-labelledby={headlineId} - className={classes.dialog} - style={{ - ...dialogStyles, - // @ts-expect-error z-index can be a string - zIndex: zIndex || 'var(--cui-z-index-modal)', - }} - > -
-
- - {label} - - - {closeCalendarButtonLabel} - -
- - - -
- - -
-
-
-
-
- ); - }, -); - -DateInput.displayName = 'DateInput'; diff --git a/packages/circuit-ui/components/experimental/DateInput/index.ts b/packages/circuit-ui/components/experimental/DateInput/index.ts deleted file mode 100644 index 6765a4a419..0000000000 --- a/packages/circuit-ui/components/experimental/DateInput/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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. - */ - -export { DateInput } from './DateInput.js'; - -export type { DateInputProps } from './DateInput.js'; From 1fc8bec864a071b498c3b7ed5b1e43e72f4c55a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Tue, 8 Oct 2024 13:58:16 +0200 Subject: [PATCH 04/31] Silence polyfill warnings in JSDOM Related: https://github.com/jsdom/jsdom/issues/3294 --- packages/circuit-ui/vendor/dialog-polyfill/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/circuit-ui/vendor/dialog-polyfill/index.js b/packages/circuit-ui/vendor/dialog-polyfill/index.js index 525e29bded..85214e4c20 100644 --- a/packages/circuit-ui/vendor/dialog-polyfill/index.js +++ b/packages/circuit-ui/vendor/dialog-polyfill/index.js @@ -621,7 +621,7 @@ if (typeof window === 'undefined') { * @param {!Element} element to force upgrade */ dialogPolyfill.forceRegisterDialog = function (element) { - if (window.HTMLDialogElement || element.showModal) { + if (window.HTMLDialogElement && element.showModal) { console.warn( 'This browser already supports , the polyfill ' + 'may not work correctly', From 14cfb04dd7c38ca09678c2319c399fdad2f9691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 10 Oct 2024 15:59:40 +0200 Subject: [PATCH 05/31] Remove input[date] fallback --- .../components/DateInput/DateInput.spec.tsx | 30 ++++++++- .../components/DateInput/DateInput.tsx | 65 ++++++++----------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx index 72c443cc36..1bdbd5be3b 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -16,7 +16,13 @@ import { describe, expect, it, vi } from 'vitest'; import { createRef } from 'react'; -import { render, screen, axe } from '../../util/test-utils.js'; +import { + render, + screen, + axe, + userEvent, + fireEvent, +} from '../../util/test-utils.js'; import { DateInput } from './DateInput.js'; @@ -39,6 +45,28 @@ describe('DateInput', () => { expect(ref.current).toBe(input); }); + it('should select a calendar date', async () => { + render(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + const openCalendarButton = screen.getByRole('button', { + name: /change date/i, + }); + + // For some reason, userEvent doesn't work here. + fireEvent.click(openCalendarButton); + + const calendarDialog = screen.getByRole('dialog'); + + expect(calendarDialog).toBeVisible(); + + const dateButton = screen.getByRole('button', { name: /12/ }); + + await userEvent.click(dateButton); + + expect(input).toHaveValue('2024-10-12'); + }); + it('should have no accessibility violations', async () => { const { container } = render(); const actual = await axe(container); diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 7f17ec0eb4..71d80386ff 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -24,10 +24,10 @@ import { useState, type ChangeEvent, } from 'react'; -import { Calendar as CalendarIcon } from '@sumup/icons'; import type { Temporal } from 'temporal-polyfill'; import { flip, offset, shift, useFloating } from '@floating-ui/react-dom'; -import { formatDate } from '@sumup/intl'; +import { Calendar as CalendarIcon } from '@sumup-oss/icons'; +import { formatDate } from '@sumup-oss/intl'; import dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; import { Input, type InputProps } from '../Input/index.js'; @@ -136,20 +136,11 @@ export const DateInput = forwardRef( ) => { const zIndex = useStackContext(); const isMobile = useMedia('(max-width: 479px)'); - const inputRef = useRef(null); const dialogRef = useRef(null); const calendarRef = useRef(null); const headlineId = useId(); - const [isHydrated, setHydrated] = useState(false); const [open, setOpen] = useState(false); - const [selection, setSelection] = useState(toPlainDate(value) || undefined); - - // Initially, an `input[type="date"]` element is rendered in case - // JavaScript isn't available. Once hydrated, the input is progressively - // enhanced with the custom UI. - useEffect(() => { - setHydrated(true); - }, []); + const [selection, setSelection] = useState(); useEffect(() => { const dialogElement = dialogRef.current; @@ -169,7 +160,7 @@ export const DateInput = forwardRef( dialogElement.addEventListener('close', handleClose); return () => { - dialogElement.addEventListener('close', handleClose); + dialogElement.removeEventListener('close', handleClose); }; }, []); @@ -236,6 +227,7 @@ export const DateInput = forwardRef( const openCalendar = () => { if (dialogRef.current) { dialogRef.current.show(); + setSelection(toPlainDate(value) || undefined); setOpen(true); } }; @@ -276,43 +268,38 @@ export const DateInput = forwardRef( } } - const calendarButtonLabel = value - ? [ - openCalendarButtonLabel, - // FIXME: Don't error on incomplete date - formatDate(new Date(value), locale, 'long'), - ].join(', ') + const plainDate = toPlainDate(value); + const calendarButtonLabel = plainDate + ? [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join( + ', ', + ) : openCalendarButtonLabel; return (
( - - {calendarButtonLabel} - - ) - : undefined - } + renderSuffix={(suffixProps) => ( + + {calendarButtonLabel} + + )} onChange={handleInputChange} /> Date: Thu, 10 Oct 2024 16:50:06 +0200 Subject: [PATCH 06/31] Extract Dialog component --- .../components/DateInput/DateInput.module.css | 64 ------- .../components/DateInput/DateInput.tsx | 156 +++++++----------- .../DateInput/components/Dialog.module.css | 63 +++++++ .../DateInput/components/Dialog.tsx | 114 +++++++++++++ 4 files changed, 235 insertions(+), 162 deletions(-) create mode 100644 packages/circuit-ui/components/DateInput/components/Dialog.module.css create mode 100644 packages/circuit-ui/components/DateInput/components/Dialog.tsx diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 689f39b8c9..7013191b14 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -5,70 +5,6 @@ border-bottom-left-radius: 0 !important; } -.dialog { - position: absolute; - z-index: var(--cui-z-index-popover); - width: max-content; - max-width: 410px; - max-width: min(410px, 100vw); - max-height: 100vh; - padding: 0; - margin: 0; - overflow: scroll; - pointer-events: none; - visibility: hidden; - background: none; - border: none; -} - -.dialog[open] { - pointer-events: auto; - visibility: visible; -} - -@media (max-width: 479px) { - .dialog { - width: 100%; - max-width: 100%; - transition: - transform var(--cui-transitions-default), - visibility var(--cui-transitions-default); - transform: translateY(100%); - } - - .dialog[open] { - transform: translateY(0); - } -} - -.backdrop { - pointer-events: none; - visibility: hidden; - opacity: 0; -} - -@media (max-width: 479px) { - .backdrop { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - background-color: var(--cui-bg-overlay); - transition: - opacity var(--cui-transitions-default), - visibility var(--cui-transitions-default); - } - - .dialog[open] + .backdrop { - pointer-events: auto; - visibility: visible; - opacity: 1; - } -} - .content { color: var(--cui-fg-normal); background-color: var(--cui-bg-elevated); diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 71d80386ff..5b837131ea 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -17,7 +17,6 @@ import { forwardRef, - useCallback, useEffect, useId, useRef, @@ -29,15 +28,11 @@ 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 dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; import { Input, 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 { useStackContext } from '../StackContext/StackContext.js'; -import { useClickOutside } from '../../hooks/useClickOutside/useClickOutside.js'; -import { useEscapeKey } from '../../hooks/useEscapeKey/useEscapeKey.js'; import { toPlainDate } from '../../util/date.js'; import { AccessibilityError, @@ -49,6 +44,7 @@ import { clsx } from '../../styles/clsx.js'; import { Button } from '../Button/Button.js'; import classes from './DateInput.module.css'; +import { Dialog } from './components/Dialog.js'; export interface DateInputProps extends Omit< @@ -134,39 +130,15 @@ export const DateInput = forwardRef( }, ref, ) => { - const zIndex = useStackContext(); const isMobile = useMedia('(max-width: 479px)'); - const dialogRef = useRef(null); const calendarRef = useRef(null); const headlineId = useId(); const [open, setOpen] = useState(false); const [selection, setSelection] = useState(); - useEffect(() => { - const dialogElement = dialogRef.current; - - if (!dialogElement) { - return undefined; - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore The package is bundled incorrectly - dialogPolyfill.registerDialog(dialogElement); - - const handleClose = () => { - setOpen(false); - }; - - dialogElement.addEventListener('close', handleClose); - - return () => { - dialogElement.removeEventListener('close', handleClose); - }; - }, []); - const { refs, floatingStyles, update } = useFloating({ open, - placement: 'bottom-start', + placement: 'bottom-end', middleware: [offset(4), flip(), shift()], }); @@ -174,7 +146,7 @@ export const DateInput = forwardRef( /** * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), * we can look into using Floating UI's `autoUpdate` (but we can't use - * `whileElementInMounted` because our implementation hides the floating + * `whileElementIsMounted` because our implementation hides the floating * element using CSS instead of using conditional rendering. * See https://floating-ui.com/docs/react-dom#updating */ @@ -192,12 +164,14 @@ export const DateInput = forwardRef( }; }, [open, update]); - const closeCalendar = useCallback(() => { - dialogRef.current?.close(); - }, []); + const openCalendar = () => { + setSelection(toPlainDate(value) || undefined); + setOpen(true); + }; - useClickOutside(refs.floating, closeCalendar, open); - useEscapeKey(closeCalendar, open); + const closeCalendar = () => { + setOpen(false); + }; const placeholder = 'yyyy-mm-dd'; @@ -224,14 +198,6 @@ export const DateInput = forwardRef( onChange(event.target.value); }; - const openCalendar = () => { - if (dialogRef.current) { - dialogRef.current.show(); - setSelection(toPlainDate(value) || undefined); - setOpen(true); - } - }; - const mobileStyles = { position: 'fixed', bottom: '0px', @@ -270,7 +236,8 @@ export const DateInput = forwardRef( const plainDate = toPlainDate(value); const calendarButtonLabel = plainDate - ? [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join( + ? // @ts-expect-error FIXME: Update @sumup-oss/intl + [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join( ', ', ) : openCalendarButtonLabel; @@ -302,64 +269,57 @@ export const DateInput = forwardRef( )} onChange={handleInputChange} /> - ( - dialogRef, - refs.setFloating, - )} + -
-
- - {label} - - - {closeCalendarButtonLabel} - -
+ {() => ( +
+
+ + {label} + + + {closeCalendarButtonLabel} + +
- + -
- - +
+ + +
-
-
-
+ )} +
); }, diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.module.css b/packages/circuit-ui/components/DateInput/components/Dialog.module.css new file mode 100644 index 0000000000..47eab46c2f --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.module.css @@ -0,0 +1,63 @@ +.dialog { + position: absolute; + z-index: var(--cui-z-index-popover); + width: max-content; + max-width: 410px; + max-width: min(410px, 100vw); + max-height: 100vh; + padding: 0; + margin: 0; + overflow: scroll; + pointer-events: none; + visibility: hidden; + background: none; + border: none; +} + +.dialog[open] { + pointer-events: auto; + visibility: visible; +} + +@media (max-width: 479px) { + .dialog { + width: 100%; + max-width: 100%; + transition: + transform var(--cui-transitions-default), + visibility var(--cui-transitions-default); + transform: translateY(100%); + } + + .dialog[open] { + transform: translateY(0); + } +} + +.backdrop { + pointer-events: none; + visibility: hidden; + opacity: 0; +} + +@media (max-width: 479px) { + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--cui-bg-overlay); + transition: + opacity var(--cui-transitions-default), + visibility var(--cui-transitions-default); + } + + .dialog[open] + .backdrop { + pointer-events: auto; + visibility: visible; + opacity: 1; + } +} diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.tsx new file mode 100644 index 0000000000..52f8808924 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.tsx @@ -0,0 +1,114 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { + forwardRef, + useEffect, + useRef, + type HTMLAttributes, + type ReactNode, +} from 'react'; + +import dialogPolyfill from '../../../vendor/dialog-polyfill/index.js'; +import { useStackContext } from '../../StackContext/StackContext.js'; +import { applyMultipleRefs } from '../../../util/refs.js'; +import { clsx } from '../../../styles/clsx.js'; +import { useClickOutside } from '../../../hooks/useClickOutside/useClickOutside.js'; +import { useEscapeKey } from '../../../hooks/useEscapeKey/useEscapeKey.js'; + +import classes from './Dialog.module.css'; + +export interface DialogProps + extends Omit, 'children'> { + open: boolean; + onClose: () => void; + children: () => ReactNode; +} + +export const Dialog = forwardRef( + ({ children, open, onClose, className, style, ...props }, ref) => { + const zIndex = useStackContext(); + const dialogRef = useRef(null); + + useClickOutside(dialogRef, onClose, open); + useEscapeKey(onClose, open); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The package is bundled incorrectly + dialogPolyfill.registerDialog(dialogElement); + + dialogElement.addEventListener('close', onClose); + + return () => { + dialogElement.removeEventListener('close', onClose); + }; + }, [onClose]); + + // TODO: modal + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + if (open) { + dialogElement.show(); + } else { + dialogElement.close(); + } + + return () => { + dialogElement.close(); + }; + }, [open]); + + return ( + <> + {/* @ts-expect-error "Expression produces a union type that is too complex to represent" */} + + {open ? children() : null} + +
+ + ); + }, +); + +Dialog.displayName = 'Dialog'; From 7b47ab73639d616cb26d711ebbc7612184b8c496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 19 Oct 2024 13:44:39 +0200 Subject: [PATCH 07/31] Bump @sumup-oss/intl to v3 --- .changeset/chilly-dodos-end.md | 4 ++-- package-lock.json | 16 ++++++++-------- packages/circuit-ui/package.json | 2 +- templates/astro/package.json | 2 +- templates/nextjs/template/package.json | 2 +- templates/remix/package.json | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.changeset/chilly-dodos-end.md b/.changeset/chilly-dodos-end.md index 3852fafc24..455ebd2d91 100644 --- a/.changeset/chilly-dodos-end.md +++ b/.changeset/chilly-dodos-end.md @@ -1,5 +1,5 @@ --- -"@sumup-oss/circuit-ui": major +'@sumup-oss/circuit-ui': major --- -Upgraded to `@sumup-oss/intl` v2. If your app also depends on `@sumup-oss/intl` (previously called `@sumup/intl`), you need to upgrade it as well. +Upgraded to `@sumup-oss/intl` v3. If your app also depends on `@sumup-oss/intl` (previously called `@sumup/intl`), you need to upgrade it as well. diff --git a/package-lock.json b/package-lock.json index 2649e73e35..dd5dbb37a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42405,7 +42405,7 @@ "@emotion/styled": "^11.13.0", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", @@ -42612,7 +42612,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "astro": "^4.15.9", @@ -42664,7 +42664,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "next": "^14.2.10", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -42702,7 +42702,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "isbot": "^5.1.13", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -49488,7 +49488,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -49518,7 +49518,7 @@ "@nanostores/react": "^0.7.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", @@ -49657,7 +49657,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -64042,7 +64042,7 @@ "@sumup-oss/eslint-plugin-circuit-ui": "^5.0.0-next.2", "@sumup-oss/foundry": "^8.2.0", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@sumup-oss/stylelint-plugin-circuit-ui": "^3.0.0-next.2", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json index 9a76e2c4f6..697decad36 100644 --- a/packages/circuit-ui/package.json +++ b/packages/circuit-ui/package.json @@ -64,7 +64,7 @@ "@emotion/styled": "^11.13.0", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", diff --git a/templates/astro/package.json b/templates/astro/package.json index 4f0669d3b7..504c6f78f0 100644 --- a/templates/astro/package.json +++ b/templates/astro/package.json @@ -19,7 +19,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "astro": "^4.15.9", diff --git a/templates/nextjs/template/package.json b/templates/nextjs/template/package.json index a5a7e91c6a..39dd82d3df 100644 --- a/templates/nextjs/template/package.json +++ b/templates/nextjs/template/package.json @@ -17,7 +17,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "next": "^14.2.10", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/templates/remix/package.json b/templates/remix/package.json index ad4d16064b..25f9bfd5ad 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -21,7 +21,7 @@ "@sumup-oss/circuit-ui": "^9.0.0-next.2", "@sumup-oss/design-tokens": "^8.0.0-next.2", "@sumup-oss/icons": "^5.0.0-next.1", - "@sumup-oss/intl": "^3.0.0", + "@sumup-oss/intl": "^3.0.1", "isbot": "^5.1.13", "react": "^18.3.1", "react-dom": "^18.3.1" From fab403fd65670720e42d7e6c41c5ff1bd437b85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 12 Oct 2024 10:09:18 +0200 Subject: [PATCH 08/31] Split text input into segments --- .../components/DateInput/DateInput.module.css | 33 +++ .../DateInput/DateInput.stories.tsx | 7 +- .../components/DateInput/DateInput.tsx | 225 ++++++++++++++---- .../DateInput/DateInputService.spec.ts | 27 +++ .../components/DateInput/DateInputService.ts | 28 +++ .../DateInput/components/Segment.module.css | 39 +++ .../DateInput/components/Segment.tsx | 49 ++++ .../circuit-ui/components/DateInput/hooks.ts | 220 +++++++++++++++++ packages/circuit-ui/util/date.spec.ts | 6 +- packages/circuit-ui/util/date.ts | 6 +- 10 files changed, 589 insertions(+), 51 deletions(-) create mode 100644 packages/circuit-ui/components/DateInput/DateInputService.spec.ts create mode 100644 packages/circuit-ui/components/DateInput/DateInputService.ts create mode 100644 packages/circuit-ui/components/DateInput/components/Segment.module.css create mode 100644 packages/circuit-ui/components/DateInput/components/Segment.tsx create mode 100644 packages/circuit-ui/components/DateInput/hooks.ts 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 663e167616..c34484d80e 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; } } From 4710d9a56faef510063e3fd2765d1973565c76b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 12 Oct 2024 14:01:10 +0200 Subject: [PATCH 09/31] Focus following segment after clearing the current one --- .../components/DateInput/DateInput.tsx | 8 +- .../DateInput/components/Segment.tsx | 11 +- .../circuit-ui/components/DateInput/hooks.ts | 142 +++++++++++------- packages/circuit-ui/util/key-codes.spec.tsx | 36 +++++ packages/circuit-ui/util/key-codes.ts | 7 + 5 files changed, 145 insertions(+), 59 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 15087eb743..f4f7eb82a0 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -172,11 +172,11 @@ export const DateInput = forwardRef( const headlineId = useId(); const validationHintId = useId(); - const [focusProps, focusNextSegment] = useSegmentFocus(); + const [focusProps, focusHandlers] = useSegmentFocus(); const state = usePlainDateState({ value, min, max }); - const yearProps = useYearSegment(state, focusNextSegment); - const monthProps = useMonthSegment(state, focusNextSegment); - const dayProps = useDaySegment(state, focusNextSegment); + const yearProps = useYearSegment(state, focusHandlers); + const monthProps = useMonthSegment(state, focusHandlers); + const dayProps = useDaySegment(state, focusHandlers); const [open, setOpen] = useState(false); const [selection, setSelection] = useState(); diff --git a/packages/circuit-ui/components/DateInput/components/Segment.tsx b/packages/circuit-ui/components/DateInput/components/Segment.tsx index 54473d0e19..2d6e2e1ee9 100644 --- a/packages/circuit-ui/components/DateInput/components/Segment.tsx +++ b/packages/circuit-ui/components/DateInput/components/Segment.tsx @@ -15,7 +15,12 @@ 'use client'; -import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'; +import { + useLayoutEffect, + useRef, + useState, + type InputHTMLAttributes, +} from 'react'; import classes from './Segment.module.css'; @@ -23,8 +28,8 @@ export function Segment(props: InputHTMLAttributes) { const sizeRef = useRef(null); const [width, setWidth] = useState('4ch'); - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { + // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: + useLayoutEffect(() => { if (sizeRef.current) { setWidth(`${sizeRef.current.offsetWidth}px`); } diff --git a/packages/circuit-ui/components/DateInput/hooks.ts b/packages/circuit-ui/components/DateInput/hooks.ts index 6e4c04a20a..fd42b6840e 100644 --- a/packages/circuit-ui/components/DateInput/hooks.ts +++ b/packages/circuit-ui/components/DateInput/hooks.ts @@ -14,17 +14,19 @@ */ import { - useCallback, useId, + useMemo, useState, type FormEvent, type InputHTMLAttributes, + type KeyboardEvent, } 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'; +import { isBackspace, isDelete } from '../../util/key-codes.js'; /** * These hooks assume a Gregorian or ISO 8601 calendar: @@ -73,10 +75,35 @@ export function usePlainDateState({ return { date, update, minDate, maxDate }; } +export function useSegment( + focus: FocusHandlers, +): InputHTMLAttributes { + const onKeyDown = (event: KeyboardEvent) => { + const input = event.currentTarget; + + // Focus the following segment after clearing the current one + if (!input.value && !input.validity.badInput) { + if (isBackspace(event)) { + event.preventDefault(); + focus.previous(); + } + + if (isDelete(event)) { + event.preventDefault(); + focus.next(); + } + } + }; + + return { onKeyDown }; +} + export function useYearSegment( state: PlainDateState, - focusNextSegment: () => void, + focus: FocusHandlers, ): InputHTMLAttributes { + const props = useSegment(focus); + if ( state.minDate && state.maxDate && @@ -88,32 +115,30 @@ export function useYearSegment( }; } + const value = state.date.year; + const placeholder = 'yyyy'; 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); + const newValue = 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) : ''; + const year = newValue ? clamp(newValue, min, max) : ''; state.update({ year }); if (year && year > 999) { - focusNextSegment(); + focus.next(); } }; - return { - value: state.date.year, - min, - max, - placeholder: 'yyyy', - onChange, - }; + return { ...props, value, placeholder, min, max, onChange }; } export function useMonthSegment( state: PlainDateState, - focusNextSegment: () => void, + focus: FocusHandlers, ): InputHTMLAttributes { + const props = useSegment(focus); + if ( state.minDate && state.maxDate && @@ -126,6 +151,8 @@ export function useMonthSegment( }; } + const value = state.date.month; + const placeholder = 'mm'; let min = 1; let max = 12; @@ -138,28 +165,26 @@ export function useMonthSegment( } const onChange = (event: FormEvent) => { - const value = Number.parseInt(event.currentTarget.value, 10); + const newValue = 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) : ''; + const month = newValue ? clamp(newValue, min, max) : ''; state.update({ month }); if (month && month > 1) { - focusNextSegment(); + focus.next(); } }; - return { - value: state.date.month, - min, - max, - placeholder: 'mm', - onChange, - }; + return { ...props, value, placeholder, min, max, onChange }; } export function useDaySegment( state: PlainDateState, - focusNextSegment: () => void, + focus: FocusHandlers, ): InputHTMLAttributes { + const props = useSegment(focus); + + const value = state.date.day; + const placeholder = 'dd'; const min = 1; let max = 31; @@ -173,48 +198,61 @@ export function useDaySegment( } const onChange = (event: FormEvent) => { - const value = Number.parseInt(event.currentTarget.value, 10); + const newValue = 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) : ''; + const day = newValue ? clamp(newValue, min, max) : ''; state.update({ day }); if (day && day > 3) { - focusNextSegment(); + focus.next(); } }; - return { - value: state.date.day, - min, - max, - placeholder: 'dd', - onChange, - }; + return { ...props, value, placeholder, min, max, onChange }; } -export function useSegmentFocus(): [Record, () => void] { +type FocusProps = { 'data-focus-list': string }; +type FocusHandlers = { previous: () => void; next: () => void }; + +export function useSegmentFocus(): [FocusProps, FocusHandlers] { 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); + return useMemo(() => { + const getElements = () => { + const elements = document.querySelectorAll( + `[data-focus-list="${name}"]`, + ); + return Array.from(elements); + }; - if (currentIndex === -1) { - return; - } + const getCurrentIndex = (elements: HTMLElement[]) => { + const currentElement = document.activeElement as HTMLElement; + return elements.indexOf(currentElement); + }; - const newIndex = currentIndex + 1; + const previous = () => { + const elements = getElements(); + const currentIndex = getCurrentIndex(elements); + const newIndex = currentIndex - 1; - if (newIndex >= items.length) { - return; - } + if (newIndex < 0) { + return; + } - const newEl = items.item(newIndex); + elements[newIndex].focus(); + }; - newEl.focus(); - }, [name]); + const next = () => { + const elements = getElements(); + const currentIndex = getCurrentIndex(elements); + const newIndex = currentIndex + 1; + + if (newIndex >= elements.length) { + return; + } - return [{ 'data-focus-list': name }, focusNextSegment]; + elements[newIndex].focus(); + }; + + return [{ 'data-focus-list': name }, { previous, next }]; + }, [name]); } diff --git a/packages/circuit-ui/util/key-codes.spec.tsx b/packages/circuit-ui/util/key-codes.spec.tsx index 75acf00061..0a58a72c44 100644 --- a/packages/circuit-ui/util/key-codes.spec.tsx +++ b/packages/circuit-ui/util/key-codes.spec.tsx @@ -20,6 +20,8 @@ import { isArrowLeft, isArrowRight, isArrowUp, + isBackspace, + isDelete, isEnter, isEscape, isSpacebar, @@ -62,6 +64,16 @@ describe('key codes', () => { key: 'ArrowDown', code: 'ArrowDown', }), + backspace: new KeyboardEvent('keydown', { + keyCode: 8, + key: 'Backspace', + code: 'Backspace', + }), + delete: new KeyboardEvent('keydown', { + keyCode: 46, + key: 'Delete', + code: 'Delete', + }), }; describe('isEnter', () => { @@ -147,4 +159,28 @@ describe('key codes', () => { expect(actual).toBeFalsy(); }); }); + + describe('isBackspace', () => { + it('should return true if the backspace key was pressed', () => { + const actual = isBackspace(events.backspace); + expect(actual).toBeTruthy(); + }); + + it('should return false if another key was pressed', () => { + const actual = isBackspace(events.delete); + expect(actual).toBeFalsy(); + }); + }); + + describe('isDelete', () => { + it('should return true if the delete key was pressed', () => { + const actual = isDelete(events.delete); + expect(actual).toBeTruthy(); + }); + + it('should return false if another key was pressed', () => { + const actual = isDelete(events.backspace); + expect(actual).toBeFalsy(); + }); + }); }); diff --git a/packages/circuit-ui/util/key-codes.ts b/packages/circuit-ui/util/key-codes.ts index 3196e17159..42ff490e2d 100644 --- a/packages/circuit-ui/util/key-codes.ts +++ b/packages/circuit-ui/util/key-codes.ts @@ -40,3 +40,10 @@ export const isArrowRight = ( export const isArrowDown = ( event: KeyboardEvent | React.KeyboardEvent, ): boolean => event.key === 'ArrowDown'; + +export const isBackspace = ( + event: KeyboardEvent | React.KeyboardEvent, +): boolean => event.key === 'Backspace'; + +export const isDelete = (event: KeyboardEvent | React.KeyboardEvent): boolean => + event.key === 'Delete'; From 62085d7c095811b63e219721a66c8c9eb43af914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 12 Oct 2024 20:14:59 +0200 Subject: [PATCH 10/31] Sync value between calendar and input --- .../components/DateInput/DateInput.tsx | 20 +++++------- .../circuit-ui/components/DateInput/hooks.ts | 31 +++++++++++++------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index f4f7eb82a0..9adc82aeab 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -173,7 +173,7 @@ export const DateInput = forwardRef( const validationHintId = useId(); const [focusProps, focusHandlers] = useSegmentFocus(); - const state = usePlainDateState({ value, min, max }); + const state = usePlainDateState({ value, min, max, onChange }); const yearProps = useYearSegment(state, focusHandlers); const monthProps = useMonthSegment(state, focusHandlers); const dayProps = useDaySegment(state, focusHandlers); @@ -226,18 +226,22 @@ export const DateInput = forwardRef( setSelection(date); if (!isMobile) { - onChange(date.toString()); + const { year, month, day } = date; + state.update({ year, month, day }); closeCalendar(); } }; const handleApply = () => { - onChange(selection?.toString() || ''); + if (selection) { + const { year, month, day } = selection; + state.update({ year, month, day }); + } closeCalendar(); }; const handleClear = () => { - onChange(''); + state.update({ year: '', month: '', day: '' }); closeCalendar(); }; @@ -304,14 +308,6 @@ export const DateInput = forwardRef( 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? */} diff --git a/packages/circuit-ui/components/DateInput/hooks.ts b/packages/circuit-ui/components/DateInput/hooks.ts index fd42b6840e..ed8c7632d1 100644 --- a/packages/circuit-ui/components/DateInput/hooks.ts +++ b/packages/circuit-ui/components/DateInput/hooks.ts @@ -14,6 +14,8 @@ */ import { + useCallback, + useEffect, useId, useMemo, useState, @@ -32,6 +34,7 @@ import { isBackspace, isDelete } from '../../util/key-codes.js'; * These hooks assume a Gregorian or ISO 8601 calendar: * * - The maximum number of days in a month is 31. + * - The year has to be positive and can have a maximum of 4 digits. */ type PartialPlainDate = { @@ -47,17 +50,14 @@ type PlainDateState = { maxDate?: Temporal.PlainDate; }; -export function usePlainDateState({ - value, - min, - max, -}: { +export function usePlainDateState(props: { value?: string; min?: string; max?: string; + onChange: (date: string) => void; }): PlainDateState { const [date, setDate] = useState(() => { - const plainDate = toPlainDate(value); + const plainDate = toPlainDate(props.value); if (!plainDate) { return { day: '', month: '', year: '' }; } @@ -65,12 +65,23 @@ export function usePlainDateState({ return { year, month, day }; }); - const update = (newDate: PartialPlainDate) => { + const { year, month, day } = date; + + useEffect(() => { + if (isNumber(year) && isNumber(month) && isNumber(day)) { + const plainDate = new Temporal.PlainDate(year, month, day); + props.onChange(plainDate.toString()); + } else { + props.onChange(''); + } + }, [year, month, day, props.onChange]); + + const update = useCallback((newDate: PartialPlainDate) => { setDate((prevDate) => ({ ...prevDate, ...newDate })); - }; + }, []); - const minDate = toPlainDate(min); - const maxDate = toPlainDate(max); + const minDate = toPlainDate(props.min); + const maxDate = toPlainDate(props.max); return { date, update, minDate, maxDate }; } From 07439a50229f1d51200a517ba2f4fcbd61533aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 12 Oct 2024 20:20:26 +0200 Subject: [PATCH 11/31] Support autocomplete attribute --- .../components/DateInput/DateInput.stories.tsx | 1 + packages/circuit-ui/components/DateInput/DateInput.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index c34484d80e..dc0f33d795 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -40,6 +40,7 @@ const baseArgs = { monthInputLabel: 'Month', dayInputLabel: 'Day', locale: 'en-US', + autoComplete: 'bday', // min: '2024-11-14', // max: '2024-11-24', }; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 9adc82aeab..4080497db5 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -159,6 +159,7 @@ export const DateInput = forwardRef( dayInputLabel, className, style, + autoComplete, // ...props }, // ref @@ -341,6 +342,9 @@ export const DateInput = forwardRef( required={required} disabled={disabled} readOnly={readOnly} + autoComplete={ + autoComplete === 'bday' ? 'bday-year' : undefined + } {...focusProps} {...yearProps} /> @@ -353,6 +357,9 @@ export const DateInput = forwardRef( required={required} disabled={disabled} readOnly={readOnly} + autoComplete={ + autoComplete === 'bday' ? 'bday-month' : undefined + } {...focusProps} {...monthProps} /> @@ -365,6 +372,9 @@ export const DateInput = forwardRef( required={required} disabled={disabled} readOnly={readOnly} + autoComplete={ + autoComplete === 'bday' ? 'bday-day' : undefined + } {...focusProps} {...dayProps} /> From 231bc39e6f5e4ac7617cc711c67381ea425c705d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 14 Oct 2024 11:05:51 +0200 Subject: [PATCH 12/31] Do not clamp values --- packages/circuit-ui/components/DateInput/hooks.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/hooks.ts b/packages/circuit-ui/components/DateInput/hooks.ts index ed8c7632d1..76a9a3e9e5 100644 --- a/packages/circuit-ui/components/DateInput/hooks.ts +++ b/packages/circuit-ui/components/DateInput/hooks.ts @@ -26,7 +26,6 @@ import { import { Temporal } from 'temporal-polyfill'; import { toPlainDate } from '../../util/date.js'; -import { clamp } from '../../util/helpers.js'; import { isNumber } from '../../util/type-check.js'; import { isBackspace, isDelete } from '../../util/key-codes.js'; @@ -132,9 +131,7 @@ export function useYearSegment( const max = state.maxDate ? state.maxDate.year : 9999; const onChange = (event: FormEvent) => { - const newValue = Number.parseInt(event.currentTarget.value, 10); - // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 - const year = newValue ? clamp(newValue, min, max) : ''; + const year = Number.parseInt(event.currentTarget.value, 10) || ''; state.update({ year }); if (year && year > 999) { focus.next(); @@ -176,9 +173,7 @@ export function useMonthSegment( } const onChange = (event: FormEvent) => { - const newValue = Number.parseInt(event.currentTarget.value, 10); - // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 - const month = newValue ? clamp(newValue, min, max) : ''; + const month = Number.parseInt(event.currentTarget.value, 10) || ''; state.update({ month }); if (month && month > 1) { focus.next(); @@ -209,9 +204,7 @@ export function useDaySegment( } const onChange = (event: FormEvent) => { - const newValue = Number.parseInt(event.currentTarget.value, 10); - // FIXME: Clamping makes for a confusing user experience, especially with custom min and max dates 🤔 - const day = newValue ? clamp(newValue, min, max) : ''; + const day = Number.parseInt(event.currentTarget.value, 10) || ''; state.update({ day }); if (day && day > 3) { focus.next(); From 46824b6af880af728926d11af8456062f8372007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 14 Oct 2024 11:06:16 +0200 Subject: [PATCH 13/31] Tweak segment styles --- .../components/DateInput/components/Segment.module.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/components/Segment.module.css b/packages/circuit-ui/components/DateInput/components/Segment.module.css index b3d506c0ed..a856539e8e 100644 --- a/packages/circuit-ui/components/DateInput/components/Segment.module.css +++ b/packages/circuit-ui/components/DateInput/components/Segment.module.css @@ -4,11 +4,10 @@ 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); + border-radius: var(--cui-border-radius-byte); transition: background-color var(--cui-transitions-default); } @@ -33,7 +32,6 @@ 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; } From 478bbdf38debd9a3029b78a9f2596cdab4cd6ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 14 Oct 2024 11:06:37 +0200 Subject: [PATCH 14/31] Focus first segment when clicking date field --- .../components/DateInput/DateInput.module.css | 1 + .../components/DateInput/DateInput.tsx | 33 ++++++++++++++----- .../components/Field/Field.module.css | 1 + 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index a6a60d3a1b..3a78f251bf 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -1,6 +1,7 @@ .input { display: flex; justify-content: space-between; + cursor: text; background-color: var(--cui-bg-normal); border: none; border-radius: var(--cui-border-radius-byte); diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 4080497db5..c195e06373 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -21,18 +21,19 @@ 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 type { InputProps } from '../Input/index.js'; -import { IconButton } from '../Button/IconButton.js'; -import { Calendar, type CalendarProps } from '../Calendar/Calendar.js'; +import type { ClickEvent } from '../../types/events.js'; import { useMedia } from '../../hooks/useMedia/useMedia.js'; import { toPlainDate } from '../../util/date.js'; import { AccessibilityError, isSufficientlyLabelled, } from '../../util/errors.js'; -import { Headline } from '../Headline/Headline.js'; -import { CloseButton } from '../CloseButton/CloseButton.js'; +import type { InputProps } from '../Input/index.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, @@ -41,9 +42,8 @@ import { FieldWrapper, } from '../Field/Field.js'; -import classes from './DateInput.module.css'; import { Dialog } from './components/Dialog.js'; -import { getDateSegments } from './DateInputService.js'; +import { Segment } from './components/Segment.js'; import { usePlainDateState, useDaySegment, @@ -51,7 +51,8 @@ import { useYearSegment, useSegmentFocus, } from './hooks.js'; -import { Segment } from './components/Segment.js'; +import { getDateSegments } from './DateInputService.js'; +import classes from './DateInput.module.css'; export interface DateInputProps extends Omit< @@ -214,6 +215,20 @@ export const DateInput = forwardRef( }; }, [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 or the calendar button. + if ( + element.tagName === 'INPUT' || + element.tagName === 'BUTTON' || + element.matches('button :scope') + ) { + return; + } + focusHandlers.next(); + }; + const openCalendar = () => { setSelection(toPlainDate(value) || undefined); setOpen(true); @@ -321,7 +336,7 @@ export const DateInput = forwardRef( readOnly={readOnly} {...props} /> */} -
+
Date: Mon, 14 Oct 2024 14:07:14 +0200 Subject: [PATCH 15/31] Hide the clear button when the input is required --- .../components/DateInput/DateInput.module.css | 4 +++ .../DateInput/DateInput.stories.tsx | 1 + .../components/DateInput/DateInput.tsx | 28 +++++++++++-------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 3a78f251bf..4b187407ff 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -91,6 +91,10 @@ border-top: var(--cui-border-width-kilo) solid var(--cui-border-divider); } +.apply { + margin-left: auto; +} + @media (min-width: 480px) { .apply { display: none; diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index dc0f33d795..1844bb6b7e 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -25,6 +25,7 @@ export default { }, argTypes: { disabled: { control: 'boolean' }, + required: { control: 'boolean' }, }, }; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index c195e06373..a45849aa78 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -463,18 +463,22 @@ export const DateInput = forwardRef( nextMonthButtonLabel={nextMonthButtonLabel} /> -
- - -
+ {(!required || isMobile) && ( +
+ {!required && ( + + )} + +
+ )}
)}
From 45a2d7687abf050371584ff3ee06ed0881816636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Wed, 16 Oct 2024 16:16:22 +0200 Subject: [PATCH 16/31] Install mockdate package --- package-lock.json | 15 +++++++++++++++ packages/circuit-ui/package.json | 1 + 2 files changed, 16 insertions(+) diff --git a/package-lock.json b/package-lock.json index dd5dbb37a6..d2a3f60a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31383,6 +31383,13 @@ "ufo": "^1.5.3" } }, + "node_modules/mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -42417,6 +42424,7 @@ "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "jest-axe": "^9.0.0", + "mockdate": "^3.0.5", "moment": "^2.29.4", "react": "^18.3.1", "react-dates": "^21.8.0", @@ -49530,6 +49538,7 @@ "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "jest-axe": "^9.0.0", + "mockdate": "^3.0.5", "moment": "^2.29.4", "nanostores": "^0.10.3", "react": "^18.3.1", @@ -63854,6 +63863,12 @@ "ufo": "^1.5.3" } }, + "mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json index 697decad36..5a82f04c5d 100644 --- a/packages/circuit-ui/package.json +++ b/packages/circuit-ui/package.json @@ -76,6 +76,7 @@ "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "jest-axe": "^9.0.0", + "mockdate": "^3.0.5", "moment": "^2.29.4", "react": "^18.3.1", "react-dates": "^21.8.0", From 2225481ab950a21b6e7e5337f452c9738ed5a63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Wed, 16 Oct 2024 16:18:11 +0200 Subject: [PATCH 17/31] Fix opening and closing of Dialog --- .../components/DateInput/components/Dialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.tsx index 52f8808924..ebf7f71da6 100644 --- a/packages/circuit-ui/components/DateInput/components/Dialog.tsx +++ b/packages/circuit-ui/components/DateInput/components/Dialog.tsx @@ -74,13 +74,17 @@ export const Dialog = forwardRef( } if (open) { - dialogElement.show(); - } else { + if (!dialogElement.open) { + dialogElement.show(); + } + } else if (dialogElement.open) { dialogElement.close(); } return () => { - dialogElement.close(); + if (dialogElement.open) { + dialogElement.close(); + } }; }, [open]); From cda94ee4426d214d4868af716ca9a983b2566efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Fri, 18 Oct 2024 10:01:11 +0200 Subject: [PATCH 18/31] Implement validation states --- .../components/DateInput/DateInput.module.css | 65 ++++++- .../DateInput/DateInput.stories.tsx | 75 +++++++- .../components/DateInput/DateInput.tsx | 46 +++-- .../DateInput/components/Dialog.module.css | 8 +- .../DateInput/components/Segment.module.css | 34 ++-- .../DateInput/components/Segment.tsx | 26 ++- .../circuit-ui/components/DateInput/hooks.ts | 177 ++++++++++++------ packages/circuit-ui/util/date.ts | 10 + 8 files changed, 348 insertions(+), 93 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 4b187407ff..f9f919d28d 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -3,21 +3,66 @@ justify-content: space-between; cursor: text; background-color: var(--cui-bg-normal); - border: none; + border: 1px solid var(--cui-border-normal); border-radius: var(--cui-border-radius-byte); outline: 0; - box-shadow: 0 0 0 1px var(--cui-border-normal); + box-shadow: none; 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); + border-color: var(--cui-border-normal-hovered); } .input:focus-within { - box-shadow: 0 0 0 2px var(--cui-border-accent); + border-color: var(--cui-border-accent); + box-shadow: 0 0 0 1px var(--cui-border-accent); +} + +.invalid { + border-color: var(--cui-border-danger); +} + +.invalid:hover { + border-color: var(--cui-border-danger-hovered); +} + +.invalid:focus-within { + border-color: var(--cui-border-danger); + box-shadow: 0 0 0 1px var(--cui-border-danger); +} + +.invalid:not(:focus-within):not([disabled])::placeholder { + color: var(--cui-fg-danger); +} + +.warning { + border-color: var(--cui-border-warning); +} + +.warning:hover { + border-color: var(--cui-border-warning-hovered); +} + +.warning:focus-within { + border-color: var(--cui-border-warning); + box-shadow: 0 0 0 1px var(--cui-border-warning); +} + +.warning:not(:focus-within):not([disabled])::placeholder { + color: var(--cui-fg-warning); +} + +:global([data-disabled="true"]) .input { + color: var(--cui-fg-normal-disabled); + background-color: var(--cui-bg-normal-disabled); + border-color: var(--cui-border-normal-disabled); +} + +.readonly { + background-color: var(--cui-bg-subtle-disabled); } .segments { @@ -28,8 +73,12 @@ .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); + font-size: var(--cui-body-m-font-size); + line-height: var(--cui-body-m-line-height); +} + +.readonly .literal { + color: var(--cui-fg-subtle); } .calendar-button { @@ -39,6 +88,10 @@ border-bottom-left-radius: 0 !important; } +.calendar-button:focus { + box-shadow: inset 0 0 0 2px var(--cui-border-focus); +} + .content { color: var(--cui-fg-normal); background-color: var(--cui-bg-elevated); diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index 1844bb6b7e..ae2a49c4e7 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -15,6 +15,8 @@ import { useState } from 'react'; +import { Stack } from '../../../../.storybook/components/index.js'; + import { DateInput, type DateInputProps } from './DateInput.js'; export default { @@ -29,21 +31,21 @@ export default { }, }; +// Fun fact: Circuit UI was created on August 28, 2017 + const baseArgs = { label: 'Date of birth', prevMonthButtonLabel: 'Previous month', nextMonthButtonLabel: 'Previous month', openCalendarButtonLabel: 'Change date', - closeCalendarButtonLabel: 'Close', + closeCalendarButtonLabel: 'Close calendar', applyDateButtonLabel: 'Apply', clearDateButtonLabel: 'Clear', yearInputLabel: 'Year', monthInputLabel: 'Month', dayInputLabel: 'Day', - locale: 'en-US', autoComplete: 'bday', - // min: '2024-11-14', - // max: '2024-11-24', + locale: 'en-US', }; export const Base = (args: DateInputProps) => { @@ -52,3 +54,68 @@ export const Base = (args: DateInputProps) => { }; Base.args = baseArgs; + +export const Validations = (args: DateInputProps) => ( + + + + + + + + + + + +); + +Validations.args = baseArgs; + +export const Optional = (args: DateInputProps) => ; + +Optional.args = { + ...baseArgs, + optionalLabel: 'optional', +}; + +export const Readonly = (args: DateInputProps) => ; + +Readonly.args = { + ...baseArgs, + label: 'Appointment date', + value: '2017-08-28', + readOnly: true, +}; + +export const Disabled = (args: DateInputProps) => ; + +Disabled.args = { + ...baseArgs, + value: '2017-08-28', + disabled: true, +}; diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index a45849aa78..553086ddb6 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -28,6 +28,7 @@ import { AccessibilityError, isSufficientlyLabelled, } from '../../util/errors.js'; +import { clsx } from '../../styles/clsx.js'; import type { InputProps } from '../Input/index.js'; import { Calendar, type CalendarProps } from '../Calendar/Calendar.js'; import { Button } from '../Button/Button.js'; @@ -94,6 +95,11 @@ export interface DateInputProps * format (`YYYY-MM-DD`). */ value?: string; + /** + * The initially selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + defaultValue?: 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. @@ -129,11 +135,12 @@ export interface DateInputProps * DateInput component for forms. * The input value is always a string in the format `YYYY-MM-DD`. */ -export const DateInput = forwardRef( +export const DateInput = forwardRef( ( { label, value, + defaultValue, onChange, min, max, @@ -161,9 +168,10 @@ export const DateInput = forwardRef( className, style, autoComplete, + // TODO: // ...props }, - // ref + ref, ) => { const isMobile = useMedia('(max-width: 479px)'); @@ -174,11 +182,14 @@ export const DateInput = forwardRef( const headlineId = useId(); const validationHintId = useId(); + const minDate = toPlainDate(min); + const maxDate = toPlainDate(max); + const [focusProps, focusHandlers] = useSegmentFocus(); - const state = usePlainDateState({ value, min, max, onChange }); - const yearProps = useYearSegment(state, focusHandlers); - const monthProps = useMonthSegment(state, focusHandlers); - const dayProps = useDaySegment(state, focusHandlers); + const state = usePlainDateState(value, defaultValue, onChange); + const yearProps = useYearSegment(state, focusHandlers, minDate, maxDate); + const monthProps = useMonthSegment(state, focusHandlers, minDate, maxDate); + const dayProps = useDaySegment(state, focusHandlers, minDate, maxDate); const [open, setOpen] = useState(false); const [selection, setSelection] = useState(); @@ -230,6 +241,7 @@ export const DateInput = forwardRef( }; const openCalendar = () => { + // TODO: Focus the calendar setSelection(toPlainDate(value) || undefined); setOpen(true); }; @@ -336,7 +348,7 @@ export const DateInput = forwardRef( readOnly={readOnly} {...props} /> */} -
+
( optionalLabel={optionalLabel} /> -
+
{segments.map((segment, index) => { switch (segment.type) { @@ -355,6 +375,7 @@ export const DateInput = forwardRef( key={segment.type} aria-label={yearInputLabel} required={required} + invalid={invalid} disabled={disabled} readOnly={readOnly} autoComplete={ @@ -370,6 +391,7 @@ export const DateInput = forwardRef( key={segment.type} aria-label={monthInputLabel} required={required} + invalid={invalid} disabled={disabled} readOnly={readOnly} autoComplete={ @@ -385,6 +407,7 @@ export const DateInput = forwardRef( key={segment.type} aria-label={dayInputLabel} required={required} + invalid={invalid} disabled={disabled} readOnly={readOnly} autoComplete={ @@ -414,6 +437,7 @@ export const DateInput = forwardRef( variant="secondary" onClick={openCalendar} className={classes['calendar-button']} + disabled={disabled || readOnly} > {calendarButtonLabel} @@ -437,7 +461,7 @@ export const DateInput = forwardRef( {() => (
- + {label} ( className={classes.calendar} onSelect={handleSelect} selection={selection} - minDate={state.minDate} - maxDate={state.maxDate} + minDate={minDate} + maxDate={maxDate} locale={locale} firstDayOfWeek={firstDayOfWeek} modifiers={modifiers} diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.module.css b/packages/circuit-ui/components/DateInput/components/Dialog.module.css index 47eab46c2f..a109f39298 100644 --- a/packages/circuit-ui/components/DateInput/components/Dialog.module.css +++ b/packages/circuit-ui/components/DateInput/components/Dialog.module.css @@ -35,9 +35,7 @@ } .backdrop { - pointer-events: none; - visibility: hidden; - opacity: 0; + display: none; } @media (max-width: 479px) { @@ -47,9 +45,13 @@ right: 0; bottom: 0; left: 0; + display: block; width: 100%; height: 100%; + pointer-events: none; + visibility: hidden; background-color: var(--cui-bg-overlay); + opacity: 0; transition: opacity var(--cui-transitions-default), visibility var(--cui-transitions-default); diff --git a/packages/circuit-ui/components/DateInput/components/Segment.module.css b/packages/circuit-ui/components/DateInput/components/Segment.module.css index a856539e8e..f8272106ea 100644 --- a/packages/circuit-ui/components/DateInput/components/Segment.module.css +++ b/packages/circuit-ui/components/DateInput/components/Segment.module.css @@ -1,37 +1,49 @@ -.input { +.base { width: calc(var(--width) + 2 * var(--cui-spacings-bit)); padding: var(--cui-spacings-bit); - font-size: var(--cui-typography-body-m-font-size); + font-size: var(--cui-body-m-font-size); font-variant-numeric: tabular-nums; - line-height: var(--cui-typography-body-m-line-height); + line-height: var(--cui-body-m-line-height); appearance: textfield; - background-color: var(--cui-bg-normal); + background-color: transparent; border: none; border-radius: var(--cui-border-radius-byte); transition: background-color var(--cui-transitions-default); } -.input::-webkit-outer-spin-button, -.input::-webkit-inner-spin-button { +.base::-webkit-outer-spin-button, +.base::-webkit-inner-spin-button { margin: 0; appearance: none; } -.input:focus { +.base::placeholder { + color: var(--cui-fg-placeholder); +} + +.base[aria-invalid="true"]:not(:focus):not([disabled])::placeholder { + color: var(--cui-fg-danger); +} + +.base:focus { background-color: var(--cui-bg-highlight); outline: none; } -.input:read-only { +.base:read-only { + color: var(--cui-fg-subtle); +} + +.base:disabled, +.base[disabled] { 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-size: var(--cui-body-m-font-size); font-variant-numeric: tabular-nums; - line-height: var(--cui-typography-body-m-line-height); + line-height: var(--cui-body-m-line-height); 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 index 2d6e2e1ee9..ae800a7ce5 100644 --- a/packages/circuit-ui/components/DateInput/components/Segment.tsx +++ b/packages/circuit-ui/components/DateInput/components/Segment.tsx @@ -24,11 +24,26 @@ import { import classes from './Segment.module.css'; -export function Segment(props: InputHTMLAttributes) { +export interface SegmentProps extends InputHTMLAttributes { + /** + * Triggers error styles on the component. Important for accessibility. + */ + invalid?: boolean; + /** + * Triggers warning styles on the component. + */ + hasWarning?: boolean; + /** + * Enables valid styles on the component. + */ + showValid?: boolean; +} + +export function Segment({ invalid, ...props }: SegmentProps) { const sizeRef = useRef(null); const [width, setWidth] = useState('4ch'); - // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: + // biome-ignore lint/correctness/useExhaustiveDependencies: The width needs to be recalculated when the value changes useLayoutEffect(() => { if (sizeRef.current) { setWidth(`${sizeRef.current.offsetWidth}px`); @@ -36,10 +51,11 @@ export function Segment(props: InputHTMLAttributes) { }, [props.value]); return ( -
+ <> ) { -
+ ); } diff --git a/packages/circuit-ui/components/DateInput/hooks.ts b/packages/circuit-ui/components/DateInput/hooks.ts index 76a9a3e9e5..633379b2ae 100644 --- a/packages/circuit-ui/components/DateInput/hooks.ts +++ b/packages/circuit-ui/components/DateInput/hooks.ts @@ -25,9 +25,17 @@ import { } from 'react'; import { Temporal } from 'temporal-polyfill'; -import { toPlainDate } from '../../util/date.js'; -import { isNumber } from '../../util/type-check.js'; +import { + MAX_MONTH, + MAX_YEAR, + MIN_DAY, + MIN_MONTH, + MIN_YEAR, + toPlainDate, +} from '../../util/date.js'; +import { isNumber, isString } from '../../util/type-check.js'; import { isBackspace, isDelete } from '../../util/key-codes.js'; +import { clamp } from '../../util/helpers.js'; /** * These hooks assume a Gregorian or ISO 8601 calendar: @@ -45,44 +53,74 @@ type PartialPlainDate = { type PlainDateState = { date: PartialPlainDate; update: (date: PartialPlainDate) => void; - minDate?: Temporal.PlainDate; - maxDate?: Temporal.PlainDate; }; -export function usePlainDateState(props: { - value?: string; - min?: string; - max?: string; - onChange: (date: string) => void; -}): PlainDateState { - const [date, setDate] = useState(() => { - const plainDate = toPlainDate(props.value); - if (!plainDate) { - return { day: '', month: '', year: '' }; - } - const { year, month, day } = plainDate; - return { year, month, day }; - }); +function parseValue(value?: string): PartialPlainDate { + const plainDate = toPlainDate(value); + if (!plainDate) { + return { day: '', month: '', year: '' }; + } + const { year, month, day } = plainDate; + return { year, month, day }; +} - const { year, month, day } = date; +export function usePlainDateState( + value?: string, + defaultValue?: string, + onChange?: (date: string) => void, +): PlainDateState { + const [date, setDate] = useState(() => + parseValue(defaultValue || value), + ); useEffect(() => { - if (isNumber(year) && isNumber(month) && isNumber(day)) { - const plainDate = new Temporal.PlainDate(year, month, day); - props.onChange(plainDate.toString()); - } else { - props.onChange(''); + if (isString(value)) { + setDate(parseValue(value)); } - }, [year, month, day, props.onChange]); - - const update = useCallback((newDate: PartialPlainDate) => { - setDate((prevDate) => ({ ...prevDate, ...newDate })); - }, []); - - const minDate = toPlainDate(props.min); - const maxDate = toPlainDate(props.max); - - return { date, update, minDate, maxDate }; + }, [value]); + + const update = useCallback( + (newDate: PartialPlainDate) => { + setDate((prevDate) => { + let year: PartialPlainDate['year'] = + (newDate.year ?? prevDate.year) || ''; + + if (isNumber(year)) { + year = clamp(year, MIN_YEAR, MAX_YEAR); + } + + let month: PartialPlainDate['month'] = + (newDate.month ?? prevDate.month) || ''; + + if (isNumber(month)) { + month = clamp(month, MIN_MONTH, MAX_MONTH); + } + + let day: PartialPlainDate['day'] = (newDate.day ?? prevDate.day) || ''; + + if (isNumber(day)) { + let maxDay = 31; + if (isNumber(year) && year > 999 && isNumber(month)) { + const plainYearMonth = new Temporal.PlainYearMonth(year, month); + maxDay = plainYearMonth.daysInMonth; + } + day = clamp(day, MIN_DAY, maxDay); + } + + if (isNumber(year) && year > 999 && isNumber(month) && isNumber(day)) { + const plainDate = new Temporal.PlainDate(year, month, day); + onChange?.(plainDate.toString()); + } else { + onChange?.(''); + } + + return { year, month, day }; + }); + }, + [onChange], + ); + + return { date, update }; } export function useSegment( @@ -111,24 +149,24 @@ export function useSegment( export function useYearSegment( state: PlainDateState, focus: FocusHandlers, + minDate?: Temporal.PlainDate, + maxDate?: Temporal.PlainDate, ): InputHTMLAttributes { const props = useSegment(focus); - if ( - state.minDate && - state.maxDate && - state.minDate.year === state.maxDate.year - ) { + if (minDate && maxDate && minDate.year === maxDate.year) { + // FIXME: Set value in state return { - value: state.minDate.year, + value: minDate.year, readOnly: true, }; } const value = state.date.year; const placeholder = 'yyyy'; - const min = state.minDate ? state.minDate.year : 1; - const max = state.maxDate ? state.maxDate.year : 9999; + + const min = minDate ? minDate.year : 1; + const max = maxDate ? maxDate.year : 9999; const onChange = (event: FormEvent) => { const year = Number.parseInt(event.currentTarget.value, 10) || ''; @@ -144,32 +182,38 @@ export function useYearSegment( export function useMonthSegment( state: PlainDateState, focus: FocusHandlers, + minDate?: Temporal.PlainDate, + maxDate?: Temporal.PlainDate, ): InputHTMLAttributes { const props = useSegment(focus); if ( - state.minDate && - state.maxDate && - state.minDate.year === state.maxDate.year && - state.minDate.month === state.maxDate.month + minDate && + maxDate && + minDate.year === maxDate.year && + minDate.month === maxDate.month ) { + // FIXME: Set value in state return { - value: state.minDate.month, + value: minDate.month, readOnly: true, }; } const value = state.date.month; const placeholder = 'mm'; + let min = 1; let max = 12; - if (state.minDate && state.minDate.year === state.date.year) { - min = state.minDate.month; + const sameYear = minDate && maxDate && minDate.year === maxDate.year; + + if (sameYear || (minDate && minDate.year === state.date.year)) { + min = minDate.month; } - if (state.maxDate && state.maxDate.year === state.date.year) { - max = state.maxDate.month; + if (sameYear || (maxDate && maxDate.year === state.date.year)) { + max = maxDate.month; } const onChange = (event: FormEvent) => { @@ -186,21 +230,48 @@ export function useMonthSegment( export function useDaySegment( state: PlainDateState, focus: FocusHandlers, + minDate?: Temporal.PlainDate, + maxDate?: Temporal.PlainDate, ): InputHTMLAttributes { const props = useSegment(focus); const value = state.date.day; const placeholder = 'dd'; - const min = 1; + + let min = 1; let max = 31; + if ( + minDate && + maxDate && + minDate.year === maxDate.year && + minDate.month === maxDate.month + ) { + min = minDate.day; + max = maxDate.day; + } + 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 + + if ( + minDate && + minDate.year === state.date.year && + minDate.month === state.date.month + ) { + min = minDate.day; + } + if ( + maxDate && + maxDate.year === state.date.year && + maxDate.month === state.date.month + ) { + max = maxDate.day; + } } const onChange = (event: FormEvent) => { diff --git a/packages/circuit-ui/util/date.ts b/packages/circuit-ui/util/date.ts index 4c22f15322..db3de16b71 100644 --- a/packages/circuit-ui/util/date.ts +++ b/packages/circuit-ui/util/date.ts @@ -22,6 +22,16 @@ export type PlainDateRange = | [Temporal.PlainDate] | [Temporal.PlainDate, Temporal.PlainDate]; +// ISO 8601 timestamps only support positive 4-digit years +export const MIN_YEAR = 1; +export const MAX_YEAR = 9999; + +export const MIN_MONTH = 1; +export const MAX_MONTH = 12; + +export const MIN_DAY = 1; +// MAX_DAY is not constant as it depends on the year and month + export function getTodaysDate() { return Temporal.Now.plainDateISO(); } From 413466a80b6fe47b3b605fd442732c5d56b0894f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Fri, 18 Oct 2024 10:05:38 +0200 Subject: [PATCH 19/31] WIP tests Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> --- .../components/DateInput/DateInput.spec.tsx | 136 +++++++++++++++--- .../DateInput/DateInputService.spec.ts | 2 +- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx index 1bdbd5be3b..2ede39fcf7 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -13,16 +13,11 @@ * limitations under the License. */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createRef } from 'react'; +import MockDate from 'mockdate'; -import { - render, - screen, - axe, - userEvent, - fireEvent, -} from '../../util/test-utils.js'; +import { render, screen, axe, userEvent } from '../../util/test-utils.js'; import { DateInput } from './DateInput.js'; @@ -36,25 +31,43 @@ describe('DateInput', () => { closeCalendarButtonLabel: 'Close', applyDateButtonLabel: 'Apply', clearDateButtonLabel: 'Clear', + yearInputLabel: 'Year', + monthInputLabel: 'Month', + dayInputLabel: 'Day', }; + beforeAll(() => { + MockDate.set('2000-01-01'); + }); + + // TODO: Move ref to outermost div? it('should forward a ref', () => { - const ref = createRef(); + const ref = createRef(); render(); - const input = screen.getByRole('textbox'); - expect(ref.current).toBe(input); + const fieldset = screen.getByRole('group'); + expect(ref.current).toBe(fieldset); + }); + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + const { container } = render( + , + ); + // eslint-disable-next-line testing-library/no-container + const wrapper = container.querySelectorAll('div')[0]; + expect(wrapper?.className).toContain(className); }); it('should select a calendar date', async () => { - render(); + const onChange = vi.fn(); + + render(); - const input: HTMLInputElement = screen.getByRole('textbox'); const openCalendarButton = screen.getByRole('button', { name: /change date/i, }); - // For some reason, userEvent doesn't work here. - fireEvent.click(openCalendarButton); + await userEvent.click(openCalendarButton); const calendarDialog = screen.getByRole('dialog'); @@ -64,7 +77,98 @@ describe('DateInput', () => { await userEvent.click(dateButton); - expect(input).toHaveValue('2024-10-12'); + expect(onChange).toHaveBeenCalledWith('2000-01-12'); + }); + + it('should display the initial value correctly', () => { + render(); + + expect(screen.getByLabelText(/day/i)).toHaveValue(12); + expect(screen.getByLabelText(/month/i)).toHaveValue(1); + expect(screen.getByLabelText(/year/i)).toHaveValue(2000); + }); + + it('should render a disabled input', () => { + render(); + expect(screen.getByLabelText(/day/i)).toBeDisabled(); + expect(screen.getByLabelText(/month/i)).toBeDisabled(); + expect(screen.getByLabelText(/year/i)).toBeDisabled(); + expect( + screen.getByRole('button', { name: baseProps.openCalendarButtonLabel }), + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should handle min/max dates', () => { + render(); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1'); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '31'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '12'); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('min', '2000'); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('max', '2001'); + }); + + it('should handle min/max dates as the user types year', async () => { + render(); + + await userEvent.type(screen.getByLabelText(/year/i), '2001'); + /* expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1'); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '1'); */ + expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '2'); + }); + + it('should handle min/max dates as the user types year and month', async () => { + render(); + + await userEvent.type(screen.getByLabelText(/year/i), '2001'); + await userEvent.type(screen.getByLabelText(/month/i), '02'); + + expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1'); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '15'); + }); + + it('years field should be readonly if min/max dates have the same year', () => { + render(); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '4'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '6'); + }); + + it('years and months fields should render as readonly if min/max dates have the same year and same month', () => { + render(); + expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly'); + expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly'); + + expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '9'); + expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '27'); + }); + + 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 () => { diff --git a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts index 5ef0c1fa37..7925d1a052 100644 --- a/packages/circuit-ui/components/DateInput/DateInputService.spec.ts +++ b/packages/circuit-ui/components/DateInput/DateInputService.spec.ts @@ -19,7 +19,7 @@ import { getDateSegments } from './DateInputService.js'; describe('DateInputService', () => { describe('getDateSegments', () => { - it('should', () => { + it.todo('should', () => { const actual = getDateSegments(); expect(actual).toBe('TODO:'); }); From ca14e333e77d83b329fdb2d7135b80dffba3075a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Sat, 19 Oct 2024 13:36:38 +0200 Subject: [PATCH 20/31] Align styles with other composite inputs --- .../components/DateInput/DateInput.module.css | 42 +++++++++++-------- .../components/DateInput/DateInput.tsx | 23 +++++----- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index f9f919d28d..623b68465c 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -1,10 +1,18 @@ -.input { +.wrapper { display: flex; - justify-content: space-between; +} + +.segments { + position: relative; + z-index: var(--cui-z-index-absolute); + display: flex; + gap: 2px; + padding: var(--cui-spacings-byte) var(--cui-spacings-mega); cursor: text; background-color: var(--cui-bg-normal); border: 1px solid var(--cui-border-normal); - border-radius: var(--cui-border-radius-byte); + border-top-left-radius: var(--cui-border-radius-byte); + border-bottom-left-radius: var(--cui-border-radius-byte); outline: 0; box-shadow: none; transition: @@ -12,13 +20,13 @@ padding var(--cui-transitions-default); } -.input:hover { +.segments:hover { border-color: var(--cui-border-normal-hovered); } -.input:focus-within { +.segments:focus-within { border-color: var(--cui-border-accent); - box-shadow: 0 0 0 1px var(--cui-border-accent); + box-shadow: inset 0 0 0 1px var(--cui-border-accent); } .invalid { @@ -31,7 +39,7 @@ .invalid:focus-within { border-color: var(--cui-border-danger); - box-shadow: 0 0 0 1px var(--cui-border-danger); + box-shadow: inset 0 0 0 1px var(--cui-border-danger); } .invalid:not(:focus-within):not([disabled])::placeholder { @@ -48,7 +56,7 @@ .warning:focus-within { border-color: var(--cui-border-warning); - box-shadow: 0 0 0 1px var(--cui-border-warning); + box-shadow: inset 0 0 0 1px var(--cui-border-warning); } .warning:not(:focus-within):not([disabled])::placeholder { @@ -65,12 +73,6 @@ background-color: var(--cui-bg-subtle-disabled); } -.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-body-m-font-size); @@ -82,14 +84,16 @@ } .calendar-button { - border: none; - border-left: 1px solid var(--cui-border-normal); + position: relative; border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; + transform: translateX(-1px); } .calendar-button:focus { - box-shadow: inset 0 0 0 2px var(--cui-border-focus); + z-index: calc(var(--cui-z-index-absolute) + 1); + border-color: var(--cui-border-focus); + box-shadow: inset 0 0 0 1px var(--cui-border-focus); } .content { @@ -129,6 +133,10 @@ white-space: nowrap; border: 0; } + + .close-button { + display: none; + } } .calendar { diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 553086ddb6..880acf933e 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -196,7 +196,7 @@ export const DateInput = forwardRef( const { floatingStyles, update } = useFloating({ open, - placement: 'bottom-end', + placement: 'bottom-start', middleware: [offset(4), flip(), shift()], elements: { reference: referenceRef.current, @@ -357,16 +357,16 @@ export const DateInput = forwardRef( optionalLabel={optionalLabel} /> -
-
+
+
{segments.map((segment, index) => { switch (segment.type) { case 'year': @@ -468,6 +468,7 @@ export const DateInput = forwardRef( size="s" variant="tertiary" onClick={closeCalendar} + className={classes['close-button']} > {closeCalendarButtonLabel} From 9dfe7e691d71c9e7ad55492fac67b589c0649cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 21 Oct 2024 13:15:59 +0200 Subject: [PATCH 21/31] Add basic documentation --- .../components/DateInput/DateInput.mdx | 46 ++++++++-- .../components/DateInput/DateInput.tsx | 88 +++++++++++-------- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/packages/circuit-ui/components/DateInput/DateInput.mdx b/packages/circuit-ui/components/DateInput/DateInput.mdx index 6b05320c2a..c785ff972b 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.mdx +++ b/packages/circuit-ui/components/DateInput/DateInput.mdx @@ -7,13 +7,45 @@ import * as Stories from './DateInput.stories'; +The DateInput component allows user to type or select a specific date. The input value is always a string in the format `YYYY-MM-DD`. + -- can't use `input[type="date"]` because of accessibility issues and lack of customization -- date range selection -- closing the popover after date selection -- free-form input -- autocomplete -- context on mobile dialog: field name (and current value?) -- abstract away dialog? -> leave for later once re-implementing the Modal components +## Usage + +Use the component whenever asking for a specific individual date such as a birth date, expiry date, or appointment date. + +For selecting a range of dates, we expect to introduce an iteration of this component in the future. Until then, we recommend using a combination of two input fields (one for the start and the other for the end date). + +## Validations + +TODO: Decide on built-in validation. + +Use the `validationHint` to communicate the expected response to users. + +### Invalid + +The user needs to change the value to proceed. This could be due to a + +### Warning + +The user is recommended to change the value, but can proceed without doing so. Use it when the provided value could have unintended side-effects, such as a date in the far future. + +### Valid + +The user is reassured that the value is valid. Use sparingly. + + + +## Optional + +Use the `optionalLabel` prop to indicate that the field is optional. This can help reduce the cognitive load for the user by clearly indicating which fields are required and which are not. This label is only displayed when the `required` prop is falsy. + + + +## Readonly + +Use the `readOnly` prop to indicate that the field is not currently editable. This can be useful in situations where the user needs to view but not edit the date, such as in a summary or review screen. + + diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 880acf933e..f92bd719d0 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -15,7 +15,14 @@ 'use client'; -import { forwardRef, useEffect, useId, useRef, useState } from 'react'; +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'; @@ -56,15 +63,19 @@ import { getDateSegments } from './DateInputService.js'; import classes from './DateInput.module.css'; export interface DateInputProps - extends Omit< + extends Omit, 'onChange'>, + Pick< InputProps, - | 'type' - | 'onChange' - | 'value' - | 'defaultValue' - | 'placeholder' - | 'as' - | 'renderSuffix' + | 'label' + | 'hideLabel' + | 'invalid' + | 'hasWarning' + | 'showValid' + | 'required' + | 'disabled' + | 'readOnly' + | 'validationHint' + | 'optionalLabel' >, Pick< CalendarProps, @@ -74,6 +85,28 @@ export interface DateInputProps | 'nextMonthButtonLabel' | 'modifiers' > { + /** + * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + value?: string; + /** + * The initially selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) + * format (`YYYY-MM-DD`). + */ + defaultValue?: string; + /** + * Visually hidden label for the year input. + */ + yearInputLabel: string; + /** + * Visually hidden label for the month input. + */ + monthInputLabel: string; + /** + * Visually hidden label for the day input. + */ + dayInputLabel: string; /** * Label for the trailing button that opens the calendar dialog. */ @@ -90,16 +123,6 @@ export interface DateInputProps * Label for the button to clear the date value and close the calendar dialog. */ clearDateButtonLabel: string; - /** - * The currently selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) - * format (`YYYY-MM-DD`). - */ - value?: string; - /** - * The initially selected date in the [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) - * format (`YYYY-MM-DD`). - */ - defaultValue?: 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. @@ -118,24 +141,16 @@ export interface DateInputProps */ max?: string; /** - * TODO: - */ - yearInputLabel: string; - /** - * TODO: + * A hint to the user agent specifying how to prefill the input. */ - monthInputLabel: string; - /** - * TODO: - */ - dayInputLabel: string; + autoComplete?: 'bday'; } /** - * DateInput component for forms. + * The DateInput component allows user to type or select a specific date. * The input value is always a string in the format `YYYY-MM-DD`. */ -export const DateInput = forwardRef( +export const DateInput = forwardRef( ( { label, @@ -165,11 +180,8 @@ export const DateInput = forwardRef( yearInputLabel, monthInputLabel, dayInputLabel, - className, - style, autoComplete, - // TODO: - // ...props + ...props }, ref, ) => { @@ -241,7 +253,6 @@ export const DateInput = forwardRef( }; const openCalendar = () => { - // TODO: Focus the calendar setSelection(toPlainDate(value) || undefined); setOpen(true); }; @@ -337,7 +348,7 @@ export const DateInput = forwardRef( const segments = getDateSegments(locale); return ( - + {/* TODO: Replicate native date input for uncontrolled inputs? */} {/* ( readOnly={readOnly} {...props} /> */} -
+
( case 'literal': return (
{ describe('getDateSegments', () => { @@ -43,4 +44,29 @@ describe('DateInputService', () => { expect(literalSegment?.value).toBe(literal); }); }); + + describe('getCalendarButtonLabel', () => { + const label = 'Change date'; + + it('should return the plain label if the date is undefined', () => { + const date = undefined; + const locale = undefined; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(label); + }); + + it('should postfix the formatted date to the label', () => { + const date = new Temporal.PlainDate(2017, 8, 28); + const locale = undefined; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(`${label}, August 28, 2017`); + }); + + it('should format the date for the locale', () => { + const date = new Temporal.PlainDate(2017, 8, 28); + const locale = 'fr-FR'; + const actual = getCalendarButtonLabel(label, date, locale); + expect(actual).toBe(`${label}, 28 août 2017`); + }); + }); }); diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx new file mode 100644 index 0000000000..f9ba53fa69 --- /dev/null +++ b/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx @@ -0,0 +1,144 @@ +/** + * Copyright 2014, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRef } from 'react'; + +import { render, screen, axe, userEvent } from '../../../util/test-utils.js'; + +import { Dialog } from './Dialog.js'; + +describe('Dialog', () => { + const props = { + onClose: vi.fn(), + open: false, + children: vi.fn(() =>
), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should forward a ref', () => { + const ref = createRef(); + const { container } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog'); + expect(ref.current).toBe(dialog); + }); + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + const { container } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog'); + expect(dialog?.className).toContain(className); + }); + + it('should open the dialog when the open prop becomes truthy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'show'); + rerender(); + expect(dialog.show).toHaveBeenCalledOnce(); + }); + + it('should open the dialog as a modal when the open prop becomes truthy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'showModal'); + rerender(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + }); + + it('should re-open the dialog as a modal when the isModal prop changes', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + vi.spyOn(dialog, 'showModal'); + rerender(); + expect(dialog.close).toHaveBeenCalledOnce(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when the open prop becomes falsy', () => { + const { container, rerender } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + rerender(); + expect(dialog.close).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when the component is unmounted', () => { + const { container, unmount } = render(); + // eslint-disable-next-line testing-library/no-container + const dialog = container.querySelector('dialog') as HTMLDialogElement; + vi.spyOn(dialog, 'close'); + unmount(); + expect(dialog.close).toHaveBeenCalledOnce(); + }); + + describe('when the dialog is closed', () => { + it('should not render its children', () => { + render(); + const children = screen.queryByTestId('children'); + expect(props.children).not.toHaveBeenCalled(); + expect(children).not.toBeInTheDocument(); + }); + + it('should do nothing when pressing the Escape key', async () => { + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + it('should do nothing when pressing outside the dialog', async () => { + const { container } = render(); + await userEvent.click(container); + expect(props.onClose).not.toHaveBeenCalled(); + }); + }); + + describe('when the dialog is open', () => { + it('should render its children', () => { + render(); + const children = screen.getByTestId('children'); + expect(props.children).toHaveBeenCalledOnce(); + expect(children).toBeVisible(); + }); + + it('should close the dialog when pressing the Escape key', async () => { + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + + it('should close the dialog when pressing outside the dialog', async () => { + const { container } = render(); + await userEvent.click(container); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.tsx index ebf7f71da6..5692c94d01 100644 --- a/packages/circuit-ui/components/DateInput/components/Dialog.tsx +++ b/packages/circuit-ui/components/DateInput/components/Dialog.tsx @@ -35,12 +35,13 @@ import classes from './Dialog.module.css'; export interface DialogProps extends Omit, 'children'> { open: boolean; + isModal?: boolean; onClose: () => void; children: () => ReactNode; } export const Dialog = forwardRef( - ({ children, open, onClose, className, style, ...props }, ref) => { + ({ children, open, onClose, className, style, isModal, ...props }, ref) => { const zIndex = useStackContext(); const dialogRef = useRef(null); @@ -65,7 +66,6 @@ export const Dialog = forwardRef( }; }, [onClose]); - // TODO: modal useEffect(() => { const dialogElement = dialogRef.current; @@ -75,7 +75,11 @@ export const Dialog = forwardRef( if (open) { if (!dialogElement.open) { - dialogElement.show(); + if (isModal) { + dialogElement.showModal(); + } else { + dialogElement.show(); + } } } else if (dialogElement.open) { dialogElement.close(); @@ -86,7 +90,7 @@ export const Dialog = forwardRef( dialogElement.close(); } }; - }, [open]); + }, [open, isModal]); return ( <> diff --git a/packages/circuit-ui/components/SidePanel/SidePanelContext.spec.tsx b/packages/circuit-ui/components/SidePanel/SidePanelContext.spec.tsx index 62b50c1c3b..2bb1d3708d 100644 --- a/packages/circuit-ui/components/SidePanel/SidePanelContext.spec.tsx +++ b/packages/circuit-ui/components/SidePanel/SidePanelContext.spec.tsx @@ -45,7 +45,7 @@ import { type SidePanelContextProps, } from './SidePanelContext.js'; -vi.mock('../../hooks/useMedia'); +vi.mock('../../hooks/useMedia/index.js'); describe('SidePanelContext', () => { beforeAll(() => { diff --git a/packages/circuit-ui/components/SidePanel/useSidePanel.spec.tsx b/packages/circuit-ui/components/SidePanel/useSidePanel.spec.tsx index 39830a9e1d..ea03dc68b2 100644 --- a/packages/circuit-ui/components/SidePanel/useSidePanel.spec.tsx +++ b/packages/circuit-ui/components/SidePanel/useSidePanel.spec.tsx @@ -24,7 +24,7 @@ import { SidePanelContext } from './SidePanelContext.js'; const defaultId = '1'; const testId = 'test'; -vi.mock('../../util/id', () => ({ +vi.mock('../../util/id.js', () => ({ uniqueId: () => defaultId, })); diff --git a/packages/circuit-ui/components/Step/Step.spec.tsx b/packages/circuit-ui/components/Step/Step.spec.tsx index 20ef01bc39..62e6a9dbe4 100644 --- a/packages/circuit-ui/components/Step/Step.spec.tsx +++ b/packages/circuit-ui/components/Step/Step.spec.tsx @@ -20,7 +20,7 @@ import { render } from '../../util/test-utils.js'; import { Step } from './Step.js'; import { useStep } from './hooks/useStep.js'; -vi.mock('./hooks/useStep', () => ({ useStep: vi.fn(() => ({})) })); +vi.mock('./hooks/useStep.js', () => ({ useStep: vi.fn(() => ({})) })); describe('Step', () => { afterAll(() => { diff --git a/packages/circuit-ui/hooks/useComponentSize/useComponentSize.spec.ts b/packages/circuit-ui/hooks/useComponentSize/useComponentSize.spec.ts index d0cb7b42e5..a626c80746 100644 --- a/packages/circuit-ui/hooks/useComponentSize/useComponentSize.spec.ts +++ b/packages/circuit-ui/hooks/useComponentSize/useComponentSize.spec.ts @@ -19,7 +19,7 @@ import { renderHook, act } from '../../util/test-utils.js'; import { useComponentSize } from './useComponentSize.js'; -vi.mock('../../util/helpers', () => ({ +vi.mock('../../util/helpers.js', () => ({ throttle: vi.fn((fn: T) => fn), })); From 804f19fcf715f5ac9e7235f406e1d54a4f324615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 28 Oct 2024 18:56:31 +0100 Subject: [PATCH 31/31] Throw error if shiftInRange args are invalid --- packages/circuit-ui/util/helpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/circuit-ui/util/helpers.ts b/packages/circuit-ui/util/helpers.ts index 6679f0a048..731a7a06a9 100644 --- a/packages/circuit-ui/util/helpers.ts +++ b/packages/circuit-ui/util/helpers.ts @@ -110,6 +110,19 @@ export function shiftInRange( min: number, // inclusive max: number, // inclusive ) { + if (process.env.NODE_ENV !== 'production') { + if (min >= max) { + throw new RangeError( + `The minimum value (${min}) must be less than the maximum value (${max}).`, + ); + } + if (value < min || value > max) { + throw new TypeError( + `The value (${value}) must be inside the provided range (${min}'-'${max})`, + ); + } + } + const modulus = max - min + 1; return ((value - min + (offset % modulus) + modulus) % modulus) + min; }