diff --git a/.babelrc b/.babelrc index ae6b4aaf..fe2b9d63 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,43 @@ { - "presets": [ - "es2015", - "react", - "stage-0" - ] + "presets": [ + "react" + ], + "env": { + "es6": { + "presets": [ + [ + "es2015", + { + "modules": false + } + ] + ], + "plugins": [ + "transform-class-properties" + ] + }, + "es5": { + "presets": [ + "es2015" + ], + "plugins": [ + "transform-class-properties" + ] + }, + "test": { + "presets": [ + [ + "env", + { + "targets": { + "node": 6 + } + } + ] + ], + "plugins": [ + "transform-class-properties" + ] + } + } } diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000..615c5821 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,19 @@ +engines: + duplication: + enabled: true + config: + languages: + - javascript: + eslint: + enabled: true + fixme: + enabled: true +exclude_paths: + - examples/**/* + - tests/**/* + - examples.config.js + - webpack.config.js + - *.md +ratings: + paths: + - src/**/* diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..400887f7 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +examples/bundle.js diff --git a/.eslintrc.yml b/.eslintrc.yml index 9aad67a3..9fa70bd1 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1 +1,11 @@ -extends: ["vkbansal", "vkbansal/react"] +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/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..dc90e5a1 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# 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/.gitignore b/.gitignore index d53b5546..36f1f6d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ node_modules/ public/ dist/ modules/ +es6/ +coverage/ +.vscode +.idea +*.log +*.js.map \ No newline at end of file diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 3738c156..00000000 --- a/.npmignore +++ /dev/null @@ -1,10 +0,0 @@ -examples/ -reports/ -scripts/ -test/ -.editorconfig -.eslintrc.yml -.travis.yml -ISSUE_TEMPLATE.md -server.js -*.config.js diff --git a/.travis.yml b/.travis.yml index 2242e090..56c062dd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,8 @@ language: node_js node_js: - - 5 + - 8 after_success: - npm run coveralls script: - - bash ./scripts/deploy.sh -env: - global: - - GITHUB_REPO: vkbansal/react-contextmenu - - secure: "qJiEWuE8BuFx5Rzx7ZBvQ1bHGChkZjkDQo13szIA3GMRojB72aPGDDj2f4jKaik0THEdIFz2nryjVHlsWoG2DToUiFUneEue5S0Si7ZToF55ntFAZmyporvYHhS4+LE4/lFYkcnDBr2Sc62FG4ILRkRf+QN0hhM9hw0uHjF810XSZRLBKG3COb/qM9psJr0eS4V5YaUngp4e3pmeanygspOUoB2dwfqp8d7jIPNCMYw8Doyw8kUzJyT6WgxgSGgyf62nK/gFu+ZkzGYzI7kMHMQh8Qn7usdKoMqiE1CqrzhOQLvZnnQTeBLzyz06IxiDKW8srf17phsECx0K+BfmZh1vzOrkbjBqynhFkuuSX5FjPGAuBHEhOB7+NVSyKXYStcORxhSWirJp1OlMF89zrD07uOH1vIS/mU4cL4hTzr5a8W8ckrYcTaaTQzjxZNqcXJqFL2vxb3JN3lTP0oHV8shhN6k1wpe869nRJUy/uSHSHP+O7b9meEuu+nnZs6/LyERzXyIH8h4/FLQGSAqKOTTilwaN7QUd8/bix0WMAj41AMUWRHlHI09uU0T4K3Q9WS7rc74I+GZMDcRlzWS+fLR/8vJshl12CIq+R/zciP2TUKnEAyl6/pJ/7akwIH4w3Vi6p2LA3M4Ih8QvuVWn5fwNtSFGNYszIVmRgvTIYfo=" - -branches: - only: - - master + - npm test + - bash ./deploy.sh diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 08b456e3..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,152 +0,0 @@ -# Changelog - -All the changes can be found below. Order used: - -- Added -- Changed -- Deprecated -- Removed -- Fixed -- Security - -## v1.6.3 - -### Fixed -- Correct position of the menu when out of viewport (#31) -- Fix failing tests - -### Changed -- Update dev packages - -## v1.6.2 - -### Fixed -- Context menu doesn't show during test in PhantomJS (#20) - -## v1.6.1 - -### Fixed -- fixed `cannot read parentNode of undefined` (#19) - -## v1.6.0 - -### Added -- Added touch support (#17) - -### Fixed -- Fix: error while unmounting the component (#16) -- Fix: when context menu is showing, right click shows the browser's context menu (#14) - -## v1.5.0 - -### Added -- Added `attributes` prop to `MenuItem` and `ContextMenuLayer` for further customization. - -## v1.4.0 - -### Added -- Added examples for custom wrappers using `renderTag`. -- Added support for `react v15.0`. - -## v1.3.0 - -### Added - -- Added `renderTag` prop to customize the wrapper inside `ContextMenuLayer`. - -## v1.2.0 - -### Added - -- Added `connect` helper to render different items based on which component was (right) clicked. - -## v1.1.1 - -### Fixed -- Fixed `getDefaultProps` in ``. - -## v1.1.0 - -### Added - -- Support for nested sub-menus. -- More custom styling options. -- Proper API documentation. - -### Removed -- Hard Dependency on `react-dom` inside components. - -## v1.0.1 - -### Changed -- Internal: use `getBoundingClientRect()` instead of `offsetHeight` and `offsetWidth`. -- Internal: Update packages and scripts. - -### Fixed -- Fix class names (`.react-context-menu-link`) for links. - -## v1.0.0 - -### Added - -- Ability to use custom styles. - -### Changed - -- Use `react-bootstap/react-overlays` instead of custom logic to render the menu. -- Make `configure` of `ContextMenuLayer` optional. - -### Removed - -- Dependency on bootstrap styling. - - -## v0.4.0 - -### Changed -- Replace `webpack-dev-server` with `react-hmre`. -- Revert to use `createClass`. -- Switch to Redux from Flummox. - -### Fixed - - Menu goes outside when closer to edges. - -## v0.3.0 - -### Added -- Compatibility for react-0.14. - -### Changed -- Updated to babel 6. - -### Fixed -- Fixed unbind event handlers. - -## v0.2.2 - -### Added - - Add compatibility with IE (Still needed for IE11). - -### Fixed - - Better event handling. - - Only hide the currently visible context menu. On touch screens, the menu closes before the event to click is triggered. This only happened when using multiple contex menus. - -## v0.2.1 - -### Fixed -- Fixed `hideMenu is not a function` error while scrolling. - -## v0.2.0 - -### Added -- Added `monitor` component to get currentItem and position. -- Added `.npmignore`. - -### Changed -- Replaced `lodash.merge` with `object-assign`. -- Now children are not cloned inside `ContextMenu` component. -- Updated dev dependencies. - -## v0.1.0 - -- Initial Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..37e31c9c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing +First off, thanks for taking the time to contribute. + +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. + +##### Submiting a (good) bug report + +- **Use a clear and descriptive title** for the issue to identify the problem. +- **Provide specific examples to demonstrate the steps**. Include links to CodePen/JS Bin or GitHub projects, or copy/paste-able snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). +- **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. +- **Explain which behavior you expected to see instead and why.** +- **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. + +### Proposing New or Changed API? + +Please provide thoughtful comments and some sample code. Proposals without substance will be closed. + +### Making a Pull Request + +Pull requests need only the 👍 of admin or two or more collaborators to be merged. + +##### Linting and Tests + +- All the commits must pass lint and tests. +- All commits that fix bugs or add features need a test. + +You can run lint + tests via: + +``` +npm test +``` + +If you just want to run lint: + +``` +npm run lint +``` + +If you just want to run all tests: + +``` +npm run test:only +``` + +##### Development + +You can start webpack & dev server that watches for changes and build the examples via: + +``` +npm start +``` + + + diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index 2d304909..39087b3d 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -4,10 +4,10 @@ Thank you for contributing! ## Version -1.2.0 +1.9.1 ## Test Case -http://jsbin.com/sacerobuxi/edit?html,js,output +https://jsfiddle.net/Luktwrdm/54/ ## Steps to reproduce diff --git a/README.md b/README.md index c96a06f0..104da2b4 100644 --- a/README.md +++ b/README.md @@ -1,293 +1,114 @@ +# 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] -# React ContextMenu +[![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/) + +## Table of contents -ContextMenu in React. + - [Installation](#installation) + - [Browser Support](#browser-support) + - [Usage](#usage) + - [API](#api) + - [FAQs](#faqs) + - [Contributors](#contributors) + - [Changelog](#changelog) + - [License](#license) ## Installation +Using npm + ``` npm install --save react-contextmenu ``` +Using yarn + +``` +yarn add react-contextmenu +``` + +## Browser Support +- IE 11 and Edge >= 12 +- FireFox >= 38 +- Chrome >= 47 +- Opera >= 34 +- Safari >= 8 + ## Usage -You need to setup two things: -1. The `ContextMenu` -2. The Component on which the `ContextMenu` must be invoked. +Simple example -```js +```jsx import React from "react"; import ReactDOM from "react-dom"; -import { ContextMenu, MenuItem, ContextMenuLayer } from "react-contextmenu"; - -//Component on which context-menu must be triggred -const MyComponent = ContextMenuLayer("some_unique_identifier")( - React.createClass({ - render() { - return
; - } - }) -); - -//The context-menu to be triggered -const MyContextMenu = React.createClass({ - render() { - return ( - - - ContextMenu Item 1 - - - ContextMenu Item 2 - - - - ContextMenu Item 3 - - - ); - }, - handleClick(e, data) { - console.log(data); - } -}); - -const MyApp = React.createClass({ - render() { - return ( -
- - -
- ); - } -}); +import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu"; + +function handleClick(e, data) { + console.log(data.foo); +} + +function MyApp() { + return ( +
+ {/* NOTICE: id must be unique between EVERY and pair */} + {/* NOTICE: inside the pair, and must have the same id */} + + +
Right click to see the menu
+
+ + + + ContextMenu Item 1 + + + ContextMenu Item 2 + + + + ContextMenu Item 3 + + + +
+ ); +} ReactDOM.render(, document.getElementById("main")); ``` -As you can see that the `ContextMenu` to be showed on any component is dependent on a **unique identifier**. - -The `ContextMenuLayer` higher order function takes two parameters. First is the **unique identifier** (same as the one used on the `ContextMenu`) and second is (optional) a function that must return some data that will be passed on to the `onClick` method of the `MenuItem`. This helps in identifying the component on which context click occured. - -See [examples](./examples) for more in detail usage. - -##Styling - -The styling can be apllied to using following classes. - -- `react-context-menu` : applied to menu root element. -- `react-context-menu-item` : applied to menu items. -- `react-context-menu-link` : applied to menu links inside items. -- `react-context-menu-wrapper` : applied to wrapper around elements in `ContextMenuLayer`. -- `submenu` : applied to items that are submenus. -- `disabled` : applied to links (title in submenu) when they are disabled. -- `active` : applied to title in submenu when submenu is open. - -See [react-context-menu.css](./examples/react-context-menu.css) for example. - +see [usage docs](./docs/usage.md) / [examples](./examples) for more details. ## API -The module exports the following: - -- `ContextMenu` -- `ContextMenuLayer` -- `MenuItem` -- `SubMenu` -- `monitor` -- `connect` - - -### ContextMenu(props) - -Type: React Component - -Base Contextmenu Component. - -**props.identifier** - -Type: `String` required - -A unique identifier for the menu. - -### ContextMenuLayer(identifier, configure) - -Type: `Function` - -Return: `Function` - -A Higher Order function that returns a function which in turn can be used to create a React Component on which the context menu must be triggered. - -**identifier** - -Type: `String` required - -The unique identifier of the menu to be called. - -**configure** - -Type: `Function` optional - -A simple function which takes props as input and returns the data to be passed to contextmenu. - -### MenuItem(props) - -Type: React Component - -A Simple Component for menu items. - -**props.data** - -Type: `Object` optional - -Default: `{}` - -The extra data (if required) to be passed to `onClick` event. - -**props.disabled** - -Type: `Boolean` optional - -Default: `false` - -If `true`, disables the click event and adds `.disabled` class. - -**props.onClick** - -Type: `Function` required - -The function to be called on click of item. The function will receive two parameters. The first is `event` object and the second is the extra data passed either using `props.data` or configure from `ContextMenuLayer`. +[API docs](./docs/api.md) -**props.preventClose** +## FAQs -Type: `Boolean` optional +[ALL FAQs](./docs/faq.md) -Default: `false` +## Who's using react-contextmenu? +- [react-data-grid](https://github.com/adazzle/react-data-grid) +- [teamup.com](https://teamup.com) +- [Spotify Web Player](https://open.spotify.com) -By default, the context menu is closed as soon as an item is clicked. Set this prop to control this behavior. +## Contributors -**props.attributes** +[All Contributors](https://github.com/vkbansal/react-contextmenu/graphs/contributors) -Type: `Object` optional - -The attributes will be passed directly passed to the root element of `MenuItem`. Use this to customize it like adding custom classes, etc. - -### SubMenu(props) - -Type: React Component - -A component for using submenus within the contextmenu. - -**props.title** - -Type: `String` required - -The content to be displayed in parent menu. - -**props.hoverDelay** - -Type: `Number` optional - -Default: 500 - -The time (in ms) after which the menu is to be displayed when hovered upon. - -**props.disabled** - -Type: `Boolean` optional - -Default: `false` - -If `true`, disables the menu from opening and adds `.disabled` class. - -### monitor - -Type: `Object` - -A utility object. - -**monitor.getItem()** - -Type: `Function` - -Returns: `Object` - -A getter for the data passed to contextmenu, which is set using `configure`. - -**monitor.getPosition()** - -Type: `Function` - -Returns: `Object` - -Gives the position of the current or last active contextmenu. - -**monitor.hideMenu()** - -Type: `Function` - -A utility function to hide the currently active contextmenu programmatically. - -### connect(Menu) - -Type: `Function` - -Returns: React Component - -A simple wrapper to be used when different items must be rendered based on which component was (right) clicked. - -**Menu** - -Type: React Component - -The `Menu` component that needs to be updated depending on the current selection. The component will receive object from `monitor.getItem()` in its props with `item` as key. All the other props will be simply passed through. - -## Customization -The higher order component created using `ContextMenuLayer` can accept the following props. - -**renderTag** - -Type: React Element (optional) - -The element inside which the Component must be wrapped. By default `div` is used. But this prop can used to customize it. - -**attributes** - -Type: `Object` optional - -The attributes will be passed directly passed to the root element of component. Use this to customize it like adding custom classes, adding `colspan` etc. - -```js -// following examples shows usage of `tr` instead of `div` - -import { ContextMenuLayer } from "react-contextmenu"; -import MenuTarget from "./menu-target"; - -const Item = ContextMenuLayer("identifier")(MenuTarget); - -const App = React.createClass({ - render() { - return ( - - - {this.props.items.map((item, i) => ( - - ))} - -
- ); - } -}); -``` +## Changelog -## Credits -This project is based on the ideas from [react-dnd](https://github.com/gaearon/react-dnd) by [Dan Abramov](https://github.com/gaearon). +For Changelog, see [releases](https://github.com/vkbansal/react-contextmenu/releases) ## License diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..a02f6293 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,25 @@ +#!/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 new file mode 100644 index 00000000..f0942e9e --- /dev/null +++ b/docs/api.md @@ -0,0 +1,80 @@ +# API + +The module exports the following: + +- `ContextMenu` +- `ContextMenuTrigger` +- `MenuItem` +- `SubMenu` + +### `` +Base Contextmenu Component. + +#### PropTypes + +| Property | Type | Required? | Description | +|--------------------------|----------|-----------|------------------------------------------------------------------| +| id | String | ✓ | A unique identifier for a menu. | +| hideOnLeave | Boolean | | Hides the context menu on mouse leave. | +| onMouseLeave | Function | | Callback called when the mouse leaves the menu or submenu areas. | +| onHide | Function | | Callback called when the menu is hidden. | +| onShow | Function | | Callback called when the menu is shown. | +| preventHideOnContextMenu | Boolean | | Prevents hiding of the context menu on contextMenu event. | +| preventHideOnResize | Boolean | | Prevents hiding of the context menu on resize event. | +| preventHideOnScroll | Boolean | | Prevents hiding of the context menu on scroll event. | +| className | String | | Custom `className` applied to root element of the context-menu. | + +### `` + +Contextmenu Trigger Component + +#### PropTypes + +| Property | Type | Required? | Default | Description | +|-------------------------|-------------------------|-----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| id | String | ✓ | | The unique identifier of the menu to be called. | +| attributes | Object | | | The attributes will be passed directly passed to the root element of component. Use this to customize it like adding custom classes, adding `colspan` etc. | +| collect | Function | | | A simple function which takes `props` as input and returns the data to be passed to contextmenu. | +| disable | Boolean | | `false` | Prop to ignore right clicks and display the default browser context menu. | +| 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 | + +### `` + +A Simple Component for menu items. + +#### PropTypes + +| Property | Type | Required? | Default | Description | +|--------------|----------|-----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| onClick | Function | ✓ | | The function to be called on click of item. The function will receive three parameters. The first is `event` object. The second is the merged data passed using `props.data` and collect from `ContextMenuTrigger`. The third is the element on which right-click occured. | +| data | Object | | `{}` | The extra data (if required) to be passed to `onClick` event. | +| disabled | Boolean | | `false` | If `true`, disables the click event and adds `.disabled` class. | +| preventClose | Boolean | | `false` | By default, the context menu is closed as soon as an item is clicked. Set this prop to control this behavior. | +| attributes | Object | | | The attributes will be passed directly passed to the root element of `MenuItem`. Use this to customize it like adding custom classes (`className`, `disabledClassName`, `dividerClassName` and `selectedClassName`), etc. | +| selected | boolean | | `false` | **Internal Prop**: will be set from the surrounded context `ContextMenu` or `SubMenu`. If set to `true` the css class `react-contextmenu-item--selected` will be added to associated element. | +| onMouseMove | Function | | | **Internal Prop**: will be directly passed to associated element, so the surrounded context `ContextMenu` or `SubMenu` can handle the interactions to pass the correct `selected` state. Also the surrounded context can store the current selected `MenuItem`. | +| onMouseLeave | Function | | | **Internal Prop**: will be directly passed to associated element, so the surrounded context `ContextMenu` or `SubMenu` can handle the interactions to pass the correct `selected` state. Also the surrounded context can store the current selected `MenuItem`. | + +### `` + +A component for using submenus within the contextmenu. + +#### PropTypes + +| Property | Type | Required? | Default | Description | +|-------------|----------|-----------|---------|-----------------------------------------------------------------------------| +| title | String | ✓ | | The content to be displayed in parent menu. | +| disabled | Boolean | | `false` | If `true`, disables the menu from opening and adds `.disabled` class. | +| hoverDelay | Number | | `500` | The time (in ms) after which the menu is to be displayed when hovered upon. | +| className | String | | | Custom `className` applied to root element of the context-menu. | +| attributes | Object | | | The attributes will be passed directly passed to the root element of `SubMenu`. Use this to customize it like adding custom classes (`className`, `disabledClassName`, `visibleClassName`, `selectedClassName` and `listClassName`), etc. | +| selected | boolean | | `false` | **Internal Prop**: will be set from the surrounded context `ContextMenu` or `SubMenu`. If set to `true` the css class `react-contextmenu-item--selected` will be added to associated element. | +| onClick | Function | | | The function to be called on click of `SubMenu`. The function will receive three parameters. The first is `event` object. The second is the merged data passed using `props.data` and collect from `ContextMenuTrigger`. The third is the element on which right-click occured. | +| onMouseMove | Function | | | **Internal Prop**: will be directly passed to associated element, so the surrounded context `ContextMenu` or `SubMenu` can handle the interactions to pass the correct `selected` state. Also the surrounded context can store the current selected `SubMenu` entry. | +| onMouseOut | Function | | | **Internal Prop**: will be directly passed to associated element, so the surrounded context `ContextMenu` or `SubMenu` can handle the interactions to pass the correct `selected` state. Also the surrounded context can store the current selected `SubMenu` entry. | +| preventCloseOnClick | Boolean | | `false` | Prevent the context-menu to close when clicking on this `SubMenu`. | +| forceOpen | boolean | | `false` | **Internal Prop**: if the user hits enter or the right arrow key on a selected `SubMenu` entry, the surrounded context will pass `true` to this flag. The `SubMenu` stays open until this flag is `false`. | +| forceClose | Function | | | **Internal Prop**: if the user hits the escape key during an open `SubMenu`, this function will be called to indicate the surrounding context to reset the `forceOpen` flag. | +| parentKeyNavigationHandler | Function | | | **Internal Prop**: the `keydown` handler from the surrounding context will be passed to every open `SubMenu`. Then the `Submenu` will unregister the referenced handler and use it's own for key control. When the `SubMenu` hides again the original handler will be restored. | diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000..7a5cd773 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,31 @@ +# FAQs + +**Long left click triggers context menu** + +As metioned in [API docs](), to disable the long press trigger on left-click just set a negative `holdToDisplay` value such as `-1`; + +**Manually opening the context menu** + +If you want a [menu button](http://i.imgur.com/5Qsu2yW.png) that toggles the context menu manually with a regular left click, create a reference to the `ContextMenuTrigger` and pass the click event to `handleContextClick`. + +```jsx +let contextTrigger = null; + +const toggleMenu = e => { + if(contextTrigger) { + contextTrigger.handleContextClick(e); + } +}; + +return ( + // Handles right clicking the trigger + contextTrigger = c}> + + // The trigger has a menu icon on top of it, allowing for left click + + + + ... + +); +``` diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 00000000..ad1542ea --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,5 @@ +# Docs + +- [Usage](./usage.md) +- [API](./api.md) +- [FAQs](./faq.md) diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..e80706ad --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,62 @@ +# Usage + +You need to setup two things: +1. The `ContextMenu` +2. The `ContextMenuTrigger` + +```jsx +import React from "react"; +import ReactDOM from "react-dom"; +import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu"; + +function handleClick(e, data) { + console.log(data.foo); +} + +function MyApp() { + return ( +
+ + +
Right click to see the menu
+
+ + + + ContextMenu Item 1 + + + ContextMenu Item 2 + + + + ContextMenu Item 3 + + + +
+ ); +} + +ReactDOM.render(, document.getElementById("main")); +``` + +As you can see that the `ContextMenu` to be showed on any component is dependent on a **unique id**. + +See [examples](../examples) for more in detail usage. + +## Styling + +The styling can be apllied to using following classes. + +- `react-contextmenu` : applied to menu root element. +- `react-contextmenu--visible` : applied to menu root element when visible. +- `react-contextmenu-item` : applied to menu items. +- `react-contextmenu-item--active` : applied to menu items and title in submenu when submenu is open. +- `react-contextmenu-item--selected` : applied to currently selected menu item. +- `react-contextmenu-item--disabled` : applied to menu items and title in submenu when they are disabled. +- `react-contextmenu-item--divider` : applied to menu items with the `divider` prop. +- `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. diff --git a/examples.config.js b/examples.config.js deleted file mode 100644 index e6f1d86b..00000000 --- a/examples.config.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable */ - -var webpack = require("webpack"); - -module.exports = { - entry: ["./examples/index.js"], - output: { - filename: "bundle.js", - sourceMapFileName: "bundle.js.map", - path: process.cwd(), - publicPath: "/" - }, - module: { - loaders: [ - { - test: /\.js$/, - loader: "babel", - exclude: /node_modules/ - } - ] - }, - devtool: "source-map", - plugins: [ - new webpack.optimize.OccurenceOrderPlugin(), - new webpack.optimize.UglifyJsPlugin({ - compressor: { - warnings: false - } - }), - new webpack.DefinePlugin({ - "process.env": { - "NODE_ENV": JSON.stringify("production") - } - }) - ] -}; diff --git a/examples/.eslintrc.yml b/examples/.eslintrc.yml new file mode 100644 index 00000000..504d133d --- /dev/null +++ b/examples/.eslintrc.yml @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..f0eb15b5 --- /dev/null +++ b/examples/Customization.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; + +const MENU_TYPE = 'SIMPLE'; + +const targets = [{ + name: 'Banana' +}, { + name: 'Apple' +}, { + name: 'Papaya' +}, { + name: 'Mango' +}, { + name: 'Orange' +}, { + name: 'Pineapple' +}]; + +function collect(props) { + return { name: props.name }; +} + +export default class Customization extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on ${data.name} menu ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Custom Wrappers

