Skip to content

Commit dcffe03

Browse files
authored
feat(InfoTip): Add global escape close
Adds a global escape close to floating InfoTips. Also moves focus back to info button after focus leaves the infotip. Fixes a bug where the infotip text was not getting read by screenreaders.
1 parent b6804d5 commit dcffe03

File tree

5 files changed

+216
-89
lines changed

5 files changed

+216
-89
lines changed

packages/gamut/src/Tip/InfoTip/index.tsx

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22

33
import { FloatingTip } from '../shared/FloatingTip';
44
import { InlineTip } from '../shared/InlineTip';
@@ -29,27 +29,34 @@ export const InfoTip: React.FC<InfoTipProps> = ({
2929
}) => {
3030
const [isTipHidden, setHideTip] = useState(true);
3131
const [isAriaHidden, setIsAriaHidden] = useState(false);
32+
const [shouldAnnounce, setShouldAnnounce] = useState(false);
3233
const wrapperRef = useRef<HTMLDivElement>(null);
34+
const buttonRef = useRef<HTMLButtonElement>(null);
35+
const popoverContentRef = useRef<HTMLDivElement>(null);
3336
const [loaded, setLoaded] = useState(false);
3437

3538
useEffect(() => {
3639
setLoaded(true);
3740
}, []);
3841

39-
const setTipIsHidden = (nextTipState: boolean) => {
40-
if (!nextTipState) {
41-
setHideTip(nextTipState);
42-
if (placement !== 'floating') {
43-
// on inline component - stops text from being able to be navigated through, instead user can nav through visible text
44-
setTimeout(() => {
45-
setIsAriaHidden(true);
46-
}, 1000);
42+
const setTipIsHidden = useCallback(
43+
(nextTipState: boolean) => {
44+
if (!nextTipState) {
45+
setHideTip(nextTipState);
46+
if (placement !== 'floating') {
47+
// on inline component - stops text from being able to be navigated through, instead user can nav through visible text
48+
setTimeout(() => {
49+
setIsAriaHidden(true);
50+
}, 1000);
51+
}
52+
} else {
53+
if (isAriaHidden) setIsAriaHidden(false);
54+
setHideTip(nextTipState);
55+
setShouldAnnounce(false);
4756
}
48-
} else {
49-
if (isAriaHidden) setIsAriaHidden(false);
50-
setHideTip(nextTipState);
51-
}
52-
};
57+
},
58+
[isAriaHidden, placement]
59+
);
5360

5461
const escapeKeyPressHandler = (
5562
event: React.KeyboardEvent<HTMLDivElement>
@@ -73,6 +80,12 @@ export const InfoTip: React.FC<InfoTipProps> = ({
7380
const clickHandler = () => {
7481
const currentTipState = !isTipHidden;
7582
setTipIsHidden(currentTipState);
83+
if (!currentTipState) {
84+
// Delay slightly to ensure focus has settled back on button before announcing
85+
setTimeout(() => {
86+
setShouldAnnounce(true);
87+
}, 0);
88+
}
7689
// we want to call the onClick handler after the tip has mounted
7790
if (onClick) setTimeout(() => onClick({ isTipHidden: currentTipState }), 0);
7891
};
@@ -84,6 +97,59 @@ export const InfoTip: React.FC<InfoTipProps> = ({
8497
};
8598
});
8699

100+
useEffect(() => {
101+
if (!isTipHidden && placement === 'floating') {
102+
const handleGlobalEscapeKey = (e: KeyboardEvent) => {
103+
if (e.key === 'Escape') {
104+
setTipIsHidden(true);
105+
buttonRef.current?.focus();
106+
}
107+
};
108+
109+
const handleFocusOut = (event: FocusEvent) => {
110+
const popoverContent = popoverContentRef.current;
111+
const button = buttonRef.current;
112+
const wrapper = wrapperRef.current;
113+
114+
const { relatedTarget } = event;
115+
116+
if (relatedTarget instanceof Node) {
117+
// If focus is moving back to the button or wrapper, allow it
118+
const movingToButton =
119+
button?.contains(relatedTarget) || wrapper?.contains(relatedTarget);
120+
if (movingToButton) return;
121+
122+
// If focus is staying within the popover content, allow it
123+
if (popoverContent?.contains(relatedTarget)) return;
124+
}
125+
126+
// Return focus to button to maintain logical tab order
127+
setTimeout(() => {
128+
buttonRef.current?.focus();
129+
}, 0);
130+
};
131+
132+
// Wait for the popover ref to be set before attaching the listener
133+
let popoverContent: HTMLDivElement | null = null;
134+
const timeoutId = setTimeout(() => {
135+
popoverContent = popoverContentRef.current;
136+
if (popoverContent) {
137+
popoverContent.addEventListener('focusout', handleFocusOut);
138+
}
139+
}, 0);
140+
141+
document.addEventListener('keydown', handleGlobalEscapeKey);
142+
143+
return () => {
144+
clearTimeout(timeoutId);
145+
if (popoverContent) {
146+
popoverContent.removeEventListener('focusout', handleFocusOut);
147+
}
148+
document.removeEventListener('keydown', handleGlobalEscapeKey);
149+
};
150+
}
151+
}, [isTipHidden, placement, setTipIsHidden]);
152+
87153
const isFloating = placement === 'floating';
88154

89155
const Tip = loaded && isFloating ? FloatingTip : InlineTip;
@@ -93,6 +159,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
93159
escapeKeyPressHandler,
94160
info,
95161
isTipHidden,
162+
popoverContentRef,
96163
wrapperRef,
97164
...rest,
98165
};
@@ -103,7 +170,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
103170
aria-live="assertive"
104171
screenreader
105172
>
106-
{!isTipHidden ? info : `\xa0`}
173+
{shouldAnnounce && !isTipHidden ? info : `\xa0`}
107174
</ScreenreaderNavigableText>
108175
);
109176

@@ -112,6 +179,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
112179
active={!isTipHidden}
113180
aria-expanded={!isTipHidden}
114181
emphasis={emphasis}
182+
ref={buttonRef}
115183
onClick={() => clickHandler()}
116184
/>
117185
);

packages/gamut/src/Tip/__tests__/InfoTip.test.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { setupRtl } from '@codecademy/gamut-tests';
2-
import { act } from '@testing-library/react';
2+
import { act, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
4+
import { createRef } from 'react';
45

6+
import { Anchor } from '../../Anchor';
7+
import { Text } from '../../Typography';
58
import { InfoTip } from '../InfoTip';
69

710
const info = 'I am information';
811
const renderView = setupRtl(InfoTip, {
9-
info: 'I am information',
12+
info,
1013
});
1114

1215
describe('InfoTip', () => {
@@ -46,5 +49,65 @@ describe('InfoTip', () => {
4649
// The first get by text result is the a11y text, the second is the actual tip text
4750
expect(view.queryAllByText(info).length).toBe(2);
4851
});
52+
53+
it('closes the tip when Escape key is pressed and returns focus to the button', async () => {
54+
const { view } = renderView({
55+
placement: 'floating',
56+
});
57+
58+
const button = view.getByLabelText('Show information');
59+
await act(async () => {
60+
await userEvent.click(button);
61+
});
62+
63+
expect(view.queryAllByText(info).length).toBe(2);
64+
65+
await act(async () => {
66+
await userEvent.keyboard('{Escape}');
67+
});
68+
69+
await waitFor(() => {
70+
expect(view.queryByText(info)).toBeNull();
71+
});
72+
expect(button).toHaveFocus();
73+
});
74+
75+
it('closes the tip with links when Escape key is pressed and returns focus to the button', async () => {
76+
const linkText = 'cool link';
77+
const linkRef = createRef<HTMLAnchorElement>();
78+
const { view } = renderView({
79+
placement: 'floating',
80+
info: (
81+
<Text>
82+
Hey! Here is a{' '}
83+
<Anchor href="https://giphy.com/search/nichijou" ref={linkRef}>
84+
{linkText}
85+
</Anchor>{' '}
86+
that is super important.
87+
</Text>
88+
),
89+
onClick: ({ isTipHidden }: { isTipHidden: boolean }) => {
90+
if (!isTipHidden) {
91+
linkRef.current?.focus();
92+
}
93+
},
94+
});
95+
96+
const button = view.getByLabelText('Show information');
97+
await act(async () => {
98+
await userEvent.click(button);
99+
});
100+
101+
expect(view.queryAllByText(linkText).length).toBe(2);
102+
103+
await act(async () => {
104+
await userEvent.keyboard('{Escape}');
105+
});
106+
107+
await waitFor(() => {
108+
expect(view.queryByText(linkText)).toBeNull();
109+
});
110+
expect(button).toHaveFocus();
111+
});
49112
});
50113
});

