From ccbf40432724606d23456fe035cdfe4e9fcb1a07 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:27:00 +0300 Subject: [PATCH 1/2] fix(button): announce loading once and fix icon-only name duplication #70 --- .../button-content/button-content.spec.tsx | 17 ++++++++++++-- .../buttons/button-content/button-content.tsx | 5 +++-- .../loaders/spinner/spinner.spec.tsx | 9 ++++++++ .../components/loaders/spinner/spinner.tsx | 18 +++++++++++++-- .../overlays/overlay/overlay-content.tsx | 2 ++ .../overlays/overlay/overlay-trigger.tsx | 4 ++-- .../components/overlays/overlay/overlay.tsx | 15 ++++++++++++- .../overlays/tooltip/tooltip.spec.tsx | 22 +++++++++++++++++++ 8 files changed, 83 insertions(+), 9 deletions(-) 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..8e8c02661 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,11 @@ describe('ButtonContent component', () => { fireEvent.mouseEnter(button); expect(document.querySelector('.tedi-overlay__content')).not.toBeInTheDocument(); }); + + it('exposes the icon-only label once as the accessible name (visual-only tooltip)', () => { + render(Delete); + const button = screen.getByRole('button'); + expect(button).toHaveAccessibleName('Delete'); + 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..b406ef2b5 100644 --- a/src/tedi/components/loaders/spinner/spinner.spec.tsx +++ b/src/tedi/components/loaders/spinner/spinner.spec.tsx @@ -61,6 +61,15 @@ 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(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'); + }); }); From c4a4941def0e30dd3c5e9e0cb9ca4df34fa4f157 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:46:25 +0300 Subject: [PATCH 2/2] fix(button): cr fixes #70 --- .../buttons/button-content/button-content.spec.tsx | 13 +++++++++++-- .../components/loaders/spinner/spinner.spec.tsx | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) 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 8e8c02661..d6fb91d92 100644 --- a/src/tedi/components/buttons/button-content/button-content.spec.tsx +++ b/src/tedi/components/buttons/button-content/button-content.spec.tsx @@ -119,10 +119,19 @@ describe('ButtonContent component', () => { expect(document.querySelector('.tedi-overlay__content')).not.toBeInTheDocument(); }); - it('exposes the icon-only label once as the accessible name (visual-only tooltip)', () => { - render(Delete); + 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/loaders/spinner/spinner.spec.tsx b/src/tedi/components/loaders/spinner/spinner.spec.tsx index b406ef2b5..10c5d7c75 100644 --- a/src/tedi/components/loaders/spinner/spinner.spec.tsx +++ b/src/tedi/components/loaders/spinner/spinner.spec.tsx @@ -66,6 +66,7 @@ describe('Spinner component with breakpoint support', () => { 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(); });