diff --git a/packages/eui/changelogs/upcoming/9140.md b/packages/eui/changelogs/upcoming/9140.md new file mode 100644 index 00000000000..bbfbec90259 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9140.md @@ -0,0 +1,4 @@ +**Accessibility** + +- Improved the accessibility experience of `EuiBasicTable` and `EuiInMemoryTable` by ensuring tooltips displayed for action buttons are visual-only whenever the `action.name` and `action.description` are exactly the same + diff --git a/packages/eui/src/components/basic_table/collapsed_item_actions.test.tsx b/packages/eui/src/components/basic_table/collapsed_item_actions.test.tsx index b1b93636e39..7d95c6d6b4c 100644 --- a/packages/eui/src/components/basic_table/collapsed_item_actions.test.tsx +++ b/packages/eui/src/components/basic_table/collapsed_item_actions.test.tsx @@ -13,6 +13,7 @@ import { waitForEuiPopoverOpen, waitForEuiPopoverClose, waitForEuiToolTipVisible, + waitForEuiToolTipHidden, } from '../../test/rtl'; import { CollapsedItemActions } from './collapsed_item_actions'; @@ -156,4 +157,54 @@ describe('CollapsedItemActions', () => { fireEvent.click(getByTestSubject('customAction')); await waitForEuiPopoverClose(); }); + + // If `name` and `description` are exactly the same + // we don't want screen readers announcing the same text twice + test('tooltip screen-reader output', async () => { + const props = { + actions: [ + { + name: 'same', + description: 'different', + onClick: () => {}, + 'data-test-subj': 'different', + }, + { + name: 'same', + description: 'same', + onClick: () => {}, + 'data-test-subj': 'same', + }, + ], + itemId: 'id', + item: { id: 'abc' }, + actionsDisabled: false, + displayedRowIndex: 0, + }; + + const { getByTestSubject, getByRole } = render( + + ); + fireEvent.click(getByTestSubject('euiCollapsedItemActionsButton')); + await waitForEuiPopoverOpen(); + + const actionDifferent = getByTestSubject('different'); + fireEvent.mouseOver(actionDifferent); + await waitForEuiToolTipVisible(); + const tooltipDifferent = getByRole('tooltip'); + expect(tooltipDifferent).toHaveTextContent('different'); + expect(actionDifferent).toHaveAttribute('aria-describedby'); + expect(actionDifferent.getAttribute('aria-describedby')).toEqual( + tooltipDifferent.id + ); + fireEvent.mouseOut(actionDifferent); + await waitForEuiToolTipHidden(); + + const actionSame = getByTestSubject('same'); + fireEvent.mouseOver(actionSame); + await waitForEuiToolTipVisible(); + const tooltipSame = getByRole('tooltip'); + expect(tooltipSame).toHaveTextContent('same'); + expect(actionSame).not.toHaveAttribute('aria-describedby'); + }); }); diff --git a/packages/eui/src/components/basic_table/collapsed_item_actions.tsx b/packages/eui/src/components/basic_table/collapsed_item_actions.tsx index 1a86031b680..9dbc6f8ff2b 100644 --- a/packages/eui/src/components/basic_table/collapsed_item_actions.tsx +++ b/packages/eui/src/components/basic_table/collapsed_item_actions.tsx @@ -110,7 +110,13 @@ export const CollapsedItemActions = ({ if (!event.isPropagationStopped()) closePopover(); }} toolTipContent={toolTipContent} - toolTipProps={{ delay: 'long' }} + toolTipProps={{ + delay: 'long', + // Avoid screen-readers announcing the same text twice + disableScreenReaderOutput: + typeof buttonContent === 'string' && + buttonContent === toolTipContent, + }} > {buttonContent} diff --git a/packages/eui/src/components/basic_table/default_item_action.test.tsx b/packages/eui/src/components/basic_table/default_item_action.test.tsx index 6514004e1b3..9ad93e49ef3 100644 --- a/packages/eui/src/components/basic_table/default_item_action.test.tsx +++ b/packages/eui/src/components/basic_table/default_item_action.test.tsx @@ -123,6 +123,59 @@ describe('DefaultItemAction', () => { expect(getByText('goodbye tooltip')).toBeInTheDocument(); }); + it('is described by the tooltip via aria-describedby', async () => { + const actionWithDifferentNameAndDescription: EmptyButtonAction = { + name: 'same', + description: 'different', + 'data-test-subj': 'different', + type: 'button', + onClick: () => {}, + }; + const { getByTestSubject, getByRole } = render( + + ); + + const action = getByTestSubject('different'); + fireEvent.mouseOver(action); + await waitForEuiToolTipVisible(); + const tooltip = getByRole('tooltip'); + expect(tooltip).toHaveTextContent('different'); + expect(tooltip).toBeInTheDocument(); + expect(action).toHaveAttribute('aria-describedby'); + expect(action.getAttribute('aria-describedby')).toEqual(tooltip.id); + }); + + // If `name` and `description` are exactly the same + // we don't want screen readers announcing the same text twice + it('has visual-only tooltip when `name` equals `description`', async () => { + const actionWithEqualNameAndDescription: EmptyButtonAction = { + name: 'same', + description: 'same', + 'data-test-subj': 'same', + type: 'button', + onClick: () => {}, + }; + const { getByTestSubject, getByRole } = render( + + ); + + const action = getByTestSubject('same'); + fireEvent.mouseOver(action); + await waitForEuiToolTipVisible(); + const tooltip = getByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('same'); + expect(action).not.toHaveAttribute('aria-describedby'); + }); + it('passes back the original click event as well as the row item to onClick', () => { const onClick = jest.fn((item, event) => { event.preventDefault(); diff --git a/packages/eui/src/components/basic_table/default_item_action.tsx b/packages/eui/src/components/basic_table/default_item_action.tsx index e026adf6ceb..4b032dcef6e 100644 --- a/packages/eui/src/components/basic_table/default_item_action.tsx +++ b/packages/eui/src/components/basic_table/default_item_action.tsx @@ -14,7 +14,7 @@ import { EuiButtonEmptyProps, EuiButtonIconProps, } from '../button'; -import { EuiToolTip } from '../tool_tip'; +import { EuiToolTip, EuiToolTipProps } from '../tool_tip'; import { useGeneratedHtmlId } from '../../services/accessibility'; import { EuiScreenReaderOnly } from '../accessibility'; @@ -58,6 +58,13 @@ export const DefaultItemAction = ({ : undefined; const actionContent = callWithItemIfFunction(item)(action.name); const tooltipContent = callWithItemIfFunction(item)(action.description); + const tooltipProps: Omit = { + content: tooltipContent, + delay: 'long', + // Avoid screen-readers announcing the same text twice + disableScreenReaderOutput: + typeof actionContent === 'string' && actionContent === tooltipContent, + }; const href = callWithItemIfFunction(item)(action.href); const dataTestSubj = callWithItemIfFunction(item)(action['data-test-subj']); @@ -114,9 +121,7 @@ export const DefaultItemAction = ({ return enabled ? ( <> - - {button} - + {button} {/* SR text has to be rendered outside the tooltip, otherwise EuiToolTip's own aria-labelledby won't properly clone */} {ariaLabelledBy} diff --git a/packages/website/docs/components/tables/basic.mdx b/packages/website/docs/components/tables/basic.mdx index db71030430b..adc4737c137 100644 --- a/packages/website/docs/components/tables/basic.mdx +++ b/packages/website/docs/components/tables/basic.mdx @@ -170,6 +170,21 @@ Actions follow these UI/UX rules: - Actions become semi-transparent when you hover over the row. With more than 2 actions, only the ellipsis button stays visible at all times. - When one or more rows are selected, all individual actions are disabled. Users should use bulk actions outside the table instead. +### Accessibility + +`action.name` and `action.description` should be different. The name is a short identifier, while the description should explain the purpose of the action or provide hints. + +```jsx +const action = { + name: 'User profile', + description: + ({ firstName, lastName }) => `Visit ${firstName} ${lastName}'s profile`, + // ... +}; +``` + +If a meaningful description for an action is not possible, setting the exact same value for `action.name` and `action.description` as a `string` will ensure the button's tooltip is visual-only and the text is not announced to screen-readers twice. + ```tsx interactive import React, { useState, useMemo } from 'react'; import {