Skip to content

Commit 08a10a8

Browse files
thatblindgeyeEric Olkowski
andauthored
fix(Tooltip): added aria attributes for triggerRef (#11953)
Co-authored-by: Eric Olkowski <[email protected]>
1 parent 6bcad45 commit 08a10a8

File tree

2 files changed

+202
-3
lines changed

2 files changed

+202
-3
lines changed

packages/react-core/src/components/Tooltip/Tooltip.tsx

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,75 @@ export const Tooltip: React.FunctionComponent<TooltipProps> = ({
174174
const [visible, setVisible] = useState(false);
175175
const popperRef = createRef<HTMLDivElement>();
176176

177+
const getTriggerRefElement = (): HTMLElement | null => {
178+
if (typeof triggerRef === 'function') {
179+
return triggerRef();
180+
} else if (triggerRef && typeof triggerRef === 'object' && 'current' in triggerRef) {
181+
return triggerRef.current;
182+
} else if (triggerRef instanceof HTMLElement) {
183+
return triggerRef;
184+
}
185+
return null;
186+
};
187+
188+
const getAriaAttributeName = () => (aria !== 'none' ? `aria-${aria}` : null);
189+
190+
const addAriaToRefElement = (element: HTMLElement): void => {
191+
const attributeName = getAriaAttributeName();
192+
if (!element || !attributeName) {
193+
return;
194+
}
195+
196+
const existingAria = element.getAttribute(attributeName);
197+
if (!existingAria || !existingAria.includes(id)) {
198+
const newAria = existingAria ? `${existingAria} ${id}` : id;
199+
element.setAttribute(attributeName, newAria);
200+
}
201+
};
202+
203+
const removeAriaFromRefElement = (element: HTMLElement): void => {
204+
const attributeName = getAriaAttributeName();
205+
if (!element || !attributeName) {
206+
return;
207+
}
208+
209+
const existingAria = element.getAttribute(attributeName);
210+
if (!existingAria) {
211+
return;
212+
}
213+
const newAria = existingAria.replace(new RegExp(`\\b${id}\\b`, 'g'), '').trim();
214+
if (newAria) {
215+
element.setAttribute(attributeName, newAria);
216+
} else {
217+
element.removeAttribute(attributeName);
218+
}
219+
};
220+
221+
const updateTriggerElementAria = (shouldAddAria: boolean): void => {
222+
if (aria === 'none' || !triggerRef || children) {
223+
return;
224+
}
225+
226+
const triggerElement = getTriggerRefElement();
227+
if (!triggerElement) {
228+
return;
229+
}
230+
231+
if (shouldAddAria) {
232+
addAriaToRefElement(triggerElement);
233+
} else {
234+
removeAriaFromRefElement(triggerElement);
235+
}
236+
};
237+
238+
useEffect(() => {
239+
updateTriggerElementAria(visible);
240+
241+
return () => {
242+
updateTriggerElementAria(false);
243+
};
244+
}, [visible, aria, triggerRef, children, id]);
245+
177246
const onDocumentKeyDown = (event: KeyboardEvent) => {
178247
if (!triggerManually) {
179248
if (event.key === KeyTypes.Escape && visible) {
@@ -258,8 +327,12 @@ export const Tooltip: React.FunctionComponent<TooltipProps> = ({
258327
}
259328
};
260329

261-
const addAriaToTrigger = () => {
262-
if (aria === 'describedby' && children && children.props && !children.props['aria-describedby']) {
330+
const addAriaToChildren = () => {
331+
if (!children) {
332+
return;
333+
}
334+
335+
if (aria === 'describedby' && children.props && !children.props['aria-describedby']) {
263336
return cloneElement(children, { 'aria-describedby': id });
264337
} else if (aria === 'labelledby' && children.props && !children.props['aria-labelledby']) {
265338
return cloneElement(children, { 'aria-labelledby': id });
@@ -269,7 +342,7 @@ export const Tooltip: React.FunctionComponent<TooltipProps> = ({
269342

270343
return (
271344
<Popper
272-
trigger={aria !== 'none' && visible ? addAriaToTrigger() : children}
345+
trigger={aria !== 'none' && visible ? addAriaToChildren() : children}
273346
triggerRef={triggerRef}
274347
popper={content}
275348
popperRef={popperRef}

packages/react-core/src/components/Tooltip/__tests__/Tooltip.test.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,129 @@ test('Matches snapshot', async () => {
204204
const tooltip = await screen.findByRole('tooltip');
205205
expect(tooltip).toMatchSnapshot();
206206
});
207+
208+
test('Applies aria-describedby to triggerRef element when no children are provided', async () => {
209+
const triggerRef = createRef<HTMLButtonElement>();
210+
211+
render(
212+
<>
213+
<button ref={triggerRef}>Trigger</button>
214+
<Tooltip id="trigger-ref-test" triggerRef={triggerRef} isVisible content="Test description" />
215+
</>
216+
);
217+
218+
await screen.findByRole('tooltip');
219+
expect(triggerRef.current).toHaveAccessibleDescription('Test description');
220+
});
221+
222+
test('Applies aria-labelledby to triggerRef element when no children are provided', async () => {
223+
const triggerRef = createRef<HTMLButtonElement>();
224+
225+
render(
226+
<>
227+
<button ref={triggerRef}>Trigger</button>
228+
<Tooltip id="trigger-ref-test" aria="labelledby" triggerRef={triggerRef} isVisible content="Test label" />
229+
</>
230+
);
231+
232+
await screen.findByRole('tooltip');
233+
expect(triggerRef.current).toHaveAccessibleName('Test label');
234+
});
235+
236+
test('Removes aria-describedby from triggerRef element when tooltip is hidden', async () => {
237+
const triggerRef = createRef<HTMLButtonElement>();
238+
239+
const TooltipTest = () => {
240+
const [isVisible, setIsVisible] = useState(true);
241+
242+
return (
243+
<>
244+
<button ref={triggerRef} onClick={() => setIsVisible(!isVisible)}>
245+
Trigger
246+
</button>
247+
<Tooltip id="trigger-ref-test" triggerRef={triggerRef} isVisible={isVisible} content="Test description" />
248+
</>
249+
);
250+
};
251+
252+
render(<TooltipTest />);
253+
254+
// Tooltip should be visible initially
255+
await screen.findByRole('tooltip');
256+
expect(triggerRef.current).toHaveAccessibleDescription('Test description');
257+
258+
// Hide tooltip
259+
const user = userEvent.setup();
260+
await user.click(triggerRef.current);
261+
262+
// aria-describedby should be removed
263+
expect(triggerRef.current).not.toHaveAccessibleDescription();
264+
});
265+
266+
test('Removes aria-labelledby from triggerRef element when tooltip is hidden', async () => {
267+
const triggerRef = createRef<HTMLButtonElement>();
268+
269+
const TooltipTest = () => {
270+
const [isVisible, setIsVisible] = useState(true);
271+
272+
return (
273+
<>
274+
<button ref={triggerRef} onClick={() => setIsVisible(!isVisible)} />
275+
<Tooltip
276+
aria="labelledby"
277+
id="trigger-ref-test"
278+
triggerRef={triggerRef}
279+
isVisible={isVisible}
280+
content="Test label"
281+
/>
282+
</>
283+
);
284+
};
285+
286+
render(<TooltipTest />);
287+
288+
// Tooltip should be visible initially
289+
await screen.findByRole('tooltip');
290+
expect(triggerRef.current).toHaveAccessibleName('Test label');
291+
292+
// Hide tooltip
293+
const user = userEvent.setup();
294+
await user.click(triggerRef.current);
295+
296+
// aria-describedby should be removed
297+
expect(triggerRef.current).not.toHaveAccessibleName();
298+
});
299+
300+
test('Preserves existing aria-describedby on triggerRef element', async () => {
301+
const triggerRef = createRef<HTMLButtonElement>();
302+
303+
render(
304+
<>
305+
<div id="existing-aria">Existing description</div>
306+
<button ref={triggerRef} aria-describedby="existing-aria">
307+
Trigger
308+
</button>
309+
<Tooltip id="trigger-ref-test" triggerRef={triggerRef} isVisible content="Test description" />
310+
</>
311+
);
312+
313+
await screen.findByRole('tooltip');
314+
expect(triggerRef.current).toHaveAccessibleDescription('Existing description Test description');
315+
});
316+
317+
test('Preserves existing aria-labelledby on triggerRef element', async () => {
318+
const triggerRef = createRef<HTMLButtonElement>();
319+
320+
render(
321+
<>
322+
<div id="existing-aria">Existing label</div>
323+
<button ref={triggerRef} aria-labelledby="existing-aria">
324+
Trigger
325+
</button>
326+
<Tooltip aria="labelledby" id="trigger-ref-test" triggerRef={triggerRef} isVisible content="Test label" />
327+
</>
328+
);
329+
330+
await screen.findByRole('tooltip');
331+
expect(triggerRef.current).toHaveAccessibleName('Existing label Test label');
332+
});

0 commit comments

Comments
 (0)