Skip to content

Commit 5489369

Browse files
committed
feat(a11y): add screen reader support for Tooltip
1 parent ea549a3 commit 5489369

File tree

6 files changed

+163
-18
lines changed

6 files changed

+163
-18
lines changed

jest.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
testEnvironment: 'jsdom',
3+
setupFiles: ['<rootDir>/tests/setup.js'],
4+
};
5+

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@
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",
51+
"@testing-library/jest-dom": "^6.6.3",
5052
"@testing-library/react": "^14.0.0",
53+
"@testing-library/user-event": "^14.5.2",
5154
"@types/jest": "^29.4.0",
5255
"@types/react": "^18.0.26",
5356
"@types/react-dom": "^18.0.10",
@@ -69,4 +72,4 @@
6972
"react": ">=16.9.0",
7073
"react-dom": ">=16.9.0"
7174
}
72-
}
75+
}

src/Popup.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,51 @@ export interface ContentProps {
1111
bodyClassName?: string;
1212
}
1313

14+
const getTextContent = (node: (() => React.ReactNode) | React.ReactNode): string => {
15+
if (!node) {
16+
return '';
17+
}
18+
19+
const resolvedNode = typeof node === 'function' ? node() : node;
20+
21+
if (typeof resolvedNode === 'string' || typeof resolvedNode === 'number') {
22+
return String(resolvedNode);
23+
}
24+
25+
if (Array.isArray(resolvedNode)) {
26+
return resolvedNode.map(getTextContent).join(' ');
27+
}
28+
29+
if (React.isValidElement(resolvedNode)) {
30+
return getTextContent(resolvedNode.props.children);
31+
}
32+
};
33+
1434
export default function Popup(props: ContentProps) {
1535
const { children, prefixCls, id, overlayInnerStyle: innerStyle, bodyClassName, className, style } =
1636
props;
1737

38+
const tooltipText = getTextContent(children);
39+
1840
return (
19-
<div className={classNames(`${prefixCls}-content`, className)} style={style}>
20-
<div
21-
className={classNames(`${prefixCls}-inner`, bodyClassName)}
22-
id={id}
23-
role="tooltip"
24-
style={innerStyle}
25-
>
26-
{typeof children === 'function' ? children() : children}
41+
<>
42+
<div className={classNames(`${prefixCls}-content`, className)} style={style}>
43+
<div
44+
className={classNames(`${prefixCls}-inner`, bodyClassName)}
45+
style={innerStyle}
46+
>
47+
{typeof children === 'function' ? children() : children}
48+
</div>
2749
</div>
28-
</div>
50+
{tooltipText && (
51+
<div
52+
id={id}
53+
role="tooltip"
54+
style={{ width: 0, height: 0, position: 'absolute', overflow: 'hidden', opacity: 0 }}
55+
>
56+
{tooltipText}
57+
</div>
58+
)}
59+
</>
2960
);
3061
}

src/Tooltip.tsx

Lines changed: 19 additions & 5 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';
5-
import { forwardRef, useImperativeHandle, useRef } from 'react';
6+
import { forwardRef, useImperativeHandle, useRef, isValidElement, cloneElement } 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,9 +61,11 @@ 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>) => {
67+
const defaultId = useId();
68+
6669
const {
6770
overlayClassName,
6871
trigger = ['hover'],
@@ -84,7 +87,7 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
8487
overlayInnerStyle,
8588
arrowContent,
8689
overlay,
87-
id,
90+
id = defaultId,
8891
showArrow = true,
8992
classNames: tooltipClassNames,
9093
styles: tooltipStyles,
@@ -111,6 +114,17 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
111114
</Popup>
112115
);
113116

117+
const getChildren = () => {
118+
const originalProps = (children as React.ReactElement)?.props || {};
119+
120+
const childProps = {
121+
...originalProps,
122+
'aria-describedby': overlay ? id : null,
123+
};
124+
125+
return cloneElement(children, childProps);
126+
};
127+
114128
return (
115129
<Trigger
116130
popupClassName={classNames(overlayClassName, tooltipClassNames?.root)}
@@ -135,7 +149,7 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
135149
arrow={showArrow}
136150
{...extraProps}
137151
>
138-
{children}
152+
{getChildren()}
139153
</Trigger>
140154
);
141155
};

