diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 2ddb77c..0000000 --- a/jest.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - setupFiles: ["./tests/setup.js"], - snapshotSerializers: [require.resolve("enzyme-to-json/serializer")] -}; diff --git a/package.json b/package.json index 73f9703..679c931 100644 --- a/package.json +++ b/package.json @@ -7,65 +7,75 @@ "react-dropdown" ], "homepage": "http://github.com/react-component/dropdown", - "maintainers": [ - "yiminghe@gmail.com", - "hualei5280@gmail.com" - ], + "bugs": { + "url": "http://github.com/react-component/dropdown/issues" + }, "repository": { "type": "git", "url": "git@github.com:react-component/dropdown.git" }, - "bugs": { - "url": "http://github.com/react-component/dropdown/issues" - }, + "license": "MIT", + "maintainers": [ + "yiminghe@gmail.com", + "hualei5280@gmail.com" + ], + "main": "lib/index", + "module": "./es/index", "files": [ "lib", "es", "assets/*.css" ], - "main": "lib/index", - "module": "./es/index", - "license": "MIT", "scripts": { - "start": "dumi dev", "build": "dumi build", "compile": "father build && lessc assets/index.less assets/index.css", - "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish", + "coverage": "rc-test --coverage", "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", - "test": "father test", - "coverage": "father test --coverage", - "now-build": "npm run build" + "now-build": "npm run build", + "prepare": "husky install", + "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish", + "start": "dumi dev", + "test": "rc-test" + }, + "lint-staged": { + "**/*.{js,jsx,tsx,ts,md,json}": [ + "prettier --write", + "git add" + ] + }, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^1.7.0", + "classnames": "^2.2.6", + "rc-util": "^5.17.0" }, "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", "@types/classnames": "^2.2.6", - "@types/enzyme": "^3.1.15", - "@types/jest": "^26.0.12", - "@types/react": "^16.8.19", - "@types/react-dom": "^16.8.4", + "@types/jest": "^29.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", "@types/warning": "^3.0.0", "cross-env": "^7.0.0", "dumi": "^1.1.38", - "enzyme": "^3.3.0", - "enzyme-adapter-react-16": "^1.0.2", - "enzyme-to-json": "^3.4.0", "father": "^2.13.2", + "husky": "^8.0.3", + "jest-environment-jsdom": "^29.5.0", "jquery": "^3.3.1", "less": "^3.11.1", + "lint-staged": "^13.2.1", "np": "^6.0.0", + "prettier": "^2.8.7", "rc-menu": "^9.5.2", - "react": "^16.11.0", - "react-dom": "^16.11.0", + "rc-test": "^7.0.14", + "react": "^18.0.0", + "react-dom": "^18.0.0", "regenerator-runtime": "^0.13.9", "typescript": "^4.0.2" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" - }, - "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.6", - "rc-trigger": "^5.3.1", - "rc-util": "^5.17.0" } } diff --git a/src/Dropdown.tsx b/src/Dropdown.tsx index 8853529..58e8709 100644 --- a/src/Dropdown.tsx +++ b/src/Dropdown.tsx @@ -1,15 +1,18 @@ import * as React from 'react'; -import Trigger from 'rc-trigger'; -import type { TriggerProps } from 'rc-trigger'; +import Trigger from '@rc-component/trigger'; +import type { TriggerProps } from '@rc-component/trigger'; import classNames from 'classnames'; import type { AnimationType, AlignType, BuildInPlacements, ActionType, -} from 'rc-trigger/lib/interface'; +} from '@rc-component/trigger/lib/interface'; import Placements from './placements'; import useAccessibility from './hooks/useAccessibility'; +import Overlay from './Overlay'; +import { composeRef, supportRef } from 'rc-util/lib/ref'; +import { ReactElement } from 'react'; export interface DropdownProps extends Pick< @@ -60,6 +63,9 @@ function Dropdown(props: DropdownProps, ref) { visible, trigger = ['hover'], autoFocus, + overlay, + children, + onVisibleChange, ...otherProps } = props; @@ -67,27 +73,23 @@ function Dropdown(props: DropdownProps, ref) { const mergedVisible = 'visible' in props ? visible : triggerVisible; const triggerRef = React.useRef(null); + const overlayRef = React.useRef(null); + const childRef = React.useRef(null); React.useImperativeHandle(ref, () => triggerRef.current); + const handleVisibleChange = (newVisible: boolean) => { + setTriggerVisible(newVisible); + onVisibleChange?.(newVisible); + }; + useAccessibility({ visible: mergedVisible, - setTriggerVisible, - triggerRef, - onVisibleChange: props.onVisibleChange, + triggerRef: childRef, + onVisibleChange: handleVisibleChange, autoFocus, + overlayRef, }); - const getOverlayElement = (): React.ReactElement => { - const { overlay } = props; - let overlayElement: React.ReactElement; - if (typeof overlay === 'function') { - overlayElement = overlay(); - } else { - overlayElement = overlay; - } - return overlayElement; - }; - const onClick = (e) => { const { onOverlayClick } = props; setTriggerVisible(false); @@ -97,27 +99,9 @@ function Dropdown(props: DropdownProps, ref) { } }; - const onVisibleChange = (newVisible: boolean) => { - const { onVisibleChange: onVisibleChangeProp } = props; - setTriggerVisible(newVisible); - if (typeof onVisibleChangeProp === 'function') { - onVisibleChangeProp(newVisible); - } - }; - - const getMenuElement = () => { - const overlayElement = getOverlayElement(); - - return ( - <> - {arrow &&
} - {overlayElement} - - ); - }; + const getMenuElement = () => const getMenuElementOrLambda = () => { - const { overlay } = props; if (typeof overlay === 'function') { return getMenuElement; } @@ -141,16 +125,10 @@ function Dropdown(props: DropdownProps, ref) { return `${prefixCls}-open`; }; - const renderChildren = () => { - const { children } = props; - const childrenProps = children.props ? children.props : {}; - const childClassName = classNames(childrenProps.className, getOpenClassName()); - return mergedVisible && children - ? React.cloneElement(children, { - className: childClassName, - }) - : children; - }; + const childrenNode = React.cloneElement(children, { + className: classNames(children.props?.className, mergedVisible && getOpenClassName()), + ref: supportRef(children) ? composeRef(childRef, (children as ReactElement & {ref: React.Ref}).ref) : undefined, + }) let triggerHideAction = hideAction; if (!triggerHideAction && trigger.indexOf('contextMenu') !== -1) { @@ -169,7 +147,7 @@ function Dropdown(props: DropdownProps, ref) { popupStyle={overlayStyle} action={trigger} showAction={showAction} - hideAction={triggerHideAction || []} + hideAction={triggerHideAction} popupPlacement={placement} popupAlign={align} popupTransitionName={transitionName} @@ -177,11 +155,11 @@ function Dropdown(props: DropdownProps, ref) { popupVisible={mergedVisible} stretch={getMinOverlayWidthMatchTrigger() ? 'minWidth' : ''} popup={getMenuElementOrLambda()} - onPopupVisibleChange={onVisibleChange} + onPopupVisibleChange={handleVisibleChange} onPopupClick={onClick} getPopupContainer={getPopupContainer} > - {renderChildren()} + {childrenNode} ); } diff --git a/src/Overlay.tsx b/src/Overlay.tsx new file mode 100644 index 0000000..ee1c539 --- /dev/null +++ b/src/Overlay.tsx @@ -0,0 +1,30 @@ +import React, { forwardRef, ReactElement, useMemo } from 'react'; +import type { DropdownProps } from './Dropdown'; +import { composeRef, supportRef } from 'rc-util/lib/ref'; + +export type OverlayProps = Pick + +const Overlay = forwardRef((props, ref) => { + const {overlay, arrow, prefixCls} = props; + + const overlayNode = useMemo(() => { + let overlayElement: React.ReactElement; + if (typeof overlay === 'function') { + overlayElement = overlay(); + } else { + overlayElement = overlay; + } + return overlayElement; + }, [overlay]); + + const composedRef = composeRef(ref, (overlayNode as ReactElement & {ref: React.Ref})?.ref); + + return ( + <> + {arrow &&
} + {React.cloneElement(overlayNode, { ref: supportRef(overlayNode) ? composedRef : undefined })} + + ) +}); + +export default Overlay; \ No newline at end of file diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index cc01deb..8d2850d 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -1,43 +1,36 @@ -import * as React from 'react'; -import KeyCode from 'rc-util/lib/KeyCode'; -import raf from 'rc-util/lib/raf'; -import { getFocusNodeList } from 'rc-util/lib/Dom/focus'; +import KeyCode from "rc-util/lib/KeyCode"; +import raf from "rc-util/lib/raf"; +import * as React from "react"; const { ESC, TAB } = KeyCode; interface UseAccessibilityProps { visible: boolean; - setTriggerVisible: (visible: boolean) => void; triggerRef: React.RefObject; onVisibleChange?: (visible: boolean) => void; autoFocus?: boolean; + overlayRef?: React.RefObject; } export default function useAccessibility({ visible, - setTriggerVisible, triggerRef, onVisibleChange, autoFocus, + overlayRef, }: UseAccessibilityProps) { const focusMenuRef = React.useRef(false); const handleCloseMenuAndReturnFocus = () => { - if (visible && triggerRef.current) { - triggerRef.current?.triggerRef?.current?.focus?.(); - setTriggerVisible(false); - if (typeof onVisibleChange === 'function') { - onVisibleChange(false); - } + if (visible) { + triggerRef.current?.focus?.(); + onVisibleChange?.(false); } }; const focusMenu = () => { - const elements = getFocusNodeList(triggerRef.current?.popupRef?.current?.getElement?.()); - const firstElement = elements[0]; - - if (firstElement?.focus) { - firstElement.focus(); + if (overlayRef.current?.focus) { + overlayRef.current.focus(); focusMenuRef.current = true; return true; } @@ -67,13 +60,13 @@ export default function useAccessibility({ React.useEffect(() => { if (visible) { - window.addEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); if (autoFocus) { // FIXME: hack with raf raf(focusMenu, 3); } return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener("keydown", handleKeyDown); focusMenuRef.current = false; }; } diff --git a/tests/__mocks__/@rc-component/trigger.tsx b/tests/__mocks__/@rc-component/trigger.tsx new file mode 100644 index 0000000..887c966 --- /dev/null +++ b/tests/__mocks__/@rc-component/trigger.tsx @@ -0,0 +1,3 @@ +import Trigger from '@rc-component/trigger/lib/mock'; + +export default Trigger; diff --git a/tests/__mocks__/rc-trigger.js b/tests/__mocks__/rc-trigger.js deleted file mode 100644 index 3230307..0000000 --- a/tests/__mocks__/rc-trigger.js +++ /dev/null @@ -1,3 +0,0 @@ -import Trigger from 'rc-trigger/lib/mock'; - -export default Trigger; diff --git a/tests/__snapshots__/basic.test.js.snap b/tests/__snapshots__/basic.test.js.snap deleted file mode 100644 index 140945b..0000000 --- a/tests/__snapshots__/basic.test.js.snap +++ /dev/null @@ -1,53 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dropdown simply works 1`] = ` -Array [ - , -
-
- - -
, -] -`; diff --git a/tests/__snapshots__/basic.test.tsx.snap b/tests/__snapshots__/basic.test.tsx.snap new file mode 100644 index 0000000..14c07ac --- /dev/null +++ b/tests/__snapshots__/basic.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dropdown simply works 1`] = ` +
+ +
+ + +
+`; diff --git a/tests/basic.test.js b/tests/basic.test.js deleted file mode 100644 index c0fdc72..0000000 --- a/tests/basic.test.js +++ /dev/null @@ -1,475 +0,0 @@ -/* eslint-disable react/button-has-type,react/no-find-dom-node,react/no-render-return-value,object-shorthand,func-names,max-len */ -import React, { createRef, forwardRef, useImperativeHandle } from 'react'; -import { mount } from 'enzyme'; -import Menu, { Divider, Item as MenuItem } from 'rc-menu'; -import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; -import { getPopupDomNode, sleep } from './utils'; -import Dropdown from '../src'; -import placements from '../src/placements'; -import '../assets/index.less'; - -spyElementPrototypes(HTMLElement, { - offsetParent: { - get: () => document.body, - }, - offsetLeft: { - get: function () { - return parseFloat(window.getComputedStyle(this).marginLeft) || 0; - }, - }, - offsetTop: { - get: function () { - return parseFloat(window.getComputedStyle(this).marginTop) || 0; - }, - }, - offsetHeight: { - get: function () { - return parseFloat(window.getComputedStyle(this).height) || 0; - }, - }, - offsetWidth: { - get: function () { - return parseFloat(window.getComputedStyle(this).width) || 0; - }, - }, -}); - -describe('dropdown', () => { - it('default visible', () => { - const dropdown = mount( - Test
} visible> - - , - ); - expect(getPopupDomNode(dropdown) instanceof HTMLDivElement).toBeTruthy(); - expect(dropdown.find('.my-button').hasClass('rc-dropdown-open')).toBe(true); - }); - - it('supports constrolled visible prop', () => { - const onVisibleChange = jest.fn(); - const dropdown = mount( - Test
} - visible - trigger={['click']} - onVisibleChange={onVisibleChange} - > - - , - ); - expect(getPopupDomNode(dropdown) instanceof HTMLDivElement).toBeTruthy(); - expect(dropdown.find('.my-button').hasClass('rc-dropdown-open')).toBe(true); - - dropdown.find('.my-button').simulate('click'); - expect(onVisibleChange).toHaveBeenCalledWith(false); - }); - - it('simply works', async () => { - let clicked; - - function onClick({ key }) { - clicked = key; - } - - const onOverlayClick = jest.fn(); - - const menu = ( - - - one - - - two - - ); - const dropdown = mount( - - - , - ); - expect(dropdown.find('.my-button')).toBeTruthy(); - expect(dropdown.find('.rc-dropdown')).toBeTruthy(); - - dropdown.find('.my-button').simulate('click'); - expect(clicked).toBeUndefined(); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - expect(dropdown.render()).toMatchSnapshot(); - - dropdown.find('.my-menuitem').simulate('click'); - expect(clicked).toBe('1'); - expect(onOverlayClick).toHaveBeenCalled(); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(true); - }); - - it('re-align works', async () => { - const buttonStyle = { width: 600, height: 20, marginLeft: 100 }; - const menu = ( - - one - - ); - const dropdown = mount( - - - , - ); - - dropdown.find('.my-btn').simulate('click'); - await sleep(500); - expect(getPopupDomNode(dropdown).getAttribute('style')).toEqual( - expect.stringContaining( - `left: -${999 - buttonStyle.width - placements.bottomLeft.offset[0]}px; top: -${ - 999 - buttonStyle.height - placements.bottomLeft.offset[1] - }px;`, - ), - ); - }); - - // https://github.com/ant-design/ant-design/issues/9559 - it('should have correct menu width when switch from shorter menu to longer', async () => { - class Example extends React.Component { - state = { longList: true }; - - getPopupDomNode() { - return this.trigger.getPopupDomNode(); - } - - short = () => { - this.setState({ longList: false }); - }; - - long = () => { - this.setState({ longList: true }); - }; - - render() { - const menuItems = [ - 1st item, - 2nd item, - ]; - if (this.state.longList) { - menuItems.push(3rd LONG SUPER LONG item); - } - return ( - { - this.trigger = node; - }} - overlay={{menuItems}} - > - - - ); - } - } - const dropdown = mount(); - dropdown.find('button').simulate('click'); - await sleep(500); - expect(getPopupDomNode(dropdown).getAttribute('style')).toEqual( - expect.stringContaining( - `left: -${999 - placements.bottomLeft.offset[0]}px; top: -${ - 999 - placements.bottomLeft.offset[1] - }px;`, - ), - ); - - // Todo - offsetwidth - }); - - it('Test default minOverlayWidthMatchTrigger', async () => { - const overlayWidth = 50; - const overlay =
Test
; - - const dropdown = mount( - - - , - ); - - dropdown.find('.my-button').simulate('click'); - await sleep(500); - expect(getPopupDomNode(dropdown).getAttribute('style')).toEqual( - expect.stringContaining('min-width: 100px'), - ); - }); - - it('user pass minOverlayWidthMatchTrigger', async () => { - const overlayWidth = 50; - const overlay =
Test
; - - const dropdown = mount( - - - , - ); - - dropdown.find('.my-button').simulate('click'); - await sleep(500); - expect(getPopupDomNode(dropdown).getAttribute('style')).not.toEqual( - expect.stringContaining('min-width: 100px'), - ); - }); - - it('should support default openClassName', () => { - const overlay =
Test
; - const dropdown = mount( - - - , - ); - dropdown.find('.my-button').simulate('click'); - expect(dropdown.find('.my-button').prop('className')).toBe('my-button rc-dropdown-open'); - dropdown.find('.my-button').simulate('click'); - expect(dropdown.find('.my-button').prop('className')).toBe('my-button'); - }); - - it('should support custom openClassName', async () => { - const overlay =
Test
; - const dropdown = mount( - - - , - ); - - dropdown.find('.my-button').simulate('click'); - expect(dropdown.find('.my-button').prop('className')).toBe('my-button opened'); - dropdown.find('.my-button').simulate('click'); - expect(dropdown.find('.my-button').prop('className')).toBe('my-button'); - }); - - it('overlay callback', async () => { - const overlay =
Test
; - const dropdown = mount( - overlay}> - - , - ); - - dropdown.find('.my-button').simulate('click'); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - }); - - it('should support arrow', async () => { - const overlay =
Test
; - const dropdown = mount( - - - , - ); - - dropdown.find('.my-button').simulate('click'); - await sleep(500); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-show-arrow')).toBe(true); - expect( - getPopupDomNode(dropdown).firstElementChild.classList.contains('rc-dropdown-arrow'), - ).toBe(true); - }); - - it('Keyboard navigation works', async () => { - const overlay = ( - - - one - - two - - ); - const dropdown = mount( - - - , - ); - const trigger = dropdown.find('.my-button'); - - // Open menu - trigger.simulate('click'); - await sleep(200); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - - // Close menu with Esc - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })); // Esc - await sleep(200); - expect(document.activeElement.className).toContain('my-button'); - - // Open menu - trigger.simulate('click'); - await sleep(200); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - - // Focus menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - expect(document.activeElement.className).toContain('menu'); - - // Close menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - await sleep(200); - expect(document.activeElement.className).toContain('my-button'); - }); - - it('keyboard should work if menu is wrapped', async () => { - const overlay = ( -
- - - one - - two - -
- ); - const dropdown = mount( - - - , - ); - const trigger = dropdown.find('.my-button'); - - // Open menu - trigger.simulate('click'); - await sleep(200); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - - // Close menu with Esc - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })); // Esc - await sleep(200); - expect(document.activeElement.className).toContain('my-button'); - - // Open menu - trigger.simulate('click'); - await sleep(200); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - - // Focus menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - - // Close menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - await sleep(200); - expect(document.activeElement.className).toContain('my-button'); - }); - - it('support Menu expandIcon', async () => { - const props = { - overlay: ( - }> - foo - - foo - - - ), - visible: true, - getPopupContainer: (node) => node, - }; - - const wrapper = mount( - - - , - ); - await sleep(500); - expect(wrapper.find(Dropdown).find('#customExpandIcon').length).toBe(1); - }); - - it('should support customized menuRef', async () => { - const menuRef = createRef(); - const props = { - overlay: ( - - foo - - ), - visible: true, - }; - - const wrapper = mount( - - - , - ); - - await sleep(500); - expect(menuRef.current).toBeTruthy(); - }); - - it('should support trigger which not support focus', async () => { - jest.useFakeTimers(); - const Button = forwardRef((props, ref) => { - useImperativeHandle(ref, () => ({ - foo: () => {}, - })); - return ( - - ); - }); - const wrapper = mount( - node} - overlay={ - - foo - - } - > - - , - ); - const trigger = dropdown.find('.my-button'); - - // Open menu - trigger.simulate('click'); - await sleep(200); - expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false); - expect(document.activeElement.className).toContain('menu'); - - // Close menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - await sleep(200); - expect(document.activeElement.className).toContain('my-button'); - }); -}); diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx new file mode 100644 index 0000000..e4d8c36 --- /dev/null +++ b/tests/basic.test.tsx @@ -0,0 +1,601 @@ +/* eslint-disable react/button-has-type,react/no-find-dom-node,react/no-render-return-value,object-shorthand,func-names,max-len */ +import { act, fireEvent } from "@testing-library/react"; +import Menu, { Divider, Item as MenuItem, MenuRef } from "rc-menu"; +import { _rs } from "rc-resize-observer"; +import { spyElementPrototypes } from "rc-util/lib/test/domHook"; +import * as React from "react"; +import { + createRef, + forwardRef, + HTMLAttributes, + useImperativeHandle, +} from "react"; +import Dropdown from "../src"; +import { render, sleep } from "./utils"; + +// Fix prettier rm this +console.log(!!React); + +async function waitForTime() { + for (let i = 0; i < 10; i += 1) { + await act(async () => { + jest.runAllTimers(); + }); + } +} + +async function triggerResize(target: Element) { + act(() => { + _rs([{ target } as ResizeObserverEntry]); + }); + + await waitForTime(); +} + +spyElementPrototypes(HTMLElement, { + offsetParent: { + get: () => document.body, + }, + offsetLeft: { + get: function () { + return parseFloat(window.getComputedStyle(this).marginLeft) || 0; + }, + }, + offsetTop: { + get: function () { + return parseFloat(window.getComputedStyle(this).marginTop) || 0; + }, + }, + offsetHeight: { + get: function () { + return parseFloat(window.getComputedStyle(this).height) || 0; + }, + }, + offsetWidth: { + get: function () { + return parseFloat(window.getComputedStyle(this).width) || 0; + }, + }, + + getBoundingClientRect: () => ({ + width: 100, + height: 100, + }), +}); + +describe("dropdown", () => { + beforeEach(() => { + jest.clearAllTimers(); + }); + + it("default visible", () => { + const { container } = render( + Test
} visible> + + + ); + expect(container instanceof HTMLDivElement).toBeTruthy(); + expect( + container + .querySelector(".my-button") + ?.classList.contains("rc-dropdown-open") + ).toBeTruthy(); + }); + + it("supports controlled visible prop", () => { + const onVisibleChange = jest.fn(); + const { container } = render( + Test
} + visible + trigger={["click"]} + onVisibleChange={onVisibleChange} + > + + + ); + expect(container instanceof HTMLDivElement).toBeTruthy(); + expect( + container + .querySelector(".my-button") + ?.classList.contains("rc-dropdown-open") + ).toBeTruthy(); + + fireEvent.click(container.querySelector(".my-button")); + expect(onVisibleChange).toHaveBeenCalledWith(false); + }); + + it("simply works", async () => { + let clicked; + + function onClick({ key }) { + clicked = key; + } + + const onOverlayClick = jest.fn(); + + const menu = ( + + + one + + + two + + ); + const { container, baseElement } = render( + + + + ); + expect(container.querySelector(".my-button")).toBeTruthy(); + // should not display until be triggered + expect(baseElement.querySelector(".rc-dropdown")).toBeFalsy(); + + fireEvent.click(container.querySelector(".my-button")); + expect(clicked).toBeUndefined(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + expect(container).toMatchSnapshot(); + + fireEvent.click(baseElement.querySelector(".my-menuitem")); + expect(clicked).toBe("1"); + expect(onOverlayClick).toHaveBeenCalled(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeTruthy(); + }); + + it("re-align works", async () => { + jest.useFakeTimers(); + + const onPopupAlign = jest.fn(); + + const buttonStyle = { width: 600, height: 20, marginLeft: 100 }; + const menu = ( + + one + + ); + const { container } = render( + + + + ); + + expect(onPopupAlign).not.toHaveBeenCalled(); + + fireEvent.click(container.querySelector(".my-btn")); + await waitForTime(); + + expect(onPopupAlign).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it("Test default minOverlayWidthMatchTrigger", async () => { + jest.useFakeTimers(); + + const overlayWidth = 50; + const overlay =
Test
; + + const { container, baseElement } = render( + + + + ); + + await triggerResize(container.querySelector("button")); + + expect(baseElement.querySelector(".rc-dropdown")).toHaveStyle({ + minWidth: "100px", + }); + + jest.useRealTimers(); + }); + + it("user pass minOverlayWidthMatchTrigger", async () => { + jest.useFakeTimers(); + + const overlayWidth = 50; + const overlay =
Test
; + + const { container, baseElement } = render( + + + + ); + + await triggerResize(container.querySelector("button")); + + expect(baseElement.querySelector(".rc-dropdown")).not.toHaveStyle({ + minWidth: "100px", + }); + + jest.useRealTimers(); + }); + + it("should support default openClassName", () => { + const overlay =
Test
; + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(".my-button")); + expect( + container + .querySelector(".my-button") + .classList.contains("rc-dropdown-open") + ).toBeTruthy(); + fireEvent.click(container.querySelector(".my-button")); + expect( + container + .querySelector(".my-button") + .classList.contains("rc-dropdown-open") + ).toBeFalsy(); + }); + + it("should support custom openClassName", async () => { + const overlay =
Test
; + const { container } = render( + + + + ); + + fireEvent.click(container.querySelector(".my-button")); + expect( + container.querySelector(".my-button").classList.contains("opened") + ).toBeTruthy(); + fireEvent.click(container.querySelector(".my-button")); + expect( + container.querySelector(".my-button").classList.contains("opened") + ).toBeFalsy(); + }); + + it("overlay callback", async () => { + const overlay =
Test
; + const { container, baseElement } = render( + overlay}> + + + ); + + fireEvent.click(container.querySelector(".my-button")); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + }); + + it("should support arrow", async () => { + const overlay =
Test
; + const { container, baseElement } = render( + + + + ); + + fireEvent.click(container.querySelector(".my-button")); + await sleep(500); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-show-arrow") + ).toBeTruthy(); + expect( + baseElement + .querySelector(".rc-dropdown") + .firstElementChild.classList.contains("rc-dropdown-arrow") + ).toBeTruthy(); + }); + + it("Keyboard navigation works", async () => { + jest.useFakeTimers(); + + const overlay = ( + + + one + + two + + ); + const { container, baseElement } = render( + + + + ); + const trigger = container.querySelector(".my-button"); + + // Open menu; + fireEvent.click(trigger); + await waitForTime(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Close menu with Esc + fireEvent.keyDown(window, { key: "Esc", keyCode: 27 }); + await waitForTime(); + expect(document.activeElement.className).toContain("my-button"); + + // Open menu + fireEvent.click(trigger); + await waitForTime(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Focus menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + expect(document.activeElement.className).toContain("menu"); + + // Close menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + await waitForTime(); + expect(document.activeElement.className).toContain("my-button"); + + jest.useRealTimers(); + }); + + it("Tab should close menu if overlay cannot be focused", async () => { + jest.useFakeTimers(); + + const Overlay = () =>
test
; + const { container, baseElement } = render( + }> + + + ); + const trigger = container.querySelector(".my-button"); + + // Open menu; + fireEvent.click(trigger); + await waitForTime(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Close menu with Esc + fireEvent.keyDown(window, { key: "Esc", keyCode: 27 }); + await waitForTime(); + expect(document.activeElement.className).toContain("my-button"); + + // Open menu + fireEvent.click(trigger); + await waitForTime(); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Close menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + await waitForTime(); + expect(document.activeElement.className).toContain("my-button"); + + jest.useRealTimers(); + }); + + it("keyboard should work if menu is wrapped", async () => { + const overlay = ( +
+ + + one + + two + +
+ ); + const { container, baseElement } = render( + + + + ); + const trigger = container.querySelector(".my-button"); + + // Open menu + fireEvent.click(trigger); + await sleep(200); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Close menu with Esc + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 27 })); // Esc + await sleep(200); + expect(document.activeElement.className).toContain("my-button"); + + // Open menu + fireEvent.click(trigger); + await sleep(200); + expect( + baseElement + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + + // Focus menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + + // Close menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + await sleep(200); + expect(document.activeElement.className).toContain("my-button"); + }); + + it("support Menu expandIcon", async () => { + const props = { + overlay: ( + }> + foo + + foo + + + ), + visible: true, + getPopupContainer: (node) => node, + }; + + const { container } = render( + + + + ); + await sleep(500); + expect(container.querySelector("#customExpandIcon")).toBeTruthy(); + }); + + it("should support customized menuRef", async () => { + const menuRef = createRef(); + const props = { + overlay: ( + + foo + + ), + visible: true, + }; + + render( + + + + ); + + await sleep(500); + expect(menuRef.current).toBeTruthy(); + }); + + it("should support trigger which not support focus", async () => { + jest.useFakeTimers(); + const Button = forwardRef>( + (props, ref) => { + useImperativeHandle(ref, () => ({ + foo: () => {}, + })); + return ( + + ); + } + ); + const { container, baseElement } = render( + node} + overlay={ + + foo + + } + > + + + ); + const trigger = container.querySelector(".my-button"); + + // Open menu + fireEvent.click(trigger); + + await waitForTime(); + + expect( + container + .querySelector(".rc-dropdown") + .classList.contains("rc-dropdown-hidden") + ).toBeFalsy(); + expect(document.activeElement.className).toContain("menu"); + + // Close menu with Tab + window.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 9 })); // Tab + + await waitForTime(); + + expect(document.activeElement.className).toContain("my-button"); + + jest.useRealTimers(); + }); +}); diff --git a/tests/point.test.js b/tests/point.test.js deleted file mode 100644 index 94ef7e6..0000000 --- a/tests/point.test.js +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable react/button-has-type,react/no-render-return-value */ -import React from 'react'; -import { mount } from 'enzyme'; -import Dropdown from '../src'; -import placements from '../src/placements'; -import { sleep, getPopupDomNode } from './utils'; - -describe('point', () => { - it('click show', async () => { - const overlay = ( -
- Test -
- ); - - const dropdown = mount( - - - , - ); - - const pageStyle = { - pageX: 9, - pageY: 3, - }; - - dropdown.find('.my-button').simulate('contextmenu', pageStyle); - - await sleep(500); - - expect(getPopupDomNode(dropdown).getAttribute('style')).toEqual( - expect.stringContaining( - `left: -${999 - pageStyle.pageX - placements.bottomLeft.offset[0]}px; top: -${999 - - pageStyle.pageY - - placements.bottomLeft.offset[1]}px;`, - ), - ); - }); -}); diff --git a/tests/point.test.tsx b/tests/point.test.tsx new file mode 100644 index 0000000..d7ed13a --- /dev/null +++ b/tests/point.test.tsx @@ -0,0 +1,62 @@ +/* eslint-disable react/button-has-type,react/no-render-return-value */ +import { act, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import Dropdown from '../src'; +import { render } from './utils'; + +// Fix prettier rm this +console.log(!!React); + +async function waitForTime() { + for (let i = 0; i < 10; i += 1) { + await act(async () => { + jest.runAllTimers(); + }); + } +} + +describe('point', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('click show', async () => { + const overlay = ( +
+ Test +
+ ); + + const onPopupAlign = jest.fn(); + + const { container } = render( + + + , + ); + + fireEvent.contextMenu(container.querySelector('.my-button')); + await waitForTime(); + + expect(container.querySelector('.rc-dropdown')).toBeTruthy(); + }); +}); diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index b4d522f..0000000 --- a/tests/setup.js +++ /dev/null @@ -1,12 +0,0 @@ -global.requestAnimationFrame = - global.requestAnimationFrame || - function requestAnimationFrame(cb) { - return setTimeout(cb, 0); - }; - -require('regenerator-runtime/runtime'); - -const Enzyme = require('enzyme'); -const Adapter = require('enzyme-adapter-react-16'); - -Enzyme.configure({ adapter: new Adapter() }); diff --git a/tests/utils.js b/tests/utils.js index 0ff7b29..66236b7 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -1,15 +1,18 @@ -/* eslint-disable no-param-reassign */ -export function sleep(timeout = 0) { - return new Promise(resolve => { - setTimeout(() => { - resolve(); - }, timeout); +import { StrictMode } from 'react'; +import { render, act } from '@testing-library/react'; + +const globalTimeout = global.setTimeout; + +export async function sleep(timeout = 0) { + await act(async () => { + await new Promise((resolve) => { + globalTimeout(resolve, timeout); + }); }); } -export function getPopupDomNode(wrapper) { - return wrapper - .find('Trigger') - .instance() - .getPopupDomNode(); +function customRender(ui, options) { + return render(ui, { wrapper: StrictMode, ...options }); } + +export { customRender as render }; diff --git a/tsconfig.json b/tsconfig.json index 929df7b..c6bd6b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,10 +5,10 @@ "module": "esnext", "target": "esnext", "moduleResolution": "node", - "jsx": "react", + "jsx": "react-jsx", "skipLibCheck": true }, - "include": ["./src", "./typings/"], + "include": ["./src", "./tests", "./typings/"], "typings": "./typings/index.d.ts", "exclude": [ "node_modules",