diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 3da7cbbec6..adbedf66fa 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -108,6 +108,7 @@ } .content { + padding: var(--cui-spacings-mega); color: var(--cui-fg-normal); background-color: var(--cui-bg-elevated); border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); @@ -118,8 +119,11 @@ @media (max-width: 479px) { .content { + padding: 0; + border: none; border-bottom-right-radius: 0; border-bottom-left-radius: 0; + box-shadow: none; } } @@ -127,8 +131,7 @@ 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); + margin: var(--cui-spacings-byte) 0; } @media (min-width: 480px) { @@ -151,7 +154,12 @@ } .calendar { - padding: var(--cui-spacings-mega); + margin: var(--cui-spacings-mega) 0; +} + +.divider { + width: calc(100% + 32px) !important; + margin-left: -16px; } .buttons { @@ -159,8 +167,15 @@ 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); +} + +.popover { + max-width: min(410px, 100vw); + box-shadow: none !important; +} + +.popover::after { + visibility: hidden; } .apply { diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index 9de2f04e69..ca4f1fdc7f 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -17,21 +17,13 @@ import { forwardRef, - useEffect, useId, useRef, useState, type InputHTMLAttributes, } from 'react'; import type { Temporal } from 'temporal-polyfill'; -import { - flip, - offset, - shift, - size, - useFloating, - type Placement, -} from '@floating-ui/react-dom'; +import type { Placement } from '@floating-ui/react-dom'; import { Calendar as CalendarIcon } from '@sumup-oss/icons'; import type { ClickEvent } from '../../types/events.js'; @@ -45,7 +37,6 @@ import { clsx } from '../../styles/clsx.js'; import type { InputProps } from '../Input/Input.js'; import { Calendar, type CalendarProps } from '../Calendar/Calendar.js'; import { Button } from '../Button/Button.js'; -import { CloseButton } from '../CloseButton/CloseButton.js'; import { IconButton } from '../Button/IconButton.js'; import { Headline } from '../Headline/Headline.js'; import { @@ -58,8 +49,9 @@ import { import { toPlainDate } from '../../util/date.js'; import { applyMultipleRefs } from '../../util/refs.js'; import { changeInputValue } from '../../util/input-value.js'; +import { Popover } from '../Popover/index.js'; +import { Hr } from '../Hr/index.js'; -import { Dialog } from './components/Dialog.js'; import { DateSegment } from './components/DateSegment.js'; import { usePlainDateState } from './hooks/usePlainDateState.js'; import { useSegmentFocus } from './hooks/useSegmentFocus.js'; @@ -219,51 +211,6 @@ export const DateInput = forwardRef( const [open, setOpen] = useState(false); const [selection, setSelection] = useState(); - const padding = 16; // px - - const { floatingStyles, update } = useFloating({ - open, - placement, - strategy: 'fixed', - middleware: [ - offset(4), - flip({ padding, fallbackAxisSideDirection: 'start' }), - shift({ padding }), - size({ - padding, - apply({ availableHeight, elements }) { - elements.floating.style.maxHeight = `${availableHeight}px`; - }, - }), - ], - elements: { - reference: calendarButtonRef.current, - floating: dialogRef.current, - }, - }); - - useEffect(() => { - /** - * When we support `ResizeObserver` (https://caniuse.com/resizeobserver), - * we can look into using Floating UI's `autoUpdate` (but we can't use - * `whileElementIsMounted` because our implementation hides the floating - * element using CSS instead of using conditional rendering. - * See https://floating-ui.com/docs/react-dom#updating - */ - if (open) { - update(); - window.addEventListener('resize', update); - window.addEventListener('scroll', update); - } else { - window.removeEventListener('resize', update); - window.removeEventListener('scroll', update); - } - return () => { - window.removeEventListener('resize', update); - window.removeEventListener('scroll', update); - }; - }, [open, update]); - // Focus the first date segment when clicking anywhere on the field... const handleClick = (event: ClickEvent) => { const element = event.target as HTMLElement; @@ -274,9 +221,9 @@ export const DateInput = forwardRef( focus.next(); }; - const openCalendar = () => { + const toggleCalendar = () => { setSelection(state.date); - setOpen(true); + setOpen((prev) => !prev); }; const closeCalendar = () => { @@ -306,16 +253,6 @@ export const DateInput = forwardRef( closeCalendar(); }; - const mobileStyles = { - position: 'fixed', - top: 'auto', - right: '0px', - bottom: '0px', - left: '0px', - } as const; - - const dialogStyles = isMobile ? mobileStyles : floatingStyles; - const segments = getDateSegments(locale); const calendarButtonLabel = getCalendarButtonLabel( openCalendarButtonLabel, @@ -435,20 +372,79 @@ export const DateInput = forwardRef( } })} - ( + + {calendarButtonLabel} + + )} > - {calendarButtonLabel} - + {() => ( +
+
+ + {label} + +
+ + +
+ + {(!required || isMobile) && ( +
+ {!required && ( + + )} + +
+ )} +
+ )} + ( validationHint={validationHint} /> - - {() => ( -
-
- - {label} - - - {closeCalendarButtonLabel} - -
- - - - {(!required || isMobile) && ( -
- {!required && ( - - )} - -
- )} -
- )} -
); }, diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.module.css b/packages/circuit-ui/components/DateInput/components/Dialog.module.css deleted file mode 100644 index a109f39298..0000000000 --- a/packages/circuit-ui/components/DateInput/components/Dialog.module.css +++ /dev/null @@ -1,65 +0,0 @@ -.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 { - display: none; -} - -@media (max-width: 479px) { - .backdrop { - position: fixed; - top: 0; - 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); - } - - .dialog[open] + .backdrop { - pointer-events: auto; - visibility: visible; - opacity: 1; - } -} diff --git a/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx b/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx deleted file mode 100644 index 0931ca790e..0000000000 --- a/packages/circuit-ui/components/DateInput/components/Dialog.spec.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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 close the dialog when modal and pressing the backdrop', async () => { - render(); - await userEvent.click(screen.getByRole('dialog', { hidden: true })); - 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 deleted file mode 100644 index 816217c572..0000000000 --- a/packages/circuit-ui/components/DateInput/components/Dialog.tsx +++ /dev/null @@ -1,164 +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, - useEffect, - useRef, - type HTMLAttributes, - type ReactNode, - useCallback, -} 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; - isModal?: boolean; - onClose: () => void; - children: () => ReactNode; -} - -export const Dialog = forwardRef( - ({ children, open, onClose, className, style, isModal, ...props }, ref) => { - const zIndex = useStackContext(); - const dialogRef = useRef(null); - - // the last focused element, used to restore focus when the dialog is closed - const lastFocusedElementRef = useRef(null); - - const handleClickOutside = useCallback( - // When the dialog first opens, we store the document's active element as the last active - // element to restore focus to it when the dialog closes. - // however, if the dialog is closed by clicking outside of it in non-modal mode, - // we don't want to restore focus to the last active element, so we override it - // with the element triggering the useClickOutside hook. - (event: Event) => { - if (event.target instanceof HTMLElement) { - lastFocusedElementRef.current = event.target; - } - onClose(); - }, - [onClose], - ); - - useClickOutside(dialogRef, handleClickOutside, open); - useEscapeKey(onClose, open); - - const onClickListener = useCallback( - (e: MouseEvent) => { - if (isModal && e.target === dialogRef.current) { - dialogRef.current?.close(); - } - }, - [isModal], - ); - - 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); - dialogElement.addEventListener('click', onClickListener); - - return () => { - dialogElement.removeEventListener('close', onClose); - dialogElement.removeEventListener('click', onClickListener); - }; - }, [onClose, onClickListener]); - - useEffect(() => { - const dialogElement = dialogRef.current; - - if (!dialogElement) { - return undefined; - } - - if (open) { - lastFocusedElementRef.current = document.activeElement; - if (!dialogElement.open) { - if (isModal) { - dialogElement.showModal(); - } else { - dialogElement.show(); - } - } - } else if (dialogElement.open) { - // restore focus to the last focused element - if ( - lastFocusedElementRef.current && - lastFocusedElementRef.current instanceof HTMLElement - ) { - lastFocusedElementRef.current?.focus(); - } - dialogElement.close(); - } - - return () => { - if (dialogElement.open) { - // restore focus to the last focused element - if ( - lastFocusedElementRef.current && - lastFocusedElementRef.current instanceof HTMLElement - ) { - lastFocusedElementRef.current?.focus(); - } - dialogElement.close(); - } - }; - }, [open, isModal]); - - return ( - <> - - {open ? children() : null} - -
- - ); - }, -); - -Dialog.displayName = 'Dialog';