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 267bd4f751..8e83f6ba46 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.spec.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.spec.tsx @@ -13,21 +13,30 @@ * 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 type { InputElement } from '../Input/index.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 022a2ba45a..627126a101 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/Input/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 a20a3343c6..7dbec52fda 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 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' | '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';