Skip to content

Commit 3a3a4fd

Browse files
authored
fix: tooltip and icon-button respect child aria attributes (#19308)
* fix: tooltip and icon-button respect child aria attributes * test: add aria accessibility tests for Tooltip * refactor: apply review suggestion to simplify aria logic * fix(tooltip): ignore empty or whitespace aria-labels for a11y support
1 parent 577e609 commit 3a3a4fd

File tree

3 files changed

+92
-7
lines changed

3 files changed

+92
-7
lines changed

packages/react/src/components/IconButton/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ const IconButton = React.forwardRef(function IconButton(
220220
isSelected={isSelected}
221221
hasIconOnly
222222
className={className}
223-
aria-describedby={badgeCount && badgeId}>
223+
aria-describedby={rest['aria-describedby'] || (badgeCount && badgeId)}>
224224
{children}
225225
{!disabled && badgeCount !== undefined && (
226226
<BadgeIndicator
@@ -350,8 +350,13 @@ IconButton.propTypes = {
350350
/**
351351
* Provide the label to be rendered inside of the Tooltip. The label will use
352352
* `aria-labelledby` and will fully describe the child node that is provided.
353+
* If the child node already has an `aria-label`, the tooltip will not apply
354+
* `aria-labelledby`. If the child node has `aria-labelledby`, that value will
355+
* be used instead. Otherwise, the tooltip will use its own ID as the label.
353356
* This means that if you have text in the child node it will not be
354357
* announced to the screen reader.
358+
* If using `badgeCount={0}`, make sure the label explains that there is a
359+
* new notification.
355360
*/
356361
label: PropTypes.node.isRequired,
357362

packages/react/src/components/Tooltip/Tooltip.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ interface TooltipBaseProps {
8181
/**
8282
* Provide the label to be rendered inside of the Tooltip. The label will use
8383
* `aria-labelledby` and will fully describe the child node that is provided.
84+
* If the child already has an `aria-label`, the tooltip will not apply
85+
* `aria-labelledby`. If the child has its own `aria-labelledby`, that value
86+
* will be kept. Otherwise, the tooltip will use its own ID to label the child.
8487
* This means that if you have text in the child node, that it will not be
8588
* announced to the screen reader.
8689
*
@@ -130,6 +133,30 @@ const Tooltip: TooltipComponent = React.forwardRef(
130133
const prefix = usePrefix();
131134
const child = React.Children.only(children);
132135

136+
const {
137+
'aria-label': ariaLabel,
138+
'aria-labelledby': ariaLabelledBy,
139+
'aria-describedby': ariaDescribedBy,
140+
} = child?.props ?? {};
141+
142+
const hasLabel = !!label;
143+
const hasAriaLabel =
144+
typeof ariaLabel === 'string' ? ariaLabel.trim() !== '' : false;
145+
146+
// An `aria-label` takes precedence over `aria-describedby`, but when it's
147+
// needed and the user doesn't specify one, the fallback `id` is used.
148+
const labelledBy = hasAriaLabel
149+
? null
150+
: hasLabel
151+
? (ariaLabelledBy ?? id)
152+
: undefined;
153+
154+
// If `aria-label` is present, use any provided `aria-describedby`.
155+
// If not, fallback to child's `aria-describedby` or the tooltip `id` if needed.
156+
const describedBy = hasAriaLabel
157+
? ariaDescribedBy
158+
: (ariaDescribedBy ?? (!hasLabel && !ariaLabelledBy ? id : undefined));
159+
133160
const triggerProps = {
134161
onFocus: () => !focusByMouse && setOpen(true),
135162
onBlur: () => {
@@ -143,6 +170,8 @@ const Tooltip: TooltipComponent = React.forwardRef(
143170
onMouseDown,
144171
onMouseMove: onMouseMove,
145172
onTouchStart: onDragStart,
173+
'aria-labelledby': labelledBy,
174+
'aria-describedby': describedBy,
146175
};
147176

148177
function getChildEventHandlers(childProps: any) {
@@ -161,12 +190,6 @@ const Tooltip: TooltipComponent = React.forwardRef(
161190
return eventHandlers;
162191
}
163192

164-
if (label) {
165-
triggerProps['aria-labelledby'] = id;
166-
} else {
167-
triggerProps['aria-describedby'] = id;
168-
}
169-
170193
const onKeyDown = useCallback(
171194
(event: KeyboardEvent) => {
172195
if (open && match(event, keys.Escape)) {

packages/react/src/components/Tooltip/__tests__/Tooltip-test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,60 @@ describe('Tooltip', () => {
110110
expect(popoverContainer).not.toHaveClass('cds--popover--open');
111111
});
112112
});
113+
114+
describe('Tooltip ARIA logic', () => {
115+
it('should not use aria-labelledby when the button already has aria-label', () => {
116+
render(
117+
<Tooltip defaultOpen label="Label text">
118+
<button aria-label="Aria Label">X</button>
119+
</Tooltip>
120+
);
121+
const button = screen.getByRole('button');
122+
expect(button).not.toHaveAttribute('aria-labelledby');
123+
expect(button).toHaveAttribute('aria-label', 'Aria Label');
124+
});
125+
126+
it('should keep the button’s aria-labelledby if it already has one', () => {
127+
render(
128+
<Tooltip defaultOpen label="Label text">
129+
<button aria-labelledby="custom-id">X</button>
130+
</Tooltip>
131+
);
132+
const button = screen.getByRole('button');
133+
expect(button).toHaveAttribute('aria-labelledby', 'custom-id');
134+
});
135+
136+
it('should apply tooltip ID to aria-labelledby if label is given and the button doesn’t have its own', () => {
137+
render(
138+
<Tooltip defaultOpen label="Label text">
139+
<button>X</button>
140+
</Tooltip>
141+
);
142+
const button = screen.getByRole('button');
143+
const tooltip = screen.getByRole('tooltip');
144+
expect(button).toHaveAttribute('aria-labelledby', tooltip.id);
145+
});
146+
147+
it('should keep aria-describedby from the button if aria-label is also set', () => {
148+
render(
149+
<Tooltip defaultOpen description="Some description">
150+
<button aria-label="Label" aria-describedby="desc-id">
151+
X
152+
</button>
153+
</Tooltip>
154+
);
155+
const button = screen.getByRole('button');
156+
expect(button).toHaveAttribute('aria-describedby', 'desc-id');
157+
});
158+
159+
it('should use its own ID for aria-describedby if only description is given', () => {
160+
render(
161+
<Tooltip defaultOpen description="Some description">
162+
<button>X</button>
163+
</Tooltip>
164+
);
165+
const button = screen.getByRole('button');
166+
const tooltip = screen.getByRole('tooltip');
167+
expect(button).toHaveAttribute('aria-describedby', tooltip.id);
168+
});
169+
});

0 commit comments

Comments
 (0)