+

+ This demo shows usage of customization. + Instead of using div(s) by default, we are using tr(s) +

+ + + + + + + + + {targets.map((item, i) => ( + + + + + ))} + +
#Fruit
{i + 1}{item.name}
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu Item 1 + Menu Item 2 + +
+ ); + } +} diff --git a/examples/DynamicMenu.js b/examples/DynamicMenu.js new file mode 100644 index 00000000..29cfbb23 --- /dev/null +++ b/examples/DynamicMenu.js @@ -0,0 +1,115 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; +import connectMenu from 'src/connectMenu'; + +const MENU_TYPE = 'DYNAMIC'; + +const targets = [{ + name: 'Banana' +}, { + name: 'Apple' +}, { + name: 'Papaya' +}, { + name: 'Mango' +}, { + name: 'Orange' +}, { + name: 'Pineapple' +}]; + +function collect(props) { + return props; +} + +const DynamicMenu = (props) => { + const { id, trigger } = props; + const handleItemClick = trigger ? trigger.onItemClick : null; + + return ( + + {trigger && {`Add 1 ${trigger.name}`}} + {trigger && ( + trigger.allowRemoval + ? {`Remove 1 ${trigger.name}`} + : {'Removal disabled'} + )} + + ); +}; + +DynamicMenu.propTypes = { + id: PropTypes.string.isRequired, + trigger: PropTypes.shape({ + name: PropTypes.string.isRequired, + onItemClick: PropTypes.func.isRequired, + allowRemoval: PropTypes.bool + }).isRequired +}; + +const ConnectedMenu = connectMenu(MENU_TYPE)(DynamicMenu); + +export default class DynamicMenuExample extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data, target) => { + const count = parseInt(target.getAttribute('data-count'), 10); + + if (data.action === 'Added') { + target.setAttribute('data-count', count + 1); + + return this.setState(({ logs }) => ({ + logs: [`${data.action} 1 ${data.name}`, ...logs] + })); + } + if (data.action === 'Removed' && count > 0) { + target.setAttribute('data-count', count - 1); + + return this.setState(({ logs }) => ({ + logs: [`${data.action} 1 ${data.name}`, ...logs] + })); + } + return this.setState(({ logs }) => ({ + logs: [` ${data.name} cannot be ${data.action.toLowerCase()}`, ...logs] + })); + } + + render() { + const attributes = { + 'data-count': 0, + className: 'example-multiple-targets well' + }; + + return ( +
+

Dynamic Menu

+

This demo shows usage of dynamically created menu on multiple targets.

+
+ {targets.map((item, i) => ( +
+ + {item.name} + +
+ ))} +
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ +
+ ); + } +} diff --git a/examples/MultipleMenus.js b/examples/MultipleMenus.js new file mode 100644 index 00000000..21ae4ac3 --- /dev/null +++ b/examples/MultipleMenus.js @@ -0,0 +1,54 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; + +const MENU_1 = 'MENU_1'; +const MENU_2 = 'MENU_2'; + +export default class MultipleMenus extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on menu ${data.menu} item ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Multiple Menus

