From 54893699f1796f6b8d5c41782d5a6eceda46376a Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Thu, 2 Jan 2025 13:49:27 +0800 Subject: [PATCH 01/17] feat(a11y): add screen reader support for Tooltip --- jest.config.js | 5 +++ package.json | 7 +++- src/Popup.tsx | 49 ++++++++++++++++++----- src/Tooltip.tsx | 24 ++++++++--- tests/popup.test.tsx | 94 +++++++++++++++++++++++++++++++++++++++++++- tests/setup.js | 2 +- 6 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..7fd366b7 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + testEnvironment: 'jsdom', + setupFiles: ['/tests/setup.js'], +}; + diff --git a/package.json b/package.json index 4194b6c0..11846452 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,14 @@ "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", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.4.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", @@ -69,4 +72,4 @@ "react": ">=16.9.0", "react-dom": ">=16.9.0" } -} +} \ No newline at end of file diff --git a/src/Popup.tsx b/src/Popup.tsx index 2ab11844..d949339b 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -11,20 +11,51 @@ export interface ContentProps { bodyClassName?: string; } +const getTextContent = (node: (() => React.ReactNode) | React.ReactNode): string => { + if (!node) { + return ''; + } + + const resolvedNode = typeof node === 'function' ? node() : node; + + if (typeof resolvedNode === 'string' || typeof resolvedNode === 'number') { + return String(resolvedNode); + } + + if (Array.isArray(resolvedNode)) { + return resolvedNode.map(getTextContent).join(' '); + } + + if (React.isValidElement(resolvedNode)) { + return getTextContent(resolvedNode.props.children); + } +}; + export default function Popup(props: ContentProps) { const { children, prefixCls, id, overlayInnerStyle: innerStyle, bodyClassName, className, style } = props; + const tooltipText = getTextContent(children); + return ( -
- + {tooltipText && ( + + )} + ); } diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx index 3f9a2f41..5b8f76af 100644 --- a/src/Tooltip.tsx +++ b/src/Tooltip.tsx @@ -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 { forwardRef, useImperativeHandle, useRef, isValidElement, cloneElement } 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< @@ -60,9 +61,11 @@ export interface TooltipClassNames { body?: string; } -export interface TooltipRef extends TriggerRef {} +export interface TooltipRef extends TriggerRef { } const Tooltip = (props: TooltipProps, ref: React.Ref) => { + const defaultId = useId(); + const { overlayClassName, trigger = ['hover'], @@ -84,7 +87,7 @@ const Tooltip = (props: TooltipProps, ref: React.Ref) => { overlayInnerStyle, arrowContent, overlay, - id, + id = defaultId, showArrow = true, classNames: tooltipClassNames, styles: tooltipStyles, @@ -111,6 +114,17 @@ const Tooltip = (props: TooltipProps, ref: React.Ref) => { ); + const getChildren = () => { + const originalProps = (children as React.ReactElement)?.props || {}; + + const childProps = { + ...originalProps, + 'aria-describedby': overlay ? id : null, + }; + + return cloneElement(children, childProps); + }; + return ( ) => { arrow={showArrow} {...extraProps} > - {children} + {getChildren()} ); }; diff --git a/tests/popup.test.tsx b/tests/popup.test.tsx index dce44ee4..7165d21d 100644 --- a/tests/popup.test.tsx +++ b/tests/popup.test.tsx @@ -1,8 +1,100 @@ -import { Popup } from '../src'; +import React from 'react'; +import { render } from '@testing-library/react'; +import Popup from '../src/Popup'; describe('Popup', () => { // Used in antd for C2D2C it('should export', () => { expect(Popup).toBeTruthy(); }); + + it('should correctly extract text from string, number, function, and element', () => { + const { getByRole } = render( + + {() => ( + <> + {'Hello'} + {123} + World + + )} + , + ); + + const tooltip = getByRole('tooltip'); + const hiddenTextContainer = tooltip.querySelector('div > div'); + + expect(hiddenTextContainer.textContent).toBe('Hello 123 World'); + }); + + it('should apply updated hidden text styles correctly', () => { + const { getByRole } = render( + + test hidden text + , + ); + + const tooltip = getByRole('tooltip'); + const hiddenTextContainer = tooltip.querySelector('div > div'); + + expect(hiddenTextContainer).toHaveStyle({ + width: '0', + height: '0', + position: 'absolute', + overflow: 'hidden', + opacity: '0', + }); + }); + + it('should return empty string if children is null or undefined', () => { + const { getByRole } = render( + + {null} + , + ); + const tooltip = getByRole('tooltip'); + + expect(tooltip.querySelector('div > div')).toBeNull(); + }); + + it('should handle nested arrays correctly', () => { + const { getByRole } = render( + + {[ + 'First', + ['Second', 'Third'], + Fourth, + ]} + , + ); + const tooltip = getByRole('tooltip'); + const hiddenTextContainer = tooltip.querySelector('div > div'); + + // "First Second Third Fourth" + expect(hiddenTextContainer.textContent).toBe('First Second Third Fourth'); + }); + + it('should handle function returning an array', () => { + const { getByRole } = render( + + {() => ['Alpha', Beta]} + , + ); + const tooltip = getByRole('tooltip'); + const hiddenTextContainer = tooltip.querySelector('div > div'); + + // "Alpha Beta" + expect(hiddenTextContainer.textContent).toBe('Alpha Beta'); + }); + + it('should handle function returning undefined', () => { + const { getByRole } = render( + + {() => undefined} + , + ); + const tooltip = getByRole('tooltip'); + + expect(tooltip.querySelector('div > div')).toBeNull(); + }); }); diff --git a/tests/setup.js b/tests/setup.js index 8623ee6b..b87a7e9d 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -11,7 +11,7 @@ if (typeof window !== 'undefined') { global.window.innerHeight = height || global.window.innerHeight; global.window.dispatchEvent(new Event('resize')); }; - global.window.scrollTo = () => {}; + global.window.scrollTo = () => { }; // ref: https://github.com/ant-design/ant-design/issues/18774 if (!window.matchMedia) { Object.defineProperty(global.window, 'matchMedia', { From 241527ec85f1e30a05531dbdc6866b239d0c1cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 16:15:11 +0800 Subject: [PATCH 02/17] fix: lint fix --- src/Tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx index 5b8f76af..dc219011 100644 --- a/src/Tooltip.tsx +++ b/src/Tooltip.tsx @@ -3,7 +3,7 @@ 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, isValidElement, cloneElement } from 'react'; +import { forwardRef, useImperativeHandle, useRef, cloneElement } from 'react'; import { placements } from './placements'; import Popup from './Popup'; import useId from 'rc-util/lib/hooks/useId'; From 3f2bea8f7203047e45b7238f6cd6df1f85f8dc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 16:47:30 +0800 Subject: [PATCH 03/17] chore: remove unnecessary jest config file --- jest.config.js | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 7fd366b7..00000000 --- a/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - testEnvironment: 'jsdom', - setupFiles: ['/tests/setup.js'], -}; - From dbe400e30eb67ee0b52682146b8482cf5101576c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 16:48:28 +0800 Subject: [PATCH 04/17] fix: ensure getTextContent returns an empty string for invalid nodes in Popup component --- src/Popup.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Popup.tsx b/src/Popup.tsx index d949339b..7447d850 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -29,6 +29,8 @@ const getTextContent = (node: (() => React.ReactNode) | React.ReactNode): string if (React.isValidElement(resolvedNode)) { return getTextContent(resolvedNode.props.children); } + + return ''; }; export default function Popup(props: ContentProps) { From 4b09132c7464ab6edf9e35b757df3736ceaf7a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 17:28:20 +0800 Subject: [PATCH 05/17] chore: clean code --- src/Popup.tsx | 35 +---------------- tests/popup.test.tsx | 92 -------------------------------------------- 2 files changed, 2 insertions(+), 125 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index 7447d850..069e6b9b 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -11,53 +11,22 @@ export interface ContentProps { bodyClassName?: string; } -const getTextContent = (node: (() => React.ReactNode) | React.ReactNode): string => { - if (!node) { - return ''; - } - - const resolvedNode = typeof node === 'function' ? node() : node; - - if (typeof resolvedNode === 'string' || typeof resolvedNode === 'number') { - return String(resolvedNode); - } - - if (Array.isArray(resolvedNode)) { - return resolvedNode.map(getTextContent).join(' '); - } - - if (React.isValidElement(resolvedNode)) { - return getTextContent(resolvedNode.props.children); - } - - return ''; -}; - export default function Popup(props: ContentProps) { const { children, prefixCls, id, overlayInnerStyle: innerStyle, bodyClassName, className, style } = props; - const tooltipText = getTextContent(children); - return ( <>
- {tooltipText && ( - - )} ); } diff --git a/tests/popup.test.tsx b/tests/popup.test.tsx index 7165d21d..52a97f3e 100644 --- a/tests/popup.test.tsx +++ b/tests/popup.test.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import { render } from '@testing-library/react'; import Popup from '../src/Popup'; describe('Popup', () => { @@ -7,94 +5,4 @@ describe('Popup', () => { it('should export', () => { expect(Popup).toBeTruthy(); }); - - it('should correctly extract text from string, number, function, and element', () => { - const { getByRole } = render( - - {() => ( - <> - {'Hello'} - {123} - World - - )} - , - ); - - const tooltip = getByRole('tooltip'); - const hiddenTextContainer = tooltip.querySelector('div > div'); - - expect(hiddenTextContainer.textContent).toBe('Hello 123 World'); - }); - - it('should apply updated hidden text styles correctly', () => { - const { getByRole } = render( - - test hidden text - , - ); - - const tooltip = getByRole('tooltip'); - const hiddenTextContainer = tooltip.querySelector('div > div'); - - expect(hiddenTextContainer).toHaveStyle({ - width: '0', - height: '0', - position: 'absolute', - overflow: 'hidden', - opacity: '0', - }); - }); - - it('should return empty string if children is null or undefined', () => { - const { getByRole } = render( - - {null} - , - ); - const tooltip = getByRole('tooltip'); - - expect(tooltip.querySelector('div > div')).toBeNull(); - }); - - it('should handle nested arrays correctly', () => { - const { getByRole } = render( - - {[ - 'First', - ['Second', 'Third'], - Fourth, - ]} - , - ); - const tooltip = getByRole('tooltip'); - const hiddenTextContainer = tooltip.querySelector('div > div'); - - // "First Second Third Fourth" - expect(hiddenTextContainer.textContent).toBe('First Second Third Fourth'); - }); - - it('should handle function returning an array', () => { - const { getByRole } = render( - - {() => ['Alpha', Beta]} - , - ); - const tooltip = getByRole('tooltip'); - const hiddenTextContainer = tooltip.querySelector('div > div'); - - // "Alpha Beta" - expect(hiddenTextContainer.textContent).toBe('Alpha Beta'); - }); - - it('should handle function returning undefined', () => { - const { getByRole } = render( - - {() => undefined} - , - ); - const tooltip = getByRole('tooltip'); - - expect(tooltip.querySelector('div > div')).toBeNull(); - }); }); From f645402c90792e261a60bfa8d3d2a6521cf65761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 17:35:03 +0800 Subject: [PATCH 06/17] chore: clean code --- src/Popup.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index 069e6b9b..6506196a 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -16,17 +16,15 @@ export default function Popup(props: ContentProps) { props; return ( - <> -
- +
+ - +
); } From 36ec1b86105a6fc96b513bb1abd01213f32fe70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 2 Jan 2025 19:03:27 +0800 Subject: [PATCH 07/17] chore: clean code --- package.json | 2 -- src/Popup.tsx | 2 +- src/Tooltip.tsx | 5 +++- tests/index.test.tsx | 64 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 11846452..c5b3aa3a 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,7 @@ }, "devDependencies": { "@rc-component/father-plugin": "^1.0.0", - "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.4.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", diff --git a/src/Popup.tsx b/src/Popup.tsx index 6506196a..2ab11844 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -18,9 +18,9 @@ export default function Popup(props: ContentProps) { return (