tests/popup.test.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,100 @@
1-
import { Popup } from '../src';
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import Popup from '../src/Popup';
24

35
describe('Popup', () => {
46
// Used in antd for C2D2C
57
it('should export', () => {
68
expect(Popup).toBeTruthy();
79
});
10+
11+
it('should correctly extract text from string, number, function, and element', () => {
12+
const { getByRole } = render(
13+
<Popup prefixCls="test" id="popup-id">
14+
{() => (
15+
<>
16+
{'Hello'}
17+
{123}
18+
<span>World</span>
19+
</>
20+
)}
21+
</Popup>,
22+
);
23+
24+
const tooltip = getByRole('tooltip');
25+
const hiddenTextContainer = tooltip.querySelector('div > div');
26+
27+
expect(hiddenTextContainer.textContent).toBe('Hello 123 World');
28+
});
29+
30+
it('should apply updated hidden text styles correctly', () => {
31+
const { getByRole } = render(
32+
<Popup prefixCls="test" id="popup-id">
33+
test hidden text
34+
</Popup>,
35+
);
36+
37+
const tooltip = getByRole('tooltip');
38+
const hiddenTextContainer = tooltip.querySelector('div > div');
39+
40+
expect(hiddenTextContainer).toHaveStyle({
41+
width: '0',
42+
height: '0',
43+
position: 'absolute',
44+
overflow: 'hidden',
45+
opacity: '0',
46+
});
47+
});
48+
49+
it('should return empty string if children is null or undefined', () => {
50+
const { getByRole } = render(
51+
<Popup prefixCls="test" id="popup-empty">
52+
{null}
53+
</Popup>,
54+
);
55+
const tooltip = getByRole('tooltip');
56+
57+
expect(tooltip.querySelector('div > div')).toBeNull();
58+
});
59+
60+
it('should handle nested arrays correctly', () => {
61+
const { getByRole } = render(
62+
<Popup prefixCls="test" id="popup-nested">
63+
{[
64+
'First',
65+
['Second', 'Third'],
66+
<span key="fourth">Fourth</span>,
67+
]}
68+
</Popup>,
69+
);
70+
const tooltip = getByRole('tooltip');
71+
const hiddenTextContainer = tooltip.querySelector('div > div');
72+
73+
// "First Second Third Fourth"
74+
expect(hiddenTextContainer.textContent).toBe('First Second Third Fourth');
75+
});
76+
77+
it('should handle function returning an array', () => {
78+
const { getByRole } = render(
79+
<Popup prefixCls="test" id="popup-func-array">
80+
{() => ['Alpha', <span key="beta">Beta</span>]}
81+
</Popup>,
82+
);
83+
const tooltip = getByRole('tooltip');
84+
const hiddenTextContainer = tooltip.querySelector('div > div');
85+
86+
// "Alpha Beta"
87+
expect(hiddenTextContainer.textContent).toBe('Alpha Beta');
88+
});
89+
90+
it('should handle function returning undefined', () => {
91+
const { getByRole } = render(
92+
<Popup prefixCls="test" id="popup-func-undefined">
93+
{() => undefined}
94+
</Popup>,
95+
);
96+
const tooltip = getByRole('tooltip');
97+
98+
expect(tooltip.querySelector('div > div')).toBeNull();
99+
});
8100
});

tests/setup.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ if (typeof window !== 'undefined') {
1111
global.window.innerHeight = height || global.window.innerHeight;
1212
global.window.dispatchEvent(new Event('resize'));
1313
};
14-
global.window.scrollTo = () => {};
14+
global.window.scrollTo = () => { };
1515
// ref: https://github.com/ant-design/ant-design/issues/18774
1616
if (!window.matchMedia) {
1717
Object.defineProperty(global.window, 'matchMedia', {

0 commit comments

Comments
 (0)