Skip to content

Commit 759180e

Browse files
authored
feat(a11y): add screen reader support for Tooltip (#490)
* feat(a11y): add screen reader support for Tooltip * fix: lint fix * chore: remove unnecessary jest config file * fix: ensure getTextContent returns an empty string for invalid nodes in Popup component * chore: clean code * chore: clean code * chore: clean code * refactor(Tooltip): simplify ID handling by merging useId with props * chore: adjust logic * fix: lint fix * chore: revert some changes * fix(Tooltip): handle invalid children by wrapping them in a span element * chore: clean code * chore: clean code * fix: lint fic * fix: lint revert * test: add test case
1 parent ea549a3 commit 759180e

File tree

3 files changed

+80
-6
lines changed

3 files changed

+80
-6
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"dependencies": {
4444
"@babel/runtime": "^7.11.2",
4545
"@rc-component/trigger": "^2.0.0",
46-
"classnames": "^2.3.1"
46+
"classnames": "^2.3.1",
47+
"rc-util": "^5.44.3"
4748
},
4849
"devDependencies": {
4950
"@rc-component/father-plugin": "^1.0.0",
@@ -69,4 +70,4 @@
6970
"react": ">=16.9.0",
7071
"react-dom": ">=16.9.0"
7172
}
72-
}
73+
}

src/Tooltip.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { ArrowType, TriggerProps, TriggerRef } from '@rc-component/trigger';
22
import Trigger from '@rc-component/trigger';
33
import type { ActionType, AlignType, AnimationType } from '@rc-component/trigger/lib/interface';
4+
import classNames from 'classnames';
45
import * as React from 'react';
56
import { forwardRef, useImperativeHandle, useRef } from 'react';
67
import { placements } from './placements';
78
import Popup from './Popup';
8-
import classNames from 'classnames';
9+
import useId from 'rc-util/lib/hooks/useId';
910

1011
export interface TooltipProps
1112
extends Pick<
@@ -60,7 +61,7 @@ export interface TooltipClassNames {
6061
body?: string;
6162
}
6263

63-
export interface TooltipRef extends TriggerRef {}
64+
export interface TooltipRef extends TriggerRef { }
6465

6566
const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
6667
const {
@@ -91,7 +92,9 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
9192
...restProps
9293
} = props;
9394

95+
const mergedId = useId(id);
9496
const triggerRef = useRef<TriggerRef>(null);
97+
9598
useImperativeHandle(ref, () => triggerRef.current);
9699

97100
const extraProps: Partial<TooltipProps & TriggerProps> = { ...restProps };
@@ -103,14 +106,26 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
103106
<Popup
104107
key="content"
105108
prefixCls={prefixCls}
106-
id={id}
109+
id={mergedId}
107110
bodyClassName={tooltipClassNames?.body}
108111
overlayInnerStyle={{ ...overlayInnerStyle, ...tooltipStyles?.body }}
109112
>
110113
{overlay}
111114
</Popup>
112115
);
113116

117+
const getChildren = () => {
118+
const child = React.Children.only(children);
119+
const originalProps = child?.props || {};
120+
121+
const childProps = {
122+
...originalProps,
123+
'aria-describedby': overlay ? mergedId : null,
124+
};
125+
126+
return React.cloneElement(children, childProps);
127+
};
128+
114129
return (
115130
<Trigger
116131
popupClassName={classNames(overlayClassName, tooltipClassNames?.root)}
@@ -135,7 +150,7 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
135150
arrow={showArrow}
136151
{...extraProps}
137152
>
138-
{children}
153+
{getChildren()}
139154
</Trigger>
140155
);
141156
};

tests/index.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,62 @@ describe('rc-tooltip', () => {
279279
expect(tooltipElement.style.backgroundColor).toBe('blue');
280280
expect(tooltipBodyElement.style.color).toBe('red');
281281
});
282+
283+
describe('children handling', () => {
284+
it('should pass aria-describedby to child element when overlay exists', () => {
285+
const { container } = render(
286+
<Tooltip id="test-id" overlay="tooltip content">
287+
<button>Click me</button>
288+
</Tooltip>,
289+
);
290+
291+
expect(container.querySelector('button')).toHaveAttribute('aria-describedby', 'test-id');
292+
});
293+
294+
it('should not pass aria-describedby when overlay is empty', () => {
295+
const { container } = render(
296+
<Tooltip id="test-id" overlay={null}>
297+
<button>Click me</button>
298+
</Tooltip>,
299+
);
300+
301+
expect(container.querySelector('button')).not.toHaveAttribute('aria-describedby');
302+
});
303+
304+
it('should preserve original props of children', () => {
305+
const onMouseEnter = jest.fn();
306+
307+
const { container } = render(
308+
<Tooltip overlay="tip">
309+
<button className="custom-btn" onMouseEnter={onMouseEnter}>
310+
Click me
311+
</button>
312+
</Tooltip>,
313+
);
314+
315+
const btn = container.querySelector('button');
316+
expect(btn).toHaveClass('custom-btn');
317+
318+
// 触发原始事件处理器
319+
fireEvent.mouseEnter(btn);
320+
expect(onMouseEnter).toHaveBeenCalled();
321+
});
322+
323+
it('should throw error when multiple children provided', () => {
324+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
325+
326+
expect(() => {
327+
render(
328+
// @ts-expect-error
329+
<Tooltip overlay="tip" >
330+
<button>First</button>
331+
<button>Second</button>
332+
</Tooltip>,
333+
);
334+
}).toThrow();
335+
336+
errorSpy.mockRestore();
337+
});
338+
});
282339
});
340+

0 commit comments

Comments
 (0)