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
26 changes: 24 additions & 2 deletions src/tedi/components/buttons/button-content/button-content.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,17 @@ describe('ButtonContent component', () => {
expect(button).toHaveClass('tedi-btn--underline');
});

it('renders in loading state with spinner', () => {
it('renders in loading state with a decorative spinner and conveys loading via aria-busy', () => {
render(<ButtonContent {...defaultProps} isLoading />);
const spinner = screen.getByRole('status');

const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-busy', 'true');
const spinner = screen.getByTestId('tedi-spinner');
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass('tedi-btn__spinner');
expect(spinner).toHaveAttribute('aria-hidden', 'true');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(button).toHaveAccessibleName('Click Me');
});

it('renders with full width when fullWidth is true', () => {
Expand Down Expand Up @@ -112,4 +118,20 @@ describe('ButtonContent component', () => {
fireEvent.mouseEnter(button);
expect(document.querySelector('.tedi-overlay__content')).not.toBeInTheDocument();
});

it('renders the icon-only tooltip as visual-only so the name is not duplicated', async () => {
render(
<ButtonContent icon="delete" showTooltip>
Delete
</ButtonContent>
);
const button = screen.getByRole('button');
// Name comes from the visually-hidden label — announced once.
expect(button).toHaveAccessibleName('Delete');

fireEvent.mouseEnter(button);
const content = await screen.findByTestId('overlay-content');
expect(content).toHaveAttribute('aria-hidden', 'true');
expect(button).not.toHaveAttribute('aria-describedby');
});
});
5 changes: 3 additions & 2 deletions src/tedi/components/buttons/button-content/button-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const InternalButtonContent = forwardRef(
? { ...defaultIconProps, name: icon }
: { ...defaultIconProps, ...icon, className: cn(defaultIconProps.className, icon?.className) };

return isLoading ? <Spinner className={iconProps.className} size={18} /> : <Icon {...iconProps} />;
return isLoading ? <Spinner className={iconProps.className} size={18} decorative /> : <Icon {...iconProps} />;
};

const renderContent = (): JSX.Element => {
Expand All @@ -166,6 +166,7 @@ const InternalButtonContent = forwardRef(
position={!hasIcon ? 'absolute' : undefined}
className={cn(styles['tedi-btn__spinner'], styles['tedi-btn__spinner--left'])}
size={18}
decorative
/>
) : iconLeft ? (
getIcon('left', iconLeft)
Expand Down Expand Up @@ -216,7 +217,7 @@ const InternalButtonContent = forwardRef(
return (
<Print visibility="hide">
{showTooltip && isIconOnly && buttonText ? (
<Tooltip>
<Tooltip ariaHidden>
<Tooltip.Trigger>{buttonElement}</Tooltip.Trigger>
<Tooltip.Content>{buttonText}</Tooltip.Content>
</Tooltip>
Expand Down
10 changes: 10 additions & 0 deletions src/tedi/components/loaders/spinner/spinner.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ describe('Spinner component with breakpoint support', () => {
expect(hiddenText).toHaveClass('sr-only');
});

it('renders as purely decorative when decorative is set (no status role or label)', () => {
const { container, queryByRole, queryByText } = render(<Spinner decorative label="Loading..." />);
const spinner = container.querySelector('.tedi-spinner');

expect(spinner).toHaveAttribute('aria-hidden', 'true');
expect(spinner).not.toHaveAttribute('aria-live');
expect(queryByRole('status')).not.toBeInTheDocument();
expect(queryByText('Loading...')).not.toBeInTheDocument();
});

it('uses different size and color based on breakpoint props', () => {
(useBreakpointProps as jest.Mock).mockReturnValue({
getCurrentBreakpointProps: jest.fn(() => ({
Expand Down
18 changes: 16 additions & 2 deletions src/tedi/components/loaders/spinner/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export interface SpinnerProps extends BreakpointSupport<SpinnerBreakpointProps>
* Provides a text label for screen readers to announce the spinner's purpose or status.
*/
label?: string;
/**
* Renders the spinner as purely decorative — no `role="status"` live region and
* no screen-reader label. Use when the loading state is already announced by an
* ancestor (e.g. a button's `aria-busy`), so it isn't announced twice.
* @default false
*/
decorative?: boolean;
}

export const Spinner = (props: SpinnerProps): JSX.Element => {
Expand All @@ -52,6 +59,7 @@ export const Spinner = (props: SpinnerProps): JSX.Element => {
color = 'primary',
label = getLabel('spinner.loading'),
position,
decorative = false,
} = getCurrentBreakpointProps<SpinnerProps>(props);

const spinnerBEM = cn(
Expand All @@ -63,11 +71,17 @@ export const Spinner = (props: SpinnerProps): JSX.Element => {
);

return (
<span className={spinnerBEM} role="status" aria-live="polite" data-testid="tedi-spinner">
<span
className={spinnerBEM}
role={decorative ? undefined : 'status'}
aria-live={decorative ? undefined : 'polite'}
aria-hidden={decorative || undefined}
data-testid="tedi-spinner"
>
<svg viewBox="22 22 44 44" aria-hidden="true">
<circle className={styles['tedi-spinner__inner']} cx="44" cy="44" r="20" fill="none"></circle>
</svg>
<span className="sr-only">{label}</span>
{!decorative && <span className="sr-only">{label}</span>}
</span>
);
};
2 changes: 2 additions & 0 deletions src/tedi/components/overlays/overlay/overlay-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const OverlayContent = (props: OverlayContentProps) => {
scrollLock,
contentId,
role,
ariaHidden,
} = useContext(OverlayContext);

useEffect(() => {
Expand All @@ -89,6 +90,7 @@ export const OverlayContent = (props: OverlayContentProps) => {
ref: floating,
tabIndex: -1,
id: contentId,
'aria-hidden': ariaHidden || undefined,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
style: {
Expand Down
4 changes: 2 additions & 2 deletions src/tedi/components/overlays/overlay/overlay-trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ export interface OverlayTriggerProps {
export const OverlayTrigger = (props: OverlayTriggerProps) => {
const { children, className } = props;
const { getLabel } = useLabels();
const { getReferenceProps, reference, openWith, open, role, contentId } = useContext(OverlayContext);
const { getReferenceProps, reference, openWith, open, role, ariaHidden, contentId } = useContext(OverlayContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = useMergeRefs([reference, (children as React.ComponentPropsWithRef<any>).ref]);
const extraProps =
role === 'tooltip'
role === 'tooltip' && !ariaHidden
? {
'aria-describedby': open ? contentId : undefined,
}
Expand Down
15 changes: 14 additions & 1 deletion src/tedi/components/overlays/overlay/overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface OverlayProps {
* @default tooltip
*/
role?: UseRoleProps['role'];
/**
* Renders the overlay purely visually: no `useRole` aria wiring on the trigger
* (so no `aria-describedby`) and the content is `aria-hidden`. Use when the
* trigger already conveys the same text through its accessible name — e.g. an
* icon-only button whose visible tooltip merely mirrors its label — so screen
* readers don't announce it twice.
* @default false
*/
ariaHidden?: boolean;
/**
* Content overlay arrow dimensions.
*/
Expand Down Expand Up @@ -137,6 +146,7 @@ export interface OverlayContextType {
context: FloatingContext<ReferenceType>;
scrollLock?: boolean;
role?: UseRoleProps['role'];
ariaHidden?: boolean;
contentId: string;
}

Expand Down Expand Up @@ -178,6 +188,7 @@ export const Overlay = (props: OverlayProps) => {
open: externalOpen,
onToggle,
role = 'tooltip',
ariaHidden = false,
arrowDimensions,
offset: offsetOptions = GAP + (arrowDimensions?.height ?? 0),
arrowPadding = 4,
Expand Down Expand Up @@ -239,7 +250,7 @@ export const Overlay = (props: OverlayProps) => {
useFocus(context, {
enabled: openWith === 'hover',
}),
useRole(context, { role }),
useRole(context, { role, enabled: !ariaHidden }),
useDismiss(context, {
enabled: dismissible,
outsidePressEvent: openWith === 'click' ? 'mousedown' : 'pointerdown',
Expand Down Expand Up @@ -278,6 +289,7 @@ export const Overlay = (props: OverlayProps) => {
placement,
scrollLock,
role,
ariaHidden,
contentId,
}),
[
Expand All @@ -301,6 +313,7 @@ export const Overlay = (props: OverlayProps) => {
placement,
scrollLock,
role,
ariaHidden,
contentId,
modal,
order,
Expand Down
22 changes: 22 additions & 0 deletions src/tedi/components/overlays/tooltip/tooltip.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,26 @@ describe('Tooltip component', () => {
const button = screen.getByTestId('test-button');
expect(button).toHaveAttribute('aria-label', 'test button');
});

it('wires aria-describedby to the trigger when open (default)', () => {
renderTooltip({ children: 'Trigger content' }, { children: 'Tooltip content' });

const trigger = screen.getByText('Trigger content');
fireEvent.mouseEnter(trigger);

expect(screen.getByText('Tooltip content')).toBeInTheDocument();
expect(trigger).toHaveAttribute('aria-describedby');
});

it('is visual-only when ariaHidden: no aria-describedby on the trigger, content is aria-hidden', () => {
renderTooltip({ children: 'Trigger content' }, { children: 'Tooltip content' }, { ariaHidden: true });

const trigger = screen.getByText('Trigger content');
fireEvent.mouseEnter(trigger);

const content = screen.getByText('Tooltip content');
expect(content).toBeInTheDocument();
expect(trigger).not.toHaveAttribute('aria-describedby');
expect(content.closest('[data-testid="overlay-content"]')).toHaveAttribute('aria-hidden', 'true');
});
});
Loading