diff --git a/packages/components/src/components/context-menu/_context-menu.scss b/packages/components/src/components/context-menu/_context-menu.scss new file mode 100644 index 000000000000..b5f74b7c6211 --- /dev/null +++ b/packages/components/src/components/context-menu/_context-menu.scss @@ -0,0 +1,126 @@ +// +// Copyright IBM Corp. 2020 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@import '../../globals/scss/vars'; +@import '../../globals/scss/vendor/@carbon/elements/scss/import-once/import-once'; +@import '../../globals/scss/helper-mixins'; + +/// Context Menu styles +/// @access private +/// @group context-menu +@mixin context-menu { + .#{$prefix}--context-menu { + @include box-shadow; + + position: fixed; + z-index: z('modal'); + min-width: 13rem; + max-width: 18rem; + padding: $spacing-02 0; + background-color: $ui-01; + visibility: hidden; + } + + .#{$prefix}--context-menu--open { + visibility: visible; + + &:focus { + @include focus-outline('border'); + } + } + + .#{$prefix}--context-menu--invisible { + opacity: 0; + } + + .#{$prefix}--context-menu-option { + position: relative; + height: $spacing-07; + background-color: $ui-01; + cursor: pointer; + transition: background-color $duration--fast-01 motion(standard, productive); + + &:focus { + @include focus-outline('outline'); + } + } + + .#{$prefix}--context-menu-option--active, + .#{$prefix}--context-menu-option:hover { + background-color: $hover-ui; + } + + .#{$prefix}--context-menu-option > .#{$prefix}--context-menu { + margin-top: calc(#{$spacing-02} * -1); + } + + .#{$prefix}--context-menu-option__content { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + padding: 0 $spacing-05; + } + + .#{$prefix}--context-menu-option__content--disabled { + background-color: $ui-01; + cursor: not-allowed; + } + + .#{$prefix}--context-menu-option__content--disabled + .#{$prefix}--context-menu-option__label, + .#{$prefix}--context-menu-option__content--disabled + .#{$prefix}--context-menu-option__info, + .#{$prefix}--context-menu-option__content--disabled + .#{$prefix}--context-menu-option__icon { + color: $disabled-02; + } + + .#{$prefix}--context-menu-option__content--indented + .#{$prefix}--context-menu-option__label { + margin-left: $spacing-05; + } + + .#{$prefix}--context-menu-option__label { + @include type-style('body-short-01'); + + flex-grow: 1; + // add top/bottom padding to make sure letters are not cut off by hidden overflow + padding: $spacing-02 0; + overflow: hidden; + color: $text-01; + white-space: nowrap; + text-align: start; + text-overflow: ellipsis; + } + + .#{$prefix}--context-menu-option__info { + display: inline-flex; + margin-left: $spacing-05; + color: $icon-01; + } + + .#{$prefix}--context-menu-option__icon { + display: flex; + align-items: center; + width: 1rem; + height: 1rem; + margin-right: $spacing-03; + color: $icon-01; + } + + .#{$prefix}--context-menu-divider { + width: 100%; + height: 1px; + margin: $spacing-02 0; + background-color: $ui-03; + } +} + +@include exports('context-menu') { + @include context-menu; +} diff --git a/packages/components/src/globals/scss/styles.scss b/packages/components/src/globals/scss/styles.scss index 605d06404f7f..69701c6c2c55 100644 --- a/packages/components/src/globals/scss/styles.scss +++ b/packages/components/src/globals/scss/styles.scss @@ -130,6 +130,7 @@ $deprecations--message: 'Deprecated code was found, this code will be removed be @import '../../components/code-snippet/code-snippet'; @import '../../components/overflow-menu/overflow-menu'; @import '../../components/content-switcher/content-switcher'; +@import '../../components/context-menu/context-menu'; @import '../../components/date-picker/date-picker'; @import '../../components/dropdown/dropdown'; @import '../../components/loading/loading'; diff --git a/packages/react/.storybook/styles.scss b/packages/react/.storybook/styles.scss index 96b5efb90f5c..dd55b9508d34 100644 --- a/packages/react/.storybook/styles.scss +++ b/packages/react/.storybook/styles.scss @@ -51,6 +51,7 @@ $prefix: 'bx'; @import '~carbon-components/src/components/code-snippet/code-snippet'; @import '~carbon-components/src/components/overflow-menu/overflow-menu'; @import '~carbon-components/src/components/content-switcher/content-switcher'; +@import '~carbon-components/src/components/context-menu/context-menu'; @import '~carbon-components/src/components/date-picker/date-picker'; @import '~carbon-components/src/components/dropdown/dropdown'; @import '~carbon-components/src/components/loading/loading'; diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 01a5c403c6ac..86cc00dfb459 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7608,6 +7608,161 @@ Map { }, }, }, + "unstable_ContextMenu" => Object { + "ContextMenuDivider": Object {}, + "ContextMenuGroup": Object { + "propTypes": Object { + "children": Object { + "type": "node", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + }, + }, + "ContextMenuItem": Object { + "propTypes": Object { + "children": Object { + "type": "node", + }, + "disabled": Object { + "type": "bool", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + "shortcut": Object { + "type": "node", + }, + }, + }, + "ContextMenuRadioGroup": Object { + "propTypes": Object { + "initialSelectedItem": Object { + "type": "string", + }, + "items": Object { + "args": Array [ + Object { + "type": "string", + }, + ], + "isRequired": true, + "type": "arrayOf", + }, + "label": Object { + "isRequired": true, + "type": "string", + }, + "onChange": Object { + "type": "func", + }, + }, + }, + "ContextMenuSelectableItem": Object { + "propTypes": Object { + "initialChecked": Object { + "type": "bool", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + "onChange": Object { + "type": "func", + }, + }, + }, + "propTypes": Object { + "children": Object { + "type": "node", + }, + "level": Object { + "type": "number", + }, + "onClose": Object { + "type": "func", + }, + "open": Object { + "type": "bool", + }, + "x": Object { + "type": "number", + }, + "y": Object { + "type": "number", + }, + }, + }, + "unstable_ContextMenuDivider" => Object {}, + "unstable_ContextMenuGroup" => Object { + "propTypes": Object { + "children": Object { + "type": "node", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + }, + }, + "unstable_ContextMenuItem" => Object { + "propTypes": Object { + "children": Object { + "type": "node", + }, + "disabled": Object { + "type": "bool", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + "shortcut": Object { + "type": "node", + }, + }, + }, + "unstable_ContextMenuRadioGroup" => Object { + "propTypes": Object { + "initialSelectedItem": Object { + "type": "string", + }, + "items": Object { + "args": Array [ + Object { + "type": "string", + }, + ], + "isRequired": true, + "type": "arrayOf", + }, + "label": Object { + "isRequired": true, + "type": "string", + }, + "onChange": Object { + "type": "func", + }, + }, + }, + "unstable_ContextMenuSelectableItem" => Object { + "propTypes": Object { + "initialChecked": Object { + "type": "bool", + }, + "label": Object { + "isRequired": true, + "type": "node", + }, + "onChange": Object { + "type": "func", + }, + }, + }, + "unstable_useContextMenu" => Object {}, "unstable_Heading" => Object { "propTypes": Object { "children": Object { diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index df82386cb6f4..f13933ba9394 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -194,12 +194,19 @@ describe('Carbon Components React', () => { "TooltipDefinition", "TooltipIcon", "UnorderedList", + "unstable_ContextMenu", + "unstable_ContextMenuDivider", + "unstable_ContextMenuGroup", + "unstable_ContextMenuItem", + "unstable_ContextMenuRadioGroup", + "unstable_ContextMenuSelectableItem", "unstable_Heading", "unstable_PageSelector", "unstable_Pagination", "unstable_Section", "unstable_TreeNode", "unstable_TreeView", + "unstable_useContextMenu", ] `); }); diff --git a/packages/react/src/components/ContextMenu/ContextMenu-story.js b/packages/react/src/components/ContextMenu/ContextMenu-story.js new file mode 100644 index 000000000000..910480d0b1b9 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenu-story.js @@ -0,0 +1,154 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { InlineNotification } from '../Notification'; + +import ContextMenu, { + ContextMenuDivider, + ContextMenuGroup, + ContextMenuItem, + ContextMenuRadioGroup, + ContextMenuSelectableItem, + useContextMenu, +} from '../ContextMenu'; + +export default { + title: 'Experimental/unstable_ContextMenu', + parameters: { + component: ContextMenu, + }, +}; + +const InfoBanners = () => ( + <> + + + +); + +const Story = (items) => { + const contextMenuProps = useContextMenu(); + + function renderItem(item, i) { + switch (item.type) { + case 'item': + return ( + + {item.children && item.children.map(renderItem)} + + ); + case 'divider': + return ; + case 'selectable': + return ( + + ); + case 'radiogroup': + return ( + + ); + case 'group': + return ( + + {item.children && item.children.map(renderItem)} + + ); + } + } + + return ( +
+ + {items.map(renderItem)} +
+ ); +}; + +export const _ContextMenu = () => + Story([ + { + type: 'item', + label: 'Share with', + children: [ + { + type: 'radiogroup', + label: 'Share with', + items: ['None', 'Product team', 'Organization', 'Company'], + initialSelectedItem: 'Product team', + }, + ], + }, + { type: 'divider' }, + { type: 'item', label: 'Cut', shortcut: '⌘X' }, + { type: 'item', label: 'Copy', shortcut: '⌘C' }, + { type: 'item', label: 'Copy path', shortcut: '⌥⌘C' }, + { type: 'item', label: 'Paste', shortcut: '⌘V', disabled: true }, + { type: 'item', label: 'Duplicate' }, + { type: 'divider' }, + { type: 'selectable', label: 'Publish', initialChecked: true }, + { type: 'divider' }, + { type: 'item', label: 'Rename', shortcut: '↩︎' }, + { type: 'item', label: 'Delete', shortcut: '⌘⌫' }, + ]); +_ContextMenu.storyName = 'ContextMenu'; + +export const _MultipleGroups = () => + Story([ + { + type: 'group', + label: 'Font style', + children: [ + { type: 'selectable', label: 'Bold' }, + { type: 'selectable', label: 'Italic' }, + ], + }, + { type: 'divider' }, + { + type: 'radiogroup', + label: 'Text color', + items: ['Black', 'Blue', 'Red', 'Green'], + initialSelectedItem: 'Black', + }, + { type: 'divider' }, + { + type: 'radiogroup', + label: 'Text decoration', + items: ['None', 'Overline', 'Line-through', 'Underline'], + initialSelectedItem: 'None', + }, + ]); +_MultipleGroups.storyName = 'MultipleGroups'; diff --git a/packages/react/src/components/ContextMenu/ContextMenu-test.js b/packages/react/src/components/ContextMenu/ContextMenu-test.js new file mode 100644 index 000000000000..18ea146ab8ea --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenu-test.js @@ -0,0 +1,146 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ContextMenu, { + ContextMenuItem, + ContextMenuRadioGroup, + ContextMenuSelectableItem, + ContextMenuDivider, +} from '../ContextMenu'; +import { mount } from 'enzyme'; +import { settings } from 'carbon-components'; +import { describe, expect } from 'window-or-global'; + +const { prefix } = settings; + +describe('ContextMenu', () => { + describe('renders as expected', () => { + describe('menu', () => { + it('receives the expected classes when closed', () => { + const wrapper = mount(); + const container = wrapper.childAt(0).childAt(0); + + expect(container.hasClass(`${prefix}--context-menu`)).toBe(true); + expect(container.hasClass(`${prefix}--context-menu--open`)).toBe(false); + }); + + it('receives the expected classes when opened', () => { + const wrapper = mount(); + + const container = wrapper.childAt(0).childAt(0); + + expect(container.hasClass(`${prefix}--context-menu`)).toBe(true); + expect(container.hasClass(`${prefix}--context-menu--open`)).toBe(true); + }); + }); + + describe('option', () => { + it('receives the expected classes', () => { + const wrapper = mount(); + const container = wrapper.childAt(0).childAt(0); + + expect(container.hasClass(`${prefix}--context-menu-option`)).toBe(true); + }); + + it('renders props.label', () => { + const wrapper = mount(); + + expect( + wrapper.find(`span.${prefix}--context-menu-option__label`).text() + ).toBe('Copy'); + expect( + wrapper + .find(`span.${prefix}--context-menu-option__label`) + .prop('title') + ).toBe('Copy'); + }); + + it('renders props.shortcut when provided', () => { + const wrapper = mount(); + + expect( + wrapper.find(`div.${prefix}--context-menu-option__info`).length + ).toBeGreaterThan(0); + expect( + wrapper.find(`div.${prefix}--context-menu-option__info`).text() + ).toBe('⌘C'); + }); + + it('respects props.disabled', () => { + const wrapper = mount(); + const content = wrapper.find( + `div.${prefix}--context-menu-option__content` + ); + + expect( + content.hasClass(`${prefix}--context-menu-option__content--disabled`) + ).toBe(true); + expect( + wrapper + .find(`li.${prefix}--context-menu-option`) + .prop('aria-disabled') + ).toBe(true); + }); + + it('renders props.children as submenu', () => { + const wrapper = mount( + + + + + + + ); + + const level1 = wrapper.find(`li.${prefix}--context-menu-option`).at(0); + + expect( + level1.find(`ul.${prefix}--context-menu`).length + ).toBeGreaterThan(0); + }); + }); + + describe('radiogroup', () => { + it('children have role "menuitemradio"', () => { + const wrapper = mount( + + ); + const options = wrapper.find(`li.${prefix}--context-menu-option`); + + expect(options.every('li[role="menuitemradio"]')).toBe(true); + }); + }); + + describe('selectable', () => { + it('has role "menuitemcheckbox"', () => { + const wrapper = mount(); + const container = wrapper.childAt(0); + + expect(container.prop('role')).toBe('menuitemcheckbox'); + }); + }); + + describe('divider', () => { + it('receives the expected classes', () => { + const wrapper = mount(); + const container = wrapper.childAt(0); + + expect(container.hasClass(`${prefix}--context-menu-divider`)).toBe( + true + ); + }); + + it('has role "separator"', () => { + const wrapper = mount(); + const container = wrapper.childAt(0); + + expect(container.prop('role')).toBe('separator'); + }); + }); + }); +}); diff --git a/packages/react/src/components/ContextMenu/ContextMenu.js b/packages/react/src/components/ContextMenu/ContextMenu.js new file mode 100644 index 000000000000..371a6604499b --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenu.js @@ -0,0 +1,316 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { settings } from 'carbon-components'; +import { keys, match } from '../../internal/keyboard'; +import ClickListener from '../../internal/ClickListener'; + +import { + clickedElementHasSubnodes, + getValidNodes, + resetFocus, + focusNode as focusNodeUtil, + getNextNode, + getParentNode, + getParentMenu, +} from './_utils'; + +import ContextMenuGroup from './ContextMenuGroup'; +import ContextMenuRadioGroup from './ContextMenuRadioGroup'; +import ContextMenuRadioGroupOptions from './ContextMenuRadioGroupOptions'; +import ContextMenuSelectableItem from './ContextMenuSelectableItem'; + +const { prefix } = settings; + +const margin = 16; // distance to keep to body edges, in px + +const ContextMenu = function ContextMenu({ + children, + open, + level = 1, + x = 0, + y = 0, + onClose = () => {}, + ...rest +}) { + const rootRef = useRef(null); + const [direction, setDirection] = useState(1); // 1 = to right, -1 = to left + const [position, setPosition] = useState([x, y]); + const [canBeClosed, setCanBeClosed] = useState(false); + const isRootMenu = level === 1; + + function focusNode(node) { + if (node) { + resetFocus(rootRef?.current?.element); + focusNodeUtil(node); + } + } + + function handleKeyDown(event) { + if ( + event.target.tagName === 'LI' && + (match(event, keys.Enter) || match(event, keys.Space)) + ) { + handleClick(event); + } else { + event.stopPropagation(); + } + + if ( + match(event, keys.Escape) || + (!isRootMenu && match(event, keys.ArrowLeft)) + ) { + onClose(); + } + + let nodeToFocus; + + if (event.target.tagName === 'LI') { + const currentNode = event.target; + + if (match(event, keys.ArrowUp)) { + nodeToFocus = getNextNode(currentNode, -1); + } else if (match(event, keys.ArrowDown)) { + nodeToFocus = getNextNode(currentNode, 1); + } else if (match(event, keys.ArrowLeft)) { + nodeToFocus = getParentNode(currentNode); + } + } else if (event.target.tagName === 'UL') { + const validNodes = getValidNodes(event.target); + + if (validNodes.length > 0 && match(event, keys.ArrowUp)) { + nodeToFocus = validNodes[validNodes.length - 1]; + } else if (validNodes.length > 0 && match(event, keys.ArrowDown)) { + nodeToFocus = validNodes[0]; + } + } + + focusNode(nodeToFocus); + + if (rest.onKeyDown) { + rest.onKeyDown(event); + } + } + + function handleClick(e) { + if (!clickedElementHasSubnodes(e) && e.target.tagName !== 'UL') { + onClose(); + } else { + e.stopPropagation(); + } + } + + function handleClickOutside(e) { + if (!clickedElementHasSubnodes(e) && open && canBeClosed) { + onClose(); + } + } + + function getCorrectedPosition(assumedDirection) { + const pos = [x, y]; + + const { + width, + height, + } = rootRef?.current?.element?.getBoundingClientRect(); + const { clientWidth: bodyWidth, clientHeight: bodyHeight } = document.body; + const parentWidth = isRootMenu + ? 0 + : getParentMenu(rootRef?.current?.element)?.getBoundingClientRect() + ?.width; + let localDirection = assumedDirection; + + const min = [margin, margin]; + const max = [bodyWidth - margin - width, bodyHeight - margin - height]; + + // in case it is root menu previously had direction -1, check + // if direction 1 would be possible + if (isRootMenu && localDirection === -1 && pos[0] < max[0]) { + localDirection = 1; + } + + // make sure menu is visible in y bounds + if (pos[1] > max[1]) { + pos[1] = max[1]; + } + if (pos[1] < min[1]) { + pos[1] = min[1]; + } + + if (localDirection === 1) { + // if it won't fit anymore + if (pos[0] > max[0]) { + pos[0] = x - width - parentWidth; + if (pos[0] + width > bodyWidth - margin) { + pos[0] = max[0]; + } + localDirection = -1; + } else if (pos[0] < min[0]) { + // keep distance to left screen edge + pos[0] = min[0]; + } + } else if (localDirection === -1) { + pos[0] = x - width - parentWidth; + + // if it should re-reverse + if (pos[0] < min[0]) { + pos[0] = x; + localDirection = 1; + } + } + + setDirection(localDirection); + + return [Math.round(pos[0]), Math.round(pos[1])]; + } + + useEffect(() => { + setCanBeClosed(false); + + if (open) { + let localDirection = 1; + + if (isRootMenu) { + rootRef?.current?.element?.focus(); + } else { + const parentMenu = getParentMenu(rootRef?.current?.element); + + if (parentMenu) { + localDirection = Number(parentMenu.dataset.direction); + } + } + + const correctedPosition = getCorrectedPosition(localDirection); + setPosition(correctedPosition); + + document.addEventListener( + 'mouseup', + () => { + // wait until mouse button is released before allowing ClickListener + // to close context menu as Safari emits 'click' event after 'contextmenu' + // event when 'e.preventDefault()' is used on 'contextmenu' and would + // otherwise close the menu immediately + setCanBeClosed(true); + }, + { once: true } + ); + } else { + setPosition([0, 0]); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, x, y]); + + const someNodesHaveIcons = React.Children.toArray(children).some( + (node) => + node.type === ContextMenuSelectableItem || + node.type === ContextMenuRadioGroup + ); + + const options = React.Children.map(children, (node) => { + if (React.isValidElement(node)) { + return React.cloneElement(node, { + indented: someNodesHaveIcons, + level: level, + }); + } + }); + + const classes = classnames(`${prefix}--context-menu`, { + [`${prefix}--context-menu--open`]: open, + [`${prefix}--context-menu--invisible`]: + open && position[0] === 0 && position[1] === 0, + [`${prefix}--context-menu--root`]: isRootMenu, + }); + + const ulAttributes = { + className: classes, + onKeyDown: handleKeyDown, + onClick: handleClick, + role: 'menu', + tabIndex: -1, + 'data-direction': direction, + 'data-level': level, + style: { + left: `${position[0]}px`, + top: `${position[1]}px`, + }, + }; + + let childrenToRender = options; + + // if the only child is a radiogroup, don't render it as radiogroup component, but + // only the items to prevent duplicate markup + if ( + options && + options.length === 1 && + options[0].type === ContextMenuRadioGroup + ) { + const radioGroupProps = options[0].props; + + ulAttributes['aria-label'] = radioGroupProps.label; + childrenToRender = ( + + ); + } + + // if the only child is a generic group, don't render it as group component, but + // only the children to prevent duplicate markup + if (options && options.length === 1 && options[0].type === ContextMenuGroup) { + const groupProps = options[0].props; + + ulAttributes['aria-label'] = groupProps.label; + childrenToRender = React.Children.toArray(options[0].props.children); + } + + return ( + +
    {childrenToRender}
