diff --git a/package.json b/package.json index 38ba8fbbeb99957d87f57d6630adbffd5b3474a0..e80b880a03cea23c56c8af788f5dc2b86c98d5dd 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "postcss-simple-vars": "^4.0.0", "prop-types": "^15.5.10", "react": "15.5.4", + "react-contextmenu": "2.6.5", "react-dom": "15.5.4", "react-draggable": "2.2.6", "react-intl": "2.3.0", diff --git a/src/components/context-menu/context-menu.css b/src/components/context-menu/context-menu.css new file mode 100644 index 0000000000000000000000000000000000000000..d74dc37204b04c2970c21c3c232ebe58dd65896a --- /dev/null +++ b/src/components/context-menu/context-menu.css @@ -0,0 +1,29 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.context-menu { + min-width: 130px; + padding: 5px 0; /* The white strip at the top and bottom of the menu */ + margin: 2px 0 0; /* To keep the menu below the cursor comfortably */ + font-size: 0.85rem; + text-align: left; + background-color: #fff; + border: 1px solid $ui-pane-border; + border-radius: calc($space / 2); + box-shadow: 0px 0px 5px 1px rgba(0, 0, 0, 0.1); + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 100; +} + +.menu-item { + padding: 8px 12px; + white-space: nowrap; + cursor: pointer; + transition: 0.1s ease; +} + +.menu-item:hover { + background: $motion-primary; + color: white; +} diff --git a/src/components/context-menu/context-menu.jsx b/src/components/context-menu/context-menu.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7d33fe641ef436415298bc43c1caa77bad9ad649 --- /dev/null +++ b/src/components/context-menu/context-menu.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {ContextMenu, MenuItem} from 'react-contextmenu'; + +import styles from './context-menu.css'; + +const StyledContextMenu = props => ( + <ContextMenu + {...props} + className={styles.contextMenu} + /> +); + +const StyledMenuItem = props => ( + <MenuItem + {...props} + attributes={{className: styles.menuItem}} + /> +); + +export { + StyledContextMenu as ContextMenu, + StyledMenuItem as MenuItem +}; diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index d1be3c3dc33bb57c697539daa384203b92ca4f05..37e80217126cd251f3e1b09ca6987659f9cdae71 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -2,21 +2,25 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import Box from '../box/box.jsx'; import CostumeCanvas from '../costume-canvas/costume-canvas.jsx'; import CloseButton from '../close-button/close-button.jsx'; import styles from './sprite-selector-item.css'; +import {ContextMenuTrigger} from 'react-contextmenu'; +import {ContextMenu, MenuItem} from '../context-menu/context-menu.jsx'; +import {FormattedMessage} from 'react-intl'; + +// react-contextmenu requires unique id to match trigger and context menu +let contextMenuId = 0; const SpriteSelectorItem = props => ( - <Box - className={classNames( - props.className, - styles.spriteSelectorItem, - { + <ContextMenuTrigger + attributes={{ + className: classNames(props.className, styles.spriteSelectorItem, { [styles.isSelected]: props.selected - } - )} - onClick={props.onClick} + }), + onClick: props.onClick + }} + id={`${props.name}-${contextMenuId}`} > {props.selected ? ( <CloseButton @@ -24,7 +28,7 @@ const SpriteSelectorItem = props => ( size={CloseButton.SIZE_SMALL} onClick={props.onDeleteButtonClick} /> - ) : null } + ) : null } {props.costumeURL ? ( <CostumeCanvas className={styles.spriteImage} @@ -32,9 +36,18 @@ const SpriteSelectorItem = props => ( url={props.costumeURL} width={32} /> - ) : null} + ) : null} <div className={styles.spriteName}>{props.name}</div> - </Box> + <ContextMenu id={`${props.name}-${contextMenuId++}`}> + <MenuItem onClick={props.onDeleteButtonClick}> + <FormattedMessage + defaultMessage="delete" + description="Menu item to delete in the right click menu" + id="contextMenu.delete" + /> + </MenuItem> + </ContextMenu> + </ContextMenuTrigger> ); SpriteSelectorItem.propTypes = { diff --git a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap index 39146df349974556185db47203ad60931acb5c74..d30617f9aa25d3bcec9972f271d2cf7d76aeaf7d 100644 --- a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap +++ b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap @@ -2,23 +2,14 @@ exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = ` <div - className="ponies undefined" + className="react-contextmenu-wrapper ponies undefined" onClick={[Function]} - style={ - Object { - "alignContent": undefined, - "alignItems": undefined, - "alignSelf": undefined, - "flexBasis": undefined, - "flexDirection": undefined, - "flexGrow": undefined, - "flexShrink": undefined, - "flexWrap": undefined, - "height": undefined, - "justifyContent": undefined, - "width": undefined, - } - } + onContextMenu={[Function]} + onMouseDown={[Function]} + onMouseOut={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} > <div className="" @@ -39,5 +30,35 @@ exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = ` > Pony sprite </div> + <nav + className="react-contextmenu" + onContextMenu={[Function]} + onMouseLeave={[Function]} + role="menu" + style={ + Object { + "opacity": 0, + "pointerEvents": "none", + "position": "fixed", + } + } + tabIndex="-1" + > + <div + aria-disabled="false" + aria-orientation={null} + className="react-contextmenu-item" + onClick={[Function]} + onMouseLeave={[Function]} + onMouseMove={[Function]} + onTouchEnd={[Function]} + role="menuitem" + tabIndex="-1" + > + <span> + delete + </span> + </div> + </nav> </div> `; diff --git a/test/unit/components/sprite-selector-item.test.jsx b/test/unit/components/sprite-selector-item.test.jsx index 8059b7152f973f9090aee8c019c8d260f61f1bb6..fe0b1de5238b5076e9938da8303dfb714023212c 100644 --- a/test/unit/components/sprite-selector-item.test.jsx +++ b/test/unit/components/sprite-selector-item.test.jsx @@ -1,11 +1,10 @@ /* eslint-env jest */ import React from 'react'; // eslint-disable-line no-unused-vars -import {shallow} from 'enzyme'; +import {mountWithIntl, shallowWithIntl, componentWithIntl} from '../../helpers/intl-helpers'; // eslint-disable-next-line no-unused-vars import SpriteSelectorItemComponent from '../../../src/components/sprite-selector-item/sprite-selector-item'; import CostumeCanvas from '../../../src/components/costume-canvas/costume-canvas'; import CloseButton from '../../../src/components/close-button/close-button'; // eslint-disable-line no-unused-vars -import renderer from 'react-test-renderer'; describe('SpriteSelectorItemComponent', () => { let className; @@ -36,36 +35,46 @@ describe('SpriteSelectorItemComponent', () => { }); test('matches snapshot when selected', () => { - const component = renderer.create(getComponent()); + const component = componentWithIntl(getComponent()); expect(component.toJSON()).toMatchSnapshot(); }); test('does not have a close box when not selected', () => { selected = false; - const componentShallowWrapper = shallow(getComponent()); - expect(componentShallowWrapper.find(CloseButton).exists()).toBe(false); + const wrapper = shallowWithIntl(getComponent()); + expect(wrapper.find(CloseButton).exists()).toBe(false); }); test('triggers callback when Box component is clicked', () => { - const componentShallowWrapper = shallow(getComponent()); - componentShallowWrapper.simulate('click'); + // Use `mount` here because of the way ContextMenuTrigger consumes onClick + const wrapper = mountWithIntl(getComponent()); + wrapper.simulate('click'); expect(onClick).toHaveBeenCalled(); }); test('triggers callback when CloseButton component is clicked', () => { - const componentShallowWrapper = shallow(getComponent()); - componentShallowWrapper.find(CloseButton).simulate('click'); + const wrapper = shallowWithIntl(getComponent()); + wrapper.find(CloseButton).simulate('click'); expect(onDeleteButtonClick).toHaveBeenCalled(); }); test('creates a CostumeCanvas when a costume url is defined', () => { - const componentShallowWrapper = shallow(getComponent()); - expect(componentShallowWrapper.find(CostumeCanvas).exists()).toBe(true); + const wrapper = shallowWithIntl(getComponent()); + expect(wrapper.find(CostumeCanvas).exists()).toBe(true); }); test('does not create a CostumeCanvas when a costume url is null', () => { costumeURL = null; - const componentShallowWrapper = shallow(getComponent()); - expect(componentShallowWrapper.find(CostumeCanvas).exists()).toBe(false); + const wrapper = shallowWithIntl(getComponent()); + expect(wrapper.find(CostumeCanvas).exists()).toBe(false); + }); + + test('it has a context menu with delete menu item and callback', () => { + const wrapper = mountWithIntl(getComponent()); + const contextMenu = wrapper.find('ContextMenu'); + expect(contextMenu.exists()).toBe(true); + + contextMenu.find('[children="delete"]').simulate('click'); + expect(onDeleteButtonClick).toHaveBeenCalled(); }); }); diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index d5a822996b6a4f6b4958271bd4fa0fb19094f822..1efd40cb7cc05a26f9521dee06ad3935225fd7e8 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -1,6 +1,6 @@ /* eslint-env jest */ import React from 'react'; // eslint-disable-line no-unused-vars -import {mount} from 'enzyme'; +import {mountWithIntl} from '../../helpers/intl-helpers'; import configureStore from 'redux-mock-store'; import {Provider} from 'react-redux'; // eslint-disable-line no-unused-vars @@ -43,7 +43,7 @@ describe('SpriteSelectorItem Container', () => { }); test('should confirm if the user really wants to delete the sprite', () => { - const wrapper = mount(getContainer()); + const wrapper = mountWithIntl(getContainer()); wrapper.find(CloseButton).simulate('click'); expect(global.confirm).toHaveBeenCalled(); expect(onDeleteButtonClick).toHaveBeenCalledWith(1337); @@ -51,7 +51,7 @@ describe('SpriteSelectorItem Container', () => { test('should not delete the sprite if the user cancels', () => { global.confirm = jest.fn(() => false); - const wrapper = mount(getContainer()); + const wrapper = mountWithIntl(getContainer()); wrapper.find(CloseButton).simulate('click'); expect(global.confirm).toHaveBeenCalled(); expect(onDeleteButtonClick).not.toHaveBeenCalled();