packages/gamut/src/Tip/shared/FloatingTip.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
3131
loading,
3232
narrow,
3333
overline,
34+
popoverContentRef,
3435
truncateLines,
3536
type,
3637
username,
@@ -134,6 +135,8 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
134135
info
135136
);
136137

138+
const isPopoverOpen = isHoverType ? isOpen : !isTipHidden;
139+
137140
return (
138141
<Box
139142
display="inline-flex"
@@ -162,8 +165,9 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
162165
animation="fade"
163166
dims={dims}
164167
horizontalOffset={offset}
165-
isOpen={isHoverType ? isOpen : !isTipHidden}
168+
isOpen={isPopoverOpen}
166169
outline
170+
popoverContainerRef={popoverContentRef}
167171
skipFocusTrap
168172
targetRef={ref}
169173
variant="secondary"

packages/gamut/src/Tip/shared/types.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export type TipPlacementComponentProps = Omit<
7878
escapeKeyPressHandler?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
7979
id?: string;
8080
isTipHidden?: boolean;
81+
popoverContentRef?: React.RefObject<HTMLDivElement>;
8182
type: 'info' | 'tool' | 'preview';
8283
wrapperRef?: React.RefObject<HTMLDivElement>;
8384
zIndex?: number;

0 commit comments

Comments
 (0)