Skip to content

Commit

Permalink
feat(tooltip): add ariaLinkMode to use tooltip as label instead of …
Browse files Browse the repository at this point in the history
…description
  • Loading branch information
gcornut committed Oct 1, 2024
1 parent 52aba50 commit 4a60549
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 89 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,18 @@ describe(`<${ImageLightbox.displayName}>`, () => {

// Focus moved to the close button
const imageLightbox = queries.getImageLightbox();
expect(queries.queryCloseButton(imageLightbox)).toHaveFocus();
const closeButton = queries.queryCloseButton(imageLightbox);
expect(closeButton).toHaveFocus();
const tooltip = screen.getByRole('tooltip', { name: 'Close' });
expect(tooltip).toBeInTheDocument();

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

// Close tooltip
await userEvent.keyboard('{escape}');
expect(tooltip).not.toBeInTheDocument();

// Close on escape
await userEvent.keyboard('{escape}');
expect(imageLightbox).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import { Button, Dialog, Dropdown, Placement, Tooltip } from '@lumx/react';
import React, { useState } from 'react';
import { getSelectArgType } from '@lumx/react/stories/controls/selectArgType';
import { withChromaticForceScreenSize } from '@lumx/react/stories/decorators/withChromaticForceScreenSize';
import { ARIA_LINK_MODES } from '@lumx/react/components/tooltip/constants';

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

export default {
title: 'LumX components/tooltip/Tooltip',
component: Tooltip,
args: Tooltip.defaultProps,
argTypes: {
placement: getSelectArgType(placements),
children: { control: false },
closeMode: { control: { type: 'inline-radio' }, options: CLOSE_MODES },
ariaLinkMode: { control: { type: 'inline-radio' }, options: ARIA_LINK_MODES },
},
decorators: [
// Force minimum chromatic screen size to make sure the dialog appears in view.
Expand Down
221 changes: 160 additions & 61 deletions packages/lumx-react/src/components/tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

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

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

it('should wrap unknown children and not add aria-describedby when closed', async () => {
const { anchorWrapper } = await setup({
label: 'Tooltip label',
children: 'Anchor',
forceOpen: false,
});
expect(anchorWrapper).not.toHaveAttribute('aria-describedby');
});

it('should not wrap Button and not add aria-describedby when closed', async () => {
await setup({
label: 'Tooltip label',
children: <Button>Anchor</Button>,
forceOpen: false,
});
const button = screen.queryByRole('button', { name: 'Anchor' });
expect(button).not.toHaveAttribute('aria-describedby');
});

it('should not wrap Button', async () => {
const { tooltip, anchorWrapper } = await setup({
label: 'Tooltip label',
Expand All @@ -96,35 +76,6 @@ describe(`<${Tooltip.displayName}>`, () => {
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
});

it('should not add aria-describedby if button label is the same as tooltip label', async () => {
const label = 'Tooltip label';
render(<IconButton label={label} tooltipProps={{ forceOpen: true }} />);
const tooltip = screen.queryByRole('tooltip', { name: label });
expect(tooltip).toBeInTheDocument();
const button = screen.queryByRole('button', { name: label });
expect(button).not.toHaveAttribute('aria-describedby');
});

it('should keep anchor aria-describedby if button label is the same as tooltip label', async () => {
const label = 'Tooltip label';
render(<IconButton label={label} aria-describedby=":header-1:" tooltipProps={{ forceOpen: true }} />);
const tooltip = screen.queryByRole('tooltip', { name: label });
expect(tooltip).toBeInTheDocument();
const button = screen.queryByRole('button', { name: label });
expect(button).toHaveAttribute('aria-describedby', ':header-1:');
});

it('should concat aria-describedby if already exists', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
children: <Button aria-describedby=":header-1:">Anchor</Button>,
forceOpen: true,
});
expect(tooltip).toBeInTheDocument();
const button = screen.queryByRole('button', { name: 'Anchor' });
expect(button).toHaveAttribute('aria-describedby', `:header-1: ${tooltip?.id}`);
});

it('should wrap disabled Button', async () => {
const { tooltip, anchorWrapper } = await setup({
label: 'Tooltip label',
Expand Down Expand Up @@ -172,17 +123,166 @@ describe(`<${Tooltip.displayName}>`, () => {
expect(ref.current === element).toBe(true);
});

it('should render in closeMode=hide', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
children: <Button>Anchor</Button>,
closeMode: 'hide',
forceOpen: false,
describe('closeMode="hide"', () => {
it('should not render with empty label', async () => {
const { tooltip, anchorWrapper } = await setup({
label: undefined,
forceOpen: true,
closeMode: 'hide',
});
expect(tooltip).not.toBeInTheDocument();
expect(anchorWrapper).not.toBeInTheDocument();
});

it('should render hidden', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
children: <Button>Anchor</Button>,
closeMode: 'hide',
forceOpen: false,
});
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveClass('lumx-tooltip--is-hidden');

const anchor = screen.getByRole('button', { name: 'Anchor' });
await userEvent.hover(anchor);
expect(tooltip).not.toHaveClass('lumx-tooltip--is-hidden');
});
});

describe('ariaLinkMode="aria-describedby"', () => {
it('should add aria-describedby on anchor on open', async () => {
await setup({
label: 'Tooltip label',
forceOpen: false,
children: <Button aria-describedby=":description1:">Anchor</Button>,
});
const anchor = screen.getByRole('button', { name: 'Anchor' });
expect(anchor).toHaveAttribute('aria-describedby', ':description1:');

await userEvent.hover(anchor);
const tooltip = screen.queryByRole('tooltip');
expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip?.id}`);
});

it('should always add aria-describedby on anchor with closeMode="hide"', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
forceOpen: false,
children: <Button aria-describedby=":description1:">Anchor</Button>,
closeMode: 'hide',
});
const anchor = screen.getByRole('button', { name: 'Anchor' });
expect(anchor).toHaveAttribute('aria-describedby', `:description1: ${tooltip?.id}`);
});

it('should skip aria-describedby if anchor has label', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
forceOpen: true,
children: (
<Button aria-describedby=":description1:" aria-label="Tooltip label">
Anchor
</Button>
),
});
expect(tooltip).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', `:description1:`);
});

it('should add aria-describedby on anchor wrapper on open', async () => {
const { anchorWrapper } = await setup({
label: 'Tooltip label',
forceOpen: false,
children: 'Anchor',
});
expect(anchorWrapper).not.toHaveAttribute('aria-describedby');

await userEvent.hover(anchorWrapper as any);
const tooltip = screen.queryByRole('tooltip');
expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip?.id);
});

it('should always add aria-describedby on anchor wrapper with closeMode="hide"', async () => {
const { tooltip, anchorWrapper } = await setup({
label: 'Tooltip label',
forceOpen: false,
children: 'Anchor',
closeMode: 'hide',
});
expect(anchorWrapper).toHaveAttribute('aria-describedby', `${tooltip?.id}`);
});
});

describe('ariaLinkMode="aria-labelledby"', () => {
it('should add aria-labelledby on anchor on open', async () => {
await setup({
label: 'Tooltip label',
forceOpen: false,
children: <Button aria-labelledby=":label1:">Anchor</Button>,
ariaLinkMode: 'aria-labelledby',
});
const anchor = screen.getByRole('button', { name: 'Anchor' });
expect(anchor).toHaveAttribute('aria-labelledby', ':label1:');

await userEvent.hover(anchor);
const tooltip = screen.queryByRole('tooltip');
expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip?.id}`);
});

it('should always add aria-labelledby on anchor with closeMode="hide"', async () => {
const label = 'Tooltip label';
const { tooltip } = await setup({
label,
forceOpen: false,
children: <Button aria-labelledby=":label1:">Anchor</Button>,
ariaLinkMode: 'aria-labelledby',
closeMode: 'hide',
});
const anchor = screen.queryByRole('button', { name: label });
expect(anchor).toBeInTheDocument();
expect(anchor).toHaveAttribute('aria-labelledby', `:label1: ${tooltip?.id}`);
});

it('should skip aria-labelledby if anchor has label', async () => {
const { tooltip } = await setup({
label: 'Tooltip label',
forceOpen: true,
children: (
<Button aria-labelledby=":label1:" aria-label="Tooltip label">
Anchor
</Button>
),
ariaLinkMode: 'aria-labelledby',
});
expect(tooltip).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-labelledby', `:label1:`);
});

it('should add aria-labelledby on anchor wrapper on open', async () => {
const { anchorWrapper } = await setup({
label: 'Tooltip label',
forceOpen: false,
children: 'Anchor',
ariaLinkMode: 'aria-labelledby',
});
expect(anchorWrapper).not.toHaveAttribute('aria-labelledby');

await userEvent.hover(anchorWrapper as any);
const tooltip = screen.queryByRole('tooltip');
expect(anchorWrapper).toHaveAttribute('aria-labelledby', tooltip?.id);
});

it('should always add aria-labelledby on anchor wrapper with closeMode="hide"', async () => {
const { tooltip, anchorWrapper } = await setup({
label: 'Tooltip label',
forceOpen: false,
children: 'Anchor',
ariaLinkMode: 'aria-labelledby',
closeMode: 'hide',
});
expect(anchorWrapper).toHaveAttribute('aria-labelledby', `${tooltip?.id}`);
});
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveClass('lumx-tooltip--is-hidden');
const button = screen.queryByRole('button', { name: 'Anchor' });
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
});
});

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

// Un-hover anchor button
await userEvent.unhover(button);
Expand Down
20 changes: 16 additions & 4 deletions packages/lumx-react/src/components/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
import { Placement } from '@lumx/react/components/popover';
import { TooltipContextProvider } from '@lumx/react/components/tooltip/context';
import { useId } from '@lumx/react/hooks/useId';
import { usePopper } from '@lumx/react/hooks/usePopper';

import { ARIA_LINK_MODES } from '@lumx/react/components/tooltip/constants';
import { useInjectTooltipRef } from './useInjectTooltipRef';
import { useTooltipOpen } from './useTooltipOpen';
import { usePopper } from '@lumx/react/hooks/usePopper';

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

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

/**
Expand All @@ -66,7 +70,8 @@ const ARROW_SIZE = 8;
* @return React element.
*/
export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, ref) => {
const { label, children, className, delay, placement, forceOpen, closeMode, ...forwardedProps } = props;
const { label, children, className, delay, placement, forceOpen, closeMode, ariaLinkMode, ...forwardedProps } =
props;
// Disable in SSR.
if (!DOCUMENT) {
return <>{children}</>;
Expand All @@ -89,8 +94,15 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
const position = attributes?.popper?.['data-popper-placement'] ?? placement;
const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
const isOpen = (isActivated || forceOpen) && !!label;
const isMounted = isOpen || closeMode === 'hide';
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isMounted, id, label);
const isMounted = !!label && (isOpen || closeMode === 'hide');
const wrappedChildren = useInjectTooltipRef({
children,
setAnchorElement,
isMounted,
id,
label,
ariaLinkMode: ariaLinkMode as any,
});

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

Expand Down
1 change: 1 addition & 0 deletions packages/lumx-react/src/components/tooltip/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ARIA_LINK_MODES = ['aria-describedby', 'aria-labelledby'] as const;
Loading

0 comments on commit 4a60549

Please sign in to comment.