Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# rc-tooltip
# @rc-component/tooltip

React Tooltip

Expand All @@ -9,18 +9,16 @@ React Tooltip
[![bundle size][bundlephobia-image]][bundlephobia-url]
[![dumi][dumi-image]][dumi-url]

[npm-image]: http://img.shields.io/npm/v/rc-tooltip.svg?style=flat-square
[npm-url]: http://npmjs.org/package/rc-tooltip
[travis-image]: https://img.shields.io/travis/react-component/tooltip/master?style=flat-square
[travis-url]: https://travis-ci.com/react-component/tooltip
[npm-image]: http://img.shields.io/npm/v/@rc-component/tooltip.svg?style=flat-square
[npm-url]: http://npmjs.org/package/@rc-component/tooltip
[github-actions-image]: https://github.com/react-component/tooltip/actions/workflows/react-component-ci.yml/badge.svg
[github-actions-url]: https://github.com/react-component/tooltip/actions/workflows/react-component-ci.yml
[codecov-image]: https://img.shields.io/codecov/c/github/react-component/tooltip/master.svg?style=flat-square
[codecov-url]: https://app.codecov.io/gh/react-component/tooltip
[download-image]: https://img.shields.io/npm/dm/rc-tooltip.svg?style=flat-square
[download-url]: https://npmjs.org/package/rc-tooltip
[bundlephobia-url]: https://bundlephobia.com/package/rc-tooltip
[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-tooltip
[download-image]: https://img.shields.io/npm/dm/@rc-component/tooltip.svg?style=flat-square
[download-url]: https://npmjs.org/package/@rc-component/tooltip
[bundlephobia-url]: https://bundlephobia.com/package/@rc-component/tooltip
[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/@rc-component/tooltip
[dumi-url]: https://github.com/umijs/dumi
[dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square

Expand All @@ -36,18 +34,18 @@ React Tooltip

## Install

[![rc-tooltip](https://nodei.co/npm/rc-tooltip.png)](https://npmjs.org/package/rc-tooltip)
[![@rc-component/tooltip](https://nodei.co/npm/@rc-component/tooltip.png)](https://npmjs.org/package/@rc-component/tooltip)

## Usage

```js
var Tooltip = require('rc-tooltip');
var Tooltip = require('@rc-component/tooltip');
var React = require('react');
var ReactDOM = require('react-dom');

// By default, the tooltip has no style.
// Consider importing the stylesheet it comes with:
// 'rc-tooltip/assets/bootstrap_white.css'
// '@rc-component/tooltip/assets/bootstrap_white.css'

ReactDOM.render(
<Tooltip placement="left" trigger={['click']} overlay={<span>tooltip</span>}>
Expand Down Expand Up @@ -135,4 +133,4 @@ npm run coverage

## License

`rc-tooltip` is released under the MIT license.
`@rc-component/tooltip` is released under the MIT license.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"devDependencies": {
"@rc-component/father-plugin": "^2.0.1",
"@rc-component/np": "^1.0.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/jest": "^29.4.0",
"@types/node": "^22.15.18",
Expand Down
22 changes: 13 additions & 9 deletions src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export interface TooltipProps
| 'onPopupAlign'
| 'builtinPlacements'
| 'fresh'
| 'children'
| 'mouseLeaveDelay'
| 'mouseEnterDelay'
| 'prefixCls'
| 'forceRender'
| 'popupVisible'
> {
children: React.ReactElement;
// Style
classNames?: Partial<Record<SemanticName, string>>;
styles?: Partial<Record<SemanticName, React.CSSProperties>>;
Expand Down Expand Up @@ -79,6 +79,7 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
showArrow = true,
classNames,
styles,
forceRender,
...restProps
} = props;

Expand All @@ -93,6 +94,10 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
extraProps.popupVisible = props.visible;
}

const handleVisibleChange = (nextVisible: boolean) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

清理一下

onVisibleChange?.(nextVisible);
};

// ========================= Arrow ==========================
// Process arrow configuration
const mergedArrow = React.useMemo(() => {
Expand All @@ -113,14 +118,12 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
}, [showArrow, classNames?.arrow, styles?.arrow, arrowContent]);

// ======================== Children ========================
const getChildren = () => {
const getChildren = (open: boolean) => {
const child = React.Children.only(children);
const originalProps = child?.props || {};
const childProps = {
...originalProps,
'aria-describedby': overlay ? mergedId : null,
const ariaProps: React.AriaAttributes = {
'aria-describedby': overlay && open ? mergedId : undefined,
};
return React.cloneElement<any>(children, childProps) as any;
return React.cloneElement(child, ariaProps);
};
Comment on lines +116 to 122
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

修复 aria-describedby 属性覆盖问题。

React.cloneElement(child, ariaProps) 会直接覆盖子元素已有的 aria-describedby 属性,破坏子元素自身的无障碍关联关系。根据 WAI-ARIA 规范,应使用空格分隔的 ID 列表合并多个描述源。

应用此 diff 以正确合并 aria-describedby:

  const getChildren: TriggerProps['children'] = ({ open }) => {
    const child = React.Children.only(children);
+   const existingDescribedBy = child.props['aria-describedby'];
+   const tooltipDescribedBy = overlay && open ? mergedId : undefined;
+   const mergedDescribedBy = 
+     tooltipDescribedBy && existingDescribedBy
+       ? `${existingDescribedBy} ${tooltipDescribedBy}`
+       : tooltipDescribedBy || existingDescribedBy;
    const ariaProps: React.AriaAttributes = {
-     'aria-describedby': overlay && open ? mergedId : undefined,
+     'aria-describedby': mergedDescribedBy,
    };
    return React.cloneElement(child, ariaProps);
  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getChildren: TriggerProps['children'] = ({ open }) => {
const child = React.Children.only(children);
const originalProps = child?.props || {};
const childProps = {
...originalProps,
'aria-describedby': overlay ? mergedId : null,
const ariaProps: React.AriaAttributes = {
'aria-describedby': overlay && open ? mergedId : undefined,
};
return React.cloneElement<any>(children, childProps) as any;
return React.cloneElement(child, ariaProps);
};
const getChildren: TriggerProps['children'] = ({ open }) => {
const child = React.Children.only(children);
const existingDescribedBy = child.props['aria-describedby'];
const tooltipDescribedBy = overlay && open ? mergedId : undefined;
const mergedDescribedBy =
tooltipDescribedBy && existingDescribedBy
? `${existingDescribedBy} ${tooltipDescribedBy}`
: tooltipDescribedBy || existingDescribedBy;
const ariaProps: React.AriaAttributes = {
'aria-describedby': mergedDescribedBy,
};
return React.cloneElement(child, ariaProps);
};
🤖 Prompt for AI Agents
In src/Tooltip.tsx around lines 116 to 122, React.cloneElement(child, ariaProps)
currently overwrites any existing child aria-describedby; instead read the
child's existing aria-describedby (child.props &&
child.props['aria-describedby']), and if overlay && open and mergedId exist,
merge them by space-separating the existing value and mergedId (deduplicate if
present), then pass the combined string (or undefined if empty) into
cloneElement so existing aria-describedby values are preserved and multiple IDs
follow WAI-ARIA space-separated list rules.


// ========================= Render =========================
Expand All @@ -145,10 +148,11 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
ref={triggerRef}
popupAlign={align}
getPopupContainer={getTooltipContainer}
onOpenChange={onVisibleChange}
onOpenChange={handleVisibleChange}
afterOpenChange={afterVisibleChange}
popupMotion={motion}
defaultPopupVisible={defaultVisible}
forceRender={forceRender}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

这个你已经不消费了,还原给解构填入吧

autoDestroy={destroyOnHidden}
mouseLeaveDelay={mouseLeaveDelay}
popupStyle={styles?.root}
Expand All @@ -158,7 +162,7 @@ const Tooltip = React.forwardRef<TooltipRef, TooltipProps>((props, ref) => {
uniqueContainerStyle={styles?.uniqueContainer}
{...extraProps}
>
{getChildren()}
{({ open }) => getChildren(open)}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

这里不用额外的函数,因为本来就支持 renderProps 了

</Trigger>
);
});
Expand Down
79 changes: 76 additions & 3 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -502,14 +502,24 @@ describe('rc-tooltip', () => {
});

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

expect(container.querySelector('button')).toHaveAttribute('aria-describedby', 'test-id');
const btn = container.querySelector('button');
expect(btn).not.toHaveAttribute('aria-describedby');

fireEvent.click(btn);
await waitFakeTimers();
const describedby = btn.getAttribute('aria-describedby');
expect(describedby).toBeTruthy();

fireEvent.click(btn);
await waitFakeTimers();
expect(btn).not.toHaveAttribute('aria-describedby');
});

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

it('should set aria-describedby immediately when defaultVisible is true', () => {
const { container } = render(
<Tooltip defaultVisible overlay="tooltip content">
<button>Click me</button>
</Tooltip>,
);

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

it('should only set aria-describedby for forceRender after visible', async () => {
const { container } = render(
<Tooltip forceRender trigger={['click']} overlay="tooltip content">
<button>Click me</button>
</Tooltip>,
);

const btn = container.querySelector('button');
expect(btn).not.toHaveAttribute('aria-describedby');

fireEvent.click(btn);
await waitFakeTimers();
expect(btn).toHaveAttribute('aria-describedby');
});

it('should remove aria-describedby when controlled hidden without destroy', () => {
const overlay = 'tooltip content';
const { container, rerender } = render(
<Tooltip overlay={overlay} visible>
<button>Click me</button>
</Tooltip>,
);

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

rerender(
<Tooltip overlay={overlay} visible={false}>
<button>Click me</button>
</Tooltip>,
);

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

it('should remove aria-describedby when popup is destroyed on hide', async () => {
const { container } = render(
<Tooltip destroyOnHidden trigger={['click']} overlay="tooltip content">
<button>Click me</button>
</Tooltip>,
);

const btn = container.querySelector('button');
expect(btn).not.toHaveAttribute('aria-describedby');

fireEvent.click(btn);
await waitFakeTimers();
expect(btn).toHaveAttribute('aria-describedby');

fireEvent.click(btn);
await waitFakeTimers();
expect(btn).not.toHaveAttribute('aria-describedby');
});

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

Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"declaration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["@testing-library/jest-dom"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

By adding the types property, you override TypeScript's default type inclusion. This means that other ambient type definitions, like for Jest (jest) and Node.js (node), will no longer be automatically included, which will likely break your build. You need to explicitly list all required ambient types.1

Suggested change
"types": ["@testing-library/jest-dom"],
"types": ["jest", "node", "@testing-library/jest-dom"],

Rules References

Footnotes

  1. When using the compilerOptions.types property in tsconfig.json, TypeScript will only include the type definitions for the packages explicitly listed in the array. This overrides the default behavior of automatically including all packages found in node_modules/@types. Therefore, you must list all necessary type definitions, such as jest and node, to avoid compilation errors.

"paths": {
"@/*": [
"src/*"
Expand Down
Loading