diff --git a/src/tedi/components/overlays/overlay/overlay-content.tsx b/src/tedi/components/overlays/overlay/overlay-content.tsx index 64ab6fb4c..9b49ec2b8 100644 --- a/src/tedi/components/overlays/overlay/overlay-content.tsx +++ b/src/tedi/components/overlays/overlay/overlay-content.tsx @@ -5,20 +5,49 @@ import { OverlayContext } from './overlay'; export interface OverlayContentProps { /** - * Content. + * Overlay content. + * Can contain any valid React nodes (text, elements, components). */ children: ReactNode | ReactNode[]; + /** - * Additional class names. + * Additional class names for styling overlay elements. */ classNames?: { + /** + * Class name applied to the floating content container. + */ content: string; + + /** + * Class name applied to the overlay arrow element. + */ arrow: string; }; + + /** + * ID of the element that labels the overlay content. + * + * This is used to set the `aria-labelledby` attribute on the overlay container, + * providing an accessible name for screen readers. + * + * Typically points to a heading element inside the overlay (e.g. a title). + */ + labelledBy?: string; + + /** + * ID of the element that describes the overlay content. + * + * This is used to set the `aria-describedby` attribute on the overlay container, + * allowing screen readers to announce additional descriptive text. + * + * Useful for longer explanations or supporting content that complements the title. + */ + describedBy?: string; } export const OverlayContent = (props: OverlayContentProps) => { - const { children, classNames } = props; + const { children, classNames, labelledBy, describedBy } = props; const { open, x, @@ -58,6 +87,9 @@ export const OverlayContent = (props: OverlayContentProps) => {
{ scrollLock, } = props; - const { order = ['reference', 'content'], initialFocus = -1, ...restFocusManager } = focusManager ?? {}; + const { order = ['reference', 'content'], initialFocus, modal, ...restFocusManager } = focusManager ?? {}; + const resolvedInitialFocus = initialFocus !== undefined ? initialFocus : modal ? 0 : undefined; const [open, setOpen] = useState(defaultOpen); const arrowRef = useRef(null); @@ -229,11 +230,14 @@ export const Overlay = (props: OverlayProps) => { reference: refs.setReference, floating: refs.setFloating, arrowRef, - focusManager: { - order, - initialFocus, - ...restFocusManager, - }, + focusManager: focusManager + ? { + order, + modal, + initialFocus: resolvedInitialFocus, + ...restFocusManager, + } + : undefined, x, y, strategy, diff --git a/src/tedi/components/overlays/popover/popover-content.tsx b/src/tedi/components/overlays/popover/popover-content.tsx index 62a3923d4..1086962e4 100644 --- a/src/tedi/components/overlays/popover/popover-content.tsx +++ b/src/tedi/components/overlays/popover/popover-content.tsx @@ -1,5 +1,6 @@ import cn from 'classnames'; import { useContext } from 'react'; +import { useId } from 'react'; import { Text, TextProps } from '../../base/typography/text/text'; import ClosingButton, { ClosingButtonProps } from '../../buttons/closing-button/closing-button'; @@ -47,6 +48,9 @@ export const PopoverContent = (props: PopoverContentProps) => { closeProps = { size: 'large' }, } = props; const { onOpenChange } = useContext(OverlayContext); + const titleId = useId(); + const hasDescription = Boolean(children); + const descriptionId = useId(); return ( { content: cn(styles['tedi-popover'], { [styles[`tedi-popover--${width}`]]: width }, className), arrow: styles['tedi-popover__arrow'], }} + labelledBy={title ? titleId : undefined} + describedBy={hasDescription ? descriptionId : undefined} > {(title || close) && (
{title && ( - + {title} )} @@ -73,7 +79,7 @@ export const PopoverContent = (props: PopoverContentProps) => { )}
)} - {children} + {hasDescription ?
{children}
: children}
); }; diff --git a/src/tedi/components/overlays/popover/popover.stories.tsx b/src/tedi/components/overlays/popover/popover.stories.tsx index cd7becbaa..a77658489 100644 --- a/src/tedi/components/overlays/popover/popover.stories.tsx +++ b/src/tedi/components/overlays/popover/popover.stories.tsx @@ -109,7 +109,7 @@ const ContentExamplesTemplate: StoryFn = (args) => { The polar bear (Ursus maritimus) is a large bear native to the Arctic and nearby areas. - + Read more @@ -434,6 +434,20 @@ export const NotDismissible: Story = { args: { dismissible: false, }, + parameters: { + docs: { + description: { + story: ` + Accessibility warning + + When \`dismissible=false\`: + - A visible close button MUST be present + - Keyboard users must have a clear exit path + - This pattern should only be used when content does not obscure critical information + `, + }, + }, + }, }; export const ScrollLocked: Story = { @@ -483,15 +497,116 @@ export const FocusLocked: Story = { docs: { description: { story: ` - This story demonstrates a Popover with a “locked” focus behavior, where keyboard navigation (Tab) is confined - to the Popover content until the user clicks an action like "Cancel" or "Submit". - - Key points: - - Keyboard focus is restricted inside the Popover until it is closed. - - \`focusManager.modal\` ensures focus stays within the Popover content. - - \`initialFocus\` sets the first element to receive focus when opening. - - This setup covers mostly edge cases; the default focus trap is false. -`, + This story demonstrates a Popover with a “locked” focus behavior, where keyboard navigation (Tab) is confined + to the Popover content until the user clicks an action like "Cancel" or "Submit". + + Key points: + - Keyboard focus is restricted inside the Popover until it is closed. + - \`focusManager.modal\` ensures focus stays within the Popover content. + - \`initialFocus\` sets the first element to receive focus when opening. + - This setup covers mostly edge cases; the default focus trap is false. + `, + }, + }, + }, +}; + +export const AccessibilityBaseline: Story = { + render: () => ( + + + + + +

+ This popover contains text, a link, and buttons. Screen readers should announce roles correctly. +

+ + Read more + +
+ + +
+
+
+ ), + parameters: { + docs: { + description: { + story: ` + Accessibility baseline test + + Use this story to verify: + - Dialog role is announced + - Title is used as the accessible name + - Buttons and links announce their roles + - Focus moves into the popover on open + - Escape closes the popover + - Close button is reachable via keyboard + `, + }, + }, + }, +}; + +export const NoTitleAccessibleName: Story = { + render: () => ( + + + + + +

This popover has no title. The accessible name will fall back to the trigger label.

+ +
+
+ ), + parameters: { + docs: { + description: { + story: ` + No-title popover + + Expected behavior: + - Dialog is announced + - Accessible name is inherited from trigger + - Roles of buttons are still announced + `, + }, + }, + }, +}; + +export const ReadAllStressTest: Story = { + render: () => ( + + + + + +

+ Paragraph one with inline formatting. +

+

+ Paragraph two with a link. +

+ +
+
+ ), + parameters: { + docs: { + description: { + story: ` + Use VoiceOver "Read All" inside the popover. + + Verify: + - Content is not flattened + - Buttons are announced as buttons + - Links are announced as links + - Content is not duplicated + `, }, }, }, diff --git a/src/tedi/components/overlays/popover/popover.tsx b/src/tedi/components/overlays/popover/popover.tsx index 68f8de888..0b933c717 100644 --- a/src/tedi/components/overlays/popover/popover.tsx +++ b/src/tedi/components/overlays/popover/popover.tsx @@ -30,6 +30,7 @@ export const Popover = (props: PopoverProps) => { height: ARROW_HEIGHT, }} openWith={openWith} + role="dialog" {...rest} /> );