Skip to content

Commit 4a60549

Browse files
committed
feat(tooltip): add ariaLinkMode to use tooltip as label instead of description
1 parent 52aba50 commit 4a60549

File tree

9 files changed

+223
-89
lines changed

9 files changed

+223
-89
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### Added
1616

17-
- `Tooltip`: Add `closeMode` to hide the tooltip instead of unmounting it
17+
- `Tooltip`: Add `closeMode` to hide the tooltip instead of unmounting it
18+
- `Tooltip`: Add `ariaLinkMode` to use tooltip as label instead of description
1819

1920
## [3.9.1][] - 2024-09-17
2021

packages/lumx-react/src/components/image-lightbox/ImageLightbox.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,18 @@ describe(`<${ImageLightbox.displayName}>`, () => {
145145

146146
// Focus moved to the close button
147147
const imageLightbox = queries.getImageLightbox();
148-
expect(queries.queryCloseButton(imageLightbox)).toHaveFocus();
148+
const closeButton = queries.queryCloseButton(imageLightbox);
149+
expect(closeButton).toHaveFocus();
150+
const tooltip = screen.getByRole('tooltip', { name: 'Close' });
151+
expect(tooltip).toBeInTheDocument();
149152

150153
// Image lightbox opened on the correct image
151154
expect(queries.queryImage(imageLightbox, 'Image 2')).toBeInTheDocument();
152155

156+
// Close tooltip
157+
await userEvent.keyboard('{escape}');
158+
expect(tooltip).not.toBeInTheDocument();
159+
153160
// Close on escape
154161
await userEvent.keyboard('{escape}');
155162
expect(imageLightbox).not.toBeInTheDocument();

packages/lumx-react/src/components/tooltip/Tooltip.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ import { Button, Dialog, Dropdown, Placement, Tooltip } from '@lumx/react';
22
import React, { useState } from 'react';
33
import { getSelectArgType } from '@lumx/react/stories/controls/selectArgType';
44
import { withChromaticForceScreenSize } from '@lumx/react/stories/decorators/withChromaticForceScreenSize';
5+
import { ARIA_LINK_MODES } from '@lumx/react/components/tooltip/constants';
56

67
const placements = [Placement.TOP, Placement.BOTTOM, Placement.RIGHT, Placement.LEFT];
8+
const CLOSE_MODES = ['hide', 'unmount'];
79

810
export default {
911
title: 'LumX components/tooltip/Tooltip',
1012
component: Tooltip,
1113
args: Tooltip.defaultProps,
1214
argTypes: {
1315
placement: getSelectArgType(placements),
16+
children: { control: false },
17+
closeMode: { control: { type: 'inline-radio' }, options: CLOSE_MODES },
18+
ariaLinkMode: { control: { type: 'inline-radio' }, options: ARIA_LINK_MODES },
1419
},
1520
decorators: [
1621
// Force minimum chromatic screen size to make sure the dialog appears in view.

packages/lumx-react/src/components/tooltip/Tooltip.test.tsx

Lines changed: 160 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { Button, IconButton } from '@lumx/react';
3+
import { Button } from '@lumx/react';
44
import { screen, render } from '@testing-library/react';
55
import { queryAllByTagName, queryByClassName } from '@lumx/react/testing/utils/queries';
66
import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
@@ -51,7 +51,6 @@ describe(`<${Tooltip.displayName}>`, () => {
5151
// Default placement
5252
expect(tooltip).toHaveAttribute('data-popper-placement', 'bottom');
5353
expect(anchorWrapper).toBeInTheDocument();
54-
expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip?.id);
5554
});
5655

5756
it('should render with custom placement', async () => {
@@ -65,25 +64,6 @@ describe(`<${Tooltip.displayName}>`, () => {
6564
expect(tooltip).toHaveAttribute('data-popper-placement', 'top');
6665
});
6766

68-
it('should wrap unknown children and not add aria-describedby when closed', async () => {
69-
const { anchorWrapper } = await setup({
70-
label: 'Tooltip label',
71-
children: 'Anchor',
72-
forceOpen: false,
73-
});
74-
expect(anchorWrapper).not.toHaveAttribute('aria-describedby');
75-
});
76-
77-
it('should not wrap Button and not add aria-describedby when closed', async () => {
78-
await setup({
79-
label: 'Tooltip label',
80-
children: <Button>Anchor</Button>,
81-
forceOpen: false,
82-
});
83-
const button = screen.queryByRole('button', { name: 'Anchor' });
84-
expect(button).not.toHaveAttribute('aria-describedby');
85-
});
86-
8767
it('should not wrap Button', async () => {
8868
const { tooltip, anchorWrapper } = await setup({
8969
label: 'Tooltip label',
@@ -96,35 +76,6 @@ describe(`<${Tooltip.displayName}>`, () => {
9676
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
9777
});
9878

99-
it('should not add aria-describedby if button label is the same as tooltip label', async () => {
100-
const label = 'Tooltip label';
101-
render(<IconButton label={label} tooltipProps={{ forceOpen: true }} />);
102-
const tooltip = screen.queryByRole('tooltip', { name: label });
103-
expect(tooltip).toBeInTheDocument();
104-
const button = screen.queryByRole('button', { name: label });
105-
expect(button).not.toHaveAttribute('aria-describedby');
106-
});
107-
108-
it('should keep anchor aria-describedby if button label is the same as tooltip label', async () => {
109-
const label = 'Tooltip label';
110-
render(<IconButton label={label} aria-describedby=":header-1:" tooltipProps={{ forceOpen: true }} />);
111-
const tooltip = screen.queryByRole('tooltip', { name: label });
112-
expect(tooltip).toBeInTheDocument();
113-
const button = screen.queryByRole('button', { name: label });
114-
expect(button).toHaveAttribute('aria-describedby', ':header-1:');
115-
});
116-
117-
it('should concat aria-describedby if already exists', async () => {
118-
const { tooltip } = await setup({
119-
label: 'Tooltip label',
120-
children: <Button aria-describedby=":header-1:">Anchor</Button>,
121-
forceOpen: true,
122-
});
123-
expect(tooltip).toBeInTheDocument();
124-
const button = screen.queryByRole('button', { name: 'Anchor' });
125-
expect(button).toHaveAttribute('aria-describedby', `:header-1: ${tooltip?.id}`);
126-
});
127-
12879
it('should wrap disabled Button', async () => {
12980
const { tooltip, anchorWrapper } = await setup({
13081
label: 'Tooltip label',
@@ -172,17 +123,166 @@ describe(`<${Tooltip.displayName}>`, () => {
172123
expect(ref.current === element).toBe(true);
173124
});
174125

175-
it('should render in closeMode=hide', async () => {
176-
const { tooltip } = await setup({
177-
label: 'Tooltip label',
178-
children: <Button>Anchor</Button>,
179-
closeMode: 'hide',
180-
forceOpen: false,
126+
describe('closeMode="hide"', () => {
127+
it('should not render with empty label', async () => {
128+
const { tooltip, anchorWrapper } = await setup({
129+
label: undefined,
130+
forceOpen: true,
131+
closeMode: 'hide',
132+
});
133+
expect(tooltip).not.toBeInTheDocument();
134+
expect(anchorWrapper).not.toBeInTheDocument();
135+
});
136+
137+
it('should render hidden', async () => {
138+
const { tooltip } = await setup({
139+
label: 'Tooltip label',
140+
children: <Button>Anchor</Button>,
141+
closeMode: 'hide',
142+
forceOpen: false,
143+
});
144+
expect(tooltip).toBeInTheDocument();
145+
expect(tooltip).toHaveClass('lumx-tooltip--is-hidden');
146+
147+
const anchor = screen.getByRole('button', { name: 'Anchor' });
148+
await userEvent.hover(anchor);
149+
expect(tooltip).not.toHaveClass('lumx-tooltip--is-hidden');
150+
});
151+
});
152+
153+
describe('ariaLinkMode="aria-describedby"', () => {
154+
it('should add aria-describedby on anchor on open', async () => {
155+
await setup({
156+
label: 'Tooltip label',
157+
forceOpen: false,
158+
children: <Button aria-describedby=":description1:">Anchor</Button>,
159+
});
160+
const anchor = screen.getByRole('button', { name: 'Anchor' });
161+
expect(anchor).toHaveAttribute('aria-describedby', ':description1:');
162+
163+
await userEvent.hover(anchor);
164+
const tooltip = screen.queryByRole('tooltip');
165+
expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip?.id}`);
166+
});
167+
168+
it('should always add aria-describedby on anchor with closeMode="hide"', async () => {
169+
const { tooltip } = await setup({
170+
label: 'Tooltip label',
171+
forceOpen: false,
172+
children: <Button aria-describedby=":description1:">Anchor</Button>,
173+
closeMode: 'hide',
174+
});
175+
const anchor = screen.getByRole('button', { name: 'Anchor' });
176+
expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip?.id}`);
177+
});
178+
179+
it('should skip aria-describedby if anchor has label', async () => {
180+
const { tooltip } = await setup({
181+
label: 'Tooltip label',
182+
forceOpen: true,
183+
children: (
184+
<Button aria-describedby=":description1:" aria-label="Tooltip label">
185+
Anchor
186+
</Button>
187+
),
188+
});
189+
expect(tooltip).toBeInTheDocument();
190+
expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', `:description1:`);
191+
});
192+
193+
it('should add aria-describedby on anchor wrapper on open', async () => {
194+
const { anchorWrapper } = await setup({
195+
label: 'Tooltip label',
196+
forceOpen: false,
197+
children: 'Anchor',
198+
});
199+
expect(anchorWrapper).not.toHaveAttribute('aria-describedby');
200+
201+
await userEvent.hover(anchorWrapper as any);
202+
const tooltip = screen.queryByRole('tooltip');
203+
expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip?.id);
204+
});
205+
206+
it('should always add aria-describedby on anchor wrapper with closeMode="hide"', async () => {
207+
const { tooltip, anchorWrapper } = await setup({
208+
label: 'Tooltip label',
209+
forceOpen: false,
210+
children: 'Anchor',
211+
closeMode: 'hide',
212+
});
213+
expect(anchorWrapper).toHaveAttribute('aria-describedby', `${tooltip?.id}`);
214+
});
215+
});
216+
217+
describe('ariaLinkMode="aria-labelledby"', () => {
218+
it('should add aria-labelledby on anchor on open', async () => {
219+
await setup({
220+
label: 'Tooltip label',
221+
forceOpen: false,
222+
children: <Button aria-labelledby=":label1:">Anchor</Button>,
223+
ariaLinkMode: 'aria-labelledby',
224+
});
225+
const anchor = screen.getByRole('button', { name: 'Anchor' });
226+
expect(anchor).toHaveAttribute('aria-labelledby', ':label1:');
227+
228+
await userEvent.hover(anchor);
229+
const tooltip = screen.queryByRole('tooltip');
230+
expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip?.id}`);
231+
});
232+
233+
it('should always add aria-labelledby on anchor with closeMode="hide"', async () => {
234+
const label = 'Tooltip label';
235+
const { tooltip } = await setup({
236+
label,
237+
forceOpen: false,
238+
children: <Button aria-labelledby=":label1:">Anchor</Button>,
239+
ariaLinkMode: 'aria-labelledby',
240+
closeMode: 'hide',
241+
});
242+
const anchor = screen.queryByRole('button', { name: label });
243+
expect(anchor).toBeInTheDocument();
244+
expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip?.id}`);
245+
});
246+
247+
it('should skip aria-labelledby if anchor has label', async () => {
248+
const { tooltip } = await setup({
249+
label: 'Tooltip label',
250+
forceOpen: true,
251+
children: (
252+
<Button aria-labelledby=":label1:" aria-label="Tooltip label">
253+
Anchor
254+
</Button>
255+
),
256+
ariaLinkMode: 'aria-labelledby',
257+
});
258+
expect(tooltip).toBeInTheDocument();
259+
expect(screen.getByRole('button')).toHaveAttribute('aria-labelledby', `:label1:`);
260+
});
261+
262+
it('should add aria-labelledby on anchor wrapper on open', async () => {
263+
const { anchorWrapper } = await setup({
264+
label: 'Tooltip label',
265+
forceOpen: false,
266+
children: 'Anchor',
267+
ariaLinkMode: 'aria-labelledby',
268+
});
269+
expect(anchorWrapper).not.toHaveAttribute('aria-labelledby');
270+
271+
await userEvent.hover(anchorWrapper as any);
272+
const tooltip = screen.queryByRole('tooltip');
273+
expect(anchorWrapper).toHaveAttribute('aria-labelledby', tooltip?.id);
274+
});
275+
276+
it('should always add aria-labelledby on anchor wrapper with closeMode="hide"', async () => {
277+
const { tooltip, anchorWrapper } = await setup({
278+
label: 'Tooltip label',
279+
forceOpen: false,
280+
children: 'Anchor',
281+
ariaLinkMode: 'aria-labelledby',
282+
closeMode: 'hide',
283+
});
284+
expect(anchorWrapper).toHaveAttribute('aria-labelledby', `${tooltip?.id}`);
181285
});
182-
expect(tooltip).toBeInTheDocument();
183-
expect(tooltip).toHaveClass('lumx-tooltip--is-hidden');
184-
const button = screen.queryByRole('button', { name: 'Anchor' });
185-
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
186286
});
187287
});
188288

@@ -203,7 +303,6 @@ describe(`<${Tooltip.displayName}>`, () => {
203303
// Tooltip opened
204304
tooltip = await screen.findByRole('tooltip', { name: 'Tooltip label' });
205305
expect(tooltip).toBeInTheDocument();
206-
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
207306

208307
// Un-hover anchor button
209308
await userEvent.unhover(button);

packages/lumx-react/src/components/tooltip/Tooltip.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
1111
import { Placement } from '@lumx/react/components/popover';
1212
import { TooltipContextProvider } from '@lumx/react/components/tooltip/context';
1313
import { useId } from '@lumx/react/hooks/useId';
14+
import { usePopper } from '@lumx/react/hooks/usePopper';
1415

16+
import { ARIA_LINK_MODES } from '@lumx/react/components/tooltip/constants';
1517
import { useInjectTooltipRef } from './useInjectTooltipRef';
1618
import { useTooltipOpen } from './useTooltipOpen';
17-
import { usePopper } from '@lumx/react/hooks/usePopper';
1819

1920
/** Position of the tooltip relative to the anchor element. */
2021
export type TooltipPlacement = Extract<Placement, 'top' | 'right' | 'bottom' | 'left'>;
@@ -33,6 +34,8 @@ export interface TooltipProps extends GenericProps, HasCloseMode {
3334
label?: string | null | false;
3435
/** Placement of the tooltip relative to the anchor. */
3536
placement?: TooltipPlacement;
37+
/** Choose how the tooltip text should link to the anchor */
38+
ariaLinkMode?: (typeof ARIA_LINK_MODES)[number];
3639
}
3740

3841
/**
@@ -51,6 +54,7 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
5154
const DEFAULT_PROPS: Partial<TooltipProps> = {
5255
placement: Placement.BOTTOM,
5356
closeMode: 'unmount',
57+
ariaLinkMode: 'aria-describedby',
5458
};
5559

5660
/**
@@ -66,7 +70,8 @@ const ARROW_SIZE = 8;
6670
* @return React element.
6771
*/
6872
export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, ref) => {
69-
const { label, children, className, delay, placement, forceOpen, closeMode, ...forwardedProps } = props;
73+
const { label, children, className, delay, placement, forceOpen, closeMode, ariaLinkMode, ...forwardedProps } =
74+
props;
7075
// Disable in SSR.
7176
if (!DOCUMENT) {
7277
return <>{children}</>;
@@ -89,8 +94,15 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
8994
const position = attributes?.popper?.['data-popper-placement'] ?? placement;
9095
const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
9196
const isOpen = (isActivated || forceOpen) && !!label;
92-
const isMounted = isOpen || closeMode === 'hide';
93-
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isMounted, id, label);
97+
const isMounted = !!label && (isOpen || closeMode === 'hide');
98+
const wrappedChildren = useInjectTooltipRef({
99+
children,
100+
setAnchorElement,
101+
isMounted,
102+
id,
103+
label,
104+
ariaLinkMode: ariaLinkMode as any,
105+
});
94106

95107
const labelLines = label ? label.split('\n') : [];
96108

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const ARIA_LINK_MODES = ['aria-describedby', 'aria-labelledby'] as const;

0 commit comments

Comments
 (0)