From 498f64c943dd3986354ddb6021acfd380d877bf3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:01:49 -0500 Subject: [PATCH 01/59] add logs --- packages/gamut/src/Tip/InfoTip/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 9a39bae3fc..b9636af63f 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -107,6 +107,7 @@ export const InfoTip: React.FC = ({ }; const handleFocusOut = (event: FocusEvent) => { + console.log('in handleFocusOut'); const popoverContent = popoverContentRef.current; const button = buttonRef.current; const wrapper = wrapperRef.current; @@ -117,14 +118,21 @@ export const InfoTip: React.FC = ({ // If focus is moving back to the button or wrapper, allow it const movingToButton = button?.contains(relatedTarget) || wrapper?.contains(relatedTarget); - if (movingToButton) return; + if (movingToButton) { + console.log('focus moving to button or wrapper'); + return; + } // If focus is staying within the popover content, allow it - if (popoverContent?.contains(relatedTarget)) return; + if (popoverContent?.contains(relatedTarget)) { + console.log('focus staying within popover content'); + return; + } } // Return focus to button to maintain logical tab order setTimeout(() => { + console.log('in setTimeout'); buttonRef.current?.focus(); }, 0); }; From b11d0d6ddcbb7a889d61ceadbe1177e626a630c1 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:09:16 -0500 Subject: [PATCH 02/59] up timeout --- packages/gamut/src/Tip/InfoTip/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index b9636af63f..3a23b13c0b 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -134,7 +134,7 @@ export const InfoTip: React.FC = ({ setTimeout(() => { console.log('in setTimeout'); buttonRef.current?.focus(); - }, 0); + }, 300); }; // Wait for the popover ref to be set before attaching the listener From bb9a2dd19e8cb8d98781efa618cde7290a7899f4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:09:53 -0500 Subject: [PATCH 03/59] add another log --- packages/gamut/src/Tip/InfoTip/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 3a23b13c0b..dec1101e93 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -114,6 +114,7 @@ export const InfoTip: React.FC = ({ const { relatedTarget } = event; + console.log('relatedTarget', relatedTarget); if (relatedTarget instanceof Node) { // If focus is moving back to the button or wrapper, allow it const movingToButton = From c81bae1e66321cd5d82e52edc0cd7fbf9e1f3fc7 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:25:58 -0500 Subject: [PATCH 04/59] try something --- packages/gamut/src/Tip/InfoTip/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index dec1101e93..a3b0fbe51b 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -132,10 +132,8 @@ export const InfoTip: React.FC = ({ } // Return focus to button to maintain logical tab order - setTimeout(() => { - console.log('in setTimeout'); - buttonRef.current?.focus(); - }, 300); + console.log('focusing on button'); + buttonRef.current?.focus(); }; // Wait for the popover ref to be set before attaching the listener From 39830e87aba8caf20abb682dd009c506688ba867 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:26:35 -0500 Subject: [PATCH 05/59] longer timeout --- packages/gamut/src/Tip/InfoTip/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index a3b0fbe51b..e5a5a1d64f 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -132,8 +132,10 @@ export const InfoTip: React.FC = ({ } // Return focus to button to maintain logical tab order - console.log('focusing on button'); - buttonRef.current?.focus(); + setTimeout(() => { + console.log('in setTimeout'); + buttonRef.current?.focus(); + }, 1000); }; // Wait for the popover ref to be set before attaching the listener From 3513a21c425815fe4e977673229604ae54823565 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:43:56 -0500 Subject: [PATCH 06/59] change back --- packages/gamut/src/Tip/InfoTip/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index e5a5a1d64f..229c260681 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -135,7 +135,7 @@ export const InfoTip: React.FC = ({ setTimeout(() => { console.log('in setTimeout'); buttonRef.current?.focus(); - }, 1000); + }, 0); }; // Wait for the popover ref to be set before attaching the listener From d0e9ab11edc0adbbcb6a47f2855e8c8656fc588e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 6 Nov 2025 10:54:21 -0500 Subject: [PATCH 07/59] try this --- packages/gamut/src/Tip/InfoTip/index.tsx | 55 +++++++++++------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 229c260681..143c999f1a 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -106,36 +106,33 @@ export const InfoTip: React.FC = ({ } }; - const handleFocusOut = (event: FocusEvent) => { - console.log('in handleFocusOut'); - const popoverContent = popoverContentRef.current; - const button = buttonRef.current; - const wrapper = wrapperRef.current; - - const { relatedTarget } = event; - - console.log('relatedTarget', relatedTarget); - 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) { - console.log('focus moving to button or wrapper'); - return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + const popoverContent = popoverContentRef.current; + if (!popoverContent) return; + + const focusableElements = + popoverContent.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const { activeElement } = document; + + // If tabbing forward from the last element, prevent default and move to button + if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + buttonRef.current?.focus(); } - - // If focus is staying within the popover content, allow it - if (popoverContent?.contains(relatedTarget)) { - console.log('focus staying within popover content'); - return; + // If tabbing backward from the first element, prevent default and move to button + else if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + buttonRef.current?.focus(); } } - - // Return focus to button to maintain logical tab order - setTimeout(() => { - console.log('in setTimeout'); - buttonRef.current?.focus(); - }, 0); }; // Wait for the popover ref to be set before attaching the listener @@ -143,7 +140,7 @@ export const InfoTip: React.FC = ({ const timeoutId = setTimeout(() => { popoverContent = popoverContentRef.current; if (popoverContent) { - popoverContent.addEventListener('focusout', handleFocusOut); + popoverContent.addEventListener('keydown', handleKeyDown); } }, 0); @@ -152,7 +149,7 @@ export const InfoTip: React.FC = ({ return () => { clearTimeout(timeoutId); if (popoverContent) { - popoverContent.removeEventListener('focusout', handleFocusOut); + popoverContent.removeEventListener('keydown', handleKeyDown); } document.removeEventListener('keydown', handleGlobalEscapeKey); }; From 658cc64a7ae582bcea9e1907842819deba488c2a Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 10:45:55 -0500 Subject: [PATCH 08/59] fix(InfoTip): wrap focus when shift tabbing --- packages/gamut/src/Tip/InfoTip/index.tsx | 48 ++++----- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 101 ++++++++++++++++++ 2 files changed, 124 insertions(+), 25 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 143c999f1a..2195f4c085 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -107,31 +107,29 @@ export const InfoTip: React.FC = ({ }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - const popoverContent = popoverContentRef.current; - if (!popoverContent) return; - - const focusableElements = - popoverContent.querySelectorAll( - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' - ); - - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - const { activeElement } = document; - - // If tabbing forward from the last element, prevent default and move to button - if (!event.shiftKey && activeElement === lastElement) { - event.preventDefault(); - buttonRef.current?.focus(); - } - // If tabbing backward from the first element, prevent default and move to button - else if (event.shiftKey && activeElement === firstElement) { - event.preventDefault(); - buttonRef.current?.focus(); - } + if (event.key !== 'Tab') return; + + const popoverContent = popoverContentRef.current; + if (!popoverContent) return; + + const focusableElements = popoverContent.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const { activeElement } = document; + + const isTabbingForwardFromLast = + !event.shiftKey && activeElement === lastElement; + const isTabbingBackwardFromFirst = + event.shiftKey && activeElement === firstElement; + + if (isTabbingForwardFromLast || isTabbingBackwardFromFirst) { + event.preventDefault(); + buttonRef.current?.focus(); } }; diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index e884f3ed9d..c076ef2321 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -109,5 +109,106 @@ describe('InfoTip', () => { }); expect(button).toHaveFocus(); }); + + it('wraps focus to button when tabbing forward from last focusable element', async () => { + const linkText = 'cool link'; + const { view } = renderView({ + placement: 'floating', + info: ( + + Hey! Here is a{' '} + {linkText} that is super + important. + + ), + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(view.queryAllByText(linkText).length).toBe(2); + }); + + const link = view.getAllByRole('link', { name: linkText })[1]; + link.focus(); + expect(link).toHaveFocus(); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + expect(button).toHaveFocus(); + }); + + it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { + const linkText = 'cool link'; + const { view } = renderView({ + placement: 'floating', + info: ( + + Hey! Here is a{' '} + {linkText} that is super + important. + + ), + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(view.queryAllByText(linkText).length).toBe(2); + }); + + const link = view.getAllByRole('link', { name: linkText })[1]; + link.focus(); + expect(link).toHaveFocus(); + + await act(async () => { + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + }); + + expect(button).toHaveFocus(); + }); + + it('allows normal tabbing between focusable elements within popover', async () => { + const firstLinkText = 'first link'; + const secondLinkText = 'second link'; + const { view } = renderView({ + placement: 'floating', + info: ( + + {firstLinkText} and{' '} + {secondLinkText} + + ), + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(view.queryAllByText(firstLinkText).length).toBe(2); + }); + + const firstLink = view.getAllByRole('link', { name: firstLinkText })[1]; + firstLink.focus(); + expect(firstLink).toHaveFocus(); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + const secondLink = view.getAllByRole('link', { name: secondLinkText })[1]; + expect(secondLink).toHaveFocus(); + expect(button).not.toHaveFocus(); + }); }); }); From 60c60ccdddc6cbe3d545a6ba42b74ab6f2b13d61 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 12:23:46 -0500 Subject: [PATCH 09/59] tests passing, need to dry up --- packages/gamut/src/Tip/InfoTip/index.tsx | 42 +++- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 202 ++++++++++++++++-- 2 files changed, 218 insertions(+), 26 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 2195f4c085..009f27523e 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + isValidElement, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; @@ -87,7 +93,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(() => { @@ -168,13 +178,39 @@ export const InfoTip: React.FC = ({ ...rest, }; + // 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 (!children) return ''; + + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + + if (Array.isArray(children)) { + return children.map((child) => extractTextContent(child)).join(' '); + } + + if (isValidElement(children)) { + const props = children.props as Record; + if (props.children) { + return extractTextContent(props.children as React.ReactNode); + } + } + + return ''; + }; + + const screenreaderInfo = + shouldAnnounce && !isTipHidden ? extractTextContent(info) : `\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 c076ef2321..b99061135d 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -32,6 +32,101 @@ 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); + }); + + const tips = view.getAllByText(info); + const tip = tips[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 firstLinkRef = createRef(); + const { view } = renderView({ + info: ( + + + {firstLinkText} + {' '} + and {secondLinkText} + + ), + onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + firstLinkRef.current?.focus(); + } + }, + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(view.getAllByText(firstLinkText)[0]).toBeVisible(); + }); + + const firstLink = view.getAllByRole('link', { name: firstLinkText })[0]; + expect(firstLink).toHaveFocus(); + + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + const secondLink = view.getAllByRole('link', { name: secondLinkText })[0]; + expect(secondLink).toHaveFocus(); + expect(firstLink).not.toHaveFocus(); + }); + + it('allows focus to move to links within the tip', async () => { + const linkText = 'cool link'; + const linkRef = createRef(); + const { view } = renderView({ + info: ( + + Hey! Here is a{' '} + + {linkText} + {' '} + that is super important. + + ), + onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + linkRef.current?.focus(); + } + }, + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(view.getAllByText(linkText)[0]).toBeVisible(); + }); + + const link = view.getAllByRole('link', { name: linkText })[0]; + expect(link).toHaveFocus(); + }); }); describe('floating placement', () => { @@ -98,7 +193,10 @@ describe('InfoTip', () => { await userEvent.click(button); }); - 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}'); @@ -112,15 +210,23 @@ describe('InfoTip', () => { it('wraps focus to button when tabbing forward from last focusable element', async () => { const linkText = 'cool link'; + const linkRef = createRef(); const { view } = renderView({ placement: 'floating', info: ( Hey! Here is a{' '} - {linkText} that is super - important. + + {linkText} + {' '} + that is super important. ), + onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + linkRef.current?.focus(); + } + }, }); const button = view.getByLabelText('Show information'); @@ -128,13 +234,26 @@ describe('InfoTip', () => { await userEvent.click(button); }); - await waitFor(() => { - expect(view.queryAllByText(linkText).length).toBe(2); + const link = await waitFor(() => { + const links = view.getAllByRole('link', { name: linkText }); + expect(links.length).toBe(1); + return links[0]; }); - const link = view.getAllByRole('link', { name: linkText })[1]; - link.focus(); - expect(link).toHaveFocus(); + await waitFor( + () => { + expect(linkRef.current).toBeTruthy(); + expect(linkRef.current).toBe(link); + }, + { timeout: 2000 } + ); + + await waitFor( + () => { + expect(link).toHaveFocus(); + }, + { timeout: 2000 } + ); await act(async () => { await userEvent.keyboard('{Tab}'); @@ -145,15 +264,23 @@ describe('InfoTip', () => { it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { const linkText = 'cool link'; + const linkRef = createRef(); const { view } = renderView({ placement: 'floating', info: ( Hey! Here is a{' '} - {linkText} that is super - important. + + {linkText} + {' '} + that is super important. ), + onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + linkRef.current?.focus(); + } + }, }); const button = view.getByLabelText('Show information'); @@ -161,13 +288,27 @@ describe('InfoTip', () => { await userEvent.click(button); }); - await waitFor(() => { - expect(view.queryAllByText(linkText).length).toBe(2); + // Wait for popover content to be visible (screenreader text doesn't interfere in real component) + const link = await waitFor(() => { + const links = view.getAllByRole('link', { name: linkText }); + expect(links.length).toBe(1); + return links[0]; }); - const link = view.getAllByRole('link', { name: linkText })[1]; - link.focus(); - expect(link).toHaveFocus(); + await waitFor( + () => { + expect(linkRef.current).toBeTruthy(); + expect(linkRef.current).toBe(link); + }, + { timeout: 2000 } + ); + + await waitFor( + () => { + expect(link).toHaveFocus(); + }, + { timeout: 2000 } + ); await act(async () => { await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); @@ -179,14 +320,22 @@ describe('InfoTip', () => { it('allows normal tabbing between focusable elements within popover', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; + const firstLinkRef = createRef(); const { view } = renderView({ placement: 'floating', info: ( - {firstLinkText} and{' '} - {secondLinkText} + + {firstLinkText} + {' '} + and {secondLinkText} ), + onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + firstLinkRef.current?.focus(); + } + }, }); const button = view.getByLabelText('Show information'); @@ -194,19 +343,26 @@ describe('InfoTip', () => { await userEvent.click(button); }); - await waitFor(() => { - expect(view.queryAllByText(firstLinkText).length).toBe(2); + const firstLink = await waitFor(() => { + const links = view.getAllByRole('link', { name: firstLinkText }); + expect(links.length).toBe(1); + return links[0]; }); - const firstLink = view.getAllByRole('link', { name: firstLinkText })[1]; - firstLink.focus(); - expect(firstLink).toHaveFocus(); + 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.getAllByRole('link', { name: secondLinkText })[1]; + const secondLink = view.getAllByRole('link', { name: secondLinkText })[0]; expect(secondLink).toHaveFocus(); expect(button).not.toHaveFocus(); }); From 25abb1073b29957663cb094cda4cfa1a545a72fa Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 12:41:54 -0500 Subject: [PATCH 10/59] dry up tests --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 338 ++++++++---------- 1 file changed, 154 insertions(+), 184 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index b99061135d..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 () => { @@ -41,8 +120,12 @@ describe('InfoTip', () => { await userEvent.click(button); }); - const tips = view.getAllByText(info); - const tip = tips[0]; + // 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 () => { @@ -57,75 +140,49 @@ describe('InfoTip', () => { it('allows normal tabbing through focusable elements within tip', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; - const firstLinkRef = createRef(); - const { view } = renderView({ - info: ( - - - {firstLinkText} - {' '} - and {secondLinkText} - - ), - onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - firstLinkRef.current?.focus(); - } - }, - }); + const { info, onClick } = createMultiLinkSetup( + firstLinkText, + secondLinkText + ); + const { view } = renderView({ info, onClick }); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + await clickButton(view); await waitFor(() => { - expect(view.getAllByText(firstLinkText)[0]).toBeVisible(); + expect(view.getByText(firstLinkText)).toBeVisible(); }); - const firstLink = view.getAllByRole('link', { name: firstLinkText })[0]; - expect(firstLink).toHaveFocus(); + const firstLink = view.getByRole('link', { name: firstLinkText }); + await waitFor(() => { + expect(firstLink).toHaveFocus(); + }); await act(async () => { await userEvent.keyboard('{Tab}'); }); - const secondLink = view.getAllByRole('link', { name: secondLinkText })[0]; - expect(secondLink).toHaveFocus(); + 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 linkRef = createRef(); - const { view } = renderView({ - info: ( - - Hey! Here is a{' '} - - {linkText} - {' '} - that is super important. - - ), - onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - linkRef.current?.focus(); - } - }, - }); + const { info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ info, onClick }); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + await clickButton(view); await waitFor(() => { - expect(view.getAllByText(linkText)[0]).toBeVisible(); + expect(view.getByText(linkText)).toBeVisible(); }); - const link = view.getAllByRole('link', { name: linkText })[0]; - expect(link).toHaveFocus(); + const link = view.getByRole('link', { name: linkText }); + await waitFor(() => { + expect(link).toHaveFocus(); + }); }); }); @@ -150,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); @@ -164,34 +218,24 @@ 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); await waitFor(() => { const links = view.getAllByRole('link', { name: linkText }); @@ -205,149 +249,73 @@ 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 = createRef(); + const { linkRef, info, onClick } = createLinkSetup(linkText); const { view } = renderView({ placement: 'floating', - info: ( - - Hey! Here is a{' '} - - {linkText} - {' '} - that is super important. - - ), - onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - linkRef.current?.focus(); - } - }, - }); - - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); - - const link = await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - return links[0]; + info, + onClick, }); - await waitFor( - () => { - expect(linkRef.current).toBeTruthy(); - expect(linkRef.current).toBe(link); - }, - { timeout: 2000 } - ); + const button = await clickButton(view); - await waitFor( - () => { - expect(link).toHaveFocus(); - }, - { timeout: 2000 } - ); + const link = await waitForPopoverLink(view, linkText); + await waitForLinkFocus(linkRef, link); await act(async () => { await userEvent.keyboard('{Tab}'); }); - expect(button).toHaveFocus(); + 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 = createRef(); + const { linkRef, info, onClick } = createLinkSetup(linkText); 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); - // Wait for popover content to be visible (screenreader text doesn't interfere in real component) - const link = await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - return links[0]; - }); - - await waitFor( - () => { - expect(linkRef.current).toBeTruthy(); - expect(linkRef.current).toBe(link); - }, - { timeout: 2000 } - ); - - await waitFor( - () => { - expect(link).toHaveFocus(); - }, - { timeout: 2000 } - ); + const link = await waitForPopoverLink(view, linkText); + await waitForLinkFocus(linkRef, link); await act(async () => { await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); }); - expect(button).toHaveFocus(); + 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 = createRef(); + const { firstLinkRef, info, onClick } = createMultiLinkSetup( + firstLinkText, + secondLinkText + ); const { view } = renderView({ placement: 'floating', - info: ( - - - {firstLinkText} - {' '} - and {secondLinkText} - - ), - onClick: ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - firstLinkRef.current?.focus(); - } - }, + info, + onClick, }); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + await clickButton(view); - const firstLink = await waitFor(() => { - const links = view.getAllByRole('link', { name: firstLinkText }); - expect(links.length).toBe(1); - return links[0]; - }); + const firstLink = await waitForPopoverLink(view, firstLinkText); await waitFor( () => { @@ -362,9 +330,11 @@ describe('InfoTip', () => { await userEvent.keyboard('{Tab}'); }); - const secondLink = view.getAllByRole('link', { name: secondLinkText })[0]; - expect(secondLink).toHaveFocus(); - expect(button).not.toHaveFocus(); + const secondLink = view.getByRole('link', { name: secondLinkText }); + await waitFor(() => { + expect(secondLink).toHaveFocus(); + }); + expect(view.getByLabelText('Show information')).not.toHaveFocus(); }); }); }); From 8e49f83ffb9b238ea8a62c12cd1e6f3e4606f9df Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 13:43:47 -0500 Subject: [PATCH 11/59] Children --- packages/gamut/src/Tip/InfoTip/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 009f27523e..bebb08c4ad 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,4 +1,5 @@ import { + Children, isValidElement, useCallback, useEffect, @@ -187,18 +188,18 @@ export const InfoTip: React.FC = ({ return String(children); } - if (Array.isArray(children)) { - return children.map((child) => extractTextContent(child)).join(' '); - } - if (isValidElement(children)) { const props = children.props as Record; if (props.children) { return extractTextContent(props.children as React.ReactNode); } + return ''; } - return ''; + // Children.toArray normalizes arrays and fragments automatically + return Children.toArray(children) + .map((child) => extractTextContent(child)) + .join(' '); }; const screenreaderInfo = From c0bb30d53132047425ead7a63a9ed47fa892d043 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 14:10:24 -0500 Subject: [PATCH 12/59] fix --- packages/gamut/src/Tip/InfoTip/index.tsx | 32 +++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index bebb08c4ad..48803534cc 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -3,6 +3,7 @@ import { isValidElement, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -179,31 +180,32 @@ export const InfoTip: React.FC = ({ ...rest, }; - // 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 (!children) return ''; - if (typeof children === 'string' || typeof children === 'number') { return String(children); } - if (isValidElement(children)) { - const props = children.props as Record; - if (props.children) { - return extractTextContent(props.children as React.ReactNode); - } - return ''; - } - - // Children.toArray normalizes arrays and fragments automatically return Children.toArray(children) - .map((child) => extractTextContent(child)) + .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(' '); }; + const extractedTextContent = useMemo(() => extractTextContent(info), [info]); + const screenreaderInfo = - shouldAnnounce && !isTipHidden ? extractTextContent(info) : `\xa0`; + shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`; const text = ( Date: Fri, 7 Nov 2025 15:06:35 -0500 Subject: [PATCH 13/59] lint --- packages/gamut/src/Tip/InfoTip/index.tsx | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 48803534cc..cb512599e7 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -74,16 +74,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; @@ -107,7 +110,7 @@ export const InfoTip: React.FC = ({ return () => { document.removeEventListener('mousedown', handleOutsideClick); }; - }); + }, [handleOutsideClick]); useEffect(() => { if (!isTipHidden && placement === 'floating') { From 7de0791e80b87c53c8f99c076227ddeadcfcc611 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 7 Nov 2025 15:15:42 -0500 Subject: [PATCH 14/59] lint fix --- packages/gamut/src/Tip/InfoTip/index.tsx | 46 ++++++++++++------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index cb512599e7..1f4cf5ab04 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -27,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', @@ -183,28 +207,6 @@ export const InfoTip: React.FC = ({ ...rest, }; - 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(' '); - }; - const extractedTextContent = useMemo(() => extractTextContent(info), [info]); const screenreaderInfo = From d9ada1915667c48a38d7183a79f3e7ab44bf5354 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 12 Nov 2025 12:10:51 -0500 Subject: [PATCH 15/59] refactors --- packages/gamut/src/Tip/InfoTip/index.tsx | 35 +------ .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 89 ++---------------- packages/gamut/src/Tip/__tests__/helpers.tsx | 93 +++++++++++++++++++ packages/gamut/src/utils/react.ts | 40 ++++++++ 4 files changed, 142 insertions(+), 115 deletions(-) create mode 100644 packages/gamut/src/Tip/__tests__/helpers.tsx create mode 100644 packages/gamut/src/utils/react.ts diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 1f4cf5ab04..db036cbcdd 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,13 +1,6 @@ -import { - Children, - isValidElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { extractTextContent } from '../../utils/react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; import { @@ -27,30 +20,6 @@ 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', diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 01c47b99ba..bf50032b9c 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,96 +1,21 @@ import { setupRtl } from '@codecademy/gamut-tests'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef, RefObject } from 'react'; -import { Anchor } from '../../Anchor'; -import { Text } from '../../Typography'; import { InfoTip } from '../InfoTip'; +import { + clickButton, + createLinkSetup, + createMultiLinkSetup, + waitForLinkFocus, + waitForPopoverLink, +} from './helpers'; const info = 'I am information'; 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 () => { diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx new file mode 100644 index 0000000000..4d5bc42cd0 --- /dev/null +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -0,0 +1,93 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef, RefObject } from 'react'; + +import { Anchor } from '../../Anchor'; +import { Text } from '../../Typography'; +import { InfoTip } from '../InfoTip'; + +export const createFocusOnClick = (ref: RefObject) => { + return ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) { + ref.current?.focus(); + } + }; +}; + +export 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) }; +}; + +export 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) }; +}; + +export const clickButton = async ( + view: ReturnType>['view']> +) => { + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + return button; +}; + +export 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]; + }); +}; + +export 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 } + ); +}; + diff --git a/packages/gamut/src/utils/react.ts b/packages/gamut/src/utils/react.ts new file mode 100644 index 0000000000..174c4a9660 --- /dev/null +++ b/packages/gamut/src/utils/react.ts @@ -0,0 +1,40 @@ +import { Children, isValidElement } from 'react'; + +/** + * Recursively extracts plain text content from React children. + * + * Useful for converting JSX to plain text for accessibility purposes + * like screenreader announcements or aria-labels. + * + * @param children - React children to extract text from + * @returns Plain text string with all text content joined by spaces + * + * @example + * ```tsx + * const text = extractTextContent( + *
Hello world!
+ * ); + * // Returns: "Hello world !" + * ``` + */ +export 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(' '); +}; From 1b2d3d0ddf9a21e9395dcbf3ea64ce2a42ac0cfd Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 12 Nov 2025 12:21:11 -0500 Subject: [PATCH 16/59] format --- packages/gamut/src/Tip/__tests__/helpers.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 4d5bc42cd0..44864698f8 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -90,4 +90,3 @@ export const waitForLinkFocus = async ( { timeout: 2000 } ); }; - From 2781953d039363d0ae06c0565b98c07a9fc4e32f Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 12 Nov 2025 12:25:31 -0500 Subject: [PATCH 17/59] fix view --- packages/gamut/src/Tip/__tests__/helpers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 44864698f8..4d19eaa1f5 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -51,7 +51,7 @@ export const createMultiLinkSetup = ( }; export const clickButton = async ( - view: ReturnType>['view']> + view: ReturnType>>['view'] ) => { const button = view.getByLabelText('Show information'); await act(async () => { @@ -61,7 +61,7 @@ export const clickButton = async ( }; export const waitForPopoverLink = async ( - view: ReturnType>['view']>, + view: ReturnType>>['view'], linkText: string ) => { return await waitFor(() => { From 12045944aa764398caf99548274441029809dd10 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 12 Nov 2025 15:51:16 -0500 Subject: [PATCH 18/59] DRY up test code --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 127 +++--------------- packages/gamut/src/Tip/__tests__/helpers.tsx | 91 +++++++++++++ 2 files changed, 112 insertions(+), 106 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index bf50032b9c..4e42e47ae5 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -4,11 +4,12 @@ import userEvent from '@testing-library/user-event'; import { InfoTip } from '../InfoTip'; import { - clickButton, createLinkSetup, createMultiLinkSetup, - waitForLinkFocus, - waitForPopoverLink, + openTipAndWaitForLink, + testEscapeKeyCloseTip, + testFocusWrap, + testTabbingBetweenLinks, } from './helpers'; const info = 'I am information'; @@ -71,26 +72,12 @@ describe('InfoTip', () => { ); 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(); + await testTabbingBetweenLinks( + view, + firstLinkText, + secondLinkText, + 'inline' + ); }); it('allows focus to move to links within the tip', async () => { @@ -98,13 +85,8 @@ describe('InfoTip', () => { const { info, onClick } = createLinkSetup(linkText); const { view } = renderView({ info, onClick }); - await clickButton(view); - - await waitFor(() => { - expect(view.getByText(linkText)).toBeVisible(); - }); + const link = await openTipAndWaitForLink(view, linkText); - const link = view.getByRole('link', { name: linkText }); await waitFor(() => { expect(link).toHaveFocus(); }); @@ -132,20 +114,7 @@ describe('InfoTip', () => { placement: 'floating', }); - const button = await clickButton(view); - - expect(view.queryAllByText(info).length).toBe(2); - - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); - - await waitFor(() => { - expect(view.queryByText(info)).toBeNull(); - }); - await waitFor(() => { - expect(button).toHaveFocus(); - }); + await testEscapeKeyCloseTip(view, info, true); }); it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { @@ -160,23 +129,7 @@ describe('InfoTip', () => { onClick, }); - const button = await clickButton(view); - - await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - }); - - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); - - await waitFor(() => { - expect(view.queryByText(linkText)).toBeNull(); - }); - await waitFor(() => { - expect(button).toHaveFocus(); - }); + await testEscapeKeyCloseTip(view, linkText, true); }); it('wraps focus to button when tabbing forward from last focusable element', async () => { @@ -188,18 +141,7 @@ describe('InfoTip', () => { 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(); - }); + await testFocusWrap(view, linkText, linkRef, 'forward'); }); it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { @@ -211,24 +153,13 @@ describe('InfoTip', () => { 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(); - }); + await testFocusWrap(view, linkText, linkRef, 'backward'); }); it('allows normal tabbing between focusable elements within popover', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; - const { firstLinkRef, info, onClick } = createMultiLinkSetup( + const { info, onClick } = createMultiLinkSetup( firstLinkText, secondLinkText ); @@ -238,28 +169,12 @@ describe('InfoTip', () => { 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 testTabbingBetweenLinks( + view, + firstLinkText, + secondLinkText, + 'floating' ); - - 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(); }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 4d19eaa1f5..908dda4c60 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -90,3 +90,94 @@ export const waitForLinkFocus = async ( { timeout: 2000 } ); }; + +export const openTipAndWaitForLink = async ( + view: ReturnType>>['view'], + linkText: string +) => { + await clickButton(view); + await waitFor(() => { + expect(view.getByText(linkText)).toBeVisible(); + }); + return view.getByRole('link', { name: linkText }); +}; + +export const testEscapeKeyCloseTip = async ( + view: ReturnType>>['view'], + contentToCheck: string, + shouldReturnFocus = false +) => { + const button = await clickButton(view); + + await waitFor(() => { + const elements = view.getAllByText(contentToCheck); + expect(elements.length).toBeGreaterThan(0); + }); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(view.queryByText(contentToCheck)).toBeNull(); + }); + + if (shouldReturnFocus) { + await waitFor(() => { + expect(button).toHaveFocus(); + }); + } +}; + +export const testFocusWrap = async ( + view: ReturnType>>['view'], + linkText: string, + linkRef: RefObject, + direction: 'forward' | 'backward' +) => { + const button = await clickButton(view); + const link = await waitForPopoverLink(view, linkText); + await waitForLinkFocus(linkRef, link); + + await act(async () => { + const key = direction === 'forward' ? '{Tab}' : '{Shift>}{Tab}{/Shift}'; + await userEvent.keyboard(key); + }); + + await waitFor(() => { + expect(button).toHaveFocus(); + }); +}; + +export const testTabbingBetweenLinks = async ( + view: ReturnType>>['view'], + firstLinkText: string, + secondLinkText: string, + placement: 'inline' | 'floating' +) => { + 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(); + + if (placement === 'floating') { + expect(view.getByLabelText('Show information')).not.toHaveFocus(); + } +}; From 5e681415edd8608cbbd3df12b9ea8cf87e835707 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 17 Nov 2025 13:23:23 -0500 Subject: [PATCH 19/59] test polling --- packages/gamut/src/Tip/InfoTip/index.tsx | 97 ++++++++++++++++++++---- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index db036cbcdd..4401c593e1 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -28,32 +28,65 @@ export const InfoTip: React.FC = ({ placement = tipDefaultProps.placement, ...rest }) => { + const isFloating = placement === 'floating'; + const [isTipHidden, setHideTip] = useState(true); const [isAriaHidden, setIsAriaHidden] = useState(false); const [shouldAnnounce, setShouldAnnounce] = useState(false); + const wrapperRef = useRef(null); const buttonRef = useRef(null); const popoverContentRef = useRef(null); const [loaded, setLoaded] = useState(false); + const pollTimeoutIdRef = useRef(null); + const ariaHiddenTimeoutRef = useRef(null); + const announceTimeoutRef = useRef(null); + useEffect(() => { setLoaded(true); }, []); + useEffect(() => { + return () => { + if (pollTimeoutIdRef.current) clearTimeout(pollTimeoutIdRef.current); + if (ariaHiddenTimeoutRef.current) + clearTimeout(ariaHiddenTimeoutRef.current); + if (announceTimeoutRef.current) clearTimeout(announceTimeoutRef.current); + }; + }, []); + + /* + * Clean up pending onClick poll for floating tips with interactive content + */ + useEffect(() => { + if (isFloating && isTipHidden && pollTimeoutIdRef.current) { + clearTimeout(pollTimeoutIdRef.current); + pollTimeoutIdRef.current = null; + } + }, [isTipHidden, isFloating]); + const setTipIsHidden = useCallback( (nextTipState: boolean) => { if (!nextTipState) { setHideTip(nextTipState); if (placement !== 'floating') { - // on inline component - stops text from being able to be navigated through, instead user can nav through visible text - setTimeout(() => { + if (ariaHiddenTimeoutRef.current) { + clearTimeout(ariaHiddenTimeoutRef.current); + } + ariaHiddenTimeoutRef.current = setTimeout(() => { setIsAriaHidden(true); + ariaHiddenTimeoutRef.current = null; }, 1000); } } else { if (isAriaHidden) setIsAriaHidden(false); setHideTip(nextTipState); setShouldAnnounce(false); + if (ariaHiddenTimeoutRef.current) { + clearTimeout(ariaHiddenTimeoutRef.current); + ariaHiddenTimeoutRef.current = null; + } } }, [isAriaHidden, placement] @@ -84,17 +117,55 @@ export const InfoTip: React.FC = ({ const clickHandler = () => { const currentTipState = !isTipHidden; setTipIsHidden(currentTipState); + + // Clean up previous announce timeout if any + if (announceTimeoutRef.current) { + clearTimeout(announceTimeoutRef.current); + announceTimeoutRef.current = null; + } + if (!currentTipState) { // Delay slightly to ensure focus has settled back on button before announcing - setTimeout(() => { + announceTimeoutRef.current = setTimeout(() => { setShouldAnnounce(true); + announceTimeoutRef.current = null; }, 0); } - // we want to call the onClick handler after the tip has mounted - // For floating placement, wait a bit longer to ensure refs are set + + /* + * Handle onClick callback for programmatic focus of interactive elements. + * + * For floating tips: Poll until the portal ref is available (up to 20ms) to ensure + * focusable elements inside the portal are mounted and ready. This prevents calling + * onClick before the portal content exists in the DOM. Polling is cancelled if the + * tip is closed during the wait or if max attempts are reached. + * + * For inline tips: Call onClick immediately since no portal mounting is required. + */ if (onClick) { - const delay = placement === 'floating' ? 10 : 0; - setTimeout(() => onClick({ isTipHidden: currentTipState }), delay); + if (isFloating) { + if (pollTimeoutIdRef.current) { + clearTimeout(pollTimeoutIdRef.current); + pollTimeoutIdRef.current = null; + } + + const pollForRef = (attempts = 0) => { + if (popoverContentRef.current && !currentTipState) { + pollTimeoutIdRef.current = null; + onClick({ isTipHidden: currentTipState }); + } else if (attempts < 20 && !currentTipState) { + pollTimeoutIdRef.current = setTimeout( + () => pollForRef(attempts + 1), + 1 + ); + } else { + pollTimeoutIdRef.current = null; + } + }; + pollForRef(); + } else { + onClick({ isTipHidden: currentTipState }); + } } }; @@ -162,8 +233,6 @@ export const InfoTip: React.FC = ({ } }, [isTipHidden, placement, setTipIsHidden]); - const isFloating = placement === 'floating'; - const Tip = loaded && isFloating ? FloatingTip : InlineTip; const tipProps = { @@ -171,8 +240,8 @@ export const InfoTip: React.FC = ({ escapeKeyPressHandler, info, isTipHidden, - popoverContentRef, wrapperRef, + ...(isFloating && { popoverContentRef }), ...rest, }; @@ -201,9 +270,11 @@ export const InfoTip: React.FC = ({ /> ); - /* on floating alignment - since Popover uses React.Portal the DOM order is incorrect so the screenreader text needs to be navigable, in the correct DOM order, and never aria-hidden - should be fixed in GM-797 */ + /* + on floating alignment + * since Popover uses React.Portal the DOM order is incorrect so the screenreader text needs to be navigable, in the correct DOM order, and never aria-hidden + * should be fixed in GM-797 + */ return ( From ba6f9a7ca14ae5655e33fa04e5a9b9477dc3d6e2 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 17 Nov 2025 16:55:23 -0500 Subject: [PATCH 20/59] added better focus mgmt + fixed esc handler for inline --- packages/gamut/src/Tip/InfoTip/index.tsx | 319 +++++++++--------- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 126 +++++-- packages/gamut/src/Tip/__tests__/helpers.tsx | 90 ++++- packages/gamut/src/Tip/shared/types.tsx | 4 +- 4 files changed, 349 insertions(+), 190 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 4401c593e1..c183d4e238 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { extractTextContent } from '../../utils/react'; import { FloatingTip } from '../shared/FloatingTip'; @@ -20,6 +27,10 @@ export type InfoTipProps = TipBaseProps & { onClick?: (arg0: { isTipHidden: boolean }) => void; }; +const ARIA_HIDDEN_DELAY_MS = 1000; +const FOCUSABLE_SELECTOR = + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; + export const InfoTip: React.FC = ({ alignment = 'top-right', emphasis = 'low', @@ -33,55 +44,79 @@ export const InfoTip: React.FC = ({ const [isTipHidden, setHideTip] = useState(true); const [isAriaHidden, setIsAriaHidden] = useState(false); const [shouldAnnounce, setShouldAnnounce] = useState(false); + const [loaded, setLoaded] = useState(false); const wrapperRef = useRef(null); const buttonRef = useRef(null); - const popoverContentRef = useRef(null); - const [loaded, setLoaded] = useState(false); + const popoverContentNodeRef = useRef(null); - const pollTimeoutIdRef = useRef(null); const ariaHiddenTimeoutRef = useRef(null); const announceTimeoutRef = useRef(null); - useEffect(() => { - setLoaded(true); + const getFocusableElements = useCallback(() => { + const popoverContent = popoverContentNodeRef.current; + if (!popoverContent) return []; + + return Array.from( + popoverContent.querySelectorAll(FOCUSABLE_SELECTOR) + ); }, []); + const clearAndSetTimeout = useCallback( + ( + timeoutRef: React.MutableRefObject, + callback: () => void, + delay: number + ) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callback(); + timeoutRef.current = null; + }, delay); + }, + [] + ); + + const popoverContentRef = useCallback( + (node: HTMLDivElement | null) => { + popoverContentNodeRef.current = node; + + // We call onClick when the popover is mounted to make sure the refs are available + if (node && onClick && !isTipHidden && isFloating) { + onClick({ isTipHidden: false }); + } + }, + [onClick, isTipHidden, isFloating] + ); + useEffect(() => { + setLoaded(true); + + const ariaHiddenTimeout = ariaHiddenTimeoutRef.current; + const announceTimeout = announceTimeoutRef.current; + return () => { - if (pollTimeoutIdRef.current) clearTimeout(pollTimeoutIdRef.current); - if (ariaHiddenTimeoutRef.current) - clearTimeout(ariaHiddenTimeoutRef.current); - if (announceTimeoutRef.current) clearTimeout(announceTimeoutRef.current); + if (ariaHiddenTimeout) clearTimeout(ariaHiddenTimeout); + if (announceTimeout) clearTimeout(announceTimeout); }; }, []); - /* - * Clean up pending onClick poll for floating tips with interactive content - */ - useEffect(() => { - if (isFloating && isTipHidden && pollTimeoutIdRef.current) { - clearTimeout(pollTimeoutIdRef.current); - pollTimeoutIdRef.current = null; - } - }, [isTipHidden, isFloating]); - const setTipIsHidden = useCallback( (nextTipState: boolean) => { + setHideTip(nextTipState); + if (!nextTipState) { - setHideTip(nextTipState); - if (placement !== 'floating') { - if (ariaHiddenTimeoutRef.current) { - clearTimeout(ariaHiddenTimeoutRef.current); - } - ariaHiddenTimeoutRef.current = setTimeout(() => { - setIsAriaHidden(true); - ariaHiddenTimeoutRef.current = null; - }, 1000); + if (!isFloating) { + clearAndSetTimeout( + ariaHiddenTimeoutRef, + () => setIsAriaHidden(true), + ARIA_HIDDEN_DELAY_MS + ); } } else { if (isAriaHidden) setIsAriaHidden(false); - setHideTip(nextTipState); setShouldAnnounce(false); if (ariaHiddenTimeoutRef.current) { clearTimeout(ariaHiddenTimeoutRef.current); @@ -89,24 +124,15 @@ export const InfoTip: React.FC = ({ } } }, - [isAriaHidden, placement] + [isAriaHidden, isFloating, clearAndSetTimeout] ); - const escapeKeyPressHandler = ( - event: React.KeyboardEvent - ) => { - if (event.key === 'Escape') { - setTipIsHidden(true); - } - }; - const handleOutsideClick = useCallback( (e: MouseEvent) => { + const wrapper = wrapperRef.current; if ( - wrapperRef.current && - (e.target instanceof HTMLElement - ? !wrapperRef.current?.contains(e?.target) - : true) + wrapper && + (e.target instanceof HTMLElement ? !wrapper.contains(e.target) : true) ) { setTipIsHidden(true); } @@ -114,179 +140,168 @@ export const InfoTip: React.FC = ({ [setTipIsHidden] ); - const clickHandler = () => { + const clickHandler = useCallback(() => { const currentTipState = !isTipHidden; setTipIsHidden(currentTipState); - // Clean up previous announce timeout if any - if (announceTimeoutRef.current) { - clearTimeout(announceTimeoutRef.current); - announceTimeoutRef.current = null; - } - if (!currentTipState) { - // Delay slightly to ensure focus has settled back on button before announcing - announceTimeoutRef.current = setTimeout(() => { - setShouldAnnounce(true); - announceTimeoutRef.current = null; - }, 0); + clearAndSetTimeout(announceTimeoutRef, () => setShouldAnnounce(true), 0); } + }, [isTipHidden, setTipIsHidden, clearAndSetTimeout]); - /* - * Handle onClick callback for programmatic focus of interactive elements. - * - * For floating tips: Poll until the portal ref is available (up to 20ms) to ensure - * focusable elements inside the portal are mounted and ready. This prevents calling - * onClick before the portal content exists in the DOM. Polling is cancelled if the - * tip is closed during the wait or if max attempts are reached. - * - * For inline tips: Call onClick immediately since no portal mounting is required. - */ - if (onClick) { - if (isFloating) { - if (pollTimeoutIdRef.current) { - clearTimeout(pollTimeoutIdRef.current); - pollTimeoutIdRef.current = null; - } + const handleButtonKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Tab' && event.shiftKey && !isTipHidden && isFloating) { + const focusableElements = getFocusableElements(); - const pollForRef = (attempts = 0) => { - if (popoverContentRef.current && !currentTipState) { - pollTimeoutIdRef.current = null; - onClick({ isTipHidden: currentTipState }); - } else if (attempts < 20 && !currentTipState) { - pollTimeoutIdRef.current = setTimeout( - () => pollForRef(attempts + 1), - 1 - ); - } else { - pollTimeoutIdRef.current = null; - } - }; - pollForRef(); - } else { - onClick({ isTipHidden: currentTipState }); + if (focusableElements.length > 0) { + event.preventDefault(); + focusableElements[focusableElements.length - 1].focus(); + } } + }, + [isTipHidden, isFloating, getFocusableElements] + ); + + useLayoutEffect(() => { + // for inline tips the onClick runs after DOM updates to make sure refs are available + if (!isFloating && !isTipHidden && onClick) { + onClick({ isTipHidden: false }); } - }; + }, [isTipHidden, isFloating, onClick]); useEffect(() => { + if (isTipHidden) return; + document.addEventListener('mousedown', handleOutsideClick); - return () => { - document.removeEventListener('mousedown', handleOutsideClick); - }; - }, [handleOutsideClick]); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [isTipHidden, handleOutsideClick]); useEffect(() => { - if (!isTipHidden && placement === 'floating') { - const handleGlobalEscapeKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setTipIsHidden(true); + if (isTipHidden) return; + + const handleGlobalEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setTipIsHidden(true); + // We only return focus to the button for floating tips + if (isFloating) { buttonRef.current?.focus(); } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Tab') return; + } + }; - const popoverContent = popoverContentRef.current; - if (!popoverContent) return; + document.addEventListener('keydown', handleGlobalEscapeKey); - const focusableElements = popoverContent.querySelectorAll( - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' - ); + if (isFloating) { + const handleTabKeyInPopover = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + const focusableElements = getFocusableElements(); if (focusableElements.length === 0) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const { activeElement } = document; - const isTabbingForwardFromLast = - !event.shiftKey && activeElement === lastElement; - const isTabbingBackwardFromFirst = - event.shiftKey && activeElement === firstElement; + const shouldWrapFocus = + (!event.shiftKey && activeElement === lastElement) || + (event.shiftKey && activeElement === firstElement); - if (isTabbingForwardFromLast || isTabbingBackwardFromFirst) { + if (shouldWrapFocus) { event.preventDefault(); buttonRef.current?.focus(); } }; - // Wait for the popover ref to be set before attaching the listener let popoverContent: HTMLDivElement | null = null; const timeoutId = setTimeout(() => { - popoverContent = popoverContentRef.current; + popoverContent = popoverContentNodeRef.current; if (popoverContent) { - popoverContent.addEventListener('keydown', handleKeyDown); + popoverContent.addEventListener('keydown', handleTabKeyInPopover); } }, 0); - document.addEventListener('keydown', handleGlobalEscapeKey); - return () => { clearTimeout(timeoutId); if (popoverContent) { - popoverContent.removeEventListener('keydown', handleKeyDown); + popoverContent.removeEventListener('keydown', handleTabKeyInPopover); } document.removeEventListener('keydown', handleGlobalEscapeKey); }; } - }, [isTipHidden, placement, setTipIsHidden]); + + return () => document.removeEventListener('keydown', handleGlobalEscapeKey); + }, [isTipHidden, isFloating, setTipIsHidden, getFocusableElements]); const Tip = loaded && isFloating ? FloatingTip : InlineTip; - const tipProps = { - alignment, - escapeKeyPressHandler, - info, - isTipHidden, - wrapperRef, - ...(isFloating && { popoverContentRef }), - ...rest, - }; + const tipProps = useMemo( + () => ({ + alignment, + info, + isTipHidden, + wrapperRef, + ...(isFloating && { popoverContentRef }), + ...rest, + }), + [ + alignment, + info, + isTipHidden, + wrapperRef, + isFloating, + popoverContentRef, + rest, + ] + ); const extractedTextContent = useMemo(() => extractTextContent(info), [info]); const screenreaderInfo = shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`; - const text = ( - - {screenreaderInfo} - + const screenreaderText = useMemo( + () => ( + + {screenreaderInfo} + + ), + [isAriaHidden, screenreaderInfo] ); - const tip = ( - clickHandler()} - /> + const button = useMemo( + () => ( + + ), + [isTipHidden, emphasis, clickHandler, handleButtonKeyDown] ); - /* - on floating alignment - * since Popover uses React.Portal the DOM order is incorrect so the screenreader text needs to be navigable, in the correct DOM order, and never aria-hidden - * should be fixed in GM-797 - */ - + /* + * For floating placement, screenreader text comes before button to maintain + * correct DOM order despite Portal rendering. See GM-797 for planned fix. + */ return ( {isFloating && alignment.includes('top') ? ( <> - {text} - {tip} + {screenreaderText} + {button} ) : ( <> - {tip} - {text} + {button} + {screenreaderText} )} diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 4e42e47ae5..e82423fa18 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -6,8 +6,12 @@ import { InfoTip } from '../InfoTip'; import { createLinkSetup, createMultiLinkSetup, + getTipContent, openTipAndWaitForLink, + setupLinkTestWithPlacement, + setupMultiLinkTestWithPlacement, testEscapeKeyCloseTip, + testEscapeKeyWithOutsideFocus, testFocusWrap, testTabbingBetweenLinks, } from './helpers'; @@ -46,12 +50,7 @@ describe('InfoTip', () => { 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]; + const tip = getTipContent(view, info); expect(tip).toBeVisible(); await act(async () => { @@ -66,11 +65,12 @@ describe('InfoTip', () => { it('allows normal tabbing through focusable elements within tip', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; - const { info, onClick } = createMultiLinkSetup( + const { view } = setupMultiLinkTestWithPlacement( firstLinkText, - secondLinkText + secondLinkText, + 'inline', + renderView ); - const { view } = renderView({ info, onClick }); await testTabbingBetweenLinks( view, @@ -91,6 +91,31 @@ describe('InfoTip', () => { expect(link).toHaveFocus(); }); }); + + it('closes the tip when Escape is pressed even when focus is on a link inside', async () => { + const linkText = 'cool link'; + const { info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ info, onClick }); + + const link = await openTipAndWaitForLink(view, linkText); + + await waitFor(() => { + expect(link).toHaveFocus(); + }); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(link).not.toBeVisible(); + }); + }); + + it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { + const { view } = renderView({}); + await testEscapeKeyWithOutsideFocus(view, info); + }); }); describe('floating placement', () => { @@ -134,40 +159,80 @@ describe('InfoTip', () => { 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 { view, linkRef } = setupLinkTestWithPlacement( + linkText, + 'floating', + renderView + ); await testFocusWrap(view, linkText, linkRef, 'forward'); }); 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 { view, linkRef } = setupLinkTestWithPlacement( + linkText, + 'floating', + renderView + ); await testFocusWrap(view, linkText, linkRef, 'backward'); }); + it('wraps focus to last link when shift+tabbing backward from button', async () => { + const linkText = 'cool link'; + const { view } = setupLinkTestWithPlacement( + linkText, + 'floating', + renderView + ); + + const button = view.getByLabelText('Show information'); + + // Open the tip + await act(async () => { + await userEvent.click(button); + }); + + // Wait for the link to be focused + const link = await waitFor(() => { + const links = view.getAllByRole('link', { name: linkText }); + expect(links.length).toBe(1); + return links[0]; + }); + + await waitFor(() => { + expect(link).toHaveFocus(); + }); + + // Tab forward to get back to the button + await act(async () => { + await userEvent.keyboard('{Tab}'); + }); + + await waitFor(() => { + expect(button).toHaveFocus(); + }); + + // Now Shift+Tab from the button should wrap back to the last link + await act(async () => { + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + }); + + await waitFor(() => { + expect(link).toHaveFocus(); + }); + }); + it('allows normal tabbing between focusable elements within popover', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; - const { info, onClick } = createMultiLinkSetup( + const { view } = setupMultiLinkTestWithPlacement( firstLinkText, - secondLinkText + secondLinkText, + 'floating', + renderView ); - const { view } = renderView({ - placement: 'floating', - info, - onClick, - }); await testTabbingBetweenLinks( view, @@ -176,5 +241,10 @@ describe('InfoTip', () => { 'floating' ); }); + + it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { + const { view } = renderView({ placement: 'floating' }); + await testEscapeKeyWithOutsideFocus(view, info); + }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 908dda4c60..95210ba715 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -6,6 +6,11 @@ import { createRef, RefObject } from 'react'; import { Anchor } from '../../Anchor'; import { Text } from '../../Typography'; import { InfoTip } from '../InfoTip'; +import { TipPlacements } from '../shared/types'; + +type InfoTipView = ReturnType< + ReturnType> +>['view']; export const createFocusOnClick = (ref: RefObject) => { return ({ isTipHidden }: { isTipHidden: boolean }) => { @@ -50,9 +55,7 @@ export const createMultiLinkSetup = ( return { firstLinkRef, info, onClick: createFocusOnClick(firstLinkRef) }; }; -export const clickButton = async ( - view: ReturnType>>['view'] -) => { +export const clickButton = async (view: InfoTipView) => { const button = view.getByLabelText('Show information'); await act(async () => { await userEvent.click(button); @@ -61,7 +64,7 @@ export const clickButton = async ( }; export const waitForPopoverLink = async ( - view: ReturnType>>['view'], + view: InfoTipView, linkText: string ) => { return await waitFor(() => { @@ -92,7 +95,7 @@ export const waitForLinkFocus = async ( }; export const openTipAndWaitForLink = async ( - view: ReturnType>>['view'], + view: InfoTipView, linkText: string ) => { await clickButton(view); @@ -103,7 +106,7 @@ export const openTipAndWaitForLink = async ( }; export const testEscapeKeyCloseTip = async ( - view: ReturnType>>['view'], + view: InfoTipView, contentToCheck: string, shouldReturnFocus = false ) => { @@ -130,7 +133,7 @@ export const testEscapeKeyCloseTip = async ( }; export const testFocusWrap = async ( - view: ReturnType>>['view'], + view: InfoTipView, linkText: string, linkRef: RefObject, direction: 'forward' | 'backward' @@ -149,11 +152,25 @@ export const testFocusWrap = async ( }); }; +export const getTipContent = ( + view: InfoTipView, + text: string, + useQuery = false +) => { + const getAllMethod = useQuery ? 'queryAllByText' : 'getAllByText'; + const elements = view[getAllMethod](text); + // Find the tip body (not the screenreader text with aria-live="assertive") + return ( + elements.find((el) => el.getAttribute('aria-live') !== 'assertive') || + elements[0] + ); +}; + export const testTabbingBetweenLinks = async ( - view: ReturnType>>['view'], + view: InfoTipView, firstLinkText: string, secondLinkText: string, - placement: 'inline' | 'floating' + placement: TipPlacements ) => { await clickButton(view); @@ -181,3 +198,58 @@ export const testTabbingBetweenLinks = async ( expect(view.getByLabelText('Show information')).not.toHaveFocus(); } }; + +export const setupLinkTestWithPlacement = ( + linkText: string, + placement: TipPlacements, + renderView: ReturnType> +) => { + const { linkRef, info, onClick } = createLinkSetup(linkText); + const { view } = renderView({ placement, info, onClick }); + return { view, linkRef, info, onClick }; +}; + +export const setupMultiLinkTestWithPlacement = ( + firstLinkText: string, + secondLinkText: string, + placement: TipPlacements, + renderView: ReturnType> +) => { + const { info, onClick } = createMultiLinkSetup(firstLinkText, secondLinkText); + const { view } = renderView({ placement, info, onClick }); + return { view, info, onClick }; +}; + +export const testEscapeKeyWithOutsideFocus = async ( + view: InfoTipView, + contentToCheck: string +) => { + const outsideButton = document.createElement('button'); + outsideButton.textContent = 'Outside Button'; + document.body.appendChild(outsideButton); + + try { + const button = await clickButton(view); + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'true'); + const tip = getTipContent(view, contentToCheck); + expect(tip).toBeVisible(); + }); + + outsideButton.focus(); + expect(outsideButton).toHaveFocus(); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'false'); + const tip = getTipContent(view, contentToCheck, true); + expect(tip).not.toBeVisible(); + }); + } finally { + document.body.removeChild(outsideButton); + } +}; diff --git a/packages/gamut/src/Tip/shared/types.tsx b/packages/gamut/src/Tip/shared/types.tsx index a5b70fc1b5..a91c5e6a6f 100644 --- a/packages/gamut/src/Tip/shared/types.tsx +++ b/packages/gamut/src/Tip/shared/types.tsx @@ -78,7 +78,9 @@ export type TipPlacementComponentProps = Omit< escapeKeyPressHandler?: (event: React.KeyboardEvent) => void; id?: string; isTipHidden?: boolean; - popoverContentRef?: React.RefObject; + popoverContentRef?: + | React.RefObject + | ((node: HTMLDivElement | null) => void); type: 'info' | 'tool' | 'preview'; wrapperRef?: React.RefObject; zIndex?: number; From 53b3d5fa1e481b7b41c1cdd858618ef62e98fe34 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 18 Nov 2025 11:37:48 -0500 Subject: [PATCH 21/59] better docs --- packages/gamut/src/Tip/InfoTip/index.tsx | 43 ++---- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 115 ++++++++------ .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 23 +++ .../Tips/InfoTip/InfoTip.stories.tsx | 142 +++++++++++++++++- 4 files changed, 245 insertions(+), 78 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index c183d4e238..9cb3ce54f5 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -30,6 +30,7 @@ export type InfoTipProps = TipBaseProps & { const ARIA_HIDDEN_DELAY_MS = 1000; const FOCUSABLE_SELECTOR = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; +const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; export const InfoTip: React.FC = ({ alignment = 'top-right', @@ -149,20 +150,6 @@ export const InfoTip: React.FC = ({ } }, [isTipHidden, setTipIsHidden, clearAndSetTimeout]); - const handleButtonKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === 'Tab' && event.shiftKey && !isTipHidden && isFloating) { - const focusableElements = getFocusableElements(); - - if (focusableElements.length > 0) { - event.preventDefault(); - focusableElements[focusableElements.length - 1].focus(); - } - } - }, - [isTipHidden, isFloating, getFocusableElements] - ); - useLayoutEffect(() => { // for inline tips the onClick runs after DOM updates to make sure refs are available if (!isFloating && !isTipHidden && onClick) { @@ -181,33 +168,30 @@ export const InfoTip: React.FC = ({ if (isTipHidden) return; const handleGlobalEscapeKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setTipIsHidden(true); - // We only return focus to the button for floating tips - if (isFloating) { - buttonRef.current?.focus(); - } - } + if (e.key !== 'Escape') return; + + const hasModal = document.querySelector(MODAL_SELECTOR); + if (hasModal) return; + + e.preventDefault(); + setTipIsHidden(true); + buttonRef.current?.focus(); }; document.addEventListener('keydown', handleGlobalEscapeKey); if (isFloating) { const handleTabKeyInPopover = (event: KeyboardEvent) => { - if (event.key !== 'Tab') return; + if (event.key !== 'Tab' || event.shiftKey) return; const focusableElements = getFocusableElements(); if (focusableElements.length === 0) return; - const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const { activeElement } = document; - const shouldWrapFocus = - (!event.shiftKey && activeElement === lastElement) || - (event.shiftKey && activeElement === firstElement); - - if (shouldWrapFocus) { + // Only wrap forward: if on last element, wrap to button + if (activeElement === lastElement) { event.preventDefault(); buttonRef.current?.focus(); } @@ -281,10 +265,9 @@ export const InfoTip: React.FC = ({ emphasis={emphasis} ref={buttonRef} onClick={clickHandler} - onKeyDown={handleButtonKeyDown} /> ), - [isTipHidden, emphasis, clickHandler, handleButtonKeyDown] + [isTipHidden, emphasis, clickHandler] ); /* diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index e82423fa18..1bff0241b1 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { InfoTip } from '../InfoTip'; import { createLinkSetup, - createMultiLinkSetup, getTipContent, openTipAndWaitForLink, setupLinkTestWithPlacement, @@ -42,7 +41,7 @@ describe('InfoTip', () => { expect(tip).toBeVisible(); }); - it('closes the tip when Escape key is pressed', async () => { + it('closes the tip when Escape key is pressed and returns focus to button', async () => { const { view } = renderView({}); const button = view.getByLabelText('Show information'); @@ -59,6 +58,7 @@ describe('InfoTip', () => { await waitFor(() => { expect(tip).not.toBeVisible(); + expect(button).toHaveFocus(); }); }); @@ -116,6 +116,37 @@ describe('InfoTip', () => { const { view } = renderView({}); await testEscapeKeyWithOutsideFocus(view, info); }); + + it('does not close the tip when Escape is pressed if a modal is open', async () => { + const { view } = renderView({}); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + + // Simulate a modal being present in the DOM + const mockModal = document.createElement('div'); + mockModal.setAttribute('role', 'dialog'); + document.body.appendChild(mockModal); + + try { + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + // Tip should still be visible because modal is present + await waitFor(() => { + expect(tip).toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + } finally { + document.body.removeChild(mockModal); + } + }); }); describe('floating placement', () => { @@ -179,51 +210,6 @@ describe('InfoTip', () => { await testFocusWrap(view, linkText, linkRef, 'backward'); }); - it('wraps focus to last link when shift+tabbing backward from button', async () => { - const linkText = 'cool link'; - const { view } = setupLinkTestWithPlacement( - linkText, - 'floating', - renderView - ); - - const button = view.getByLabelText('Show information'); - - // Open the tip - await act(async () => { - await userEvent.click(button); - }); - - // Wait for the link to be focused - const link = await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - return links[0]; - }); - - await waitFor(() => { - expect(link).toHaveFocus(); - }); - - // Tab forward to get back to the button - await act(async () => { - await userEvent.keyboard('{Tab}'); - }); - - await waitFor(() => { - expect(button).toHaveFocus(); - }); - - // Now Shift+Tab from the button should wrap back to the last link - await act(async () => { - await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - }); - - await waitFor(() => { - expect(link).toHaveFocus(); - }); - }); - it('allows normal tabbing between focusable elements within popover', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; @@ -246,5 +232,40 @@ describe('InfoTip', () => { const { view } = renderView({ placement: 'floating' }); await testEscapeKeyWithOutsideFocus(view, info); }); + + it('does not close the tip when Escape is pressed if a modal is open', async () => { + const { view } = renderView({ placement: 'floating' }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'true'); + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + }); + + // Simulate a modal being present in the DOM + const mockModal = document.createElement('div'); + mockModal.setAttribute('role', 'dialog'); + document.body.appendChild(mockModal); + + try { + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + // Tip should still be visible because modal is present + await waitFor(() => { + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + } finally { + document.body.removeChild(mockModal); + } + }); }); }); diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 2bc24f4d30..e88aa325c5 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -53,12 +53,35 @@ This `floating` variant should only be used as needed. +# Keyboard Navigation & Accessibility + ## InfoTips with links or buttons Links or buttons within InfoTips should be used sparingly and only when the information is critical to the user's understanding of the content. If an infotip _absolutely requires_ a link or button, it needs to provide a programmatic focus by way of the `onClick` prop. The `onClick` prop accepts a function that calls the object `{isTipHidden}` and should focus when the tip is visible. +### Floating Placement + +When using `placement="floating"`, InfoTips implement **forward focus wrapping** to keep users within the tip context: + +- **Tab**: Navigate forward through focusable elements (links, buttons) inside the tip. When reaching the last element, wraps back to the InfoTip button +- **Shift+Tab**: Navigate backward naturally - from the button, exits to the previous page element (no backward trap) +- **Escape**: Closes the tip and returns focus to the InfoTip button + + + +### Global Escape Key Handling + +InfoTips listen for the Escape key globally and will close automatically **unless** a higher-priority element is open: + +- **InfoTips close on Escape** regardless of where keyboard focus is +- **Modals and dialogs take priority** - InfoTips detect when `dialog[open]`, `role="dialog"`, or `role="alertdialog"` elements are present and defer Escape handling to them +- After closing a modal, pressing Escape again will close the InfoTip +- When an InfoTip closes via Escape, focus automatically returns to the InfoTip button + + + ## InfoTips and zIndex You can change the zIndex of your `InfoTip` with the zIndex property. diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index efad164ad6..95fac38774 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -1,13 +1,15 @@ import { Anchor, Box, + FillButton, FlexBox, GridBox, InfoTip, + Modal, Text, } from '@codecademy/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; const meta: Meta = { component: InfoTip, @@ -124,3 +126,141 @@ export const ZIndex: Story = { ), }; + +export const KeyboardNavigation: Story = { + args: { + placement: 'floating', + }, + render: function KeyboardNavigation(args) { + return ( + + + + + Floating Placement + + + With focus trap + + Link 1,{' '} + Link 2,{' '} + Link 3 + + } + placement="floating" + /> + + + + + + Inline Placement + + + Normal tab flow + + Link A,{' '} + Link B + + } + placement="inline" + /> + + + + + + + Keyboard Navigation: + + +
  • + Floating - Tab: Navigates forward through links, + then wraps to button (contained) +
  • +
  • + Floating - Shift+Tab: Natural backward navigation + (exits to page) +
  • +
  • + Inline: Tab/Shift+Tab follows normal document + flow +
  • +
  • + Escape (both): Closes tip and returns focus to + button +
  • +
  • + Escape works even when focus is on links or outside elements +
  • +
    +
    +
    + ); + }, +}; + +export const WithModal: Story = { + args: { + placement: 'floating', + info: 'This InfoTip should not close when you press Escape inside the modal!', + }, + render: function WithModal(args) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + + + Here is some information + + + + setIsModalOpen(true)}>Open Modal + + + + Test Escape Key Behavior with Modals: + + + 1. Click the InfoTip to open it +
    + 2. Click "Open Modal" button +
    + 3. Press Escape - should close modal only (InfoTip stays open) +
    + 4. Press Escape again - should close InfoTip +
    +
    + + InfoTip detects when modals are open and defers Escape key + handling to them. + +
    +
    + + setIsModalOpen(false)} + > + + + This is a modal. Press Escape to close it. +
    + The InfoTip should remain open behind this modal. +
    + setIsModalOpen(false)}> + Close Modal + +
    +
    +
    + ); + }, +}; From bede696e43ec7a887216d25bbcef7ce59c19726e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 18 Nov 2025 13:48:03 -0500 Subject: [PATCH 22/59] fix popover r/l width --- packages/gamut/src/Popover/styles/base.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/Popover/styles/base.ts b/packages/gamut/src/Popover/styles/base.ts index 97cc96bd10..d3aae9f9c2 100644 --- a/packages/gamut/src/Popover/styles/base.ts +++ b/packages/gamut/src/Popover/styles/base.ts @@ -39,6 +39,9 @@ export const raisedDivVariants = variant({ bg: popoverPrimaryBgColor, borderRadius: 'sm', }, - secondary: { ...toolTipBodyCss }, + secondary: { + ...toolTipBodyCss, + width: 'inherit', + }, }, }); From 259a63b20da21171787b930c82d3577210d21f7d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 19 Nov 2025 12:20:34 -0500 Subject: [PATCH 23/59] refactor for container refs --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 149 +++++++++--------- packages/gamut/src/Tip/__tests__/helpers.tsx | 111 ++++++------- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 6 +- .../Tips/InfoTip/InfoTip.stories.tsx | 101 ++++++------ 4 files changed, 185 insertions(+), 182 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 1bff0241b1..d0bc458563 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -7,6 +7,7 @@ import { createLinkSetup, getTipContent, openTipAndWaitForLink, + pressKey, setupLinkTestWithPlacement, setupMultiLinkTestWithPlacement, testEscapeKeyCloseTip, @@ -20,6 +21,66 @@ const renderView = setupRtl(InfoTip, { info, }); +const openTipTabToLinkAndWaitForFocus = async ( + view: ReturnType['view'], + linkText: string +) => { + const link = await openTipAndWaitForLink(view, linkText); + await act(async () => { + await userEvent.tab(); + }); + await waitFor(() => { + expect(link).toHaveFocus(); + }); + return link; +}; + +const testModalDoesNotCloseInfoTip = async ( + view: ReturnType['view'], + info: string, + useModalButton = false +) => { + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'true'); + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + }); + + const mockModal = document.createElement('div'); + mockModal.setAttribute('role', 'dialog'); + + if (useModalButton) { + const modalButton = document.createElement('button'); + modalButton.textContent = 'Modal button'; + mockModal.appendChild(modalButton); + view.container.appendChild(mockModal); + modalButton.focus(); + } else { + document.body.appendChild(mockModal); + } + + try { + await pressKey('{Escape}'); + + await waitFor(() => { + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + } finally { + if (useModalButton) { + view.container.removeChild(mockModal); + } else { + document.body.removeChild(mockModal); + } + } +}; + describe('InfoTip', () => { describe('inline placement', () => { it('shows the tip when it is clicked on', async () => { @@ -52,9 +113,7 @@ describe('InfoTip', () => { const tip = getTipContent(view, info); expect(tip).toBeVisible(); - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); + await pressKey('{Escape}'); await waitFor(() => { expect(tip).not.toBeVisible(); @@ -85,11 +144,7 @@ describe('InfoTip', () => { const { info, onClick } = createLinkSetup(linkText); const { view } = renderView({ info, onClick }); - const link = await openTipAndWaitForLink(view, linkText); - - await waitFor(() => { - expect(link).toHaveFocus(); - }); + await openTipTabToLinkAndWaitForFocus(view, linkText); }); it('closes the tip when Escape is pressed even when focus is on a link inside', async () => { @@ -97,15 +152,9 @@ describe('InfoTip', () => { const { info, onClick } = createLinkSetup(linkText); const { view } = renderView({ info, onClick }); - const link = await openTipAndWaitForLink(view, linkText); + const link = await openTipTabToLinkAndWaitForFocus(view, linkText); - await waitFor(() => { - expect(link).toHaveFocus(); - }); - - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); + await pressKey('{Escape}'); await waitFor(() => { expect(link).not.toBeVisible(); @@ -119,33 +168,7 @@ describe('InfoTip', () => { it('does not close the tip when Escape is pressed if a modal is open', async () => { const { view } = renderView({}); - - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); - - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - - // Simulate a modal being present in the DOM - const mockModal = document.createElement('div'); - mockModal.setAttribute('role', 'dialog'); - document.body.appendChild(mockModal); - - try { - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); - - // Tip should still be visible because modal is present - await waitFor(() => { - expect(tip).toBeVisible(); - expect(button).toHaveAttribute('aria-expanded', 'true'); - }); - } finally { - document.body.removeChild(mockModal); - } + await testModalDoesNotCloseInfoTip(view, info); }); }); @@ -190,24 +213,24 @@ describe('InfoTip', () => { it('wraps focus to button when tabbing forward from last focusable element', async () => { const linkText = 'cool link'; - const { view, linkRef } = setupLinkTestWithPlacement( + const { view, containerRef } = setupLinkTestWithPlacement( linkText, 'floating', renderView ); - await testFocusWrap(view, linkText, linkRef, 'forward'); + await testFocusWrap(view, containerRef, 'forward'); }); it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { const linkText = 'cool link'; - const { view, linkRef } = setupLinkTestWithPlacement( + const { view, containerRef } = setupLinkTestWithPlacement( linkText, 'floating', renderView ); - await testFocusWrap(view, linkText, linkRef, 'backward'); + await testFocusWrap(view, containerRef, 'backward'); }); it('allows normal tabbing between focusable elements within popover', async () => { @@ -235,37 +258,7 @@ describe('InfoTip', () => { it('does not close the tip when Escape is pressed if a modal is open', async () => { const { view } = renderView({ placement: 'floating' }); - - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); - - await waitFor(() => { - expect(button).toHaveAttribute('aria-expanded', 'true'); - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - }); - - // Simulate a modal being present in the DOM - const mockModal = document.createElement('div'); - mockModal.setAttribute('role', 'dialog'); - document.body.appendChild(mockModal); - - try { - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); - - // Tip should still be visible because modal is present - await waitFor(() => { - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - expect(button).toHaveAttribute('aria-expanded', 'true'); - }); - } finally { - document.body.removeChild(mockModal); - } + await testModalDoesNotCloseInfoTip(view, info, true); }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 95210ba715..1b86e22592 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -12,7 +12,7 @@ type InfoTipView = ReturnType< ReturnType> >['view']; -export const createFocusOnClick = (ref: RefObject) => { +export const createFocusOnClick = (ref: RefObject) => { return ({ isTipHidden }: { isTipHidden: boolean }) => { if (!isTipHidden) { ref.current?.focus(); @@ -24,17 +24,14 @@ export const createLinkSetup = ( linkText: string, href = 'https://example.com' ) => { - const linkRef = createRef(); + const containerRef = createRef(); const info = ( - - Hey! Here is a{' '} - - {linkText} - {' '} - that is super important. + + Hey! Here is a {linkText} that is super + important. ); - return { linkRef, info, onClick: createFocusOnClick(linkRef) }; + return { containerRef, info, onClick: createFocusOnClick(containerRef) }; }; export const createMultiLinkSetup = ( @@ -43,16 +40,14 @@ export const createMultiLinkSetup = ( firstHref = 'https://example.com/1', secondHref = 'https://example.com/2' ) => { - const firstLinkRef = createRef(); + const containerRef = createRef(); const info = ( - - - {firstLinkText} - {' '} - and {secondLinkText} + + {firstLinkText} and{' '} + {secondLinkText} ); - return { firstLinkRef, info, onClick: createFocusOnClick(firstLinkRef) }; + return { containerRef, info, onClick: createFocusOnClick(containerRef) }; }; export const clickButton = async (view: InfoTipView) => { @@ -74,24 +69,35 @@ export const waitForPopoverLink = async ( }); }; -export const waitForLinkFocus = async ( - linkRef: RefObject, - link: HTMLElement +export const pressKey = async (key: string) => { + await act(async () => { + await userEvent.keyboard(key); + }); +}; + +export const waitForContainerFocus = async ( + containerRef: RefObject, + container: HTMLElement ) => { await waitFor( () => { - expect(linkRef.current).toBeTruthy(); - expect(linkRef.current).toBe(link); + expect(containerRef.current).toBeTruthy(); + expect(containerRef.current).toBe(container); + expect(container).toHaveFocus(); }, { timeout: 2000 } ); +}; - await waitFor( - () => { - expect(link).toHaveFocus(); - }, - { timeout: 2000 } - ); +export const waitForLinkToHaveFocus = async ( + view: InfoTipView, + linkText: string +) => { + const link = view.getByRole('link', { name: linkText }); + await waitFor(() => { + expect(link).toHaveFocus(); + }); + return link; }; export const openTipAndWaitForLink = async ( @@ -117,9 +123,7 @@ export const testEscapeKeyCloseTip = async ( expect(elements.length).toBeGreaterThan(0); }); - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); + await pressKey('{Escape}'); await waitFor(() => { expect(view.queryByText(contentToCheck)).toBeNull(); @@ -134,18 +138,25 @@ export const testEscapeKeyCloseTip = async ( export const testFocusWrap = async ( view: InfoTipView, - linkText: string, - linkRef: RefObject, + containerRef: RefObject, direction: 'forward' | 'backward' ) => { const button = await clickButton(view); - const link = await waitForPopoverLink(view, linkText); - await waitForLinkFocus(linkRef, link); - await act(async () => { - const key = direction === 'forward' ? '{Tab}' : '{Shift>}{Tab}{/Shift}'; - await userEvent.keyboard(key); - }); + await waitFor( + () => { + expect(containerRef.current).toBeTruthy(); + expect(containerRef.current).toHaveFocus(); + }, + { timeout: 2000 } + ); + + if (direction === 'forward') { + await pressKey('{Tab}'); + await pressKey('{Tab}'); + } else { + await pressKey('{Shift>}{Tab}{/Shift}'); + } await waitFor(() => { expect(button).toHaveFocus(); @@ -178,19 +189,11 @@ export const testTabbingBetweenLinks = async ( expect(view.getByText(firstLinkText)).toBeVisible(); }); - const firstLink = view.getByRole('link', { name: firstLinkText }); - await waitFor(() => { - expect(firstLink).toHaveFocus(); - }); + await pressKey('{Tab}'); + const firstLink = await waitForLinkToHaveFocus(view, firstLinkText); - await act(async () => { - await userEvent.keyboard('{Tab}'); - }); - - const secondLink = view.getByRole('link', { name: secondLinkText }); - await waitFor(() => { - expect(secondLink).toHaveFocus(); - }); + await pressKey('{Tab}'); + await waitForLinkToHaveFocus(view, secondLinkText); expect(firstLink).not.toHaveFocus(); @@ -204,9 +207,9 @@ export const setupLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { linkRef, info, onClick } = createLinkSetup(linkText); + const { containerRef, info, onClick } = createLinkSetup(linkText); const { view } = renderView({ placement, info, onClick }); - return { view, linkRef, info, onClick }; + return { view, containerRef, info, onClick }; }; export const setupMultiLinkTestWithPlacement = ( @@ -240,9 +243,7 @@ export const testEscapeKeyWithOutsideFocus = async ( outsideButton.focus(); expect(outsideButton).toHaveFocus(); - await act(async () => { - await userEvent.keyboard('{Escape}'); - }); + await pressKey('{Escape}'); await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'false'); diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index e88aa325c5..97219463c4 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -11,7 +11,7 @@ export const parameters = { type: 'figma', url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/branch/ayKNSg6QvZUjsgw0FFysW4/%F0%9F%93%90-Gamut?type=design&node-id=41538-55277&mode=design&t=fGkWf5GSl5cj5fQo-0', }, - status: 'current', + status: 'updating', source: { repo: 'gamut', githubLink: @@ -53,9 +53,9 @@ This `floating` variant should only be used as needed. -# Keyboard Navigation & Accessibility +## Keyboard Navigation & Accessibility -## InfoTips with links or buttons +### InfoTips with links or buttons Links or buttons within InfoTips should be used sparingly and only when the information is critical to the user's understanding of the content. If an infotip _absolutely requires_ a link or button, it needs to provide a programmatic focus by way of the `onClick` prop. The `onClick` prop accepts a function that calls the object `{isTipHidden}` and should focus when the tip is visible. diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 95fac38774..de8f5dee7f 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -77,7 +77,7 @@ export const WithLinksOrButtons: Story = { placement: 'floating', }, render: function WithLinksOrButtons(args) { - const ref = useRef(null); + const ref = useRef(null); const onClick = ({ isTipHidden }: { isTipHidden: boolean }) => { if (!isTipHidden) ref.current?.focus(); @@ -89,9 +89,9 @@ export const WithLinksOrButtons: Story = { + Hey! Here is a{' '} - + cool link {' '} that is super important. This is a{' '} @@ -128,58 +128,67 @@ export const ZIndex: Story = { }; export const KeyboardNavigation: Story = { - args: { - placement: 'floating', - }, - render: function KeyboardNavigation(args) { + render: function KeyboardNavigation() { + const floatingRef = useRef(null); + const inlineRef = useRef(null); + + const examples = [ + { + title: 'Floating Placement', + placement: 'floating' as const, + ref: floatingRef, + links: ['Link 1', 'Link 2', 'Link 3'], + }, + { + title: 'Inline Placement', + placement: 'inline' as const, + alignment: 'bottom-right' as const, + ref: inlineRef, + links: ['Link A', 'Link B'], + }, + ]; + return ( - - - Floating Placement - - - With focus trap - - Link 1,{' '} - Link 2,{' '} - Link 3 - - } - placement="floating" - /> - - + {examples.map(({ title, placement, alignment, ref, links }) => { + const onClick = ({ isTipHidden }: { isTipHidden: boolean }) => { + if (!isTipHidden) ref.current?.focus(); + }; - - - Inline Placement - - - Normal tab flow - - Link A,{' '} - Link B - - } - placement="inline" - /> - - + return ( + + + {title} + + + {links.map((label, idx) => ( + <> + {idx > 0 && ', '} + + {label} + + {idx < links.length - 1 && ' '} + + ))} + + } + placement={placement} + onClick={onClick} + /> + + ); + })} Keyboard Navigation: - +
  • Floating - Tab: Navigates forward through links, then wraps to button (contained) @@ -199,7 +208,7 @@ export const KeyboardNavigation: Story = {
  • Escape works even when focus is on links or outside elements
  • -
    +
    ); From b00417e76fa918bc5259105e2e0f6648a262c4ed Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 20 Nov 2025 10:07:16 -0500 Subject: [PATCH 24/59] fix(InfoTip): Remove ariaa-live and implement focus mgmt --- packages/gamut/src/Tip/__tests__/InfoTip.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index d0bc458563..619cf1afbe 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -16,6 +16,8 @@ import { testTabbingBetweenLinks, } from './helpers'; +// GMT-216 + const info = 'I am information'; const renderView = setupRtl(InfoTip, { info, From a42c7dba8b282eaf79be5ddb497a58bc3589ae6d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 20 Nov 2025 10:28:14 -0500 Subject: [PATCH 25/59] remove aria-live --- packages/gamut/src/Tip/InfoTip/elements.tsx | 8 --- packages/gamut/src/Tip/InfoTip/index.tsx | 60 +++------------------ packages/gamut/src/utils/react.ts | 40 -------------- 3 files changed, 7 insertions(+), 101 deletions(-) delete mode 100644 packages/gamut/src/Tip/InfoTip/elements.tsx delete mode 100644 packages/gamut/src/utils/react.ts diff --git a/packages/gamut/src/Tip/InfoTip/elements.tsx b/packages/gamut/src/Tip/InfoTip/elements.tsx deleted file mode 100644 index f1e21a5350..0000000000 --- a/packages/gamut/src/Tip/InfoTip/elements.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { css } from '@codecademy/gamut-styles'; -import styled from '@emotion/styled'; - -import { Text } from '../../Typography'; - -export const ScreenreaderNavigableText = styled(Text)( - css({ position: 'absolute' }) -); diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 9cb3ce54f5..7d6144eacb 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -7,7 +7,6 @@ import { useState, } from 'react'; -import { extractTextContent } from '../../utils/react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; import { @@ -15,7 +14,6 @@ import { TipBaseProps, tipDefaultProps, } from '../shared/types'; -import { ScreenreaderNavigableText } from './elements'; import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { @@ -44,7 +42,6 @@ export const InfoTip: React.FC = ({ const [isTipHidden, setHideTip] = useState(true); const [isAriaHidden, setIsAriaHidden] = useState(false); - const [shouldAnnounce, setShouldAnnounce] = useState(false); const [loaded, setLoaded] = useState(false); const wrapperRef = useRef(null); @@ -52,7 +49,6 @@ export const InfoTip: React.FC = ({ const popoverContentNodeRef = useRef(null); const ariaHiddenTimeoutRef = useRef(null); - const announceTimeoutRef = useRef(null); const getFocusableElements = useCallback(() => { const popoverContent = popoverContentNodeRef.current; @@ -96,11 +92,9 @@ export const InfoTip: React.FC = ({ setLoaded(true); const ariaHiddenTimeout = ariaHiddenTimeoutRef.current; - const announceTimeout = announceTimeoutRef.current; return () => { if (ariaHiddenTimeout) clearTimeout(ariaHiddenTimeout); - if (announceTimeout) clearTimeout(announceTimeout); }; }, []); @@ -118,7 +112,6 @@ export const InfoTip: React.FC = ({ } } else { if (isAriaHidden) setIsAriaHidden(false); - setShouldAnnounce(false); if (ariaHiddenTimeoutRef.current) { clearTimeout(ariaHiddenTimeoutRef.current); ariaHiddenTimeoutRef.current = null; @@ -144,11 +137,7 @@ export const InfoTip: React.FC = ({ const clickHandler = useCallback(() => { const currentTipState = !isTipHidden; setTipIsHidden(currentTipState); - - if (!currentTipState) { - clearAndSetTimeout(announceTimeoutRef, () => setShouldAnnounce(true), 0); - } - }, [isTipHidden, setTipIsHidden, clearAndSetTimeout]); + }, [isTipHidden, setTipIsHidden]); useLayoutEffect(() => { // for inline tips the onClick runs after DOM updates to make sure refs are available @@ -239,26 +228,12 @@ export const InfoTip: React.FC = ({ ] ); - const extractedTextContent = useMemo(() => extractTextContent(info), [info]); - - const screenreaderInfo = - shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`; - - const screenreaderText = useMemo( - () => ( - - {screenreaderInfo} - - ), - [isAriaHidden, screenreaderInfo] - ); - - const button = useMemo( - () => ( + /* + * For floating placement, screenreader text comes before button to maintain + * correct DOM order despite Portal rendering. See GM-797 for planned fix. + */ + return ( + = ({ ref={buttonRef} onClick={clickHandler} /> - ), - [isTipHidden, emphasis, clickHandler] - ); - - /* - * For floating placement, screenreader text comes before button to maintain - * correct DOM order despite Portal rendering. See GM-797 for planned fix. - */ - return ( - - {isFloating && alignment.includes('top') ? ( - <> - {screenreaderText} - {button} - - ) : ( - <> - {button} - {screenreaderText} - - )} ); }; diff --git a/packages/gamut/src/utils/react.ts b/packages/gamut/src/utils/react.ts deleted file mode 100644 index 174c4a9660..0000000000 --- a/packages/gamut/src/utils/react.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Children, isValidElement } from 'react'; - -/** - * Recursively extracts plain text content from React children. - * - * Useful for converting JSX to plain text for accessibility purposes - * like screenreader announcements or aria-labels. - * - * @param children - React children to extract text from - * @returns Plain text string with all text content joined by spaces - * - * @example - * ```tsx - * const text = extractTextContent( - *
    Hello world!
    - * ); - * // Returns: "Hello world !" - * ``` - */ -export 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(' '); -}; From e0cd55d25131c613d456f483393341891c1a7209 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 20 Nov 2025 11:36:38 -0500 Subject: [PATCH 26/59] implement automatic programmatic fgocus --- packages/gamut/src/Tip/InfoTip/index.tsx | 144 ++++-------------- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 16 +- packages/gamut/src/Tip/__tests__/helpers.tsx | 83 ++++------ packages/gamut/src/Tip/shared/FloatingTip.tsx | 4 +- packages/gamut/src/Tip/shared/InlineTip.tsx | 3 + packages/gamut/src/Tip/shared/types.tsx | 2 +- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 12 +- .../Tips/InfoTip/InfoTip.stories.tsx | 29 +--- 8 files changed, 89 insertions(+), 204 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 7d6144eacb..39e03ab0a8 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; @@ -19,13 +12,8 @@ import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { alignment?: TipBaseAlignment; emphasis?: 'low' | 'high'; - /** - * Called when the info tip is clicked - intended to be used for programmatic focus in the case of links within the tip. - */ - onClick?: (arg0: { isTipHidden: boolean }) => void; }; -const ARIA_HIDDEN_DELAY_MS = 1000; const FOCUSABLE_SELECTOR = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; @@ -34,93 +22,35 @@ export const InfoTip: React.FC = ({ alignment = 'top-right', emphasis = 'low', info, - onClick, placement = tipDefaultProps.placement, ...rest }) => { const isFloating = placement === 'floating'; const [isTipHidden, setHideTip] = useState(true); - const [isAriaHidden, setIsAriaHidden] = useState(false); const [loaded, setLoaded] = useState(false); const wrapperRef = useRef(null); const buttonRef = useRef(null); - const popoverContentNodeRef = useRef(null); - - const ariaHiddenTimeoutRef = useRef(null); + const contentNodeRef = useRef(null); const getFocusableElements = useCallback(() => { - const popoverContent = popoverContentNodeRef.current; - if (!popoverContent) return []; + const content = contentNodeRef.current; + if (!content) return []; return Array.from( - popoverContent.querySelectorAll(FOCUSABLE_SELECTOR) + content.querySelectorAll(FOCUSABLE_SELECTOR) ); }, []); - const clearAndSetTimeout = useCallback( - ( - timeoutRef: React.MutableRefObject, - callback: () => void, - delay: number - ) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - callback(); - timeoutRef.current = null; - }, delay); - }, - [] - ); - - const popoverContentRef = useCallback( - (node: HTMLDivElement | null) => { - popoverContentNodeRef.current = node; - - // We call onClick when the popover is mounted to make sure the refs are available - if (node && onClick && !isTipHidden && isFloating) { - onClick({ isTipHidden: false }); - } - }, - [onClick, isTipHidden, isFloating] - ); + const contentRef = useCallback((node: HTMLDivElement | null) => { + contentNodeRef.current = node; + }, []); useEffect(() => { setLoaded(true); - - const ariaHiddenTimeout = ariaHiddenTimeoutRef.current; - - return () => { - if (ariaHiddenTimeout) clearTimeout(ariaHiddenTimeout); - }; }, []); - const setTipIsHidden = useCallback( - (nextTipState: boolean) => { - setHideTip(nextTipState); - - if (!nextTipState) { - if (!isFloating) { - clearAndSetTimeout( - ariaHiddenTimeoutRef, - () => setIsAriaHidden(true), - ARIA_HIDDEN_DELAY_MS - ); - } - } else { - if (isAriaHidden) setIsAriaHidden(false); - if (ariaHiddenTimeoutRef.current) { - clearTimeout(ariaHiddenTimeoutRef.current); - ariaHiddenTimeoutRef.current = null; - } - } - }, - [isAriaHidden, isFloating, clearAndSetTimeout] - ); - const handleOutsideClick = useCallback( (e: MouseEvent) => { const wrapper = wrapperRef.current; @@ -128,23 +58,15 @@ export const InfoTip: React.FC = ({ wrapper && (e.target instanceof HTMLElement ? !wrapper.contains(e.target) : true) ) { - setTipIsHidden(true); + setHideTip(true); } }, - [setTipIsHidden] + [] ); const clickHandler = useCallback(() => { - const currentTipState = !isTipHidden; - setTipIsHidden(currentTipState); - }, [isTipHidden, setTipIsHidden]); - - useLayoutEffect(() => { - // for inline tips the onClick runs after DOM updates to make sure refs are available - if (!isFloating && !isTipHidden && onClick) { - onClick({ isTipHidden: false }); - } - }, [isTipHidden, isFloating, onClick]); + setHideTip(!isTipHidden); + }, [isTipHidden]); useEffect(() => { if (isTipHidden) return; @@ -163,7 +85,7 @@ export const InfoTip: React.FC = ({ if (hasModal) return; e.preventDefault(); - setTipIsHidden(true); + setHideTip(true); buttonRef.current?.focus(); }; @@ -186,25 +108,35 @@ export const InfoTip: React.FC = ({ } }; - let popoverContent: HTMLDivElement | null = null; + let content: HTMLDivElement | null = null; const timeoutId = setTimeout(() => { - popoverContent = popoverContentNodeRef.current; - if (popoverContent) { - popoverContent.addEventListener('keydown', handleTabKeyInPopover); + content = contentNodeRef.current; + if (content) { + content.addEventListener('keydown', handleTabKeyInPopover); } }, 0); return () => { clearTimeout(timeoutId); - if (popoverContent) { - popoverContent.removeEventListener('keydown', handleTabKeyInPopover); + if (content) { + content.removeEventListener('keydown', handleTabKeyInPopover); } document.removeEventListener('keydown', handleGlobalEscapeKey); }; } return () => document.removeEventListener('keydown', handleGlobalEscapeKey); - }, [isTipHidden, isFloating, setTipIsHidden, getFocusableElements]); + }, [isTipHidden, isFloating, getFocusableElements]); + + useEffect(() => { + if (isTipHidden) return; + + const timeoutId = setTimeout(() => { + contentNodeRef.current?.focus(); + }, 0); + + return () => clearTimeout(timeoutId); + }, [isTipHidden]); const Tip = loaded && isFloating ? FloatingTip : InlineTip; @@ -213,25 +145,13 @@ export const InfoTip: React.FC = ({ alignment, info, isTipHidden, + contentRef, wrapperRef, - ...(isFloating && { popoverContentRef }), ...rest, }), - [ - alignment, - info, - isTipHidden, - wrapperRef, - isFloating, - popoverContentRef, - rest, - ] + [alignment, info, isTipHidden, contentRef, wrapperRef, rest] ); - /* - * For floating placement, screenreader text comes before button to maintain - * correct DOM order despite Portal rendering. See GM-797 for planned fix. - */ return ( { 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 }); + const { info } = createLinkSetup(linkText); + const { view } = renderView({ info }); await openTipTabToLinkAndWaitForFocus(view, linkText); }); it('closes the tip when Escape is pressed even when focus is on a link inside', async () => { const linkText = 'cool link'; - const { info, onClick } = createLinkSetup(linkText); - const { view } = renderView({ info, onClick }); + const { info } = createLinkSetup(linkText); + const { view } = renderView({ info }); const link = await openTipTabToLinkAndWaitForFocus(view, linkText); @@ -186,8 +186,9 @@ describe('InfoTip', () => { await userEvent.click(view.getByRole('button')); }); - // The first get by text result is the a11y text, the second is the actual tip text - expect(view.queryAllByText(info).length).toBe(2); + await waitFor(() => { + expect(view.getByText(info)).toBeVisible(); + }); }); it('closes the tip when Escape key is pressed and returns focus to the button', async () => { @@ -200,14 +201,13 @@ describe('InfoTip', () => { it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { const linkText = 'cool link'; - const { info, onClick } = createLinkSetup( + const { info } = createLinkSetup( linkText, 'https://giphy.com/search/nichijou' ); const { view } = renderView({ placement: 'floating', info, - onClick, }); await testEscapeKeyCloseTip(view, linkText, true); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 1b86e22592..ba6200a30e 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -12,14 +12,6 @@ type InfoTipView = ReturnType< ReturnType> >['view']; -export const createFocusOnClick = (ref: RefObject) => { - return ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - ref.current?.focus(); - } - }; -}; - export const createLinkSetup = ( linkText: string, href = 'https://example.com' @@ -31,7 +23,7 @@ export const createLinkSetup = ( important. ); - return { containerRef, info, onClick: createFocusOnClick(containerRef) }; + return { containerRef, info }; }; export const createMultiLinkSetup = ( @@ -47,7 +39,7 @@ export const createMultiLinkSetup = ( {secondLinkText} ); - return { containerRef, info, onClick: createFocusOnClick(containerRef) }; + return { containerRef, info }; }; export const clickButton = async (view: InfoTipView) => { @@ -58,37 +50,12 @@ export const clickButton = async (view: InfoTipView) => { return button; }; -export const waitForPopoverLink = async ( - view: InfoTipView, - linkText: string -) => { - return await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - return links[0]; - }); -}; - export const pressKey = async (key: string) => { await act(async () => { await userEvent.keyboard(key); }); }; -export const waitForContainerFocus = async ( - containerRef: RefObject, - container: HTMLElement -) => { - await waitFor( - () => { - expect(containerRef.current).toBeTruthy(); - expect(containerRef.current).toBe(container); - expect(container).toHaveFocus(); - }, - { timeout: 2000 } - ); -}; - export const waitForLinkToHaveFocus = async ( view: InfoTipView, linkText: string @@ -119,14 +86,15 @@ export const testEscapeKeyCloseTip = async ( const button = await clickButton(view); await waitFor(() => { - const elements = view.getAllByText(contentToCheck); - expect(elements.length).toBeGreaterThan(0); + expect(view.getByText(contentToCheck)).toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'true'); }); await pressKey('{Escape}'); await waitFor(() => { expect(view.queryByText(contentToCheck)).toBeNull(); + expect(button).toHaveAttribute('aria-expanded', 'false'); }); if (shouldReturnFocus) { @@ -143,10 +111,11 @@ export const testFocusWrap = async ( ) => { const button = await clickButton(view); + // Wait for the popover container to have focus (automatic focus behavior) await waitFor( () => { - expect(containerRef.current).toBeTruthy(); - expect(containerRef.current).toHaveFocus(); + const popover = view.getByTestId('popover-content-container'); + expect(popover).toHaveFocus(); }, { timeout: 2000 } ); @@ -168,13 +137,8 @@ export const getTipContent = ( text: string, useQuery = false ) => { - const getAllMethod = useQuery ? 'queryAllByText' : 'getAllByText'; - const elements = view[getAllMethod](text); - // Find the tip body (not the screenreader text with aria-live="assertive") - return ( - elements.find((el) => el.getAttribute('aria-live') !== 'assertive') || - elements[0] - ); + const getMethod = useQuery ? 'queryByText' : 'getByText'; + return view[getMethod](text); }; export const testTabbingBetweenLinks = async ( @@ -186,7 +150,8 @@ export const testTabbingBetweenLinks = async ( await clickButton(view); await waitFor(() => { - expect(view.getByText(firstLinkText)).toBeVisible(); + const button = view.getByLabelText('Show information'); + expect(button).toHaveAttribute('aria-expanded', 'true'); }); await pressKey('{Tab}'); @@ -207,9 +172,9 @@ export const setupLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { containerRef, info, onClick } = createLinkSetup(linkText); - const { view } = renderView({ placement, info, onClick }); - return { view, containerRef, info, onClick }; + const { containerRef, info } = createLinkSetup(linkText); + const { view } = renderView({ placement, info }); + return { view, containerRef, info }; }; export const setupMultiLinkTestWithPlacement = ( @@ -218,9 +183,9 @@ export const setupMultiLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { info, onClick } = createMultiLinkSetup(firstLinkText, secondLinkText); - const { view } = renderView({ placement, info, onClick }); - return { view, info, onClick }; + const { info } = createMultiLinkSetup(firstLinkText, secondLinkText); + const { view } = renderView({ placement, info }); + return { view, info }; }; export const testEscapeKeyWithOutsideFocus = async ( @@ -234,10 +199,13 @@ export const testEscapeKeyWithOutsideFocus = async ( try { const button = await clickButton(view); + let isFloating = false; await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'true'); const tip = getTipContent(view, contentToCheck); expect(tip).toBeVisible(); + // Check if this is a floating tip by looking for the popover + isFloating = view.queryByTestId('popover-content-container') !== null; }); outsideButton.focus(); @@ -247,8 +215,13 @@ export const testEscapeKeyWithOutsideFocus = async ( await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'false'); - const tip = getTipContent(view, contentToCheck, true); - expect(tip).not.toBeVisible(); + // For floating tips, check popover is gone; for inline tips, check tip body is not visible + if (isFloating) { + expect(view.queryByTestId('popover-content-container')).toBeNull(); + } else { + const tip = getTipContent(view, contentToCheck, true); + expect(tip).not.toBeVisible(); + } }); } finally { document.body.removeChild(outsideButton); diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index c251d1570c..77f4e63ca5 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -31,7 +31,7 @@ export const FloatingTip: React.FC = ({ loading, narrow, overline, - popoverContentRef, + contentRef, truncateLines, type, username, @@ -167,7 +167,7 @@ export const FloatingTip: React.FC = ({ horizontalOffset={offset} isOpen={isPopoverOpen} outline - popoverContainerRef={popoverContentRef} + popoverContainerRef={contentRef} skipFocusTrap targetRef={ref} variant="secondary" diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index dec57516fa..406621d3a2 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -23,6 +23,7 @@ export const InlineTip: React.FC = ({ loading, narrow, overline, + contentRef, truncateLines, type, username, @@ -65,7 +66,9 @@ export const InlineTip: React.FC = ({ color="currentColor" horizNarrow={narrow && isHorizontalCenter} id={id} + ref={contentRef} role={type === 'tool' ? 'tooltip' : undefined} + tabIndex={type === 'info' ? -1 : undefined} width={narrow && !isHorizontalCenter ? narrowWidth : 'max-content'} zIndex="auto" > diff --git a/packages/gamut/src/Tip/shared/types.tsx b/packages/gamut/src/Tip/shared/types.tsx index a91c5e6a6f..164ed1aaf3 100644 --- a/packages/gamut/src/Tip/shared/types.tsx +++ b/packages/gamut/src/Tip/shared/types.tsx @@ -78,7 +78,7 @@ export type TipPlacementComponentProps = Omit< escapeKeyPressHandler?: (event: React.KeyboardEvent) => void; id?: string; isTipHidden?: boolean; - popoverContentRef?: + contentRef?: | React.RefObject | ((node: HTMLDivElement | null) => void); type: 'info' | 'tool' | 'preview'; diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 97219463c4..7e94dc5fc6 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -57,16 +57,18 @@ This `floating` variant should only be used as needed. ### InfoTips with links or buttons -Links or buttons within InfoTips should be used sparingly and only when the information is critical to the user's understanding of the content. If an infotip _absolutely requires_ a link or button, it needs to provide a programmatic focus by way of the `onClick` prop. The `onClick` prop accepts a function that calls the object `{isTipHidden}` and should focus when the tip is visible. +Links or buttons within InfoTips should be used sparingly and only when the information is critical to the user's understanding of the content. When an InfoTip opens, focus automatically moves to the tip content, allowing keyboard users to immediately interact with any links or buttons inside. -### Floating Placement +### Automatic Focus Management -When using `placement="floating"`, InfoTips implement **forward focus wrapping** to keep users within the tip context: +InfoTips automatically manage focus for optimal keyboard accessibility: -- **Tab**: Navigate forward through focusable elements (links, buttons) inside the tip. When reaching the last element, wraps back to the InfoTip button -- **Shift+Tab**: Navigate backward naturally - from the button, exits to the previous page element (no backward trap) +- **Opening**: Focus automatically moves to the tip content when opened +- **Tab (Floating)**: Navigate forward through focusable elements (links, buttons) inside the tip. When reaching the last element, wraps back to the InfoTip button +- **Shift+Tab (Floating)**: Navigate backward naturally - from the button, exits to the previous page element (no backward trap) +- **Tab/Shift+Tab (Inline)**: Follows normal document flow - **Escape**: Closes the tip and returns focus to the InfoTip button diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index de8f5dee7f..35cd38bfe3 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -9,7 +9,7 @@ import { Text, } from '@codecademy/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; const meta: Meta = { component: InfoTip, @@ -77,19 +77,13 @@ export const WithLinksOrButtons: Story = { placement: 'floating', }, render: function WithLinksOrButtons(args) { - const ref = useRef(null); - - const onClick = ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) ref.current?.focus(); - }; - return ( This text is in a small space and needs info {' '} + Hey! Here is a{' '} cool link @@ -101,7 +95,6 @@ export const WithLinksOrButtons: Story = { that is also super important. } - onClick={onClick} /> ); @@ -129,21 +122,16 @@ export const ZIndex: Story = { export const KeyboardNavigation: Story = { render: function KeyboardNavigation() { - const floatingRef = useRef(null); - const inlineRef = useRef(null); - const examples = [ { title: 'Floating Placement', placement: 'floating' as const, - ref: floatingRef, links: ['Link 1', 'Link 2', 'Link 3'], }, { title: 'Inline Placement', placement: 'inline' as const, alignment: 'bottom-right' as const, - ref: inlineRef, links: ['Link A', 'Link B'], }, ]; @@ -151,11 +139,7 @@ export const KeyboardNavigation: Story = { return ( - {examples.map(({ title, placement, alignment, ref, links }) => { - const onClick = ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) ref.current?.focus(); - }; - + {examples.map(({ title, placement, alignment, links }) => { return ( @@ -164,7 +148,7 @@ export const KeyboardNavigation: Story = { + {links.map((label, idx) => ( <> {idx > 0 && ', '} @@ -177,7 +161,6 @@ export const KeyboardNavigation: Story = { } placement={placement} - onClick={onClick} /> ); @@ -189,6 +172,10 @@ export const KeyboardNavigation: Story = { Keyboard Navigation: +
  • + Opening: Focus automatically moves to the tip + content when opened +
  • Floating - Tab: Navigates forward through links, then wraps to button (contained) From 1b895ef3919c955501231ff8a0c7eeb661e821ce Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 20 Nov 2025 16:51:39 -0500 Subject: [PATCH 27/59] add custom label + stories --- .../gamut/src/Tip/InfoTip/InfoTipButton.tsx | 6 +- packages/gamut/src/Tip/InfoTip/index.tsx | 40 +++++++----- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 62 ++++++++++++------- packages/gamut/src/Tip/__tests__/helpers.tsx | 50 ++++++++++----- packages/gamut/src/Tip/shared/FloatingTip.tsx | 4 +- packages/gamut/src/Tip/shared/InlineTip.tsx | 4 +- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 6 ++ .../Tips/InfoTip/InfoTip.stories.tsx | 12 ++++ 8 files changed, 121 insertions(+), 63 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx index e9137d78c0..be8edce9ed 100644 --- a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx +++ b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx @@ -22,15 +22,15 @@ export type InfoTipButtonProps = ComponentProps & Pick; export const InfoTipButton = forwardRef( - ({ active, children, emphasis, ...props }, ref) => { + ({ active, children, emphasis, 'aria-label': ariaLabel, ...props }, ref) => { const Icon = emphasis === 'high' ? MiniInfoCircleIcon : MiniInfoOutlineIcon; return ( {Icon && ( diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 39e03ab0a8..69a241018d 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -11,6 +11,7 @@ import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { alignment?: TipBaseAlignment; + ariaLabel?: string; emphasis?: 'low' | 'high'; }; @@ -20,6 +21,7 @@ const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; export const InfoTip: React.FC = ({ alignment = 'top-right', + ariaLabel, emphasis = 'low', info, placement = tipDefaultProps.placement, @@ -51,22 +53,19 @@ export const InfoTip: React.FC = ({ setLoaded(true); }, []); - const handleOutsideClick = useCallback( - (e: MouseEvent) => { - const wrapper = wrapperRef.current; - if ( - wrapper && - (e.target instanceof HTMLElement ? !wrapper.contains(e.target) : true) - ) { - setHideTip(true); - } - }, - [] - ); + const handleOutsideClick = useCallback((e: MouseEvent) => { + const wrapper = wrapperRef.current; + if ( + wrapper && + (e.target instanceof HTMLElement ? !wrapper.contains(e.target) : true) + ) { + setHideTip(true); + } + }, []); const clickHandler = useCallback(() => { - setHideTip(!isTipHidden); - }, [isTipHidden]); + setHideTip((prev) => !prev); + }, []); useEffect(() => { if (isTipHidden) return; @@ -96,10 +95,18 @@ export const InfoTip: React.FC = ({ if (event.key !== 'Tab' || event.shiftKey) return; const focusableElements = getFocusableElements(); - if (focusableElements.length === 0) return; + const { activeElement } = document; + + // If no focusable elements and popover itself has focus, wrap to button + if (focusableElements.length === 0) { + if (activeElement === contentNodeRef.current) { + event.preventDefault(); + buttonRef.current?.focus(); + } + return; + } const lastElement = focusableElements[focusableElements.length - 1]; - const { activeElement } = document; // Only wrap forward: if on last element, wrap to button if (activeElement === lastElement) { @@ -157,6 +164,7 @@ export const InfoTip: React.FC = ({ ['view'], linkText: string ) => { + const user = userEvent.setup(); const link = await openTipAndWaitForLink(view, linkText); - await act(async () => { - await userEvent.tab(); - }); + await user.tab(); await waitFor(() => { expect(link).toHaveFocus(); }); @@ -42,10 +41,7 @@ const testModalDoesNotCloseInfoTip = async ( info: string, useModalButton = false ) => { - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + const button = await clickButton(view); await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'true'); @@ -86,15 +82,14 @@ const testModalDoesNotCloseInfoTip = async ( describe('InfoTip', () => { describe('inline placement', () => { it('shows the tip when it is clicked on', async () => { + const user = userEvent.setup(); const { view } = renderView({}); const tip = view.getByText(info); expect(tip).not.toBeVisible(); - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); + await user.click(view.getByRole('button')); expect(tip.parentElement).not.toHaveStyle({ visibility: 'hidden', @@ -107,10 +102,7 @@ describe('InfoTip', () => { it('closes the tip when Escape key is pressed and returns focus to button', async () => { const { view } = renderView({}); - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + const button = await clickButton(view); const tip = getTipContent(view, info); expect(tip).toBeVisible(); @@ -165,7 +157,7 @@ describe('InfoTip', () => { it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { const { view } = renderView({}); - await testEscapeKeyWithOutsideFocus(view, info); + await testEscapeKeyWithOutsideFocus(view, info, false); }); it('does not close the tip when Escape is pressed if a modal is open', async () => { @@ -176,15 +168,14 @@ describe('InfoTip', () => { describe('floating placement', () => { it('shows the tip when it is clicked on', async () => { + const user = userEvent.setup(); const { view } = renderView({ placement: 'floating', }); expect(view.queryByText(info)).toBeNull(); - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); + await user.click(view.getByRole('button')); await waitFor(() => { expect(view.getByText(info)).toBeVisible(); @@ -255,12 +246,39 @@ describe('InfoTip', () => { it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { const { view } = renderView({ placement: 'floating' }); - await testEscapeKeyWithOutsideFocus(view, info); + await testEscapeKeyWithOutsideFocus(view, info, true); }); it('does not close the tip when Escape is pressed if a modal is open', async () => { const { view } = renderView({ placement: 'floating' }); await testModalDoesNotCloseInfoTip(view, info, true); }); + + it('wraps focus to button when tabbing from popover with no interactive elements', async () => { + const { view } = renderView({ placement: 'floating' }); + await testTabFromPopoverWithNoInteractiveElements(view); + }); + }); + + describe('ariaLabel', () => { + it('applies default aria-label when ariaLabel is not provided', () => { + const { view } = renderView({}); + view.getByLabelText('Show information'); + }); + + it('applies custom aria-label when provided', () => { + const { view } = renderView({ + ariaLabel: 'Additional details', + }); + view.getByLabelText('Additional details'); + }); + + it('works with floating placement', () => { + const { view } = renderView({ + placement: 'floating', + ariaLabel: 'Help text', + }); + view.getByLabelText('Help text'); + }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index ba6200a30e..2181f1e4b9 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -1,5 +1,5 @@ import { setupRtl } from '@codecademy/gamut-tests'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef, RefObject } from 'react'; @@ -43,17 +43,15 @@ export const createMultiLinkSetup = ( }; export const clickButton = async (view: InfoTipView) => { - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + const user = userEvent.setup(); + const button = view.getByRole('button'); + await user.click(button); return button; }; export const pressKey = async (key: string) => { - await act(async () => { - await userEvent.keyboard(key); - }); + const user = userEvent.setup(); + await user.keyboard(key); }; export const waitForLinkToHaveFocus = async ( @@ -132,6 +130,26 @@ export const testFocusWrap = async ( }); }; +export const testTabFromPopoverWithNoInteractiveElements = async ( + view: InfoTipView +) => { + const button = await clickButton(view); + + await waitFor( + () => { + const popover = view.getByTestId('popover-content-container'); + expect(popover).toHaveFocus(); + }, + { timeout: 2000 } + ); + + await pressKey('{Tab}'); + + await waitFor(() => { + expect(button).toHaveFocus(); + }); +}; + export const getTipContent = ( view: InfoTipView, text: string, @@ -147,10 +165,9 @@ export const testTabbingBetweenLinks = async ( secondLinkText: string, placement: TipPlacements ) => { - await clickButton(view); + const button = await clickButton(view); await waitFor(() => { - const button = view.getByLabelText('Show information'); expect(button).toHaveAttribute('aria-expanded', 'true'); }); @@ -163,7 +180,7 @@ export const testTabbingBetweenLinks = async ( expect(firstLink).not.toHaveFocus(); if (placement === 'floating') { - expect(view.getByLabelText('Show information')).not.toHaveFocus(); + expect(button).not.toHaveFocus(); } }; @@ -190,22 +207,23 @@ export const setupMultiLinkTestWithPlacement = ( export const testEscapeKeyWithOutsideFocus = async ( view: InfoTipView, - contentToCheck: string + contentToCheck: string, + isFloating: boolean ) => { + const button = view.getByRole('button'); + const outsideButton = document.createElement('button'); outsideButton.textContent = 'Outside Button'; document.body.appendChild(outsideButton); try { - const button = await clickButton(view); + const user = userEvent.setup(); + await user.click(button); - let isFloating = false; await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'true'); const tip = getTipContent(view, contentToCheck); expect(tip).toBeVisible(); - // Check if this is a floating tip by looking for the popover - isFloating = view.queryByTestId('popover-content-container') !== null; }); outsideButton.focus(); diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index 77f4e63ca5..c301366154 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -152,9 +152,7 @@ export const FloatingTip: React.FC = ({ width={inheritDims ? 'inherit' : undefined} onBlur={toolOnlyEventFunc} onFocus={toolOnlyEventFunc} - onKeyDown={ - escapeKeyPressHandler ? (e) => escapeKeyPressHandler(e) : undefined - } + onKeyDown={escapeKeyPressHandler} onMouseDown={(e) => e.preventDefault()} onMouseEnter={toolOnlyEventFunc} > diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 406621d3a2..871cfaac1a 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -46,9 +46,7 @@ export const InlineTip: React.FC = ({ height={inheritDims ? 'inherit' : undefined} ref={wrapperRef} width={inheritDims ? 'inherit' : undefined} - onKeyDown={ - escapeKeyPressHandler ? (e) => escapeKeyPressHandler(e) : undefined - } + onKeyDown={escapeKeyPressHandler} > {children} diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 7e94dc5fc6..08c62d4ecb 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -84,6 +84,12 @@ InfoTips listen for the Escape key globally and will close automatically **unles +## Custom Accessible Label + +The InfoTip button label can be customized using the `ariaLabel` prop. This is useful when the default "Show information" label doesn't provide enough context. + + + ## InfoTips and zIndex You can change the zIndex of your `InfoTip` with the zIndex property. diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 35cd38bfe3..29297a2208 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -72,6 +72,18 @@ export const Placement: Story = { ), }; +export const AriaLabel: Story = { + render: (args) => ( + + + + ), +}; + export const WithLinksOrButtons: Story = { args: { placement: 'floating', From 82744162660aaa553c208f6ada1613d671236a01 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 09:45:03 -0500 Subject: [PATCH 28/59] move focusable selectors func --- packages/gamut/src/Tip/InfoTip/index.tsx | 10 +---- packages/gamut/src/utils/focus.ts | 42 +++++++++++++++++++ packages/gamut/src/utils/focusVisibleStyle.ts | 12 ------ packages/gamut/src/utils/index.ts | 2 +- 4 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 packages/gamut/src/utils/focus.ts delete mode 100644 packages/gamut/src/utils/focusVisibleStyle.ts diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 9cb3ce54f5..52b03c2c1f 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react'; +import { getFocusableElements as getFocusableElementsUtil } from '../../utils/focus'; import { extractTextContent } from '../../utils/react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; @@ -28,8 +29,6 @@ export type InfoTipProps = TipBaseProps & { }; const ARIA_HIDDEN_DELAY_MS = 1000; -const FOCUSABLE_SELECTOR = - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; export const InfoTip: React.FC = ({ @@ -55,12 +54,7 @@ export const InfoTip: React.FC = ({ const announceTimeoutRef = useRef(null); const getFocusableElements = useCallback(() => { - const popoverContent = popoverContentNodeRef.current; - if (!popoverContent) return []; - - return Array.from( - popoverContent.querySelectorAll(FOCUSABLE_SELECTOR) - ); + return getFocusableElementsUtil(popoverContentNodeRef.current); }, []); const clearAndSetTimeout = useCallback( diff --git a/packages/gamut/src/utils/focus.ts b/packages/gamut/src/utils/focus.ts new file mode 100644 index 0000000000..3501e7ecf6 --- /dev/null +++ b/packages/gamut/src/utils/focus.ts @@ -0,0 +1,42 @@ +import { theme } from '@codecademy/gamut-styles'; + +export const focusVisibleStyle = (outlineOffset = '4px') => ({ + '&:focus-visible': { + outlineOffset, + /* + We use !important here to ensure this overrides other browser default focus styles. + Gamut's reset css does a good job wiping most of these out but this accounts for some edge cases. + */ + outline: `2px solid ${theme.colors.primary} !important`, + }, +}); + +/** + * Selector for all focusable elements in the DOM. + * Includes links, buttons, form controls, and elements with non-negative tabindex. + */ +export const FOCUSABLE_SELECTOR = + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; + +/** + * Gets all focusable elements within a container element. + * + * @param container - The container element to search within + * @returns Array of focusable HTML elements, or empty array if container is null + * + * @example + * ```tsx + * const focusableElements = getFocusableElements(popoverRef.current); + * focusableElements[0]?.focus(); + * ``` + */ +export const getFocusableElements = ( + container: HTMLElement | null +): HTMLElement[] => { + if (!container) return []; + + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR) + ); +}; + diff --git a/packages/gamut/src/utils/focusVisibleStyle.ts b/packages/gamut/src/utils/focusVisibleStyle.ts deleted file mode 100644 index fbc13a3d59..0000000000 --- a/packages/gamut/src/utils/focusVisibleStyle.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { theme } from '@codecademy/gamut-styles'; - -export const focusVisibleStyle = (outlineOffset = '4px') => ({ - '&:focus-visible': { - outlineOffset, - /* - We use !important here to ensure this overrides other browser default focus styles. - Gamut's reset css does a good job wiping most of these out but this accounts for some edge cases. - */ - outline: `2px solid ${theme.colors.primary} !important`, - }, -}); diff --git a/packages/gamut/src/utils/index.ts b/packages/gamut/src/utils/index.ts index a3ff0656a2..b4db92e372 100644 --- a/packages/gamut/src/utils/index.ts +++ b/packages/gamut/src/utils/index.ts @@ -1,5 +1,5 @@ export * from './createPromise'; -export * from './focusVisibleStyle'; +export * from './focus'; export * from './generateResponsiveClassnames'; export * from './omitProps'; export * from './useIsMounted'; From 88cacce9c0743214e643844f4366d59d2b129e21 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 10:42:48 -0500 Subject: [PATCH 29/59] start docs feed back --- packages/gamut/src/Tip/InfoTip/index.tsx | 2 +- .../src/lib/Molecules/Tips/InfoTip/InfoTip.mdx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 52b03c2c1f..a180aac201 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -266,7 +266,7 @@ export const InfoTip: React.FC = ({ /* * For floating placement, screenreader text comes before button to maintain - * correct DOM order despite Portal rendering. See GM-797 for planned fix. + * correct DOM order despite Portal rendering. See GMT-64 for planned fix. */ return ( diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 97219463c4..28b40beca7 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -53,7 +53,7 @@ This `floating` variant should only be used as needed. -## Keyboard Navigation & Accessibility +## Keyboard navigation & accessibility ### InfoTips with links or buttons @@ -61,17 +61,17 @@ Links or buttons within InfoTips should be used sparingly and only when the info -### Floating Placement +### Floating placement -When using `placement="floating"`, InfoTips implement **forward focus wrapping** to keep users within the tip context: +When using `placement="floating"`, InfoTips implements focus management for easier navigation: -- **Tab**: Navigate forward through focusable elements (links, buttons) inside the tip. When reaching the last element, wraps back to the InfoTip button -- **Shift+Tab**: Navigate backward naturally - from the button, exits to the previous page element (no backward trap) +- **Tab**: Navigate forward through focusable elements (links, buttons) inside the tip. When reaching the last element, wraps back to the InfoTip button for convenience +- **Shift+Tab**: Navigate backward naturally through the page - **Escape**: Closes the tip and returns focus to the InfoTip button -### Global Escape Key Handling +### Global Escape key handling InfoTips listen for the Escape key globally and will close automatically **unless** a higher-priority element is open: From 0b6be28542117e6fb9189ebe10450b1e4cffc7ff Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 10:51:28 -0500 Subject: [PATCH 30/59] clean up docs to detect new behavior --- .../Tips/InfoTip/InfoTip.stories.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index de8f5dee7f..1bd341e1a2 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -234,40 +234,37 @@ export const WithModal: Story = { - Test Escape Key Behavior with Modals: - - - 1. Click the InfoTip to open it -
    - 2. Click "Open Modal" button -
    - 3. Press Escape - should close modal only (InfoTip stays open) -
    - 4. Press Escape again - should close InfoTip -
    -
    - - InfoTip detects when modals are open and defers Escape key - handling to them. - + Test Escape Key Behavior with Modals:
    + +
  • Press enter to open the InfoTip
  • +
  • Tab to the "Open Modal" button and press enter
  • +
  • Press Escape - should close modal only (InfoTip stays open)
  • +
  • Press Escape again - should close InfoTip
  • +
  • + + InfoTip detects when modals are open and defers Escape key + handling to them. + +
  • +
    setIsModalOpen(false)} > - + - This is a modal. Press Escape to close it. -
    - The InfoTip should remain open behind this modal. + This is a modal. Press Escape to close it. The InfoTip should + remain open behind this modal.
    setIsModalOpen(false)}> Close Modal -
    +
    ); From 3fd6067b1791f389e68564eac2c29346de2f90fc Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 10:56:15 -0500 Subject: [PATCH 31/59] format --- packages/gamut/src/utils/focus.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/gamut/src/utils/focus.ts b/packages/gamut/src/utils/focus.ts index 3501e7ecf6..9d20278a56 100644 --- a/packages/gamut/src/utils/focus.ts +++ b/packages/gamut/src/utils/focus.ts @@ -20,10 +20,10 @@ export const FOCUSABLE_SELECTOR = /** * Gets all focusable elements within a container element. - * + * * @param container - The container element to search within * @returns Array of focusable HTML elements, or empty array if container is null - * + * * @example * ```tsx * const focusableElements = getFocusableElements(popoverRef.current); @@ -39,4 +39,3 @@ export const getFocusableElements = ( container.querySelectorAll(FOCUSABLE_SELECTOR) ); }; - From 17b9269e31c8519bbffbe9d30ca7eaf189183396 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 11:11:30 -0500 Subject: [PATCH 32/59] finish merge + fix docs --- packages/gamut/src/Tip/InfoTip/index.tsx | 21 +------------------ .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 3 +-- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index f78dd3a5c8..7b0de41a77 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -<<<<<<< HEAD -======= import { getFocusableElements as getFocusableElementsUtil } from '../../utils/focus'; -import { extractTextContent } from '../../utils/react'; ->>>>>>> cass-ajr-GMT-1479 import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; import { @@ -20,12 +16,6 @@ export type InfoTipProps = TipBaseProps & { emphasis?: 'low' | 'high'; }; -<<<<<<< HEAD -const FOCUSABLE_SELECTOR = - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; -======= -const ARIA_HIDDEN_DELAY_MS = 1000; ->>>>>>> cass-ajr-GMT-1479 const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; export const InfoTip: React.FC = ({ @@ -46,16 +36,7 @@ export const InfoTip: React.FC = ({ const contentNodeRef = useRef(null); const getFocusableElements = useCallback(() => { -<<<<<<< HEAD - const content = contentNodeRef.current; - if (!content) return []; - - return Array.from( - content.querySelectorAll(FOCUSABLE_SELECTOR) - ); -======= - return getFocusableElementsUtil(popoverContentNodeRef.current); ->>>>>>> cass-ajr-GMT-1479 + return getFocusableElementsUtil(contentNodeRef.current); }, []); const contentRef = useCallback((node: HTMLDivElement | null) => { diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index e839474cc4..10c447d4c2 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -28,8 +28,7 @@ export const parameters = { A tip is triggered by clicking on an information icon button and can be closed by clicking outside, pressing Esc, or clicking the info button again. Use an infotip to provide additional info about a nearby element or content. - -Infotip consists of an icon button and the .tip-bg subcomponent. The info button has low and high emphasis variants and the `.tip` has 4 alignment variants. +The info button has low and high emphasis variants and the `Tip` has 4 alignment variants. ## Variants From 70f528243b36cdc23eb8a1f3ae724271718198b7 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 12:09:35 -0500 Subject: [PATCH 33/59] fixed onClick + more tests --- packages/gamut/src/Tip/InfoTip/index.tsx | 19 +++- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index a180aac201..c68d942dee 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -23,7 +23,7 @@ export type InfoTipProps = TipBaseProps & { alignment?: TipBaseAlignment; emphasis?: 'low' | 'high'; /** - * Called when the info tip is clicked - intended to be used for programmatic focus in the case of links within the tip. + * Called when the info tip is clicked - the onClick function is called after the DOM updates and the tip is mounted. */ onClick?: (arg0: { isTipHidden: boolean }) => void; }; @@ -49,6 +49,7 @@ export const InfoTip: React.FC = ({ const wrapperRef = useRef(null); const buttonRef = useRef(null); const popoverContentNodeRef = useRef(null); + const isInitialMount = useRef(true); const ariaHiddenTimeoutRef = useRef(null); const announceTimeoutRef = useRef(null); @@ -78,7 +79,6 @@ export const InfoTip: React.FC = ({ (node: HTMLDivElement | null) => { popoverContentNodeRef.current = node; - // We call onClick when the popover is mounted to make sure the refs are available if (node && onClick && !isTipHidden && isFloating) { onClick({ isTipHidden: false }); } @@ -145,9 +145,18 @@ export const InfoTip: React.FC = ({ }, [isTipHidden, setTipIsHidden, clearAndSetTimeout]); useLayoutEffect(() => { - // for inline tips the onClick runs after DOM updates to make sure refs are available - if (!isFloating && !isTipHidden && onClick) { - onClick({ isTipHidden: false }); + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + if (!isFloating && onClick) { + onClick({ isTipHidden }); + } + }, [isTipHidden, isFloating, onClick]); + + useLayoutEffect(() => { + if (isFloating && isTipHidden && onClick) { + onClick({ isTipHidden: true }); } }, [isTipHidden, isFloating, onClick]); diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index d0bc458563..35daf3af71 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -170,6 +170,56 @@ describe('InfoTip', () => { const { view } = renderView({}); await testModalDoesNotCloseInfoTip(view, info); }); + + it('calls onClick with isTipHidden: false when tip opens', async () => { + const onClick = jest.fn(); + const { view } = renderView({ onClick }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + }); + }); + + it('calls onClick with isTipHidden: true when tip closes', async () => { + const onClick = jest.fn(); + const { view } = renderView({ onClick }); + + const button = view.getByLabelText('Show information'); + + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + }); + + onClick.mockClear(); + + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: true }); + }); + }); + + it('does not call onClick on initial mount', async () => { + const onClick = jest.fn(); + renderView({ onClick }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onClick).not.toHaveBeenCalled(); + }); }); describe('floating placement', () => { @@ -260,5 +310,58 @@ describe('InfoTip', () => { const { view } = renderView({ placement: 'floating' }); await testModalDoesNotCloseInfoTip(view, info, true); }); + + it('calls onClick with isTipHidden: false when tip opens', async () => { + const onClick = jest.fn(); + const { view } = renderView({ placement: 'floating', onClick }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + }); + }); + + it('calls onClick with isTipHidden: true when tip closes', async () => { + const onClick = jest.fn(); + const { view } = renderView({ placement: 'floating', onClick }); + + const button = view.getByLabelText('Show information'); + + // Open the tip + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + }); + + onClick.mockClear(); + + // Close the tip + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: true }); + }); + }); + + it('does not call onClick on initial mount', async () => { + const onClick = jest.fn(); + renderView({ placement: 'floating', onClick }); + + // Wait a bit to ensure no calls were made + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onClick).not.toHaveBeenCalled(); + }); }); }); From e07837734d88a244d3de7c40f5d3025c891fe540 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 21 Nov 2025 16:14:43 -0500 Subject: [PATCH 34/59] more focusable list --- packages/gamut/src/utils/focus.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/gamut/src/utils/focus.ts b/packages/gamut/src/utils/focus.ts index 9d20278a56..61dd24bc82 100644 --- a/packages/gamut/src/utils/focus.ts +++ b/packages/gamut/src/utils/focus.ts @@ -15,8 +15,24 @@ export const focusVisibleStyle = (outlineOffset = '4px') => ({ * Selector for all focusable elements in the DOM. * Includes links, buttons, form controls, and elements with non-negative tabindex. */ -export const FOCUSABLE_SELECTOR = - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'; +const FOCUSABLE_SELECTORS = [ + '[contentEditable=true]:not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + 'a[href]:not([tabindex="-1"])', + 'audio[controls]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'details:not([tabindex="-1"])', + 'dialog', + 'embed:not([tabindex="-1"])', + 'iframe:not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'map[name] area[href]:not([tabindex="-1"])', + 'object:not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + 'summary:not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'video[controls]:not([tabindex="-1"])', +]; /** * Gets all focusable elements within a container element. @@ -36,6 +52,6 @@ export const getFocusableElements = ( if (!container) return []; return Array.from( - container.querySelectorAll(FOCUSABLE_SELECTOR) + container.querySelectorAll(FOCUSABLE_SELECTORS.join(',')) ); }; From 0aa3a42bee834287a3da627bc7485055b3b3c131 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 24 Nov 2025 11:56:16 -0500 Subject: [PATCH 35/59] aria-roledescription --- packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx | 2 +- packages/gamut/src/Tip/InfoTip/index.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx index be8edce9ed..9432498792 100644 --- a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx +++ b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx @@ -30,7 +30,7 @@ export const InfoTipButton = forwardRef( {...props} active={active} aria-expanded={active} - aria-label={ariaLabel || 'Show information'} + aria-label={ariaLabel} ref={ref} > {Icon && ( diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 6b5010ba21..2449b3a5e6 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -195,6 +195,7 @@ export const InfoTip: React.FC = ({ active={!isTipHidden} aria-expanded={!isTipHidden} aria-label={ariaLabel} + aria-roledescription="More information button" emphasis={emphasis} ref={buttonRef} onClick={clickHandler} From 9cc5b0fdcf6bf883e1f008826ecafc65fef08a0e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 25 Nov 2025 11:44:42 -0500 Subject: [PATCH 36/59] test clean up --- .../__snapshots__/gamut.test.ts.snap | 1 + packages/gamut/src/Tip/InfoTip/index.tsx | 3 - .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 309 +++++++----------- packages/gamut/src/Tip/__tests__/helpers.tsx | 278 ++++++++++++---- 4 files changed, 336 insertions(+), 255 deletions(-) diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 0795c55b29..d27f3657ca 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -52,6 +52,7 @@ exports[`Gamut Exported Keys 1`] = ` "FormPropsContext", "FormRequiredText", "generateResponsiveClassnames", + "getFocusableElements", "GridBox", "GridForm", "GridFormContent", diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index c68d942dee..5630e76af3 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -152,9 +152,6 @@ export const InfoTip: React.FC = ({ if (!isFloating && onClick) { onClick({ isTipHidden }); } - }, [isTipHidden, isFloating, onClick]); - - useLayoutEffect(() => { if (isFloating && isTipHidden && onClick) { onClick({ isTipHidden: true }); } diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 35daf3af71..c957698e43 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -6,13 +6,16 @@ import { InfoTip } from '../InfoTip'; import { createLinkSetup, getTipContent, - openTipAndWaitForLink, + openTipTabToLinkAndWaitForFocus, pressKey, setupLinkTestWithPlacement, setupMultiLinkTestWithPlacement, testEscapeKeyCloseTip, testEscapeKeyWithOutsideFocus, testFocusWrap, + testModalDoesNotCloseInfoTip, + testOutsideClick, + testRapidToggle, testTabbingBetweenLinks, } from './helpers'; @@ -21,104 +24,98 @@ const renderView = setupRtl(InfoTip, { info, }); -const openTipTabToLinkAndWaitForFocus = async ( - view: ReturnType['view'], - linkText: string -) => { - const link = await openTipAndWaitForLink(view, linkText); - await act(async () => { - await userEvent.tab(); - }); - await waitFor(() => { - expect(link).toHaveFocus(); - }); - return link; -}; - -const testModalDoesNotCloseInfoTip = async ( - view: ReturnType['view'], - info: string, - useModalButton = false -) => { - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); - - await waitFor(() => { - expect(button).toHaveAttribute('aria-expanded', 'true'); - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - }); +describe('InfoTip', () => { + describe.each<{ placement: 'inline' | 'floating' }>([ + { placement: 'inline' }, + { placement: 'floating' }, + ])('$placement placement', ({ placement }) => { + it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { + const { view } = renderView({ + placement, + }); + await testEscapeKeyWithOutsideFocus({ view, info }); + }); - const mockModal = document.createElement('div'); - mockModal.setAttribute('role', 'dialog'); + it('does not close the tip when Escape is pressed if a modal is open', async () => { + const { view } = renderView({ + placement, + }); + await testModalDoesNotCloseInfoTip({ view, info, placement }); + }); - if (useModalButton) { - const modalButton = document.createElement('button'); - modalButton.textContent = 'Modal button'; - mockModal.appendChild(modalButton); - view.container.appendChild(mockModal); - modalButton.focus(); - } else { - document.body.appendChild(mockModal); - } + it('calls onClick with isTipHidden: false when tip opens', async () => { + const onClick = jest.fn(); + const { view } = renderView({ + placement, + onClick, + }); - try { - await pressKey('{Escape}'); + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); - await waitFor(() => { - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - expect(button).toHaveAttribute('aria-expanded', 'true'); + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + }); }); - } finally { - if (useModalButton) { - view.container.removeChild(mockModal); - } else { - document.body.removeChild(mockModal); - } - } -}; -describe('InfoTip', () => { - describe('inline placement', () => { - it('shows the tip when it is clicked on', async () => { - const { view } = renderView({}); - - const tip = view.getByText(info); + it('calls onClick with isTipHidden: true when tip closes', async () => { + const onClick = jest.fn(); + const { view } = renderView({ + placement, + onClick, + }); - expect(tip).not.toBeVisible(); + const button = view.getByLabelText('Show information'); await act(async () => { - await userEvent.click(view.getByRole('button')); + await userEvent.click(button); }); - expect(tip.parentElement).not.toHaveStyle({ - visibility: 'hidden', - opacity: 0, + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); }); - expect(tip).toBeVisible(); + onClick.mockClear(); + + await act(async () => { + await userEvent.click(button); + }); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith({ isTipHidden: true }); + }); }); - it('closes the tip when Escape key is pressed and returns focus to button', async () => { - const { view } = renderView({}); + it('does not call onClick on initial mount', async () => { + const onClick = jest.fn(); + renderView({ + placement, + onClick, + }); - const button = view.getByLabelText('Show information'); await act(async () => { - await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 100)); }); - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); + expect(onClick).not.toHaveBeenCalled(); + }); - await pressKey('{Escape}'); + it('closes the tip when clicking outside the wrapper', async () => { + const { view } = renderView({ + placement, + }); + await testOutsideClick({ view, info, placement }); + }); - await waitFor(() => { - expect(tip).not.toBeVisible(); - expect(button).toHaveFocus(); + it('handles rapid open/close cycles correctly', async () => { + const onClick = jest.fn(); + const { view } = renderView({ + placement, + onClick, }); + await testRapidToggle({ view, onClick }); }); it('allows normal tabbing through focusable elements within tip', async () => { @@ -127,30 +124,30 @@ describe('InfoTip', () => { const { view } = setupMultiLinkTestWithPlacement( firstLinkText, secondLinkText, - 'inline', + placement, renderView ); - await testTabbingBetweenLinks( + await testTabbingBetweenLinks({ view, firstLinkText, secondLinkText, - 'inline' - ); + placement, + }); }); 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 }); + const { info, onClick } = createLinkSetup({ linkText }); + const { view } = renderView({ placement, info, onClick }); await openTipTabToLinkAndWaitForFocus(view, linkText); }); it('closes the tip when Escape is pressed even when focus is on a link inside', async () => { const linkText = 'cool link'; - const { info, onClick } = createLinkSetup(linkText); - const { view } = renderView({ info, onClick }); + const { info, onClick } = createLinkSetup({ linkText }); + const { view } = renderView({ placement, info, onClick }); const link = await openTipTabToLinkAndWaitForFocus(view, linkText); @@ -160,65 +157,45 @@ describe('InfoTip', () => { expect(link).not.toBeVisible(); }); }); + }); - it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { + describe('inline placement', () => { + it('shows the tip when it is clicked on', async () => { const { view } = renderView({}); - await testEscapeKeyWithOutsideFocus(view, info); - }); - it('does not close the tip when Escape is pressed if a modal is open', async () => { - const { view } = renderView({}); - await testModalDoesNotCloseInfoTip(view, info); - }); + const tip = view.getByText(info); - it('calls onClick with isTipHidden: false when tip opens', async () => { - const onClick = jest.fn(); - const { view } = renderView({ onClick }); + expect(tip).not.toBeVisible(); - const button = view.getByLabelText('Show information'); await act(async () => { - await userEvent.click(button); + await userEvent.click(view.getByRole('button')); }); - await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + expect(tip.parentElement).not.toHaveStyle({ + visibility: 'hidden', + opacity: 0, }); + + expect(tip).toBeVisible(); }); - it('calls onClick with isTipHidden: true when tip closes', async () => { - const onClick = jest.fn(); - const { view } = renderView({ onClick }); + it('closes the tip when Escape key is pressed and returns focus to button', async () => { + const { view } = renderView({}); const button = view.getByLabelText('Show information'); - await act(async () => { await userEvent.click(button); }); - await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); - }); - - onClick.mockClear(); + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); - await act(async () => { - await userEvent.click(button); - }); + await pressKey('{Escape}'); await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: true }); - }); - }); - - it('does not call onClick on initial mount', async () => { - const onClick = jest.fn(); - renderView({ onClick }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); + expect(tip).not.toBeVisible(); + expect(button).toHaveFocus(); }); - - expect(onClick).not.toHaveBeenCalled(); }); }); @@ -243,22 +220,30 @@ describe('InfoTip', () => { placement: 'floating', }); - await testEscapeKeyCloseTip(view, info, true); + await testEscapeKeyCloseTip({ + view, + contentToCheck: info, + shouldReturnFocus: true, + }); }); it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { const linkText = 'cool link'; - const { info, onClick } = createLinkSetup( + const { info, onClick } = createLinkSetup({ linkText, - 'https://giphy.com/search/nichijou' - ); + href: 'https://giphy.com/search/nichijou', + }); const { view } = renderView({ placement: 'floating', info, onClick, }); - await testEscapeKeyCloseTip(view, linkText, true); + await testEscapeKeyCloseTip({ + view, + contentToCheck: linkText, + shouldReturnFocus: true, + }); }); it('wraps focus to button when tabbing forward from last focusable element', async () => { @@ -269,7 +254,7 @@ describe('InfoTip', () => { renderView ); - await testFocusWrap(view, containerRef, 'forward'); + await testFocusWrap({ view, containerRef, direction: 'forward' }); }); it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { @@ -280,10 +265,10 @@ describe('InfoTip', () => { renderView ); - await testFocusWrap(view, containerRef, 'backward'); + await testFocusWrap({ view, containerRef, direction: 'backward' }); }); - it('allows normal tabbing between focusable elements within popover', async () => { + it('does not wrap focus when tabbing from non-last focusable element', async () => { const firstLinkText = 'first link'; const secondLinkText = 'second link'; const { view } = setupMultiLinkTestWithPlacement( @@ -293,75 +278,35 @@ describe('InfoTip', () => { renderView ); - await testTabbingBetweenLinks( - view, - firstLinkText, - secondLinkText, - 'floating' - ); - }); - - it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { - const { view } = renderView({ placement: 'floating' }); - await testEscapeKeyWithOutsideFocus(view, info); - }); - - it('does not close the tip when Escape is pressed if a modal is open', async () => { - const { view } = renderView({ placement: 'floating' }); - await testModalDoesNotCloseInfoTip(view, info, true); - }); - - it('calls onClick with isTipHidden: false when tip opens', async () => { - const onClick = jest.fn(); - const { view } = renderView({ placement: 'floating', onClick }); - const button = view.getByLabelText('Show information'); await act(async () => { await userEvent.click(button); }); await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + expect(view.getByText(firstLinkText)).toBeVisible(); }); - }); - it('calls onClick with isTipHidden: true when tip closes', async () => { - const onClick = jest.fn(); - const { view } = renderView({ placement: 'floating', onClick }); - - const button = view.getByLabelText('Show information'); - - // Open the tip await act(async () => { - await userEvent.click(button); + await userEvent.tab(); }); - await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: false }); + const firstLink = await waitFor(() => { + const link = view.getByRole('link', { name: firstLinkText }); + expect(link).toHaveFocus(); + return link; }); - onClick.mockClear(); - - // Close the tip await act(async () => { - await userEvent.click(button); + await userEvent.tab(); }); await waitFor(() => { - expect(onClick).toHaveBeenCalledWith({ isTipHidden: true }); + const secondLink = view.getByRole('link', { name: secondLinkText }); + expect(secondLink).toHaveFocus(); + expect(button).not.toHaveFocus(); + expect(firstLink).not.toHaveFocus(); }); }); - - it('does not call onClick on initial mount', async () => { - const onClick = jest.fn(); - renderView({ placement: 'floating', onClick }); - - // Wait a bit to ensure no calls were made - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(onClick).not.toHaveBeenCalled(); - }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 1b86e22592..ded5f8850f 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -5,13 +5,20 @@ import { createRef, RefObject } from 'react'; import { Anchor } from '../../Anchor'; import { Text } from '../../Typography'; -import { InfoTip } from '../InfoTip'; +import { InfoTip, InfoTipProps } from '../InfoTip'; import { TipPlacements } from '../shared/types'; type InfoTipView = ReturnType< ReturnType> >['view']; +type Placement = NonNullable; + +type ViewParam = { view: InfoTipView }; +type LinkTextParam = { linkText: string }; +type InfoParam = { info: string }; +type PlacementParam = { placement: Placement }; + export const createFocusOnClick = (ref: RefObject) => { return ({ isTipHidden }: { isTipHidden: boolean }) => { if (!isTipHidden) { @@ -20,10 +27,13 @@ export const createFocusOnClick = (ref: RefObject) => { }; }; -export const createLinkSetup = ( - linkText: string, - href = 'https://example.com' -) => { +export const createLinkSetup = ({ + linkText, + href = 'https://example.com', +}: { + linkText: string; + href?: string; +}) => { const containerRef = createRef(); const info = ( @@ -34,12 +44,17 @@ export const createLinkSetup = ( return { containerRef, info, onClick: createFocusOnClick(containerRef) }; }; -export const createMultiLinkSetup = ( - firstLinkText: string, - secondLinkText: string, +export const createMultiLinkSetup = ({ + firstLinkText, + secondLinkText, firstHref = 'https://example.com/1', - secondHref = 'https://example.com/2' -) => { + secondHref = 'https://example.com/2', +}: { + firstLinkText: string; + secondLinkText: string; + firstHref?: string; + secondHref?: string; +}) => { const containerRef = createRef(); const info = ( @@ -58,41 +73,16 @@ export const clickButton = async (view: InfoTipView) => { return button; }; -export const waitForPopoverLink = async ( - view: InfoTipView, - linkText: string -) => { - return await waitFor(() => { - const links = view.getAllByRole('link', { name: linkText }); - expect(links.length).toBe(1); - return links[0]; - }); -}; - export const pressKey = async (key: string) => { await act(async () => { await userEvent.keyboard(key); }); }; -export const waitForContainerFocus = async ( - containerRef: RefObject, - container: HTMLElement -) => { - await waitFor( - () => { - expect(containerRef.current).toBeTruthy(); - expect(containerRef.current).toBe(container); - expect(container).toHaveFocus(); - }, - { timeout: 2000 } - ); -}; - -export const waitForLinkToHaveFocus = async ( - view: InfoTipView, - linkText: string -) => { +export const waitForLinkToHaveFocus = async ({ + view, + linkText, +}: ViewParam & LinkTextParam) => { const link = view.getByRole('link', { name: linkText }); await waitFor(() => { expect(link).toHaveFocus(); @@ -100,10 +90,10 @@ export const waitForLinkToHaveFocus = async ( return link; }; -export const openTipAndWaitForLink = async ( - view: InfoTipView, - linkText: string -) => { +export const openTipAndWaitForLink = async ({ + view, + linkText, +}: ViewParam & LinkTextParam) => { await clickButton(view); await waitFor(() => { expect(view.getByText(linkText)).toBeVisible(); @@ -111,11 +101,25 @@ export const openTipAndWaitForLink = async ( return view.getByRole('link', { name: linkText }); }; -export const testEscapeKeyCloseTip = async ( +export const openTipTabToLinkAndWaitForFocus = async ( view: InfoTipView, - contentToCheck: string, - shouldReturnFocus = false + linkText: string ) => { + const link = await openTipAndWaitForLink({ view, linkText }); + await act(async () => { + await userEvent.tab(); + }); + await waitFor(() => { + expect(link).toHaveFocus(); + }); + return link; +}; + +export const testEscapeKeyCloseTip = async ({ + view, + contentToCheck, + shouldReturnFocus = false, +}: ViewParam & { contentToCheck: string; shouldReturnFocus?: boolean }) => { const button = await clickButton(view); await waitFor(() => { @@ -136,11 +140,14 @@ export const testEscapeKeyCloseTip = async ( } }; -export const testFocusWrap = async ( - view: InfoTipView, - containerRef: RefObject, - direction: 'forward' | 'backward' -) => { +export const testFocusWrap = async ({ + view, + containerRef, + direction, +}: ViewParam & { + containerRef: RefObject; + direction: 'forward' | 'backward'; +}) => { const button = await clickButton(view); await waitFor( @@ -177,12 +184,16 @@ export const getTipContent = ( ); }; -export const testTabbingBetweenLinks = async ( - view: InfoTipView, - firstLinkText: string, - secondLinkText: string, - placement: TipPlacements -) => { +export const testTabbingBetweenLinks = async ({ + view, + firstLinkText, + secondLinkText, + placement, +}: ViewParam & { + firstLinkText: string; + secondLinkText: string; + placement: TipPlacements; +}) => { await clickButton(view); await waitFor(() => { @@ -190,10 +201,13 @@ export const testTabbingBetweenLinks = async ( }); await pressKey('{Tab}'); - const firstLink = await waitForLinkToHaveFocus(view, firstLinkText); + const firstLink = await waitForLinkToHaveFocus({ + view, + linkText: firstLinkText, + }); await pressKey('{Tab}'); - await waitForLinkToHaveFocus(view, secondLinkText); + await waitForLinkToHaveFocus({ view, linkText: secondLinkText }); expect(firstLink).not.toHaveFocus(); @@ -207,7 +221,7 @@ export const setupLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { containerRef, info, onClick } = createLinkSetup(linkText); + const { containerRef, info, onClick } = createLinkSetup({ linkText }); const { view } = renderView({ placement, info, onClick }); return { view, containerRef, info, onClick }; }; @@ -218,25 +232,27 @@ export const setupMultiLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { info, onClick } = createMultiLinkSetup(firstLinkText, secondLinkText); + const { info, onClick } = createMultiLinkSetup({ + firstLinkText, + secondLinkText, + }); const { view } = renderView({ placement, info, onClick }); return { view, info, onClick }; }; -export const testEscapeKeyWithOutsideFocus = async ( - view: InfoTipView, - contentToCheck: string -) => { +export const testEscapeKeyWithOutsideFocus = async ({ + view, + info, +}: ViewParam & InfoParam) => { const outsideButton = document.createElement('button'); outsideButton.textContent = 'Outside Button'; - document.body.appendChild(outsideButton); - try { + await withTemporaryElement(outsideButton, document.body, async () => { const button = await clickButton(view); await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'true'); - const tip = getTipContent(view, contentToCheck); + const tip = getTipContent(view, info); expect(tip).toBeVisible(); }); @@ -247,10 +263,132 @@ export const testEscapeKeyWithOutsideFocus = async ( await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'false'); - const tip = getTipContent(view, contentToCheck, true); + const tip = getTipContent(view, info, true); + expect(tip).not.toBeVisible(); + }); + }); +}; + +const assertTipOpen = async ({ + view, + button, + info, + placement, +}: ViewParam & InfoParam & PlacementParam & { button: HTMLElement }) => { + if (placement === 'floating') { + await waitFor(() => { + expect(view.queryAllByText(info).length).toBe(2); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + } else { + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + } +}; + +const assertTipClosed = async ({ + view, + button, + info, + placement, +}: ViewParam & InfoParam & PlacementParam & { button: HTMLElement }) => { + if (placement === 'floating') { + await waitFor(() => { + expect(view.queryByText(info)).toBeNull(); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + } else { + const tip = getTipContent(view, info); + await waitFor(() => { expect(tip).not.toBeVisible(); + expect(button).toHaveAttribute('aria-expanded', 'false'); }); + } +}; + +const withTemporaryElement = async ( + element: HTMLElement, + parent: HTMLElement, + callback: () => Promise +): Promise => { + parent.appendChild(element); + try { + return await callback(); } finally { - document.body.removeChild(outsideButton); + parent.removeChild(element); } }; + +export const testOutsideClick = async ({ + view, + info, + placement, +}: ViewParam & InfoParam & PlacementParam) => { + const button = await clickButton(view); + + await assertTipOpen({ view, button, info, placement }); + + const outsideElement = document.createElement('div'); + + await withTemporaryElement(outsideElement, document.body, async () => { + await userEvent.click(outsideElement); + await assertTipClosed({ view, button, info, placement }); + }); +}; + +export const testModalDoesNotCloseInfoTip = async ({ + view, + info, + placement, +}: ViewParam & InfoParam & PlacementParam) => { + const button = await clickButton(view); + + await assertTipOpen({ view, button, info, placement }); + + const mockModal = document.createElement('div'); + mockModal.setAttribute('role', 'dialog'); + + if (placement === 'floating') { + const modalButton = document.createElement('button'); + modalButton.textContent = 'Modal button'; + mockModal.appendChild(modalButton); + await withTemporaryElement(mockModal, view.container, async () => { + modalButton.focus(); + await pressKey('{Escape}'); + await assertTipOpen({ view, button, info, placement }); + }); + } else { + await withTemporaryElement(mockModal, document.body, async () => { + await pressKey('{Escape}'); + await assertTipOpen({ view, button, info, placement }); + }); + } +}; + +export const testRapidToggle = async ({ + view, + onClick, +}: ViewParam & { onClick: jest.Mock }) => { + const button = view.getByLabelText('Show information'); + + await act(async () => { + await userEvent.click(button); + }); + await waitFor(() => expect(onClick).toHaveBeenCalledTimes(1)); + + await act(async () => { + await userEvent.click(button); + }); + await waitFor(() => expect(onClick).toHaveBeenCalledTimes(2)); + + await act(async () => { + await userEvent.click(button); + }); + await waitFor(() => expect(onClick).toHaveBeenCalledTimes(3)); + + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClick).toHaveBeenNthCalledWith(1, { isTipHidden: false }); + expect(onClick).toHaveBeenNthCalledWith(2, { isTipHidden: true }); + expect(onClick).toHaveBeenNthCalledWith(3, { isTipHidden: false }); +}; From ae927ea79d3de8aa2807a22f873ec2fc192340ba Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 25 Nov 2025 12:18:49 -0500 Subject: [PATCH 37/59] clean up --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 106 +++--------------- packages/gamut/src/Tip/__tests__/helpers.tsx | 63 +++++++++-- 2 files changed, 68 insertions(+), 101 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index c957698e43..4194c6e7c1 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -5,17 +5,17 @@ import userEvent from '@testing-library/user-event'; import { InfoTip } from '../InfoTip'; import { createLinkSetup, - getTipContent, openTipTabToLinkAndWaitForFocus, pressKey, setupLinkTestWithPlacement, setupMultiLinkTestWithPlacement, - testEscapeKeyCloseTip, + testEscapeKeyReturnsFocus, testEscapeKeyWithOutsideFocus, testFocusWrap, testModalDoesNotCloseInfoTip, testOutsideClick, testRapidToggle, + testShowTipOnClick, testTabbingBetweenLinks, } from './helpers'; @@ -29,6 +29,20 @@ describe('InfoTip', () => { { placement: 'inline' }, { placement: 'floating' }, ])('$placement placement', ({ placement }) => { + it('shows the tip when it is clicked on', async () => { + const { view } = renderView({ + placement, + }); + await testShowTipOnClick({ view, info, placement }); + }); + + it('closes the tip when Escape key is pressed and returns focus to button', async () => { + const { view } = renderView({ + placement, + }); + await testEscapeKeyReturnsFocus({ view, info, placement }); + }); + it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { const { view } = renderView({ placement, @@ -159,93 +173,7 @@ describe('InfoTip', () => { }); }); - describe('inline placement', () => { - it('shows the tip when it is clicked on', async () => { - const { view } = renderView({}); - - const tip = view.getByText(info); - - expect(tip).not.toBeVisible(); - - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); - - expect(tip.parentElement).not.toHaveStyle({ - visibility: 'hidden', - opacity: 0, - }); - - expect(tip).toBeVisible(); - }); - - it('closes the tip when Escape key is pressed and returns focus to button', async () => { - const { view } = renderView({}); - - const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); - - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); - - await pressKey('{Escape}'); - - await waitFor(() => { - expect(tip).not.toBeVisible(); - expect(button).toHaveFocus(); - }); - }); - }); - - describe('floating placement', () => { - it('shows the tip when it is clicked on', async () => { - const { view } = renderView({ - placement: 'floating', - }); - - expect(view.queryByText(info)).toBeNull(); - - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); - - // The first get by text result is the a11y text, the second is the actual tip text - expect(view.queryAllByText(info).length).toBe(2); - }); - - it('closes the tip when Escape key is pressed and returns focus to the button', async () => { - const { view } = renderView({ - placement: 'floating', - }); - - await testEscapeKeyCloseTip({ - view, - contentToCheck: info, - shouldReturnFocus: true, - }); - }); - - it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { - const linkText = 'cool link'; - const { info, onClick } = createLinkSetup({ - linkText, - href: 'https://giphy.com/search/nichijou', - }); - const { view } = renderView({ - placement: 'floating', - info, - onClick, - }); - - await testEscapeKeyCloseTip({ - view, - contentToCheck: linkText, - shouldReturnFocus: true, - }); - }); - + describe('floating placement focus management', () => { it('wraps focus to button when tabbing forward from last focusable element', async () => { const linkText = 'cool link'; const { view, containerRef } = setupLinkTestWithPlacement( diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index ded5f8850f..0c7885c312 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -115,26 +115,65 @@ export const openTipTabToLinkAndWaitForFocus = async ( return link; }; -export const testEscapeKeyCloseTip = async ({ +export const testShowTipOnClick = async ({ view, - contentToCheck, - shouldReturnFocus = false, -}: ViewParam & { contentToCheck: string; shouldReturnFocus?: boolean }) => { - const button = await clickButton(view); + info, + placement, +}: ViewParam & InfoParam & PlacementParam) => { + const tip = placement === 'inline' ? view.getByText(info) : null; - await waitFor(() => { - const elements = view.getAllByText(contentToCheck); - expect(elements.length).toBeGreaterThan(0); + if (placement === 'inline') { + expect(tip).not.toBeVisible(); + } else { + expect(view.queryByText(info)).toBeNull(); + } + + await act(async () => { + await userEvent.click(view.getByRole('button')); }); - await pressKey('{Escape}'); + if (placement === 'inline') { + expect(tip?.parentElement).not.toHaveStyle({ + visibility: 'hidden', + opacity: 0, + }); + expect(tip).toBeVisible(); + } else { + // The first get by text result is the a11y text, the second is the actual tip text + expect(view.queryAllByText(info).length).toBe(2); + } +}; - await waitFor(() => { - expect(view.queryByText(contentToCheck)).toBeNull(); +export const testEscapeKeyReturnsFocus = async ({ + view, + info, + placement, +}: ViewParam & InfoParam & PlacementParam) => { + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); }); - if (shouldReturnFocus) { + if (placement === 'inline') { + const tip = getTipContent(view, info); + expect(tip).toBeVisible(); + + await pressKey('{Escape}'); + await waitFor(() => { + expect(tip).not.toBeVisible(); + expect(button).toHaveFocus(); + }); + } else { + await waitFor(() => { + const elements = view.getAllByText(info); + expect(elements.length).toBeGreaterThan(0); + }); + + await pressKey('{Escape}'); + + await waitFor(() => { + expect(view.queryByText(info)).toBeNull(); expect(button).toHaveFocus(); }); } From fc5e36097cef3a381fba701e6913c5861e25ac68 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 25 Nov 2025 12:58:14 -0500 Subject: [PATCH 38/59] more cleanup --- packages/gamut/src/Tip/InfoTip/index.tsx | 69 +++++++---------- packages/gamut/src/Tip/__tests__/helpers.tsx | 80 ++++++++++---------- 2 files changed, 67 insertions(+), 82 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 5630e76af3..01945aac1b 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -64,9 +64,7 @@ export const InfoTip: React.FC = ({ callback: () => void, delay: number ) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + clearTimeout(timeoutRef.current ?? undefined); timeoutRef.current = setTimeout(() => { callback(); timeoutRef.current = null; @@ -79,8 +77,8 @@ export const InfoTip: React.FC = ({ (node: HTMLDivElement | null) => { popoverContentNodeRef.current = node; - if (node && onClick && !isTipHidden && isFloating) { - onClick({ isTipHidden: false }); + if (node && !isTipHidden && isFloating) { + onClick?.({ isTipHidden: false }); } }, [onClick, isTipHidden, isFloating] @@ -93,8 +91,8 @@ export const InfoTip: React.FC = ({ const announceTimeout = announceTimeoutRef.current; return () => { - if (ariaHiddenTimeout) clearTimeout(ariaHiddenTimeout); - if (announceTimeout) clearTimeout(announceTimeout); + clearTimeout(ariaHiddenTimeout ?? undefined); + clearTimeout(announceTimeout ?? undefined); }; }, []); @@ -102,21 +100,17 @@ export const InfoTip: React.FC = ({ (nextTipState: boolean) => { setHideTip(nextTipState); - if (!nextTipState) { - if (!isFloating) { - clearAndSetTimeout( - ariaHiddenTimeoutRef, - () => setIsAriaHidden(true), - ARIA_HIDDEN_DELAY_MS - ); - } - } else { + if (!nextTipState && !isFloating) { + clearAndSetTimeout( + ariaHiddenTimeoutRef, + () => setIsAriaHidden(true), + ARIA_HIDDEN_DELAY_MS + ); + } else if (nextTipState) { if (isAriaHidden) setIsAriaHidden(false); setShouldAnnounce(false); - if (ariaHiddenTimeoutRef.current) { - clearTimeout(ariaHiddenTimeoutRef.current); - ariaHiddenTimeoutRef.current = null; - } + clearTimeout(ariaHiddenTimeoutRef.current ?? undefined); + ariaHiddenTimeoutRef.current = null; } }, [isAriaHidden, isFloating, clearAndSetTimeout] @@ -125,10 +119,11 @@ export const InfoTip: React.FC = ({ const handleOutsideClick = useCallback( (e: MouseEvent) => { const wrapper = wrapperRef.current; - if ( + const isOutside = wrapper && - (e.target instanceof HTMLElement ? !wrapper.contains(e.target) : true) - ) { + (!(e.target instanceof HTMLElement) || !wrapper.contains(e.target)); + + if (isOutside) { setTipIsHidden(true); } }, @@ -149,11 +144,11 @@ export const InfoTip: React.FC = ({ isInitialMount.current = false; return; } - if (!isFloating && onClick) { - onClick({ isTipHidden }); - } - if (isFloating && isTipHidden && onClick) { - onClick({ isTipHidden: true }); + + if (!isFloating) { + onClick?.({ isTipHidden }); + } else if (isTipHidden) { + onClick?.({ isTipHidden: true }); } }, [isTipHidden, isFloating, onClick]); @@ -168,10 +163,7 @@ export const InfoTip: React.FC = ({ if (isTipHidden) return; const handleGlobalEscapeKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - - const hasModal = document.querySelector(MODAL_SELECTOR); - if (hasModal) return; + if (e.key !== 'Escape' || document.querySelector(MODAL_SELECTOR)) return; e.preventDefault(); setTipIsHidden(true); @@ -188,10 +180,9 @@ export const InfoTip: React.FC = ({ if (focusableElements.length === 0) return; const lastElement = focusableElements[focusableElements.length - 1]; - const { activeElement } = document; // Only wrap forward: if on last element, wrap to button - if (activeElement === lastElement) { + if (document.activeElement === lastElement) { event.preventDefault(); buttonRef.current?.focus(); } @@ -200,16 +191,12 @@ export const InfoTip: React.FC = ({ let popoverContent: HTMLDivElement | null = null; const timeoutId = setTimeout(() => { popoverContent = popoverContentNodeRef.current; - if (popoverContent) { - popoverContent.addEventListener('keydown', handleTabKeyInPopover); - } + popoverContent?.addEventListener('keydown', handleTabKeyInPopover); }, 0); return () => { clearTimeout(timeoutId); - if (popoverContent) { - popoverContent.removeEventListener('keydown', handleTabKeyInPopover); - } + popoverContent?.removeEventListener('keydown', handleTabKeyInPopover); document.removeEventListener('keydown', handleGlobalEscapeKey); }; } @@ -242,7 +229,7 @@ export const InfoTip: React.FC = ({ const extractedTextContent = useMemo(() => extractTextContent(info), [info]); const screenreaderInfo = - shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`; + shouldAnnounce && !isTipHidden ? extractedTextContent : '\xa0'; const screenreaderText = useMemo( () => ( diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 0c7885c312..230c34561c 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -21,9 +21,7 @@ type PlacementParam = { placement: Placement }; export const createFocusOnClick = (ref: RefObject) => { return ({ isTipHidden }: { isTipHidden: boolean }) => { - if (!isTipHidden) { - ref.current?.focus(); - } + if (!isTipHidden) ref.current?.focus(); }; }; @@ -120,9 +118,10 @@ export const testShowTipOnClick = async ({ info, placement, }: ViewParam & InfoParam & PlacementParam) => { - const tip = placement === 'inline' ? view.getByText(info) : null; + const isInline = placement === 'inline'; + const tip = isInline ? view.getByText(info) : null; - if (placement === 'inline') { + if (isInline) { expect(tip).not.toBeVisible(); } else { expect(view.queryByText(info)).toBeNull(); @@ -132,7 +131,7 @@ export const testShowTipOnClick = async ({ await userEvent.click(view.getByRole('button')); }); - if (placement === 'inline') { + if (isInline) { expect(tip?.parentElement).not.toHaveStyle({ visibility: 'hidden', opacity: 0, @@ -154,29 +153,27 @@ export const testEscapeKeyReturnsFocus = async ({ await userEvent.click(button); }); - if (placement === 'inline') { + const isInline = placement === 'inline'; + + if (isInline) { const tip = getTipContent(view, info); expect(tip).toBeVisible(); - - await pressKey('{Escape}'); - - await waitFor(() => { - expect(tip).not.toBeVisible(); - expect(button).toHaveFocus(); - }); } else { await waitFor(() => { - const elements = view.getAllByText(info); - expect(elements.length).toBeGreaterThan(0); + expect(view.getAllByText(info).length).toBeGreaterThan(0); }); + } - await pressKey('{Escape}'); + await pressKey('{Escape}'); - await waitFor(() => { + await waitFor(() => { + if (isInline) { + expect(getTipContent(view, info)).not.toBeVisible(); + } else { expect(view.queryByText(info)).toBeNull(); - expect(button).toHaveFocus(); - }); - } + } + expect(button).toHaveFocus(); + }); }; export const testFocusWrap = async ({ @@ -214,11 +211,10 @@ export const getTipContent = ( text: string, useQuery = false ) => { - const getAllMethod = useQuery ? 'queryAllByText' : 'getAllByText'; - const elements = view[getAllMethod](text); + const elements = view[useQuery ? 'queryAllByText' : 'getAllByText'](text); // Find the tip body (not the screenreader text with aria-live="assertive") return ( - elements.find((el) => el.getAttribute('aria-live') !== 'assertive') || + elements.find((el) => el.getAttribute('aria-live') !== 'assertive') ?? elements[0] ); }; @@ -314,14 +310,15 @@ const assertTipOpen = async ({ info, placement, }: ViewParam & InfoParam & PlacementParam & { button: HTMLElement }) => { - if (placement === 'floating') { + const isFloating = placement === 'floating'; + + if (isFloating) { await waitFor(() => { expect(view.queryAllByText(info).length).toBe(2); expect(button).toHaveAttribute('aria-expanded', 'true'); }); } else { - const tip = getTipContent(view, info); - expect(tip).toBeVisible(); + expect(getTipContent(view, info)).toBeVisible(); expect(button).toHaveAttribute('aria-expanded', 'true'); } }; @@ -332,18 +329,16 @@ const assertTipClosed = async ({ info, placement, }: ViewParam & InfoParam & PlacementParam & { button: HTMLElement }) => { - if (placement === 'floating') { - await waitFor(() => { + const isFloating = placement === 'floating'; + + await waitFor(() => { + if (isFloating) { expect(view.queryByText(info)).toBeNull(); - expect(button).toHaveAttribute('aria-expanded', 'false'); - }); - } else { - const tip = getTipContent(view, info); - await waitFor(() => { - expect(tip).not.toBeVisible(); - expect(button).toHaveAttribute('aria-expanded', 'false'); - }); - } + } else { + expect(getTipContent(view, info)).not.toBeVisible(); + } + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); }; const withTemporaryElement = async ( @@ -388,17 +383,20 @@ export const testModalDoesNotCloseInfoTip = async ({ const mockModal = document.createElement('div'); mockModal.setAttribute('role', 'dialog'); - if (placement === 'floating') { + const isFloating = placement === 'floating'; + const parent = isFloating ? view.container : document.body; + + if (isFloating) { const modalButton = document.createElement('button'); modalButton.textContent = 'Modal button'; mockModal.appendChild(modalButton); - await withTemporaryElement(mockModal, view.container, async () => { + await withTemporaryElement(mockModal, parent, async () => { modalButton.focus(); await pressKey('{Escape}'); await assertTipOpen({ view, button, info, placement }); }); } else { - await withTemporaryElement(mockModal, document.body, async () => { + await withTemporaryElement(mockModal, parent, async () => { await pressKey('{Escape}'); await assertTipOpen({ view, button, info, placement }); }); From ad95c33684f96063e1e4e441ebd21f783dcdf51a Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 25 Nov 2025 16:04:22 -0500 Subject: [PATCH 39/59] start labelledBy --- packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx | 13 ++++++++++++- packages/gamut/src/Tip/InfoTip/index.tsx | 9 +++++++++ packages/gamut/src/Tip/__tests__/InfoTip.test.tsx | 3 ++- .../src/lib/Molecules/Tips/InfoTip/InfoTip.mdx | 9 +++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx index 9432498792..f402542a69 100644 --- a/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx +++ b/packages/gamut/src/Tip/InfoTip/InfoTipButton.tsx @@ -22,7 +22,17 @@ export type InfoTipButtonProps = ComponentProps & Pick; export const InfoTipButton = forwardRef( - ({ active, children, emphasis, 'aria-label': ariaLabel, ...props }, ref) => { + ( + { + active, + children, + emphasis, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + ...props + }, + ref + ) => { const Icon = emphasis === 'high' ? MiniInfoCircleIcon : MiniInfoOutlineIcon; return ( @@ -31,6 +41,7 @@ export const InfoTipButton = forwardRef( active={active} aria-expanded={active} aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} ref={ref} > {Icon && ( diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 45677e7b74..fe913652b4 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -19,7 +19,14 @@ import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { alignment?: TipBaseAlignment; + /** + * Accessible label for the InfoTip button. + */ ariaLabel?: string; + /** + * ID of an element that labels the InfoTip button. + */ + ariaLabelledby?: string; emphasis?: 'low' | 'high'; /** * Called when the info tip is clicked - the onClick function is called after the DOM updates and the tip is mounted. @@ -32,6 +39,7 @@ const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; export const InfoTip: React.FC = ({ alignment = 'top-right', ariaLabel, + ariaLabelledby, emphasis = 'low', info, onClick, @@ -194,6 +202,7 @@ export const InfoTip: React.FC = ({ active={!isTipHidden} aria-expanded={!isTipHidden} aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} aria-roledescription="More information button" emphasis={emphasis} ref={buttonRef} diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index a08261ea71..ec6b49c731 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -20,6 +20,7 @@ import { const info = 'I am information'; const renderView = setupRtl(InfoTip, { + ariaLabel: 'Show information', info, }); @@ -247,7 +248,7 @@ describe('InfoTip', () => { }); describe('ariaLabel', () => { - it('applies default aria-label when ariaLabel is not provided', () => { + it('applies aria-label from setup', () => { const { view } = renderView({}); view.getByLabelText('Show information'); }); diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 10c447d4c2..77c7d558b4 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -83,9 +83,14 @@ InfoTips listen for the Escape key globally and will close automatically **unles -## Custom Accessible Label +## Custom Accessible Labeling -The InfoTip button label can be customized using the `ariaLabel` prop. This is useful when the default "Show information" label doesn't provide enough context. +**Important for Accessibility**: Always provide either `ariaLabel` or `ariaLabelledby` to ensure screen reader users understand the purpose of the InfoTip button. + +The InfoTip button's accessible label can be customized using either prop: + +- **`ariaLabel`**: Directly sets the accessible label text. Useful when you want to provide a custom label without referencing another element. +- **`ariaLabelledby`**: References the ID of another element to use as the label. Useful when you want the InfoTip button to be labeled by visible text elsewhere on the page. This is useful for when the `InfoTip` is beside text that contextualizes it. From 54bb2d33ea8f5a3237497ab8a2fe6e66ddff0d06 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 26 Nov 2025 09:40:30 -0500 Subject: [PATCH 40/59] aria-label example --- packages/gamut/src/Tip/InfoTip/index.tsx | 4 +-- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 2 +- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 4 ++- .../Tips/InfoTip/InfoTip.stories.tsx | 32 +++++++++++++++---- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index fe913652b4..ff7a68174b 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -20,11 +20,11 @@ import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { alignment?: TipBaseAlignment; /** - * Accessible label for the InfoTip button. + * Accessible label for the InfoTip button. Its recommended to provide either `ariaLabel` or `ariaLabelledby`. */ ariaLabel?: string; /** - * ID of an element that labels the InfoTip button. + * ID of an element that labels the InfoTip button. Its recommended to provide either `ariaLabel` or `ariaLabelledby`. */ ariaLabelledby?: string; emphasis?: 'low' | 'high'; diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index ec6b49c731..666732ff58 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -248,7 +248,7 @@ describe('InfoTip', () => { }); describe('ariaLabel', () => { - it('applies aria-label from setup', () => { + it('applies aria-label when provided', () => { const { view } = renderView({}); view.getByLabelText('Show information'); }); diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 77c7d558b4..5b09c4dad3 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -85,13 +85,15 @@ InfoTips listen for the Escape key globally and will close automatically **unles ## Custom Accessible Labeling -**Important for Accessibility**: Always provide either `ariaLabel` or `ariaLabelledby` to ensure screen reader users understand the purpose of the InfoTip button. +Provide either `ariaLabel` or `ariaLabelledby` to ensure screen reader users understand the purpose of the InfoTip button. The InfoTip button's accessible label can be customized using either prop: - **`ariaLabel`**: Directly sets the accessible label text. Useful when you want to provide a custom label without referencing another element. - **`ariaLabelledby`**: References the ID of another element to use as the label. Useful when you want the InfoTip button to be labeled by visible text elsewhere on the page. This is useful for when the `InfoTip` is beside text that contextualizes it. +The `InfoTipButton` also has an `aria-roledescription` of `More information button` to provide essential context to screen reader users about the button's purpose. + ## InfoTips and zIndex diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index f181ae2d97..18db38de04 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -4,10 +4,12 @@ import { FillButton, FlexBox, GridBox, + IconButton, InfoTip, Modal, Text, } from '@codecademy/gamut'; +import { SparkleIcon } from '@codecademy/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; @@ -74,12 +76,30 @@ export const Placement: Story = { export const AriaLabel: Story = { render: (args) => ( - - + + + null} + /> + + + + + I am some helpful yet concise text that needs more explanation + + ), }; From ad0ffba8634dae14d4d8478a6fa9da0f26c3e20e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 1 Dec 2025 10:48:57 -0500 Subject: [PATCH 41/59] fix infotops inside modals --- packages/gamut/src/Tip/InfoTip/index.tsx | 17 ++++-- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 5 ++ packages/gamut/src/Tip/__tests__/helpers.tsx | 46 ++++++++++++++ packages/gamut/src/Tip/__tests__/mocks.tsx | 23 ++++++- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 10 +++ .../Tips/InfoTip/InfoTip.stories.tsx | 61 +++++++++++++++++++ 6 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 01945aac1b..f456ec3a27 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -163,14 +163,22 @@ export const InfoTip: React.FC = ({ if (isTipHidden) return; const handleGlobalEscapeKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || document.querySelector(MODAL_SELECTOR)) return; + if (e.key !== 'Escape') return; + + const openModals = document.querySelectorAll(MODAL_SELECTOR); + const hasUnrelatedModal = Array.from(openModals).some( + (modal) => wrapperRef.current && !modal.contains(wrapperRef.current) + ); + + if (hasUnrelatedModal) return; e.preventDefault(); + e.stopImmediatePropagation(); setTipIsHidden(true); buttonRef.current?.focus(); }; - document.addEventListener('keydown', handleGlobalEscapeKey); + document.addEventListener('keydown', handleGlobalEscapeKey, true); if (isFloating) { const handleTabKeyInPopover = (event: KeyboardEvent) => { @@ -197,11 +205,12 @@ export const InfoTip: React.FC = ({ return () => { clearTimeout(timeoutId); popoverContent?.removeEventListener('keydown', handleTabKeyInPopover); - document.removeEventListener('keydown', handleGlobalEscapeKey); + document.removeEventListener('keydown', handleGlobalEscapeKey, true); }; } - return () => document.removeEventListener('keydown', handleGlobalEscapeKey); + return () => + document.removeEventListener('keydown', handleGlobalEscapeKey, true); }, [isTipHidden, isFloating, setTipIsHidden, getFocusableElements]); const Tip = loaded && isFloating ? FloatingTip : InlineTip; diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 4194c6e7c1..e41bb353cc 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -12,6 +12,7 @@ import { testEscapeKeyReturnsFocus, testEscapeKeyWithOutsideFocus, testFocusWrap, + testInfoTipInsideModalClosesOnEscape, testModalDoesNotCloseInfoTip, testOutsideClick, testRapidToggle, @@ -57,6 +58,10 @@ describe('InfoTip', () => { await testModalDoesNotCloseInfoTip({ view, info, placement }); }); + it('closes the tip when Escape is pressed if the InfoTip is inside a modal', async () => { + await testInfoTipInsideModalClosesOnEscape({ info, placement }); + }); + it('calls onClick with isTipHidden: false when tip opens', async () => { const onClick = jest.fn(); const { view } = renderView({ diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 230c34561c..1883ff4bb2 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -429,3 +429,49 @@ export const testRapidToggle = async ({ expect(onClick).toHaveBeenNthCalledWith(2, { isTipHidden: true }); expect(onClick).toHaveBeenNthCalledWith(3, { isTipHidden: false }); }; + +export const testInfoTipInsideModalClosesOnEscape = async ({ + info, + placement, +}: InfoParam & PlacementParam) => { + const { InfoTipInsideModalMock } = await import('./mocks'); + const renderView = setupRtl(InfoTipInsideModalMock, { info, placement }); + const { view } = renderView(); + + const openModalButton = view.getByRole('button', { name: 'Open Modal' }); + await act(async () => { + await userEvent.click(openModalButton); + }); + + await waitFor(() => { + expect(view.getByRole('dialog')).toBeInTheDocument(); + }); + + const infoTipButton = view.getByLabelText('Show information'); + + await act(async () => { + await userEvent.click(infoTipButton); + }); + + // Wait for InfoTip to be visible + await waitFor(() => { + const infoTexts = view.getAllByText(info); + const visibleInfo = infoTexts.find( + (el) => el.getAttribute('aria-hidden') !== 'true' + ); + expect(visibleInfo).toBeVisible(); + }); + + await pressKey('{Escape}'); + + // InfoTip should be closed - both the tip body and screenreader text should not be visible + await waitFor(() => { + const infoTexts = view.queryAllByText(info); + infoTexts.forEach((el) => { + expect(el).not.toBeVisible(); + }); + }); + + // Modal should still be open + expect(view.getByRole('dialog')).toBeInTheDocument(); +}; diff --git a/packages/gamut/src/Tip/__tests__/mocks.tsx b/packages/gamut/src/Tip/__tests__/mocks.tsx index 4cbbc1f561..e76d95ddca 100644 --- a/packages/gamut/src/Tip/__tests__/mocks.tsx +++ b/packages/gamut/src/Tip/__tests__/mocks.tsx @@ -1,6 +1,8 @@ -import { ComponentProps } from 'react'; +import { ComponentProps, useState } from 'react'; import { FillButton } from '../../Button'; +import { Modal } from '../../Modals'; +import { InfoTip, InfoTipProps } from '../InfoTip'; import { ToolTip, ToolTipProps } from '../ToolTip'; export const ToolTipMock: React.FC< @@ -14,3 +16,22 @@ export const ToolTipMock: React.FC< ); }; + +export const InfoTipInsideModalMock: React.FC< + Pick +> = ({ info, placement }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + setIsOpen(true)}>Open Modal + setIsOpen(false)} + > + + + + ); +}; diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 28b40beca7..b66b81ff58 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -82,6 +82,16 @@ InfoTips listen for the Escape key globally and will close automatically **unles +### InfoTips inside Modals + +InfoTips can be placed inside Modal content and will work correctly: + +- **InfoTips inside Modals close on Escape first** - When you press Escape, the InfoTip closes but the Modal stays open +- **Pressing Escape again closes the Modal** - This provides proper layered keyboard navigation +- **Focus management works correctly** - Focus returns to the InfoTip button when closed, maintaining proper focus flow within the Modal + + + ## InfoTips and zIndex You can change the zIndex of your `InfoTip` with the zIndex property. diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 1bd341e1a2..332f3bdf74 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -270,3 +270,64 @@ export const WithModal: Story = { ); }, }; + +export const InfoTipInsideModal: Story = { + args: { + placement: 'inline', + info: 'This is helpful information about the field. Try pressing Escape!', + }, + render: function InfoTipInsideModal(args) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + + setIsModalOpen(true)}> + Open Modal with InfoTip Inside + + + + + Test InfoTip Inside Modal (Inline): + + +
  • Click "Open Modal with InfoTip Inside"
  • +
  • Click or press Enter on the InfoTip button (ⓘ icon)
  • +
  • Press Escape - should close InfoTip (Modal stays open)
  • +
  • Press Escape again - should close Modal
  • +
  • + + Inline InfoTips work correctly inside Modals without z-index + issues. + +
  • +
    +
    + + setIsModalOpen(false)} + > + + This modal contains an InfoTip below: + + + Field Label + + + + + The InfoTip inside this modal can be closed with Escape without + closing the modal itself. Inline placement works correctly. + + + setIsModalOpen(false)}> + Close Modal + + + +
    + ); + }, +}; From 2fcb873052a0e3077bbe790071763cd140d3d5b8 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 1 Dec 2025 11:31:56 -0500 Subject: [PATCH 42/59] clean up stories --- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 22 +--- .../Tips/InfoTip/InfoTip.stories.tsx | 110 +++++------------- 2 files changed, 33 insertions(+), 99 deletions(-) diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index b66b81ff58..25126adff8 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -71,24 +71,14 @@ When using `placement="floating"`, InfoTips implements focus management for easi -### Global Escape key handling +### InfoTips with Modals and Dialogs -InfoTips listen for the Escape key globally and will close automatically **unless** a higher-priority element is open: +InfoTips have intelligent Escape key handling that works correctly both inside and outside Modals: -- **InfoTips close on Escape** regardless of where keyboard focus is -- **Modals and dialogs take priority** - InfoTips detect when `dialog[open]`, `role="dialog"`, or `role="alertdialog"` elements are present and defer Escape handling to them -- After closing a modal, pressing Escape again will close the InfoTip -- When an InfoTip closes via Escape, focus automatically returns to the InfoTip button - - - -### InfoTips inside Modals - -InfoTips can be placed inside Modal content and will work correctly: - -- **InfoTips inside Modals close on Escape first** - When you press Escape, the InfoTip closes but the Modal stays open -- **Pressing Escape again closes the Modal** - This provides proper layered keyboard navigation -- **Focus management works correctly** - Focus returns to the InfoTip button when closed, maintaining proper focus flow within the Modal +- **InfoTips close on Escape** regardless of where keyboard focus is, and focus returns to the InfoTip button +- **InfoTips inside Modals close first** - When an InfoTip is placed inside a Modal, pressing Escape closes the InfoTip while keeping the Modal open +- **Layered navigation** - After closing an InfoTip inside a Modal, pressing Escape again closes the Modal +- **Modals take priority over external InfoTips** - InfoTips detect when `dialog[open]`, `role="dialog"`, or `role="alertdialog"` elements are present and defer Escape handling to them when the InfoTip is outside the Modal diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 332f3bdf74..9379091488 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -22,14 +22,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - render: (args) => ( - - Some text that needs info - - ), -}; - export const Emphasis: Story = { args: { emphasis: 'high', @@ -108,25 +100,6 @@ export const WithLinksOrButtons: Story = { }, }; -export const ZIndex: Story = { - args: { - info: 'I am inline, cool', - zIndex: 5, - }, - render: (args) => ( - - - I will not be behind the infotip, sad + unreadable - - - - I will be behind the infotip, nice + great - - - - ), -}; - export const KeyboardNavigation: Story = { render: function KeyboardNavigation() { const floatingRef = useRef(null); @@ -215,62 +188,6 @@ export const KeyboardNavigation: Story = { }, }; -export const WithModal: Story = { - args: { - placement: 'floating', - info: 'This InfoTip should not close when you press Escape inside the modal!', - }, - render: function WithModal(args) { - const [isModalOpen, setIsModalOpen] = useState(false); - - return ( - - - Here is some information - - - - setIsModalOpen(true)}>Open Modal - - - - Test Escape Key Behavior with Modals: - - -
  • Press enter to open the InfoTip
  • -
  • Tab to the "Open Modal" button and press enter
  • -
  • Press Escape - should close modal only (InfoTip stays open)
  • -
  • Press Escape again - should close InfoTip
  • -
  • - - InfoTip detects when modals are open and defers Escape key - handling to them. - -
  • -
    -
    - - setIsModalOpen(false)} - > - - - This is a modal. Press Escape to close it. The InfoTip should - remain open behind this modal. - - setIsModalOpen(false)}> - Close Modal - - - -
    - ); - }, -}; - export const InfoTipInsideModal: Story = { args: { placement: 'inline', @@ -331,3 +248,30 @@ export const InfoTipInsideModal: Story = { ); }, }; + +export const ZIndex: Story = { + args: { + info: 'I am inline, cool', + zIndex: 5, + }, + render: (args) => ( + + + I will not be behind the infotip, sad + unreadable + + + + I will be behind the infotip, nice + great + + + + ), +}; + +export const Default: Story = { + render: (args) => ( + + Some text that needs info + + ), +}; From caaad67039f73d7ed025d872d70adb2129c0ea05 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 1 Dec 2025 12:56:27 -0500 Subject: [PATCH 43/59] close all on esc --- packages/gamut/src/Tip/InfoTip/index.tsx | 2 +- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 86 ++++++++++++++++++- packages/gamut/src/Tip/__tests__/helpers.tsx | 53 ++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index f456ec3a27..e8b21d946f 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -173,7 +173,7 @@ export const InfoTip: React.FC = ({ if (hasUnrelatedModal) return; e.preventDefault(); - e.stopImmediatePropagation(); + e.stopPropagation(); setTipIsHidden(true); buttonRef.current?.focus(); }; diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index e41bb353cc..7b04945107 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,10 +1,13 @@ import { setupRtl } from '@codecademy/gamut-tests'; -import { act, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { InfoTip } from '../InfoTip'; import { createLinkSetup, + expectTipToBeClosed, + expectTipToBeVisible, + openInfoTipsWithKeyboard, openTipTabToLinkAndWaitForFocus, pressKey, setupLinkTestWithPlacement, @@ -242,4 +245,85 @@ describe('InfoTip', () => { }); }); }); + + describe('Multiple InfoTips', () => { + it('closes all InfoTips when Escape is pressed', async () => { + const view = render( + <> + + + + + ); + + await openInfoTipsWithKeyboard({ view, count: 3 }); + + // Verify all are visible + await waitFor(() => { + expectTipToBeVisible({ view, text: 'InfoTip A' }); + expectTipToBeVisible({ view, text: 'InfoTip B' }); + expectTipToBeVisible({ view, text: 'InfoTip C' }); + }); + + // Press Escape - all should close + await pressKey('{Escape}'); + + await waitFor(() => { + expectTipToBeClosed({ view, text: 'InfoTip A' }); + expectTipToBeClosed({ view, text: 'InfoTip B' }); + expectTipToBeClosed({ view, text: 'InfoTip C' }); + }); + }); + + it('closes all InfoTips when clicking outside', async () => { + const view = render( +
    + + +
    Outside
    +
    + ); + + await openInfoTipsWithKeyboard({ view, count: 2 }); + + // Verify both are visible + await waitFor(() => { + expectTipToBeVisible({ view, text: 'InfoTip A' }); + expectTipToBeVisible({ view, text: 'InfoTip B' }); + }); + + // Click outside - both should close + await userEvent.click(view.getByTestId('outside')); + + await waitFor(() => { + expectTipToBeClosed({ view, text: 'InfoTip A' }); + expectTipToBeClosed({ view, text: 'InfoTip B' }); + }); + }); + + it('works with both inline and floating placement InfoTips', async () => { + const view = render( + <> + + + + ); + + await openInfoTipsWithKeyboard({ view, count: 2 }); + + // Verify both are visible + await waitFor(() => { + expectTipToBeVisible({ view, text: 'Inline InfoTip' }); + expectTipToBeVisible({ view, text: 'Floating InfoTip' }); + }); + + // Press Escape - both should close + await pressKey('{Escape}'); + + await waitFor(() => { + expectTipToBeClosed({ view, text: 'Inline InfoTip' }); + expectTipToBeClosed({ view, text: 'Floating InfoTip' }); + }); + }); + }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 1883ff4bb2..16724d2ccf 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -475,3 +475,56 @@ export const testInfoTipInsideModalClosesOnEscape = async ({ // Modal should still be open expect(view.getByRole('dialog')).toBeInTheDocument(); }; + +// Multiple InfoTips helpers +export const getVisibleTip = ({ + view, + text, +}: { + view: InfoTipView; + text: string; +}) => { + const elements = view.getAllByText(text); + return elements.find((el) => el.getAttribute('aria-hidden') === 'false'); +}; + +export const expectTipToBeVisible = ({ + view, + text, +}: { + view: InfoTipView; + text: string; +}) => { + const tip = getVisibleTip({ view, text }); + expect(tip).toBeVisible(); +}; + +export const expectTipToBeClosed = ({ + view, + text, +}: { + view: InfoTipView; + text: string; +}) => { + const tip = getVisibleTip({ view, text }); + expect(tip).toBeUndefined(); +}; + +export const openInfoTipsWithKeyboard = async ({ + view, + count, +}: { + view: InfoTipView; + count: number; +}) => { + const buttons = view.getAllByLabelText('Show information'); + buttons[0].focus(); + await userEvent.keyboard('{Enter}'); + + for (let i = 1; i < count; i += 1) { + // eslint-disable-next-line no-await-in-loop + await userEvent.tab(); + // eslint-disable-next-line no-await-in-loop + await userEvent.keyboard('{Enter}'); + } +}; From 624041a7b79b74c41053bc6c2868ca2c744b65cd Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 1 Dec 2025 12:57:38 -0500 Subject: [PATCH 44/59] add --- packages/gamut/src/Tip/__tests__/helpers.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 16724d2ccf..60d782abfe 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -472,11 +472,9 @@ export const testInfoTipInsideModalClosesOnEscape = async ({ }); }); - // Modal should still be open expect(view.getByRole('dialog')).toBeInTheDocument(); }; -// Multiple InfoTips helpers export const getVisibleTip = ({ view, text, From 8dc7bf73241a869990340ec1221d3352da86aa12 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 1 Dec 2025 15:27:36 -0500 Subject: [PATCH 45/59] tests fixed --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 67 +++++++++---------- packages/gamut/src/Tip/__tests__/helpers.tsx | 52 ++++++++++---- 2 files changed, 72 insertions(+), 47 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index 7b04945107..f3d454c78d 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,4 +1,4 @@ -import { setupRtl } from '@codecademy/gamut-tests'; +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; import { act, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -249,80 +249,79 @@ describe('InfoTip', () => { describe('Multiple InfoTips', () => { it('closes all InfoTips when Escape is pressed', async () => { const view = render( - <> + - + ); await openInfoTipsWithKeyboard({ view, count: 3 }); - // Verify all are visible await waitFor(() => { - expectTipToBeVisible({ view, text: 'InfoTip A' }); - expectTipToBeVisible({ view, text: 'InfoTip B' }); - expectTipToBeVisible({ view, text: 'InfoTip C' }); + expectTipToBeVisible({ text: 'InfoTip A' }); + expectTipToBeVisible({ text: 'InfoTip B' }); + expectTipToBeVisible({ text: 'InfoTip C' }); }); - // Press Escape - all should close await pressKey('{Escape}'); await waitFor(() => { - expectTipToBeClosed({ view, text: 'InfoTip A' }); - expectTipToBeClosed({ view, text: 'InfoTip B' }); - expectTipToBeClosed({ view, text: 'InfoTip C' }); + expectTipToBeClosed({ text: 'InfoTip A' }); + expectTipToBeClosed({ text: 'InfoTip B' }); + expectTipToBeClosed({ text: 'InfoTip C' }); }); }); it('closes all InfoTips when clicking outside', async () => { const view = render( -
    - - -
    Outside
    -
    + +
    + + +
    Outside
    +
    +
    ); await openInfoTipsWithKeyboard({ view, count: 2 }); - // Verify both are visible await waitFor(() => { - expectTipToBeVisible({ view, text: 'InfoTip A' }); - expectTipToBeVisible({ view, text: 'InfoTip B' }); + expectTipToBeVisible({ text: 'InfoTip A' }); + expectTipToBeVisible({ text: 'InfoTip B' }); }); - // Click outside - both should close await userEvent.click(view.getByTestId('outside')); await waitFor(() => { - expectTipToBeClosed({ view, text: 'InfoTip A' }); - expectTipToBeClosed({ view, text: 'InfoTip B' }); + expectTipToBeClosed({ text: 'InfoTip A' }); + expectTipToBeClosed({ text: 'InfoTip B' }); }); }); - it('works with both inline and floating placement InfoTips', async () => { + it('closes multiple InfoTips with different placements', async () => { const view = render( - <> - - - + + + + + ); - await openInfoTipsWithKeyboard({ view, count: 2 }); + await openInfoTipsWithKeyboard({ view, count: 3 }); - // Verify both are visible await waitFor(() => { - expectTipToBeVisible({ view, text: 'Inline InfoTip' }); - expectTipToBeVisible({ view, text: 'Floating InfoTip' }); + expectTipToBeVisible({ text: 'First Tip' }); + expectTipToBeVisible({ text: 'Second Tip' }); + expectTipToBeVisible({ text: 'Third Tip' }); }); - // Press Escape - both should close await pressKey('{Escape}'); await waitFor(() => { - expectTipToBeClosed({ view, text: 'Inline InfoTip' }); - expectTipToBeClosed({ view, text: 'Floating InfoTip' }); + expectTipToBeClosed({ text: 'First Tip' }); + expectTipToBeClosed({ text: 'Second Tip' }); + expectTipToBeClosed({ text: 'Third Tip' }); }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 60d782abfe..74d48b40c3 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -1,5 +1,5 @@ import { setupRtl } from '@codecademy/gamut-tests'; -import { act, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef, RefObject } from 'react'; @@ -475,44 +475,70 @@ export const testInfoTipInsideModalClosesOnEscape = async ({ expect(view.getByRole('dialog')).toBeInTheDocument(); }; +type ViewWithQueries = { + getAllByText: (text: string) => HTMLElement[]; + getAllByLabelText: (text: string) => HTMLElement[]; +}; + export const getVisibleTip = ({ - view, text, + placement, }: { - view: InfoTipView; text: string; + placement?: 'inline' | 'floating'; }) => { - const elements = view.getAllByText(text); - return elements.find((el) => el.getAttribute('aria-hidden') === 'false'); + const elements = screen.queryAllByText(text); + + for (const el of elements) { + if (el.closest('[class*="ScreenreaderNavigableText"]')) { + continue; + } + + if (!placement || placement === 'inline') { + const tipBody = el.closest('[class*="TipBody"]'); + if (tipBody && tipBody.getAttribute('aria-hidden') === 'false') { + return el; + } + } + + if (placement === 'floating') { + const popover = el.closest('[role="dialog"]'); + if (popover) { + return el; + } + } + } + + return undefined; }; export const expectTipToBeVisible = ({ - view, text, + placement, }: { - view: InfoTipView; text: string; + placement?: 'inline' | 'floating'; }) => { - const tip = getVisibleTip({ view, text }); + const tip = getVisibleTip({ text, placement }); expect(tip).toBeVisible(); }; export const expectTipToBeClosed = ({ - view, text, + placement, }: { - view: InfoTipView; text: string; + placement?: 'inline' | 'floating'; }) => { - const tip = getVisibleTip({ view, text }); - expect(tip).toBeUndefined(); + const tip = getVisibleTip({ text, placement }); + expect(tip).not.toBeVisible(); }; export const openInfoTipsWithKeyboard = async ({ view, count, }: { - view: InfoTipView; + view: ViewWithQueries; count: number; }) => { const buttons = view.getAllByLabelText('Show information'); From c149f00909739286ff999d98cba526e3468d136e Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 2 Dec 2025 10:44:02 -0500 Subject: [PATCH 46/59] refactor tests --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 183 +++++++----------- packages/gamut/src/Tip/__tests__/helpers.tsx | 18 +- packages/gamut/src/Tip/__tests__/mocks.tsx | 14 ++ 3 files changed, 105 insertions(+), 110 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index f3d454c78d..bf6ab2cec9 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,12 +1,12 @@ -import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; -import { act, render, waitFor } from '@testing-library/react'; +import { setupRtl } from '@codecademy/gamut-tests'; +import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { InfoTip } from '../InfoTip'; import { createLinkSetup, - expectTipToBeClosed, - expectTipToBeVisible, + expectTipsClosed, + expectTipsVisible, openInfoTipsWithKeyboard, openTipTabToLinkAndWaitForFocus, pressKey, @@ -22,10 +22,12 @@ import { testShowTipOnClick, testTabbingBetweenLinks, } from './helpers'; +import { MultipleInfoTipsMock } from './mocks'; -const info = 'I am information'; +const infoText = 'I am information'; +const linkText = 'cool link'; const renderView = setupRtl(InfoTip, { - info, + info: infoText, }); describe('InfoTip', () => { @@ -34,45 +36,37 @@ describe('InfoTip', () => { { placement: 'floating' }, ])('$placement placement', ({ placement }) => { it('shows the tip when it is clicked on', async () => { - const { view } = renderView({ - placement, - }); - await testShowTipOnClick({ view, info, placement }); + const { view } = renderView({ placement }); + await testShowTipOnClick({ view, info: infoText, placement }); }); it('closes the tip when Escape key is pressed and returns focus to button', async () => { - const { view } = renderView({ - placement, - }); - await testEscapeKeyReturnsFocus({ view, info, placement }); + const { view } = renderView({ placement }); + await testEscapeKeyReturnsFocus({ view, info: infoText, placement }); }); it('closes the tip when Escape is pressed even when focus is on an outside element', async () => { - const { view } = renderView({ - placement, - }); - await testEscapeKeyWithOutsideFocus({ view, info }); + const { view } = renderView({ placement }); + await testEscapeKeyWithOutsideFocus({ view, info: infoText }); }); it('does not close the tip when Escape is pressed if a modal is open', async () => { - const { view } = renderView({ - placement, - }); - await testModalDoesNotCloseInfoTip({ view, info, placement }); + const { view } = renderView({ placement }); + await testModalDoesNotCloseInfoTip({ view, info: infoText, placement }); }); it('closes the tip when Escape is pressed if the InfoTip is inside a modal', async () => { - await testInfoTipInsideModalClosesOnEscape({ info, placement }); + await testInfoTipInsideModalClosesOnEscape({ + info: infoText, + placement, + }); }); it('calls onClick with isTipHidden: false when tip opens', async () => { const onClick = jest.fn(); - const { view } = renderView({ - placement, - onClick, - }); - + const { view } = renderView({ placement, onClick }); const button = view.getByLabelText('Show information'); + await act(async () => { await userEvent.click(button); }); @@ -84,11 +78,7 @@ describe('InfoTip', () => { it('calls onClick with isTipHidden: true when tip closes', async () => { const onClick = jest.fn(); - const { view } = renderView({ - placement, - onClick, - }); - + const { view } = renderView({ placement, onClick }); const button = view.getByLabelText('Show information'); await act(async () => { @@ -112,10 +102,7 @@ describe('InfoTip', () => { it('does not call onClick on initial mount', async () => { const onClick = jest.fn(); - renderView({ - placement, - onClick, - }); + renderView({ placement, onClick }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -125,18 +112,13 @@ describe('InfoTip', () => { }); it('closes the tip when clicking outside the wrapper', async () => { - const { view } = renderView({ - placement, - }); - await testOutsideClick({ view, info, placement }); + const { view } = renderView({ placement }); + await testOutsideClick({ view, info: infoText, placement }); }); it('handles rapid open/close cycles correctly', async () => { const onClick = jest.fn(); - const { view } = renderView({ - placement, - onClick, - }); + const { view } = renderView({ placement, onClick }); await testRapidToggle({ view, onClick }); }); @@ -159,7 +141,6 @@ describe('InfoTip', () => { }); it('allows focus to move to links within the tip', async () => { - const linkText = 'cool link'; const { info, onClick } = createLinkSetup({ linkText }); const { view } = renderView({ placement, info, onClick }); @@ -167,7 +148,6 @@ describe('InfoTip', () => { }); it('closes the tip when Escape is pressed even when focus is on a link inside', async () => { - const linkText = 'cool link'; const { info, onClick } = createLinkSetup({ linkText }); const { view } = renderView({ placement, info, onClick }); @@ -182,26 +162,23 @@ describe('InfoTip', () => { }); describe('floating placement focus management', () => { - it('wraps focus to button when tabbing forward from last focusable element', async () => { - const linkText = 'cool link'; - const { view, containerRef } = setupLinkTestWithPlacement( - linkText, - 'floating', - renderView - ); - - await testFocusWrap({ view, containerRef, direction: 'forward' }); - }); - - it('wraps focus to button when shift+tabbing backward from first focusable element', async () => { - const linkText = 'cool link'; - const { view, containerRef } = setupLinkTestWithPlacement( - linkText, - 'floating', - renderView - ); - - await testFocusWrap({ view, containerRef, direction: 'backward' }); + describe.each<{ direction: 'forward' | 'backward' }>([ + { direction: 'forward' }, + { direction: 'backward' }, + ])('$direction tabbing', ({ direction }) => { + it(`wraps focus to button when ${ + direction === 'forward' + ? 'tabbing forward from last' + : 'shift+tabbing backward from first' + } focusable element`, async () => { + const { view, containerRef } = setupLinkTestWithPlacement( + linkText, + 'floating', + renderView + ); + + await testFocusWrap({ view, containerRef, direction }); + }); }); it('does not wrap focus when tabbing from non-last focusable element', async () => { @@ -248,80 +225,68 @@ describe('InfoTip', () => { describe('Multiple InfoTips', () => { it('closes all InfoTips when Escape is pressed', async () => { - const view = render( - - - - - - ); + const tips = [ + { info: 'InfoTip A' }, + { info: 'InfoTip B' }, + { info: 'InfoTip C' }, + ]; + const { view } = setupRtl(MultipleInfoTipsMock, { tips })(); - await openInfoTipsWithKeyboard({ view, count: 3 }); + await openInfoTipsWithKeyboard({ view, count: tips.length }); await waitFor(() => { - expectTipToBeVisible({ text: 'InfoTip A' }); - expectTipToBeVisible({ text: 'InfoTip B' }); - expectTipToBeVisible({ text: 'InfoTip C' }); + expectTipsVisible(tips.map(({ info }) => ({ text: info }))); }); await pressKey('{Escape}'); await waitFor(() => { - expectTipToBeClosed({ text: 'InfoTip A' }); - expectTipToBeClosed({ text: 'InfoTip B' }); - expectTipToBeClosed({ text: 'InfoTip C' }); + expectTipsClosed(tips.map(({ info }) => ({ text: info }))); }); }); it('closes all InfoTips when clicking outside', async () => { - const view = render( - -
    - - -
    Outside
    -
    -
    - ); + const tips = [{ info: 'InfoTip A' }, { info: 'InfoTip B' }]; + const { view } = setupRtl(MultipleInfoTipsMock, { + tips, + includeOutsideElement: true, + })(); - await openInfoTipsWithKeyboard({ view, count: 2 }); + await openInfoTipsWithKeyboard({ view, count: tips.length }); await waitFor(() => { - expectTipToBeVisible({ text: 'InfoTip A' }); - expectTipToBeVisible({ text: 'InfoTip B' }); + expectTipsVisible(tips.map(({ info }) => ({ text: info }))); }); await userEvent.click(view.getByTestId('outside')); await waitFor(() => { - expectTipToBeClosed({ text: 'InfoTip A' }); - expectTipToBeClosed({ text: 'InfoTip B' }); + expectTipsClosed(tips.map(({ info }) => ({ text: info }))); }); }); it('closes multiple InfoTips with different placements', async () => { - const view = render( - - - - - - ); + const tips = [ + { info: 'First Tip', placement: 'inline' as const }, + { info: 'Second Tip', placement: 'floating' as const }, + { info: 'Third Tip', placement: 'inline' as const }, + ]; + const { view } = setupRtl(MultipleInfoTipsMock, { tips })(); - await openInfoTipsWithKeyboard({ view, count: 3 }); + await openInfoTipsWithKeyboard({ view, count: tips.length }); await waitFor(() => { - expectTipToBeVisible({ text: 'First Tip' }); - expectTipToBeVisible({ text: 'Second Tip' }); - expectTipToBeVisible({ text: 'Third Tip' }); + expectTipsVisible( + tips.map(({ info, placement }) => ({ text: info, placement })) + ); }); await pressKey('{Escape}'); await waitFor(() => { - expectTipToBeClosed({ text: 'First Tip' }); - expectTipToBeClosed({ text: 'Second Tip' }); - expectTipToBeClosed({ text: 'Third Tip' }); + expectTipsClosed( + tips.map(({ info, placement }) => ({ text: info, placement })) + ); }); }); }); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 74d48b40c3..3e4ff9bc6f 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -502,7 +502,7 @@ export const getVisibleTip = ({ } if (placement === 'floating') { - const popover = el.closest('[role="dialog"]'); + const popover = el.closest('[data-testid="popover-content-container"]'); if (popover) { return el; } @@ -552,3 +552,19 @@ export const openInfoTipsWithKeyboard = async ({ await userEvent.keyboard('{Enter}'); } }; + +export const expectTipsVisible = ( + tips: { text: string; placement?: 'inline' | 'floating' }[] +) => { + tips.forEach((tip) => { + expectTipToBeVisible({ text: tip.text, placement: tip.placement }); + }); +}; + +export const expectTipsClosed = ( + tips: { text: string; placement?: 'inline' | 'floating' }[] +) => { + tips.forEach((tip) => { + expectTipToBeClosed({ text: tip.text, placement: tip.placement }); + }); +}; diff --git a/packages/gamut/src/Tip/__tests__/mocks.tsx b/packages/gamut/src/Tip/__tests__/mocks.tsx index e76d95ddca..f57737ac49 100644 --- a/packages/gamut/src/Tip/__tests__/mocks.tsx +++ b/packages/gamut/src/Tip/__tests__/mocks.tsx @@ -35,3 +35,17 @@ export const InfoTipInsideModalMock: React.FC< ); }; + +export const MultipleInfoTipsMock: React.FC<{ + tips: Array>; + includeOutsideElement?: boolean; +}> = ({ tips, includeOutsideElement }) => { + return ( + <> + {tips.map((tip, index) => ( + + ))} + {includeOutsideElement &&
    Outside
    } + + ); +}; From e0683f54479844535f62b89767a7ef86e624dfa1 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 2 Dec 2025 11:34:23 -0500 Subject: [PATCH 47/59] fix tests --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 17 ++++++++++------- packages/gamut/src/Tip/__tests__/helpers.tsx | 8 ++++---- packages/gamut/src/Tip/__tests__/mocks.tsx | 6 +++--- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index bf6ab2cec9..0a9ba89bfc 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -226,9 +226,9 @@ describe('InfoTip', () => { describe('Multiple InfoTips', () => { it('closes all InfoTips when Escape is pressed', async () => { const tips = [ - { info: 'InfoTip A' }, - { info: 'InfoTip B' }, - { info: 'InfoTip C' }, + { id: 'tip-a', info: 'InfoTip A' }, + { id: 'tip-b', info: 'InfoTip B' }, + { id: 'tip-c', info: 'InfoTip C' }, ]; const { view } = setupRtl(MultipleInfoTipsMock, { tips })(); @@ -246,7 +246,10 @@ describe('InfoTip', () => { }); it('closes all InfoTips when clicking outside', async () => { - const tips = [{ info: 'InfoTip A' }, { info: 'InfoTip B' }]; + const tips = [ + { id: 'tip-a', info: 'InfoTip A' }, + { id: 'tip-b', info: 'InfoTip B' }, + ]; const { view } = setupRtl(MultipleInfoTipsMock, { tips, includeOutsideElement: true, @@ -267,9 +270,9 @@ describe('InfoTip', () => { it('closes multiple InfoTips with different placements', async () => { const tips = [ - { info: 'First Tip', placement: 'inline' as const }, - { info: 'Second Tip', placement: 'floating' as const }, - { info: 'Third Tip', placement: 'inline' as const }, + { id: 'tip-1', info: 'First Tip', placement: 'inline' as const }, + { id: 'tip-2', info: 'Second Tip', placement: 'floating' as const }, + { id: 'tip-3', info: 'Third Tip', placement: 'inline' as const }, ]; const { view } = setupRtl(MultipleInfoTipsMock, { tips })(); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 3e4ff9bc6f..4eaa8ef41a 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -556,15 +556,15 @@ export const openInfoTipsWithKeyboard = async ({ export const expectTipsVisible = ( tips: { text: string; placement?: 'inline' | 'floating' }[] ) => { - tips.forEach((tip) => { - expectTipToBeVisible({ text: tip.text, placement: tip.placement }); + tips.forEach(({ text, placement }) => { + expectTipToBeVisible({ text, placement }); }); }; export const expectTipsClosed = ( tips: { text: string; placement?: 'inline' | 'floating' }[] ) => { - tips.forEach((tip) => { - expectTipToBeClosed({ text: tip.text, placement: tip.placement }); + tips.forEach(({ text, placement }) => { + expectTipToBeClosed({ text, placement }); }); }; diff --git a/packages/gamut/src/Tip/__tests__/mocks.tsx b/packages/gamut/src/Tip/__tests__/mocks.tsx index f57737ac49..af62b1ced5 100644 --- a/packages/gamut/src/Tip/__tests__/mocks.tsx +++ b/packages/gamut/src/Tip/__tests__/mocks.tsx @@ -37,13 +37,13 @@ export const InfoTipInsideModalMock: React.FC< }; export const MultipleInfoTipsMock: React.FC<{ - tips: Array>; + tips: (Pick & { id: string })[]; includeOutsideElement?: boolean; }> = ({ tips, includeOutsideElement }) => { return ( <> - {tips.map((tip, index) => ( - + {tips.map(({ id, info, placement }) => ( + ))} {includeOutsideElement &&
    Outside
    } From 08e244e1d4e54bcfd3508e1ca33cd4c5ad8f90d4 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 2 Dec 2025 13:51:01 -0500 Subject: [PATCH 48/59] remove field label reference --- .../lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 9379091488..87e4bbc87a 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -191,7 +191,7 @@ export const KeyboardNavigation: Story = { export const InfoTipInsideModal: Story = { args: { placement: 'inline', - info: 'This is helpful information about the field. Try pressing Escape!', + info: 'This is helpful information about the Modal. Try pressing Escape!', }, render: function InfoTipInsideModal(args) { const [isModalOpen, setIsModalOpen] = useState(false); @@ -210,13 +210,7 @@ export const InfoTipInsideModal: Story = {
  • Click "Open Modal with InfoTip Inside"
  • Click or press Enter on the InfoTip button (ⓘ icon)
  • Press Escape - should close InfoTip (Modal stays open)
  • -
  • Press Escape again - should close Modal
  • -
  • - - Inline InfoTips work correctly inside Modals without z-index - issues. - -
  • +
  • Press Escape again - should close Modal
  • {' '} @@ -230,7 +224,7 @@ export const InfoTipInsideModal: Story = { This modal contains an InfoTip below: - Field Label + Some text that needs explanation From 0b11c4e43fe6a4b7e24831f1a3227ac14a7f96f3 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 2 Dec 2025 15:45:08 -0500 Subject: [PATCH 49/59] refactor stories for best practices --- .../lib/Molecules/Tips/InfoTip/InfoTip.mdx | 4 +- .../Tips/InfoTip/InfoTip.stories.tsx | 74 ++++++++++++++----- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 16400871ef..6a41e0ee4c 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -92,7 +92,9 @@ The InfoTip button's accessible label can be customized using either prop: - **`ariaLabel`**: Directly sets the accessible label text. Useful when you want to provide a custom label without referencing another element. - **`ariaLabelledby`**: References the ID of another element to use as the label. Useful when you want the InfoTip button to be labeled by visible text elsewhere on the page. This is useful for when the `InfoTip` is beside text that contextualizes it. -The `InfoTipButton` also has an `aria-roledescription` of `More information button` to provide essential context to screen reader users about the button's purpose. +### Custom Role Description + +The `InfoTipButton` uses [`aria-roledescription="More information button"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-roledescription) to provide additional context to screen reader users about the button's specific purpose. diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 81541dbf87..70a5977778 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -17,6 +17,7 @@ const meta: Meta = { component: InfoTip, args: { alignment: 'top-left', + ariaLabel: 'More information', info: `I am additional information about a nearby element or content.`, }, }; @@ -30,7 +31,10 @@ export const Emphasis: Story = { }, render: (args) => ( - Some text that needs info + + Some text that needs info + + ), }; @@ -40,10 +44,15 @@ export const Alignments: Story = { {(['top-right', 'top-left', 'bottom-right', 'bottom-left'] as const).map( (alignment) => { + const labelId = `alignment-${alignment}`; return ( - {alignment} - + {alignment} + ); } @@ -58,18 +67,23 @@ export const Placement: Story = { }, render: (args) => ( - + This text is in a small space and needs floating placement {' '} - + ), }; export const AriaLabel: Story = { render: (args) => ( - - + + + + Using ariaLabel (no visible label text): + + + - - - - I am some helpful yet concise text that needs more explanation + + + + + Using ariaLabelledby (references visible text): + + + + + I am some helpful yet concise text that needs more explanation + - +
    ), }; @@ -103,9 +124,12 @@ export const WithLinksOrButtons: Story = { render: function WithLinksOrButtons(args) { return ( - This text is in a small space and needs info {' '} + + This text is in a small space and needs info + {' '} Hey! Here is a{' '} @@ -145,13 +169,15 @@ export const KeyboardNavigation: Story = { {examples.map(({ title, placement, alignment, links }) => { + const labelId = `keyboard-nav-${placement}`; return ( - + {title} {links.map((label, idx) => ( @@ -243,8 +269,10 @@ export const InfoTipInsideModal: Story = { This modal contains an InfoTip below: - Some text that needs explanation - + + Some text that needs explanation + + @@ -272,11 +300,14 @@ export const ZIndex: Story = { I will not be behind the infotip, sad + unreadable - + I will be behind the infotip, nice + great - + ), }; @@ -284,7 +315,10 @@ export const ZIndex: Story = { export const Default: Story = { render: (args) => ( - Some text that needs info + + Some text that needs info + + ), }; From 6af94df96d451c694f1d4dce8daea399cb237094 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 2 Dec 2025 15:56:23 -0500 Subject: [PATCH 50/59] formatted --- packages/gamut/src/Tip/__tests__/mocks.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/Tip/__tests__/mocks.tsx b/packages/gamut/src/Tip/__tests__/mocks.tsx index 117e4d85d5..9ccc2b2811 100644 --- a/packages/gamut/src/Tip/__tests__/mocks.tsx +++ b/packages/gamut/src/Tip/__tests__/mocks.tsx @@ -30,7 +30,11 @@ export const InfoTipInsideModalMock: React.FC< title="Test Modal" onRequestClose={() => setIsOpen(false)} > - + ); From 4647bcb3c945e42cc9afbc4e869a8cd6252927be Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 5 Dec 2025 11:39:57 -0500 Subject: [PATCH 51/59] put selector back --- packages/gamut/src/Tip/InfoTip/index.tsx | 6 ++- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 50 +------------------ packages/gamut/src/Tip/__tests__/helpers.tsx | 50 ++++++------------- 3 files changed, 20 insertions(+), 86 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index c1425af629..1f70051464 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -35,7 +35,8 @@ export type InfoTipProps = TipBaseProps & { onClick?: (arg0: { isTipHidden: boolean }) => void; }; -const MODAL_SELECTOR = 'dialog[open],[role="dialog"],[role="alertdialog"]'; +const MODAL_SELECTOR = + 'dialog[open],[role="dialog"]:not([aria-hidden="true"]),[role="alertdialog"]:not([aria-hidden="true"])'; export const InfoTip: React.FC = ({ alignment = 'top-right', @@ -181,7 +182,8 @@ export const InfoTip: React.FC = ({ }; } - return () => document.removeEventListener('keydown', handleGlobalEscapeKey); + return () => + document.removeEventListener('keydown', handleGlobalEscapeKey, true); }, [isTipHidden, isFloating, getFocusableElements, setTipIsHidden]); useEffect(() => { diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index c4520f43a2..e38f0a1489 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -37,7 +37,6 @@ describe('InfoTip', () => { { placement: 'floating' }, ])('$placement placement', ({ placement }) => { it('shows the tip when it is clicked on', async () => { - const user = userEvent.setup(); const { view } = renderView({ placement }); const isInline = placement === 'inline'; @@ -49,7 +48,7 @@ describe('InfoTip', () => { expect(view.queryByText(info)).toBeNull(); } - await user.click(view.getByRole('button')); + await userEvent.click(view.getByRole('button')); if (isInline) { const tip = view.getByText(info); @@ -190,31 +189,6 @@ describe('InfoTip', () => { describe('floating placement focus management', () => { const linkText = 'cool link'; - it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => { - const { info, onClick } = createLinkSetup({ - linkText, - href: 'https://giphy.com/search/nichijou', - }); - const { view } = renderView({ - placement: 'floating', - info, - onClick, - }); - - const button = await clickButton(view); - - await waitFor(() => { - expect(view.getByText(linkText)).toBeVisible(); - }); - - await pressKey('{Escape}'); - - await waitFor(() => { - expect(view.queryByText(linkText)).toBeNull(); - expect(button).toHaveFocus(); - }); - }); - describe.each<{ direction: 'forward' | 'backward' }>([ { direction: 'forward' }, { direction: 'backward' }, @@ -234,24 +208,6 @@ describe('InfoTip', () => { }); }); - it('allows normal tabbing between focusable elements within popover', async () => { - const firstLinkText = 'first link'; - const secondLinkText = 'second link'; - const { view } = setupMultiLinkTestWithPlacement( - firstLinkText, - secondLinkText, - 'floating', - renderView - ); - - await testTabbingBetweenLinks({ - view, - firstLinkText, - secondLinkText, - placement: 'floating', - }); - }); - it('wraps focus to button when tabbing from popover with no interactive elements', async () => { const { view } = renderView({ placement: 'floating' }); await testTabFromPopoverWithNoInteractiveElements(view); @@ -336,9 +292,7 @@ describe('InfoTip', () => { await openInfoTipsWithKeyboard({ view, count: tips.length }); await waitFor(() => { - expectTipsVisible( - tips.map(({ info, placement }) => ({ text: info, placement })) - ); + expectTipsVisible(tips.map(({ info }) => ({ text: info }))); }); await pressKey('{Escape}'); diff --git a/packages/gamut/src/Tip/__tests__/helpers.tsx b/packages/gamut/src/Tip/__tests__/helpers.tsx index 6317511bd5..9807f6b590 100644 --- a/packages/gamut/src/Tip/__tests__/helpers.tsx +++ b/packages/gamut/src/Tip/__tests__/helpers.tsx @@ -5,19 +5,17 @@ import { createRef, RefObject } from 'react'; import { Anchor } from '../../Anchor'; import { Text } from '../../Typography'; -import { InfoTip, InfoTipProps } from '../InfoTip'; +import { InfoTip } from '../InfoTip'; import { TipPlacements } from '../shared/types'; type InfoTipView = ReturnType< ReturnType> >['view']; -type Placement = NonNullable; - type ViewParam = { view: InfoTipView }; type LinkTextParam = { linkText: string }; type InfoParam = { info: string }; -type PlacementParam = { placement: Placement }; +type PlacementParam = { placement: TipPlacements }; export const createFocusOnClick = (ref: RefObject) => { return ({ isTipHidden }: { isTipHidden: boolean }) => { @@ -66,16 +64,13 @@ export const createMultiLinkSetup = ({ }; export const clickButton = async (view: InfoTipView) => { - const user = userEvent.setup(); const button = view.getByRole('button'); - await user.click(button); + await userEvent.click(button); return button; }; export const pressKey = async (key: string) => { - await act(async () => { - await userEvent.keyboard(key); - }); + await userEvent.keyboard(key); }; export const waitForLinkToHaveFocus = async ({ @@ -105,9 +100,7 @@ export const openTipTabToLinkAndWaitForFocus = async ( linkText: string ) => { const link = await openTipAndWaitForLink({ view, linkText }); - await act(async () => { - await userEvent.tab(); - }); + await userEvent.tab(); await waitFor(() => { expect(link).toHaveFocus(); }); @@ -197,9 +190,9 @@ export const setupLinkTestWithPlacement = ( placement: TipPlacements, renderView: ReturnType> ) => { - const { containerRef, info, onClick } = createLinkSetup({ linkText }); + const { info, onClick } = createLinkSetup({ linkText }); const { view } = renderView({ placement, info, onClick }); - return { view, containerRef, info, onClick }; + return { view, info, onClick }; }; export const setupMultiLinkTestWithPlacement = ( @@ -225,9 +218,7 @@ export const testEscapeKeyWithOutsideFocus = async ({ await withTemporaryElement(outsideButton, document.body, async () => { const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + await userEvent.click(button); await waitFor(() => { expect(button).toHaveAttribute('aria-expanded', 'true'); @@ -345,19 +336,13 @@ export const testRapidToggle = async ({ }: ViewParam & { onClick: jest.Mock }) => { const button = view.getByLabelText('Show information'); - await act(async () => { - await userEvent.click(button); - }); + await userEvent.click(button); await waitFor(() => expect(onClick).toHaveBeenCalledTimes(1)); - await act(async () => { - await userEvent.click(button); - }); + await userEvent.click(button); await waitFor(() => expect(onClick).toHaveBeenCalledTimes(2)); - await act(async () => { - await userEvent.click(button); - }); + await userEvent.click(button); await waitFor(() => expect(onClick).toHaveBeenCalledTimes(3)); expect(onClick).toHaveBeenCalledTimes(3); @@ -375,19 +360,14 @@ export const testInfoTipInsideModalClosesOnEscape = async ({ const { view } = renderView(); const openModalButton = view.getByRole('button', { name: 'Open Modal' }); - await act(async () => { - await userEvent.click(openModalButton); - }); + await userEvent.click(openModalButton); await waitFor(() => { expect(view.getByRole('dialog')).toBeInTheDocument(); }); const infoTipButton = view.getByLabelText('Show information'); - - await act(async () => { - await userEvent.click(infoTipButton); - }); + await userEvent.click(infoTipButton); await waitFor(() => { expect(view.getByText(info)).toBeVisible(); @@ -436,9 +416,7 @@ export const openInfoTipsWithKeyboard = async ({ }); }; -export const expectTipsVisible = ( - tips: { text: string; placement?: 'inline' | 'floating' }[] -) => { +export const expectTipsVisible = (tips: { text: string }[]) => { tips.forEach(({ text }) => { const element = screen.queryByText(text); expect(element).toBeInTheDocument(); From 5e8ee276f8899f971eef451334153606fadc6ef5 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 5 Dec 2025 15:48:07 -0500 Subject: [PATCH 52/59] start infotip form accessibility --- .../src/ConnectedForm/ConnectedFormGroup.tsx | 5 ++ .../__tests__/ConnectedForm.test.tsx | 87 ++++++++++++++++++- .../src/Form/__tests__/FormGroup.test.tsx | 43 +++++++++ .../src/Form/elements/FormGroupLabel.tsx | 17 +++- .../__fixtures__/renderers.tsx | 4 +- .../__tests__/GridFormInputGroup.test.tsx | 65 ++++++++++++++ packages/gamut/src/GridForm/types.ts | 5 ++ packages/gamut/src/Tip/InfoTip/index.tsx | 5 ++ .../Tips/InfoTip/InfoTip.stories.tsx | 2 +- 9 files changed, 228 insertions(+), 5 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx index 90944d44ae..8344b977cb 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx @@ -33,6 +33,11 @@ export interface ConnectedFormGroupBaseProps name: string; label: React.ReactNode; required?: boolean; + /** + * InfoTip to display next to the field label. String labels automatically + * label the InfoTip button. For ReactNode labels, provide `ariaLabel` or + * set `labelledByFieldLabel: true` to ensure the InfoTip is accessible. + */ infotip?: InfoTipProps; } diff --git a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx index b5f5918126..0d1ed29f80 100644 --- a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx +++ b/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx @@ -2,8 +2,9 @@ import { setupRtl } from '@codecademy/gamut-tests'; import { fireEvent, queries } from '@testing-library/dom'; import { act, RenderResult, waitFor } from '@testing-library/react'; +import { InfoTipProps } from '../../Tip/InfoTip'; import { createPromise } from '../../utils'; -import { ConnectedForm } from '..'; +import { ConnectedForm, ConnectedFormGroup, ConnectedInput } from '..'; import { PlainConnectedFields } from '../__fixtures__/helpers'; const renderView = setupRtl(ConnectedForm, { @@ -359,3 +360,87 @@ describe('ConnectedForm', () => { }); }); }); + +describe('ConnectedFormGroup infotip accessibility', () => { + const label = 'Infotip Label'; + const info = 'helpful information'; + const ariaLabel = 'Custom label'; + + const renderConnectedFormGroupView = setupRtl(ConnectedForm, { + defaultValues: { input: '' }, + onSubmit: jest.fn(), + children: null, + }); + + const renderWithInfotip = ({ + infotip, + fieldLabel = label, + }: { + infotip: InfoTipProps; + fieldLabel?: React.ReactNode; + }) => + renderConnectedFormGroupView({ + children: ( + + ), + }); + + it('automatically labels InfoTip button by the field label when label is a string', () => { + const { view } = renderWithInfotip({ infotip: { info } }); + + view.getByRole('button', { name: new RegExp(label) }); + }); + + it('uses explicit ariaLabel when provided', () => { + const { view } = renderWithInfotip({ + infotip: { info, ariaLabel }, + }); + + view.getByRole('button', { name: ariaLabel }); + }); + + it('uses explicit ariaLabelledby when provided', () => { + const externalLabelId = 'external-label-id'; + const externalLabelText = 'External Label'; + + const { view } = renderConnectedFormGroupView({ + children: ( + <> + {externalLabelText} + + + ), + }); + + view.getByRole('button', { name: externalLabelText }); + }); + + it('does not automatically label InfoTip when label is a ReactNode', () => { + const { view } = renderWithInfotip({ + infotip: { info, ariaLabel }, + fieldLabel: {label}, + }); + + view.getByRole('button', { name: ariaLabel }); + expect(view.queryByRole('button', { name: new RegExp(label) })).toBeNull(); + }); + + it('labels InfoTip by field label when labelledByFieldLabel is true with ReactNode label', () => { + const { view } = renderWithInfotip({ + infotip: { info, labelledByFieldLabel: true }, + fieldLabel: {label}, + }); + + view.getByRole('button', { name: new RegExp(label) }); + }); +}); diff --git a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx b/packages/gamut/src/Form/__tests__/FormGroup.test.tsx index 0fc10265cc..345df29b1a 100644 --- a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx +++ b/packages/gamut/src/Form/__tests__/FormGroup.test.tsx @@ -70,4 +70,47 @@ describe('FormGroup', () => { 'there is no up dog here...' ); }); + + describe('infotip accessibility', () => { + const info = 'helpful information'; + const ariaLabel = 'Custom label'; + + it('automatically labels InfoTip button by the field label when label is a string', () => { + const { view } = renderView({ label, htmlFor, infotip: { info } }); + + view.getByRole('button', { name: new RegExp(label) }); + }); + + it('uses explicit ariaLabel when provided', () => { + const { view } = renderView({ + label, + htmlFor, + infotip: { info, ariaLabel }, + }); + + view.getByRole('button', { name: ariaLabel }); + }); + + it('uses explicit ariaLabelledby when provided', () => { + const externalLabelId = 'external-label-id'; + const externalLabelText = 'External Label'; + + const { view } = renderView({ + label, + htmlFor, + infotip: { info, ariaLabelledby: externalLabelId }, + children: ( + <> + {externalLabelText} + + + ), + }); + + view.getByRole('button', { name: externalLabelText }); + }); + + // Note: No "ReactNode label" test case here because FormGroup.label + // is typed as string - automatic ariaLabelledby always applies. + }); }); diff --git a/packages/gamut/src/Form/elements/FormGroupLabel.tsx b/packages/gamut/src/Form/elements/FormGroupLabel.tsx index 5ab9d96caa..f0e39fb2b0 100644 --- a/packages/gamut/src/Form/elements/FormGroupLabel.tsx +++ b/packages/gamut/src/Form/elements/FormGroupLabel.tsx @@ -1,7 +1,7 @@ import { states, variant } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; -import { HTMLAttributes } from 'react'; +import { HTMLAttributes, useId } from 'react'; import * as React from 'react'; import { FlexBox } from '../..'; @@ -62,6 +62,13 @@ export const FormGroupLabel: React.FC = ({ size, ...rest }) => { + const labelId = useId(); + const isStringLabel = typeof children === 'string'; + const shouldLabelInfoTip = + (isStringLabel || infotip?.labelledByFieldLabel) && + !infotip?.ariaLabel && + !infotip?.ariaLabelledby; + return ( - {infotip && } + {infotip && ( + + )} ); }; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx index ef1740d298..937208ad7c 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/__fixtures__/renderers.tsx @@ -83,13 +83,15 @@ export const getComponent = (componentName: string) => { type GridFormInputGroupTestComponentProps = GridFormInputGroupProps & { mode?: 'onSubmit' | 'onChange'; + externalLabel?: { id: string; text: string }; }; export const GridFormInputGroupTestComponent: React.FC< GridFormInputGroupTestComponentProps -> = ({ field, mode = 'onSubmit', ...rest }) => { +> = ({ field, mode = 'onSubmit', externalLabel, ...rest }) => { return ( + {externalLabel && {externalLabel.text}} ); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx index 7c2a3e578a..f6d44c7ecc 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/__tests__/GridFormInputGroup.test.tsx @@ -238,4 +238,69 @@ describe('GridFormInputGroup', () => { }); expect(view.container).not.toContainHTML('Column'); }); + + describe('infotip accessibility', () => { + const info = 'helpful information'; + const ariaLabel = 'Custom label'; + const textLabel = 'Stub Text'; + const checkboxLabel = 'Check me!'; + + it('automatically labels InfoTip button by the field label when label is a string', () => { + const { view } = renderView({ + field: { ...stubTextField, infotip: { info } }, + }); + + view.getByRole('button', { name: new RegExp(textLabel) }); + }); + + it('uses explicit ariaLabel when provided', () => { + const { view } = renderView({ + field: { ...stubTextField, infotip: { info, ariaLabel } }, + }); + + view.getByRole('button', { name: ariaLabel }); + }); + + it('uses explicit ariaLabelledby when provided', () => { + const externalLabelId = 'external-label-id'; + const externalLabelText = 'External Label'; + + const { view } = renderView({ + field: { + ...stubTextField, + infotip: { info, ariaLabelledby: externalLabelId }, + }, + externalLabel: { id: externalLabelId, text: externalLabelText }, + }); + + view.getByRole('button', { name: externalLabelText }); + }); + + it('does not automatically label InfoTip when label is a ReactNode', () => { + const { view } = renderView({ + field: { + ...stubCheckboxField, + label: {checkboxLabel}, + infotip: { info, ariaLabel }, + }, + }); + + view.getByRole('button', { name: ariaLabel }); + expect( + view.queryByRole('button', { name: new RegExp(checkboxLabel) }) + ).toBeNull(); + }); + + it('labels InfoTip by field label when labelledByFieldLabel is true with ReactNode label', () => { + const { view } = renderView({ + field: { + ...stubCheckboxField, + label: {checkboxLabel}, + infotip: { info, labelledByFieldLabel: true }, + }, + }); + + view.getByRole('button', { name: new RegExp(checkboxLabel) }); + }); + }); }); diff --git a/packages/gamut/src/GridForm/types.ts b/packages/gamut/src/GridForm/types.ts index 4ac4a39b49..1852e0d5c5 100644 --- a/packages/gamut/src/GridForm/types.ts +++ b/packages/gamut/src/GridForm/types.ts @@ -31,6 +31,11 @@ export type BaseFormField = { */ id?: string; + /** + * InfoTip to display next to the field label. String labels automatically + * label the InfoTip button. For ReactNode labels, provide `ariaLabel` or + * set `labelledByFieldLabel: true` to ensure the InfoTip is accessible. + */ infotip?: InfoTipProps; isSoloField?: boolean; diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 1f70051464..49fe6927b5 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -29,6 +29,11 @@ export type InfoTipProps = TipBaseProps & { */ ariaLabelledby?: string; emphasis?: 'low' | 'high'; + /** + * When true, the InfoTip button will be labelled by the form field's label element. + * This is automatic for string labels, but can be opted into for ReactNode labels. + */ + labelledByFieldLabel?: boolean; /** * Called when the info tip is clicked - the onClick function is called after the DOM updates and the tip is mounted. */ diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 83d510689b..8ed6226c59 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -131,7 +131,7 @@ export const WithLinksOrButtons: Story = { {...args} ariaLabelledby="links-text" info={ - + Hey! Here is a{' '} cool link From cadb7f422d41c9f03f67767b84e967713e5e164d Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 5 Dec 2025 16:17:31 -0500 Subject: [PATCH 53/59] add docs to form infotips --- .../FormElements/FormGroup/FormGroup.mdx | 6 ++ .../FormGroup/FormGroup.stories.tsx | 11 +++ .../ConnectedFormGroup/ConnectedFormGroup.mdx | 20 +++++ .../ConnectedFormGroup.stories.tsx | 80 +++++++++++++++++++ .../src/lib/Organisms/GridForm/Fields.mdx | 20 +++++ .../lib/Organisms/GridForm/Fields.stories.tsx | 73 ++++++++++++++++- 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx b/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx index bafb0f40f6..6690273240 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx +++ b/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx @@ -69,6 +69,12 @@ A field can include our existing `InfoTip`< +#### Accessibility + +InfoTip buttons are automatically labelled by string field labels for accessibility. + + + ## Playground diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx b/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx index 09faa218a1..c8ce0e0781 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx +++ b/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx @@ -97,3 +97,14 @@ export const HighEmphasisInfoTip: Story = { children: , }, }; + +export const InfoTipAutoLabelling: Story = { + args: { + label: 'Email address', + htmlFor: 'auto-label-input', + infotip: { + info: 'We will never share your email with third parties.', + }, + children: , + }, +}; diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.mdx index f150a0955e..5214d35cbf 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.mdx @@ -61,6 +61,26 @@ A `ConnectedFormGroup` can be in one of three states: `default`, `error`, or `di +## InfoTip + +A `ConnectedFormGroup` can include an `infotip` prop to provide additional context. + +### Automatic labelling + +InfoTip buttons are automatically labelled by string field labels for accessibility. + + + +### ReactNode labels + +For ReactNode labels (e.g., styled text or icons), you have three options: + +- `labelledByFieldLabel: true` - opt into automatic labelling by the field label +- `ariaLabel` - provide a custom accessible name +- `ariaLabelledby` - reference another element on the page, such as a section heading + + + ## Playground To see how a `ConnectedFormGroup` can be used in a `ConnectedForm`, check out the ConnectedForm page. diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.stories.tsx index 716e2ecf22..4b669686f0 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormGroup/ConnectedFormGroup.stories.tsx @@ -2,7 +2,9 @@ import { ConnectedForm, ConnectedFormGroup, ConnectedFormGroupProps, + ConnectedInput, ConnectedRadioGroupInput, + Text, useConnectedForm, } from '@codecademy/gamut'; import { action } from '@storybook/addon-actions'; @@ -119,3 +121,81 @@ export const States = () => { ); }; + +/** + * InfoTip buttons are automatically labelled by string field labels for accessibility. + * Screen readers will announce "Field Label, button" when focusing the InfoTip. + */ +export const InfoTipAutoLabelling: Story = { + render: () => ( + action('Form Submitted')(values)} + > + + + ), +}; + +/** + * For ReactNode labels, you have three options for accessible InfoTip labelling: + * - `labelledByFieldLabel: true` - uses the field label + * - `ariaLabel` - provides a custom accessible name + * - `ariaLabelledby` - references another element on the page + */ +export const InfoTipWithReactNodeLabel: Story = { + render: () => ( + action('Form Submitted')(values)} + > + + API Configuration + + + Username (labelledByFieldLabel) + + } + name="username" + /> + + Password (ariaLabel) + + } + name="password" + /> + + API Key (ariaLabelledby) +
    + } + name="apiKey" + /> + + ), +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index 84f408b073..7385423a85 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -95,3 +95,23 @@ Hidden inputs can be used to include data that users can't see or modify with th We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. + +## InfoTip + +Any field can include an `infotip` prop to provide additional context to users. + +### Automatic labelling + +InfoTip buttons are automatically labelled by string field labels for accessibility. + + + +### ReactNode labels + +For ReactNode labels, you have three options: + +- `labelledByFieldLabel: true` - opt into automatic labelling by the field label +- `ariaLabel` - provide a custom accessible name +- `ariaLabelledby` - reference another element on the page, such as a section heading + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx index 40ecc134b0..36f483c066 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx @@ -1,4 +1,4 @@ -import { FormGroup, GridForm, Input } from '@codecademy/gamut'; +import { FormGroup, GridForm, Input, Text } from '@codecademy/gamut'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; @@ -328,3 +328,74 @@ export const SweetContainer: Story = { ], }, }; + +/** + * InfoTip buttons are automatically labelled by string field labels for accessibility. + * Screen readers will announce "Field Label, button" when focusing the InfoTip. + */ +export const InfoTipAutoLabelling: Story = { + args: { + fields: [ + { + label: 'Email address', + name: 'email', + size: 9, + type: 'email', + infotip: { + info: 'We will never share your email with third parties.', + }, + }, + ], + }, +}; + +/** + * For ReactNode labels, you have three options for accessible InfoTip labelling: + * - `labelledByFieldLabel: true` - uses the field label + * - `ariaLabel` - provides a custom accessible name + * - `ariaLabelledby` - references another element on the page + */ +export const InfoTipWithReactNodeLabel: Story = { + render: (args) => ( + <> + + API Configuration + + + + ), + args: { + fields: [ + { + label: Username (labelledByFieldLabel), + name: 'username', + size: 9, + type: 'text', + infotip: { + info: 'Choose a unique username between 3-20 characters.', + labelledByFieldLabel: true, + }, + }, + { + label: Password (ariaLabel), + name: 'password', + size: 9, + type: 'password', + infotip: { + info: 'Password must be at least 8 characters with one uppercase letter and one number.', + ariaLabel: 'Password requirements', + }, + }, + { + label: API Key (ariaLabelledby), + name: 'apiKey', + size: 9, + type: 'text', + infotip: { + info: 'You can find your API key in the developer settings dashboard.', + ariaLabelledby: 'api-section-heading', + }, + }, + ], + }, +}; From 08c545952778d3d6dee14f8c80e9f2877661de40 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 9 Dec 2025 12:36:18 -0500 Subject: [PATCH 54/59] needless comment --- packages/gamut/src/Form/__tests__/FormGroup.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx b/packages/gamut/src/Form/__tests__/FormGroup.test.tsx index 345df29b1a..d774b702ed 100644 --- a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx +++ b/packages/gamut/src/Form/__tests__/FormGroup.test.tsx @@ -109,8 +109,5 @@ describe('FormGroup', () => { view.getByRole('button', { name: externalLabelText }); }); - - // Note: No "ReactNode label" test case here because FormGroup.label - // is typed as string - automatic ariaLabelledby always applies. }); }); From f80df1d62fb6f0da326b3a844bd299fc88ebda7a Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 9 Dec 2025 15:25:00 -0500 Subject: [PATCH 55/59] test token --- .github/workflows/publish-alpha.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-alpha.yml index ab251d25d5..d4de5ac60a 100644 --- a/.github/workflows/publish-alpha.yml +++ b/.github/workflows/publish-alpha.yml @@ -48,6 +48,9 @@ jobs: - run: npx nx run-many --target=publish-build --parallel=3 + - name: Verify npm token + run: npm whoami + - name: Publish alpha packages run: | SHORT_SHA=${GITHUB_SHA:0:6} From d1626f1d0b175b9d5b7b6de378442e7a5e600ed4 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 9 Dec 2025 15:29:04 -0500 Subject: [PATCH 56/59] publish --- .github/workflows/publish-alpha.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-alpha.yml index d4de5ac60a..ab251d25d5 100644 --- a/.github/workflows/publish-alpha.yml +++ b/.github/workflows/publish-alpha.yml @@ -48,9 +48,6 @@ jobs: - run: npx nx run-many --target=publish-build --parallel=3 - - name: Verify npm token - run: npm whoami - - name: Publish alpha packages run: | SHORT_SHA=${GITHUB_SHA:0:6} From d8603103fa8316f6c88475b5e3f8165fc84b33ef Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 10 Dec 2025 15:35:10 -0500 Subject: [PATCH 57/59] More information microcopy --- packages/gamut/src/Tip/InfoTip/index.tsx | 8 +++++++- .../styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 49fe6927b5..da2ea43f0b 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -28,6 +28,11 @@ export type InfoTipProps = TipBaseProps & { * ID of an element that labels the InfoTip button. Its recommended to provide either `ariaLabel` or `ariaLabelledby`. */ ariaLabelledby?: string; + /** + * Accessible role description for the InfoTip button. Useful for translation. + * @default "More information button" + */ + ariaRoleDescription?: string; emphasis?: 'low' | 'high'; /** * When true, the InfoTip button will be labelled by the form field's label element. @@ -47,6 +52,7 @@ export const InfoTip: React.FC = ({ alignment = 'top-right', ariaLabel, ariaLabelledby, + ariaRoleDescription = 'More information button', emphasis = 'low', info, onClick, @@ -222,7 +228,7 @@ export const InfoTip: React.FC = ({ aria-expanded={!isTipHidden} aria-label={ariaLabel} aria-labelledby={ariaLabelledby} - aria-roledescription="More information button" + aria-roledescription={ariaRoleDescription} emphasis={emphasis} ref={buttonRef} onClick={clickHandler} diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx index 6a41e0ee4c..d8123beb1f 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.mdx @@ -94,7 +94,7 @@ The InfoTip button's accessible label can be customized using either prop: ### Custom Role Description -The `InfoTipButton` uses [`aria-roledescription="More information button"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-roledescription) to provide additional context to screen reader users about the button's specific purpose. +The `InfoTipButton` uses [`aria-roledescription`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-roledescription) to provide additional context to screen reader users about the button's specific purpose. This defaults to `"More information button"` but can be customized via the `ariaRoleDescription` prop for translation or other accessibility needs. From 8dd47a37d0b83da0897d0338c65eb4054343b2de Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 10 Dec 2025 15:50:51 -0500 Subject: [PATCH 58/59] add tests --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index e38f0a1489..1115e0cac6 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -236,6 +236,28 @@ describe('InfoTip', () => { }); }); + describe('ariaRoleDescription', () => { + it('applies default aria-roledescription', () => { + const { view } = renderView({}); + const button = view.getByRole('button'); + expect(button).toHaveAttribute( + 'aria-roledescription', + 'More information button' + ); + }); + + it('applies custom aria-roledescription when provided', () => { + const { view } = renderView({ + ariaRoleDescription: 'Botón de más información', + }); + const button = view.getByRole('button'); + expect(button).toHaveAttribute( + 'aria-roledescription', + 'Botón de más información' + ); + }); + }); + describe('Multiple InfoTips', () => { it('closes all InfoTips when Escape is pressed', async () => { const tips = [ From d22d5c2bcbafcfc64a7181c2a0811b55b35ef0c2 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 6 Jan 2026 13:45:08 -0500 Subject: [PATCH 59/59] amy commments --- jest.config.ts | 8 ++++---- .../src/ConnectedForm/__tests__/ConnectedForm.test.tsx | 2 +- .../src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx | 1 - .../ConnectedFormGroup/ConnectedFormGroup.stories.tsx | 6 +++++- .../src/lib/Organisms/GridForm/Fields.stories.tsx | 1 + packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx | 2 +- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index d0dbd1b889..6b3f2d6e24 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,5 @@ -import { getJestProjects } from '@nx/jest'; +import { getJestProjectsAsync } from '@nx/jest'; -export default { - projects: getJestProjects(), -}; +export default async () => ({ + projects: await getJestProjectsAsync(), +}); diff --git a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx b/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx index 0d1ed29f80..e96a3b7348 100644 --- a/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx +++ b/packages/gamut/src/ConnectedForm/__tests__/ConnectedForm.test.tsx @@ -393,7 +393,7 @@ describe('ConnectedFormGroup infotip accessibility', () => { it('automatically labels InfoTip button by the field label when label is a string', () => { const { view } = renderWithInfotip({ infotip: { info } }); - view.getByRole('button', { name: new RegExp(label) }); + view.getByRole('button', { name: `${label}\u00A0(optional)` }); }); it('uses explicit ariaLabel when provided', () => { diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index 8ed6226c59..5fa267cfd9 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -107,7 +107,6 @@ export const AriaLabel: Story = { I am some helpful yet concise text that needs more explanation
    @@ -160,6 +163,7 @@ export const InfoTipWithReactNodeLabel: Story = { InfoTip story for more information on what props are available. +A field can include our existing `InfoTip`. See the Fields story for specific accessibility tooling and InfoTip story for more information on what props are available. See the Radio story for an example of how to add a infotip to a radio option.