Skip to content

Commit

Permalink
feat: Add new description variant to Tooltip and deprecate `describ…
Browse files Browse the repository at this point in the history
…e` variant for better accessibility. (#2814)

Fixes: #2797

[category:Components]

Release Note:
- We've added a new `description` variant and **deprecated** the `describe` variant.

The `describe` variant of the tool tip relies on `aria-describedby` attribute to assign the tool tip text to the accessible description of the target element. This only works when the tool tip is visible and present in the DOM, which only occurs when the element has focus. Screen readers can access web page elements without necessarily using keyboard focus, therefore, this variant didn't appear to always work reliably for screen reader users. 

The preferred type to use is `description` to provide better accessible support.

Co-authored-by: manuel.carrera <[email protected]>
Co-authored-by: @mannycarrera4 <[email protected]>
  • Loading branch information
3 people authored Feb 4, 2025
1 parent 36cb5e3 commit d2351f7
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 13 deletions.
39 changes: 38 additions & 1 deletion cypress/component/Tooltip.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import {Default} from '../../modules/react/tooltip/stories/examples/Default';
import {DescribeType} from '../../modules/react/tooltip/stories/examples/DescribeType';
import {DescriptionType} from '../../modules/react/tooltip/stories/examples/DescriptionType';
import {Muted} from '../../modules/react/tooltip/stories/examples/Muted';
import {Ellipsis} from '../../modules/react/tooltip/stories/examples/Ellipsis';
import {NonInteractive, Overflow} from '../../modules/react/tooltip/stories/testing.stories';
Expand Down Expand Up @@ -121,7 +122,7 @@ describe('Tooltip', () => {
cy.checkA11y();
});

it('the "Delete" button should not have an aria-describedby', () => {
it('the "Delete" button should not have an aria-describedby before hovering', () => {
cy.findByRole('button', {name: 'Delete'}).should('not.have.attr', 'aria-describedby');
});

Expand All @@ -147,6 +148,42 @@ describe('Tooltip', () => {
});
});

context('given the DescriptionType example is rendered', () => {
beforeEach(() => {
cy.mount(<DescriptionType />);
});

it('should not have any axe-core errors', () => {
cy.checkA11y();
});

it('the "Delete" button should have an aria-description before hovering', () => {
cy.findByRole('button', {name: 'Delete'}).should('have.attr', 'aria-description');
});

context('when the "Delete" button is hovered', () => {
beforeEach(() => {
cy.findByRole('button', {name: 'Delete'}).trigger('mouseover');
});

it('should show the tooltip', () => {
cy.findByRole('tooltip').should('be.visible');
});

it('should not have any axe-core errors', () => {
cy.checkA11y();
});

it('the "Delete" button should have an accessible description equal to the tooltip text', () => {
cy.findByRole('button', {name: 'Delete'}).should(
'have.attr',
'aria-description',
'The service will restart after this action'
);
});
});
});

context('given the [Components/Popups/Tooltip, Muted] example is rendered', () => {
beforeEach(() => {
cy.mount(<Muted />);
Expand Down
16 changes: 16 additions & 0 deletions modules/react/_examples/stories/Tooltips.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Tooltip} from '@workday/canvas-kit-react/tooltip';
import {ListOfUploadedFiles} from './examples/Tooltips/ListOfUploadedFiles';

<Meta title="Examples/Tooltips" component={Tooltip} />

# Accessible Tooltip Examples

## Using descriptive tooltips for repeated text buttons

In this example, the "Delete" buttons are used repeatedly to reference the multiple files that have
been uploaded to the web app. The text buttons already have an accessible name (a.k.a. label)
derived from the button's inner text. The `describe` tooltip can be useful for providing more
in-context description for both low vision sighted users and screen reader users without overriding
the button name "Delete".

<ExampleCodeBlock code={ListOfUploadedFiles} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

import {DeleteButton} from '@workday/canvas-kit-react/button';
import {Flex} from '@workday/canvas-kit-react/layout';
import {Heading, Text} from '@workday/canvas-kit-react/text';
import {Tooltip} from '@workday/canvas-kit-react/tooltip';
import {trashIcon} from '@workday/canvas-system-icons-web';

const files = ['Cover Letter.docx', 'Resume.docx', 'Portfolio.pptx', 'Portrait.png'];

const listStyles = {
alignItems: 'center',
width: '35rem',
};

const deleteBtnStyle = {
marginLeft: 'auto',
};

export const ListOfUploadedFiles = () => {
return (
<>
<Heading size="medium">Uploaded Files:</Heading>
<Flex as="ul" gap="1rem" flexDirection="column">
{files.map(i => (
<Flex as="li" style={listStyles}>
<Text>{i}</Text>
<Tooltip type="description" title={i}>
<DeleteButton icon={trashIcon} style={deleteBtnStyle}>
Delete
</DeleteButton>
</Tooltip>
</Flex>
))}
</Flex>
</>
);
};
15 changes: 13 additions & 2 deletions modules/react/tooltip/lib/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
/**
* This should be a string in most cases. HTML is supported, but only text is understood
* by assistive technology. This is true for both `label` and `describe` modes.
*
* **Note:** If you use `description` type and want to pass `jsx`, it **must* be inline and **not** a component to ensure the inner text is properly translated.
*
* ```jsx
* // The text will be understood as: You must accept terms and conditions
* <Tooltip type="description" title={<span>You<i>must</i> accept terms and conditions</span>}/>
*
* // This will render a string including the html and will not be properly understood by voice over.
* const MyComponent = () => <span>You<i>must</i> accept terms and conditions</span>
* <Tooltip type="description" title={MyComponent/>
* ```
*/
title: React.ReactNode;
/**
Expand Down Expand Up @@ -45,7 +56,7 @@ export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
* - `label`: Sets the accessible name for the wrapped element. Use for icons or if tooltip
* `title` prop is the same as the text content of the wrapped element. E.g. TertiaryButtons that render an icon or
* Ellipsis tooltips.
* - `describe`: Sets `aria-describedby` of the wrapped element. Use if the tooltip has additional
* - **Deprecated: `describe` is deprecated, please use `description`**.`describe`: Sets `aria-describedby` of the wrapped element. Use if the tooltip has additional
* information about the target.
* - `muted`: No effort is made to make the tooltip accessible to screen readers. Use if the
* tooltip contents are not useful to a screen reader or if you have handled accessibility of
Expand All @@ -55,7 +66,7 @@ export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
* Consider an alternate way to inform a user of additional important information.
* @default 'label'
*/
type?: 'label' | 'describe' | 'muted';
type?: 'label' | 'describe' | 'muted' | 'description';
/**
* Amount of time (in ms) to delay before showing the tooltip
*/
Expand Down
7 changes: 4 additions & 3 deletions modules/react/tooltip/lib/useTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,17 @@ export function useTooltip<T extends Element = Element>({
* - `label`: Sets the accessible name for the wrapped element. Use for icons or if tooltip
* `title` prop is the same as the text content of the wrapped element. E.g. TertiaryButton that renders an icon or
* Ellipsis tooltips.
* - `describe`: Sets `aria-describedby` of the wrapped element. Use if the tooltip has additional
* information about the target.
* - **Deprecated: `describe` is deprecated, please use `description`.**`describe`: Sets `aria-describedby` of the wrapped element. Use if the tooltip has additional information about the target
* - `muted`: No effort is made to make the tooltip accessible to screen readers. Use if the
* tooltip contents are not useful to a screen reader or if you have handled accessibility of
* the tooltip yourself.
* - `description`: Sets `aria-description` strings for the wrapped element. Use if the tooltip has additional about the target
*
* **Note**: Assistive technology may ignore `describe` techniques based on verbosity settings.
* Consider an alternate way to inform a user of additional important information.
* @default 'label'
*/
type?: 'label' | 'describe' | 'muted';
type?: 'label' | 'describe' | 'muted' | 'description';
/**
* The content of the `aria-label` if `type` is `label.
*/
Expand Down Expand Up @@ -136,6 +136,7 @@ export function useTooltip<T extends Element = Element>({
const targetProps = {
// extra description of the target element for assistive technology
'aria-describedby': type === 'describe' && visible ? id : undefined,
'aria-description': type === 'description' ? titleText : undefined,
// This will replace the accessible name of the target element
'aria-label': type === 'label' ? titleText : undefined,
onMouseEnter: onOpenFromTarget,
Expand Down
5 changes: 2 additions & 3 deletions modules/react/tooltip/spec/Tooltip.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ describe('Tooltip', () => {
fireEvent.mouseEnter(screen.getByText('Test Text')); // triggers the tooltip

jest.advanceTimersByTime(300); // advance the timer by the amount of delay time
expect(screen.getByText('Test Text')).toHaveAttribute('aria-describedby');
expect(screen.getByText('Test Text')).toHaveAttribute('aria-describedby', 'a1');

const id = screen.getByText('Test Text').getAttribute('aria-describedby');
expect(screen.getByRole('tooltip')).toHaveAttribute('id', id);
expect(screen.getByRole('tooltip')).toHaveAttribute('id', 'a1');
});
jest.clearAllTimers();
});
Expand Down
21 changes: 17 additions & 4 deletions modules/react/tooltip/spec/useTooltip.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {render, fireEvent, screen} from '@testing-library/react';

import {useTooltip, TooltipContainer} from '..';

const TooltipWithHook = ({type}: {type: 'label' | 'describe'}) => {
const TooltipWithHook = ({type}: {type: 'label' | 'describe' | 'description'}) => {
const {targetProps, tooltipProps} = useTooltip({type, titleText: 'Hover'});

return (
Expand Down Expand Up @@ -40,13 +40,26 @@ describe('useTooltip with type="describe"', () => {
render(<TooltipWithHook type="describe" />);

const target = screen.getByText('Hover');
const tooltip = screen.getByRole('tooltip');

fireEvent.mouseOver(target); // assign the ID to the tooltip
jest.advanceTimersByTime(300); // advance the timer by the amount of delay time

expect(tooltip).toHaveAttribute('id');
const id = tooltip.getAttribute('id');
expect(screen.getByText('Hover')).toHaveAttribute('aria-describedby', 'originalDescribedById');
});
jest.clearAllTimers();
});

describe('useTooltip with type="description"', () => {
jest.useFakeTimers();
it('should add aria attributes to correlate the target and the tooltip', () => {
render(<TooltipWithHook type="description" />);

const target = screen.getByText('Hover');

fireEvent.mouseOver(target); // assign the ID to the tooltip
jest.advanceTimersByTime(300); // advance the timer by the amount of delay time

expect(screen.getByText('Hover')).toHaveAttribute('aria-description', 'Hover');
});
jest.clearAllTimers();
});
30 changes: 30 additions & 0 deletions modules/react/tooltip/stories/Tooltip.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {ExampleCodeBlock, SymbolDoc, Specifications} from '@workday/canvas-kit-docs';
import {InformationHighlight} from '@workday/canvas-kit-preview-react/information-highlight'
import {StatusIndicator} from '@workday/canvas-kit-preview-react/status-indicator'
import * as TooltipStories from './Tooltip.stories';
import {Default} from './examples/Default';
import {CustomContent} from './examples/CustomContent';
import {DelayedTooltip} from './examples/DelayedTooltip';
import {DescriptionType} from './examples/DescriptionType';
import {DescribeType} from './examples/DescribeType';
import {Muted} from './examples/Muted';
import {Placements} from './examples/Placements';
Expand Down Expand Up @@ -65,13 +68,40 @@ and focus events.

### Describing an Element

<InformationHighlight variant={'caution'} className='sb-unstyled'>
<InformationHighlight.Icon />
<InformationHighlight.Heading> Caution: Describe type has been deprecated </InformationHighlight.Heading>
<InformationHighlight.Body>
Assistive technology may ignore <StatusIndicator variant='gray'><StatusIndicator.Label cs={{textTransform: 'lowercase'}}>type="describe"</StatusIndicator.Label></StatusIndicator> techniques based on verbosity settings. Please use <StatusIndicator cs={{textTransform: 'lowercase'}} variant='gray'><StatusIndicator.Label cs={{textTransform: 'lowercase'}}>type="description"</StatusIndicator.Label></StatusIndicator> on Tooltips.
</InformationHighlight.Body>
</InformationHighlight>

The default mode for a tooltip is to label content via `aria-label`. If a tooltip is meant to
provide ancillary information, the `type` can be set to `describe`. This will add `aria-describedby`
to the target element. This will allow screen reader users to hear the name of the control that is
being focused and the ancillary tooltip information.

<ExampleCodeBlock code={DescribeType} />

### Description of an Element

The default mode for a tooltip is to assign a name to the target element with an `aria-label`
string. If a tooltip is meant to provide ancillary information, the `type` can be set to `description`.
This will add `aria-description` strings to the target element instead. This variant is useful on
text buttons and other components that already have a label or name. Use this type instead of `describe` to ensure proper aria attributes are added to the dom regardless if the tooltip is visible.

> **Note:** If you use `description` type and want to pass `jsx`, it **must* be inline and **not** a component to ensure the inner text is properly read by voiceover.
>
> ```jsx
> // The text will be understood as: You must accept terms and conditions
> <Tooltip type="description" title={<span>You<i>must</i> accept terms and conditions</span>}/>
>
> // This will render a string including the html and will not be properly understood by voice over.
> const MyComponent = () => <span>You<i>must</i> accept terms and conditions</span>
> <Tool
<ExampleCodeBlock code={DescriptionType} />
### Muted Tooltips
If a tooltip does not need to be visible to screen reader users, or you handle accessibility of the
Expand Down
4 changes: 4 additions & 0 deletions modules/react/tooltip/stories/Tooltip.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {Tooltip} from '@workday/canvas-kit-react/tooltip';
import {Default as DefaultExample} from './examples/Default';
import {CustomContent as CustomContentExample} from './examples/CustomContent';
import {DelayedTooltip as DelayedTooltipExample} from './examples/DelayedTooltip';
import {DescriptionType as DescriptionTypeExample} from './examples/DescriptionType';
import {DescribeType as DescribeTypeExample} from './examples/DescribeType';
import {Muted as MutedExample} from './examples/Muted';
import {Placements as PlacementsExample} from './examples/Placements';
Expand Down Expand Up @@ -36,6 +37,9 @@ export const CustomContent: Story = {
export const DelayedTooltip: Story = {
render: DelayedTooltipExample,
};
export const DescriptionType: Story = {
render: DescriptionTypeExample,
};
export const DescribeType: Story = {
render: DescribeTypeExample,
};
Expand Down
22 changes: 22 additions & 0 deletions modules/react/tooltip/stories/examples/DescriptionType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';

import {DeleteButton, SecondaryButton, TertiaryButton} from '@workday/canvas-kit-react/button';
import {Tooltip} from '@workday/canvas-kit-react/tooltip';
import {Flex} from '@workday/canvas-kit-react/layout';
import {chartConfigIcon} from '@workday/canvas-system-icons-web';

export const DescriptionType = () => {
return (
<Flex gap="s">
<Tooltip type="description" title="Search using additional criteria">
<TertiaryButton icon={chartConfigIcon}>Advanced Search</TertiaryButton>
</Tooltip>
<Tooltip type="description" title="Create saved search">
<SecondaryButton>Save</SecondaryButton>
</Tooltip>
<Tooltip type="description" title="The service will restart after this action">
<DeleteButton>Delete</DeleteButton>
</Tooltip>
</Flex>
);
};

0 comments on commit d2351f7

Please sign in to comment.