From 29eb6473d48faa324911f3e4b1f11b276d61bf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Mon, 7 Sep 2026 15:00:03 +0800 Subject: [PATCH 1/4] feat: support open API with visible compatibility --- README.md | 6 +- README.zh-CN.md | 6 +- docs/examples/arrow.jsx | 8 +- docs/examples/multiple.jsx | 14 +-- docs/examples/overlay-callback.jsx | 6 +- docs/examples/simple.jsx | 6 +- src/Dropdown.tsx | 63 +++++++------ src/hooks/useAccessibility.ts | 16 ++-- tests/basic.test.tsx | 24 ++--- tests/open.test.tsx | 141 +++++++++++++++++++++++++++++ tests/props.test.tsx | 5 +- 11 files changed, 227 insertions(+), 68 deletions(-) create mode 100644 tests/open.test.tsx diff --git a/README.md b/README.md index d265ba0..5e6906a 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ Additional props are passed to the underlying [`@rc-component/trigger`](https:// | prefixCls | Component class name prefix | string | `rc-dropdown` | | transitionName | Popup transition class name | string | - | | trigger | Trigger action | `ActionType \| ActionType[]` | `['hover']` | -| visible | Controlled visible state | boolean | - | +| open | Controlled open state | boolean | - | | onOverlayClick | Callback when overlay is clicked | `(event: Event) => void` | - | -| onVisibleChange | Callback when visibility changes | `(visible: boolean) => void` | - | +| onOpenChange | Callback when the open state changes | `(open: boolean) => void` | - | + +`visible` and `onVisibleChange` are deprecated aliases for `open` and `onOpenChange`. When both state props are provided, a defined `open` takes precedence. Both callbacks are called when provided. Clicking the overlay closes an uncontrolled dropdown and calls `onOverlayClick`, without calling either state-change callback. ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index ed96f31..56356cd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -73,9 +73,11 @@ npm start | prefixCls | 组件类名前缀 | string | `rc-dropdown` | | transitionName | 弹层过渡类名 | string | - | | trigger | 触发动作 | `ActionType \| ActionType[]` | `['hover']` | -| visible | 受控可见状态 | boolean | - | +| open | 受控可见状态 | boolean | - | | onOverlayClick | 点击下拉菜单内容时的回调 | `(event: Event) => void` | - | -| onVisibleChange | 可见性变化时的回调 | `(visible: boolean) => void` | - | +| onOpenChange | 可见性变化时的回调 | `(open: boolean) => void` | - | + +`visible` 和 `onVisibleChange` 已弃用,请改用 `open` 和 `onOpenChange`。同时传入两个状态属性时,已定义的 `open` 优先;同时传入两个回调时,两者都会调用。点击浮层会关闭非受控下拉菜单并调用 `onOverlayClick`,不会调用这两个状态变化回调。 ## 本地开发 diff --git a/docs/examples/arrow.jsx b/docs/examples/arrow.jsx index 224fc06..e291a92 100644 --- a/docs/examples/arrow.jsx +++ b/docs/examples/arrow.jsx @@ -7,8 +7,8 @@ function onSelect({ key }) { console.log(`${key} selected`); } -function onVisibleChange(visible) { - console.log(visible); +function onOpenChange(open) { + console.log(open); } const menu = ( @@ -30,7 +30,7 @@ export default function Arrow() { trigger={['click']} overlay={menu} animation="slide-up" - onVisibleChange={onVisibleChange} + onOpenChange={onOpenChange} > @@ -42,7 +42,7 @@ export default function Arrow() { trigger={['click']} overlay={menu} animation="slide-up" - onVisibleChange={onVisibleChange} + onOpenChange={onOpenChange} > diff --git a/docs/examples/multiple.jsx b/docs/examples/multiple.jsx index 34f33df..b117ddf 100644 --- a/docs/examples/multiple.jsx +++ b/docs/examples/multiple.jsx @@ -5,13 +5,13 @@ import '../../assets/index.less'; class Test extends Component { state = { - visible: false, + open: false, }; - onVisibleChange = (visible) => { - console.log('visible', visible); + onOpenChange = (open) => { + console.log('open', open); this.setState({ - visible, + open, }); }; @@ -24,7 +24,7 @@ class Test extends Component { confirm = () => { console.log(this.selected); this.setState({ - visible: false, + open: false, }); }; @@ -57,8 +57,8 @@ class Test extends Component { return ( ( @@ -29,7 +29,7 @@ export default function OverlayCallback() { trigger={['click']} overlay={menuCallback} animation="slide-up" - onVisibleChange={onVisibleChange} + onOpenChange={onOpenChange} > diff --git a/docs/examples/simple.jsx b/docs/examples/simple.jsx index f2f0fec..a672dbb 100644 --- a/docs/examples/simple.jsx +++ b/docs/examples/simple.jsx @@ -8,8 +8,8 @@ function onSelect({ key }) { console.log(`${key} selected`); } -function onVisibleChange(visible) { - console.log(visible); +function onOpenChange(open) { + console.log(open); } const menu = ( @@ -31,7 +31,7 @@ export default function Simple() { trigger={['click']} overlay={menu} animation="slide-up" - onVisibleChange={onVisibleChange} + onOpenChange={onOpenChange} > diff --git a/src/Dropdown.tsx b/src/Dropdown.tsx index f2deba4..c03f7a3 100644 --- a/src/Dropdown.tsx +++ b/src/Dropdown.tsx @@ -25,6 +25,8 @@ export interface DropdownProps > { minOverlayWidthMatchTrigger?: boolean; arrow?: boolean; + onOpenChange?: (open: boolean) => void; + /** @deprecated Use `onOpenChange` instead. */ onVisibleChange?: (visible: boolean) => void; onOverlayClick?: (e: Event) => void; prefixCls?: string; @@ -41,6 +43,8 @@ export interface DropdownProps alignPoint?: boolean; showAction?: ActionType[]; hideAction?: ActionType[]; + open?: boolean; + /** @deprecated Use `open` instead. */ visible?: boolean; autoFocus?: boolean; } @@ -59,18 +63,25 @@ const Dropdown = React.forwardRef((props, ref) => { hideAction, overlayClassName, overlayStyle, + open, visible, trigger = ['hover'], autoFocus, overlay, children, + onOpenChange, onVisibleChange, disabled, ...otherProps } = props as DropdownProps & { disabled?: boolean }; - const [triggerVisible, setTriggerVisible] = React.useState(); - const mergedVisible = 'visible' in props ? visible : triggerVisible; + const [triggerOpen, setTriggerOpen] = React.useState(); + let mergedOpen = triggerOpen; + if (open !== undefined) { + mergedOpen = open; + } else if ('visible' in props) { + mergedOpen = visible; + } const mergedMotionName = animation ? `${prefixCls}-${animation}` : transitionName; @@ -80,22 +91,23 @@ const Dropdown = React.forwardRef((props, ref) => { const childRef = React.useRef(null); React.useImperativeHandle(ref, () => triggerRef.current); - const handleVisibleChange = (newVisible: boolean) => { - setTriggerVisible(newVisible); - onVisibleChange?.(newVisible); + const handleOpenChange = (newOpen: boolean) => { + setTriggerOpen(newOpen); + onOpenChange?.(newOpen); + onVisibleChange?.(newOpen); }; useAccessibility({ - visible: mergedVisible, + open: mergedOpen, triggerRef: childRef, - onVisibleChange: handleVisibleChange, + onOpenChange: handleOpenChange, autoFocus, overlayRef, }); const onClick = (e) => { const { onOverlayClick } = props; - setTriggerVisible(false); + setTriggerOpen(false); if (onOverlayClick) { onOverlayClick(e); @@ -140,7 +152,7 @@ const Dropdown = React.forwardRef((props, ref) => { >; const childClassName = clsx( elementChild.props?.className, - mergedVisible && getOpenClassName(), + mergedOpen && getOpenClassName(), ); const triggerChildProps: React.HTMLAttributes & React.RefAttributes = { @@ -148,21 +160,20 @@ const Dropdown = React.forwardRef((props, ref) => { ref: composeRef(childRef, getNodeRef(elementChild)), }; - const childrenNode = - supportRef(elementChild) ? ( - React.cloneElement( - elementChild as React.ReactElement< - React.HTMLAttributes & React.RefAttributes - >, - triggerChildProps, - ) - ) : ( - - {React.cloneElement(elementChild, { - className: childClassName, - })} - - ); + const childrenNode = supportRef(elementChild) ? ( + React.cloneElement( + elementChild as React.ReactElement< + React.HTMLAttributes & React.RefAttributes + >, + triggerChildProps, + ) + ) : ( + + {React.cloneElement(elementChild, { + className: childClassName, + })} + + ); let triggerHideAction = hideAction; if (!triggerHideAction && trigger.indexOf('contextMenu') !== -1) { @@ -185,10 +196,10 @@ const Dropdown = React.forwardRef((props, ref) => { popupPlacement={placement} popupAlign={align} popupMotion={{ motionName: mergedMotionName }} - popupVisible={mergedVisible} + popupVisible={mergedOpen} stretch={getMinOverlayWidthMatchTrigger() ? 'minWidth' : ''} popup={getMenuElementOrLambda()} - onOpenChange={handleVisibleChange} + onOpenChange={handleOpenChange} onPopupClick={onClick} getPopupContainer={getPopupContainer} > diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 2fdc744..7f31291 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -4,26 +4,26 @@ import * as React from 'react'; const { ESC, TAB } = KeyCode; interface UseAccessibilityProps { - visible: boolean; + open: boolean; triggerRef: React.RefObject; - onVisibleChange?: (visible: boolean) => void; + onOpenChange?: (open: boolean) => void; autoFocus?: boolean; overlayRef?: React.RefObject; } export default function useAccessibility({ - visible, + open, triggerRef, - onVisibleChange, + onOpenChange, autoFocus, overlayRef, }: UseAccessibilityProps) { const focusMenuRef = React.useRef(false); const handleCloseMenuAndReturnFocus = () => { - if (visible) { + if (open) { triggerRef.current?.focus?.(); - onVisibleChange?.(false); + onOpenChange?.(false); } }; @@ -58,7 +58,7 @@ export default function useAccessibility({ }; React.useEffect(() => { - if (visible) { + if (open) { window.addEventListener('keydown', handleKeyDown); if (autoFocus) { // FIXME: hack with raf @@ -72,5 +72,5 @@ export default function useAccessibility({ return () => { focusMenuRef.current = false; }; - }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps } diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx index 9feda1a..f6a6e41 100644 --- a/tests/basic.test.tsx +++ b/tests/basic.test.tsx @@ -62,9 +62,9 @@ describe('dropdown', () => { jest.clearAllTimers(); }); - it('default visible', () => { + it('default open', () => { const { container } = render( - Test} visible> + Test} open> , ); @@ -76,14 +76,14 @@ describe('dropdown', () => { ).toBeTruthy(); }); - it('supports controlled visible prop', () => { - const onVisibleChange = jest.fn(); + it('supports controlled open prop', () => { + const onOpenChange = jest.fn(); const { container } = render( Test} - visible + open trigger={['click']} - onVisibleChange={onVisibleChange} + onOpenChange={onOpenChange} > , @@ -96,7 +96,7 @@ describe('dropdown', () => { ).toBeTruthy(); fireEvent.click(container.querySelector('.my-button')); - expect(onVisibleChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false); }); it('forwards ref to trigger', () => { @@ -203,7 +203,7 @@ describe('dropdown', () => { const overlay =
Test
; const { container, baseElement } = render( - + @@ -230,7 +230,7 @@ describe('dropdown', () => { trigger={['click']} overlay={overlay} minOverlayWidthMatchTrigger={false} - visible + open > ); const { container } = render( - Test} visible> + Test} open> , ); @@ -509,7 +509,7 @@ describe('dropdown', () => { ), - visible: true, + open: true, getPopupContainer: (node) => node, }; @@ -530,7 +530,7 @@ describe('dropdown', () => { foo ), - visible: true, + open: true, }; render( diff --git a/tests/open.test.tsx b/tests/open.test.tsx new file mode 100644 index 0000000..a537f52 --- /dev/null +++ b/tests/open.test.tsx @@ -0,0 +1,141 @@ +import { act, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import Dropdown from '../src'; +import { render } from './utils'; + +describe('open API', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('keeps controlled state until open changes', () => { + const onOpenChange = jest.fn(); + const dropdown = (open: boolean) => ( + menu} + > + + + ); + const { getByRole, rerender } = render(dropdown(false)); + const button = getByRole('button'); + + fireEvent.click(button); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(button).not.toHaveClass('rc-dropdown-open'); + + rerender(dropdown(true)); + expect(button).toHaveClass('rc-dropdown-open'); + fireEvent.click(button); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(button).toHaveClass('rc-dropdown-open'); + + rerender(dropdown(false)); + expect(button).not.toHaveClass('rc-dropdown-open'); + }); + + it.each([27, 9])( + 'notifies onOpenChange and restores focus for key %s', + (keyCode) => { + const onOpenChange = jest.fn(); + const Overlay = () =>
menu
; + const { getByRole } = render( + } + > + + , + ); + const button = getByRole('button'); + fireEvent.click(button); + expect(button).toHaveClass('rc-dropdown-open'); + expect(onOpenChange).toHaveBeenNthCalledWith(1, true); + + fireEvent.keyDown(window, { keyCode }); + expect(onOpenChange).toHaveBeenNthCalledWith(2, false); + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(button).not.toHaveClass('rc-dropdown-open'); + expect(button).toHaveFocus(); + }, + ); + + it('preserves overlay click callback behavior', () => { + const onOpenChange = jest.fn(); + const onOverlayClick = jest.fn(); + const { getByRole, getByText } = render( + menu} + > + + , + ); + const button = getByRole('button'); + fireEvent.click(button); + act(() => jest.runAllTimers()); + fireEvent.click(getByText('menu')); + expect(button).not.toHaveClass('rc-dropdown-open'); + expect(onOverlayClick).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('supports deprecated state and callback', () => { + const onVisibleChange = jest.fn(); + const { getByRole } = render( + menu} + > + + , + ); + const button = getByRole('button'); + expect(button).toHaveClass('rc-dropdown-open'); + fireEvent.click(button); + expect(onVisibleChange).toHaveBeenCalledWith(false); + expect(button).toHaveClass('rc-dropdown-open'); + }); + + it.each([true, false])( + 'prefers open=%s over visible and calls both callbacks', + (open) => { + const onOpenChange = jest.fn(); + const onVisibleChange = jest.fn(); + const { getByRole } = render( + menu} + > + + , + ); + const button = getByRole('button'); + expect(button.classList.contains('rc-dropdown-open')).toBe(open); + fireEvent.click(button); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(!open); + expect(onVisibleChange).toHaveBeenCalledTimes(1); + expect(onVisibleChange).toHaveBeenCalledWith(!open); + }, + ); +}); diff --git a/tests/props.test.tsx b/tests/props.test.tsx index 6eae17e..9f98caa 100644 --- a/tests/props.test.tsx +++ b/tests/props.test.tsx @@ -22,12 +22,15 @@ it('does not forward disabled to Trigger', () => { const runtimeProps = { disabled: true }; render( - }> + }> , ); const triggerProps = mockTriggerRender.mock.calls[0][0]; expect(triggerProps).not.toHaveProperty('disabled'); + expect(triggerProps).not.toHaveProperty('open'); + expect(triggerProps).not.toHaveProperty('visible'); + expect(triggerProps).not.toHaveProperty('onVisibleChange'); expect(triggerProps).toHaveProperty('popupVisible', true); }); From 7028c124377ce8c897c1bf8687e25e475f315867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Tue, 8 Sep 2026 11:17:50 +0800 Subject: [PATCH 2/4] test: update dropdown menu ID snapshot --- tests/__snapshots__/basic.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/__snapshots__/basic.test.tsx.snap b/tests/__snapshots__/basic.test.tsx.snap index ad46ebc..c771852 100644 --- a/tests/__snapshots__/basic.test.tsx.snap +++ b/tests/__snapshots__/basic.test.tsx.snap @@ -20,7 +20,7 @@ exports[`dropdown simply works 1`] = ` >