Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/tedi/components/overlays/overlay/overlay-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +87,9 @@ export const OverlayContent = (props: OverlayContentProps) => {
<div
{...getFloatingProps({
ref: floating,
tabIndex: -1,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
style: {
position: strategy,
left: x,
Expand Down
16 changes: 10 additions & 6 deletions src/tedi/components/overlays/overlay/overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ export const Overlay = (props: OverlayProps) => {
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<SVGSVGElement | null>(null);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/tedi/components/overlays/popover/popover-content.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,18 +48,23 @@ export const PopoverContent = (props: PopoverContentProps) => {
closeProps = { size: 'large' },
} = props;
const { onOpenChange } = useContext(OverlayContext);
const titleId = useId();
const hasDescription = Boolean(children);
const descriptionId = useId();

return (
<OverlayContent
classNames={{
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) && (
<div className={cn(styles['tedi-popover__header'], { [styles['tedi-popover__header--no-title']]: !title })}>
{title && (
<Text {...titleProps} className={cn('align-self-center', titleProps.className)}>
<Text {...titleProps} id={titleId} className={cn('align-self-center', titleProps.className)}>
{title}
</Text>
)}
Expand All @@ -73,7 +79,7 @@ export const PopoverContent = (props: PopoverContentProps) => {
)}
</div>
)}
{children}
{hasDescription ? <div id={descriptionId}>{children}</div> : children}
</OverlayContent>
);
};
135 changes: 125 additions & 10 deletions src/tedi/components/overlays/popover/popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const ContentExamplesTemplate: StoryFn<PopoverProps> = (args) => {
</Popover.Trigger>
<Popover.Content>
The polar bear (Ursus maritimus) is a large bear native to the Arctic and nearby areas.
<Link underline={false} iconRight="north_east" className="align-self-end">
<Link href="#" underline={false} iconRight="north_east" className="align-self-end">
Read more
</Link>
</Popover.Content>
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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: () => (
<Popover>
<Popover.Trigger>
<Button>Open popover</Button>
</Popover.Trigger>
<Popover.Content title="Popover title" close>
<p id="popover-description">
This popover contains text, a link, and buttons. Screen readers should announce roles correctly.
</p>

<Link href="#">Read more</Link>

<div className="display-flex gap-2">
<Button visualType="secondary">Cancel</Button>
<Button>Confirm</Button>
</div>
</Popover.Content>
</Popover>
),
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: () => (
<Popover>
<Popover.Trigger>
<Button>Open popover</Button>
</Popover.Trigger>
<Popover.Content>
<p>This popover has no title. The accessible name will fall back to the trigger label.</p>
<Button>Action</Button>
</Popover.Content>
</Popover>
),
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: () => (
<Popover>
<Popover.Trigger>
<Button>Read all test</Button>
</Popover.Trigger>
<Popover.Content title="Read all test" close>
<p>
Paragraph one with <strong>inline formatting</strong>.
</p>
<p>
Paragraph two with a <Link href="#">link</Link>.
</p>
<Button>Primary action</Button>
</Popover.Content>
</Popover>
),
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
`,
},
},
},
Expand Down
1 change: 1 addition & 0 deletions src/tedi/components/overlays/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const Popover = (props: PopoverProps) => {
height: ARROW_HEIGHT,
}}
openWith={openWith}
role="dialog"
{...rest}
/>
);
Expand Down
Loading