+
+ ); +}; + +ContextMenu.propTypes = { + /** + * Specify the children of the ContextMenu + */ + children: PropTypes.node, + + /** + * Internal: keeps track of the nesting level of the menu + */ + level: PropTypes.number, + + /** + * Function called when the menu is closed + */ + onClose: PropTypes.func, + + /** + * Specify whether the ContextMenu is currently open + */ + open: PropTypes.bool, + + /** + * Specify the x position where this menu is rendered + */ + x: PropTypes.number, + + /** + * Specify the y position where this menu is rendered + */ + y: PropTypes.number, +}; + +export default ContextMenu; diff --git a/packages/react/src/components/ContextMenu/ContextMenuDivider.js b/packages/react/src/components/ContextMenu/ContextMenuDivider.js new file mode 100644 index 000000000000..5e0ad28022e9 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuDivider.js @@ -0,0 +1,17 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { settings } from 'carbon-components'; + +const { prefix } = settings; + +function ContextMenuDivider() { + return
  • ; +} + +export default ContextMenuDivider; diff --git a/packages/react/src/components/ContextMenu/ContextMenuGroup.js b/packages/react/src/components/ContextMenu/ContextMenuGroup.js new file mode 100644 index 000000000000..5c31ba58d424 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuGroup.js @@ -0,0 +1,33 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +function ContextMenuGroup({ label, children }) { + return ( +
  • +
      + {children} +
    +
  • + ); +} + +ContextMenuGroup.propTypes = { + /** + * Specify the children of the ContextMenuGroup + */ + children: PropTypes.node, + + /** + * Rendered label for the ContextMenuGroup + */ + label: PropTypes.node.isRequired, +}; + +export default ContextMenuGroup; diff --git a/packages/react/src/components/ContextMenu/ContextMenuItem.js b/packages/react/src/components/ContextMenu/ContextMenuItem.js new file mode 100644 index 000000000000..2cf581420e25 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuItem.js @@ -0,0 +1,47 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import ContextMenuOption from './ContextMenuOption'; + +function ContextMenuItem({ label, children, disabled, shortcut, ...rest }) { + return ( + + {children} + + ); +} + +ContextMenuItem.propTypes = { + /** + * Specify the children of the ContextMenuItem + */ + children: PropTypes.node, + + /** + * Specify whether this ContextMenuItem is disabled + */ + disabled: PropTypes.bool, + + /** + * Rendered label for the ContextMenuItem + */ + label: PropTypes.node.isRequired, + + /** + * Rendered shortcut for the ContextMenuItem + */ + shortcut: PropTypes.node, +}; + +export default ContextMenuItem; diff --git a/packages/react/src/components/ContextMenu/ContextMenuOption.js b/packages/react/src/components/ContextMenu/ContextMenuOption.js new file mode 100644 index 000000000000..6d5b2f0bf8e5 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuOption.js @@ -0,0 +1,264 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { settings } from 'carbon-components'; +import { CaretRight16 } from '@carbon/icons-react'; +import { keys, match } from '../../internal/keyboard'; + +import { + getFirstSubNode, + focusNode, + getParentMenu, + clickedElementHasSubnodes, +} from './_utils'; + +import ContextMenu from './ContextMenu'; + +const { prefix } = settings; + +const hoverIntentDelay = 150; // in ms + +function ContextMenuOptionContent({ + label, + info, + disabled, + icon: Icon, + indented, +}) { + const classes = classnames(`${prefix}--context-menu-option__content`, { + [`${prefix}--context-menu-option__content--disabled`]: disabled, + }); + + return ( +
    + {indented && ( +
    + {Icon && } +
    + )} + + {label} + +
    {info}
    +
    + ); +} + +function ContextMenuOption({ + children, + disabled, + indented, + label, + level, + onClick = () => {}, + renderIcon, + shortcut, + ...rest +}) { + const [submenuOpen, setSubmenuOpen] = useState(false); + const [submenuOpenedByKeyboard, setSubmenuOpenedByKeyboard] = useState(false); + const rootRef = useRef(null); + const hoverIntentTimeout = useRef(null); + + const subOptions = React.Children.map(children, (node) => { + if (React.isValidElement(node)) { + return React.cloneElement(node); + } + }); + + function openSubmenu(openedByKeyboard = false) { + setSubmenuOpenedByKeyboard(openedByKeyboard); + setSubmenuOpen(true); + } + + function handleKeyDown(event) { + if ( + clickedElementHasSubnodes(event) && + (match(event, keys.ArrowRight) || + match(event, keys.Enter) || + match(event, keys.Space)) + ) { + openSubmenu(true); + } else if ( + (match(event, keys.Enter) || match(event, keys.Space)) && + onClick + ) { + onClick(event); + } + } + + function handleMouseEnter() { + hoverIntentTimeout.current = setTimeout(openSubmenu, hoverIntentDelay); + } + + function handleMouseLeave() { + clearTimeout(hoverIntentTimeout?.current); + + setSubmenuOpen(false); + } + + function getSubmenuPosition() { + const pos = [0, 0]; + + if (subOptions) { + const parentMenu = getParentMenu(rootRef?.current); + + if (parentMenu) { + const { x, width } = parentMenu.getBoundingClientRect(); + const { y } = rootRef.current.getBoundingClientRect(); + + pos[0] = x + width; + pos[1] = y; + } + } + + return pos; + } + + useEffect(() => { + if (subOptions && submenuOpenedByKeyboard) { + const firstSubnode = getFirstSubNode(rootRef?.current); + focusNode(firstSubnode); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submenuOpen]); + + const classes = classnames(`${prefix}--context-menu-option`, { + [`${prefix}--context-menu-option--disabled`]: disabled, + [`${prefix}--context-menu-option--active`]: subOptions && submenuOpen, + }); + + const allowedRoles = ['menuitemradio', 'menuitemcheckbox']; + const role = + rest.role && allowedRoles.includes(rest.role) ? rest.role : 'menuitem'; + + const submenuPosition = getSubmenuPosition(); + + return ( + // role is either menuitemradio, menuitemcheckbox, or menuitem which are all interactive + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
  • + {subOptions ? ( + <> + } + indented={indented} + /> + { + setSubmenuOpen(false); + }} + x={submenuPosition[0]} + y={submenuPosition[1]}> + {subOptions} + + + ) : ( + + )} +
  • + ); +} + +ContextMenuOptionContent.propTypes = { + /** + * Whether this option is disabled + */ + disabled: PropTypes.bool, + + /** + * Icon that is displayed in front of the option + */ + icon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Whether the content should be indented + */ + indented: PropTypes.bool, + + /** + * Additional information such as shortcut or caret + */ + info: PropTypes.node, + + /** + * Rendered label for the ContextMenuOptionContent + */ + label: PropTypes.node.isRequired, +}; + +ContextMenuOption.propTypes = { + /** + * Specify the children of the ContextMenuOption + */ + children: PropTypes.node, + + /** + * Specify whether this ContextMenuOption is disabled + */ + disabled: PropTypes.bool, + + /** + * Whether the content should be indented (for example because it's in a group with options that have icons). + * Is automatically set by ContextMenu + */ + indented: PropTypes.bool, + + /** + * Rendered label for the ContextMenuOption + */ + label: PropTypes.node.isRequired, + + /** + * Which nested level this option is located in. + * Is automatically set by ContextMenu + */ + level: PropTypes.number, + + /** + * The onClick handler + */ + onClick: PropTypes.func, + + /** + * Rendered icon for the ContextMenuOption. + * Can be a React component class + */ + renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Rendered shortcut for the ContextMenuOption + */ + shortcut: PropTypes.node, +}; + +export default ContextMenuOption; diff --git a/packages/react/src/components/ContextMenu/ContextMenuRadioGroup.js b/packages/react/src/components/ContextMenu/ContextMenuRadioGroup.js new file mode 100644 index 000000000000..eae6164c2e60 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuRadioGroup.js @@ -0,0 +1,52 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import ContextMenuGroup from './ContextMenuGroup'; +import ContextMenuRadioGroupOptions from './ContextMenuRadioGroupOptions'; + +function ContextMenuRadioGroup({ + items, + initialSelectedItem, + label, + onChange = () => {}, +}) { + return ( + + + + ); +} + +ContextMenuRadioGroup.propTypes = { + /** + * Whether the option should be checked by default + */ + initialSelectedItem: PropTypes.string, + + /** + * Array of the radio options + */ + items: PropTypes.arrayOf(PropTypes.string).isRequired, + + /** + * The radio group label + */ + label: PropTypes.string.isRequired, + + /** + * Callback function when selection the has been changed + */ + onChange: PropTypes.func, +}; + +export default ContextMenuRadioGroup; diff --git a/packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js b/packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js new file mode 100644 index 000000000000..19794116c90f --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js @@ -0,0 +1,63 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Checkmark16 } from '@carbon/icons-react'; +import ContextMenuOption from './ContextMenuOption'; + +function ContextMenuRadioGroupOptions({ + items, + initialSelectedItem, + onChange = () => {}, +}) { + const [selected, setSelected] = useState(initialSelectedItem); + + function handleClick(option) { + setSelected(option); + onChange(option); + } + + const options = items.map((option, i) => { + const isSelected = selected === option; + + return ( + { + handleClick(option); + }} + /> + ); + }); + + return options; +} + +ContextMenuRadioGroupOptions.propTypes = { + /** + * Whether the option should be checked by default + */ + initialSelectedItem: PropTypes.string, + + /** + * Array of the radio options + */ + items: PropTypes.arrayOf(PropTypes.string).isRequired, + + /** + * Callback function when selection the has been changed + */ + onChange: PropTypes.func, +}; + +export default ContextMenuRadioGroupOptions; diff --git a/packages/react/src/components/ContextMenu/ContextMenuSelectableItem.js b/packages/react/src/components/ContextMenu/ContextMenuSelectableItem.js new file mode 100644 index 000000000000..1a8934471ac5 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenuSelectableItem.js @@ -0,0 +1,54 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Checkmark16 } from '@carbon/icons-react'; +import ContextMenuOption from './ContextMenuOption'; + +function ContextMenuSelectableItem({ + label, + initialChecked, + onChange = () => {}, +}) { + const [checked, setChecked] = useState(initialChecked); + + function handleClick() { + setChecked(!checked); + onChange(!checked); + } + + return ( + + ); +} + +ContextMenuSelectableItem.propTypes = { + /** + * Whether the option should be checked by default + */ + initialChecked: PropTypes.bool, + + /** + * Rendered label for the ContextMenuOptionContent + */ + label: PropTypes.node.isRequired, + + /** + * Callback function when selection the has been changed + */ + onChange: PropTypes.func, +}; + +export default ContextMenuSelectableItem; diff --git a/packages/react/src/components/ContextMenu/_utils.js b/packages/react/src/components/ContextMenu/_utils.js new file mode 100644 index 000000000000..45b087fdfa38 --- /dev/null +++ b/packages/react/src/components/ContextMenu/_utils.js @@ -0,0 +1,90 @@ +import { settings } from 'carbon-components'; + +const { prefix } = settings; + +export function resetFocus(el) { + if (el) { + Array.from(el.querySelectorAll('[tabindex="0"]') ?? []).forEach((node) => { + node.tabIndex = -1; + }); + } +} + +export function focusNode(node) { + if (node) { + node.tabIndex = 0; + node.focus(); + } +} + +export function getValidNodes(list) { + const { level } = list.dataset; + + let nodes = []; + + if (level) { + const submenus = Array.from(list.querySelectorAll('[data-level]')); + nodes = Array.from( + list.querySelectorAll(`li.${prefix}--context-menu-option`) + ).filter((child) => !submenus.some((submenu) => submenu.contains(child))); + } + + return nodes.filter((node) => + node.matches(`:not(.${prefix}--context-menu-option--disabled)`) + ); +} + +export function getNextNode(current, direction) { + const menu = getParentMenu(current); + const nodes = getValidNodes(menu); + const currentIndex = nodes.indexOf(current); + + const nextNode = nodes[currentIndex + direction]; + + return nextNode || null; +} + +export function getFirstSubNode(node) { + const submenu = node.querySelector(`ul.${prefix}--context-menu`); + + if (submenu) { + const subnodes = getValidNodes(submenu); + + return subnodes[0] || null; + } + + return null; +} + +export function getParentNode(node) { + if (node) { + const parentNode = node.parentNode.closest( + `li.${prefix}--context-menu-option` + ); + + return parentNode || null; + } + + return null; +} + +export function getParentMenu(el) { + if (el) { + const parentMenu = el.parentNode.closest(`ul.${prefix}--context-menu`); + + return parentMenu || null; + } + + return null; +} + +export function clickedElementHasSubnodes(e) { + if (e) { + const closestFocusableElement = e.target.closest('[tabindex]'); + if (closestFocusableElement?.tagName === 'LI') { + return getFirstSubNode(closestFocusableElement) !== null; + } + } + + return false; +} diff --git a/packages/react/src/components/ContextMenu/index.js b/packages/react/src/components/ContextMenu/index.js new file mode 100644 index 000000000000..64d423735746 --- /dev/null +++ b/packages/react/src/components/ContextMenu/index.js @@ -0,0 +1,31 @@ +/** + * Copyright IBM Corp. 2020 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import ContextMenu from './ContextMenu'; +import ContextMenuDivider from './ContextMenuDivider'; +import ContextMenuGroup from './ContextMenuGroup'; +import ContextMenuItem from './ContextMenuItem'; +import ContextMenuRadioGroup from './ContextMenuRadioGroup'; +import ContextMenuSelectableItem from './ContextMenuSelectableItem'; + +ContextMenu.ContextMenuDivider = ContextMenuDivider; +ContextMenu.ContextMenuGroup = ContextMenuGroup; +ContextMenu.ContextMenuItem = ContextMenuItem; +ContextMenu.ContextMenuRadioGroup = ContextMenuRadioGroup; +ContextMenu.ContextMenuSelectableItem = ContextMenuSelectableItem; + +import useContextMenu from './useContextMenu'; + +export { + ContextMenuDivider, + ContextMenuGroup, + ContextMenuItem, + ContextMenuRadioGroup, + ContextMenuSelectableItem, + useContextMenu, +}; +export default ContextMenu; diff --git a/packages/react/src/components/ContextMenu/useContextMenu.js b/packages/react/src/components/ContextMenu/useContextMenu.js new file mode 100644 index 000000000000..5a06d59850b4 --- /dev/null +++ b/packages/react/src/components/ContextMenu/useContextMenu.js @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +/** + * @param {Element|Document|Window} [trigger=document] The element which should trigger the ContextMenu on right-click + * @returns {object} Props object to pass onto ContextMenu component + */ +function useContextMenu(trigger = document) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState([0, 0]); + + function openContextMenu(e) { + e.preventDefault(); + + const { x, y } = e; + + setPosition([x, y]); + setOpen(true); + } + + function onClose() { + setOpen(false); + } + + useEffect(() => { + if ( + (trigger && trigger instanceof Element) || + trigger instanceof Document || + trigger instanceof Window + ) { + trigger.addEventListener('contextmenu', openContextMenu); + + return () => { + trigger.removeEventListener('contextmenu', openContextMenu); + }; + } + }, [trigger]); + + return { + open, + x: position[0], + y: position[1], + onClose, + }; +} + +export default useContextMenu; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index 5c412b56ed59..06ce7ae2cce0 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -207,6 +207,14 @@ export { export unstable_TreeView, { TreeNode as unstable_TreeNode, } from './components/TreeView'; +export unstable_ContextMenu, { + ContextMenuDivider as unstable_ContextMenuDivider, + ContextMenuGroup as unstable_ContextMenuGroup, + ContextMenuItem as unstable_ContextMenuItem, + ContextMenuRadioGroup as unstable_ContextMenuRadioGroup, + ContextMenuSelectableItem as unstable_ContextMenuSelectableItem, + useContextMenu as unstable_useContextMenu, +} from './components/ContextMenu'; export { Heading as unstable_Heading, Section as unstable_Section,