From 39393f7e6585f49c838f8979a4531e5388dad7a2 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Wed, 19 May 2021 10:05:47 +0200 Subject: [PATCH] feat: generalize menu component (#8169) * refactor: rename ContextMenu into Menu * refactor: extract storybook utils for building menus * feat: add experimental menu-based OverflowMenu component * chore: merge updates from context-menu * feat(menu): add support for target boundaries * fix(menu): fix overflowmenu-v2 not closing on first outsideclick * fix(context-menu): remove merge relics * fix: update public api snapshot to reflect menu changes * refactor: use feature flag for overflowmenu-next * fix: update data table snapshots * docs: use danger kind in context menu story * fix(overflow-menu): update feature flag name in export Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../_context-menu.scss => menu/_menu.scss} | 49 ++-- .../components/src/globals/scss/styles.scss | 54 ++-- packages/react/.storybook/styles.scss | 54 ++-- .../__snapshots__/PublicAPI-test.js.snap | 59 ++++- packages/react/src/__tests__/index-test.js | 12 +- .../ContextMenu/ContextMenu-story.js | 96 ++----- .../ContextMenu/ContextMenu-test.js | 171 ------------- .../src/components/ContextMenu/_utils.js | 90 ------- .../react/src/components/ContextMenu/index.js | 23 +- .../components/ContextMenu/useContextMenu.js | 32 ++- .../__snapshots__/DataTable-test.js.snap | 236 ++++++++++-------- .../TableToolbarMenu-test.js.snap | 112 +++++---- .../react/src/components/Menu/Menu-test.js | 143 +++++++++++ .../ContextMenu.js => Menu/Menu.js} | 210 ++++++++-------- .../MenuDivider.js} | 6 +- .../ContextMenuGroup.js => Menu/MenuGroup.js} | 10 +- .../ContextMenuItem.js => Menu/MenuItem.js} | 31 +-- .../MenuOption.js} | 65 +++-- .../MenuRadioGroup.js} | 16 +- .../MenuRadioGroupOptions.js} | 10 +- .../MenuSelectableItem.js} | 16 +- .../src/components/Menu/_storybook-utils.js | 75 ++++++ packages/react/src/components/Menu/_utils.js | 180 +++++++++++++ packages/react/src/components/Menu/index.js | 22 ++ .../OverflowMenu/OverflowMenu-story.js | 5 +- .../src/components/OverflowMenu/index.js | 17 +- .../OverflowMenu/next/OverflowMenu-story.js | 75 ++++++ .../OverflowMenu/next/OverflowMenu.js | 93 +++++++ packages/react/src/index.js | 16 +- 29 files changed, 1162 insertions(+), 816 deletions(-) rename packages/components/src/components/{context-menu/_context-menu.scss => menu/_menu.scss} (66%) delete mode 100644 packages/react/src/components/ContextMenu/ContextMenu-test.js delete mode 100644 packages/react/src/components/ContextMenu/_utils.js create mode 100644 packages/react/src/components/Menu/Menu-test.js rename packages/react/src/components/{ContextMenu/ContextMenu.js => Menu/Menu.js} (61%) rename packages/react/src/components/{ContextMenu/ContextMenuDivider.js => Menu/MenuDivider.js} (65%) rename packages/react/src/components/{ContextMenu/ContextMenuGroup.js => Menu/MenuGroup.js} (68%) rename packages/react/src/components/{ContextMenu/ContextMenuItem.js => Menu/MenuItem.js} (53%) rename packages/react/src/components/{ContextMenu/ContextMenuOption.js => Menu/MenuOption.js} (77%) rename packages/react/src/components/{ContextMenu/ContextMenuRadioGroup.js => Menu/MenuRadioGroup.js} (71%) rename packages/react/src/components/{ContextMenu/ContextMenuRadioGroupOptions.js => Menu/MenuRadioGroupOptions.js} (85%) rename packages/react/src/components/{ContextMenu/ContextMenuSelectableItem.js => Menu/MenuSelectableItem.js} (74%) create mode 100644 packages/react/src/components/Menu/_storybook-utils.js create mode 100644 packages/react/src/components/Menu/_utils.js create mode 100644 packages/react/src/components/Menu/index.js create mode 100644 packages/react/src/components/OverflowMenu/next/OverflowMenu-story.js create mode 100644 packages/react/src/components/OverflowMenu/next/OverflowMenu.js 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)} -
+ + + {renderedItems} + ); }; 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" > - - - - - + + + + + Settings + + + + + + + + - - + + + + + Settings + + + + + + + + - - + + + + Add + + + + + + + + `; 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 ( + + {item.children && item.children.map(renderItem)} + + ); + 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 ( + <> + + + {children} + + + ); +} + +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,