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 {