From 978a58582b4b7ce1855f0618979457af0cb4ce33 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 20 Oct 2025 14:54:03 -0400 Subject: [PATCH 1/6] first attempt --- packages/gamut/src/Tip/shared/FloatingTip.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index 48209166485..aa39dbeb60b 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -133,6 +133,23 @@ export const FloatingTip: React.FC = ({ info ); + const isPopoverOpen = isHoverType ? isOpen : !isTipHidden; + let focusTrapProps = {}; + // For info tips that are open, we need focus trapping to handle escape key and tab navigation + if (type === 'info' && isPopoverOpen) { + focusTrapProps = { + onRequestClose: (e: React.KeyboardEvent) => { + if (escapeKeyPressHandler) { + escapeKeyPressHandler(e); + } + }, + }; + } else { + focusTrapProps = { + skipFocusTrap: true, + }; + } + return ( = ({ Date: Wed, 22 Oct 2025 12:30:50 -0400 Subject: [PATCH 2/6] add global esc close --- packages/gamut/src/Tip/InfoTip/index.tsx | 46 +++++++++++++------ packages/gamut/src/Tip/shared/FloatingTip.tsx | 17 +------ .../Tips/InfoTip/InfoTip.stories.tsx | 2 + 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 2ebccccce7b..22a3c84e8f9 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; @@ -36,20 +36,23 @@ export const InfoTip: React.FC = ({ setLoaded(true); }, []); - const setTipIsHidden = (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(() => { - setIsAriaHidden(true); - }, 1000); + 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(() => { + setIsAriaHidden(true); + }, 1000); + } + } else { + if (isAriaHidden) setIsAriaHidden(false); + setHideTip(nextTipState); } - } else { - if (isAriaHidden) setIsAriaHidden(false); - setHideTip(nextTipState); - } - }; + }, + [isAriaHidden, placement] + ); const escapeKeyPressHandler = ( event: React.KeyboardEvent @@ -84,6 +87,21 @@ export const InfoTip: React.FC = ({ }; }); + useEffect(() => { + if (!isTipHidden && placement === 'floating') { + const handleGlobalEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setTipIsHidden(true); + } + }; + + document.addEventListener('keydown', handleGlobalEscapeKey); + return () => { + document.removeEventListener('keydown', handleGlobalEscapeKey); + }; + } + }, [isTipHidden, placement, setTipIsHidden]); + const isFloating = placement === 'floating'; const Tip = loaded && isFloating ? FloatingTip : InlineTip; diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index aa39dbeb60b..f8d28f3e656 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -134,21 +134,6 @@ export const FloatingTip: React.FC = ({ ); const isPopoverOpen = isHoverType ? isOpen : !isTipHidden; - let focusTrapProps = {}; - // For info tips that are open, we need focus trapping to handle escape key and tab navigation - if (type === 'info' && isPopoverOpen) { - focusTrapProps = { - onRequestClose: (e: React.KeyboardEvent) => { - if (escapeKeyPressHandler) { - escapeKeyPressHandler(e); - } - }, - }; - } else { - focusTrapProps = { - skipFocusTrap: true, - }; - } return ( = ({ = ({ args }) => { return ( + This text is in a small space and needs info + This text is in a small space and needs info {' '} Date: Thu, 23 Oct 2025 13:13:58 -0400 Subject: [PATCH 3/6] update --- packages/gamut/src/Tip/InfoTip/index.tsx | 48 +++++++++++++++++++ packages/gamut/src/Tip/shared/FloatingTip.tsx | 2 + packages/gamut/src/Tip/shared/types.tsx | 1 + 3 files changed, 51 insertions(+) diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 22a3c84e8f9..16e53cc228e 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -30,6 +30,8 @@ export const InfoTip: React.FC = ({ const [isTipHidden, setHideTip] = useState(true); const [isAriaHidden, setIsAriaHidden] = useState(false); const wrapperRef = useRef(null); + const buttonRef = useRef(null); + const popoverContentRef = useRef(null); const [loaded, setLoaded] = useState(false); useEffect(() => { @@ -92,11 +94,55 @@ export const InfoTip: React.FC = ({ const handleGlobalEscapeKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { setTipIsHidden(true); + buttonRef.current?.focus(); } }; + const handleFocusOut = (event: FocusEvent) => { + const popoverContent = popoverContentRef.current; + const button = buttonRef.current; + const wrapper = wrapperRef.current; + + const { relatedTarget } = event; + + if (relatedTarget instanceof Node) { + // If focus is moving back to the button or wrapper, allow it + const movingToButton = + button?.contains(relatedTarget) || wrapper?.contains(relatedTarget); + + if (movingToButton) return; + + // If focus is staying within the popover content, allow it + if (popoverContent?.contains(relatedTarget)) return; + + // Focus is leaving the InfoTip system entirely (via Tab, arrow keys, or any navigation) + // Return it to the button to maintain logical focus order + buttonRef.current?.focus(); + } else if (relatedTarget === null) { + // Focus is being removed entirely (e.g., clicking elsewhere or navigating) + // Return focus to button to maintain logical tab order + setTimeout(() => { + buttonRef.current?.focus(); + }, 0); + } + }; + + // Wait for the popover ref to be set before attaching the listener + let popoverContent: HTMLDivElement | null = null; + const timeoutId = setTimeout(() => { + popoverContent = popoverContentRef.current; + if (popoverContent) { + popoverContent.addEventListener('focusout', handleFocusOut); + } + }, 0); + document.addEventListener('keydown', handleGlobalEscapeKey); + return () => { + clearTimeout(timeoutId); + if (popoverContent) { + popoverContent.removeEventListener('focusout', handleFocusOut); + } document.removeEventListener('keydown', handleGlobalEscapeKey); }; } @@ -111,6 +157,7 @@ export const InfoTip: React.FC = ({ escapeKeyPressHandler, info, isTipHidden, + popoverContentRef, wrapperRef, ...rest, }; @@ -130,6 +177,7 @@ export const InfoTip: React.FC = ({ active={!isTipHidden} aria-expanded={!isTipHidden} emphasis={emphasis} + ref={buttonRef} onClick={() => clickHandler()} /> ); diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index f8d28f3e656..48cdae69456 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -31,6 +31,7 @@ export const FloatingTip: React.FC = ({ loading, narrow, overline, + popoverContentRef, truncateLines, type, username, @@ -165,6 +166,7 @@ export const FloatingTip: React.FC = ({ horizontalOffset={offset} isOpen={isPopoverOpen} outline + popoverContainerRef={popoverContentRef} skipFocusTrap targetRef={ref} variant="secondary" diff --git a/packages/gamut/src/Tip/shared/types.tsx b/packages/gamut/src/Tip/shared/types.tsx index 3fb51f99f35..a5b70fc1b58 100644 --- a/packages/gamut/src/Tip/shared/types.tsx +++ b/packages/gamut/src/Tip/shared/types.tsx @@ -78,6 +78,7 @@ export type TipPlacementComponentProps = Omit< escapeKeyPressHandler?: (event: React.KeyboardEvent) => void; id?: string; isTipHidden?: boolean; + popoverContentRef?: React.RefObject; type: 'info' | 'tool' | 'preview'; wrapperRef?: React.RefObject; zIndex?: number; From c78c78ed2df37940025f73fcad62268719502773 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 27 Oct 2025 13:18:03 -0400 Subject: [PATCH 4/6] undo example test --- .../src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx | 2 -- 1 file changed, 2 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 e785e2cbe2f..3b35e7184ed 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -92,8 +92,6 @@ const WithLinksOrButtonsExample: React.FC = ({ args }) => { return ( - This text is in a small space and needs info - This text is in a small space and needs info {' '} Date: Mon, 27 Oct 2025 14:05:27 -0400 Subject: [PATCH 5/6] fix stories to show code --- .../Tips/InfoTip/InfoTip.stories.tsx | 129 ++++++++---------- 1 file changed, 58 insertions(+), 71 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 3b35e7184ed..96d39620a30 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -7,7 +7,7 @@ import { Text, } from '@codecademy/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import React, { useRef } from 'react'; +import { useRef } from 'react'; const meta: Meta = { component: InfoTip, @@ -20,35 +20,27 @@ const meta: Meta = { export default meta; type Story = StoryObj; -type InfoTipProps = React.ComponentProps; - -const InfoTipExample: React.FC = (args) => { - return ( +export const Default: Story = { + render: (args) => ( Some text that needs info - ); -}; - -export const Default: Story = { - render: (args) => , + ), }; -const EmphasisExample: React.FC = (args) => { - return ( +export const Emphasis: Story = { + args: { + emphasis: 'high', + }, + render: (args) => ( - Some text that needs info and its super important{' '} - + Some text that needs info - ); + ), }; -export const Emphasis: Story = { - render: (args) => , -}; - -const AlignmentsExample: React.FC = (args) => { - return ( +export const Alignments: Story = { + render: (args) => ( {(['top-right', 'top-left', 'bottom-right', 'bottom-left'] as const).map( (alignment) => { @@ -61,62 +53,61 @@ const AlignmentsExample: React.FC = (args) => { } )} - ); + ), }; -export const Alignments: Story = { - render: (args) => , -}; - -const PlacementExample: React.FC = (args) => { - return ( +export const Placement: Story = { + args: { + placement: 'floating', + }, + render: (args) => ( This text is in a small space and needs floating placement {' '} - + - ); -}; - -export const Placement: Story = { - render: (args) => , -}; - -const WithLinksOrButtonsExample: React.FC = ({ 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 - {' '} - that is super important. - - } - placement="floating" - onClick={onClick} - {...args} - /> - - ); + ), }; export const WithLinksOrButtons: Story = { - render: (args) => , + args: { + 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 + {' '} + that is super important. + + } + onClick={onClick} + /> + + ); + }, }; -const ZIndexExample: React.FC = () => { - return ( +export const ZIndex: Story = { + args: { + info: 'I am inline, cool', + zIndex: 5, + }, + render: (args) => ( I will not be behind the infotip, sad + unreadable @@ -125,11 +116,7 @@ const ZIndexExample: React.FC = () => { I will be behind the infotip, nice + great - + - ); -}; - -export const ZIndex: Story = { - render: (args) => , + ), }; From 3fe8a22c5c152e8aed77da5d4b1b5d4f1016ab1c Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 27 Oct 2025 16:51:40 -0400 Subject: [PATCH 6/6] add test --- .../gamut/src/Tip/__tests__/InfoTip.test.tsx | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx index d94b193aa61..e884f3ed9d9 100644 --- a/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/InfoTip.test.tsx @@ -1,12 +1,15 @@ import { setupRtl } from '@codecademy/gamut-tests'; -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { Anchor } from '../../Anchor'; +import { Text } from '../../Typography'; import { InfoTip } from '../InfoTip'; const info = 'I am information'; const renderView = setupRtl(InfoTip, { - info: 'I am information', + info, }); describe('InfoTip', () => { @@ -46,5 +49,65 @@ describe('InfoTip', () => { // 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', + }); + + const button = view.getByLabelText('Show information'); + await act(async () => { + await userEvent.click(button); + }); + + expect(view.queryAllByText(info).length).toBe(2); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(view.queryByText(info)).toBeNull(); + }); + 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 { 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); + }); + + expect(view.queryAllByText(linkText).length).toBe(2); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + await waitFor(() => { + expect(view.queryByText(linkText)).toBeNull(); + }); + expect(button).toHaveFocus(); + }); }); });