diff --git a/packages/components/src/components/context-menu/_context-menu.scss b/packages/components/src/components/menu/_menu.scss
similarity index 66%
rename from packages/components/src/components/context-menu/_context-menu.scss
rename to packages/components/src/components/menu/_menu.scss
index ceb43d424070..4e52601098ca 100644
--- a/packages/components/src/components/context-menu/_context-menu.scss
+++ b/packages/components/src/components/menu/_menu.scss
@@ -9,11 +9,11 @@
@import '../../globals/scss/vendor/@carbon/elements/scss/import-once/import-once';
@import '../../globals/scss/helper-mixins';
-/// Context Menu styles
+/// Menu styles
/// @access private
-/// @group context-menu
-@mixin context-menu {
- .#{$prefix}--context-menu {
+/// @group menu
+@mixin menu {
+ .#{$prefix}--menu {
@include box-shadow;
position: fixed;
@@ -25,7 +25,7 @@
visibility: hidden;
}
- .#{$prefix}--context-menu--open {
+ .#{$prefix}--menu--open {
visibility: visible;
&:focus {
@@ -33,11 +33,11 @@
}
}
- .#{$prefix}--context-menu--invisible {
+ .#{$prefix}--menu--invisible {
opacity: 0;
}
- .#{$prefix}--context-menu-option {
+ .#{$prefix}--menu-option {
position: relative;
height: $spacing-07;
background-color: $layer;
@@ -50,22 +50,22 @@
}
}
- .#{$prefix}--context-menu-option--active,
- .#{$prefix}--context-menu-option:hover {
+ .#{$prefix}--menu-option--active,
+ .#{$prefix}--menu-option:hover {
background-color: $layer-hover;
}
- .#{$prefix}--context-menu-option--danger:hover,
- .#{$prefix}--context-menu-option--danger:focus {
+ .#{$prefix}--menu-option--danger:hover,
+ .#{$prefix}--menu-option--danger:focus {
background-color: $button-danger-primary;
color: $text-on-color;
}
- .#{$prefix}--context-menu-option > .#{$prefix}--context-menu {
+ .#{$prefix}--menu-option > .#{$prefix}--menu {
margin-top: calc(#{$spacing-02} * -1);
}
- .#{$prefix}--context-menu-option__content {
+ .#{$prefix}--menu-option__content {
display: flex;
height: 100%;
align-items: center;
@@ -73,18 +73,23 @@
padding: 0 $spacing-05;
}
- .#{$prefix}--context-menu-option__content--disabled {
+ .#{$prefix}--menu-option__content--disabled {
background-color: $layer;
color: $text-disabled;
cursor: not-allowed;
}
- .#{$prefix}--context-menu-option__content--indented
- .#{$prefix}--context-menu-option__label {
+ .#{$prefix}--menu-option__content--disabled .#{$prefix}--menu-option__label,
+ .#{$prefix}--menu-option__content--disabled .#{$prefix}--menu-option__info,
+ .#{$prefix}--menu-option__content--disabled .#{$prefix}--menu-option__icon {
+ color: $text-disabled;
+ }
+
+ .#{$prefix}--menu-option__content--indented .#{$prefix}--menu-option__label {
margin-left: $spacing-05;
}
- .#{$prefix}--context-menu-option__label {
+ .#{$prefix}--menu-option__label {
@include type-style('body-short-01');
overflow: hidden;
@@ -96,12 +101,12 @@
white-space: nowrap;
}
- .#{$prefix}--context-menu-option__info {
+ .#{$prefix}--menu-option__info {
display: inline-flex;
margin-left: $spacing-05;
}
- .#{$prefix}--context-menu-option__icon {
+ .#{$prefix}--menu-option__icon {
display: flex;
width: 1rem;
height: 1rem;
@@ -109,7 +114,7 @@
margin-right: $spacing-03;
}
- .#{$prefix}--context-menu-divider {
+ .#{$prefix}--menu-divider {
width: 100%;
height: 1px;
margin: $spacing-02 0;
@@ -117,6 +122,6 @@
}
}
-@include exports('context-menu') {
- @include context-menu;
+@include exports('menu') {
+ @include menu;
}
diff --git a/packages/components/src/globals/scss/styles.scss b/packages/components/src/globals/scss/styles.scss
index cd4d306907cb..c082b4cf68a0 100644
--- a/packages/components/src/globals/scss/styles.scss
+++ b/packages/components/src/globals/scss/styles.scss
@@ -116,49 +116,49 @@ $deprecations--message: 'Deprecated code was found, this code will be removed be
// 🍕 Components
//-------------------------
+@import '../../components/accordion/accordion';
+@import '../../components/breadcrumb/breadcrumb';
@import '../../components/button/button';
-@import '../../components/copy-button/copy-button';
-@import '../../components/file-uploader/file-uploader';
@import '../../components/checkbox/checkbox';
+@import '../../components/code-snippet/code-snippet';
@import '../../components/combo-box/combo-box';
-@import '../../components/radio-button/radio-button';
-@import '../../components/toggle/toggle';
-@import '../../components/search/search';
-@import '../../components/select/select';
-@import '../../components/text-input/text-input';
-@import '../../components/text-area/text-area';
-@import '../../components/number-input/number-input';
+@import '../../components/content-switcher/content-switcher';
+@import '../../components/copy-button/copy-button';
+@import '../../components/data-table/data-table';
+@import '../../components/date-picker/date-picker';
+@import '../../components/dropdown/dropdown';
+@import '../../components/file-uploader/file-uploader';
@import '../../components/form/form';
+@import '../../components/inline-loading/inline-loading';
@import '../../components/link/link';
@import '../../components/list-box/list-box';
@import '../../components/list/list';
-@import '../../components/data-table/data-table';
-@import '../../components/structured-list/structured-list';
-@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';
+@import '../../components/menu/menu';
@import '../../components/modal/modal';
@import '../../components/multi-select/multi-select';
@import '../../components/notification/inline-notification';
@import '../../components/notification/toast-notification';
-@import '../../components/tooltip/tooltip';
-@import '../../components/tabs/tabs';
-@import '../../components/tag/tag';
+@import '../../components/number-input/number-input';
+@import '../../components/overflow-menu/overflow-menu';
+@import '../../components/pagination-nav/pagination-nav';
@import '../../components/pagination/pagination';
-@import '../../components/accordion/accordion';
@import '../../components/progress-indicator/progress-indicator';
-@import '../../components/breadcrumb/breadcrumb';
-@import '../../components/toolbar/toolbar';
-@import '../../components/time-picker/time-picker';
+@import '../../components/radio-button/radio-button';
+@import '../../components/search/search';
+@import '../../components/select/select';
+@import '../../components/skeleton/skeleton';
@import '../../components/slider/slider';
+@import '../../components/structured-list/structured-list';
+@import '../../components/tabs/tabs';
+@import '../../components/tag/tag';
+@import '../../components/text-area/text-area';
+@import '../../components/text-input/text-input';
@import '../../components/tile/tile';
-@import '../../components/skeleton/skeleton';
-@import '../../components/inline-loading/inline-loading';
-@import '../../components/pagination-nav/pagination-nav';
+@import '../../components/time-picker/time-picker';
+@import '../../components/toggle/toggle';
+@import '../../components/toolbar/toolbar';
+@import '../../components/tooltip/tooltip';
//-------------------------------------
// 🔬 Experimental
diff --git a/packages/react/.storybook/styles.scss b/packages/react/.storybook/styles.scss
index 1ea3d00692ff..994daf508e33 100644
--- a/packages/react/.storybook/styles.scss
+++ b/packages/react/.storybook/styles.scss
@@ -30,51 +30,51 @@ $prefix: 'bx';
@import '~carbon-components/src/globals/scss/functions';
@import '~carbon-components/src/components/accordion/accordion';
+@import '~carbon-components/src/components/breadcrumb/breadcrumb';
@import '~carbon-components/src/components/button/button';
-@import '~carbon-components/src/components/copy-button/copy-button';
-@import '~carbon-components/src/components/file-uploader/file-uploader';
@import '~carbon-components/src/components/checkbox/checkbox';
+@import '~carbon-components/src/components/code-snippet/code-snippet';
@import '~carbon-components/src/components/combo-box/combo-box';
-@import '~carbon-components/src/components/radio-button/radio-button';
-@import '~carbon-components/src/components/toggle/toggle';
-@import '~carbon-components/src/components/search/search';
-@import '~carbon-components/src/components/select/select';
-@import '~carbon-components/src/components/text-input/text-input';
-@import '~carbon-components/src/components/text-area/text-area';
-@import '~carbon-components/src/components/number-input/number-input';
+@import '~carbon-components/src/components/content-switcher/content-switcher';
+@import '~carbon-components/src/components/copy-button/copy-button';
+@import '~carbon-components/src/components/data-table/data-table';
+@import '~carbon-components/src/components/date-picker/date-picker';
+@import '~carbon-components/src/components/dropdown/dropdown';
+@import '~carbon-components/src/components/file-uploader/file-uploader';
@import '~carbon-components/src/components/form/form';
+@import '~carbon-components/src/components/inline-loading/inline-loading';
@import '~carbon-components/src/components/link/link';
@import '~carbon-components/src/components/list-box/list-box';
@import '~carbon-components/src/components/list/list';
-@import '~carbon-components/src/components/data-table/data-table';
-@import '~carbon-components/src/components/structured-list/structured-list';
-@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';
+@import '~carbon-components/src/components/menu/menu';
@import '~carbon-components/src/components/modal/modal';
@import '~carbon-components/src/components/multi-select/multi-select';
@import '~carbon-components/src/components/notification/inline-notification';
@import '~carbon-components/src/components/notification/toast-notification';
-@import '~carbon-components/src/components/tooltip/tooltip';
-@import '~carbon-components/src/components/tabs/tabs';
-@import '~carbon-components/src/components/tag/tag';
-@import '~carbon-components/src/components/treeview/treeview';
+@import '~carbon-components/src/components/number-input/number-input';
+@import '~carbon-components/src/components/overflow-menu/overflow-menu';
+@import '~carbon-components/src/components/pagination-nav/pagination-nav';
@import '~carbon-components/src/components/pagination/pagination';
@import '~carbon-components/src/components/pagination/unstable_pagination';
@import '~carbon-components/src/components/popover/popover';
@import '~carbon-components/src/components/progress-indicator/progress-indicator';
-@import '~carbon-components/src/components/breadcrumb/breadcrumb';
-@import '~carbon-components/src/components/toolbar/toolbar';
-@import '~carbon-components/src/components/time-picker/time-picker';
+@import '~carbon-components/src/components/radio-button/radio-button';
+@import '~carbon-components/src/components/search/search';
+@import '~carbon-components/src/components/select/select';
+@import '~carbon-components/src/components/skeleton/skeleton';
@import '~carbon-components/src/components/slider/slider';
+@import '~carbon-components/src/components/structured-list/structured-list';
+@import '~carbon-components/src/components/tabs/tabs';
+@import '~carbon-components/src/components/tag/tag';
+@import '~carbon-components/src/components/text-area/text-area';
+@import '~carbon-components/src/components/text-input/text-input';
@import '~carbon-components/src/components/tile/tile';
-@import '~carbon-components/src/components/skeleton/skeleton';
-@import '~carbon-components/src/components/inline-loading/inline-loading';
-@import '~carbon-components/src/components/pagination-nav/pagination-nav';
+@import '~carbon-components/src/components/time-picker/time-picker';
+@import '~carbon-components/src/components/toggle/toggle';
+@import '~carbon-components/src/components/toolbar/toolbar';
+@import '~carbon-components/src/components/tooltip/tooltip';
+@import '~carbon-components/src/components/treeview/treeview';
@import '~carbon-components/src/components/ui-shell/ui-shell';
@if feature-flag-enabled('enable-css-custom-properties') {
diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
index 4b3f670f25f5..a72140f0af7d 100644
--- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
+++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
@@ -7790,9 +7790,9 @@ Map {
},
},
},
- "unstable_ContextMenu" => Object {
- "ContextMenuDivider": Object {},
- "ContextMenuGroup": Object {
+ "unstable_Menu" => Object {
+ "MenuDivider": Object {},
+ "MenuGroup": Object {
"propTypes": Object {
"children": Object {
"type": "node",
@@ -7803,7 +7803,7 @@ Map {
},
},
},
- "ContextMenuItem": Object {
+ "MenuItem": Object {
"propTypes": Object {
"children": Object {
"type": "node",
@@ -7829,7 +7829,7 @@ Map {
},
},
},
- "ContextMenuRadioGroup": Object {
+ "MenuRadioGroup": Object {
"propTypes": Object {
"initialSelectedItem": Object {
"type": "string",
@@ -7852,7 +7852,7 @@ Map {
},
},
},
- "ContextMenuSelectableItem": Object {
+ "MenuSelectableItem": Object {
"propTypes": Object {
"initialChecked": Object {
"type": "bool",
@@ -7867,6 +7867,9 @@ Map {
},
},
"propTypes": Object {
+ "autoclose": Object {
+ "type": "bool",
+ },
"children": Object {
"type": "node",
},
@@ -7880,15 +7883,45 @@ Map {
"type": "bool",
},
"x": Object {
- "type": "number",
+ "args": Array [
+ Array [
+ Object {
+ "type": "number",
+ },
+ Object {
+ "args": Array [
+ Object {
+ "type": "number",
+ },
+ ],
+ "type": "arrayOf",
+ },
+ ],
+ ],
+ "type": "oneOfType",
},
"y": Object {
- "type": "number",
+ "args": Array [
+ Array [
+ Object {
+ "type": "number",
+ },
+ Object {
+ "args": Array [
+ Object {
+ "type": "number",
+ },
+ ],
+ "type": "arrayOf",
+ },
+ ],
+ ],
+ "type": "oneOfType",
},
},
},
- "unstable_ContextMenuDivider" => Object {},
- "unstable_ContextMenuGroup" => Object {
+ "unstable_MenuDivider" => Object {},
+ "unstable_MenuGroup" => Object {
"propTypes": Object {
"children": Object {
"type": "node",
@@ -7899,7 +7932,7 @@ Map {
},
},
},
- "unstable_ContextMenuItem" => Object {
+ "unstable_MenuItem" => Object {
"propTypes": Object {
"children": Object {
"type": "node",
@@ -7925,7 +7958,7 @@ Map {
},
},
},
- "unstable_ContextMenuRadioGroup" => Object {
+ "unstable_MenuRadioGroup" => Object {
"propTypes": Object {
"initialSelectedItem": Object {
"type": "string",
@@ -7948,7 +7981,7 @@ Map {
},
},
},
- "unstable_ContextMenuSelectableItem" => Object {
+ "unstable_MenuSelectableItem" => Object {
"propTypes": Object {
"initialChecked": Object {
"type": "bool",
diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js
index ff6b3fd766bd..bc8eb2b2bcee 100644
--- a/packages/react/src/__tests__/index-test.js
+++ b/packages/react/src/__tests__/index-test.js
@@ -195,14 +195,14 @@ describe('Carbon Components React', () => {
"TooltipDefinition",
"TooltipIcon",
"UnorderedList",
- "unstable_ContextMenu",
- "unstable_ContextMenuDivider",
- "unstable_ContextMenuGroup",
- "unstable_ContextMenuItem",
- "unstable_ContextMenuRadioGroup",
- "unstable_ContextMenuSelectableItem",
"unstable_FeatureFlags",
"unstable_Heading",
+ "unstable_Menu",
+ "unstable_MenuDivider",
+ "unstable_MenuGroup",
+ "unstable_MenuItem",
+ "unstable_MenuRadioGroup",
+ "unstable_MenuSelectableItem",
"unstable_PageSelector",
"unstable_Pagination",
"unstable_Section",
diff --git a/packages/react/src/components/ContextMenu/ContextMenu-story.js b/packages/react/src/components/ContextMenu/ContextMenu-story.js
index 31921fb8df77..1b356c76c303 100644
--- a/packages/react/src/components/ContextMenu/ContextMenu-story.js
+++ b/packages/react/src/components/ContextMenu/ContextMenu-story.js
@@ -6,96 +6,36 @@
*/
import React from 'react';
-import { action } from '@storybook/addon-actions';
import { InlineNotification } from '../Notification';
-import ContextMenu, {
- ContextMenuDivider,
- ContextMenuGroup,
- ContextMenuItem,
- ContextMenuRadioGroup,
- ContextMenuSelectableItem,
- useContextMenu,
-} from '../ContextMenu';
+import Menu from '../Menu';
+import { StoryFrame, buildMenu } from '../Menu/_storybook-utils';
+
+import { useContextMenu } from './index';
export default {
- title: 'Experimental/unstable_ContextMenu',
+ title: 'Experimental/unstable_Menu/ContextMenu',
parameters: {
- component: ContextMenu,
+ component: Menu,
},
};
-const InfoBanners = () => (
- <>
-
-
- >
-);
-
const Story = (items) => {
- const contextMenuProps = useContextMenu();
+ const menuProps = 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)}
-
- );
- }
- }
+ const renderedItems = buildMenu(items);
return (
-
-
- {items.map(renderItem)}
-
+
+
+
+
);
};
diff --git a/packages/react/src/components/ContextMenu/ContextMenu-test.js b/packages/react/src/components/ContextMenu/ContextMenu-test.js
deleted file mode 100644
index 94f2ce18495b..000000000000
--- a/packages/react/src/components/ContextMenu/ContextMenu-test.js
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * 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('supports danger kind', () => {
- const wrapper = mount();
- const option = wrapper.find(`.${prefix}--context-menu-option`);
-
- expect(option.hasClass(`${prefix}--context-menu-option--danger`)).toBe(
- true
- );
- });
-
- it('ignores danger kind when it has children', () => {
- const wrapper = mount(
-
-
-
-
-
-
- );
- const option = wrapper.find(`.${prefix}--context-menu-option`).at(0);
-
- expect(option.hasClass(`${prefix}--context-menu-option--danger`)).toBe(
- false
- );
- });
-
- 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/_utils.js b/packages/react/src/components/ContextMenu/_utils.js
deleted file mode 100644
index 45b087fdfa38..000000000000
--- a/packages/react/src/components/ContextMenu/_utils.js
+++ /dev/null
@@ -1,90 +0,0 @@
-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
index 64d423735746..4d1772a5aecb 100644
--- a/packages/react/src/components/ContextMenu/index.js
+++ b/packages/react/src/components/ContextMenu/index.js
@@ -5,27 +5,6 @@
* 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;
+export { useContextMenu };
diff --git a/packages/react/src/components/ContextMenu/useContextMenu.js b/packages/react/src/components/ContextMenu/useContextMenu.js
index 5a06d59850b4..c600a678c75f 100644
--- a/packages/react/src/components/ContextMenu/useContextMenu.js
+++ b/packages/react/src/components/ContextMenu/useContextMenu.js
@@ -1,11 +1,12 @@
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
+ * @param {Element|Document|Window} [trigger=document] The element which should trigger the Menu on right-click
+ * @returns {object} Props object to pass onto Menu component
*/
function useContextMenu(trigger = document) {
const [open, setOpen] = useState(false);
+ const [canBeClosed, setCanBeClosed] = useState(false);
const [position, setPosition] = useState([0, 0]);
function openContextMenu(e) {
@@ -15,6 +16,32 @@ function useContextMenu(trigger = document) {
setPosition([x, y]);
setOpen(true);
+
+ // Safari emits the click event when preventDefault was called on
+ // the contextmenu event. This is registered by the ClickListener
+ // component and would lead to immediate closing when a user is
+ // triggering the menu with ctrl+click. To prevent this, we only
+ // allow the menu to be closed after the click event was received.
+ // Since other browsers don't emit this event, it's also reset with
+ // a 50ms delay after mouseup event was called.
+
+ document.addEventListener(
+ 'mouseup',
+ () => {
+ setTimeout(() => {
+ setCanBeClosed(true);
+ }, 50);
+ },
+ { once: true }
+ );
+
+ document.addEventListener(
+ 'click',
+ () => {
+ setCanBeClosed(true);
+ },
+ { once: true }
+ );
}
function onClose() {
@@ -39,6 +66,7 @@ function useContextMenu(trigger = document) {
open,
x: position[0],
y: position[1],
+ autoclose: canBeClosed,
onClose,
};
}
diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap
index 3b97bed5e5cc..e02fa0e63e9b 100644
--- a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap
+++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap
@@ -2450,92 +2450,106 @@ exports[`DataTable should render 1`] = `
}
title="Settings"
>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
`;
diff --git a/packages/react/src/components/Menu/Menu-test.js b/packages/react/src/components/Menu/Menu-test.js
new file mode 100644
index 000000000000..864c25f70320
--- /dev/null
+++ b/packages/react/src/components/Menu/Menu-test.js
@@ -0,0 +1,143 @@
+/**
+ * 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 Menu, {
+ MenuItem,
+ MenuRadioGroup,
+ MenuSelectableItem,
+ MenuDivider,
+} from '../Menu';
+import { mount } from 'enzyme';
+import { settings } from 'carbon-components';
+import { describe, expect } from 'window-or-global';
+
+const { prefix } = settings;
+
+describe('Menu', () => {
+ 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}--menu`)).toBe(true);
+ expect(container.hasClass(`${prefix}--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}--menu`)).toBe(true);
+ expect(container.hasClass(`${prefix}--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}--menu-option`)).toBe(true);
+ });
+
+ it('renders props.label', () => {
+ const wrapper = mount();
+
+ expect(wrapper.find(`span.${prefix}--menu-option__label`).text()).toBe(
+ 'Copy'
+ );
+ expect(
+ wrapper.find(`span.${prefix}--menu-option__label`).prop('title')
+ ).toBe('Copy');
+ });
+
+ it('renders props.shortcut when provided', () => {
+ const wrapper = mount();
+
+ expect(
+ wrapper.find(`div.${prefix}--menu-option__info`).length
+ ).toBeGreaterThan(0);
+ expect(wrapper.find(`div.${prefix}--menu-option__info`).text()).toBe(
+ '⌘C'
+ );
+ });
+
+ it('respects props.disabled', () => {
+ const wrapper = mount();
+ const content = wrapper.find(`div.${prefix}--menu-option__content`);
+
+ expect(
+ content.hasClass(`${prefix}--menu-option__content--disabled`)
+ ).toBe(true);
+ expect(
+ wrapper.find(`li.${prefix}--menu-option`).prop('aria-disabled')
+ ).toBe(true);
+ });
+
+ it('supports danger kind', () => {
+ const wrapper = mount();
+ const option = wrapper.find(`.${prefix}--menu-option`);
+
+ expect(option.hasClass(`${prefix}--menu-option--danger`)).toBe(true);
+ });
+
+ it('renders props.children as submenu', () => {
+ const wrapper = mount(
+
+ );
+
+ const level1 = wrapper.find(`li.${prefix}--menu-option`).at(0);
+
+ expect(level1.find(`ul.${prefix}--menu`).length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('radiogroup', () => {
+ it('children have role "menuitemradio"', () => {
+ const wrapper = mount(
+
+ );
+ const options = wrapper.find(`li.${prefix}--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}--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/Menu/Menu.js
similarity index 61%
rename from packages/react/src/components/ContextMenu/ContextMenu.js
rename to packages/react/src/components/Menu/Menu.js
index 2b5ce5768059..595570745273 100644
--- a/packages/react/src/components/ContextMenu/ContextMenu.js
+++ b/packages/react/src/components/Menu/Menu.js
@@ -13,25 +13,28 @@ import { keys, match } from '../../internal/keyboard';
import ClickListener from '../../internal/ClickListener';
import {
+ capWithinRange,
clickedElementHasSubnodes,
- getValidNodes,
- resetFocus,
focusNode as focusNodeUtil,
getNextNode,
- getParentNode,
getParentMenu,
+ getParentNode,
+ getPosition,
+ getValidNodes,
+ resetFocus,
} from './_utils';
-import ContextMenuGroup from './ContextMenuGroup';
-import ContextMenuRadioGroup from './ContextMenuRadioGroup';
-import ContextMenuRadioGroupOptions from './ContextMenuRadioGroupOptions';
-import ContextMenuSelectableItem from './ContextMenuSelectableItem';
+import MenuGroup from './MenuGroup';
+import MenuRadioGroup from './MenuRadioGroup';
+import MenuRadioGroupOptions from './MenuRadioGroupOptions';
+import MenuSelectableItem from './MenuSelectableItem';
const { prefix } = settings;
const margin = 16; // distance to keep to body edges, in px
-const ContextMenu = function ContextMenu({
+const Menu = function Menu({
+ autoclose = true,
children,
open,
level = 1,
@@ -46,6 +49,56 @@ const ContextMenu = function ContextMenu({
const [canBeClosed, setCanBeClosed] = useState(false);
const isRootMenu = level === 1;
+ function getContainerBoundaries() {
+ const { clientWidth: bodyWidth, clientHeight: bodyHeight } = document.body;
+ return [margin, margin, bodyWidth - margin, bodyHeight - margin];
+ }
+
+ function getTargetBoundaries() {
+ const xIsRange = typeof x === 'object' && x.length === 2;
+ const yIsRange = typeof y === 'object' && y.length === 2;
+
+ const targetBoundaries = [
+ xIsRange ? x[0] : x,
+ yIsRange ? y[0] : y,
+ xIsRange ? x[1] : x,
+ yIsRange ? y[1] : y,
+ ];
+
+ if (!isRootMenu) {
+ const { width: parentWidth } = getParentMenu(
+ rootRef?.current?.element
+ )?.getBoundingClientRect();
+
+ targetBoundaries[2] -= parentWidth;
+ }
+
+ const containerBoundaries = getContainerBoundaries();
+
+ return [
+ capWithinRange(
+ targetBoundaries[0],
+ containerBoundaries[0],
+ containerBoundaries[2]
+ ),
+ capWithinRange(
+ targetBoundaries[1],
+ containerBoundaries[1],
+ containerBoundaries[3]
+ ),
+ capWithinRange(
+ targetBoundaries[2],
+ containerBoundaries[0],
+ containerBoundaries[2]
+ ),
+ capWithinRange(
+ targetBoundaries[3],
+ containerBoundaries[1],
+ containerBoundaries[3]
+ ),
+ ];
+ }
+
function focusNode(node) {
if (node) {
resetFocus(rootRef?.current?.element);
@@ -108,67 +161,30 @@ const ContextMenu = function ContextMenu({
}
function handleClickOutside(e) {
- if (!clickedElementHasSubnodes(e) && open && canBeClosed) {
+ if (!clickedElementHasSubnodes(e) && open && canBeClosed && autoclose) {
onClose();
}
}
- function getCorrectedPosition(assumedDirection) {
- const pos = [x, y];
+ function getCorrectedPosition(preferredDirection) {
+ const elementRect = rootRef?.current?.element?.getBoundingClientRect();
+ const elementDimensions = [elementRect.width, elementRect.height];
+ const targetBoundaries = getTargetBoundaries();
+ const containerBoundaries = getContainerBoundaries();
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;
- }
- }
+ position: correctedPosition,
+ direction: correctedDirection,
+ } = getPosition(
+ elementDimensions,
+ targetBoundaries,
+ containerBoundaries,
+ preferredDirection
+ );
- setDirection(localDirection);
+ setDirection(correctedDirection);
- return [Math.round(pos[0]), Math.round(pos[1])];
+ return correctedPosition;
}
useEffect(() => {
@@ -190,31 +206,7 @@ const ContextMenu = function ContextMenu({
const correctedPosition = getCorrectedPosition(localDirection);
setPosition(correctedPosition);
- // Safari emits the click event when preventDefault was called on
- // the contextmenu event. This is registered by the ClickListener
- // component and would lead to immediate closing when a user is
- // triggering the menu with ctrl+click. To prevent this, we only
- // allow the menu to be closed after the click event was received.
- // Since other browsers don't emit this event, it's also reset with
- // a 50ms delay after mouseup event was called.
-
- document.addEventListener(
- 'mouseup',
- () => {
- setTimeout(() => {
- setCanBeClosed(true);
- }, 50);
- },
- { once: true }
- );
-
- document.addEventListener(
- 'click',
- () => {
- setCanBeClosed(true);
- },
- { once: true }
- );
+ setCanBeClosed(true);
} else {
setPosition([0, 0]);
}
@@ -223,9 +215,7 @@ const ContextMenu = function ContextMenu({
}, [open, x, y]);
const someNodesHaveIcons = React.Children.toArray(children).some(
- (node) =>
- node.type === ContextMenuSelectableItem ||
- node.type === ContextMenuRadioGroup
+ (node) => node.type === MenuSelectableItem || node.type === MenuRadioGroup
);
const options = React.Children.map(children, (node) => {
@@ -237,11 +227,11 @@ const ContextMenu = function ContextMenu({
}
});
- const classes = classnames(`${prefix}--context-menu`, {
- [`${prefix}--context-menu--open`]: open,
- [`${prefix}--context-menu--invisible`]:
+ const classes = classnames(`${prefix}--menu`, {
+ [`${prefix}--menu--open`]: open,
+ [`${prefix}--menu--invisible`]:
open && position[0] === 0 && position[1] === 0,
- [`${prefix}--context-menu--root`]: isRootMenu,
+ [`${prefix}--menu--root`]: isRootMenu,
});
const ulAttributes = {
@@ -262,16 +252,12 @@ const ContextMenu = function ContextMenu({
// 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
- ) {
+ if (options && options.length === 1 && options[0].type === MenuRadioGroup) {
const radioGroupProps = options[0].props;
ulAttributes['aria-label'] = radioGroupProps.label;
childrenToRender = (
- ;
+function MenuDivider() {
+ return ;
}
-export default ContextMenuDivider;
+export default MenuDivider;
diff --git a/packages/react/src/components/ContextMenu/ContextMenuGroup.js b/packages/react/src/components/Menu/MenuGroup.js
similarity index 68%
rename from packages/react/src/components/ContextMenu/ContextMenuGroup.js
rename to packages/react/src/components/Menu/MenuGroup.js
index 5c31ba58d424..db63ced7a36c 100644
--- a/packages/react/src/components/ContextMenu/ContextMenuGroup.js
+++ b/packages/react/src/components/Menu/MenuGroup.js
@@ -8,7 +8,7 @@
import React from 'react';
import PropTypes from 'prop-types';
-function ContextMenuGroup({ label, children }) {
+function MenuGroup({ label, children }) {
return (
@@ -18,16 +18,16 @@ function ContextMenuGroup({ label, children }) {
);
}
-ContextMenuGroup.propTypes = {
+MenuGroup.propTypes = {
/**
- * Specify the children of the ContextMenuGroup
+ * Specify the children of the MenuGroup
*/
children: PropTypes.node,
/**
- * Rendered label for the ContextMenuGroup
+ * Rendered label for the MenuGroup
*/
label: PropTypes.node.isRequired,
};
-export default ContextMenuGroup;
+export default MenuGroup;
diff --git a/packages/react/src/components/ContextMenu/ContextMenuItem.js b/packages/react/src/components/Menu/MenuItem.js
similarity index 53%
rename from packages/react/src/components/ContextMenu/ContextMenuItem.js
rename to packages/react/src/components/Menu/MenuItem.js
index a7fbd860ad3d..5e31651bcb62 100644
--- a/packages/react/src/components/ContextMenu/ContextMenuItem.js
+++ b/packages/react/src/components/Menu/MenuItem.js
@@ -8,53 +8,46 @@
import React from 'react';
import PropTypes from 'prop-types';
-import ContextMenuOption from './ContextMenuOption';
-
-function ContextMenuItem({
- label,
- children,
- disabled,
- kind = 'default',
- shortcut,
- ...rest
-}) {
+import MenuOption from './MenuOption';
+
+function MenuItem({ label, children, disabled, kind, shortcut, ...rest }) {
return (
-
{children}
-
+
);
}
-ContextMenuItem.propTypes = {
+MenuItem.propTypes = {
/**
- * Specify the children of the ContextMenuItem
+ * Specify the children of the MenuItem
*/
children: PropTypes.node,
/**
- * Specify whether this ContextMenuItem is disabled
+ * Specify whether this MenuItem is disabled
*/
disabled: PropTypes.bool,
/**
- * Optional prop to specify the kind of the ContextMenuItem
+ * Optional prop to specify the kind of the MenuItem
*/
kind: PropTypes.oneOf(['default', 'danger']),
/**
- * Rendered label for the ContextMenuItem
+ * Rendered label for the MenuItem
*/
label: PropTypes.node.isRequired,
/**
- * Rendered shortcut for the ContextMenuItem
+ * Rendered shortcut for the MenuItem
*/
shortcut: PropTypes.node,
};
-export default ContextMenuItem;
+export default MenuItem;
diff --git a/packages/react/src/components/ContextMenu/ContextMenuOption.js b/packages/react/src/components/Menu/MenuOption.js
similarity index 77%
rename from packages/react/src/components/ContextMenu/ContextMenuOption.js
rename to packages/react/src/components/Menu/MenuOption.js
index e73f82800ea1..1adac2f38e09 100644
--- a/packages/react/src/components/ContextMenu/ContextMenuOption.js
+++ b/packages/react/src/components/Menu/MenuOption.js
@@ -19,39 +19,31 @@ import {
clickedElementHasSubnodes,
} from './_utils';
-import ContextMenu from './ContextMenu';
+import Menu from './Menu';
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,
+function MenuOptionContent({ label, info, disabled, icon: Icon, indented }) {
+ const classes = classnames(`${prefix}--menu-option__content`, {
+ [`${prefix}--menu-option__content--disabled`]: disabled,
});
return (
{indented && (
-
- {Icon && }
-
+
{Icon && }
)}
-
+
{label}
- {info}
+ {info}
);
}
-function ContextMenuOption({
+function MenuOption({
children,
disabled,
indented,
@@ -131,11 +123,10 @@ function ContextMenuOption({
// 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,
- [`${prefix}--context-menu-option--danger`]:
- !subOptions && kind === 'danger',
+ const classes = classnames(`${prefix}--menu-option`, {
+ [`${prefix}--menu-option--disabled`]: disabled,
+ [`${prefix}--menu-option--active`]: subOptions && submenuOpen,
+ [`${prefix}--menu-option--danger`]: !subOptions && kind === 'danger',
});
const allowedRoles = ['menuitemradio', 'menuitemcheckbox'];
@@ -162,13 +153,13 @@ function ContextMenuOption({
onClick={onClick}>
{subOptions ? (
<>
- }
indented={indented}
/>
- {
@@ -177,10 +168,10 @@ function ContextMenuOption({
x={submenuPosition[0]}
y={submenuPosition[1]}>
{subOptions}
-
+
>
) : (
- {},
}) {
return (
-
-
+
-
+
);
}
-ContextMenuRadioGroup.propTypes = {
+MenuRadioGroup.propTypes = {
/**
* Whether the option should be checked by default
*/
@@ -49,4 +49,4 @@ ContextMenuRadioGroup.propTypes = {
onChange: PropTypes.func,
};
-export default ContextMenuRadioGroup;
+export default MenuRadioGroup;
diff --git a/packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js b/packages/react/src/components/Menu/MenuRadioGroupOptions.js
similarity index 85%
rename from packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js
rename to packages/react/src/components/Menu/MenuRadioGroupOptions.js
index 19794116c90f..bcb035e2365e 100644
--- a/packages/react/src/components/ContextMenu/ContextMenuRadioGroupOptions.js
+++ b/packages/react/src/components/Menu/MenuRadioGroupOptions.js
@@ -8,9 +8,9 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Checkmark16 } from '@carbon/icons-react';
-import ContextMenuOption from './ContextMenuOption';
+import MenuOption from './MenuOption';
-function ContextMenuRadioGroupOptions({
+function MenuRadioGroupOptions({
items,
initialSelectedItem,
onChange = () => {},
@@ -26,7 +26,7 @@ function ContextMenuRadioGroupOptions({
const isSelected = selected === option;
return (
- {},
-}) {
+function MenuSelectableItem({ label, initialChecked, onChange = () => {} }) {
const [checked, setChecked] = useState(initialChecked);
function handleClick() {
@@ -23,7 +19,7 @@ function ContextMenuSelectableItem({
}
return (
- (
+
+);
+
+// eslint-disable-next-line react/prop-types
+export const StoryFrame = ({ children }) => (
+
+
+ {children}
+
+);
+
+function renderItem(item, i) {
+ switch (item.type) {
+ case 'item':
+ return (
+
+ );
+ case 'divider':
+ return ;
+ case 'selectable':
+ return (
+
+ );
+ case 'radiogroup':
+ return (
+
+ );
+ case 'group':
+ return (
+
+ {item.children && item.children.map(renderItem)}
+
+ );
+ }
+}
+
+export const buildMenu = (items) => items.map(renderItem);
diff --git a/packages/react/src/components/Menu/_utils.js b/packages/react/src/components/Menu/_utils.js
new file mode 100644
index 000000000000..55767bff101d
--- /dev/null
+++ b/packages/react/src/components/Menu/_utils.js
@@ -0,0 +1,180 @@
+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}--menu-option`)
+ ).filter((child) => !submenus.some((submenu) => submenu.contains(child)));
+ }
+
+ return nodes.filter((node) =>
+ node.matches(`:not(.${prefix}--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}--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}--menu-option`);
+
+ return parentNode || null;
+ }
+
+ return null;
+}
+
+export function getParentMenu(el) {
+ if (el) {
+ const parentMenu = el.parentNode.closest(`ul.${prefix}--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;
+}
+
+/**
+ * @param {number} [value] The value to cap
+ * @param {number} [min] The minimum of the range
+ * @param {number} [max] The maximum of the range
+ * @returns {number} Whether or not the element fits inside the boundaries on the given axis
+ */
+export function capWithinRange(value, min, max) {
+ if (value > max) {
+ return max;
+ }
+
+ if (value < min) {
+ return min;
+ }
+
+ return value;
+}
+
+/**
+ * @param {number[]} [elementDimensions] The dimensions of the element: [width, height]
+ * @param {number[]} [position] The desired position of the element: [x, y]
+ * @param {number[]} [boundaries] The boundaries of the container the element should be contained in: [minX, minY, maxX, maxY]
+ * @param {string} [axis="x"] Which axis to check. Either "x" or "y"
+ * @returns {boolean} Whether or not the element fits inside the boundaries on the given axis
+ */
+function elementFits(elementDimensions, position, boundaries, axis = 'x') {
+ const index = axis === 'y' ? 1 : 0;
+
+ const min = boundaries[index];
+ const max = boundaries[index + 2];
+
+ const start = position[index];
+ const end = position[index] + elementDimensions[index];
+
+ return start >= min && end <= max;
+}
+
+/**
+ * @param {number[]} [elementDimensions] The dimensions of the element: [width, height]
+ * @param {number[]} [targetBoundaries] The boundaries of the target the element should attach to: [minX, minY, maxX, maxY]
+ * @param {number[]} [containerBoundaries] The boundaries of the container the element should be contained in: [minX, minY, maxX, maxY]
+ * @param {number} [preferredDirection=1] Which direction is preferred. Either 1 (right right) or -1 (to left)
+ * @returns {object} The determined position and direction of the elemnt: { position: [x, y], direction: 1 | -1 }
+ */
+export function getPosition(
+ elementDimensions,
+ targetBoundaries,
+ containerBoundaries,
+ preferredDirection = 1
+) {
+ const position = [0, 0];
+ let direction = preferredDirection;
+
+ // x
+ position[0] =
+ direction === 1
+ ? targetBoundaries[0]
+ : targetBoundaries[2] - elementDimensions[0];
+
+ const xFits = elementFits(
+ elementDimensions,
+ position,
+ containerBoundaries,
+ 'x'
+ );
+ if (!xFits) {
+ direction = direction * -1;
+ position[0] =
+ direction === 1
+ ? targetBoundaries[0]
+ : targetBoundaries[2] - elementDimensions[0];
+ }
+
+ // y
+ position[1] = targetBoundaries[3];
+
+ const yFits = elementFits(
+ elementDimensions,
+ position,
+ containerBoundaries,
+ 'y'
+ );
+ if (!yFits) {
+ position[1] = targetBoundaries[1] - elementDimensions[1];
+ }
+
+ return {
+ position,
+ direction,
+ };
+}
diff --git a/packages/react/src/components/Menu/index.js b/packages/react/src/components/Menu/index.js
new file mode 100644
index 000000000000..daa82038f123
--- /dev/null
+++ b/packages/react/src/components/Menu/index.js
@@ -0,0 +1,22 @@
+/**
+ * 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 Menu from './Menu';
+import MenuDivider from './MenuDivider';
+import MenuGroup from './MenuGroup';
+import MenuItem from './MenuItem';
+import MenuRadioGroup from './MenuRadioGroup';
+import MenuSelectableItem from './MenuSelectableItem';
+
+Menu.MenuDivider = MenuDivider;
+Menu.MenuGroup = MenuGroup;
+Menu.MenuItem = MenuItem;
+Menu.MenuRadioGroup = MenuRadioGroup;
+Menu.MenuSelectableItem = MenuSelectableItem;
+
+export { MenuDivider, MenuGroup, MenuItem, MenuRadioGroup, MenuSelectableItem };
+export default Menu;
diff --git a/packages/react/src/components/OverflowMenu/OverflowMenu-story.js b/packages/react/src/components/OverflowMenu/OverflowMenu-story.js
index 293434570126..893f5f31f163 100644
--- a/packages/react/src/components/OverflowMenu/OverflowMenu-story.js
+++ b/packages/react/src/components/OverflowMenu/OverflowMenu-story.js
@@ -8,8 +8,7 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { withKnobs, boolean, select, text } from '@storybook/addon-knobs';
-import OverflowMenu from '../OverflowMenu';
-import { OverflowMenu as OGOverflowMenu } from './OverflowMenu';
+import { OverflowMenu } from './OverflowMenu';
import OverflowMenuItem from '../OverflowMenuItem';
import mdx from './OverflowMenu.mdx';
import { Filter16 } from '@carbon/icons-react';
@@ -61,7 +60,7 @@ OverflowMenu.displayName = 'OverflowMenu';
export default {
title: 'Components/OverflowMenu',
decorators: [withKnobs],
- component: OGOverflowMenu,
+ component: OverflowMenu,
subcomponents: {
OverflowMenuItem,
},
diff --git a/packages/react/src/components/OverflowMenu/index.js b/packages/react/src/components/OverflowMenu/index.js
index 95ed830509b1..ee32ef10666b 100644
--- a/packages/react/src/components/OverflowMenu/index.js
+++ b/packages/react/src/components/OverflowMenu/index.js
@@ -5,4 +5,19 @@
* LICENSE file in the root directory of this source tree.
*/
-export default from './OverflowMenu';
+import React from 'react';
+
+import { default as OverflowMenuNext } from './next/OverflowMenu';
+import { default as OverflowMenuClassic } from './OverflowMenu';
+
+import { useFeatureFlag } from '../FeatureFlags';
+
+const OverflowMenu = React.forwardRef(function OverflowMenu(props, ref) {
+ const enabled = useFeatureFlag('enable-v11-release');
+ if (enabled) {
+ return ;
+ }
+ return ;
+});
+
+export default OverflowMenu;
diff --git a/packages/react/src/components/OverflowMenu/next/OverflowMenu-story.js b/packages/react/src/components/OverflowMenu/next/OverflowMenu-story.js
new file mode 100644
index 000000000000..718784081371
--- /dev/null
+++ b/packages/react/src/components/OverflowMenu/next/OverflowMenu-story.js
@@ -0,0 +1,75 @@
+/**
+ * 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 { ArrowsVertical16 } from '@carbon/icons-react';
+
+import Menu from '../../Menu';
+import { StoryFrame, buildMenu } from '../../Menu/_storybook-utils';
+
+import OverflowMenu from './OverflowMenu';
+
+export default {
+ title: 'Experimental/unstable_Menu/OverflowMenu',
+ parameters: {
+ component: Menu,
+ },
+};
+
+const Story = (items, props = {}) => (
+
+ {buildMenu(items)}
+
+);
+
+export const _OverflowMenu = () =>
+ Story([
+ { type: 'item', label: 'Stop app' },
+ { type: 'item', label: 'Restart app' },
+ { type: 'item', label: 'Rename app' },
+ { type: 'item', label: 'Edit routes and access' },
+ { type: 'divider' },
+ { type: 'item', label: 'Delete app', kind: 'danger' },
+ ]);
+
+export const CustomIcon = () =>
+ Story(
+ [
+ {
+ type: 'radiogroup',
+ label: 'Sort by',
+ items: ['Name', 'Date created', 'Date last modified', 'Size'],
+ initialSelectedItem: 'Date created',
+ },
+ { type: 'divider' },
+ {
+ type: 'radiogroup',
+ label: 'Sort order',
+ items: ['Ascending', 'Descending'],
+ initialSelectedItem: 'Descending',
+ },
+ ],
+ {
+ renderIcon: ArrowsVertical16,
+ }
+ );
+
+export const Nested = () =>
+ Story([
+ { type: 'item', label: 'Level 1' },
+ { type: 'item', label: 'Level 1' },
+ {
+ type: 'item',
+ label: 'Level 1',
+ children: [
+ { type: 'item', label: 'Level 2' },
+ { type: 'item', label: 'Level 2' },
+ { type: 'item', label: 'Level 2' },
+ ],
+ },
+ { type: 'item', label: 'Level 1' },
+ ]);
diff --git a/packages/react/src/components/OverflowMenu/next/OverflowMenu.js b/packages/react/src/components/OverflowMenu/next/OverflowMenu.js
new file mode 100644
index 000000000000..e4485b705752
--- /dev/null
+++ b/packages/react/src/components/OverflowMenu/next/OverflowMenu.js
@@ -0,0 +1,93 @@
+/**
+ * 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 } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { settings } from 'carbon-components';
+import { OverflowMenuVertical16 } from '@carbon/icons-react';
+import Menu from '../../Menu';
+
+const { prefix } = settings;
+
+function OverflowMenu({
+ children,
+ renderIcon: IconElement = OverflowMenuVertical16,
+ ...rest
+}) {
+ const [open, setOpen] = useState(false);
+ const [position, setPosition] = useState([
+ [0, 0],
+ [0, 0],
+ ]);
+ const triggerRef = useRef(null);
+
+ function openMenu() {
+ if (triggerRef.current) {
+ const {
+ left,
+ top,
+ right,
+ bottom,
+ } = triggerRef.current.getBoundingClientRect();
+ setPosition([
+ [left, right],
+ [top, bottom],
+ ]);
+ }
+
+ setOpen(true);
+ }
+
+ function closeMenu() {
+ setOpen(false);
+ }
+
+ function handleClick() {
+ if (open) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ }
+
+ const triggerClasses = classNames(`${prefix}--overflow-menu`, {
+ [`${prefix}--overflow-menu--open`]: open,
+ });
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+OverflowMenu.propTypes = {
+ /**
+ * Specify the children of the OverflowMenu
+ */
+ children: PropTypes.node,
+
+ /**
+ * Function called to override icon rendering.
+ */
+ renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+};
+
+export default OverflowMenu;
diff --git a/packages/react/src/index.js b/packages/react/src/index.js
index 946a17fd5adf..07431f89a06d 100644
--- a/packages/react/src/index.js
+++ b/packages/react/src/index.js
@@ -213,14 +213,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 unstable_Menu, {
+ MenuDivider as unstable_MenuDivider,
+ MenuGroup as unstable_MenuGroup,
+ MenuItem as unstable_MenuItem,
+ MenuRadioGroup as unstable_MenuRadioGroup,
+ MenuSelectableItem as unstable_MenuSelectableItem,
+} from './components/Menu';
+export { useContextMenu as unstable_useContextMenu } from './components/ContextMenu';
export {
Heading as unstable_Heading,
Section as unstable_Section,