+

This demo shows usage of multiple menus on multiple targets.

+
+
+ +
right click to see the menu
+
+
+
+ +
right click to see the menu
+
+
+
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu 1 Item 1 + Menu 1 Item 2 + + + Menu 2 Item 1 + Menu 2 Item 2 + +
+ ); + } +} diff --git a/examples/MultipleTargets.js b/examples/MultipleTargets.js new file mode 100644 index 00000000..3bb4deab --- /dev/null +++ b/examples/MultipleTargets.js @@ -0,0 +1,90 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; + +const MENU_TYPE = 'MULTI'; + +const targets = [{ + name: 'Banana' +}, { + name: 'Apple' +}, { + name: 'Papaya' +}, { + name: 'Mango' +}, { + name: 'Orange' +}, { + name: 'Pineapple' +}]; + +function collect(props) { + return { name: props.name }; +} + +export default class MultipleTargets extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data, target) => { + const count = parseInt(target.getAttribute('data-count'), 10); + + if (data.action === 'Added') { + target.setAttribute('data-count', count + 1); + + return this.setState(({ logs }) => ({ + logs: [`${data.action} 1 ${data.name}`, ...logs] + })); + } + + if (data.action === 'Removed' && count > 0) { + target.setAttribute('data-count', count - 1); + + return this.setState(({ logs }) => ({ + logs: [`${data.action} 1 ${data.name}`, ...logs] + })); + } + + return this.setState(({ logs }) => ({ + logs: [` ${data.name} cannot be ${data.action.toLowerCase()}`, ...logs] + })); + } + + render() { + const attributes = { + 'data-count': 0, + className: 'example-multiple-targets well' + }; + + return ( +
+

Multiple Menus

+

This demo shows usage of multiple menus on multiple targets.

+
+ {targets.map((item, i) => ( +
+ + {item.name} + +
+ ))} +
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Add 1 count + Remove 1 count + +
+ ); + } +} diff --git a/examples/Nested.js b/examples/Nested.js new file mode 100644 index 00000000..3ba0e61b --- /dev/null +++ b/examples/Nested.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; + +const MENU_TYPE = 'SIMPLE22'; +const MENU_TYPE2 = 'SIMPLE222'; + +export default class SimpleMenu extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on menu ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Test Menu

+

This demo simple usage of a context menu.

+ +
+ right click to see the menu + +
+ right click to see the nested menu +
+
+
+
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu Item 1 + Menu Item 2 + + Menu Item 3 + + + Nested Menu Item 1 + Nested Menu Item 2 + + Nested Menu Item 3 + +
+ ); + } +} diff --git a/examples/RTLSubMenu.js b/examples/RTLSubMenu.js new file mode 100644 index 00000000..65392d14 --- /dev/null +++ b/examples/RTLSubMenu.js @@ -0,0 +1,53 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; +import SubMenu from 'src/SubMenu'; + +const MENU_TYPE = 'RTL'; + +export default class RTLSubMenu extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on menu ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Right-to-Left ContextMenu and Submenu Menu

+

This demos usage of Right-to-Left submenus.

+ +
right click to see the menu
+
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu Item 1 + Menu Item 2 + + SubItem 1 + + SubSubItem 1 + SubSubItem 2 + + + SubSubItem 3 + SubSubItem 4 + + SubItem 2 + + +
+ ); + } +} diff --git a/examples/SimpleMenu.js b/examples/SimpleMenu.js new file mode 100644 index 00000000..cbd0e5d1 --- /dev/null +++ b/examples/SimpleMenu.js @@ -0,0 +1,42 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; + +const MENU_TYPE = 'SIMPLE'; + +export default class SimpleMenu extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on menu ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Simple Menu

+

This demo simple usage of a context menu.

+ +
right click to see the menu
+
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu Item 1 + Menu Item 2 + + Menu Item 3 + +
+ ); + } +} diff --git a/examples/SubMenus.js b/examples/SubMenus.js new file mode 100644 index 00000000..cc70f365 --- /dev/null +++ b/examples/SubMenus.js @@ -0,0 +1,53 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from 'src/ContextMenuTrigger'; +import ContextMenu from 'src/ContextMenu'; +import MenuItem from 'src/MenuItem'; +import SubMenu from 'src/SubMenu'; + +const MENU_TYPE = 'SIMPLE'; + +export default class SimpleMenu extends Component { + constructor(props) { + super(props); + + this.state = { logs: [] }; + } + + handleClick = (e, data) => { + this.setState(({ logs }) => ({ + logs: [`Clicked on menu ${data.item}`, ...logs] + })); + } + + render() { + return ( +
+

Simple Menu

+

This demo simple usage of a context menu.

+ +
right click to see the menu
+
+
+ {this.state.logs.map((log, i) =>

{log}

)} +
+ + Menu Item 1 + Menu Item 2 + + SubItem 1 + + SubSubItem 1 + SubSubItem 2 + + + SubSubItem 3 + SubSubItem 4 + + SubItem 2 + + +
+ ); + } +} diff --git a/examples/customization/constants.js b/examples/customization/constants.js deleted file mode 100644 index 0f9d4a3f..00000000 --- a/examples/customization/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - multi: "multi" -}; diff --git a/examples/customization/container.js b/examples/customization/container.js deleted file mode 100644 index 2fa5c518..00000000 --- a/examples/customization/container.js +++ /dev/null @@ -1,36 +0,0 @@ -"use strict"; - -import React from "react"; -import Target from "./target"; - -const targets = [{ - name: "Banana" -}, { - name: "Apple" -}, { - name: "Papaya" -}, { - name: "Mango" -}, { - name: "Orange" -}, { - name: "Pineapple" -}]; - -const Container = React.createClass({ - displayName: "Container", - render() { - return ( - - - {targets.map((item, i) => ( - - ))} - -
- ); - } -}); - -export default Container; diff --git a/examples/customization/index.js b/examples/customization/index.js deleted file mode 100644 index f8e39fd8..00000000 --- a/examples/customization/index.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; - -import React from "react"; -import Container from "./container"; -import Menu from "./menu"; - -const Customization = React.createClass({ - displayName: "Customization", - getInitialState() { - return { logs: [] }; - }, - addLog(log) { - this.setState({ - logs: this.state.logs.concat(log) - }); - }, - render() { - return ( -
-

Custom Wrappers

-

This demo shows usage of customization. Instead of using divs by default, we are using trs

- -
- {this.state.logs.map((log, i) => (

{log}

))} -
- -
- ); - } -}); - -export default Customization; diff --git a/examples/customization/menu.js b/examples/customization/menu.js deleted file mode 100644 index 31320259..00000000 --- a/examples/customization/menu.js +++ /dev/null @@ -1,38 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem, connect } from "../../src"; -import MenuTypes from "./constants"; - -const Menu = React.createClass({ - displayName: "Menu", - handleClick(e, data) { - this.props.addLog(`Clicked on menu ${data.item} on ${data.name}`); - }, - handleEat(e, data) { - this.props.addLog(`Must Eat ${data.name}`); - }, - render() { - let { name } = this.props.item; - - const attributes = { - className: "my-custom-menu-item-class" - }; - - return ( - - - Menu Item 1 - - - Menu Item 2 - - Eat {name} - - ); - } -}); - -export default connect(Menu); diff --git a/examples/customization/target.js b/examples/customization/target.js deleted file mode 100644 index 42bcf78b..00000000 --- a/examples/customization/target.js +++ /dev/null @@ -1,18 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenuLayer } from "../../src"; -import MenuTypes from "./constants"; - -const MenuTarget = React.createClass({ - displayName: "MenuTarget", - render() { - return ( - {this.props.name} - ); - } -}); - -export default ContextMenuLayer(MenuTypes.multi, (props) => ({ - name: props.name -}))(MenuTarget); diff --git a/examples/index.html b/examples/index.html index 18a4b7bf..c27b9623 100644 --- a/examples/index.html +++ b/examples/index.html @@ -1,23 +1,73 @@ - + + React ContextMenu - - + -
- +
+

react-contextmenu

+

ContextMenu in React with accessibility support.

+ Fork me on GitHub + +
+

Download / Usage: View on GitHub

+
+

Examples:

