diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 9a39bae3fc..1f4cf5ab04 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Children, + isValidElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; @@ -19,6 +27,30 @@ export type InfoTipProps = TipBaseProps & { onClick?: (arg0: { isTipHidden: boolean }) => void; }; +// Helper function to recursively extract text content from React elements +// Converts everything to plain text for screenreader announcements +const extractTextContent = (children: React.ReactNode): string => { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + + return Children.toArray(children) + .map((child) => { + if (typeof child === 'string' || typeof child === 'number') { + return String(child); + } + if (typeof child === 'boolean' || child == null) { + return ''; + } + if (isValidElement(child)) { + return extractTextContent(child.props.children); + } + return ''; + }) + .filter(Boolean) + .join(' '); +}; + export const InfoTip: React.FC = ({ alignment = 'top-right', emphasis = 'low', @@ -66,16 +98,19 @@ export const InfoTip: React.FC = ({ } }; - const handleOutsideClick = (e: MouseEvent) => { - if ( - wrapperRef.current && - (e.target instanceof HTMLElement - ? !wrapperRef.current?.contains(e?.target) - : true) - ) { - setTipIsHidden(true); - } - }; + const handleOutsideClick = useCallback( + (e: MouseEvent) => { + if ( + wrapperRef.current && + (e.target instanceof HTMLElement + ? !wrapperRef.current?.contains(e?.target) + : true) + ) { + setTipIsHidden(true); + } + }, + [setTipIsHidden] + ); const clickHandler = () => { const currentTipState = !isTipHidden; @@ -87,7 +122,11 @@ export const InfoTip: React.FC = ({ }, 0); } // we want to call the onClick handler after the tip has mounted - if (onClick) setTimeout(() => onClick({ isTipHidden: currentTipState }), 0); + // For floating placement, wait a bit longer to ensure refs are set + if (onClick) { + const delay = placement === 'floating' ? 10 : 0; + setTimeout(() => onClick({ isTipHidden: currentTipState }), delay); + } }; useEffect(() => { @@ -95,7 +134,7 @@ export const InfoTip: React.FC = ({ return () => { document.removeEventListener('mousedown', handleOutsideClick); }; - }); + }, [handleOutsideClick]); useEffect(() => { if (!isTipHidden && placement === 'floating') { @@ -106,27 +145,31 @@ export const InfoTip: React.FC = ({ } }; - const handleFocusOut = (event: FocusEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + const popoverContent = popoverContentRef.current; - const button = buttonRef.current; - const wrapper = wrapperRef.current; + if (!popoverContent) return; - const { relatedTarget } = event; + const focusableElements = popoverContent.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' + ); - if (relatedTarget instanceof Node) { - // If focus is moving back to the button or wrapper, allow it - const movingToButton = - button?.contains(relatedTarget) || wrapper?.contains(relatedTarget); - if (movingToButton) return; + if (focusableElements.length === 0) return; - // If focus is staying within the popover content, allow it - if (popoverContent?.contains(relatedTarget)) return; - } + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const { activeElement } = document; - // Return focus to button to maintain logical tab order - setTimeout(() => { + const isTabbingForwardFromLast = + !event.shiftKey && activeElement === lastElement; + const isTabbingBackwardFromFirst = + event.shiftKey && activeElement === firstElement; + + if (isTabbingForwardFromLast || isTabbingBackwardFromFirst) { + event.preventDefault(); buttonRef.current?.focus(); - }, 0); + } }; // Wait for the popover ref to be set before attaching the listener @@ -134,7 +177,7 @@ export const InfoTip: React.FC = ({ const timeoutId = setTimeout(() => { popoverContent = popoverContentRef.current; if (popoverContent) { - popoverContent.addEventListener('focusout', handleFocusOut); + popoverContent.addEventListener('keydown', handleKeyDown); } }, 0); @@ -143,7 +186,7 @@ export const InfoTip: React.FC = ({ return () => { clearTimeout(timeoutId); if (popoverContent) { - popoverContent.removeEventListener('focusout', handleFocusOut); + popoverContent.removeEventListener('keydown', handleKeyDown); } document.removeEventListener('keydown', handleGlobalEscapeKey); }; @@ -164,13 +207,18 @@ export const InfoTip: React.FC = ({ ...rest, }; + const extractedTextContent = useMemo(() => extractTextContent(info), [info]); + + const screenreaderInfo = + shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`; + const text = ( - {shouldAnnounce && !isTipHidden ? info : `\xa0`} + {screenreaderInfo} ); diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index e884f3ed9d..01c47b99ba 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,7 +1,7 @@ import { setupRtl } from '@codecademy/gamut-tests'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; +import { createRef, RefObject } from 'react'; import { Anchor } from '../../Anchor'; import { Text } from '../../Typography'; @@ -12,6 +12,85 @@ const renderView = setupRtl(InfoTip, { info, }); +const createFocusOnClick = (ref: RefObject) => { + return ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + ref.current?.focus(); + } + }; +}; + +const createLinkSetup = (linkText: string, href = 'https://example.com') => { + const linkRef = createRef(); + const info = ( + + Hey! Here is a{' '} + + {linkText} + {' '} + that is super important. + + ); + return { linkRef, info, onClick: createFocusOnClick(linkRef) }; +}; + +const createMultiLinkSetup = ( + firstLinkText: string, + secondLinkText: string, + firstHref = 'https://example.com/1', + secondHref = 'https://example.com/2' +) => { + const firstLinkRef = createRef(); + const info = ( + + + {firstLinkText} + {' '} + and {secondLinkText} + + ); + return { firstLinkRef, info, onClick: createFocusOnClick(firstLinkRef) }; +}; + +const clickButton = async (view: ReturnType['view']) => { + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + return button; +}; + +const waitForPopoverLink = async ( + view: ReturnType['view'], + linkText: string +) => { + return await waitFor(() => { + const links = view.getAllByRole('link', { name: linkText }); + expect(links.length).toBe(1); + return links[0]; + }); +}; + +const waitForLinkFocus = async ( + linkRef: RefObject, + link: HTMLElement +) => { + await waitFor( + () => { + expect(linkRef.current).toBeTruthy(); + expect(linkRef.current).toBe(link); + }, + { timeout: 2000 } + ); + + await waitFor( + () => { + expect(link).toHaveFocus(); + }, + { timeout: 2000 } + ); +}; + describe('InfoTip', () => { describe('inline placement', () => { it('shows the tip when it is clicked on', async () => { @@ -32,6 +111,79 @@ describe('InfoTip', () => { expect(tip).toBeVisible(); }); + + it('closes the tip when Escape key is pressed', async () => { + const { view } = renderView({}); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + // For inline placement, get the tip body (not the screenreader text) + const tip = + view + .getAllByText(info) + .find((el) => el.getAttribute('aria-live') !== 'assertive') || + view.getAllByText(info)[0]; + expect(tip).toBeVisible(); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(tip).not.toBeVisible(); + }); + }); + + it('allows normal tabbing through focusable elements within tip', async () => { + const firstLinkText = 'first link'; + const secondLinkText = 'second link'; + const { info, onClick } = createMultiLinkSetup( + firstLinkText, + secondLinkText + ); + const { view } = renderView({ info, onClick }); + + await clickButton(view); + + await waitFor(() => { + expect(view.getByText(firstLinkText)).toBeVisible(); + }); + + const firstLink = view.getByRole('link', { name: firstLinkText }); + await waitFor(() => { + expect(firstLink).toHaveFocus(); + }); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + const secondLink = view.getByRole('link', { name: secondLinkText }); + await waitFor(() => { + expect(secondLink).toHaveFocus(); + }); + expect(firstLink).not.toHaveFocus(); + }); + + it('allows focus to move to links within the tip', async () => { + const linkText = 'cool link'; + const { info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ info, onClick }); + + await clickButton(view); + + await waitFor(() => { + expect(view.getByText(linkText)).toBeVisible(); + }); + + const link = view.getByRole('link', { name: linkText }); + await waitFor(() => { + expect(link).toHaveFocus(); + }); + }); }); describe('floating placement', () => { @@ -55,10 +207,7 @@ describe('InfoTip', () => { placement: 'floating', }); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + const button = await clickButton(view); expect(view.queryAllByText(info).length).toBe(2); @@ -69,36 +218,29 @@ describe('InfoTip', () => { await waitFor(() => { expect(view.queryByText(info)).toBeNull(); }); - expect(button).toHaveFocus(); + await waitFor(() => { + expect(button).toHaveFocus(); + }); }); it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { const linkText = 'cool link'; - const linkRef = createRef(); + const { info, onClick } = createLinkSetup( + linkText, + 'https://giphy.com/search/nichijou' + ); const { view } = renderView({ placement: 'floating', - info: ( - - Hey! Here is a{' '} - - {linkText} - {' '} - that is super important. - - ), - onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - linkRef.current?.focus(); - } - }, + info, + onClick, }); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + const button = await clickButton(view); - expect(view.queryAllByText(linkText).length).toBe(2); + await waitFor(() => { + const links = view.getAllByRole('link', { name: linkText }); + expect(links.length).toBe(1); + }); await act(async () => { await userEvent.keyboard('{Escape}'); @@ -107,7 +249,92 @@ describe('InfoTip', () => { await waitFor(() => { expect(view.queryByText(linkText)).toBeNull(); }); - expect(button).toHaveFocus(); + await waitFor(() => { + expect(button).toHaveFocus(); + }); + }); + + it('wraps focus to button when tabbing forward from last focusable element', async () => { + const linkText = 'cool link'; + const { linkRef, info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ + placement: 'floating', + info, + onClick, + }); + + const button = await clickButton(view); + + const link = await waitForPopoverLink(view, linkText); + await waitForLinkFocus(linkRef, link); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + await waitFor(() => { + expect(button).toHaveFocus(); + }); + }); + + it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { + const linkText = 'cool link'; + const { linkRef, info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ + placement: 'floating', + info, + onClick, + }); + + const button = await clickButton(view); + + const link = await waitForPopoverLink(view, linkText); + await waitForLinkFocus(linkRef, link); + + await act(async () => { + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + }); + + await waitFor(() => { + expect(button).toHaveFocus(); + }); + }); + + it('allows normal tabbing between focusable elements within popover', async () => { + const firstLinkText = 'first link'; + const secondLinkText = 'second link'; + const { firstLinkRef, info, onClick } = createMultiLinkSetup( + firstLinkText, + secondLinkText + ); + const { view } = renderView({ + placement: 'floating', + info, + onClick, + }); + + await clickButton(view); + + const firstLink = await waitForPopoverLink(view, firstLinkText); + + await waitFor( + () => { + expect(firstLinkRef.current).toBeTruthy(); + expect(firstLinkRef.current).toBe(firstLink); + expect(firstLink).toHaveFocus(); + }, + { timeout: 2000 } + ); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + const secondLink = view.getByRole('link', { name: secondLinkText }); + await waitFor(() => { + expect(secondLink).toHaveFocus(); + }); + expect(view.getByLabelText('Show information')).not.toHaveFocus(); }); }); });