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 (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+}
+
+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,