diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 2ebccccce7b..9a39bae3fc6 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'; @@ -29,27 +29,34 @@ export const InfoTip: React.FC = ({ }) => { 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); useEffect(() => { 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); + setShouldAnnounce(false); } - } else { - if (isAriaHidden) setIsAriaHidden(false); - setHideTip(nextTipState); - } - }; + }, + [isAriaHidden, placement] + ); const escapeKeyPressHandler = ( event: React.KeyboardEvent @@ -73,6 +80,12 @@ export const InfoTip: React.FC = ({ const clickHandler = () => { const currentTipState = !isTipHidden; setTipIsHidden(currentTipState); + if (!currentTipState) { + // Delay slightly to ensure focus has settled back on button before announcing + setTimeout(() => { + setShouldAnnounce(true); + }, 0); + } // we want to call the onClick handler after the tip has mounted if (onClick) setTimeout(() => onClick({ isTipHidden: currentTipState }), 0); }; @@ -84,6 +97,59 @@ export const InfoTip: React.FC = ({ }; }); + useEffect(() => { + if (!isTipHidden && placement === 'floating') { + 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; + } + + // 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); + }; + } + }, [isTipHidden, placement, setTipIsHidden]); + const isFloating = placement === 'floating'; const Tip = loaded && isFloating ? FloatingTip : InlineTip; @@ -93,6 +159,7 @@ export const InfoTip: React.FC = ({ escapeKeyPressHandler, info, isTipHidden, + popoverContentRef, wrapperRef, ...rest, }; @@ -103,7 +170,7 @@ export const InfoTip: React.FC = ({ aria-live="assertive" screenreader > - {!isTipHidden ? info : `\xa0`} + {shouldAnnounce && !isTipHidden ? info : `\xa0`} ); @@ -112,6 +179,7 @@ export const InfoTip: React.FC = ({ active={!isTipHidden} aria-expanded={!isTipHidden} emphasis={emphasis} + ref={buttonRef} onClick={() => clickHandler()} /> ); 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(); + }); }); }); diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index 1236956f810..c251d1570cf 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, @@ -134,6 +135,8 @@ export const FloatingTip: React.FC = ({ info ); + const isPopoverOpen = isHoverType ? isOpen : !isTipHidden; + return ( = ({ animation="fade" dims={dims} horizontalOffset={offset} - isOpen={isHoverType ? isOpen : !isTipHidden} + 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; 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..efad164ad61 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,65 @@ 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. This is a{' '} + + second cool link + {' '} + that is also 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 +120,7 @@ const ZIndexExample: React.FC = () => { I will be behind the infotip, nice + great - + - ); -}; - -export const ZIndex: Story = { - render: (args) => , + ), };