diff --git a/src/tedi/components/buttons/button-content/button-content.spec.tsx b/src/tedi/components/buttons/button-content/button-content.spec.tsx
index f6eb0b73e..d6fb91d92 100644
--- a/src/tedi/components/buttons/button-content/button-content.spec.tsx
+++ b/src/tedi/components/buttons/button-content/button-content.spec.tsx
@@ -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();
- 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', () => {
@@ -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(
+
+ Delete
+
+ );
+ 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');
+ });
});
diff --git a/src/tedi/components/buttons/button-content/button-content.tsx b/src/tedi/components/buttons/button-content/button-content.tsx
index 15ffc5b99..e4787e82d 100644
--- a/src/tedi/components/buttons/button-content/button-content.tsx
+++ b/src/tedi/components/buttons/button-content/button-content.tsx
@@ -157,7 +157,7 @@ const InternalButtonContent = forwardRef(
? { ...defaultIconProps, name: icon }
: { ...defaultIconProps, ...icon, className: cn(defaultIconProps.className, icon?.className) };
- return isLoading ? : ;
+ return isLoading ? : ;
};
const renderContent = (): JSX.Element => {
@@ -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)
@@ -216,7 +217,7 @@ const InternalButtonContent = forwardRef(
return (
{showTooltip && isIconOnly && buttonText ? (
-
+
{buttonElement}
{buttonText}
diff --git a/src/tedi/components/loaders/spinner/spinner.spec.tsx b/src/tedi/components/loaders/spinner/spinner.spec.tsx
index 859bcd08d..10c5d7c75 100644
--- a/src/tedi/components/loaders/spinner/spinner.spec.tsx
+++ b/src/tedi/components/loaders/spinner/spinner.spec.tsx
@@ -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();
+ 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(() => ({
diff --git a/src/tedi/components/loaders/spinner/spinner.tsx b/src/tedi/components/loaders/spinner/spinner.tsx
index c3f5f1c3e..995267004 100644
--- a/src/tedi/components/loaders/spinner/spinner.tsx
+++ b/src/tedi/components/loaders/spinner/spinner.tsx
@@ -40,6 +40,13 @@ export interface SpinnerProps extends BreakpointSupport
* 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 => {
@@ -52,6 +59,7 @@ export const Spinner = (props: SpinnerProps): JSX.Element => {
color = 'primary',
label = getLabel('spinner.loading'),
position,
+ decorative = false,
} = getCurrentBreakpointProps(props);
const spinnerBEM = cn(
@@ -63,11 +71,17 @@ export const Spinner = (props: SpinnerProps): JSX.Element => {
);
return (
-
+
- {label}
+ {!decorative && {label}}
);
};
diff --git a/src/tedi/components/overlays/overlay/overlay-content.tsx b/src/tedi/components/overlays/overlay/overlay-content.tsx
index 397c0fdef..a4784dd61 100644
--- a/src/tedi/components/overlays/overlay/overlay-content.tsx
+++ b/src/tedi/components/overlays/overlay/overlay-content.tsx
@@ -63,6 +63,7 @@ export const OverlayContent = (props: OverlayContentProps) => {
scrollLock,
contentId,
role,
+ ariaHidden,
} = useContext(OverlayContext);
useEffect(() => {
@@ -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: {
diff --git a/src/tedi/components/overlays/overlay/overlay-trigger.tsx b/src/tedi/components/overlays/overlay/overlay-trigger.tsx
index 936305cba..4325381e4 100644
--- a/src/tedi/components/overlays/overlay/overlay-trigger.tsx
+++ b/src/tedi/components/overlays/overlay/overlay-trigger.tsx
@@ -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).ref]);
const extraProps =
- role === 'tooltip'
+ role === 'tooltip' && !ariaHidden
? {
'aria-describedby': open ? contentId : undefined,
}
diff --git a/src/tedi/components/overlays/overlay/overlay.tsx b/src/tedi/components/overlays/overlay/overlay.tsx
index dea295d26..304ab1bae 100644
--- a/src/tedi/components/overlays/overlay/overlay.tsx
+++ b/src/tedi/components/overlays/overlay/overlay.tsx
@@ -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.
*/
@@ -137,6 +146,7 @@ export interface OverlayContextType {
context: FloatingContext;
scrollLock?: boolean;
role?: UseRoleProps['role'];
+ ariaHidden?: boolean;
contentId: string;
}
@@ -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,
@@ -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',
@@ -278,6 +289,7 @@ export const Overlay = (props: OverlayProps) => {
placement,
scrollLock,
role,
+ ariaHidden,
contentId,
}),
[
@@ -301,6 +313,7 @@ export const Overlay = (props: OverlayProps) => {
placement,
scrollLock,
role,
+ ariaHidden,
contentId,
modal,
order,
diff --git a/src/tedi/components/overlays/tooltip/tooltip.spec.tsx b/src/tedi/components/overlays/tooltip/tooltip.spec.tsx
index 364df5bd0..1d6277905 100644
--- a/src/tedi/components/overlays/tooltip/tooltip.spec.tsx
+++ b/src/tedi/components/overlays/tooltip/tooltip.spec.tsx
@@ -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');
+ });
});