diff --git a/packages/circuit-ui/components/DateInput/DateInput.module.css b/packages/circuit-ui/components/DateInput/DateInput.module.css index 76cf5b96b1..13ce6c1a31 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.module.css +++ b/packages/circuit-ui/components/DateInput/DateInput.module.css @@ -154,8 +154,8 @@ } .divider { - width: calc(100% + 32px) !important; - margin-left: -16px; + width: calc(100% + var(--cui-spacings-tera)); + margin-left: calc(-1 * var(--cui-spacings-mega)); } .buttons { @@ -167,7 +167,8 @@ .popover { max-width: min(410px, 100vw); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle) !important; + border-color: var(--cui-border-subtle); + box-shadow: none; } .popover::after { diff --git a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx index f911191ef2..0761d5382a 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.stories.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.stories.tsx @@ -14,6 +14,7 @@ */ import { useState } from 'react'; +import { userEvent, within } from '@storybook/test'; import { Stack } from '../../../../.storybook/components/index.js'; @@ -94,13 +95,24 @@ export const Validations = (args: DateInputProps) => ( Validations.args = baseArgs; +const openCalendar = async ({ + canvasElement, +}: { + canvasElement: HTMLCanvasElement; +}) => { + const canvas = within(canvasElement); + const referenceEl = canvas.getAllByRole('button'); + + await userEvent.click(referenceEl[0]); +}; + export const Optional = (args: DateInputProps) => ; Optional.args = { ...baseArgs, optionalLabel: 'optional', }; - +Optional.play = openCalendar; export const Readonly = (args: DateInputProps) => ; Readonly.args = { diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index ca4f1fdc7f..8407824a46 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -382,6 +382,7 @@ export const DateInput = forwardRef( offset={4} placement={placement} closeButtonLabel={closeCalendarButtonLabel} + locale={locale} component={() => ( { afterEach(() => { @@ -56,6 +57,21 @@ describe('Popover', () => { onToggle: vi.fn(createStateSetter(true)), }; + const actions: Action[] = [ + { + onClick: vi.fn(), + children: 'Add', + icon: Add as FC, + }, + { type: 'divider' }, + { + onClick: vi.fn(), + children: 'Remove', + icon: Delete as FC, + destructive: true, + }, + ]; + it('should forward a ref', () => { const ref = createRef(); renderPopover({ ...baseProps, ref }); @@ -140,6 +156,20 @@ describe('Popover', () => { expect(baseProps.onToggle).toHaveBeenCalledTimes(1); }); + it('should close the popover when clicking a popover item', async () => { + renderPopover({ + ...baseProps, + children: undefined, + actions, + }); + + const popoverItems = screen.getAllByRole('menuitem'); + + await userEvent.click(popoverItems[0]); + + expect(baseProps.onToggle).toHaveBeenCalledTimes(1); + }); + it('should move focus to the first popover item after opening', async () => { const isOpen = false; const onToggle = vi.fn(createStateSetter(isOpen)); @@ -187,8 +217,23 @@ describe('Popover', () => { }); }); + it('should render the popover with menu semantics by default ', async () => { + renderPopover({ + ...baseProps, + children: undefined, + actions, + }); + + const menu = screen.getByRole('menu'); + expect(menu).toBeVisible(); + const menuitems = screen.getAllByRole('menuitem'); + expect(menuitems.length).toBe(2); + + await flushMicrotasks(); + }); + it('should render the popover without menu semantics ', async () => { - renderPopover({ ...baseProps }); + renderPopover({ ...baseProps, role: 'none' }); const menu = screen.queryByRole('menu'); expect(menu).toBeNull(); @@ -197,4 +242,17 @@ describe('Popover', () => { await flushMicrotasks(); }); + + it('should hide dividers from the accessibility tree', async () => { + const { baseElement } = renderPopover({ + ...baseProps, + children: undefined, + actions, + }); + + const dividers = baseElement.querySelectorAll('hr[aria-hidden="true"'); + expect(dividers.length).toBe(1); + + await flushMicrotasks(); + }); }); diff --git a/packages/circuit-ui/components/Popover/Popover.stories.tsx b/packages/circuit-ui/components/Popover/Popover.stories.tsx index 0bc6124b3c..e439dfcf19 100644 --- a/packages/circuit-ui/components/Popover/Popover.stories.tsx +++ b/packages/circuit-ui/components/Popover/Popover.stories.tsx @@ -19,7 +19,7 @@ import { useState, type ReactNode } from 'react'; import { Button } from '../Button/index.js'; -import { type Action, Popover } from './Popover.js'; +import { type Action, Popover, type PopoverProps } from './Popover.js'; export default { title: 'Components/Popover', @@ -54,14 +54,15 @@ function PopoverWrapper({ children }: { children: ReactNode }) { return
{children}
; } -const popoverContent =
Hello 👋
; +const popoverContent = 'Hello 👋'; -export const Base = () => { +export const Base = (args: PopoverProps) => { const [isOpen, setOpen] = useState(true); return ( { Open popover )} - > - {popoverContent} - + /> ); }; -export const WithActions = () => { + +Base.args = { + children: popoverContent, +}; + +export const WithActions = (args: PopoverProps) => { const [isOpen, setOpen] = useState(true); return ( ( @@ -95,23 +99,30 @@ export const WithActions = () => { ); }; -export const Offset = () => { +WithActions.args = { + actions, +}; + +export const Offset = (args: PopoverProps) => { const [isOpen, setOpen] = useState(true); return ( ( )} - > - {popoverContent} - + /> ); }; + +Offset.args = { + actions, + offset: 20, +}; diff --git a/packages/circuit-ui/components/Popover/Popover.tsx b/packages/circuit-ui/components/Popover/Popover.tsx index c7fb2fed91..bbc455f6c5 100644 --- a/packages/circuit-ui/components/Popover/Popover.tsx +++ b/packages/circuit-ui/components/Popover/Popover.tsx @@ -26,8 +26,6 @@ import { type HTMLAttributes, forwardRef, type RefObject, - type AnchorHTMLAttributes, - type ButtonHTMLAttributes, type AriaRole, } from 'react'; import { @@ -41,7 +39,6 @@ import { shift, type Side, } from '@floating-ui/react-dom'; -import type { IconComponentType } from '@sumup-oss/icons'; import type { ClickEvent } from '../../types/events.js'; import { isArrowDown, isArrowUp } from '../../util/key-codes.js'; @@ -55,71 +52,17 @@ import { Modal } from '../Modal/index.js'; import { getKeyboardFocusableElements } from '../Modal/ModalService.js'; import { applyMultipleRefs } from '../../util/refs.js'; import dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; -import { useComponents } from '../ComponentsContext/index.js'; -import type { EmotionAsPropType } from '../../types/prop-types.js'; -import { sharedClasses } from '../../styles/shared.js'; import { CircuitError } from '../../util/errors.js'; import { Hr } from '../Hr/index.js'; import { useFocusList } from '../../hooks/useFocusList/index.js'; import type { Locale } from '../../util/i18n.js'; +import { useStackContext } from '../StackContext/index.js'; import classes from './Popover.module.css'; - -export interface BaseProps { - /** - * The Popover item label. - */ - children: string; - /** - * Function that's called when the item is clicked. - */ - onClick?: (event: ClickEvent) => void; - /** - * Display an icon in addition to the label. Designed for 24px icons from `@sumup-oss/icons`. - */ - icon?: IconComponentType; - /** - * Destructive variant, changes the color of label and icon from blue to red to signal to the user that the action - * is irreversible or otherwise dangerous. Interactive states are the same for destructive variant. - */ - destructive?: boolean; - /** - * Disabled variant. Visually and functionally disable the button. - */ - disabled?: boolean; -} - -type LinkElProps = Omit, 'onClick'>; -type ButtonElProps = Omit, 'onClick'>; - -export type PopoverItemProps = BaseProps & LinkElProps & ButtonElProps; - -export const PopoverItem = ({ - children, - icon: Icon, - destructive, - className, - ...props -}: PopoverItemProps) => { - const { Link } = useComponents(); - - const Element = props.href ? (Link as EmotionAsPropType) : 'button'; - - return ( - - {Icon && - ); -}; +import { + PopoverItem, + type PopoverItemProps, +} from './components/PopoverItem.js'; type Divider = { type: 'divider' }; export type Action = PopoverItemProps | Divider; @@ -244,16 +187,16 @@ export const Popover = forwardRef( className, role = 'menu', children, - locale, actions, - closeButtonLabel, hasArrow, ...props }, ref, ) => { + const zIndex = useStackContext(); const triggerKey = useRef(null); const menuEl = useRef(null); + const dialogRef = useRef(null); const arrowRef = useRef(null); const triggerId = useId(); const menuId = useId(); @@ -270,40 +213,43 @@ export const Popover = forwardRef( ); } - const { floatingStyles, middlewareData, refs, update } = - useFloating({ - open: isOpen, - placement, - strategy: 'fixed', - middleware: offset - ? [ - offsetMiddleware(offset), - flip({ fallbackPlacements }), - shift(), - size(sizeOptions), - arrow({ element: arrowRef, padding: 12 }), - ] - : [ - flip({ fallbackPlacements }), - shift(), - size(sizeOptions), - arrow({ element: arrowRef, padding: 12 }), - ], - }); - - useEffect(() => { - // restore focus to triggering element - if (!isOpen && refs.reference) { - refs.reference.current?.focus(); - } - return () => { - if (refs.reference) { - refs.reference.current?.focus(); - } - }; - }, [isOpen, refs.reference]); + const padding = 16; // px + + const { + placement: finalPlacement, + floatingStyles, + middlewareData, + refs, + update, + } = useFloating({ + open: isOpen, + placement, + strategy: 'fixed', + middleware: offset + ? [ + offsetMiddleware(offset), + flip({ + fallbackPlacements, + }), + shift({ padding }), + size({ padding }), + arrow({ element: arrowRef, padding: 12 }), + ] + : [ + offsetMiddleware(offset), + flip({ fallbackPlacements }), + size(sizeOptions), + ], + }); + + useEffect( + () => () => { + dialogRef.current?.close(); + }, + [], + ); - const side = middlewareData.offset?.placement?.split('-')[0] as Side; + const side = finalPlacement.split('-')[0] as Side; const focusProps = useFocusList(); const prevOpen = usePrevious(isOpen); @@ -336,7 +282,7 @@ export const Popover = forwardRef( ); const handlePopoverItemClick = - (onClick: BaseProps['onClick']) => (event: ClickEvent) => { + (onClick: PopoverItemProps['onClick']) => (event: ClickEvent) => { onClick?.(event); handleToggle(false); }; @@ -349,7 +295,7 @@ export const Popover = forwardRef( ); useEffect(() => { - const dialogElement = refs.floating.current; + const dialogElement = dialogRef.current; if (!dialogElement) { return; @@ -358,7 +304,7 @@ export const Popover = forwardRef( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore The package is bundled incorrectly dialogPolyfill.registerDialog(dialogElement); - }, [refs.floating.current]); + }, []); useEffect(() => { /** @@ -457,7 +403,7 @@ export const Popover = forwardRef( onClose={handleTriggerClick} open={isOpen} className={className} - closeButtonLabel={closeButtonLabel} + {...props} > {popoverContent} @@ -466,14 +412,17 @@ export const Popover = forwardRef( open={isOpen} {...props} data-side={side} - ref={applyMultipleRefs(ref, refs.setFloating)} + ref={applyMultipleRefs(ref, refs.setFloating, dialogRef)} className={clsx( !isMobile && classes.content, isOpen && classes.open, actions && classes['with-actions'], className, )} - style={floatingStyles} + style={{ + ...floatingStyles, + zIndex: zIndex || 'var(--cui-z-index-popover)', + }} > {popoverContent} {hasArrow && ( diff --git a/packages/circuit-ui/components/Popover/components/PopoverItem.module.css b/packages/circuit-ui/components/Popover/components/PopoverItem.module.css new file mode 100644 index 0000000000..9ea74884dc --- /dev/null +++ b/packages/circuit-ui/components/Popover/components/PopoverItem.module.css @@ -0,0 +1,32 @@ +.item { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + font-size: var(--cui-body-m-font-size); + line-height: var(--cui-body-m-line-height); + text-align: left; + background: var(--cui-bg-elevated); +} + +@media (max-width: 479px) { + .item { + padding: var(--cui-spacings-kilo) 0; + } + + .item:first-child { + padding-top: var(--cui-spacings-bit); + } + + .item:last-child { + padding-bottom: var(--cui-spacings-bit); + } + + .divider { + margin: var(--cui-spacings-byte) 0; + } +} + +.icon { + margin-right: var(--cui-spacings-kilo); +} diff --git a/packages/circuit-ui/components/Popover/components/PopoverItem.spec.tsx b/packages/circuit-ui/components/Popover/components/PopoverItem.spec.tsx new file mode 100644 index 0000000000..24eb4410bf --- /dev/null +++ b/packages/circuit-ui/components/Popover/components/PopoverItem.spec.tsx @@ -0,0 +1,75 @@ +/** + * Copyright 2025, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FC } from 'react'; +import { Download, type IconProps } from '@sumup-oss/icons'; +import { describe, expect, it, vi } from 'vitest'; + +import { render, userEvent, type RenderFn } from '../../../util/test-utils.js'; +import type { ClickEvent } from '../../../types/events.js'; + +import { PopoverItem, type PopoverItemProps } from './PopoverItem.js'; + +describe('PopoverItem', () => { + function renderPopoverItem( + renderFn: RenderFn, + props: PopoverItemProps, + ) { + return renderFn(); + } + + const baseProps = { + children: 'PopoverItem', + icon: Download as FC, + }; + + describe('Styles', () => { + it('should render as Link when an href (and onClick) is passed', () => { + const props = { + ...baseProps, + href: 'https://sumup.com', + onClick: vi.fn(), + }; + const { container } = renderPopoverItem(render, props); + const anchorEl = container.querySelector('a'); + expect(anchorEl).toBeVisible(); + }); + + it('should render as a `button` when an onClick is passed', () => { + const props = { ...baseProps, onClick: vi.fn() }; + const { container } = renderPopoverItem(render, props); + const buttonEl = container.querySelector('button'); + expect(buttonEl).toBeVisible(); + }); + }); + + describe('Logic', () => { + it('should call onClick when rendered as Link', async () => { + const props = { + ...baseProps, + href: 'https://sumup.com', + onClick: vi.fn((event: ClickEvent) => { + event.preventDefault(); + }), + }; + const { container } = renderPopoverItem(render, props); + const anchorEl = container.querySelector('a'); + if (anchorEl) { + await userEvent.click(anchorEl); + } + expect(props.onClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/circuit-ui/components/Popover/components/PopoverItem.tsx b/packages/circuit-ui/components/Popover/components/PopoverItem.tsx new file mode 100644 index 0000000000..f7e9d32e88 --- /dev/null +++ b/packages/circuit-ui/components/Popover/components/PopoverItem.tsx @@ -0,0 +1,85 @@ +/** + * Copyright 2025, 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 type { IconComponentType } from '@sumup-oss/icons'; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; + +import { useComponents } from '../../ComponentsContext/index.js'; +import type { EmotionAsPropType } from '../../../types/prop-types.js'; +import { clsx } from '../../../styles/clsx.js'; +import { sharedClasses } from '../../../styles/shared.js'; +import type { ClickEvent } from '../../../types/events.js'; + +import classes from './PopoverItem.module.css'; + +interface PopoverItemBaseProps { + /** + * The Popover item label. + */ + children: string; + /** + * Function that's called when the item is clicked. + */ + onClick?: (event: ClickEvent) => void; + /** + * Display an icon in addition to the label. Designed for 24px icons from `@sumup-oss/icons`. + */ + icon?: IconComponentType; + /** + * Destructive variant, changes the color of label and icon from blue to red to signal to the user that the action + * is irreversible or otherwise dangerous. Interactive states are the same for destructive variant. + */ + destructive?: boolean; + /** + * Disabled variant. Visually and functionally disable the button. + */ + disabled?: boolean; +} + +type LinkElProps = Omit, 'onClick'>; +type ButtonElProps = Omit, 'onClick'>; + +export type PopoverItemProps = PopoverItemBaseProps & + LinkElProps & + ButtonElProps; + +export const PopoverItem = ({ + children, + icon: Icon, + destructive, + className, + ...props +}: PopoverItemProps) => { + const { Link } = useComponents(); + + const Element = props.href ? (Link as EmotionAsPropType) : 'button'; + + return ( + + {Icon && + ); +}; diff --git a/packages/circuit-ui/components/Popover/index.tsx b/packages/circuit-ui/components/Popover/index.tsx index 1b5ba597bb..3dc8905758 100644 --- a/packages/circuit-ui/components/Popover/index.tsx +++ b/packages/circuit-ui/components/Popover/index.tsx @@ -16,3 +16,5 @@ export { Popover } from './Popover.js'; export type { PopoverProps } from './Popover.js'; + +export type { PopoverItemProps } from './components/PopoverItem.js'; diff --git a/packages/circuit-ui/components/Toggletip/Toggletip.module.css b/packages/circuit-ui/components/Toggletip/Toggletip.module.css index 28a52b06fd..f3e15fa9f6 100644 --- a/packages/circuit-ui/components/Toggletip/Toggletip.module.css +++ b/packages/circuit-ui/components/Toggletip/Toggletip.module.css @@ -2,10 +2,14 @@ width: max-content; max-width: 360px; max-width: min(360px, 100vw); - overflow: visible !important; - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle) !important; - border-radius: var(--cui-border-radius-byte); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); + overflow: visible; +} + +@media (min-width: 480px) { + .base { + border-color: var(--cui-border-subtle); + box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); + } } .base::after { diff --git a/packages/circuit-ui/components/Toggletip/Toggletip.tsx b/packages/circuit-ui/components/Toggletip/Toggletip.tsx index 6315ae34d4..7cea93f906 100644 --- a/packages/circuit-ui/components/Toggletip/Toggletip.tsx +++ b/packages/circuit-ui/components/Toggletip/Toggletip.tsx @@ -15,18 +15,10 @@ 'use client'; -import { - forwardRef, - useCallback, - useEffect, - useId, - useRef, - useState, -} from 'react'; +import { forwardRef, useCallback, useId, useState } from 'react'; import type { ClickEvent } from '../../types/events.js'; import { clsx } from '../../styles/clsx.js'; -import { applyMultipleRefs } from '../../util/refs.js'; import { useMedia } from '../../hooks/useMedia/index.js'; import { useStackContext } from '../StackContext/index.js'; import { CloseButton } from '../CloseButton/index.js'; @@ -84,7 +76,6 @@ export const Toggletip = forwardRef( } = useI18n(props, translations); const zIndex = useStackContext(); const isMobile = useMedia('(max-width: 479px)'); - const dialogRef = useRef(null); const headlineId = useId(); const bodyId = useId(); const [open, setOpen] = useState(defaultOpen); @@ -93,13 +84,6 @@ export const Toggletip = forwardRef( setOpen(false); }, []); - useEffect( - () => () => { - dialogRef.current?.close(); - }, - [], - ); - const handleActionClick = (event: ClickEvent) => { action?.onClick?.(event); closeDialog(); @@ -110,7 +94,7 @@ export const Toggletip = forwardRef( }, []); return ( ( zIndex: zIndex || 'var(--cui-z-index-modal)', }} closeButtonLabel={closeButtonLabel} + locale={locale} onToggle={handleToggle} isOpen={open} {...rest} diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index cf046ffa60..738a98fa4d 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -151,7 +151,10 @@ export type { ProgressBarProps } from './components/ProgressBar/index.js'; export { Tag } from './components/Tag/index.js'; export type { TagProps } from './components/Tag/index.js'; export { Popover } from './components/Popover/index.js'; -export type { PopoverProps } from './components/Popover/index.js'; +export type { + PopoverProps, + PopoverItemProps, +} from './components/Popover/index.js'; export { ModalProvider } from './components/Modal/ModalContext.js'; export type { ModalProviderProps } from './components/Modal/ModalContext.js'; export { useModal } from './components/Modal/index.js';