Skip to content

Commit b7388d4

Browse files
committed
feat: improve a11y
1 parent dece1b0 commit b7388d4

File tree

2 files changed

+102
-6
lines changed

2 files changed

+102
-6
lines changed

src/Tooltip.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export interface TooltipRef extends TriggerRef {}
6060

6161
const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
6262
const {
63-
trigger = ['hover'],
63+
trigger = ['hover','focus'],
6464
mouseEnterDelay = 0,
6565
mouseLeaveDelay = 0.1,
6666
prefixCls = 'rc-tooltip',
@@ -79,6 +79,7 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
7979
showArrow = true,
8080
classNames,
8181
styles,
82+
forceRender,
8283
...restProps
8384
} = props;
8485

@@ -93,6 +94,51 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
9394
extraProps.popupVisible = props.visible;
9495
}
9596

97+
const isControlled = 'visible' in props;
98+
const mergedVisible = props.visible;
99+
const [popupMounted, setPopupMounted] = React.useState(() => {
100+
if (forceRender) {
101+
return true;
102+
}
103+
if (isControlled) {
104+
return mergedVisible;
105+
}
106+
return defaultVisible;
107+
});
108+
109+
const updatePopupMounted = React.useCallback(
110+
(nextVisible: boolean) => {
111+
setPopupMounted((prev) => {
112+
if (nextVisible) {
113+
return true;
114+
}
115+
116+
if (destroyOnHidden) {
117+
return false;
118+
}
119+
120+
return prev;
121+
});
122+
},
123+
[forceRender, destroyOnHidden],
124+
);
125+
126+
const handleVisibleChange = (nextVisible: boolean) => {
127+
updatePopupMounted(nextVisible);
128+
onVisibleChange?.(nextVisible);
129+
};
130+
131+
React.useEffect(() => {
132+
if (forceRender) {
133+
setPopupMounted(true);
134+
return;
135+
}
136+
137+
if (isControlled) {
138+
setPopupMounted(mergedVisible);
139+
}
140+
}, [forceRender, isControlled, mergedVisible]);
141+
96142
// ========================= Arrow ==========================
97143
// Process arrow configuration
98144
const mergedArrow = React.useMemo(() => {
@@ -118,7 +164,7 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
118164
const originalProps = child?.props || {};
119165
const childProps = {
120166
...originalProps,
121-
'aria-describedby': overlay ? mergedId : null,
167+
'aria-describedby': overlay && popupMounted ? mergedId : null,
122168
};
123169
return React.cloneElement<any>(children, childProps) as any;
124170
};
@@ -145,10 +191,11 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
145191
ref={triggerRef}
146192
popupAlign={align}
147193
getPopupContainer={getTooltipContainer}
148-
onOpenChange={onVisibleChange}
194+
onOpenChange={handleVisibleChange}
149195
afterOpenChange={afterVisibleChange}
150196
popupMotion={motion}
151197
defaultPopupVisible={defaultVisible}
198+
forceRender={forceRender}
152199
autoDestroy={destroyOnHidden}
153200
mouseLeaveDelay={mouseLeaveDelay}
154201
popupStyle={styles?.root}

tests/index.test.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,14 +502,24 @@ describe('rc-tooltip', () => {
502502
});
503503

504504
describe('children handling', () => {
505-
it('should pass aria-describedby to child element when overlay exists', () => {
505+
it('should only set aria-describedby once popup is mounted', async () => {
506506
const { container } = render(
507-
<Tooltip id="test-id" overlay="tooltip content">
507+
<Tooltip trigger={['click']} overlay="tooltip content">
508508
<button>Click me</button>
509509
</Tooltip>,
510510
);
511511

512-
expect(container.querySelector('button')).toHaveAttribute('aria-describedby', 'test-id');
512+
const btn = container.querySelector('button');
513+
expect(btn).not.toHaveAttribute('aria-describedby');
514+
515+
fireEvent.click(btn);
516+
await waitFakeTimers();
517+
const describedby = btn.getAttribute('aria-describedby');
518+
expect(describedby).toBeTruthy();
519+
520+
fireEvent.click(btn);
521+
await waitFakeTimers();
522+
expect(btn).toHaveAttribute('aria-describedby', describedby);
513523
});
514524

515525
it('should not pass aria-describedby when overlay is empty', () => {
@@ -522,6 +532,45 @@ describe('rc-tooltip', () => {
522532
expect(container.querySelector('button')).not.toHaveAttribute('aria-describedby');
523533
});
524534

535+
it('should set aria-describedby immediately when defaultVisible is true', () => {
536+
const { container } = render(
537+
<Tooltip defaultVisible overlay="tooltip content">
538+
<button>Click me</button>
539+
</Tooltip>,
540+
);
541+
542+
expect(container.querySelector('button')).toHaveAttribute('aria-describedby');
543+
});
544+
545+
it('should set aria-describedby immediately when forceRender is true', () => {
546+
const { container } = render(
547+
<Tooltip forceRender overlay="tooltip content">
548+
<button>Click me</button>
549+
</Tooltip>,
550+
);
551+
552+
expect(container.querySelector('button')).toHaveAttribute('aria-describedby');
553+
});
554+
555+
it('should remove aria-describedby when popup is destroyed on hide', async () => {
556+
const { container } = render(
557+
<Tooltip destroyOnHidden trigger={['click']} overlay="tooltip content">
558+
<button>Click me</button>
559+
</Tooltip>,
560+
);
561+
562+
const btn = container.querySelector('button');
563+
expect(btn).not.toHaveAttribute('aria-describedby');
564+
565+
fireEvent.click(btn);
566+
await waitFakeTimers();
567+
expect(btn).toHaveAttribute('aria-describedby');
568+
569+
fireEvent.click(btn);
570+
await waitFakeTimers();
571+
expect(btn).not.toHaveAttribute('aria-describedby');
572+
});
573+
525574
it('should preserve original props of children', () => {
526575
const onMouseEnter = jest.fn();
527576

0 commit comments

Comments
 (0)