Skip to content

Commit

Permalink
feat(a11y): add screen reader support for Tooltip (#490)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
aojunhao123 authored Jan 7, 2025
1 parent ea549a3 commit 759180e
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 6 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"dependencies": {
"@babel/runtime": "^7.11.2",
"@rc-component/trigger": "^2.0.0",
"classnames": "^2.3.1"
"classnames": "^2.3.1",
"rc-util": "^5.44.3"
},
"devDependencies": {
"@rc-component/father-plugin": "^1.0.0",
Expand All @@ -69,4 +70,4 @@
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
}
}
23 changes: 19 additions & 4 deletions src/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ArrowType, TriggerProps, TriggerRef } from '@rc-component/trigger';
import Trigger from '@rc-component/trigger';
import type { ActionType, AlignType, AnimationType } from '@rc-component/trigger/lib/interface';
import classNames from 'classnames';
import * as React from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import { placements } from './placements';
import Popup from './Popup';
import classNames from 'classnames';
import useId from 'rc-util/lib/hooks/useId';

export interface TooltipProps
extends Pick<
Expand Down Expand Up @@ -60,7 +61,7 @@ export interface TooltipClassNames {
body?: string;
}

export interface TooltipRef extends TriggerRef {}
export interface TooltipRef extends TriggerRef { }

const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
const {
Expand Down Expand Up @@ -91,7 +92,9 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
...restProps
} = props;

const mergedId = useId(id);
const triggerRef = useRef<TriggerRef>(null);

useImperativeHandle(ref, () => triggerRef.current);

const extraProps: Partial<TooltipProps & TriggerProps> = { ...restProps };
Expand All @@ -103,14 +106,26 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
<Popup
key="content"
prefixCls={prefixCls}
id={id}
id={mergedId}
bodyClassName={tooltipClassNames?.body}
overlayInnerStyle={{ ...overlayInnerStyle, ...tooltipStyles?.body }}
>
{overlay}
</Popup>
);

const getChildren = () => {
const child = React.Children.only(children);
const originalProps = child?.props || {};

const childProps = {
...originalProps,
'aria-describedby': overlay ? mergedId : null,
};

return React.cloneElement(children, childProps);
};

return (
<Trigger
popupClassName={classNames(overlayClassName, tooltipClassNames?.root)}
Expand All @@ -135,7 +150,7 @@ const Tooltip = (props: TooltipProps, ref: React.Ref<TooltipRef>) => {
arrow={showArrow}
{...extraProps}
>
{children}
{getChildren()}
</Trigger>
);
};
Expand Down
58 changes: 58 additions & 0 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,62 @@ describe('rc-tooltip', () => {
expect(tooltipElement.style.backgroundColor).toBe('blue');
expect(tooltipBodyElement.style.color).toBe('red');
});

describe('children handling', () => {
it('should pass aria-describedby to child element when overlay exists', () => {
const { container } = render(
<Tooltip id="test-id" overlay="tooltip content">
<button>Click me</button>
</Tooltip>,
);

expect(container.querySelector('button')).toHaveAttribute('aria-describedby', 'test-id');
});

it('should not pass aria-describedby when overlay is empty', () => {
const { container } = render(
<Tooltip id="test-id" overlay={null}>
<button>Click me</button>
</Tooltip>,
);

expect(container.querySelector('button')).not.toHaveAttribute('aria-describedby');
});

it('should preserve original props of children', () => {
const onMouseEnter = jest.fn();

const { container } = render(
<Tooltip overlay="tip">
<button className="custom-btn" onMouseEnter={onMouseEnter}>
Click me
</button>
</Tooltip>,
);

const btn = container.querySelector('button');
expect(btn).toHaveClass('custom-btn');

// 触发原始事件处理器
fireEvent.mouseEnter(btn);
expect(onMouseEnter).toHaveBeenCalled();
});

it('should throw error when multiple children provided', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });

expect(() => {
render(
// @ts-expect-error
<Tooltip overlay="tip" >
<button>First</button>
<button>Second</button>
</Tooltip>,
);
}).toThrow();

errorSpy.mockRestore();
});
});
});

1 comment on commit 759180e

@vercel
Copy link

@vercel vercel bot commented on 759180e Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

tooltip – ./

tooltip-react-component.vercel.app
tooltip-git-master-react-component.vercel.app

Please sign in to comment.