diff --git a/.babelrc b/.babelrc index fe2b9d63..e177afed 100644 --- a/.babelrc +++ b/.babelrc @@ -1,42 +1,37 @@ { + "assumptions": { + "setPublicClassFields": true + }, "presets": [ - "react" + "@babel/preset-react" ], "env": { "es6": { "presets": [ [ - "es2015", + "@babel/preset-env", { - "modules": false + "modules": false, + "targets": "defaults and not IE 11", } ] ], - "plugins": [ - "transform-class-properties" - ] }, "es5": { "presets": [ - "es2015" + ["@babel/preset-env", { + "targets": "defaults", + }] ], - "plugins": [ - "transform-class-properties" - ] }, "test": { "presets": [ [ - "env", + "@babel/preset-env", { - "targets": { - "node": 6 - } + "targets": { "node": "current" }, } ] - ], - "plugins": [ - "transform-class-properties" ] } } diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 400887f7..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -examples/bundle.js diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 9fa70bd1..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,11 +0,0 @@ -parser: babel-eslint -extends: - - vkbansal - - vkbansal/react -env: - browser: true -settings: - react: - version: detect -rules: - react/no-array-index-key: 0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..8ee7940c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Automatically request reviews for the dependency updates +/package.json @canova +/yarn.lock @canova diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index dc90e5a1..00000000 --- a/.github/stale.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security -# Label to use when marking an issue as stale -staleLabel: wontfix -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..72e1af72 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: test + +on: + # Trigger the workflow on push or pull request, + # but only for the master branch + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - run: yarn install + - run: yarn lint + - run: yarn test:only + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - run: yarn install + - run: yarn build:dist + - run: yarn build:es5 + - run: yarn build:es6 + - run: yarn build:examples diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 56c062dd..00000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: node_js -node_js: - - 8 -after_success: - - npm run coveralls -script: - - npm test - - bash ./deploy.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37e31c9c..6143994e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,8 @@ Please read https://reactjs.org/ and the Code of Conduct before opening an issue ### Found a bug? ##### Before Submitting A Bug Report -- Please read the [API documentation](https://github.com/vkbansal/react-contextmenu#api) thoroughly -- Perform a [cursory search](https://github.com/vkbansal/react-contextmenu/issues?utf8=%E2%9C%93&q=is%3Aissue) to see if the problem has already been reported. If it has, add a comment to the existing issue instead of opening a new one. +- Please read the [API documentation](https://github.com/firefox-devtools/react-contextmenu#api) thoroughly +- Perform a [cursory search](https://github.com/firefox-devtools/react-contextmenu/issues?utf8=%E2%9C%93&q=is%3Aissue) to see if the problem has already been reported. If it has, add a comment to the existing issue instead of opening a new one. ##### Submiting a (good) bug report @@ -33,19 +33,19 @@ Pull requests need only the 👍 of admin or two or more collaborators to be mer You can run lint + tests via: ``` -npm test +yarn test ``` If you just want to run lint: ``` -npm run lint +yarn lint ``` If you just want to run all tests: ``` -npm run test:only +yarn test:only ``` ##### Development @@ -53,7 +53,7 @@ npm run test:only You can start webpack & dev server that watches for changes and build the examples via: ``` -npm start +yarn start ``` diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 00000000..e68d516a --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,17 @@ +# Publishing a new version of this package + +1. run `yarn publish`. This will ask you the new version, login into npm, build + the package, commit and tag before pushing to npm. + If you want to stop at any moment, you can ctrl-c, and depending on the moment, + you'll need to rollback manually. +2. On github, you need to edit the branch protection rules, and uncheck the + option "Do not allow bypassing the above settings". +3. Then you can push to github with the tags: `git push upstream HEAD --tags`. This + is the only case you're allowed to push directly to github. +4. Check the option "Do not allow bypassing the above settings" again. +5. On github, go to the tags page, then create a new release from the new tag + (this is an option in the "..." menu at the right). +6. Click "Generate releases notes" then follow the format of previous releases. + + + diff --git a/README.md b/README.md index 104da2b4..aeadd3cb 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ -# Project is no longer maintained - -[![NPM version][npm-image]][npm-url] -[![Build Status][travis-image]][travis-url] -[![Dependency Status][deps-image]][deps-url] -[![Dev Dependency Status][dev-deps-image]][dev-deps-url] -[![Code Climate][climate-image]][climate-url] - -[![NPM](https://nodei.co/npm/react-contextmenu.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/react-contextmenu/) - # React Contextmenu ContextMenu in React with accessibility support. Live Examples can be found [here](//vkbansal.github.io/react-contextmenu/) +This is a fork from [the original project](https://github.com/vkbansal/react-contextmenu) for use with the [firefox profiler](https://github.com/firefox-devtools/profiler/). We +don't intend to add new features but will fix issues with the existing code, and +sometimes change it for our usage. We hope it can be useful for more projects. + +Thanks [Vivek Kumar Bansal](https://github.com/vkbansal) for all the good work +put in this project. + ## Table of contents - [Installation](#installation) @@ -28,21 +25,21 @@ ContextMenu in React with accessibility support. Live Examples can be found [her Using npm ``` -npm install --save react-contextmenu +npm install --save @firefox-devtools/react-contextmenu ``` Using yarn ``` -yarn add react-contextmenu +yarn add @firefox-devtools/react-contextmenu ``` ## Browser Support -- IE 11 and Edge >= 12 -- FireFox >= 38 -- Chrome >= 47 -- Opera >= 34 -- Safari >= 8 +- Edge >= 94 +- FireFox >= 91 and 78 +- Chrome >= 92 +- Opera >= 79 +- Safari >= 13.1 ## Usage @@ -51,7 +48,7 @@ Simple example ```jsx import React from "react"; import ReactDOM from "react-dom"; -import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu"; +import { ContextMenu, MenuItem, ContextMenuTrigger } from "@firefox-devtools/react-contextmenu"; function handleClick(e, data) { console.log(data.foo); @@ -104,27 +101,13 @@ see [usage docs](./docs/usage.md) / [examples](./examples) for more details. ## Contributors -[All Contributors](https://github.com/vkbansal/react-contextmenu/graphs/contributors) +[All Contributors](https://github.com/firefox-devtools/react-contextmenu/graphs/contributors) ## Changelog -For Changelog, see [releases](https://github.com/vkbansal/react-contextmenu/releases) +For Changelog, see [releases](https://github.com/firefox-devtools/react-contextmenu/releases) +For the changelog from before the fork, see [releases](https://github.com/vkbansal/react-contextmenu/releases) ## License [MIT](./LICENSE.md). Copyright(c) [Vivek Kumar Bansal](http://vkbansal.me/) - -[npm-url]: https://npmjs.org/package/react-contextmenu -[npm-image]: http://img.shields.io/npm/v/react-contextmenu.svg?style=flat-square - -[travis-url]: https://travis-ci.org/vkbansal/react-contextmenu -[travis-image]: http://img.shields.io/travis/vkbansal/react-contextmenu/master.svg?style=flat-square - -[deps-url]: https://david-dm.org/vkbansal/react-contextmenu -[deps-image]: https://img.shields.io/david/vkbansal/react-contextmenu.svg?style=flat-square - -[dev-deps-url]: https://david-dm.org/vkbansal/react-contextmenu -[dev-deps-image]: https://img.shields.io/david/dev/vkbansal/react-contextmenu.svg?style=flat-square - -[climate-url]: https://codeclimate.com/github/vkbansal/react-contextmenu -[climate-image]: http://img.shields.io/codeclimate/github/vkbansal/react-contextmenu.svg?style=flat-square diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index a02f6293..00000000 --- a/deploy.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# - -echo $TRAVIS_BRANCH - -if ([ "$TRAVIS_BRANCH" != "master" ] && [ -z "$TRAVIS_TAG" ]) || [ "$TRAVIS_PULL_REQUEST" != "false" ]; -then - exit -fi - -set -o errexit - -# build examples -NODE_ENV=production npm run build:examples - -cd public -git init - -git config --global user.name "Travis CI" -git config --global user.email "${USER_EMAIL}" - -git add . -git commit -m "Deploy to gh-pages" - -git push --force --quiet "https://${GITHUB_TOKEN}@github.com/vkbansal/react-contextmenu.git" master:gh-pages > /dev/null 2>&1 diff --git a/docs/api.md b/docs/api.md index f0942e9e..686b68f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,6 +39,7 @@ Contextmenu Trigger Component | holdToDisplay | Number | | `1000` | This is applicable only for touch screens. The time (in ms) for which, user has to hold down his/her finger before the menu is shown. **Note:** To disable the long press trigger on left-click just set a negative holdToDisplay value such as `-1` | | renderTag | String or React Element | | | The element inside which the Component must be wrapped. By default `div` is used. But this prop can used to customize it. | | disableIfShiftIsPressed | Boolean | | `false` | If true and shift is pressed, it will open the native browser context menu and ignore this custom component | +| triggerOnLeftClick | Boolean | | `false` | If true, the menu will open with the left click in addition to the right click (or ctrl + click on MacOS) | ### `` diff --git a/docs/usage.md b/docs/usage.md index e80706ad..0265ab6b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,7 +7,7 @@ You need to setup two things: ```jsx import React from "react"; import ReactDOM from "react-dom"; -import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu"; +import { ContextMenu, MenuItem, ContextMenuTrigger } from "@firefox-devtools/react-contextmenu"; function handleClick(e, data) { console.log(data.foo); @@ -59,4 +59,4 @@ The styling can be apllied to using following classes. - `react-contextmenu-wrapper` : applied to wrapper around elements in `ContextMenuTrigger`. - `react-contextmenu-submenu` : applied to items that are submenus. -> Note: This package does note include any styling by default. You can use [react-contextmenu.css](https://github.com/vkbansal/react-contextmenu/blob/master/examples/react-contextmenu.css) from examples for quick setup. +> Note: This package does note include any styling by default. You can use [react-contextmenu.css](https://github.com/firefox-devtools/react-contextmenu/blob/master/examples/react-contextmenu.css) from examples for quick setup. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..138065e6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,75 @@ +import globals from 'globals'; +import babelParser from '@babel/eslint-parser'; +import jest from 'eslint-plugin-jest'; +import jestDom from 'eslint-plugin-jest-dom'; +import testingLibrary from 'eslint-plugin-testing-library'; +import { FlatCompat } from '@eslint/eslintrc'; + +// Useful function tool to more easily merge external shared configuration for a +// specific files filter. +function configsForFiles({ files, configs }) { + return configs.map(config => ({ ...config, files })); +} + +// Useful to import legacy shared configuration +const compat = new FlatCompat(); + +export default [ + ...compat.extends('vkbansal', 'vkbansal/react'), + { + languageOptions: { + parser: babelParser, + globals: { + ...globals.browser + } + }, + rules: { + 'react/no-array-index-key': 'off' + }, + settings: { + react: { + version: 'detect' + } + } + }, + ...configsForFiles({ + files: ['**/tests/*.js'], + configs: [ + jest.configs['flat/recommended'], + jestDom.configs['flat/recommended'], + testingLibrary.configs['flat/react'], + { + rules: { + 'prefer-arrow-callback': 'off', + 'no-mixed-requires': 'off', + // This disallows using direct Node properties (eg: firstChild), but we + // have legitimate uses: + 'testing-library/no-node-access': 'off', + 'import/no-extraneous-dependencies': [ + 'error', + { devDependencies: true } + ] + } + } + ] + }), + { + files: ['**/examples/*.js'], + settings: { + 'import/resolver': { + webpack: { + config: 'examples/webpack.config.js' + } + } + }, + rules: { + 'import/no-extraneous-dependencies': ['error', { devDependencies: true }] + } + }, + { + files: ['eslint.config.mjs'], + rules: { + 'import/no-extraneous-dependencies': ['error', { devDependencies: true }] + } + } +]; diff --git a/examples/.eslintrc.yml b/examples/.eslintrc.yml deleted file mode 100644 index 504d133d..00000000 --- a/examples/.eslintrc.yml +++ /dev/null @@ -1,6 +0,0 @@ -settings: - import/resolver: - webpack: - config: examples/webpack.config.js -rules: - import/no-extraneous-dependencies: off diff --git a/examples/Customization.js b/examples/Customization.js index f0eb15b5..a72fa5ca 100644 --- a/examples/Customization.js +++ b/examples/Customization.js @@ -35,7 +35,7 @@ export default class Customization extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on ${data.name} menu ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/DynamicMenu.js b/examples/DynamicMenu.js index 29cfbb23..d1e984ee 100644 --- a/examples/DynamicMenu.js +++ b/examples/DynamicMenu.js @@ -80,7 +80,7 @@ export default class DynamicMenuExample extends Component { return this.setState(({ logs }) => ({ logs: [` ${data.name} cannot be ${data.action.toLowerCase()}`, ...logs] })); - } + }; render() { const attributes = { diff --git a/examples/MultipleMenus.js b/examples/MultipleMenus.js index 21ae4ac3..1f229944 100644 --- a/examples/MultipleMenus.js +++ b/examples/MultipleMenus.js @@ -18,7 +18,7 @@ export default class MultipleMenus extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on menu ${data.menu} item ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/MultipleTargets.js b/examples/MultipleTargets.js index 3bb4deab..5d9636e0 100644 --- a/examples/MultipleTargets.js +++ b/examples/MultipleTargets.js @@ -53,7 +53,7 @@ export default class MultipleTargets extends Component { return this.setState(({ logs }) => ({ logs: [` ${data.name} cannot be ${data.action.toLowerCase()}`, ...logs] })); - } + }; render() { const attributes = { diff --git a/examples/Nested.js b/examples/Nested.js index 3ba0e61b..9fa586fb 100644 --- a/examples/Nested.js +++ b/examples/Nested.js @@ -18,7 +18,7 @@ export default class SimpleMenu extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on menu ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/RTLSubMenu.js b/examples/RTLSubMenu.js index 65392d14..ed356839 100644 --- a/examples/RTLSubMenu.js +++ b/examples/RTLSubMenu.js @@ -18,7 +18,7 @@ export default class RTLSubMenu extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on menu ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/SimpleMenu.js b/examples/SimpleMenu.js index cbd0e5d1..c4802b62 100644 --- a/examples/SimpleMenu.js +++ b/examples/SimpleMenu.js @@ -17,7 +17,7 @@ export default class SimpleMenu extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on menu ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/SubMenus.js b/examples/SubMenus.js index cc70f365..338eabb7 100644 --- a/examples/SubMenus.js +++ b/examples/SubMenus.js @@ -18,7 +18,7 @@ export default class SimpleMenu extends Component { this.setState(({ logs }) => ({ logs: [`Clicked on menu ${data.item}`, ...logs] })); - } + }; render() { return ( diff --git a/examples/index.html b/examples/index.html index c27b9623..4e340397 100644 --- a/examples/index.html +++ b/examples/index.html @@ -30,11 +30,16 @@ vertical-align: bottom; } - .link-active, - .link-active:focus, - .link-active:hover { + .pure-menu-link { + /* Override pure CSS color to add more contrast for accessibility */ + color: #737373; + } + + .pure-menu-link.active, + .pure-menu-link.active:focus, + .pure-menu-link.active:hover { color: #fff; - background: #20a0ff; + background: #0060df; } .well { @@ -51,23 +56,12 @@

react-contextmenu

ContextMenu in React with accessibility support.

- Fork me on GitHub - + Fork me on GitHub
-

Download / Usage: View on GitHub

+

Download / Usage: View on GitHub


Examples:

- - diff --git a/examples/index.js b/examples/index.js index 62f3b5fe..19e85088 100644 --- a/examples/index.js +++ b/examples/index.js @@ -1,6 +1,6 @@ import React from 'react'; -import ReactDOM from 'react-dom'; -import { HashRouter as Router, Route, NavLink as Link, Switch } from 'react-router-dom'; +import ReactDOMClient from 'react-dom/client'; +import { HashRouter as Router, Route, NavLink, Routes } from 'react-router-dom'; import SimpleMenu from './SimpleMenu'; import MultipleTargets from './MultipleTargets'; @@ -14,8 +14,7 @@ import Nested from './Nested'; import './react-contextmenu.css'; const commonProps = { - className: 'pure-menu-link', - activeClassName: 'link-active' + className: 'pure-menu-link' }; function App() { @@ -24,65 +23,68 @@ function App() {
- - - - - - - - - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); } -const Routes = ( - +const root = ReactDOMClient.createRoot(document.getElementById('main')); +root.render( + ); - -ReactDOM.render(Routes, document.getElementById('main')); diff --git a/examples/react-contextmenu.css b/examples/react-contextmenu.css index 5e40ace8..89fc9784 100644 --- a/examples/react-contextmenu.css +++ b/examples/react-contextmenu.css @@ -36,8 +36,8 @@ .react-contextmenu-item.react-contextmenu-item--active, .react-contextmenu-item.react-contextmenu-item--selected { color: #fff; - background-color: #20a0ff; - border-color: #20a0ff; + background-color: #0060df; + border-color: #0060df; text-decoration: none; } diff --git a/examples/webpack.config.js b/examples/webpack.config.js index ace41112..1d65e6b4 100644 --- a/examples/webpack.config.js +++ b/examples/webpack.config.js @@ -2,21 +2,20 @@ const webpack = require('webpack'); const path = require('path'); -const Extract = require('extract-text-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const MinifyPlugin = require("babel-minify-webpack-plugin"); const PROD = process.env.NODE_ENV === 'production'; const DEV = !PROD; const config = { + mode: PROD ? "production" : "development", entry: ['./examples/index.js'], output: { - filename: DEV ? 'bundle.js' : 'bundle.[hash].js', + filename: 'bundle.[contenthash].js', path: path.resolve(__dirname, '../public'), publicPath: DEV ? '/' : '/react-contextmenu/', hashDigestLength: 6, - sourceMapFilename: 'bundle.js.map' }, resolve: { modules: [ @@ -31,18 +30,16 @@ const config = { use: [{ loader: 'babel-loader', options: { - presets: [ - 'react', - ['env', { + assumptions: { + setPublicClassFields: true + }, + presets: [ + '@babel/preset-react', + ['@babel/preset-env', { modules: false, - targets: { - browsers: 'IE >= 11, Edge >= 12, FireFox >= 38, Chrome >= 47, Opera >= 34, Safari >= 8' - } + targets: "defaults", }] ], - plugins: [ - 'transform-class-properties' - ] } }], include: [ @@ -52,19 +49,16 @@ const config = { }, { test: /\.css$/, - use: Extract.extract({ - fallback: 'style-loader', - use: [{ - loader: 'css-loader' - }] - }), + use: [ + DEV ? "style-loader": MiniCssExtractPlugin.loader, + "css-loader" + ], } ] }, plugins: [ - new Extract({ + new MiniCssExtractPlugin({ filename: DEV ? 'styles.css' : 'styles.[contenthash:6].css', - allChunks: true }), new HtmlWebpackPlugin({ template: 'examples/index.html', @@ -76,21 +70,4 @@ const config = { !PROD && (config.devtool = 'source-map'); -PROD && config.plugins.push( - new webpack.optimize.UglifyJsPlugin({ - compressor: { - warnings: false, - } - }) -); - -PROD && config.plugins.push( - new webpack.DefinePlugin({ - 'process.env': { - 'NODE_ENV': JSON.stringify('production') - } - }), - new MinifyPlugin() -); - module.exports = config; diff --git a/package.json b/package.json index 5e413651..12243b23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "react-contextmenu", - "version": "2.14.0", + "name": "@firefox-devtools/react-contextmenu", + "version": "5.2.3", "description": "Context Menu implemented in React", "main": "modules/index.js", "module": "es6/index.js", @@ -19,91 +19,90 @@ "src/index.d.ts" ], "scripts": { - "lint": "eslint ./src ./examples", - "test": "npm run lint && npm run test:only", + "lint": "eslint ./src ./tests ./examples", + "test": "yarn lint && yarn test:only", "test:only": "jest --no-cache --verbose --coverage", "test:dev": "jest --watchAll --no-cache --verbose --coverage", "clean": "rimraf ./dist && rimraf ./modules && rimraf ./es6", "clean:examples": "rimraf ./public", - "build": "npm run clean && npm run test && npm run build:dist && npm run build:es5 && npm run build:es6", - "build:dist": "webpack --progress --profile --colors", + "build": "yarn clean && yarn test && yarn build:dist && yarn build:es5 && yarn build:es6", + "build:dist": "webpack --progress --profile", "build:es5": "cross-env BABEL_ENV=es5 babel src --out-dir modules", "build:es6": "cross-env BABEL_ENV=es6 babel src --out-dir es6", - "build:examples": "npm run clean:examples && npm run build:dev", - "build:dev": "cross-env BABEL_ENV=es6 webpack --config examples/webpack.config.js --progress --profile --colors", - "start": "npm run build:examples && npm run start:server", - "start:server": "http-server public -p 3000", - "prepublishOnly": "npm run build" + "build:examples": "yarn clean:examples && yarn build:dev", + "build:dev": "cross-env BABEL_ENV=es6 webpack --config examples/webpack.config.js --progress --profile", + "start": "webpack serve --config examples/webpack.config.js", + "prepack": "yarn --frozen-lockfile && yarn build" }, "author": "Vivek Kumar Bansal ", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/vkbansal/react-contextmenu.git" + "url": "https://github.com/firefox-devtools/react-contextmenu.git" }, "bugs": { - "url": "https://github.com/vkbansal/react-contextmenu/issues" + "url": "https://github.com/firefox-devtools/react-contextmenu/issues" }, - "homepage": "https://github.com/vkbansal/react-contextmenu", + "homepage": "https://github.com/firefox-devtools/react-contextmenu", "dependencies": { - "classnames": "^2.2.5", + "classnames": "^2.5.1", "object-assign": "^4.1.0" }, "peerDependencies": { "prop-types": "^15.0.0", - "react": "^0.14.0 || ^15.0.0 || ^16.0.1", - "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.1" + "react": "^0.14.0 || ^15.0.0 || ^16.0.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { - "babel-cli": "~6.26.0", - "babel-core": "~6.26.3", - "babel-eslint": "~7.2.1", - "babel-jest": "~21.2.0", - "babel-loader": "~7.1.1", - "babel-minify-webpack-plugin": "~0.2.0", - "babel-preset-env": "~1.7.0", - "babel-preset-es2015": "~6.24.1", - "babel-preset-react": "~6.24.1", - "babel-preset-stage-2": "~6.24.1", - "babel-register": "~6.26.0", - "coveralls": "~3.0.3", - "cross-env": "~6.0.3", - "css-loader": "~0.28.7", - "enzyme": "~3.10.0", - "enzyme-adapter-react-16": "~1.15.1", - "enzyme-to-json": "~3.4.3", - "eslint": "~4.18.2", + "@babel/cli": "^7.28.3", + "@babel/core": "^7.28.5", + "@babel/eslint-parser": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "@babel/preset-react": "^7.28.5", + "@babel/preset-stage-2": "^7.8.3", + "@eslint/eslintrc": "^3.3.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "babel-jest": "~30.2.0", + "babel-loader": "~10.0.0", + "cross-env": "~10.1.0", + "css-loader": "~7.1.2", + "eslint": "~9.39.1", "eslint-config-vkbansal": "~5.2.1", - "eslint-import-resolver-webpack": "~0.8.3", - "eslint-plugin-import": "~2.18.2", - "eslint-plugin-react": "~7.16.0", - "extract-text-webpack-plugin": "~3.0.2", - "history": "~4.10.1", - "html-webpack-plugin": "~2.30.1", - "http-server": "~0.11.1", - "jest": "~21.2.1", - "jsdom": "~11.3.0", - "prop-types": "~15.7.2", - "react": "~16.8.4", - "react-dom": "~16.8.4", - "react-router-dom": "~5.0.1", - "react-test-renderer": "~16.8.4", - "rimraf": "~3.0.0", - "style-loader": "~0.19.0", - "webpack": "~3.12.0" + "eslint-import-resolver-webpack": "~0.13.10", + "eslint-plugin-import": "~2.32.0", + "eslint-plugin-jest": "^29.0.1", + "eslint-plugin-jest-dom": "^5.5.0", + "eslint-plugin-react": "~7.37.5", + "eslint-plugin-testing-library": "^7.13.3", + "globals": "^16.4.0", + "history": "~5.3.0", + "html-webpack-plugin": "~5.6.4", + "jest": "~30.2.0", + "jest-environment-jsdom": "^30.2.0", + "mini-css-extract-plugin": "^2.9.4", + "prop-types": "~15.8.1", + "react": "~19.2.0", + "react-dom": "~19.2.0", + "react-router-dom": "~7.9.4", + "rimraf": "~6.1.0", + "style-loader": "~4.0.0", + "webpack": "5.102.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.2" }, "jest": { - "setupFiles": [ + "setupFilesAfterEnv": [ "/tests/.setup.js" ], - "snapshotSerializers": [ - "enzyme-to-json/serializer" - ], "roots": [ "/tests" ], "collectCoverageFrom": [ "**/src/**/*.js" - ] + ], + "testEnvironment": "jsdom" } } diff --git a/src/AbstractMenu.js b/src/AbstractMenu.js index ed547ce6..bc075e67 100644 --- a/src/AbstractMenu.js +++ b/src/AbstractMenu.js @@ -25,24 +25,29 @@ export default class AbstractMenu extends Component { return; } - switch (e.keyCode) { - case 37: // left arrow - case 27: // escape + switch (e.key) { + case 'ArrowLeft': // left arrow + case 'Left': // IE specific value + case 'Escape': // escape + case 'Esc': // IE specific value e.preventDefault(); this.hideMenu(e); break; - case 38: // up arrow + case 'ArrowUp': // up arrow + case 'Up': // IE specific value e.preventDefault(); this.selectChildren(true); break; - case 40: // down arrow + case 'ArrowDown': // down arrow + case 'Down': // IE specific value e.preventDefault(); this.selectChildren(false); break; - case 39: // right arrow + case 'ArrowRight': // right arrow + case 'Right': // IE specific value this.tryToOpenSubMenu(e); break; - case 13: // enter + case 'Enter': // enter e.preventDefault(); this.tryToOpenSubMenu(e); { @@ -63,18 +68,18 @@ export default class AbstractMenu extends Component { default: // do nothing } - } + }; handleForceClose = () => { this.setState({ forceSubMenuOpen: false }); - } + }; tryToOpenSubMenu = (e) => { if (this.state.selectedItem && this.state.selectedItem.type === this.getSubMenuType()) { e.preventDefault(); this.setState({ forceSubMenuOpen: true }); } - } + }; selectChildren = (forward) => { const { selectedItem } = this.state; @@ -89,7 +94,7 @@ export default class AbstractMenu extends Component { return; } - if ([MenuItem, this.getSubMenuType()].indexOf(child.type) < 0) { + if ([MenuItem, this.getSubMenuType()].indexOf(child.type) < 0 && child.props.children instanceof Object) { // Maybe the MenuItem or SubMenu is capsuled in a wrapper div or something else React.Children.forEach(child.props.children, childCollector); } else if (!child.props.divider) { @@ -131,50 +136,86 @@ export default class AbstractMenu extends Component { return i === currentIndex ? null : i; } - const currentIndex = children.indexOf(selectedItem); + const currentIndex = selectedItem ? selectedItem.index : -1; const nextEnabledChildIndex = findNextEnabledChildIndex(currentIndex); if (nextEnabledChildIndex !== null) { this.setState({ - selectedItem: children[nextEnabledChildIndex], + selectedItem: { + index: nextEnabledChildIndex, + // We need to know the type of the selected item, so we can + // check it during render and tryToOpenSubMenu. + type: children[nextEnabledChildIndex].type + }, forceSubMenuOpen: false }); } - } + }; - onChildMouseMove = (child) => { - if (this.state.selectedItem !== child) { - this.setState({ selectedItem: child, forceSubMenuOpen: false }); + onChildMouseMove = (child, itemIndex) => { + if (this.state.selectedItem === null || this.state.selectedItem.index !== itemIndex) { + this.setState({ + selectedItem: { + index: itemIndex, + type: child.type + }, + forceSubMenuOpen: false + }); } - } + }; onChildMouseLeave = () => { this.setState({ selectedItem: null, forceSubMenuOpen: false }); - } + }; - renderChildren = children => React.Children.map(children, (child) => { - const props = {}; - if (!React.isValidElement(child)) return child; - if ([MenuItem, this.getSubMenuType()].indexOf(child.type) < 0) { - // Maybe the MenuItem or SubMenu is capsuled in a wrapper div or something else - props.children = this.renderChildren(child.props.children); - return React.cloneElement(child, props); - } - props.onMouseLeave = this.onChildMouseLeave.bind(this); - if (child.type === this.getSubMenuType()) { - // special props for SubMenu only - props.forceOpen = this.state.forceSubMenuOpen && (this.state.selectedItem === child); - props.forceClose = this.handleForceClose; - props.parentKeyNavigationHandler = this.handleKeyNavigation; - } - if (!child.props.divider && this.state.selectedItem === child) { - // special props for selected item only - props.selected = true; - props.ref = (ref) => { this.seletedItemRef = ref; }; + /** + * Render all the children. + * It has a `childIndexRef` parameter to be able to construct the child + * indexes properly. A reference was needed for this function because this + * is a recursive function that could mutate the index and pass it back to + * the caller. That parameter should always be undefined while calling from + * outside. + * TODO: Rewrite this function in a way that we don't need this reference. + */ + renderChildren = (children, childIndexRef = { value: -1 }) => + React.Children.map(children, (child) => { + let currentChildIndexRef = childIndexRef; + const props = {}; + if (!React.isValidElement(child)) return child; + + if ([MenuItem, this.getSubMenuType()].indexOf(child.type) < 0) { + // Maybe the MenuItem or SubMenu is capsuled in a wrapper div or something else + props.children = this.renderChildren(child.props.children, currentChildIndexRef); + return React.cloneElement(child, props); + } + + // At this point we know that this is a menu item and we are going to + // render it. We need to increment the child index and assign it as + // the item index. + let itemIndex = null; + if (!child.props.divider) { + // A MenuItem can be a divider. Do not increment the value if it's. + itemIndex = ++currentChildIndexRef.value; + } + + props.onMouseLeave = this.onChildMouseLeave.bind(this); + if (child.type === this.getSubMenuType()) { + // special props for SubMenu only + props.forceOpen = this.state.forceSubMenuOpen && + (this.state.selectedItem && this.state.selectedItem.index === itemIndex); + props.forceClose = this.handleForceClose; + props.parentKeyNavigationHandler = this.handleKeyNavigation; + } + if (!child.props.divider && + (this.state.selectedItem && this.state.selectedItem.index === itemIndex)) { + // special props for selected item only + props.selected = true; + props.ref = (ref) => { this.seletedItemRef = ref; }; + return React.cloneElement(child, props); + } + + // onMouseMove is only needed for non selected items + props.onMouseMove = () => this.onChildMouseMove(child, itemIndex); return React.cloneElement(child, props); - } - // onMouseMove is only needed for non selected items - props.onMouseMove = () => this.onChildMouseMove(child); - return React.cloneElement(child, props); - }); + }); } diff --git a/src/ContextMenu.js b/src/ContextMenu.js index ae9d2089..1a9e36e4 100644 --- a/src/ContextMenu.js +++ b/src/ContextMenu.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import cx from 'classnames'; import assign from 'object-assign'; @@ -9,6 +10,15 @@ import SubMenu from './SubMenu'; import { hideMenu } from './actions'; import { cssClasses, callIfExists, store } from './helpers'; +/* This is a backward-compatible shim for React < 18 */ +function flushSync(callback) { + if (ReactDOM.flushSync) { + ReactDOM.flushSync(callback); + } else { + callback(); + } +} + export default class ContextMenu extends AbstractMenu { static propTypes = { id: PropTypes.string.isRequired, @@ -69,7 +79,7 @@ export default class ContextMenu extends AbstractMenu { : this.getMenuPosition(x, y); wrapper(() => { - if (!this.menu) return; + if (!this.menu || !this.state.isVisible) return; this.menu.style.top = `${top}px`; this.menu.style.left = `${left}px`; this.menu.style.opacity = 1; @@ -100,7 +110,7 @@ export default class ContextMenu extends AbstractMenu { if (!this.props.preventHideOnContextMenu) document.addEventListener('contextmenu', this.handleHide); document.addEventListener('keydown', this.handleKeyNavigation); if (!this.props.preventHideOnResize) window.addEventListener('resize', this.handleHide); - } + }; unregisterHandlers = () => { document.removeEventListener('mousedown', this.handleOutsideClick); @@ -109,7 +119,7 @@ export default class ContextMenu extends AbstractMenu { document.removeEventListener('contextmenu', this.handleHide); document.removeEventListener('keydown', this.handleKeyNavigation); window.removeEventListener('resize', this.handleHide); - } + }; handleShow = (e) => { if (e.detail.id !== this.props.id || this.state.isVisible) return; @@ -119,19 +129,23 @@ export default class ContextMenu extends AbstractMenu { this.setState({ isVisible: true, x, y }); this.registerHandlers(); callIfExists(this.props.onShow, e); - } + }; handleHide = (e) => { if (this.state.isVisible && (!e.detail || !e.detail.id || e.detail.id === this.props.id)) { this.unregisterHandlers(); - this.setState({ isVisible: false, selectedItem: null, forceSubMenuOpen: false }); + flushSync(() => { + /* We rely on being able to read this state change in handleShow, + * so let's force a synchronous update in React 18. */ + this.setState({ isVisible: false, selectedItem: null, forceSubMenuOpen: false }); + }); callIfExists(this.props.onHide, e); } - } + }; handleOutsideClick = (e) => { if (!this.menu.contains(e.target)) hideMenu(); - } + }; handleMouseLeave = (event) => { event.preventDefault(); @@ -144,20 +158,22 @@ export default class ContextMenu extends AbstractMenu { ); if (this.props.hideOnLeave) hideMenu(); - } + }; handleContextMenu = (e) => { if (process.env.NODE_ENV === 'production') { e.preventDefault(); } this.handleHide(e); - } + }; + // Disabling this rule for more consistency. + /* eslint-disable-next-line class-methods-use-this */ hideMenu = (e) => { - if (e.keyCode === 27 || e.keyCode === 13) { // ECS or enter + if (e.key === 'Escape' || e.key === 'Esc' || e.key === 'Enter') { hideMenu(); } - } + }; getMenuPosition = (x = 0, y = 0) => { let menuStyles = { @@ -187,7 +203,7 @@ export default class ContextMenu extends AbstractMenu { } return menuStyles; - } + }; getRTLMenuPosition = (x = 0, y = 0) => { let menuStyles = { @@ -220,11 +236,11 @@ export default class ContextMenu extends AbstractMenu { } return menuStyles; - } + }; menuRef = (c) => { this.menu = c; - } + }; render() { const { children, className, style } = this.props; diff --git a/src/ContextMenuTrigger.js b/src/ContextMenuTrigger.js index c3cf8e15..a193a56e 100644 --- a/src/ContextMenuTrigger.js +++ b/src/ContextMenuTrigger.js @@ -17,7 +17,8 @@ export default class ContextMenuTrigger extends Component { posX: PropTypes.number, posY: PropTypes.number, renderTag: PropTypes.elementType, - mouseButton: PropTypes.number, + // Trigger on left click in addition to right click + triggerOnLeftClick: PropTypes.bool, disableIfShiftIsPressed: PropTypes.bool }; @@ -29,7 +30,7 @@ export default class ContextMenuTrigger extends Component { renderTag: 'div', posX: 0, posY: 0, - mouseButton: 2, // 0 is left click, 2 is right click + triggerOnLeftClick: false, disableIfShiftIsPressed: false }; @@ -46,21 +47,21 @@ export default class ContextMenuTrigger extends Component { ); } callIfExists(this.props.attributes.onMouseDown, event); - } + }; handleMouseUp = (event) => { if (event.button === 0) { clearTimeout(this.mouseDownTimeoutId); } callIfExists(this.props.attributes.onMouseUp, event); - } + }; handleMouseOut = (event) => { if (event.button === 0) { clearTimeout(this.mouseDownTimeoutId); } callIfExists(this.props.attributes.onMouseOut, event); - } + }; handleTouchstart = (event) => { this.touchHandled = false; @@ -78,7 +79,7 @@ export default class ContextMenuTrigger extends Component { ); } callIfExists(this.props.attributes.onTouchStart, event); - } + }; handleTouchEnd = (event) => { if (this.touchHandled) { @@ -86,21 +87,18 @@ export default class ContextMenuTrigger extends Component { } clearTimeout(this.touchstartTimeoutId); callIfExists(this.props.attributes.onTouchEnd, event); - } + }; handleContextMenu = (event) => { - if (event.button === this.props.mouseButton) { - this.handleContextClick(event); - } + this.handleContextClick(event); callIfExists(this.props.attributes.onContextMenu, event); - } + }; + // Note: this function is registered only if triggerOnLeftClick is true. handleMouseClick = (event) => { - if (event.button === this.props.mouseButton) { - this.handleContextClick(event); - } + this.handleContextClick(event); callIfExists(this.props.attributes.onClick, event); - } + }; handleContextClick = (event) => { if (this.props.disable) return; @@ -141,25 +139,26 @@ export default class ContextMenuTrigger extends Component { }); showMenu(showMenuConfig); } - } + }; elemRef = (c) => { this.elem = c; - } + }; render() { - const { renderTag, attributes, children } = this.props; - const newAttrs = assign({}, attributes, { + const { renderTag, attributes, children, triggerOnLeftClick } = this.props; + const newAttrs = { + ...attributes, className: cx(cssClasses.menuWrapper, attributes.className), onContextMenu: this.handleContextMenu, - onClick: this.handleMouseClick, + onClick: triggerOnLeftClick ? this.handleMouseClick : null, onMouseDown: this.handleMouseDown, onMouseUp: this.handleMouseUp, onTouchStart: this.handleTouchstart, onTouchEnd: this.handleTouchEnd, onMouseOut: this.handleMouseOut, ref: this.elemRef - }); + }; return React.createElement(renderTag, newAttrs, children); } diff --git a/src/MenuItem.js b/src/MenuItem.js index 51035168..3eea8546 100644 --- a/src/MenuItem.js +++ b/src/MenuItem.js @@ -18,7 +18,8 @@ export default class MenuItem extends Component { onMouseLeave: PropTypes.func, onMouseMove: PropTypes.func, preventClose: PropTypes.bool, - selected: PropTypes.bool + selected: PropTypes.bool, + role: PropTypes.string }; static defaultProps = { @@ -32,7 +33,8 @@ export default class MenuItem extends Component { onMouseMove: () => null, onMouseLeave: () => null, preventClose: false, - selected: false + selected: false, + role: 'menuitem' }; handleClick = (event) => { @@ -52,7 +54,7 @@ export default class MenuItem extends Component { if (this.props.preventClose) return; hideMenu(); - } + }; render() { const { @@ -61,7 +63,8 @@ export default class MenuItem extends Component { className, disabled, divider, - selected + selected, + role } = this.props; const menuItemClassNames = cx( @@ -78,7 +81,7 @@ export default class MenuItem extends Component { return (
{ this.ref = ref; }} onMouseMove={this.props.onMouseMove} onMouseLeave={this.props.onMouseLeave} diff --git a/src/SubMenu.js b/src/SubMenu.js index 450bd23e..d8471680 100644 --- a/src/SubMenu.js +++ b/src/SubMenu.js @@ -22,7 +22,10 @@ export default class SubMenu extends AbstractMenu { onMouseOut: PropTypes.func, forceOpen: PropTypes.bool, forceClose: PropTypes.func, - parentKeyNavigationHandler: PropTypes.func + parentKeyNavigationHandler: PropTypes.func, + onClick: PropTypes.func, + data: PropTypes.object, + preventCloseOnClick: PropTypes.bool }; static defaultProps = { @@ -36,7 +39,10 @@ export default class SubMenu extends AbstractMenu { onMouseOut: () => null, forceOpen: false, forceClose: () => null, - parentKeyNavigationHandler: () => null + parentKeyNavigationHandler: () => null, + onClick: () => null, + data: {}, + preventCloseOnClick: false }; constructor(props) { @@ -68,6 +74,7 @@ export default class SubMenu extends AbstractMenu { if (this.props.forceOpen || this.state.visible) { const wrapper = window.requestAnimationFrame || setTimeout; wrapper(() => { + if (!this.subMenu) return; const styles = this.props.rtl ? this.getRTLMenuPosition() : this.getMenuPosition(); @@ -88,6 +95,7 @@ export default class SubMenu extends AbstractMenu { }); } else { const cleanup = () => { + if (!this.subMenu) return; this.subMenu.removeEventListener('transitionend', cleanup); this.subMenu.style.removeProperty('bottom'); this.subMenu.style.removeProperty('right'); @@ -130,7 +138,7 @@ export default class SubMenu extends AbstractMenu { } return position; - } + }; getRTLMenuPosition = () => { const { innerHeight } = window; @@ -150,13 +158,13 @@ export default class SubMenu extends AbstractMenu { } return position; - } + }; hideMenu = (e) => { e.preventDefault(); this.hideSubMenu(e); - } - + }; + hideSubMenu = (e) => { // avoid closing submenus of a different menu tree if (e.detail && e.detail.id && this.menu && e.detail.id !== this.menu.id) { @@ -185,7 +193,7 @@ export default class SubMenu extends AbstractMenu { if (!this.props.onClick || this.props.preventCloseOnClick) return; hideMenu(); - } + }; handleMouseEnter = () => { if (this.closetimer) clearTimeout(this.closetimer); @@ -196,7 +204,7 @@ export default class SubMenu extends AbstractMenu { visible: true, selectedItem: null }), this.props.hoverDelay); - } + }; handleMouseLeave = () => { if (this.opentimer) clearTimeout(this.opentimer); @@ -207,27 +215,27 @@ export default class SubMenu extends AbstractMenu { visible: false, selectedItem: null }), this.props.hoverDelay); - } + }; menuRef = (c) => { this.menu = c; - } + }; subMenuRef = (c) => { this.subMenu = c; - } + }; registerHandlers = () => { document.removeEventListener('keydown', this.props.parentKeyNavigationHandler); document.addEventListener('keydown', this.handleKeyNavigation); - } + }; unregisterHandlers = (dismounting) => { document.removeEventListener('keydown', this.handleKeyNavigation); if (!dismounting) { document.addEventListener('keydown', this.props.parentKeyNavigationHandler); } - } + }; render() { const { children, attributes, disabled, title, selected } = this.props; diff --git a/src/connectMenu.js b/src/connectMenu.js index 537a34ca..65722126 100644 --- a/src/connectMenu.js +++ b/src/connectMenu.js @@ -7,7 +7,7 @@ import listener from './globalEventListener'; const ignoredTriggerProps = [...Object.keys(ContextMenuTrigger.propTypes), 'children']; // expect the id of the menu to be responsible for as outer parameter -export default function (menuId) { +export default function connectMenu(menuId) { // expect menu component to connect as inner parameter // is presumably a wrapper of return function connect(Child) { @@ -42,11 +42,11 @@ export default function (menuId) { } } this.setState({ trigger: filteredData }); - } + }; handleHide = () => { this.setState({ trigger: null }); - } + }; render() { return ; diff --git a/src/globalEventListener.js b/src/globalEventListener.js index 6927681f..d1e09947 100644 --- a/src/globalEventListener.js +++ b/src/globalEventListener.js @@ -15,13 +15,13 @@ class GlobalEventListener { for (const id in this.callbacks) { if (hasOwnProp(this.callbacks, id)) this.callbacks[id].show(event); } - } + }; handleHideEvent = (event) => { for (const id in this.callbacks) { if (hasOwnProp(this.callbacks, id)) this.callbacks[id].hide(event); } - } + }; register = (showCallback, hideCallback) => { const id = uniqueId(); @@ -32,13 +32,13 @@ class GlobalEventListener { }; return id; - } + }; unregister = (id) => { if (id && this.callbacks[id]) { delete this.callbacks[id]; } - } + }; } export default new GlobalEventListener(); diff --git a/src/index.d.ts b/src/index.d.ts index 8cc47be0..bbfc141c 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,4 @@ -declare module "react-contextmenu" { +declare module "@firefox-devtools/react-contextmenu" { import * as React from "react"; export interface ContextMenuProps { @@ -14,6 +14,7 @@ declare module "react-contextmenu" { preventHideOnResize?: boolean, preventHideOnScroll?: boolean, style?: React.CSSProperties, + children?: React.ReactNode, } export interface ContextMenuTriggerProps { @@ -22,10 +23,13 @@ declare module "react-contextmenu" { collect?: {(data: any): any}, disable?: boolean, holdToDisplay?: number, + posX?: number, + posY?: number, renderTag?: React.ElementType, - mouseButton?: number, + triggerOnLeftClick?: boolean, disableIfShiftIsPressed?: boolean, - [key: string]: any + [key: string]: any, + children?: React.ReactNode, } export interface MenuItemProps { @@ -36,16 +40,30 @@ declare module "react-contextmenu" { divider?: boolean, preventClose?: boolean, onClick?: {(event: React.TouchEvent | React.MouseEvent, data: Object, target: HTMLElement): void} | Function, + onMouseLeave?: {(event: React.MouseEvent): void} | Function, + onMouseMove?: {(event: React.MouseEvent): void} | Function, + role?: string, + selected?: boolean, + children?: React.ReactNode, } export interface SubMenuProps { title: React.ReactElement | React.ReactText, + attributes?: React.HTMLAttributes, className?: string, disabled?: boolean, hoverDelay?: number, rtl?: boolean, + selected?: boolean, + onMouseMove?: {(event: React.MouseEvent): void} | Function, + onMouseOut?: {(event: React.MouseEvent): void} | Function, + forceOpen?: boolean, + forceClose?: {(): void} | Function, + parentKeyNavigationHandler?: {(event: React.KeyboardEvent): void} | Function, preventCloseOnClick?: boolean, onClick?: {(event: React.TouchEvent | React.MouseEvent, data: Object, target: HTMLElement): void} | Function, + data?: Object, + children?: React.ReactNode, } export interface ConnectMenuProps { @@ -66,7 +84,7 @@ declare module "react-contextmenu" { export function hideMenu(opts?: any, target?: HTMLElement): void; } -declare module "react-contextmenu/modules/actions" { +declare module "@firefox-devtools/react-contextmenu/modules/actions" { export function showMenu(opts?: any, target?: HTMLElement): void; export function hideMenu(opts?: any, target?: HTMLElement): void; } diff --git a/tests/.eslintrc.yml b/tests/.eslintrc.yml deleted file mode 100644 index 3a294e4d..00000000 --- a/tests/.eslintrc.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- - env: - jest: true - rules: - prefer-arrow-callback: 0 - no-mixed-requires: 0 diff --git a/tests/.setup.js b/tests/.setup.js index 6e32dab2..8ee6c268 100644 --- a/tests/.setup.js +++ b/tests/.setup.js @@ -1,17 +1,8 @@ -const jsdom = require('jsdom'); +// Importing this here makes it work everywhere. +import '@testing-library/jest-dom'; -const documentHTML = '
'; -const dom = new jsdom.JSDOM(documentHTML); -global.document = dom.window.document; -global.window = dom.window; -global.window.resizeTo = (width, height) => { - global.window.innerWidth = width || global.window.innerWidth; - global.window.innerHeight = width || global.window.innerHeight; - global.window.dispatchEvent(new Event('resize')); +window.resizeTo = (width, height) => { + window.innerWidth = width || window.innerWidth; + window.innerHeight = height || window.innerHeight; + window.dispatchEvent(new Event('resize')); }; -global.window.requestAnimationFrame = jest.fn(); - -const Enzyme = require('enzyme'); -const Adapter = require('enzyme-adapter-react-16'); - -Enzyme.configure({ adapter: new Adapter() }); diff --git a/tests/ContextMenu.test.js b/tests/ContextMenu.test.js index 3ffd7a15..2e38e67d 100644 --- a/tests/ContextMenu.test.js +++ b/tests/ContextMenu.test.js @@ -1,197 +1,265 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import ContextMenu from '../src/ContextMenu'; -import { showMenu, hideMenu } from '../src/actions'; +import MenuItem from '../src/MenuItem'; +import { showMenu as realShowMenu, hideMenu as realHideMenu } from '../src/actions'; + +function contextMenuElement() { + return document.querySelector('.react-contextmenu'); +} + +function visibleContextMenuElement() { + return document.querySelector('.react-contextmenu--visible'); +} + +function selectedItemElement() { + return document.querySelector('.react-contextmenu-item--selected'); +} + +async function showMenu(data) { + act(() => { realShowMenu(data); }); + await waitFor(() => expect(contextMenuElement()).toBeVisible()); +} + +async function hideMenu(data) { + act(() => { realHideMenu(data); }); + await waitFor(() => expect(contextMenuElement()).not.toBeVisible()); +} describe('ContextMenu tests', () => { - test('shows when event with correct "id" is triggered', () => { + test('shows when event with correct "id" is triggered', async () => { const ID = 'CORRECT_ID'; const x = 50; const y = 50; - const component = mount(); - - expect(component).toMatchSnapshot(); - expect(component.state()).toEqual({ - isVisible: false, - x: 0, - y: 0, - forceSubMenuOpen: false, - selectedItem: null - }); - showMenu({ position: { x, y }, id: ID }); - component.update(); - expect(component.state()).toEqual({ - isVisible: true, - x, - y, - forceSubMenuOpen: false, - selectedItem: null - }); - expect(component.find('.react-contextmenu--visible').length).toBe(1); - expect(component).toMatchSnapshot(); - component.unmount(); + render(); + + expect(document.body).toMatchSnapshot(); + await showMenu({ position: { x, y }, id: ID }); + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(document.body).toMatchSnapshot(); }); - test('does not shows when event with incorrect "id" is triggered', () => { + test('does not show when event with incorrect "id" is triggered', async () => { const ID = 'CORRECT_ID'; const x = 50; const y = 50; - const component = mount(); - - expect(component).toMatchSnapshot(); - expect(component.state()).toEqual({ - isVisible: false, - x: 0, - y: 0, - forceSubMenuOpen: false, - selectedItem: null - }); - showMenu({ position: { x, y }, id: 'ID' }); - component.update(); - expect(component.state()).toEqual({ - isVisible: false, - x: 0, - y: 0, - forceSubMenuOpen: false, - selectedItem: null - }); - expect(component.find('.react-contextmenu--visible').length).toBe(0); - expect(component).toMatchSnapshot(); - component.unmount(); + render(); + + await expect(showMenu({ position: { x, y }, id: 'WRONG_ID' })).rejects.toThrow(); + expect(visibleContextMenuElement()).not.toBeInTheDocument(); }); - test('onShow and onHide are triggered correctly', () => { + test('onShow and onHide are triggered correctly', async () => { const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; const onShow = jest.fn(); const onHide = jest.fn(); - const component = mount(); - - hideMenu(); - showMenu(data); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); + render(); + + await hideMenu(); + await showMenu(data); expect(onShow).toHaveBeenCalled(); - showMenu(data); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await showMenu(data); expect(onShow).toHaveBeenCalledTimes(1); expect(onHide).not.toHaveBeenCalled(); - hideMenu(); - expect(component.state()).toEqual( - Object.assign( - { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await hideMenu(); expect(onShow).toHaveBeenCalledTimes(1); expect(onHide).toHaveBeenCalledTimes(1); - component.unmount(); + expect(visibleContextMenuElement()).not.toBeInTheDocument(); }); - test('menu should close on "Escape"', () => { + test('menu should close on "Escape"', async () => { const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; const onHide = jest.fn(); - const component = mount(); - const escape = new window.KeyboardEvent('keydown', { keyCode: 27 }); - - showMenu(data); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); - document.dispatchEvent(escape); - expect(component.state()).toEqual( - Object.assign( - { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); + render(); + const user = userEvent.setup(); + + await showMenu(data); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(visibleContextMenuElement()).not.toBeInTheDocument(); expect(onHide).toHaveBeenCalled(); - component.unmount(); }); - test('menu should close on "Enter" when selectedItem is null', () => { + test('menu should close on "Enter" when selectedItem is null', async () => { const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; const onHide = jest.fn(); - const component = mount(); - const enter = new window.KeyboardEvent('keydown', { keyCode: 13 }); - - showMenu(data); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); - document.dispatchEvent(enter); - expect(component.state()).toEqual( - Object.assign( - { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); + render(); + const user = userEvent.setup(); + + await showMenu(data); + await user.keyboard('{Enter}'); expect(onHide).toHaveBeenCalled(); - component.unmount(); }); - test('menu should close on "outside" click', () => { + test('menu should close on "outside" click', async () => { const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; const onHide = jest.fn(); - const component = mount(); - const outsideClick = new window.MouseEvent('mousedown', { target: document }); - - showMenu(data); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); - component.simulate('mousedown'); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); - document.dispatchEvent(outsideClick); - expect(component.state()).toEqual( - Object.assign( - { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) - ); + render(); + const user = userEvent.setup(); + + await showMenu(data); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await user.click(visibleContextMenuElement()); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await user.click(document.body); + expect(visibleContextMenuElement()).not.toBeInTheDocument(); expect(onHide).toHaveBeenCalled(); - component.unmount(); }); - test('hideOnLeave and onMouseLeave options', () => { + test('hideOnLeave and onMouseLeave options', async () => { const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; const onMouseLeave = jest.fn(); - const component = mount( + render( ); + const user = userEvent.setup(); + + await showMenu(data); + expect(visibleContextMenuElement()).toBeInTheDocument(); + await user.hover(visibleContextMenuElement()); + await user.unhover(visibleContextMenuElement()); + expect(visibleContextMenuElement()).not.toBeInTheDocument(); + expect(onMouseLeave).toHaveBeenCalled(); + }); - showMenu(data); - expect(component.state()).toEqual( - Object.assign( - { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) + test('should select the proper menu items with down arrow', async () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + const onHide = jest.fn(); + render( + + Item 1 + Item 2 + ); - component.simulate('mouseleave'); - expect(component.state()).toEqual( - Object.assign( - { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, - data.position - ) + const user = userEvent.setup(); + + await showMenu(data); + // Check that it's visible and there is no selected item at first. + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(selectedItemElement()).not.toBeInTheDocument(); + + // Select the first item with down arrow. + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); + + // Select the second item with down arrow. + await user.keyboard('{ArrowDown}'); + // Index 1 with MenuItem type should be selected. + expect(screen.getByText('Item 2')).toHaveClass('react-contextmenu-item--selected'); + + // Select the next item. But since this was the last item, it should loop + // back to the first again. + await user.keyboard('{ArrowDown}'); + // Index 0 with MenuItem type should be selected. + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); + }); + + test('should select the proper menu items with up arrow', async () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + const onHide = jest.fn(); + render( + + Item 1 + Item 2 + ); - expect(onMouseLeave).toHaveBeenCalled(); - component.unmount(); + const user = userEvent.setup(); + + await showMenu(data); + // Check that it's visible and there is no selected item at first. + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(selectedItemElement()).not.toBeInTheDocument(); + + // Select the previous item. But since there was nothing selected, it + // should loop back down to the last item. + await user.keyboard('{ArrowUp}'); + expect(screen.getByText('Item 2')).toHaveClass('react-contextmenu-item--selected'); + + // Select the first item with up arrow. + await user.keyboard('{ArrowUp}'); + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); + }); + + test('should preserve the selected item after an enter', async () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + const onHide = jest.fn(); + render( + + Item 1 + + Item 2 + + ); + const user = userEvent.setup(); + + await showMenu(data); + // Check that it's visible and there is no selected item at first. + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(selectedItemElement()).not.toBeInTheDocument(); + + // Select the second item up arrow. + await user.keyboard('{ArrowUp}'); + expect(screen.getByText('Item 2')).toHaveClass('react-contextmenu-item--selected'); + + // Press enter to select it. + await user.keyboard('{Enter}'); + // The selected item should be preserved and not reset. + expect(screen.getByText('Item 2')).toHaveClass('react-contextmenu-item--selected'); + }); + + test('should select proper menu item, even though it is wrapped with html element', async () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + render( + +
Item 1
+ Item 2 +
+ ); + const user = userEvent.setup(); + + await showMenu(data); + // Check that it's visible and there is no selected item at first. + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(selectedItemElement()).not.toBeInTheDocument(); + + // Select the first item with down arrow. + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); + + // Select the second item with down arrow. + await user.keyboard('{ArrowDown}'); + // Index 1 with MenuItem type should be selected. + expect(screen.getByText('Item 2')).toHaveClass('react-contextmenu-item--selected'); + + // Select the next item. But since this was the last item, it should loop + // back to the first again. + await user.keyboard('{ArrowDown}'); + // Index 0 with MenuItem type should be selected. + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); + }); + + test('should allow keyboard actions when menu contains a non menu item element', async () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + render( + + Item 1 + Item 2 +
custom-content
+
+ ); + const user = userEvent.setup(); + + await showMenu(data); + // Check that it's visible and there is no selected item at first. + expect(visibleContextMenuElement()).toBeInTheDocument(); + expect(selectedItemElement()).not.toBeInTheDocument(); + + // Select the first item with down arrow and don't throw any errors + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Item 1')).toHaveClass('react-contextmenu-item--selected'); }); }); diff --git a/tests/ContextMenuTrigger.test.js b/tests/ContextMenuTrigger.test.js new file mode 100644 index 00000000..58a957ed --- /dev/null +++ b/tests/ContextMenuTrigger.test.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ContextMenu from '../src/ContextMenu'; +import ContextMenuTrigger from '../src/ContextMenuTrigger'; + +describe('ContextMenuTrigger tests', () => { + function baseSetup(options = {}) { + const ID = 'CORRECT_ID'; + render(<> + + + ); + + const wrapper = () => document.querySelector('.react-contextmenu-wrapper'); + const contextMenu = () => document.querySelector('.react-contextmenu'); + + const user = userEvent.setup(); + + async function rightClick(target) { + await user.pointer([ + { target }, + { keys: '[MouseRight]', target } + ]); + } + + return { contextMenu, wrapper, user, rightClick }; + } + + describe('without triggerOnLeftClick', () => { + const setup = baseSetup; + test('shows a ContextMenu when right clicking', async () => { + const { contextMenu, wrapper, rightClick } = setup(); + await rightClick(wrapper()); + expect(contextMenu()).toHaveClass('react-contextmenu--visible'); + }); + + test('shows a ContextMenu when ctrl + left clicking', () => { + const { contextMenu, wrapper } = setup(); + // Due to https://github.com/testing-library/user-event/issues/924 we + // have to use fireEvent directly here. + fireEvent.contextMenu(wrapper(), { button: 0, buttons: 1 }); + expect(contextMenu()).toHaveClass('react-contextmenu--visible'); + }); + + test('does not show a ContextMenu when left clicking without a modifier', async () => { + const { contextMenu, wrapper, user } = setup(); + await user.click(wrapper()); + expect(contextMenu()).not.toHaveClass('react-contextmenu--visible'); + }); + }); + + describe('with triggerOnLeftClick', () => { + function setup() { + return baseSetup({ triggerOnLeftClick: true }); + } + + test('shows a ContextMenu when right clicking', async () => { + const { contextMenu, wrapper, rightClick } = setup(); + await rightClick(wrapper()); + expect(contextMenu()).toHaveClass('react-contextmenu--visible'); + }); + + test('shows a ContextMenu when ctrl + left clicking', () => { + const { contextMenu, wrapper } = setup(); + // Due to https://github.com/testing-library/user-event/issues/924 we + // have to use fireEvent directly here. + fireEvent.contextMenu(wrapper(), { button: 0, buttons: 1 }); + expect(contextMenu()).toHaveClass('react-contextmenu--visible'); + }); + + test('shows a ContextMenu when left clicking without a modifier', async () => { + const { contextMenu, wrapper, user } = setup(); + await user.click(wrapper()); + expect(contextMenu()).toHaveClass('react-contextmenu--visible'); + }); + }); +}); + diff --git a/tests/MenuItem.test.js b/tests/MenuItem.test.js index 046bbc77..8af33699 100644 --- a/tests/MenuItem.test.js +++ b/tests/MenuItem.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import MenuItem from '../src/MenuItem'; @@ -10,11 +10,30 @@ describe('MenuItem tests', () => { className: 'CLASSNAME_ATTRIBUTE' }; - const wrapper = shallow( - + render( + Item ); - expect(wrapper.hasClass(className)).toBe(true); - expect(wrapper.hasClass(attributes.className)).toBe(true); + const element = screen.getByText('Item'); + expect(element).toHaveClass(className); + expect(element).toHaveClass(attributes.className); + }); + + test('exposes the menuitem role by default', () => { + render( + Item + ); + + const element = screen.getByRole('menuitem'); + expect(element).toBeInTheDocument(); + }); + + test('makes it possible to change the default role', () => { + render( + Item + ); + + const element = screen.getByRole('menuitemcheckbox'); + expect(element).toBeChecked(); }); }); diff --git a/tests/SubMenu.test.js b/tests/SubMenu.test.js new file mode 100644 index 00000000..fbacb1ee --- /dev/null +++ b/tests/SubMenu.test.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import SubMenu from '../src/SubMenu'; + +async function waitForMenuVisible(menu) { + await waitFor(() => expect(menu).toHaveClass('react-contextmenu--visible')); +} + +describe('', () => { + it('should have `react-contextmenu-item` & `react-contextmenu-submenu` classes', () => { + render(); + + const submenuItem = screen.getByRole('menuitem', { name: 'foo' }); + expect(submenuItem).toHaveClass('react-contextmenu-item', 'react-contextmenu-submenu'); + }); + + it('should have `disabled` class when disabled', () => { + render(); + const submenuItem = screen.getByText('foo'); + + expect(submenuItem).toHaveClass('react-contextmenu-item--disabled'); + }); + + // waitForMenuVisible does an expectation. + // eslint-disable-next-line jest/expect-expect + it('should open submenu `onMouseEnter`', async () => { + render(); + const submenu = screen.getByRole('menu'); + const user = userEvent.setup(); + const wrapper = screen.getByText('Title'); + await user.hover(wrapper); + await waitForMenuVisible(submenu); + }); + + it('should not open submenu `onMouseEnter` when disabled', async () => { + render(); + const submenu = screen.getByRole('menu'); + const user = userEvent.setup(); + const wrapper = screen.getByText('Title'); + await user.hover(wrapper); + await expect(() => waitForMenuVisible(submenu)).rejects.toThrow(); + }); + + it('should not crash after immediate unmount', async () => { + let callback = null; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + callback = cb; + return 1; + }); + const { unmount } = render(); + const user = userEvent.setup(); + const wrapper = screen.getByText('Title'); + await user.hover(wrapper); + unmount(); + + expect(callback).not.toThrow(); + }); +}); diff --git a/tests/__snapshots__/ContextMenu.test.js.snap b/tests/__snapshots__/ContextMenu.test.js.snap index be063e0c..e08cf1ff 100644 --- a/tests/__snapshots__/ContextMenu.test.js.snap +++ b/tests/__snapshots__/ContextMenu.test.js.snap @@ -1,129 +1,27 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ContextMenu tests does not shows when event with incorrect "id" is triggered 1`] = ` - -