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 && }
- {children}
-
- );
-};
+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 && }
+ {children}
+
+ );
+};
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';