+
+
+ + diff --git a/examples/index.js b/examples/index.js index c415505e..62f3b5fe 100644 --- a/examples/index.js +++ b/examples/index.js @@ -1,60 +1,88 @@ -"use strict"; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Route, NavLink as Link, Switch } from 'react-router-dom'; -import React from "react"; -import ReactDOM from "react-dom"; -import { Router, Route, Link, IndexRoute, hashHistory } from "react-router"; -import SimpleMenu from "./simple-menu"; -import MultipleTargets from "./multiple-targets"; -import MultipleMenus from "./multiple-menus"; -import SubMenus from "./submenus"; -import Customization from "./customization"; +import SimpleMenu from './SimpleMenu'; +import MultipleTargets from './MultipleTargets'; +import MultipleMenus from './MultipleMenus'; +import SubMenus from './SubMenus'; +import DynamicMenu from './DynamicMenu'; +import Customization from './Customization'; +import RTLSubMenu from './RTLSubMenu'; +import Nested from './Nested'; -const App = React.createClass({ - displayName: "App", - render() { - let { children } = this.props; +import './react-contextmenu.css'; - return ( -
-

React ContextMenu Context menus using react

-
-
    -
  • - Simple Menu -
  • -
  • - Multiple Targets -
  • -
  • - Multiple Menus -
  • -
  • - Sub Menus -
  • -
  • - Customization -
  • -
-
-
- {children} -
+const commonProps = { + className: 'pure-menu-link', + activeClassName: 'link-active' +}; + +function App() { + return ( +
+
+
    +
  • + + Simple Menu + +
  • +
  • + + Multiple Targets + +
  • +
  • + + Multiple Menus + +
  • +
  • + + Sub Menus + +
  • +
  • + + Dynamic Menu + +
  • +
  • + + Customization + +
  • +
  • + + Right-to-Left + +
  • +
  • + + Nested + +
  • +
- ); - } -}); + + + + + + + + + + +
+ ); +} const Routes = ( - - - - - - - - - + + ); -ReactDOM.render(Routes, document.getElementById("main")); +ReactDOM.render(Routes, document.getElementById('main')); diff --git a/examples/multiple-menus/constants.js b/examples/multiple-menus/constants.js deleted file mode 100644 index 2caa35c5..00000000 --- a/examples/multiple-menus/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - menu: "menu", - menu2: "menu2" -}; diff --git a/examples/multiple-menus/container.js b/examples/multiple-menus/container.js deleted file mode 100644 index 2c021224..00000000 --- a/examples/multiple-menus/container.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -import React from "react"; -import Target from "./target"; -import MenuTypes from "./constants"; - - -const Container = React.createClass({ - displayName: "Container", - render() { - return ( -
-
- -
-
- -
-
- ); - } -}); - -export default Container; diff --git a/examples/multiple-menus/index.js b/examples/multiple-menus/index.js deleted file mode 100644 index cc40070e..00000000 --- a/examples/multiple-menus/index.js +++ /dev/null @@ -1,34 +0,0 @@ -"use strict"; - -import React from "react"; -import Container from "./container"; -import Menu from "./menu"; -import Menu2 from "./menu2"; - -const MultipleMenus = React.createClass({ - displayName: "MultipleMenus", - getInitialState() { - return { logs: [] }; - }, - addLog(log) { - this.setState({ - logs: this.state.logs.concat(log) - }); - }, - render() { - return ( -
-

Multiple Menus

-

This demo shows usage of multiple menus on multiple targets.

- -
- {this.state.logs.map((log, i) => (

{log}

))} -
- - -
- ); - } -}); - -export default MultipleMenus; diff --git a/examples/multiple-menus/menu.js b/examples/multiple-menus/menu.js deleted file mode 100644 index 579daed4..00000000 --- a/examples/multiple-menus/menu.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem } from "../../src"; -import MenuTypes from "./constants"; - -const Menu = React.createClass({ - displayName: "Menu", - handleClick(e, data) { - this.props.addLog(`Clicked on menu 1 ${data.item} on ${data.name}`); - }, - render() { - return ( - - Menu 1 Item 1 - Menu 1 Item 2 - - ); - } -}); - -export default Menu; diff --git a/examples/multiple-menus/menu2.js b/examples/multiple-menus/menu2.js deleted file mode 100644 index 8aeedba6..00000000 --- a/examples/multiple-menus/menu2.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem } from "../../src"; -import MenuTypes from "./constants"; - -const Menu2 = React.createClass({ - displayName: "Menu2", - handleClick(e, data) { - this.props.addLog(`Clicked on menu 2 ${data.item} on ${data.name}`); - }, - render() { - return ( - - Menu 2 Item 1 - Menu 2 Item 2 - - ); - } -}); - -export default Menu2; diff --git a/examples/multiple-menus/target.js b/examples/multiple-menus/target.js deleted file mode 100644 index 416138e4..00000000 --- a/examples/multiple-menus/target.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenuLayer } from "../../src"; - -const MenuTarget = React.createClass({ - displayName: "MenuTarget", - render() { - return ( -
- {this.props.name} -
- ); - } -}); - -export default ContextMenuLayer( - (props) => (props.menuType), - (props) => ({ - name: props.name - }) -)(MenuTarget); diff --git a/examples/multiple-targets/constants.js b/examples/multiple-targets/constants.js deleted file mode 100644 index 0f9d4a3f..00000000 --- a/examples/multiple-targets/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - multi: "multi" -}; diff --git a/examples/multiple-targets/container.js b/examples/multiple-targets/container.js deleted file mode 100644 index 6bca206b..00000000 --- a/examples/multiple-targets/container.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; - -import React from "react"; -import Target from "./target"; - -const targets = [{ - name: "Banana" -}, { - name: "Apple" -}, { - name: "Papaya" -}, { - name: "Mango" -}, { - name: "Orange" -}, { - name: "Pineapple" -}]; - -const Container = React.createClass({ - displayName: "Container", - render() { - return ( -
- {targets.map((item, i) => ( -
- -
- ))} -
- ); - } -}); - -export default Container; diff --git a/examples/multiple-targets/index.js b/examples/multiple-targets/index.js deleted file mode 100644 index 731e8266..00000000 --- a/examples/multiple-targets/index.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; - -import React from "react"; -import Container from "./container"; -import Menu from "./menu"; - -const MultipleTargets = React.createClass({ - displayName: "MultipleTargets", - getInitialState() { - return { logs: [] }; - }, - addLog(log) { - this.setState({ - logs: this.state.logs.concat(log) - }); - }, - render() { - return ( -
-

Multiple Targets

-

This demo shows usage of same menu on multiple targets.

- -
- {this.state.logs.map((log, i) => (

{log}

))} -
- -
- ); - } -}); - -export default MultipleTargets; diff --git a/examples/multiple-targets/menu.js b/examples/multiple-targets/menu.js deleted file mode 100644 index 585fe772..00000000 --- a/examples/multiple-targets/menu.js +++ /dev/null @@ -1,28 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem, connect } from "../../src"; -import MenuTypes from "./constants"; - -const Menu = React.createClass({ - displayName: "Menu", - handleClick(e, data) { - this.props.addLog(`Clicked on menu ${data.item} on ${data.name}`); - }, - handleEat(e, data) { - this.props.addLog(`Must Eat ${data.name}`); - }, - render() { - let { name } = this.props.item; - - return ( - - Menu Item 1 - Menu Item 2 - Eat {name} - - ); - } -}); - -export default connect(Menu); diff --git a/examples/multiple-targets/target.js b/examples/multiple-targets/target.js deleted file mode 100644 index 4ba02eeb..00000000 --- a/examples/multiple-targets/target.js +++ /dev/null @@ -1,20 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenuLayer } from "../../src"; -import MenuTypes from "./constants"; - -const MenuTarget = React.createClass({ - displayName: "MenuTarget", - render() { - return ( -
- {this.props.name} -
- ); - } -}); - -export default ContextMenuLayer(MenuTypes.multi, (props) => ({ - name: props.name -}))(MenuTarget); diff --git a/examples/react-context-menu.css b/examples/react-context-menu.css deleted file mode 100644 index f1affa29..00000000 --- a/examples/react-context-menu.css +++ /dev/null @@ -1,46 +0,0 @@ -.react-context-menu { - min-width: 160px; - padding: 5px 0; - margin: 2px 0 0; - font-size: 16px; - color: #373a3c; - text-align: left; - background-color: #fff; - -webkit-background-clip: padding-box; - background-clip: padding-box; - border: 1px solid rgba(0,0,0,.15); - border-radius: .25rem; - outline: none; -} - -.react-context-menu-link { - display: inline-block; - width: 100%; - padding: 3px 20px; - clear: both; - font-weight: 400; - line-height: 1.5; - color: #373a3c; - text-align: inherit; - white-space: nowrap; - background: 0 0; - border: 0; -} - -.react-context-menu-link.active, -.react-context-menu-link:hover { - color: #fff; - background-color: #0275d8; - border-color: #0275d8; - text-decoration: none; -} -.react-context-menu-item.submenu > a { - padding-right: 27px; -} - -.react-context-menu-item.submenu > a:after { - content: "▶"; - display: inline-block; - position: absolute; - right: 7px; -} diff --git a/examples/react-contextmenu.css b/examples/react-contextmenu.css new file mode 100644 index 00000000..5e40ace8 --- /dev/null +++ b/examples/react-contextmenu.css @@ -0,0 +1,79 @@ +.react-contextmenu { + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0,0,0,.15); + border-radius: .25rem; + color: #373a3c; + font-size: 16px; + margin: 2px 0 0; + min-width: 160px; + outline: none; + opacity: 0; + padding: 5px 0; + pointer-events: none; + text-align: left; + transition: opacity 250ms ease !important; +} + +.react-contextmenu.react-contextmenu--visible { + opacity: 1; + pointer-events: auto; + z-index: 9999; +} + +.react-contextmenu-item { + background: 0 0; + border: 0; + color: #373a3c; + cursor: pointer; + font-weight: 400; + line-height: 1.5; + padding: 3px 20px; + text-align: inherit; + white-space: nowrap; +} + +.react-contextmenu-item.react-contextmenu-item--active, +.react-contextmenu-item.react-contextmenu-item--selected { + color: #fff; + background-color: #20a0ff; + border-color: #20a0ff; + text-decoration: none; +} + +.react-contextmenu-item.react-contextmenu-item--disabled, +.react-contextmenu-item.react-contextmenu-item--disabled:hover { + background-color: transparent; + border-color: rgba(0,0,0,.15); + color: #878a8c; +} + +.react-contextmenu-item--divider { + border-bottom: 1px solid rgba(0,0,0,.15); + cursor: inherit; + margin-bottom: 3px; + padding: 2px 0; +} +.react-contextmenu-item--divider:hover { + background-color: transparent; + border-color: rgba(0,0,0,.15); +} + +.react-contextmenu-item.react-contextmenu-submenu { + padding: 0; +} + +.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item { +} + +.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after { + content: "▶"; + display: inline-block; + position: absolute; + right: 7px; +} + +.example-multiple-targets::after { + content: attr(data-count); + display: block; +} diff --git a/examples/simple-menu/constants.js b/examples/simple-menu/constants.js deleted file mode 100644 index c66815fa..00000000 --- a/examples/simple-menu/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - simple: "simple" -}; diff --git a/examples/simple-menu/container.js b/examples/simple-menu/container.js deleted file mode 100644 index 85cc04dc..00000000 --- a/examples/simple-menu/container.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenuLayer } from "../../src"; -import MenuTypes from "./constants"; - -const Container = React.createClass({ - displayName: "Container", - render() { - return ( -
right click to see the menu
- ); - } -}); - -export default ContextMenuLayer(MenuTypes.simple, (props) => props)(Container); diff --git a/examples/simple-menu/index.js b/examples/simple-menu/index.js deleted file mode 100644 index 0ecf3a9d..00000000 --- a/examples/simple-menu/index.js +++ /dev/null @@ -1,33 +0,0 @@ -"use strict"; - -import React from "react"; -import Container from "./container"; -import Menu from "./menu"; - -const SimpleMenu = React.createClass({ - displayName: "SimpleMenu", - getInitialState() { - return { logs: [] }; - }, - addLog(log) { - this.setState({ - logs: this.state.logs.concat(log) - }); - console.log(this.container.target); - }, - render() { - return ( -
-

Simple Menu

-

This demo simple usage of a context menu.

- (this.container = c)} addLog={this.addLog} holdToDisplay={1000}/> -
- {this.state.logs.map((log, i) => (

{log}

))} -
- -
- ); - } -}); - -export default SimpleMenu; diff --git a/examples/simple-menu/menu.js b/examples/simple-menu/menu.js deleted file mode 100644 index 05ae02e5..00000000 --- a/examples/simple-menu/menu.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem } from "../../src"; -import MenuTypes from "./constants"; - -const Menu = React.createClass({ - displayName: "Menu", - handleClick(e, data) { - this.props.addLog(`Clicked on menu ${data.item}`); - }, - render() { - return ( - - Menu Item 1 - Menu Item 2 - - ); - } -}); - -export default Menu; diff --git a/examples/submenus/constants.js b/examples/submenus/constants.js deleted file mode 100644 index c66815fa..00000000 --- a/examples/submenus/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - simple: "simple" -}; diff --git a/examples/submenus/container.js b/examples/submenus/container.js deleted file mode 100644 index 85cc04dc..00000000 --- a/examples/submenus/container.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenuLayer } from "../../src"; -import MenuTypes from "./constants"; - -const Container = React.createClass({ - displayName: "Container", - render() { - return ( -
right click to see the menu
- ); - } -}); - -export default ContextMenuLayer(MenuTypes.simple, (props) => props)(Container); diff --git a/examples/submenus/index.js b/examples/submenus/index.js deleted file mode 100644 index 983f4139..00000000 --- a/examples/submenus/index.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; - -import React from "react"; -import Container from "./container"; -import Menu from "./menu"; - -const SimpleMenu = React.createClass({ - displayName: "SimpleMenu", - getInitialState() { - return { logs: [] }; - }, - addLog(log) { - this.setState({ - logs: this.state.logs.concat(log) - }); - }, - render() { - return ( -
-

Sub Menus

-

This demo shows simple usage of <SubMenu/> inside context menu.

- -
- {this.state.logs.map((log, i) => (

{log}

))} -
- -
- ); - } -}); - -export default SimpleMenu; diff --git a/examples/submenus/menu.js b/examples/submenus/menu.js deleted file mode 100644 index 73147f90..00000000 --- a/examples/submenus/menu.js +++ /dev/null @@ -1,34 +0,0 @@ -"use strict"; - -import React from "react"; -import { ContextMenu, MenuItem, SubMenu } from "../../src"; -import MenuTypes from "./constants"; - -const Menu = React.createClass({ - displayName: "Menu", - handleClick(e, data) { - this.props.addLog(`Clicked on menu ${data.item}`); - }, - render() { - return ( - - Menu Item 1 - Menu Item 2 - - SubItem 1 - - SubSubItem 1 - SubSubItem 2 - - - SubSubItem 3 - SubSubItem 4 - - SubItem 2 - - - ); - } -}); - -export default Menu; diff --git a/examples/webpack.config.js b/examples/webpack.config.js new file mode 100644 index 00000000..ace41112 --- /dev/null +++ b/examples/webpack.config.js @@ -0,0 +1,96 @@ +/* eslint-disable */ + +const webpack = require('webpack'); +const path = require('path'); +const Extract = require('extract-text-webpack-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 = { + entry: ['./examples/index.js'], + output: { + filename: DEV ? 'bundle.js' : 'bundle.[hash].js', + path: path.resolve(__dirname, '../public'), + publicPath: DEV ? '/' : '/react-contextmenu/', + hashDigestLength: 6, + sourceMapFilename: 'bundle.js.map' + }, + resolve: { + modules: [ + path.resolve(__dirname, '../'), + 'node_modules' + ] + }, + module: { + rules: [ + { + test: /\.js$/, + use: [{ + loader: 'babel-loader', + options: { + presets: [ + 'react', + ['env', { + modules: false, + targets: { + browsers: 'IE >= 11, Edge >= 12, FireFox >= 38, Chrome >= 47, Opera >= 34, Safari >= 8' + } + }] + ], + plugins: [ + 'transform-class-properties' + ] + } + }], + include: [ + path.resolve(__dirname, '../src'), + path.resolve(__dirname) + ] + }, + { + test: /\.css$/, + use: Extract.extract({ + fallback: 'style-loader', + use: [{ + loader: 'css-loader' + }] + }), + } + ] + }, + plugins: [ + new Extract({ + filename: DEV ? 'styles.css' : 'styles.[contenthash:6].css', + allChunks: true + }), + new HtmlWebpackPlugin({ + template: 'examples/index.html', + inject: true, + filename: 'index.html' + }) + ] +}; + +!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 96c4e36b..5e413651 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "react-contextmenu", - "version": "1.6.3", + "version": "2.14.0", "description": "Context Menu implemented in React", "main": "modules/index.js", + "module": "es6/index.js", + "typings": "src/index.d.ts", "keywords": [ "react", "reactjs", @@ -10,18 +12,28 @@ "contextmenu", "rightclick" ], + "files": [ + "es6", + "modules", + "dist", + "src/index.d.ts" + ], "scripts": { - "coveralls": "cat ./reports/coverage/lcov.info | coveralls", - "test": "istanbul cover --dir reports/coverage node_modules/mocha/bin/_mocha test/.setup.js test/**/*-test.js -- --reporter dot", - "unit-test": "mocha test/.setup.js test/**/*-test.js", - "clean": "rimraf -rf ./dist && rimraf -rf ./modules", - "start": "node server.js", - "dist": "webpack --progress --profile --colors", - "module": "babel src --out-dir modules", - "build": "npm run test && npm run clean && npm run dist && npm run module", - "examples": "webpack --config examples.config.js --progress --profile --colors", - "watch": "./node_modules/.bin/webpack --config examples.config.js --progress --profile --colors --watch", - "prepublish": "npm run build" + "lint": "eslint ./src ./examples", + "test": "npm run lint && npm run 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: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" }, "author": "Vivek Kumar Bansal ", "license": "MIT", @@ -35,46 +47,63 @@ "homepage": "https://github.com/vkbansal/react-contextmenu", "dependencies": { "classnames": "^2.2.5", - "invariant": "^2.2.1", - "lodash.isobject": "^3.0.2", - "object-assign": "^4.1.0", - "react-overlays": "^0.6.3", - "redux": "^3.5.2" + "object-assign": "^4.1.0" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0", - "react-dom": "^0.14.0 || ^15.0.0" + "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" }, "devDependencies": { - "babel-cli": "^6.14.0", - "babel-core": "^6.14.0", - "babel-loader": "^6.2.5", - "babel-preset-es2015": "^6.14.0", - "babel-preset-react": "^6.11.1", - "babel-preset-react-hmre": "^1.1.1", - "babel-preset-stage-0": "^6.5.0", - "babel-register": "^6.14.0", - "chai": "^3.5.0", - "chai-enzyme": "^0.5.1", - "coveralls": "^2.11.13", - "enzyme": "^2.4.1", - "eslint": "^3.5.0", - "eslint-config-vkbansal": "^3.2.0", - "eslint-plugin-react": "^6.2.2", - "express": "^4.14.0", - "history": "^3.0.0", - "istanbul": "^0.4.5", - "jsdom": "^9.5.0", - "mocha": "^3.0.2", - "node-libs-browser": "^1.0.0", - "react": "^15.3.1", - "react-addons-test-utils": "^15.3.1", - "react-dom": "^15.3.1", - "react-router": "^2.8.1", - "rimraf": "^2.5.4", - "sinon": "^1.17.5", - "webpack": "^1.13.2", - "webpack-dev-middleware": "^1.7.0", - "webpack-hot-middleware": "^2.12.2" + "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", + "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" + }, + "jest": { + "setupFiles": [ + "/tests/.setup.js" + ], + "snapshotSerializers": [ + "enzyme-to-json/serializer" + ], + "roots": [ + "/tests" + ], + "collectCoverageFrom": [ + "**/src/**/*.js" + ] } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index 323fdd10..00000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# -if ([ "$TRAVIS_BRANCH" != "master" ] && [ -z "$TRAVIS_TAG" ]) || [ "$TRAVIS_PULL_REQUEST" == "true" ] -then - exit -fi - -set -o errexit - -# reset public dir -rm -rf public -mkdir public - -# copy required files -cp ./examples/index.html ./public/index.html -cp ./examples/react-context-menu.css ./public/react-context-menu.css -npm run examples && cp ./bundle.js ./public/bundle.js - - -cd public -git init - -git config --global user.name "Travis CI" -git config --global user.email "vkb0310@gmail.com" - -git add . -git commit -m "Deploy to gh-pages" - -git push --force --quiet "https://${GITHUB_TOKEN}@github.com/${GITHUB_REPO}.git" master:gh-pages > /dev/null 2>&1 diff --git a/server.js b/server.js deleted file mode 100644 index fc6b2981..00000000 --- a/server.js +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable */ -"use strict"; - -var express = require("express"), - path = require("path"), - webpack = require("webpack"), - config = require("./examples.config"); - -config.module.loaders[0].query = {presets: ["react-hmre"]}; -config.entry.unshift("webpack-hot-middleware/client"); - -config.plugins = [ - new webpack.HotModuleReplacementPlugin(), - new webpack.NoErrorsPlugin() -]; - -var app = express(); - -var compiler = webpack(config); - -app.use(require("webpack-dev-middleware")(compiler, { - noInfo: true, - publicPath: config.output.publicPath -})); - -app.use(require("webpack-hot-middleware")(compiler)); - -app.use(express.static(path.resolve(process.cwd(), "examples"))); - -app.get("*", function(req, res) { - res.sendFile(path.join(__dirname, "examples", "index.html")); -}); - -app.listen(3000, "localhost", function(err) { - if (err) { - console.log(err); - return; - } - - console.log("Listening at http://localhost:3000"); -}); diff --git a/src/AbstractMenu.js b/src/AbstractMenu.js new file mode 100644 index 00000000..ed547ce6 --- /dev/null +++ b/src/AbstractMenu.js @@ -0,0 +1,180 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import MenuItem from './MenuItem'; + +export default class AbstractMenu extends Component { + static propTypes = { + children: PropTypes.node.isRequired + }; + + constructor(props) { + super(props); + + this.seletedItemRef = null; + this.state = { + selectedItem: null, + forceSubMenuOpen: false + }; + } + + handleKeyNavigation = (e) => { + // check for isVisible strictly here as it might be undefined when this code executes in the context of SubMenu + // but we only need to check when it runs in the ContextMenu context + if (this.state.isVisible === false) { + return; + } + + switch (e.keyCode) { + case 37: // left arrow + case 27: // escape + e.preventDefault(); + this.hideMenu(e); + break; + case 38: // up arrow + e.preventDefault(); + this.selectChildren(true); + break; + case 40: // down arrow + e.preventDefault(); + this.selectChildren(false); + break; + case 39: // right arrow + this.tryToOpenSubMenu(e); + break; + case 13: // enter + e.preventDefault(); + this.tryToOpenSubMenu(e); + { + // determine the selected item is disabled or not + const disabled = this.seletedItemRef && + this.seletedItemRef.props && + this.seletedItemRef.props.disabled; + + if (this.seletedItemRef && + this.seletedItemRef.ref instanceof HTMLElement && + !disabled) { + this.seletedItemRef.ref.click(); + } else { + this.hideMenu(e); + } + } + break; + 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; + const children = []; + let disabledChildrenCount = 0; + let disabledChildIndexes = {}; + + const childCollector = (child, index) => { + // child can be empty in case you do conditional rendering of components, in which + // case it should not be accounted for as a real child + if (!child) { + return; + } + + if ([MenuItem, this.getSubMenuType()].indexOf(child.type) < 0) { + // 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) { + if (child.props.disabled) { + ++disabledChildrenCount; + disabledChildIndexes[index] = true; + } + + children.push(child); + } + }; + + React.Children.forEach(this.props.children, childCollector); + if (disabledChildrenCount === children.length) { + // All menu items are disabled, so none can be selected, don't do anything + return; + } + + function findNextEnabledChildIndex(currentIndex) { + let i = currentIndex; + let incrementCounter = () => { + if (forward) { + --i; + } else { + ++i; + } + + if (i < 0) { + i = children.length - 1; + } else if (i >= children.length) { + i = 0; + } + }; + + do { + incrementCounter(); + } while (i !== currentIndex && disabledChildIndexes[i]); + + return i === currentIndex ? null : i; + } + + const currentIndex = children.indexOf(selectedItem); + const nextEnabledChildIndex = findNextEnabledChildIndex(currentIndex); + + if (nextEnabledChildIndex !== null) { + this.setState({ + selectedItem: children[nextEnabledChildIndex], + forceSubMenuOpen: false + }); + } + } + + onChildMouseMove = (child) => { + if (this.state.selectedItem !== child) { + this.setState({ selectedItem: child, 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; }; + 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 new file mode 100644 index 00000000..ae9d2089 --- /dev/null +++ b/src/ContextMenu.js @@ -0,0 +1,249 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import assign from 'object-assign'; + +import listener from './globalEventListener'; +import AbstractMenu from './AbstractMenu'; +import SubMenu from './SubMenu'; +import { hideMenu } from './actions'; +import { cssClasses, callIfExists, store } from './helpers'; + +export default class ContextMenu extends AbstractMenu { + static propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + data: PropTypes.object, + className: PropTypes.string, + hideOnLeave: PropTypes.bool, + rtl: PropTypes.bool, + onHide: PropTypes.func, + onMouseLeave: PropTypes.func, + onShow: PropTypes.func, + preventHideOnContextMenu: PropTypes.bool, + preventHideOnResize: PropTypes.bool, + preventHideOnScroll: PropTypes.bool, + style: PropTypes.object + }; + + static defaultProps = { + className: '', + data: {}, + hideOnLeave: false, + rtl: false, + onHide() { return null; }, + onMouseLeave() { return null; }, + onShow() { return null; }, + preventHideOnContextMenu: false, + preventHideOnResize: false, + preventHideOnScroll: false, + style: {} + }; + + constructor(props) { + super(props); + + this.state = assign({}, this.state, { + x: 0, + y: 0, + isVisible: false + }); + } + + getSubMenuType() { // eslint-disable-line class-methods-use-this + return SubMenu; + } + + componentDidMount() { + this.listenId = listener.register(this.handleShow, this.handleHide); + } + + componentDidUpdate() { + const wrapper = window.requestAnimationFrame || setTimeout; + if (this.state.isVisible) { + wrapper(() => { + const { x, y } = this.state; + + const { top, left } = this.props.rtl + ? this.getRTLMenuPosition(x, y) + : this.getMenuPosition(x, y); + + wrapper(() => { + if (!this.menu) return; + this.menu.style.top = `${top}px`; + this.menu.style.left = `${left}px`; + this.menu.style.opacity = 1; + this.menu.style.pointerEvents = 'auto'; + }); + }); + } else { + wrapper(() => { + if (!this.menu) return; + this.menu.style.opacity = 0; + this.menu.style.pointerEvents = 'none'; + }); + } + } + + componentWillUnmount() { + if (this.listenId) { + listener.unregister(this.listenId); + } + + this.unregisterHandlers(); + } + + registerHandlers = () => { + document.addEventListener('mousedown', this.handleOutsideClick); + document.addEventListener('touchstart', this.handleOutsideClick); + if (!this.props.preventHideOnScroll) document.addEventListener('scroll', this.handleHide); + 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); + document.removeEventListener('touchstart', this.handleOutsideClick); + document.removeEventListener('scroll', this.handleHide); + 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; + + const { x, y } = e.detail.position; + + 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 }); + callIfExists(this.props.onHide, e); + } + } + + handleOutsideClick = (e) => { + if (!this.menu.contains(e.target)) hideMenu(); + } + + handleMouseLeave = (event) => { + event.preventDefault(); + + callIfExists( + this.props.onMouseLeave, + event, + assign({}, this.props.data, store.data), + store.target + ); + + if (this.props.hideOnLeave) hideMenu(); + } + + handleContextMenu = (e) => { + if (process.env.NODE_ENV === 'production') { + e.preventDefault(); + } + this.handleHide(e); + } + + hideMenu = (e) => { + if (e.keyCode === 27 || e.keyCode === 13) { // ECS or enter + hideMenu(); + } + } + + getMenuPosition = (x = 0, y = 0) => { + let menuStyles = { + top: y, + left: x + }; + + if (!this.menu) return menuStyles; + + const { innerWidth, innerHeight } = window; + const rect = this.menu.getBoundingClientRect(); + + if (y + rect.height > innerHeight) { + menuStyles.top -= rect.height; + } + + if (x + rect.width > innerWidth) { + menuStyles.left -= rect.width; + } + + if (menuStyles.top < 0) { + menuStyles.top = rect.height < innerHeight ? (innerHeight - rect.height) / 2 : 0; + } + + if (menuStyles.left < 0) { + menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0; + } + + return menuStyles; + } + + getRTLMenuPosition = (x = 0, y = 0) => { + let menuStyles = { + top: y, + left: x + }; + + if (!this.menu) return menuStyles; + + const { innerWidth, innerHeight } = window; + const rect = this.menu.getBoundingClientRect(); + + // Try to position the menu on the left side of the cursor + menuStyles.left = x - rect.width; + + if (y + rect.height > innerHeight) { + menuStyles.top -= rect.height; + } + + if (menuStyles.left < 0) { + menuStyles.left += rect.width; + } + + if (menuStyles.top < 0) { + menuStyles.top = rect.height < innerHeight ? (innerHeight - rect.height) / 2 : 0; + } + + if (menuStyles.left + rect.width > innerWidth) { + menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0; + } + + return menuStyles; + } + + menuRef = (c) => { + this.menu = c; + } + + render() { + const { children, className, style } = this.props; + const { isVisible } = this.state; + const inlineStyle = assign( + {}, + style, + { position: 'fixed', opacity: 0, pointerEvents: 'none' } + ); + const menuClassnames = cx(cssClasses.menu, className, { + [cssClasses.menuVisible]: isVisible + }); + + return ( + + ); + } +} diff --git a/src/ContextMenuTrigger.js b/src/ContextMenuTrigger.js new file mode 100644 index 00000000..c3cf8e15 --- /dev/null +++ b/src/ContextMenuTrigger.js @@ -0,0 +1,166 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import assign from 'object-assign'; + +import { showMenu, hideMenu } from './actions'; +import { callIfExists, cssClasses } from './helpers'; + +export default class ContextMenuTrigger extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + attributes: PropTypes.object, + collect: PropTypes.func, + disable: PropTypes.bool, + holdToDisplay: PropTypes.number, + posX: PropTypes.number, + posY: PropTypes.number, + renderTag: PropTypes.elementType, + mouseButton: PropTypes.number, + disableIfShiftIsPressed: PropTypes.bool + }; + + static defaultProps = { + attributes: {}, + collect() { return null; }, + disable: false, + holdToDisplay: 1000, + renderTag: 'div', + posX: 0, + posY: 0, + mouseButton: 2, // 0 is left click, 2 is right click + disableIfShiftIsPressed: false + }; + + touchHandled = false; + + handleMouseDown = (event) => { + if (this.props.holdToDisplay >= 0 && event.button === 0) { + event.persist(); + event.stopPropagation(); + + this.mouseDownTimeoutId = setTimeout( + () => this.handleContextClick(event), + this.props.holdToDisplay + ); + } + 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; + + if (this.props.holdToDisplay >= 0) { + event.persist(); + event.stopPropagation(); + + this.touchstartTimeoutId = setTimeout( + () => { + this.handleContextClick(event); + this.touchHandled = true; + }, + this.props.holdToDisplay + ); + } + callIfExists(this.props.attributes.onTouchStart, event); + } + + handleTouchEnd = (event) => { + if (this.touchHandled) { + event.preventDefault(); + } + clearTimeout(this.touchstartTimeoutId); + callIfExists(this.props.attributes.onTouchEnd, event); + } + + handleContextMenu = (event) => { + if (event.button === this.props.mouseButton) { + this.handleContextClick(event); + } + callIfExists(this.props.attributes.onContextMenu, event); + } + + handleMouseClick = (event) => { + if (event.button === this.props.mouseButton) { + this.handleContextClick(event); + } + callIfExists(this.props.attributes.onClick, event); + } + + handleContextClick = (event) => { + if (this.props.disable) return; + if (this.props.disableIfShiftIsPressed && event.shiftKey) return; + + event.preventDefault(); + event.stopPropagation(); + + let x = event.clientX || (event.touches && event.touches[0].pageX); + let y = event.clientY || (event.touches && event.touches[0].pageY); + + if (this.props.posX) { + x -= this.props.posX; + } + if (this.props.posY) { + y -= this.props.posY; + } + + hideMenu(); + + let data = callIfExists(this.props.collect, this.props); + let showMenuConfig = { + position: { x, y }, + target: this.elem, + id: this.props.id + }; + if (data && (typeof data.then === 'function')) { + // it's promise + data.then((resp) => { + showMenuConfig.data = assign({}, resp, { + target: event.target + }); + showMenu(showMenuConfig); + }); + } else { + showMenuConfig.data = assign({}, data, { + target: event.target + }); + showMenu(showMenuConfig); + } + } + + elemRef = (c) => { + this.elem = c; + } + + render() { + const { renderTag, attributes, children } = this.props; + const newAttrs = assign({}, attributes, { + className: cx(cssClasses.menuWrapper, attributes.className), + onContextMenu: this.handleContextMenu, + onClick: this.handleMouseClick, + 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 new file mode 100644 index 00000000..51035168 --- /dev/null +++ b/src/MenuItem.js @@ -0,0 +1,90 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import assign from 'object-assign'; + +import { hideMenu } from './actions'; +import { callIfExists, cssClasses, store } from './helpers'; + +export default class MenuItem extends Component { + static propTypes = { + attributes: PropTypes.object, + children: PropTypes.node, + className: PropTypes.string, + data: PropTypes.object, + disabled: PropTypes.bool, + divider: PropTypes.bool, + onClick: PropTypes.func, + onMouseLeave: PropTypes.func, + onMouseMove: PropTypes.func, + preventClose: PropTypes.bool, + selected: PropTypes.bool + }; + + static defaultProps = { + attributes: {}, + children: null, + className: '', + data: {}, + disabled: false, + divider: false, + onClick() { return null; }, + onMouseMove: () => null, + onMouseLeave: () => null, + preventClose: false, + selected: false + }; + + handleClick = (event) => { + if (event.button !== 0 && event.button !== 1) { + event.preventDefault(); + } + + if (this.props.disabled || this.props.divider) return; + + callIfExists( + this.props.onClick, + event, + assign({}, this.props.data, store.data), + store.target + ); + + if (this.props.preventClose) return; + + hideMenu(); + } + + render() { + const { + attributes, + children, + className, + disabled, + divider, + selected + } = this.props; + + const menuItemClassNames = cx( + className, + cssClasses.menuItem, + attributes.className, + { + [cx(cssClasses.menuItemDisabled, attributes.disabledClassName)]: disabled, + [cx(cssClasses.menuItemDivider, attributes.dividerClassName)]: divider, + [cx(cssClasses.menuItemSelected, attributes.selectedClassName)]: selected + } + ); + + return ( +
{ this.ref = ref; }} + onMouseMove={this.props.onMouseMove} onMouseLeave={this.props.onMouseLeave} + onTouchEnd={this.handleClick} onClick={this.handleClick}> + {divider ? null : children} +
+ ); + } +} diff --git a/src/SubMenu.js b/src/SubMenu.js new file mode 100644 index 00000000..450bd23e --- /dev/null +++ b/src/SubMenu.js @@ -0,0 +1,276 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import assign from 'object-assign'; + +import { hideMenu } from './actions'; +import AbstractMenu from './AbstractMenu'; +import { callIfExists, cssClasses, hasOwnProp, store } from './helpers'; +import listener from './globalEventListener'; + +export default class SubMenu extends AbstractMenu { + static propTypes = { + children: PropTypes.node.isRequired, + attributes: PropTypes.object, + title: PropTypes.node.isRequired, + className: PropTypes.string, + disabled: PropTypes.bool, + hoverDelay: PropTypes.number, + rtl: PropTypes.bool, + selected: PropTypes.bool, + onMouseMove: PropTypes.func, + onMouseOut: PropTypes.func, + forceOpen: PropTypes.bool, + forceClose: PropTypes.func, + parentKeyNavigationHandler: PropTypes.func + }; + + static defaultProps = { + disabled: false, + hoverDelay: 500, + attributes: {}, + className: '', + rtl: false, + selected: false, + onMouseMove: () => null, + onMouseOut: () => null, + forceOpen: false, + forceClose: () => null, + parentKeyNavigationHandler: () => null + }; + + constructor(props) { + super(props); + + this.state = assign({}, this.state, { + visible: false + }); + } + + componentDidMount() { + this.listenId = listener.register(() => {}, this.hideSubMenu); + } + + getSubMenuType() { // eslint-disable-line class-methods-use-this + return SubMenu; + } + + shouldComponentUpdate(nextProps, nextState) { + this.isVisibilityChange = (this.state.visible !== nextState.visible || + this.props.forceOpen !== nextProps.forceOpen) && + !(this.state.visible && nextProps.forceOpen) && + !(this.props.forceOpen && nextState.visible); + return true; + } + + componentDidUpdate() { + if (!this.isVisibilityChange) return; + if (this.props.forceOpen || this.state.visible) { + const wrapper = window.requestAnimationFrame || setTimeout; + wrapper(() => { + const styles = this.props.rtl + ? this.getRTLMenuPosition() + : this.getMenuPosition(); + + this.subMenu.style.removeProperty('top'); + this.subMenu.style.removeProperty('bottom'); + this.subMenu.style.removeProperty('left'); + this.subMenu.style.removeProperty('right'); + + if (hasOwnProp(styles, 'top')) this.subMenu.style.top = styles.top; + if (hasOwnProp(styles, 'left')) this.subMenu.style.left = styles.left; + if (hasOwnProp(styles, 'bottom')) this.subMenu.style.bottom = styles.bottom; + if (hasOwnProp(styles, 'right')) this.subMenu.style.right = styles.right; + this.subMenu.classList.add(cssClasses.menuVisible); + + this.registerHandlers(); + this.setState({ selectedItem: null }); + }); + } else { + const cleanup = () => { + this.subMenu.removeEventListener('transitionend', cleanup); + this.subMenu.style.removeProperty('bottom'); + this.subMenu.style.removeProperty('right'); + this.subMenu.style.top = 0; + this.subMenu.style.left = '100%'; + this.unregisterHandlers(); + }; + this.subMenu.addEventListener('transitionend', cleanup); + this.subMenu.classList.remove(cssClasses.menuVisible); + } + } + + componentWillUnmount() { + if (this.listenId) { + listener.unregister(this.listenId); + } + + if (this.opentimer) clearTimeout(this.opentimer); + + if (this.closetimer) clearTimeout(this.closetimer); + + this.unregisterHandlers(true); + } + + getMenuPosition = () => { + const { innerWidth, innerHeight } = window; + const rect = this.subMenu.getBoundingClientRect(); + const position = {}; + + if (rect.bottom > innerHeight) { + position.bottom = 0; + } else { + position.top = 0; + } + + if (rect.right < innerWidth) { + position.left = '100%'; + } else { + position.right = '100%'; + } + + return position; + } + + getRTLMenuPosition = () => { + const { innerHeight } = window; + const rect = this.subMenu.getBoundingClientRect(); + const position = {}; + + if (rect.bottom > innerHeight) { + position.bottom = 0; + } else { + position.top = 0; + } + + if (rect.left < 0) { + position.left = '100%'; + } else { + position.right = '100%'; + } + + 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) { + return; + } + + if (this.props.forceOpen) { + this.props.forceClose(); + } + this.setState({ visible: false, selectedItem: null }); + this.unregisterHandlers(); + }; + + handleClick = (event) => { + event.preventDefault(); + + if (this.props.disabled) return; + + callIfExists( + this.props.onClick, + event, + assign({}, this.props.data, store.data), + store.target + ); + + if (!this.props.onClick || this.props.preventCloseOnClick) return; + + hideMenu(); + } + + handleMouseEnter = () => { + if (this.closetimer) clearTimeout(this.closetimer); + + if (this.props.disabled || this.state.visible) return; + + this.opentimer = setTimeout(() => this.setState({ + visible: true, + selectedItem: null + }), this.props.hoverDelay); + } + + handleMouseLeave = () => { + if (this.opentimer) clearTimeout(this.opentimer); + + if (!this.state.visible) return; + + this.closetimer = setTimeout(() => this.setState({ + 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; + const { visible } = this.state; + const menuProps = { + ref: this.menuRef, + onMouseEnter: this.handleMouseEnter, + onMouseLeave: this.handleMouseLeave, + className: cx(cssClasses.menuItem, cssClasses.subMenu, attributes.listClassName), + style: { + position: 'relative' + } + }; + const menuItemProps = { + className: cx(cssClasses.menuItem, attributes.className, { + [cx(cssClasses.menuItemDisabled, attributes.disabledClassName)]: disabled, + [cx(cssClasses.menuItemActive, attributes.visibleClassName)]: visible, + [cx(cssClasses.menuItemSelected, attributes.selectedClassName)]: selected + }), + onMouseMove: this.props.onMouseMove, + onMouseOut: this.props.onMouseOut, + onClick: this.handleClick + }; + const subMenuProps = { + ref: this.subMenuRef, + style: { + position: 'absolute', + transition: 'opacity 1ms', // trigger transitionend event + top: 0, + left: '100%' + }, + className: cx(cssClasses.menu, this.props.className) + }; + + return ( + + ); + } +} diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 00000000..82f58be2 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,33 @@ +import assign from 'object-assign'; + +import { store } from './helpers'; + +export const MENU_SHOW = 'REACT_CONTEXTMENU_SHOW'; +export const MENU_HIDE = 'REACT_CONTEXTMENU_HIDE'; + + +export function dispatchGlobalEvent(eventName, opts, target = window) { + // Compatibale with IE + // @see http://stackoverflow.com/questions/26596123/internet-explorer-9-10-11-event-constructor-doesnt-work + let event; + + if (typeof window.CustomEvent === 'function') { + event = new window.CustomEvent(eventName, { detail: opts }); + } else { + event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, false, true, opts); + } + + if (target) { + target.dispatchEvent(event); + assign(store, opts); + } +} + +export function showMenu(opts = {}, target) { + dispatchGlobalEvent(MENU_SHOW, assign({}, opts, { type: MENU_SHOW }), target); +} + +export function hideMenu(opts = {}, target) { + dispatchGlobalEvent(MENU_HIDE, assign({}, opts, { type: MENU_HIDE }), target); +} diff --git a/src/connect.js b/src/connect.js deleted file mode 100644 index 72441e1e..00000000 --- a/src/connect.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import store from "./redux/store"; - -export default function(Component) { - const displayName = Component.displayName - || Component.name - || "Component"; - - return React.createClass({ - displayName: `ContextMenuConnector(${displayName})`, - getInitialState() { - return { - item: store.getState().currentItem - }; - }, - componentDidMount() { - this.unsubscribe = store.subscribe(this.handleUpdate); - }, - componentWillUnmount() { - this.unsubscribe(); - }, - handleUpdate() { - this.setState(this.getInitialState()); - }, - render() { - return ; - } - }); -} diff --git a/src/connectMenu.js b/src/connectMenu.js new file mode 100644 index 00000000..537a34ca --- /dev/null +++ b/src/connectMenu.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; + +import ContextMenuTrigger from './ContextMenuTrigger'; +import listener from './globalEventListener'; + +// collect ContextMenuTrigger's expected props to NOT pass them on as part of the context +const ignoredTriggerProps = [...Object.keys(ContextMenuTrigger.propTypes), 'children']; + +// expect the id of the menu to be responsible for as outer parameter +export default function (menuId) { + // expect menu component to connect as inner parameter + // is presumably a wrapper of + return function connect(Child) { + // return wrapper for that forwards the ContextMenuTrigger's additional props + return class ConnectMenu extends Component { + constructor(props) { + super(props); + this.state = { trigger: null }; + } + + componentDidMount() { + this.listenId = listener.register(this.handleShow, this.handleHide); + } + + componentWillUnmount() { + if (this.listenId) { + listener.unregister(this.listenId); + } + } + + handleShow = (e) => { + if (e.detail.id !== menuId) return; + + // the onShow event's detail.data object holds all ContextMenuTrigger props + const { data } = e.detail; + const filteredData = {}; + + for (const key in data) { + // exclude props the ContextMenuTrigger is expecting itself + if (!ignoredTriggerProps.includes(key)) { + filteredData[key] = data[key]; + } + } + this.setState({ trigger: filteredData }); + } + + handleHide = () => { + this.setState({ trigger: null }); + } + + render() { + return ; + } + }; + }; +} diff --git a/src/context-menu/index.js b/src/context-menu/index.js deleted file mode 100644 index 09a852b7..00000000 --- a/src/context-menu/index.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import store from "../redux/store"; -import ContextWrapper from "./wrapper"; - -let { PropTypes } = React; - -const ContextMenu = React.createClass({ - displayName: "ContextMenu", - propTypes: { - identifier: PropTypes.string.isRequired - }, - getInitialState() { - return store.getState(); - }, - componentDidMount() { - this.unsubscribe = store.subscribe(this.handleUpdate); - }, - componentWillUnmount() { - if (this.unsubscribe) this.unsubscribe(); - }, - handleUpdate() { - this.setState(this.getInitialState()); - }, - render() { - return ( - - ); - } -}); - -export default ContextMenu; diff --git a/src/context-menu/wrapper.js b/src/context-menu/wrapper.js deleted file mode 100644 index dd1ac653..00000000 --- a/src/context-menu/wrapper.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from "react"; -import monitor from "../monitor"; - -import Modal from "react-overlays/lib/Modal"; - -const modalStyle = { - position: "fixed", - zIndex: 1040, - top: 0, - bottom: 0, - left: 0, - right: 0 - }, - backdropStyle = { - ...modalStyle, - zIndex: "auto", - backgroundColor: "transparent" - }, - menuStyles = { - position: "fixed", - zIndex: "auto" - }; - -let ContextMenuWrapper = React.createClass({ - displayName: "ContextMenuWrapper", - getInitialState() { - return { - left: 0, - top: 0 - }; - }, - componentWillReceiveProps(nextProps) { - if (nextProps.isVisible === nextProps.identifier) { - const wrapper = window.requestAnimationFrame || setTimeout; - - wrapper(() => { - this.setState(this.getMenuPosition(nextProps.x, nextProps.y)); - this.menu.parentNode.addEventListener("contextmenu", this.hideMenu); - }); - } - }, - shouldComponentUpdate(nextProps) { - return this.props.isVisible !== nextProps.visible; - }, - hideMenu(e) { - e.preventDefault(); - this.menu.parentNode.removeEventListener("contextmenu", this.hideMenu); - monitor.hideMenu(); - }, - getMenuPosition(x, y) { - let scrollX = document.documentElement.scrollTop, - scrollY = document.documentElement.scrollLeft, - { innerWidth, innerHeight } = window, - rect = this.menu.getBoundingClientRect(), - menuStyles = { - top: y + scrollY, - left: x + scrollX - }; - - if (y + rect.height > innerHeight) { - menuStyles.top -= rect.height; - } - - if (x + rect.width > innerWidth) { - menuStyles.left -= rect.width; - } - - if (menuStyles.top < 0) { - menuStyles.top = (rect.height < innerHeight) ? (innerHeight - rect.height) / 2 : 0; - } - - if (menuStyles.left < 0) { - menuStyles.left = (rect.width < innerWidth) ? (innerWidth - rect.width) / 2 : 0; - } - - return menuStyles; - }, - render() { - let { isVisible, identifier, children } = this.props; - - const style = { - ...menuStyles, - ...this.state - }; - - return ( - monitor.hideMenu()}> - - - ); - } -}); - -export default ContextMenuWrapper; diff --git a/src/contextmenu-layer.js b/src/contextmenu-layer.js deleted file mode 100644 index b708361d..00000000 --- a/src/contextmenu-layer.js +++ /dev/null @@ -1,118 +0,0 @@ -import React from "react"; -import invariant from "invariant"; -import _isObject from "lodash.isobject"; - -import store from "./redux/store"; - -export default function(identifier, configure) { - return function(Component) { - const displayName = Component.displayName - || Component.name - || "Component"; - - invariant( - identifier && (typeof identifier === "string" - || typeof identifier === "symbol" - || typeof identifier === "function"), - "Expected identifier to be string, symbol or function. See %s", - displayName - ); - - if (configure) { - invariant( - typeof configure === "function", - "Expected configure to be a function. See %s", - displayName - ); - } - - - return React.createClass({ - displayName: `${displayName}ContextMenuLayer`, - getDefaultProps() { - return { - renderTag: "div", - attributes: {} - }; - }, - mouseDown: false, - handleMouseDown(event) { - if (this.props.holdToDisplay >= 0 && event.button === 0) { - event.persist(); - - this.mouseDown = true; - setTimeout(() => { - if (this.mouseDown) this.handleContextClick(event); - }, this.props.holdToDisplay); - } - }, - handleTouchstart(event) { - if (this.props.holdToDisplay >= 0) { - event.persist(); - - this.mouseDown = true; - setTimeout(() => { - if (this.mouseDown) this.handleContextClick(event); - }, this.props.holdToDisplay); - } - }, - handleTouchEnd(event) { - event.preventDefault(); - this.mouseDown = false; - }, - handleMouseUp(event) { - if (event.button === 0) { - this.mouseDown = false; - } - }, - handleContextClick(event) { - let currentItem = typeof configure === "function" - ? configure(this.props) - : {}; - - invariant( - _isObject(currentItem), - "Expected configure to return an object. See %s", - displayName - ); - - event.preventDefault(); - - if (this.props.stopPropagation) { - event.stopPropagation(); - } - - const xPos = event.clientX || (event.touches && event.touches[0].pageX), - yPos = event.clientY || (event.touches && event.touches[0].pageY); - - store.dispatch({ - type: "SET_PARAMS", - data: { - x: xPos, - y: yPos, - currentItem, - isVisible: typeof identifier === "function" ? identifier(this.props) : identifier - } - }); - }, - render() { - let { attributes: {className = "", ...attributes}, renderTag, ...props } = this.props; - - attributes.className = `react-context-menu-wrapper ${className}`; - attributes.onContextMenu = this.handleContextClick; - attributes.onMouseDown = this.handleMouseDown; - attributes.onMouseUp = this.handleMouseUp; - attributes.onTouchStart = this.handleTouchstart; - attributes.onTouchEnd = this.handleTouchEnd; - attributes.onMouseOut = this.handleMouseUp; - attributes.ref = (c) => (this.target = c); - - return React.createElement( - renderTag, - attributes, - React.createElement(Component, props) - ); - } - }); - }; -} diff --git a/src/globalEventListener.js b/src/globalEventListener.js new file mode 100644 index 00000000..6927681f --- /dev/null +++ b/src/globalEventListener.js @@ -0,0 +1,44 @@ +import { MENU_SHOW, MENU_HIDE } from './actions'; +import { uniqueId, hasOwnProp, canUseDOM } from './helpers'; + +class GlobalEventListener { + constructor() { + this.callbacks = {}; + + if (canUseDOM) { + window.addEventListener(MENU_SHOW, this.handleShowEvent); + window.addEventListener(MENU_HIDE, this.handleHideEvent); + } + } + + handleShowEvent = (event) => { + 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(); + + this.callbacks[id] = { + show: showCallback, + hide: hideCallback + }; + + return id; + } + + unregister = (id) => { + if (id && this.callbacks[id]) { + delete this.callbacks[id]; + } + } +} + +export default new GlobalEventListener(); diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 00000000..831f3135 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,29 @@ +export function callIfExists(func, ...args) { + return (typeof func === 'function') && func(...args); +} + +export function hasOwnProp(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); +} + +export function uniqueId() { + return Math.random().toString(36).substring(7); +} + +export const cssClasses = { + menu: 'react-contextmenu', + menuVisible: 'react-contextmenu--visible', + menuWrapper: 'react-contextmenu-wrapper', + menuItem: 'react-contextmenu-item', + menuItemActive: 'react-contextmenu-item--active', + menuItemDisabled: 'react-contextmenu-item--disabled', + menuItemDivider: 'react-contextmenu-item--divider', + menuItemSelected: 'react-contextmenu-item--selected', + subMenu: 'react-contextmenu-submenu' +}; + +export const store = {}; + +export const canUseDOM = Boolean( + typeof window !== 'undefined' && window.document && window.document.createElement +); diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 00000000..8cc47be0 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,72 @@ +declare module "react-contextmenu" { + import * as React from "react"; + + export interface ContextMenuProps { + id: string, + data?: any, + className?: string, + hideOnLeave?: boolean, + rtl?: boolean, + onHide?: {(event: any): void}, + onMouseLeave?: {(event: React.MouseEvent, data: Object, target: HTMLElement): void} | Function, + onShow?: {(event: any): void}, + preventHideOnContextMenu?: boolean, + preventHideOnResize?: boolean, + preventHideOnScroll?: boolean, + style?: React.CSSProperties, + } + + export interface ContextMenuTriggerProps { + id: string, + attributes?: React.HTMLAttributes, + collect?: {(data: any): any}, + disable?: boolean, + holdToDisplay?: number, + renderTag?: React.ElementType, + mouseButton?: number, + disableIfShiftIsPressed?: boolean, + [key: string]: any + } + + export interface MenuItemProps { + attributes?: React.HTMLAttributes, + className?: string; + data?: Object, + disabled?: boolean, + divider?: boolean, + preventClose?: boolean, + onClick?: {(event: React.TouchEvent | React.MouseEvent, data: Object, target: HTMLElement): void} | Function, + } + + export interface SubMenuProps { + title: React.ReactElement | React.ReactText, + className?: string, + disabled?: boolean, + hoverDelay?: number, + rtl?: boolean, + preventCloseOnClick?: boolean, + onClick?: {(event: React.TouchEvent | React.MouseEvent, data: Object, target: HTMLElement): void} | Function, + } + + export interface ConnectMenuProps { + id: string; + trigger: any; + } + + export const ContextMenu: React.ComponentClass; + export const ContextMenuTrigger: React.ComponentClass; + export const MenuItem: React.ComponentClass; + export const SubMenu: React.ComponentClass; + export function connectMenu

( + menuId: string + ): ( + Child: React.ComponentType

+ ) => React.ComponentType

; + export function showMenu(opts?: any, target?: HTMLElement): void; + export function hideMenu(opts?: any, target?: HTMLElement): void; +} + +declare module "react-contextmenu/modules/actions" { + export function showMenu(opts?: any, target?: HTMLElement): void; + export function hideMenu(opts?: any, target?: HTMLElement): void; +} diff --git a/src/index.js b/src/index.js index 0231371c..c67bec94 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ -export { default as ContextMenu } from "./context-menu"; -export { default as ContextMenuLayer } from "./contextmenu-layer"; -export { default as MenuItem } from "./menu-item"; -export { default as monitor } from "./monitor"; -export { default as SubMenu } from "./submenu"; -export { default as connect } from "./connect"; +export { default as ContextMenu } from './ContextMenu'; +export { default as ContextMenuTrigger } from './ContextMenuTrigger'; +export { default as MenuItem } from './MenuItem'; +export { default as SubMenu } from './SubMenu'; +export { default as connectMenu } from './connectMenu'; +export { hideMenu, showMenu } from './actions'; diff --git a/src/menu-item.js b/src/menu-item.js deleted file mode 100644 index 7ca6dd08..00000000 --- a/src/menu-item.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import assign from "object-assign"; -import monitor from "./monitor"; - -let { PropTypes } = React; - -const MenuItem = React.createClass({ - displayName: "MenuItem", - propTypes: { - onClick: PropTypes.func.isRequired, - data: PropTypes.object, - disabled: PropTypes.bool, - preventClose: PropTypes.bool - }, - getDefaultProps() { - return { - disabled: false, - data: {}, - attributes: {} - }; - }, - handleClick(event) { - let { disabled, onClick, data, preventClose } = this.props; - - event.preventDefault(); - - if (disabled) return; - - - var newData = {} - assign(newData, data, monitor.getItem()); - - if (typeof onClick === "function") { - onClick(event, newData); - } - - if (preventClose) return; - - monitor.hideMenu(); - }, - render() { - let { disabled, children, attributes: { className = "", ...props } } = this.props, - menuItemClassNames = `react-context-menu-item ${className}`; - - const classes = classnames({ - "react-context-menu-link": true, - disabled - }); - - - return ( -

- ); - } -}); - -export default MenuItem; diff --git a/src/monitor.js b/src/monitor.js deleted file mode 100644 index 9f67a68d..00000000 --- a/src/monitor.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable object-property-newline */ -import store from "./redux/store"; - -export default { - getItem() { - return store.getState().currentItem; - }, - getPosition() { - let { x, y } = store.getState(); - - return {x, y}; - }, - hideMenu() { - store.dispatch({ - type: "SET_PARAMS", - data: { - isVisible: false, - currentItem: {} - } - }); - } -}; diff --git a/src/redux/reducers.js b/src/redux/reducers.js deleted file mode 100644 index 332e62c0..00000000 --- a/src/redux/reducers.js +++ /dev/null @@ -1,14 +0,0 @@ -import assign from "object-assign"; - -const defaultState = { - x: 0, - y: 0, - isVisible: false, - currentItem: {} -}; - -export default function(state = defaultState, action) { - return action.type === "SET_PARAMS" - ? assign({}, state, action.data) - : state; -} diff --git a/src/redux/store.js b/src/redux/store.js deleted file mode 100644 index f02d47ab..00000000 --- a/src/redux/store.js +++ /dev/null @@ -1,4 +0,0 @@ -import { createStore } from "redux"; -import rootReducer from "./reducers"; - -export default createStore(rootReducer); diff --git a/src/submenu/index.js b/src/submenu/index.js deleted file mode 100644 index 6668af97..00000000 --- a/src/submenu/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -import MenuWrapper from "./wrapper"; - -const menuStyles = { - position: "relative", - zIndex: "auto" -}; - -let SubMenu = React.createClass({ - displayName: "SubMenu", - propTypes: { - title: React.PropTypes.string.isRequired, - disabled: React.PropTypes.bool, - hoverDelay: React.PropTypes.number - }, - getDefaultProps() { - return { - hoverDelay: 500 - }; - }, - getInitialState() { - return { - visible: false - }; - }, - shouldComponentUpdate(nextProps, nextState) { - return this.state.isVisible !== nextState.visible; - }, - componentWillUnmount() { - if (this.opentimer) clearTimeout(this.opentimer); - - if (this.closetimer) clearTimeout(this.closetimer); - }, - handleClick(e) { - e.preventDefault(); - }, - handleMouseEnter() { - if (this.closetimer) clearTimeout(this.closetimer); - - if (this.props.disabled || this.state.visible) return; - - this.opentimer = setTimeout(() => this.setState({visible: true}), this.props.hoverDelay); - }, - handleMouseLeave() { - if (this.opentimer) clearTimeout(this.opentimer); - - if (!this.state.visible) return; - - this.closetimer = setTimeout(() => this.setState({visible: false}), this.props.hoverDelay); - }, - render() { - let { disabled, children, title } = this.props, - { visible } = this.state; - - const classes = classnames({ - "react-context-menu-link": true, - disabled, - active: visible - }), - menuClasses = "react-context-menu-item submenu"; - - return ( -
(this.item = c)} className={menuClasses} style={menuStyles} - onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> - - {title} - - - {children} - -
- ); - } -}); - -export default SubMenu; diff --git a/src/submenu/wrapper.js b/src/submenu/wrapper.js deleted file mode 100644 index 187ef7b6..00000000 --- a/src/submenu/wrapper.js +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; - -const SubMenuWrapper = React.createClass({ - displayName: "SubMenuWrapper", - propTypes: { - visible: React.PropTypes.bool - }, - getInitialState() { - return { - position: { - top: true, - right: true - } - }; - }, - componentWillReceiveProps(nextProps) { - if (nextProps.visible) { - const wrapper = window.requestAnimationFrame || setTimeout; - - wrapper(() => { - this.setState(this.getMenuPosition()); - this.forceUpdate(); - }); - } else { - this.setState(this.getInitialState()); - } - }, - shouldComponentUpdate(nextProps) { - return this.props.visible !== nextProps.visible; - }, - getMenuPosition() { - let { innerWidth, innerHeight } = window, - rect = this.menu.getBoundingClientRect(), - position = {}; - - - if (rect.bottom > innerHeight) { - position.bottom = true; - } else { - position.top = true; - } - - if (rect.right > innerWidth) { - position.left = true; - } else { - position.right = true; - } - - return { position }; - }, - getPositionStyles() { - let style = {}, - { position } = this.state; - - if (position.top) style.top = 0; - if (position.bottom) style.bottom = 0; - if (position.right) style.left = "100%"; - if (position.left) style.right = "100%"; - - return style; - }, - render() { - let { children, visible } = this.props; - - const style = { - display: visible ? "block" : "none", - position: "absolute", - ...this.getPositionStyles() - }; - - return ( - - ); - } -}); - -export default SubMenuWrapper; diff --git a/test/.setup.js b/test/.setup.js deleted file mode 100644 index 96a08a5c..00000000 --- a/test/.setup.js +++ /dev/null @@ -1,25 +0,0 @@ -"use strict"; - -require("babel-register")(); - - -let jsdom = require("jsdom").jsdom, - chai = require("chai"), - chaiEnzyme = require("chai-enzyme"); - -chai.use(chaiEnzyme()); - -let exposedProperties = ["window", "navigator", "document"]; - -global.document = jsdom(""); -global.window = document.defaultView; -Object.keys(document.defaultView).forEach((property) => { - if (typeof global[property] === "undefined") { - exposedProperties.push(property); - global[property] = document.defaultView[property]; - } -}); - -global.navigator = { userAgent: "node.js" }; - -global.documentRef = document; diff --git a/test/menu-item-test.js b/test/menu-item-test.js deleted file mode 100644 index 08e155b0..00000000 --- a/test/menu-item-test.js +++ /dev/null @@ -1,71 +0,0 @@ -"use strict"; - -import React from "react"; -import { shallow, mount } from "enzyme"; -import sinon from "sinon"; -import { expect } from "chai"; - -import MenuItem from "../src/menu-item"; -import monitor from "../src/monitor"; - -describe("", () => { - it("should have `react-context-menu-item` class", () => { - const wrapper = shallow( ({})}/>); - - expect(wrapper).to.have.className("react-context-menu-item"); - }); - - it(" should have `react-context-menu-link` class", () => { - const wrapper = shallow( ({})}/>); - - expect(wrapper.find("a")).to.have.className("react-context-menu-link"); - expect(wrapper.find("a")).to.not.have.className("disabled"); - }); - - it(" should have `disabled` class when disabled", () => { - const wrapper = shallow( ({})} disabled/>); - - expect(wrapper.find("a")).to.have.className("react-context-menu-link"); - expect(wrapper.find("a")).to.have.className("disabled"); - }); - - it("click should be triggered", () => { - const onClick = sinon.spy(), - wrapper = mount(); - - wrapper.find("a").simulate("click"); - expect(onClick.called).to.equal(true); - expect(onClick.callCount).to.equal(1); - }); - - it("click should not be triggered when disabled", () => { - const onClick = sinon.spy(), - wrapper = mount(); - - wrapper.find("a").simulate("click"); - expect(onClick.called).to.equal(false); - expect(onClick.callCount).to.equal(0); - }); - - it("given data should be passed to click callback", () => { - const onClick = sinon.spy(), - data = { - foo: Math.random().toString(36), - bar: Math.random().toString(36) - }, - wrapper = mount(); - - wrapper.find("a").simulate("click"); - expect(onClick.getCall(0).args[1]).to.eql(data); - }); - - it("`preventClose` should not close the menu", () => { - const spy = sinon.spy(monitor, "hideMenu"), - wrapper = mount( ({})} preventClose/>); - - wrapper.find("a").simulate("click"); - expect(spy.called).to.equal(false); - expect(spy.callCount).to.equal(0); - monitor.hideMenu.restore(); - }); -}); diff --git a/test/.eslintrc.yml b/tests/.eslintrc.yml similarity index 81% rename from test/.eslintrc.yml rename to tests/.eslintrc.yml index 07c0e6cb..3a294e4d 100644 --- a/test/.eslintrc.yml +++ b/tests/.eslintrc.yml @@ -1,6 +1,6 @@ --- env: - mocha: true + jest: true rules: prefer-arrow-callback: 0 no-mixed-requires: 0 diff --git a/tests/.setup.js b/tests/.setup.js new file mode 100644 index 00000000..6e32dab2 --- /dev/null +++ b/tests/.setup.js @@ -0,0 +1,17 @@ +const jsdom = require('jsdom'); + +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')); +}; +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 new file mode 100644 index 00000000..3ffd7a15 --- /dev/null +++ b/tests/ContextMenu.test.js @@ -0,0 +1,197 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import ContextMenu from '../src/ContextMenu'; +import { showMenu, hideMenu } from '../src/actions'; + +describe('ContextMenu tests', () => { + test('shows when event with correct "id" is triggered', () => { + 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(); + }); + + test('does not shows when event with incorrect "id" is triggered', () => { + 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(); + }); + + test('onShow and onHide are triggered correctly', () => { + 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 + ) + ); + expect(onShow).toHaveBeenCalled(); + 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(onShow).toHaveBeenCalledTimes(1); + expect(onHide).toHaveBeenCalledTimes(1); + component.unmount(); + }); + + test('menu should close on "Escape"', () => { + 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 + ) + ); + expect(onHide).toHaveBeenCalled(); + component.unmount(); + }); + + test('menu should close on "Enter" when selectedItem is null', () => { + 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 + ) + ); + expect(onHide).toHaveBeenCalled(); + component.unmount(); + }); + + test('menu should close on "outside" click', () => { + 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 + ) + ); + expect(onHide).toHaveBeenCalled(); + component.unmount(); + }); + + test('hideOnLeave and onMouseLeave options', () => { + const data = { position: { x: 50, y: 50 }, id: 'CORRECT_ID' }; + const onMouseLeave = jest.fn(); + const component = mount( + + ); + + showMenu(data); + expect(component.state()).toEqual( + Object.assign( + { isVisible: true, forceSubMenuOpen: false, selectedItem: null }, + data.position + ) + ); + component.simulate('mouseleave'); + expect(component.state()).toEqual( + Object.assign( + { isVisible: false, forceSubMenuOpen: false, selectedItem: null }, + data.position + ) + ); + expect(onMouseLeave).toHaveBeenCalled(); + component.unmount(); + }); +}); diff --git a/tests/MenuItem.test.js b/tests/MenuItem.test.js new file mode 100644 index 00000000..046bbc77 --- /dev/null +++ b/tests/MenuItem.test.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import MenuItem from '../src/MenuItem'; + +describe('MenuItem tests', () => { + test('extends className correctly', () => { + const className = 'CLASSNAME_PROP'; + const attributes = { + className: 'CLASSNAME_ATTRIBUTE' + }; + + const wrapper = shallow( + + ); + + expect(wrapper.hasClass(className)).toBe(true); + expect(wrapper.hasClass(attributes.className)).toBe(true); + }); +}); diff --git a/tests/__snapshots__/ContextMenu.test.js.snap b/tests/__snapshots__/ContextMenu.test.js.snap new file mode 100644 index 00000000..be063e0c --- /dev/null +++ b/tests/__snapshots__/ContextMenu.test.js.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextMenu tests does not shows when event with incorrect "id" is triggered 1`] = ` + +