From 63eab63dd8956ec9888a38d974c2417ed75eb6ad Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Tue, 14 Feb 2023 14:50:42 +0100 Subject: [PATCH 01/11] refactor(menu): use react.context and simplify logic --- .../__snapshots__/PublicAPI-test.js.snap | 180 +++---- packages/react/src/__tests__/index-test.js | 8 +- .../ContextMenu/ContextMenu.stories.js | 206 ++------ .../react/src/components/Menu/Menu-test.js | 24 +- packages/react/src/components/Menu/Menu.js | 471 +++++++----------- .../react/src/components/Menu/Menu.stories.js | 88 ++++ .../react/src/components/Menu/MenuContext.js | 36 ++ .../react/src/components/Menu/MenuDivider.js | 16 - .../react/src/components/Menu/MenuGroup.js | 33 -- .../react/src/components/Menu/MenuItem.js | 420 +++++++++++++++- .../react/src/components/Menu/MenuOption.js | 263 ---------- .../src/components/Menu/MenuRadioGroup.js | 52 -- .../components/Menu/MenuRadioGroupOptions.js | 63 --- .../src/components/Menu/MenuSelectableItem.js | 50 -- .../src/components/Menu/_storybook-utils.js | 76 --- packages/react/src/components/Menu/_utils.js | 199 -------- packages/react/src/components/Menu/index.js | 33 +- .../OverflowMenuV2/OverflowMenuv2.stories.js | 103 ++-- .../src/components/OverflowMenuV2/index.js | 24 +- packages/react/src/index.js | 11 +- packages/react/src/index.ts | 8 +- .../styles/scss/components/menu/_menu.scss | 131 +++-- 22 files changed, 985 insertions(+), 1510 deletions(-) create mode 100644 packages/react/src/components/Menu/Menu.stories.js create mode 100644 packages/react/src/components/Menu/MenuContext.js delete mode 100644 packages/react/src/components/Menu/MenuDivider.js delete mode 100644 packages/react/src/components/Menu/MenuGroup.js delete mode 100644 packages/react/src/components/Menu/MenuOption.js delete mode 100644 packages/react/src/components/Menu/MenuRadioGroup.js delete mode 100644 packages/react/src/components/Menu/MenuRadioGroupOptions.js delete mode 100644 packages/react/src/components/Menu/MenuSelectableItem.js delete mode 100644 packages/react/src/components/Menu/_storybook-utils.js delete mode 100644 packages/react/src/components/Menu/_utils.js diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 3af9a01f41dd..766e71d70c49 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -9056,81 +9056,7 @@ Map { }, }, "unstable_Menu" => Object { - "MenuDivider": Object {}, - "MenuGroup": Object { - "propTypes": Object { - "children": Object { - "type": "node", - }, - "label": Object { - "isRequired": true, - "type": "node", - }, - }, - }, - "MenuItem": Object { - "propTypes": Object { - "children": Object { - "type": "node", - }, - "disabled": Object { - "type": "bool", - }, - "kind": Object { - "args": Array [ - Array [ - "default", - "danger", - ], - ], - "type": "oneOf", - }, - "label": Object { - "isRequired": true, - "type": "node", - }, - "shortcut": Object { - "type": "node", - }, - }, - }, - "MenuRadioGroup": Object { - "propTypes": Object { - "initialSelectedItem": Object { - "type": "string", - }, - "items": Object { - "args": Array [ - Object { - "type": "string", - }, - ], - "isRequired": true, - "type": "arrayOf", - }, - "label": Object { - "isRequired": true, - "type": "string", - }, - "onChange": Object { - "type": "func", - }, - }, - }, - "MenuSelectableItem": Object { - "propTypes": Object { - "initialChecked": Object { - "type": "bool", - }, - "label": Object { - "isRequired": true, - "type": "node", - }, - "onChange": Object { - "type": "func", - }, - }, - }, + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "children": Object { "type": "node", @@ -9138,12 +9064,9 @@ Map { "className": Object { "type": "string", }, - "id": Object { + "label": Object { "type": "string", }, - "level": Object { - "type": "number", - }, "onClose": Object { "type": "func", }, @@ -9153,6 +9076,7 @@ Map { "size": Object { "args": Array [ Array [ + "xs", "sm", "md", "lg", @@ -9200,24 +9124,17 @@ Map { "type": "oneOfType", }, }, - }, - "unstable_MenuDivider" => Object {}, - "unstable_MenuGroup" => Object { - "propTypes": Object { - "children": Object { - "type": "node", - }, - "label": Object { - "isRequired": true, - "type": "node", - }, - }, + "render": [Function], }, "unstable_MenuItem" => Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "children": Object { "type": "node", }, + "className": Object { + "type": "string", + }, "disabled": Object { "type": "bool", }, @@ -9232,26 +9149,69 @@ Map { }, "label": Object { "isRequired": true, - "type": "node", + "type": "string", + }, + "onClick": Object { + "type": "func", + }, + "renderIcon": Object { + "args": Array [ + Array [ + Object { + "type": "func", + }, + Object { + "type": "object", + }, + ], + ], + "type": "oneOfType", }, "shortcut": Object { + "type": "string", + }, + }, + "render": [Function], + }, + "unstable_MenuItemDivider" => Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "className": Object { + "type": "string", + }, + }, + "render": [Function], + }, + "unstable_MenuItemGroup" => Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "children": Object { "type": "node", }, + "className": Object { + "type": "string", + }, + "label": Object { + "isRequired": true, + "type": "string", + }, }, + "render": [Function], }, - "unstable_MenuRadioGroup" => Object { + "unstable_MenuItemRadioGroup" => Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { - "initialSelectedItem": Object { + "className": Object { "type": "string", }, + "defaultSelectedItem": Object { + "type": "any", + }, + "itemToString": Object { + "type": "func", + }, "items": Object { - "args": Array [ - Object { - "type": "string", - }, - ], - "isRequired": true, - "type": "arrayOf", + "type": "array", }, "label": Object { "isRequired": true, @@ -9260,21 +9220,33 @@ Map { "onChange": Object { "type": "func", }, + "selectedItem": Object { + "type": "any", + }, }, + "render": [Function], }, - "unstable_MenuSelectableItem" => Object { + "unstable_MenuItemSelectable" => Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { - "initialChecked": Object { + "className": Object { + "type": "string", + }, + "defaultSelected": Object { "type": "bool", }, "label": Object { "isRequired": true, - "type": "node", + "type": "string", }, "onChange": Object { "type": "func", }, + "selected": Object { + "type": "bool", + }, }, + "render": [Function], }, "unstable_OverflowMenuV2" => Object { "propTypes": Object { diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index f8022604cb8a..3868e1c6115d 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -224,11 +224,11 @@ describe('Carbon Components React', () => { "unstable_FeatureFlags", "unstable_LayoutDirection", "unstable_Menu", - "unstable_MenuDivider", - "unstable_MenuGroup", "unstable_MenuItem", - "unstable_MenuRadioGroup", - "unstable_MenuSelectableItem", + "unstable_MenuItemDivider", + "unstable_MenuItemGroup", + "unstable_MenuItemRadioGroup", + "unstable_MenuItemSelectable", "unstable_OverflowMenuV2", "unstable_PageSelector", "unstable_Pagination", diff --git a/packages/react/src/components/ContextMenu/ContextMenu.stories.js b/packages/react/src/components/ContextMenu/ContextMenu.stories.js index d83bd233277b..b7ee4ab894be 100644 --- a/packages/react/src/components/ContextMenu/ContextMenu.stories.js +++ b/packages/react/src/components/ContextMenu/ContextMenu.stories.js @@ -6,33 +6,19 @@ */ import React from 'react'; -import { InlineNotification } from '../Notification'; +import { action } from '@storybook/addon-actions'; + import CodeSnippet from '../CodeSnippet'; import UnorderedList from '../UnorderedList'; import ListItem from '../ListItem'; -import Menu, { - MenuDivider, - MenuGroup, - MenuItem, - MenuRadioGroup, - MenuSelectableItem, -} from '../Menu'; - -import { StoryFrame, buildMenu } from '../Menu/_storybook-utils'; +import { Menu, MenuItem, MenuItemDivider, MenuItemRadioGroup } from '../Menu'; -import { useContextMenu } from './index'; +import { useContextMenu } from './'; export default { - title: 'Experimental/unstable_Menu/ContextMenu', - component: Menu, - subcomponents: { - MenuItem, - MenuGroup, - MenuDivider, - MenuSelectableItem, - MenuRadioGroup, - }, + title: 'Experimental/unstable__useContextMenu', + component: useContextMenu, }; const Text = () => ( @@ -48,185 +34,57 @@ const Text = () => ( {`useContextMenu()`} hook does not set and can be configured by the user are: - size - onClose className - id + label + size target

The - {``} and + {``} and {``} components accept children items for nested menus, although the{' '} {``} component can also be used as a stand alone item. The other types of menu items ({' '} - {``}, - {``}, - {``}) do not + {``}, + {``}, + {``}) do not accept children. The{' '} - {``} accepts an - array of items to display as a group of single choice selection. + {``} accepts + an array of items to display as a group of single choice selection.

); -export const _ContextMenu = () => { - const menuProps = useContextMenu(); - - const items = [ - { - type: 'item', - label: 'Share with', - children: [ - { - type: 'radiogroup', - label: 'Share with', - items: ['None', 'Product team', 'Organization', 'Company'], - initialSelectedItem: 'Product team', - }, - ], - }, - { type: 'divider' }, - { type: 'item', label: 'Cut', shortcut: '⌘X' }, - { type: 'item', label: 'Copy', shortcut: '⌘C' }, - { type: 'item', label: 'Copy path', shortcut: '⌥⌘C' }, - { type: 'item', label: 'Paste', shortcut: '⌘V', disabled: true }, - { type: 'item', label: 'Duplicate' }, - { type: 'divider' }, - { type: 'selectable', label: 'Publish', initialChecked: true }, - { type: 'divider' }, - { type: 'item', label: 'Rename', shortcut: '↩︎' }, - { type: 'item', label: 'Delete', shortcut: '⌘⌫', kind: 'danger' }, - ]; - - const renderedItems = buildMenu(items); +export const _useContextMenu = () => { + const onClick = action('onClick (MenuItem)'); + const onChange = action('onClick (MenuItemRadioGroup)'); - return ( - - - Context Menu -

- Right-click anywhere on this page to access an example implementation - of this component. -

-
- {renderedItems} -
- ); -}; - -export const _MultipleGroups = () => { const menuProps = useContextMenu(); - const items = [ - { - type: 'group', - label: 'Font style', - children: [ - { type: 'selectable', label: 'Bold' }, - { type: 'selectable', label: 'Italic' }, - ], - }, - { type: 'divider' }, - { - type: 'radiogroup', - label: 'Text color', - items: ['Black', 'Blue', 'Red', 'Green'], - initialSelectedItem: 'Black', - }, - { type: 'divider' }, - { - type: 'radiogroup', - label: 'Text decoration', - items: ['None', 'Overline', 'Line-through', 'Underline'], - initialSelectedItem: 'None', - }, - ]; - - const renderedItems = buildMenu(items); - return ( - - - Context Menu -

- Right-click anywhere on this page to access an example implementation - of this component. -

-
- {renderedItems} -
- ); -}; - -export const _Playground = (args) => { - const props = useContextMenu(); - return ( -
+ <> - + - - - - - - - - - - - - - - - + + + + + + + + + -
+ ); }; - -_Playground.argTypes = { - size: { - control: { type: 'select' }, - options: ['sm', 'md', 'lg'], - }, - children: { - control: false, - }, - className: { - control: false, - }, - id: { - control: false, - }, - level: { - control: false, - }, - open: { - control: false, - }, - onClose: { - control: false, - }, - target: { - control: false, - }, - x: { - control: false, - }, - y: { - control: false, - }, -}; diff --git a/packages/react/src/components/Menu/Menu-test.js b/packages/react/src/components/Menu/Menu-test.js index 43974f5e89d2..0a5b3aa1cdb6 100644 --- a/packages/react/src/components/Menu/Menu-test.js +++ b/packages/react/src/components/Menu/Menu-test.js @@ -6,7 +6,7 @@ */ import React from 'react'; -import Menu, { MenuItem } from '../Menu'; +import { Menu, MenuItem } from './'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -86,7 +86,11 @@ describe('Menu', () => { describe('MenuItem', () => { describe('renders as expected', () => { it('should be disabled', () => { - render(); + render( + + + + ); expect(screen.getByRole('menuitem')).toHaveAttribute( 'aria-disabled', @@ -94,20 +98,28 @@ describe('MenuItem', () => { ); expect(screen.getByRole('menuitem')).toHaveClass( - 'cds--menu-option--disabled' + 'cds--menu-item--disabled' ); }); it('should change kind based on prop', () => { - render(); + render( + + + + ); expect(screen.getByRole('menuitem')).toHaveClass( - 'cds--menu-option--danger' + 'cds--menu-item--danger' ); }); it('should render label', () => { - render(); + render( + + + + ); expect(screen.getByText('item')).toBeInTheDocument(); }); diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index d9f694007b20..f29d13d4a42a 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -1,360 +1,275 @@ /** - * Copyright IBM Corp. 2020 + * Copyright IBM Corp. 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import React, { useEffect, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; -import classnames from 'classnames'; +import cx from 'classnames'; import PropTypes from 'prop-types'; +import React, { + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + import { keys, match } from '../../internal/keyboard'; +import { useMergedRefs } from '../../internal/useMergedRefs'; import { usePrefix } from '../../internal/usePrefix'; -import { - capWithinRange, - clickedElementHasSubnodes, - focusNode as focusNodeUtil, - getNextNode, - getParentMenu, - getParentNode, - getPosition, - getValidNodes, - resetFocus, -} from './_utils'; - -import MenuGroup from './MenuGroup'; -import MenuRadioGroup from './MenuRadioGroup'; -import MenuRadioGroupOptions from './MenuRadioGroupOptions'; -import MenuSelectableItem from './MenuSelectableItem'; - -const margin = 16; // distance to keep to body edges, in px -const defaultSize = 'sm'; - -const Menu = function Menu({ - children, - className, - id, - level = 1, - open, - size = defaultSize, - target = document.body, - x = 0, - y = 0, - onClose = () => {}, - ...rest -}) { - const rootRef = useRef(null); - const [direction, setDirection] = useState(1); // 1 = to right, -1 = to left - const [position, setPosition] = useState([x, y]); - const isRootMenu = level === 1; - const focusReturn = useRef(null); +import { MenuContext, menuReducer } from './MenuContext'; + +const spacing = 8; // distance to keep to window edges, in px + +const Menu = React.forwardRef(function Menu( + { + children, + className, + label, + onClose, + open, + size = 'sm', + target = document.body, + x = 0, + y = 0, + ...rest + }, + forwardRef +) { const prefix = usePrefix(); - function returnFocus() { - if (focusReturn.current) { - focusReturn.current.focus(); - } - } - - function close(eventType) { - const isKeyboardEvent = /^key/.test(eventType); + const focusReturn = useRef(null); - if (isKeyboardEvent) { - window.addEventListener('keyup', returnFocus, { once: true }); - } else { - window.addEventListener('mouseup', returnFocus, { once: true }); - } + const context = useContext(MenuContext); + const isRoot = !context.dispatch; + const menuSize = isRoot ? size : context.state.size; - onClose(); - } + const [childState, childDispatch] = useReducer(menuReducer, { + ...context.state, + size, + requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot, + }); + const childContext = useMemo(() => { + return { + state: childState, + dispatch: childDispatch, + }; + }, [childState, childDispatch]); + + const menu = useRef(); + const ref = useMergedRefs([forwardRef, menu]); + + const [position, setPosition] = useState([-1, -1]); + const focusableItems = childContext.state.items.filter( + (item) => !item.disabled + ); - function getContainerBoundaries() { - const { clientWidth: bodyWidth, clientHeight: bodyHeight } = document.body; - return [margin, margin, bodyWidth - margin, bodyHeight - margin]; + function handleOpen() { + if (menu.current) { + focusReturn.current = document.activeElement; + setPosition(calculatePosition()); + menu.current.focus(); + } } - 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 - )?.getBoundingClientRect(); - - targetBoundaries[2] -= parentWidth; + function handleClose() { + if (focusReturn.current) { + focusReturn.current.focus(); } - 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); - focusNodeUtil(node); + if (onClose) { + onClose(); } } - function handleKeyDown(event) { - if (match(event, keys.Tab)) { - event.preventDefault(); - close(event.type); - } + function handleKeyDown(e) { + e.stopPropagation(); - if ( - event.target.tagName === 'LI' && - (match(event, keys.Enter) || match(event, keys.Space)) - ) { - handleClick(event); - } else { - event.stopPropagation(); - } + const currentItem = focusableItems.findIndex((item) => + item.ref.current.contains(document.activeElement) + ); + let indexToFocus = currentItem; + // if the user presses escape or this is a submenu + // and the user presses ArrowLeft, close it if ( - match(event, keys.Escape) || - (!isRootMenu && match(event, keys.ArrowLeft)) + (match(e, keys.Escape) || (!isRoot && match(e, keys.ArrowLeft))) && + onClose ) { - close(event.type); - } - - let nodeToFocus; - - if (event.target.tagName === 'LI') { - const currentNode = event.target; - - if (match(event, keys.ArrowUp)) { - nodeToFocus = getNextNode(currentNode, -1); - } else if (match(event, keys.ArrowDown)) { - nodeToFocus = getNextNode(currentNode, 1); - } else if (match(event, keys.ArrowLeft)) { - nodeToFocus = getParentNode(currentNode); + handleClose(); + } else { + // if currentItem is -1, the menu itself is focused. + // in this case, the arrow keys define the first item + // to be focused. + if (match(e, keys.ArrowUp)) { + indexToFocus = + currentItem === -1 ? focusableItems.length - 1 : indexToFocus - 1; } - } else if (event.target.tagName === 'UL') { - const validNodes = getValidNodes(event.target); - - if (validNodes.length > 0 && match(event, keys.ArrowUp)) { - nodeToFocus = validNodes[validNodes.length - 1]; - } else if (validNodes.length > 0 && match(event, keys.ArrowDown)) { - nodeToFocus = validNodes[0]; + if (match(e, keys.ArrowDown)) { + indexToFocus = currentItem === -1 ? 0 : indexToFocus + 1; } - } - focusNode(nodeToFocus); + if (indexToFocus < 0) { + indexToFocus = 0; + } + if (indexToFocus >= focusableItems.length) { + indexToFocus = focusableItems.length - 1; + } - if (rest.onKeyDown) { - rest.onKeyDown(event); + if (indexToFocus !== currentItem) { + const nodeToFocus = focusableItems[indexToFocus]; + nodeToFocus.ref.current.focus(); + } } } - function handleClick(event) { - if (!clickedElementHasSubnodes(event) && event.target.tagName !== 'UL') { - close(event.type); - } else { - event.stopPropagation(); + function handleBlur(e) { + if (onClose && isRoot && !menu.current.contains(e.relatedTarget)) { + handleClose(); } } - function getCorrectedPosition(preferredDirection) { - const elementRect = rootRef.current?.getBoundingClientRect(); - const elementDimensions = [elementRect.width, elementRect.height]; - const targetBoundaries = getTargetBoundaries(); - const containerBoundaries = getContainerBoundaries(); - const { position: correctedPosition, direction: correctedDirection } = - getPosition( - elementDimensions, - targetBoundaries, - containerBoundaries, - preferredDirection, - isRootMenu, - rootRef.current - ); - - setDirection(correctedDirection); - - return correctedPosition; + function fitValue(range, axis) { + const { width, height } = menu.current.getBoundingClientRect(); + const alignment = isRoot ? 'vertical' : 'horizontal'; + + const axes = { + x: { + max: window.innerWidth, + size: width, + anchor: alignment === 'horizontal' ? range[1] : range[0], + reversedAnchor: alignment === 'horizontal' ? range[0] : range[1], + offset: 0, + }, + y: { + max: window.innerHeight, + size: height, + anchor: alignment === 'horizontal' ? range[0] : range[1], + reversedAnchor: alignment === 'horizontal' ? range[1] : range[0], + offset: isRoot ? 0 : 4, // top padding in menu, used to align the menu items + }, + }; + + const { max, size, anchor, reversedAnchor, offset } = axes[axis]; + + // get values for different scenarios, set to false if they don't work + const options = [ + // towards max (preferred) + max - spacing - size - anchor >= 0 ? anchor - offset : false, + + // towards min / reversed (first fallback) + reversedAnchor - size >= 0 ? reversedAnchor - size + offset : false, + + // align at max (second fallback) + max - spacing - size, + ]; + + const bestOption = options.find((option) => option !== false); + + return bestOption >= spacing ? bestOption : spacing; } - function handleBlur(event) { - if (isRootMenu && !rootRef.current?.contains(event.relatedTarget)) { - close(event.type); + function calculatePosition() { + if (menu.current) { + const ranges = { + x: typeof x === 'object' && x.length === 2 ? x : [x, x], + y: typeof y === 'object' && y.length === 2 ? y : [y, y], + }; + + return [fitValue(ranges.x, 'x'), fitValue(ranges.y, 'y')]; } + + return [-1, -1]; } useEffect(() => { if (open) { - focusReturn.current = document.activeElement; - let localDirection = 1; - - if (isRootMenu) { - rootRef.current?.focus(); - } else { - const parentMenu = getParentMenu(rootRef.current); - - if (parentMenu) { - localDirection = Number(parentMenu.dataset.direction); - } - } - - const correctedPosition = getCorrectedPosition(localDirection); - setPosition(correctedPosition); - } else { - setPosition([0, 0]); + handleOpen(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, x, y]); + }, [open]); - const someNodesHaveIcons = React.Children.toArray(children).some( - (node) => node.type === MenuSelectableItem || node.type === MenuRadioGroup - ); - - const options = React.Children.map(children, (node) => { - if (React.isValidElement(node)) { - return React.cloneElement(node, { - indented: someNodesHaveIcons, - level: level, - }); - } - }); - - const classes = classnames( + const classNames = cx( + className, `${prefix}--menu`, + `${prefix}--menu--${menuSize}`, { + // --open sets visibility and --shown sets opacity. + // visibility is needed for focusing elements. + // opacity is only set once the position has been set correctly + // to avoid a flicker effect when opening. [`${prefix}--menu--open`]: open, - [`${prefix}--menu--invisible`]: - open && position[0] === 0 && position[1] === 0, - [`${prefix}--menu--root`]: isRootMenu, - }, - size !== defaultSize && `${prefix}--menu--${size}`, - className + [`${prefix}--menu--shown`]: position[0] >= 0 && position[1] >= 0, + [`${prefix}--menu--with-icons`]: childContext.state.hasIcons, + } ); - const ulAttributes = { - ...rest, - id, - ref: rootRef, - className: classes, - onKeyDown: handleKeyDown, - onClick: handleClick, - onBlur: handleBlur, - role: 'menu', - tabIndex: -1, - 'data-direction': direction, - 'data-level': level, - style: { - left: `${position[0]}px`, - top: `${position[1]}px`, - }, - }; - - let childrenToRender = options; - - // if the only child is a radiogroup, don't render it as radiogroup component, but - // only the items to prevent duplicate markup - if (options && options.length === 1 && options[0].type === MenuRadioGroup) { - const radioGroupProps = options[0].props; - - ulAttributes['aria-label'] = radioGroupProps.label; - childrenToRender = ( - - ); - } - - // if the only child is a generic group, don't render it as group component, but - // only the children to prevent duplicate markup - if (options && options.length === 1 && options[0].type === MenuGroup) { - const groupProps = options[0].props; - - ulAttributes['aria-label'] = groupProps.label; - childrenToRender = React.Children.toArray(options[0].props.children); - } - - const menu =
    {childrenToRender}
; + const rendered = ( + +
    + {children} +
+
+ ); - return isRootMenu - ? (open && ReactDOM.createPortal(menu, target)) || null - : menu; -}; + return isRoot ? (open && createPortal(rendered, target)) || null : rendered; +}); Menu.propTypes = { /** - * Specify the children of the Menu + * A collection of MenuItems to be rendered within this Menu. */ children: PropTypes.node, /** - * Specify a custom className to apply to the ul node + * Additional CSS class names. */ className: PropTypes.string, /** - * Define an ID for this menu - */ - id: PropTypes.string, - - /** - * Internal: keeps track of the nesting level of the menu + * A label describing the Menu. */ - level: PropTypes.number, + label: PropTypes.string, /** - * Function called when the menu is closed + * Provide an optional function to be called when the Menu should be closed. */ onClose: PropTypes.func, /** - * Specify whether the Menu is currently open + * Whether the Menu is open or not. */ open: PropTypes.bool, /** - * Specify the size of the menu, from a list of available sizes. + * Specify the size of the Menu. */ - size: PropTypes.oneOf(['sm', 'md', 'lg']), + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), /** - * Optionally pass an element the Menu should be appended to as a child. Defaults to document.body. + * Specify a DOM node where the Menu should be rendered in. Defaults to document.body. */ target: PropTypes.object, /** - * Specify the x position where this menu is rendered + * Specify the x position of the Menu. Either pass a single number or an array with two numbers describing your activator's boundaries ([x1, x2]) */ x: PropTypes.oneOfType([ PropTypes.number, @@ -362,7 +277,7 @@ Menu.propTypes = { ]), /** - * Specify the y position where this menu is rendered + * Specify the y position of the Menu. Either pass a single number or an array with two numbers describing your activator's boundaries ([y1, y2]) */ y: PropTypes.oneOfType([ PropTypes.number, @@ -370,4 +285,4 @@ Menu.propTypes = { ]), }; -export default Menu; +export { Menu }; diff --git a/packages/react/src/components/Menu/Menu.stories.js b/packages/react/src/components/Menu/Menu.stories.js new file mode 100644 index 000000000000..a72be4eaaf77 --- /dev/null +++ b/packages/react/src/components/Menu/Menu.stories.js @@ -0,0 +1,88 @@ +/** + * Copyright IBM Corp. 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import { + Menu, + MenuItem, + MenuItemSelectable, + MenuItemGroup, + MenuItemRadioGroup, + MenuItemDivider, +} from './'; + +export default { + title: 'Experimental/unstable__Menu', + component: Menu, + subcomponents: { + MenuItem, + MenuItemSelectable, + MenuItemGroup, + MenuItemRadioGroup, + MenuItemDivider, + }, +}; + +export const Playground = (args) => { + const itemOnClick = action('onClick (MenuItem)'); + const selectableOnChange = action('onChange (MenuItemSelectable)'); + const radioOnChange = action('onChange (MenuItemRadioGroup)'); + + const target = document.getElementById('root'); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +Playground.argTypes = { + open: { + defaultValue: true, + }, +}; + +Playground.args = { + onClose: action('onClose'), +}; diff --git a/packages/react/src/components/Menu/MenuContext.js b/packages/react/src/components/Menu/MenuContext.js new file mode 100644 index 000000000000..78cc0d62e9a4 --- /dev/null +++ b/packages/react/src/components/Menu/MenuContext.js @@ -0,0 +1,36 @@ +/** + * Copyright IBM Corp. 2023 + * + * 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'; + +const menuDefaultState = { + hasIcons: false, + size: null, + items: [], + requestCloseRoot: () => {}, +}; + +function menuReducer(state, action) { + switch (action.type) { + case 'enableIcons': + return { + ...state, + hasIcons: true, + }; + case 'registerItem': + return { + ...state, + items: [...state.items, action.payload], + }; + } +} + +const MenuContext = React.createContext({ + state: menuDefaultState, +}); + +export { MenuContext, menuReducer }; diff --git a/packages/react/src/components/Menu/MenuDivider.js b/packages/react/src/components/Menu/MenuDivider.js deleted file mode 100644 index c35787b2e36f..000000000000 --- a/packages/react/src/components/Menu/MenuDivider.js +++ /dev/null @@ -1,16 +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 { usePrefix } from '../../internal/usePrefix'; - -function MenuDivider() { - const prefix = usePrefix(); - return
  • ; -} - -export default MenuDivider; diff --git a/packages/react/src/components/Menu/MenuGroup.js b/packages/react/src/components/Menu/MenuGroup.js deleted file mode 100644 index db63ced7a36c..000000000000 --- a/packages/react/src/components/Menu/MenuGroup.js +++ /dev/null @@ -1,33 +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 PropTypes from 'prop-types'; - -function MenuGroup({ label, children }) { - return ( -
  • -
      - {children} -
    -
  • - ); -} - -MenuGroup.propTypes = { - /** - * Specify the children of the MenuGroup - */ - children: PropTypes.node, - - /** - * Rendered label for the MenuGroup - */ - label: PropTypes.node.isRequired, -}; - -export default MenuGroup; diff --git a/packages/react/src/components/Menu/MenuItem.js b/packages/react/src/components/Menu/MenuItem.js index 5e31651bcb62..ea1e822ed9cf 100644 --- a/packages/react/src/components/Menu/MenuItem.js +++ b/packages/react/src/components/Menu/MenuItem.js @@ -1,53 +1,433 @@ /** - * Copyright IBM Corp. 2020 + * Copyright IBM Corp. 2023 * * 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 cx from 'classnames'; import PropTypes from 'prop-types'; +import React, { useContext, useEffect, useRef, useState } from 'react'; -import MenuOption from './MenuOption'; +import { CaretRight, Checkmark } from '@carbon/react/icons'; +import { keys, match } from '../../internal/keyboard'; +import { useControllableState } from '../../internal/useControllableState'; +import { useMergedRefs } from '../../internal/useMergedRefs'; +import { usePrefix } from '../../internal/usePrefix'; + +import { Menu } from './Menu'; +import { MenuContext } from './MenuContext'; + +const hoverIntentDelay = 150; // in ms + +const MenuItem = React.forwardRef(function MenuItem( + { + children, + className, + disabled, + kind = 'default', + label, + onClick, + renderIcon: IconElement, + shortcut, + ...rest + }, + forwardRef +) { + const prefix = usePrefix(); + const context = useContext(MenuContext); + + const menuItem = useRef(); + const ref = useMergedRefs([forwardRef, menuItem]); + const [boundaries, setBoundaries] = useState({ x: -1, y: -1 }); + + const hasChildren = Boolean(children); + const [submenuOpen, setSubmenuOpen] = useState(false); + const hoverIntentTimeout = useRef(null); + + function registerItem() { + context.dispatch({ + type: 'registerItem', + payload: { + ref: menuItem, + disabled: Boolean(disabled), + }, + }); + } + + function openSubmenu() { + const { x, y, width, height } = menuItem.current.getBoundingClientRect(); + setBoundaries({ + x: [x, x + width], + y: [y, y + height], + }); + + setSubmenuOpen(true); + } + + function closeSubmenu() { + setSubmenuOpen(false); + setBoundaries({ x: -1, y: -1 }); + } + + function handleClick(e) { + if (hasChildren) { + openSubmenu(); + } else { + context.state.requestCloseRoot(); + + if (onClick) { + onClick(e); + } + } + } + + function handleMousEnter() { + hoverIntentTimeout.current = setTimeout(() => { + openSubmenu(); + }, hoverIntentDelay); + } + + function handleMouseLeave() { + clearTimeout(hoverIntentTimeout.current); + closeSubmenu(); + menuItem.current.focus(); + } + + function handleKeyDown(e) { + if (hasChildren && match(e, keys.ArrowRight)) { + openSubmenu(); + } + + if (match(e, keys.Enter) || match(e, keys.Space)) { + handleClick(e); + } + + if (rest.onKeyDown) { + rest.onKeyDown(e); + } + } + + const classNames = cx(className, `${prefix}--menu-item`, { + [`${prefix}--menu-item--disabled`]: disabled, + [`${prefix}--menu-item--${kind}`]: kind !== 'default', + }); + + // on first render, register this menuitem in the context's state + // (used for keyboard navigation) + useEffect(() => { + registerItem(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); -function MenuItem({ label, children, disabled, kind, shortcut, ...rest }) { return ( - - {children} - + ref={ref} + className={classNames} + tabIndex="-1" + aria-disabled={disabled} + aria-haspopup={hasChildren || null} + aria-expanded={hasChildren ? submenuOpen : null} + onClick={handleClick} + onMouseEnter={hasChildren ? handleMousEnter : null} + onMouseLeave={hasChildren ? handleMouseLeave : null} + onKeyDown={handleKeyDown}> +
    + {IconElement && } +
    +
    {label}
    + {shortcut && !hasChildren && ( +
    {shortcut}
    + )} + {hasChildren && ( +
    + +
    + )} + {hasChildren && ( + { + closeSubmenu(); + menuItem.current.focus(); + }} + x={boundaries.x} + y={boundaries.y}> + {children} + + )} + ); -} +}); MenuItem.propTypes = { /** - * Specify the children of the MenuItem + * Optionally provide another Menu to create a submenu. props.children can't be used to specify the content of the MenuItem itself. Use props.label instead. */ children: PropTypes.node, /** - * Specify whether this MenuItem is disabled + * Additional CSS class names. + */ + className: PropTypes.string, + + /** + * Specify whether the MenuItem is disabled or not. */ disabled: PropTypes.bool, /** - * Optional prop to specify the kind of the MenuItem + * Specify the kind of the MenuItem. */ kind: PropTypes.oneOf(['default', 'danger']), /** - * Rendered label for the MenuItem + * A required label titling the MenuItem. Will be rendered as its text content. + */ + label: PropTypes.string.isRequired, + + /** + * Provide an optional function to be called when the MenuItem is clicked. + */ + onClick: PropTypes.func, + + /** + * This prop is not intended for use. The only supported icons are Checkmarks to depict single- and multi-selects. This prop is used by MenuItemSelectable and MenuItemRadioGroup automatically. + */ + renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Provide a shortcut for the action of this MenuItem. Note that the component will only render it as a hint but not actually register the shortcut. + */ + shortcut: PropTypes.string, +}; + +const MenuItemSelectable = React.forwardRef(function MenuItemDivider( + { className, defaultSelected, label, onChange, selected, ...rest }, + forwardRef +) { + const prefix = usePrefix(); + const context = useContext(MenuContext); + + const [checked, setChecked] = useControllableState({ + value: selected, + onChange, + defaultValue: defaultSelected, + }); + + function handleClick(e) { + setChecked(!checked); + + if (onChange) { + onChange(e); + } + } + + useEffect(() => { + if (!context.state.hasIcons) { + context.dispatch({ type: 'enableIcons' }); + } + }, [context.state.hasIcons, context]); + + const classNames = cx(className, `${prefix}--menu-item-selectable--selected`); + + return ( + + ); +}); + +MenuItemSelectable.propTypes = { + /** + * Additional CSS class names. + */ + className: PropTypes.string, + + /** + * Specify whether the option should be selected by default. + */ + defaultSelected: PropTypes.bool, + + /** + * A required label titling this option. + */ + label: PropTypes.string.isRequired, + + /** + * Provide an optional function to be called when the selection state changes. + */ + onChange: PropTypes.func, + + /** + * Pass a bool to props.selected to control the state of this option. + */ + selected: PropTypes.bool, +}; + +const MenuItemGroup = React.forwardRef(function MenuItemGroup( + { children, className, label, ...rest }, + forwardRef +) { + const prefix = usePrefix(); + + const classNames = cx(className, `${prefix}--menu-item-group`); + + return ( +
  • +
      + {children} +
    +
  • + ); +}); + +MenuItemGroup.propTypes = { + /** + * A collection of MenuItems to be rendered within this group. + */ + children: PropTypes.node, + + /** + * Additional CSS class names. + */ + className: PropTypes.string, + + /** + * A required label titling this group. + */ + label: PropTypes.string.isRequired, +}; + +const MenuItemRadioGroup = React.forwardRef(function MenuItemRadioGroup( + { + className, + defaultSelectedItem, + items, + itemToString = (item) => item.toString(), + label, + onChange, + selectedItem, + ...rest + }, + forwardRef +) { + const prefix = usePrefix(); + const context = useContext(MenuContext); + + const [selection, setSelection] = useControllableState({ + value: selectedItem, + onChange, + defaultValue: defaultSelectedItem, + }); + + function handleClick(item, e) { + setSelection(item); + + if (onChange) { + onChange(e); + } + } + + useEffect(() => { + if (!context.state.hasIcons) { + context.dispatch({ type: 'enableIcons' }); + } + }, [context.state.hasIcons, context]); + + const classNames = cx(className, `${prefix}--menu-item-radio-group`); + + return ( +
  • +
      + {items.map((item, i) => ( + { + handleClick(item, e); + }} + /> + ))} +
    +
  • + ); +}); + +MenuItemRadioGroup.propTypes = { + /** + * Additional CSS class names. + */ + className: PropTypes.string, + + /** + * Specify the default selected item. Must match the type of props.items. + */ + defaultSelectedItem: PropTypes.any, + + /** + * Provide a function to convert an item to the string that will be rendered. Defaults to item.toString(). + */ + itemToString: PropTypes.func, + + /** + * Provide the options for this radio group. Can be of any type, as long as you provide an appropriate props.itemToString function. + */ + items: PropTypes.array, + + /** + * A required label titling this radio group. + */ + label: PropTypes.string.isRequired, + + /** + * Provide an optional function to be called when the selection changes. + */ + onChange: PropTypes.func, + + /** + * Provide props.selectedItem to control the state of this radio group. Must match the type of props.items. */ - label: PropTypes.node.isRequired, + selectedItem: PropTypes.any, +}; + +const MenuItemDivider = React.forwardRef(function MenuItemDivider( + { className, ...rest }, + forwardRef +) { + const prefix = usePrefix(); + const classNames = cx(className, `${prefix}--menu-item-divider`); + + return ( +
  • + ); +}); + +MenuItemDivider.propTypes = { /** - * Rendered shortcut for the MenuItem + * Additional CSS class names. */ - shortcut: PropTypes.node, + className: PropTypes.string, }; -export default MenuItem; +export { + MenuItem, + MenuItemSelectable, + MenuItemGroup, + MenuItemRadioGroup, + MenuItemDivider, +}; diff --git a/packages/react/src/components/Menu/MenuOption.js b/packages/react/src/components/Menu/MenuOption.js deleted file mode 100644 index a871e051a0dd..000000000000 --- a/packages/react/src/components/Menu/MenuOption.js +++ /dev/null @@ -1,263 +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, { useState, useRef, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import { CaretRight } from '@carbon/icons-react'; -import { keys, match } from '../../internal/keyboard'; -import { usePrefix } from '../../internal/usePrefix'; - -import { - getFirstSubNode, - focusNode, - getParentMenu, - clickedElementHasSubnodes, -} from './_utils'; - -import Menu from './Menu'; - -const hoverIntentDelay = 150; // in ms - -function MenuOptionContent({ label, info, disabled, icon: Icon, indented }) { - const prefix = usePrefix(); - const classes = classnames(`${prefix}--menu-option__content`, { - [`${prefix}--menu-option__content--disabled`]: disabled, - }); - - return ( -
    - {indented && ( -
    {Icon && }
    - )} - - {label} - -
    {info}
    -
    - ); -} - -function MenuOption({ - children, - disabled, - indented, - kind = 'default', - label, - level, - onClick = () => {}, - renderIcon, - shortcut, - ...rest -}) { - const [submenuOpen, setSubmenuOpen] = useState(false); - const [submenuOpenedByKeyboard, setSubmenuOpenedByKeyboard] = useState(false); - const rootRef = useRef(null); - const hoverIntentTimeout = useRef(null); - const prefix = usePrefix(); - - const subOptions = React.Children.map(children, (node) => { - if (React.isValidElement(node)) { - return React.cloneElement(node); - } - }); - - function openSubmenu(openedByKeyboard = false) { - setSubmenuOpenedByKeyboard(openedByKeyboard); - setSubmenuOpen(true); - } - - function handleKeyDown(event) { - if ( - clickedElementHasSubnodes(event) && - (match(event, keys.ArrowRight) || - match(event, keys.Enter) || - match(event, keys.Space)) - ) { - openSubmenu(true); - } else if ( - (match(event, keys.Enter) || match(event, keys.Space)) && - onClick - ) { - onClick(event); - } - } - - function handleMouseEnter() { - hoverIntentTimeout.current = setTimeout(openSubmenu, hoverIntentDelay); - } - - function handleMouseLeave() { - clearTimeout(hoverIntentTimeout?.current); - - setSubmenuOpen(false); - } - - function getSubmenuPosition() { - const pos = [0, 0]; - - if (subOptions) { - const parentMenu = getParentMenu(rootRef?.current); - - if (parentMenu) { - const { x, width } = parentMenu.getBoundingClientRect(); - const { y } = rootRef.current.getBoundingClientRect(); - - pos[0] = x + width; - pos[1] = y; - } - } - - return pos; - } - - useEffect(() => { - if (subOptions && submenuOpenedByKeyboard) { - const firstSubnode = getFirstSubNode(rootRef?.current); - focusNode(firstSubnode); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [submenuOpen]); - - const classes = classnames(`${prefix}--menu-option`, { - [`${prefix}--menu-option--disabled`]: disabled, - [`${prefix}--menu-option--active`]: subOptions && submenuOpen, - [`${prefix}--menu-option--danger`]: !subOptions && kind === 'danger', - }); - - const allowedRoles = ['menuitemradio', 'menuitemcheckbox']; - const role = - rest.role && allowedRoles.includes(rest.role) ? rest.role : 'menuitem'; - - const submenuPosition = getSubmenuPosition(); - - return ( - // role is either menuitemradio, menuitemcheckbox, or menuitem which are all interactive - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
  • - {subOptions ? ( - <> - } - indented={indented} - /> - { - setSubmenuOpen(false); - }} - x={submenuPosition[0]} - y={submenuPosition[1]}> - {subOptions} - - - ) : ( - - )} -
  • - ); -} - -MenuOptionContent.propTypes = { - /** - * Whether this option is disabled - */ - disabled: PropTypes.bool, - - /** - * Icon that is displayed in front of the option - */ - icon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - - /** - * Whether the content should be indented - */ - indented: PropTypes.bool, - - /** - * Additional information such as shortcut or caret - */ - info: PropTypes.node, - - /** - * Rendered label for the MenuOptionContent - */ - label: PropTypes.node.isRequired, -}; - -MenuOption.propTypes = { - /** - * Specify the children of the MenuOption - */ - children: PropTypes.node, - - /** - * Specify whether this MenuOption is disabled - */ - disabled: PropTypes.bool, - - /** - * Whether the content should be indented (for example because it's in a group with options that have icons). - * Is automatically set by Menu - */ - indented: PropTypes.bool, - - /** - * Optional prop to specify the kind of the MenuOption - */ - kind: PropTypes.oneOf(['default', 'danger']), - - /** - * Rendered label for the MenuOption - */ - label: PropTypes.node.isRequired, - - /** - * Which nested level this option is located in. - * Is automatically set by Menu - */ - level: PropTypes.number, - - /** - * The onClick handler - */ - onClick: PropTypes.func, - - /** - * Rendered icon for the MenuOption. - * Can be a React component class - */ - renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - - /** - * Rendered shortcut for the MenuOption - */ - shortcut: PropTypes.node, -}; - -export default MenuOption; diff --git a/packages/react/src/components/Menu/MenuRadioGroup.js b/packages/react/src/components/Menu/MenuRadioGroup.js deleted file mode 100644 index e54ffd795429..000000000000 --- a/packages/react/src/components/Menu/MenuRadioGroup.js +++ /dev/null @@ -1,52 +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 PropTypes from 'prop-types'; -import MenuGroup from './MenuGroup'; -import MenuRadioGroupOptions from './MenuRadioGroupOptions'; - -function MenuRadioGroup({ - items, - initialSelectedItem, - label, - onChange = () => {}, -}) { - return ( - - - - ); -} - -MenuRadioGroup.propTypes = { - /** - * Whether the option should be checked by default - */ - initialSelectedItem: PropTypes.string, - - /** - * Array of the radio options - */ - items: PropTypes.arrayOf(PropTypes.string).isRequired, - - /** - * The radio group label - */ - label: PropTypes.string.isRequired, - - /** - * Callback function when selection the has been changed - */ - onChange: PropTypes.func, -}; - -export default MenuRadioGroup; diff --git a/packages/react/src/components/Menu/MenuRadioGroupOptions.js b/packages/react/src/components/Menu/MenuRadioGroupOptions.js deleted file mode 100644 index 6bfd757c4662..000000000000 --- a/packages/react/src/components/Menu/MenuRadioGroupOptions.js +++ /dev/null @@ -1,63 +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, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { Checkmark } from '@carbon/icons-react'; -import MenuOption from './MenuOption'; - -function MenuRadioGroupOptions({ - items, - initialSelectedItem, - onChange = () => {}, -}) { - const [selected, setSelected] = useState(initialSelectedItem); - - function handleClick(option) { - setSelected(option); - onChange(option); - } - - const options = items.map((option, i) => { - const isSelected = selected === option; - - return ( - { - handleClick(option); - }} - /> - ); - }); - - return options; -} - -MenuRadioGroupOptions.propTypes = { - /** - * Whether the option should be checked by default - */ - initialSelectedItem: PropTypes.string, - - /** - * Array of the radio options - */ - items: PropTypes.arrayOf(PropTypes.string).isRequired, - - /** - * Callback function when selection the has been changed - */ - onChange: PropTypes.func, -}; - -export default MenuRadioGroupOptions; diff --git a/packages/react/src/components/Menu/MenuSelectableItem.js b/packages/react/src/components/Menu/MenuSelectableItem.js deleted file mode 100644 index 3fdc7f6dbebb..000000000000 --- a/packages/react/src/components/Menu/MenuSelectableItem.js +++ /dev/null @@ -1,50 +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, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { Checkmark } from '@carbon/icons-react'; -import MenuOption from './MenuOption'; - -function MenuSelectableItem({ label, initialChecked, onChange = () => {} }) { - const [checked, setChecked] = useState(initialChecked); - - function handleClick() { - setChecked(!checked); - onChange(!checked); - } - - return ( - - ); -} - -MenuSelectableItem.propTypes = { - /** - * Whether the option should be checked by default - */ - initialChecked: PropTypes.bool, - - /** - * Rendered label for the MenuOptionContent - */ - label: PropTypes.node.isRequired, - - /** - * Callback function when selection the has been changed - */ - onChange: PropTypes.func, -}; - -export default MenuSelectableItem; diff --git a/packages/react/src/components/Menu/_storybook-utils.js b/packages/react/src/components/Menu/_storybook-utils.js deleted file mode 100644 index 602e412a4303..000000000000 --- a/packages/react/src/components/Menu/_storybook-utils.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { InlineNotification } from '../Notification'; - -import { - MenuDivider, - MenuGroup, - MenuItem, - MenuRadioGroup, - MenuSelectableItem, -} from '../Menu'; - -const InfoBanner = () => ( - - Exerimental component -

    - This component is considered experimental. Its API may change until the - stable version is released. -

    -
    -); - -// eslint-disable-next-line react/prop-types -export const StoryFrame = ({ children }) => ( - // eslint-disable-next-line react/forbid-dom-props -
    - - {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 deleted file mode 100644 index 79362c661bf6..000000000000 --- a/packages/react/src/components/Menu/_utils.js +++ /dev/null @@ -1,199 +0,0 @@ -const prefix = 'cds'; - -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 getSubMenuOffset(node) { - if (node) { - const nodeStyles = getComputedStyle(node); - const spacings = - parseInt(nodeStyles.paddingTop) + parseInt(nodeStyles.paddingBottom); // styles always in px, convert to number - const elementHeight = node.firstElementChild.offsetHeight; - return elementHeight + spacings || 0; - } - - return 0; -} - -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) - * @param {boolean} [isRootLevel] Flag that indicates if the element is on level 1 (the root level) - * @param {object} [element] The list element - used to calculate the offset of submenus - * @returns {object} The determined position and direction of the element: { position: [x, y], direction: 1 | -1 } - */ -export function getPosition( - elementDimensions, - targetBoundaries, - containerBoundaries, - preferredDirection = 1, - isRootLevel, - element -) { - 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]; - if (!isRootLevel && element) { - // if sub-menu and not root level, consider offset - const diff = getSubMenuOffset(element); - position[1] += diff; - } - } - - return { - position, - direction, - }; -} diff --git a/packages/react/src/components/Menu/index.js b/packages/react/src/components/Menu/index.js index c8259bc65c04..ca13b32cbf12 100644 --- a/packages/react/src/components/Menu/index.js +++ b/packages/react/src/components/Menu/index.js @@ -1,29 +1,24 @@ /** - * Copyright IBM Corp. 2020 + * Copyright IBM Corp. 2023 * * 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; +import { Menu } from './Menu'; +import { + MenuItem, + MenuItemDivider, + MenuItemGroup, + MenuItemRadioGroup, + MenuItemSelectable, +} from './MenuItem'; export { - MenuDivider, - MenuGroup, - MenuItem, - MenuRadioGroup, - MenuSelectableItem, Menu, + MenuItem, + MenuItemDivider, + MenuItemGroup, + MenuItemRadioGroup, + MenuItemSelectable, }; -export default Menu; diff --git a/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js b/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js index 9dc377c16ead..2c74202ecfeb 100644 --- a/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js +++ b/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js @@ -6,68 +6,63 @@ */ import React from 'react'; +import { action } from '@storybook/addon-actions'; + import { ArrowsVertical } from '@carbon/icons-react'; -import Menu from '../Menu'; -import { StoryFrame, buildMenu } from '../Menu/_storybook-utils'; +import { MenuItem, MenuItemRadioGroup, MenuItemDivider } from '../Menu'; -import { OverflowMenuV2 } from '.'; +import { OverflowMenuV2 } from './'; export default { - title: 'Experimental/unstable_Menu/OverflowMenuV2', - component: Menu, + title: 'Experimental/unstable__OverflowMenuV2', + component: OverflowMenuV2, }; -const Story = (items, props = {}) => ( - - {buildMenu(items)} - -); +export const _OverflowMenuV2 = () => { + const onClick = action('onClick (MenuItem)'); -export const _OverflowMenuV2 = () => - 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' }, - ]); + return ( + + + + + + + + + ); +}; -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: ArrowsVertical, - } +export const Nested = () => { + return ( + + + + + + + + + + ); +}; -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' }, - ]); +export const CustomIcon = () => { + return ( + + + + + + ); +}; diff --git a/packages/react/src/components/OverflowMenuV2/index.js b/packages/react/src/components/OverflowMenuV2/index.js index c3e3b09417fc..9c8885b188e7 100644 --- a/packages/react/src/components/OverflowMenuV2/index.js +++ b/packages/react/src/components/OverflowMenuV2/index.js @@ -10,8 +10,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { OverflowMenuVertical } from '@carbon/icons-react'; import { useId } from '../../internal/useId'; -import Menu from '../Menu'; -import { keys, matches as keyCodeMatches } from '../../internal/keyboard'; +import { Menu } from '../Menu'; import { usePrefix } from '../../internal/usePrefix'; const defaultSize = 'md'; @@ -65,20 +64,6 @@ function OverflowMenuV2({ e.preventDefault(); } - function handleKeyPress(e) { - if ( - open && - keyCodeMatches(e, [ - keys.ArrowUp, - keys.ArrowRight, - keys.ArrowDown, - keys.ArrowLeft, - ]) - ) { - e.preventDefault(); - } - } - const containerClasses = classNames(`${prefix}--overflow-menu__container`); const triggerClasses = classNames( @@ -100,7 +85,6 @@ function OverflowMenuV2({ className={triggerClasses} onClick={handleClick} onMouseDown={handleMousedown} - onKeyDown={handleKeyPress} ref={triggerRef}> @@ -119,17 +103,17 @@ function OverflowMenuV2({ OverflowMenuV2.propTypes = { /** - * Specify the children of the OverflowMenu + * A collection of MenuItems to be rendered within this OverflowMenu. */ children: PropTypes.node, /** - * Optional className for the trigger button + * Additional CSS class names for the trigger button. */ className: PropTypes.string, /** - * Function called to override icon rendering. + * Otionally provide a custom icon to be rendered on the trigger button. */ renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), diff --git a/packages/react/src/index.js b/packages/react/src/index.js index c43e65ee0a90..6e1093c6cd8e 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -262,12 +262,13 @@ export { LayoutDirection as unstable_LayoutDirection, useLayoutDirection as unstable_useLayoutDirection, } from './components/Layout'; -export unstable_Menu, { - MenuDivider as unstable_MenuDivider, - MenuGroup as unstable_MenuGroup, +export { + Menu as unstable_Menu, MenuItem as unstable_MenuItem, - MenuRadioGroup as unstable_MenuRadioGroup, - MenuSelectableItem as unstable_MenuSelectableItem, + MenuItemDivider as unstable_MenuItemDivider, + MenuItemGroup as unstable_MenuItemGroup, + MenuItemRadioGroup as unstable_MenuItemRadioGroup, + MenuItemSelectable as unstable_MenuItemSelectable, } from './components/Menu'; export { OverflowMenuV2 as unstable_OverflowMenuV2 } from './components/OverflowMenuV2'; export { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e1384c5e093a..85869503acca 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -142,11 +142,11 @@ export { } from './components/Layout'; export { Menu as unstable_Menu, - MenuDivider as unstable_MenuDivider, - MenuGroup as unstable_MenuGroup, MenuItem as unstable_MenuItem, - MenuRadioGroup as unstable_MenuRadioGroup, - MenuSelectableItem as unstable_MenuSelectableItem, + MenuItemDivider as unstable_MenuItemDivider, + MenuItemGroup as unstable_MenuItemGroup, + MenuItemRadioGroup as unstable_MenuItemRadioGroup, + MenuItemSelectable as unstable_MenuItemSelectable, } from './components/Menu'; export { OverflowMenuV2 as unstable_OverflowMenuV2 } from './components/OverflowMenuV2'; export { diff --git a/packages/styles/scss/components/menu/_menu.scss b/packages/styles/scss/components/menu/_menu.scss index 3f2da68ebf51..04be771c0de1 100644 --- a/packages/styles/scss/components/menu/_menu.scss +++ b/packages/styles/scss/components/menu/_menu.scss @@ -10,15 +10,18 @@ @use '../../spacing' as *; @use '../../theme' as *; @use '../../type' as *; -@use '../button/tokens' as button; +@use '../button/tokens' as button-tokens; @use '../../utilities/box-shadow' as *; @use '../../utilities/focus-outline' as *; @use '../../utilities/z-index' as *; +@use '../../utilities/convert' as *; /// Menu styles /// @access public /// @group menu @mixin menu { + // Menu + .#{$prefix}--menu { @include box-shadow; @@ -28,6 +31,7 @@ max-width: 18rem; padding: $spacing-02 0; background-color: $layer; + opacity: 0; visibility: hidden; } @@ -39,19 +43,27 @@ } } - .#{$prefix}--menu--invisible { - opacity: 0; - pointer-events: none; + .#{$prefix}--menu:not(.#{$prefix}--menu--open) .#{$prefix}--menu--open { + visibility: hidden; + } + + .#{$prefix}--menu--shown { + opacity: 1; } - .#{$prefix}--menu-option { - position: relative; - display: list-item; - // $size-sm + // MenuItem + + .#{$prefix}--menu-item { + @include type-style('body-short-01'); + + display: grid; height: 2rem; - background-color: $layer; + align-items: center; color: $text-primary; + column-gap: $spacing-03; cursor: pointer; + grid-template-columns: 0 1fr max-content; + padding-inline: $spacing-05; transition: background-color $duration-fast-01 motion(standard, productive); &:focus { @@ -59,88 +71,67 @@ } } - .#{$prefix}--menu-option--active, - .#{$prefix}--menu-option:hover { + .#{$prefix}--menu-item:hover { background-color: $layer-hover; } - .#{$prefix}--menu-option--danger:hover, - .#{$prefix}--menu-option--danger:focus { - background-color: button.$button-danger-primary; - color: $text-on-color; - } + $supported-sizes: ( + 'xs': 1.5rem, + 'sm': 2rem, + 'md': 2.5rem, + 'lg': 3rem, + ); - .#{$prefix}--menu-option > .#{$prefix}--menu { - margin-top: calc(#{$spacing-02} * -1); + @each $size, $value in $supported-sizes { + .#{$prefix}--menu--#{$size} .#{$prefix}--menu-item { + height: $value; + } } - .#{$prefix}--menu-option__content { + .#{$prefix}--menu-item__icon { display: flex; - height: 100%; - align-items: center; - justify-content: space-between; - padding: 0 $spacing-05; } - .#{$prefix}--menu-option__content--disabled { - background-color: $layer; - color: $text-disabled; - cursor: not-allowed; + .#{$prefix}--menu-item__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .#{$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--with-icons > .#{$prefix}--menu-item, + .#{$prefix}--menu--with-icons + > .#{$prefix}--menu-item-group + > ul + > .#{$prefix}--menu-item, + .#{$prefix}--menu--with-icons + > .#{$prefix}--menu-item-radio-group + > ul + > .#{$prefix}--menu-item { + grid-template-columns: 1rem 1fr max-content; } - .#{$prefix}--menu-option__content--indented .#{$prefix}--menu-option__label { - margin-left: $spacing-05; + .#{$prefix}--menu-item--disabled { + color: $text-disabled; + cursor: not-allowed; } - .#{$prefix}--menu-option__label { - @include type-style('body-compact-01'); - - overflow: hidden; - flex-grow: 1; - // add top/bottom padding to make sure letters are not cut off by hidden overflow - padding: $spacing-02 0; - text-align: start; - text-overflow: ellipsis; - white-space: nowrap; + .#{$prefix}--menu-item--disabled:hover { + background-color: $layer; } - .#{$prefix}--menu-option__info { - display: inline-flex; - margin-left: $spacing-05; + .#{$prefix}--menu-item--danger:focus, + .#{$prefix}--menu-item--danger:hover { + background-color: button-tokens.$button-danger-primary; + color: $text-on-color; } - .#{$prefix}--menu-option__icon { - display: flex; - width: 1rem; - height: 1rem; - align-items: center; - margin-right: $spacing-03; - } + // MenuItemDivider - .#{$prefix}--menu-divider { - display: list-item; + .#{$prefix}--menu-item-divider { + display: block; width: 100%; - height: 1px; - margin: $spacing-02 0; + height: rem(1px); background-color: $border-subtle; - } - - $supported-sizes: ( - // $size-md - 'md': 2.5rem, - // $size-lg - 'lg': 3rem - ); - - @each $size, $value in $supported-sizes { - .#{$prefix}--menu--#{$size} .#{$prefix}--menu-option { - height: $value; - } + margin-block: $spacing-02; } } From f1eebe8e7c1fd74f2a028aeb83e72f6c3eb5f57e Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Tue, 14 Feb 2023 15:33:08 +0100 Subject: [PATCH 02/11] test(menu): fix test --- packages/react/src/components/Menu/Menu-test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/Menu/Menu-test.js b/packages/react/src/components/Menu/Menu-test.js index 0a5b3aa1cdb6..2d1a3d0614bc 100644 --- a/packages/react/src/components/Menu/Menu-test.js +++ b/packages/react/src/components/Menu/Menu-test.js @@ -87,7 +87,7 @@ describe('MenuItem', () => { describe('renders as expected', () => { it('should be disabled', () => { render( - + ); @@ -104,7 +104,7 @@ describe('MenuItem', () => { it('should change kind based on prop', () => { render( - + ); @@ -116,7 +116,7 @@ describe('MenuItem', () => { it('should render label', () => { render( - + ); From d2382216b4d25518e9d04d36d39694f0ef359501 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 08:26:15 +0100 Subject: [PATCH 03/11] fix(menu): fix typo --- packages/react/src/components/Menu/MenuItem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Menu/MenuItem.js b/packages/react/src/components/Menu/MenuItem.js index ea1e822ed9cf..4e6a6db697c5 100644 --- a/packages/react/src/components/Menu/MenuItem.js +++ b/packages/react/src/components/Menu/MenuItem.js @@ -82,7 +82,7 @@ const MenuItem = React.forwardRef(function MenuItem( } } - function handleMousEnter() { + function handleMouseEnter() { hoverIntentTimeout.current = setTimeout(() => { openSubmenu(); }, hoverIntentDelay); @@ -131,7 +131,7 @@ const MenuItem = React.forwardRef(function MenuItem( aria-haspopup={hasChildren || null} aria-expanded={hasChildren ? submenuOpen : null} onClick={handleClick} - onMouseEnter={hasChildren ? handleMousEnter : null} + onMouseEnter={hasChildren ? handleMouseEnter : null} onMouseLeave={hasChildren ? handleMouseLeave : null} onKeyDown={handleKeyDown}>
    From 6b1b37adab8ea0c5dc1cfaa8974c00e4d7e48bb5 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 08:26:47 +0100 Subject: [PATCH 04/11] fix(menu): fix MenuItemSelectable function name --- packages/react/src/components/Menu/MenuItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/Menu/MenuItem.js b/packages/react/src/components/Menu/MenuItem.js index 4e6a6db697c5..6999aeef04ae 100644 --- a/packages/react/src/components/Menu/MenuItem.js +++ b/packages/react/src/components/Menu/MenuItem.js @@ -205,7 +205,7 @@ MenuItem.propTypes = { shortcut: PropTypes.string, }; -const MenuItemSelectable = React.forwardRef(function MenuItemDivider( +const MenuItemSelectable = React.forwardRef(function MenuItemSelectable( { className, defaultSelected, label, onChange, selected, ...rest }, forwardRef ) { From ed5632e1f12393e3de2ac6354f7edf687b4b09ff Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 08:27:08 +0100 Subject: [PATCH 05/11] fix(menu): combine adhacent conditional rendering blocks --- .../react/src/components/Menu/MenuItem.js | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/react/src/components/Menu/MenuItem.js b/packages/react/src/components/Menu/MenuItem.js index 6999aeef04ae..39132d6ebd06 100644 --- a/packages/react/src/components/Menu/MenuItem.js +++ b/packages/react/src/components/Menu/MenuItem.js @@ -142,22 +142,22 @@ const MenuItem = React.forwardRef(function MenuItem(
    {shortcut}
    )} {hasChildren && ( -
    - -
    - )} - {hasChildren && ( - { - closeSubmenu(); - menuItem.current.focus(); - }} - x={boundaries.x} - y={boundaries.y}> - {children} - + <> +
    + +
    + { + closeSubmenu(); + menuItem.current.focus(); + }} + x={boundaries.x} + y={boundaries.y}> + {children} + + )} ); From 77aaf70ce4de2802ea783ba4462afa1297be3b6c Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 08:33:34 +0100 Subject: [PATCH 06/11] refactor(menu): add dummy 'dispatch' to menuDefaultState --- packages/react/src/components/Menu/Menu.js | 4 +++- packages/react/src/components/Menu/MenuContext.js | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index f29d13d4a42a..210c1830dd0c 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -45,11 +45,13 @@ const Menu = React.forwardRef(function Menu( const focusReturn = useRef(null); const context = useContext(MenuContext); - const isRoot = !context.dispatch; + + const isRoot = context.state.isRoot; const menuSize = isRoot ? size : context.state.size; const [childState, childDispatch] = useReducer(menuReducer, { ...context.state, + isRoot: false, size, requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot, }); diff --git a/packages/react/src/components/Menu/MenuContext.js b/packages/react/src/components/Menu/MenuContext.js index 78cc0d62e9a4..6e05f12faea5 100644 --- a/packages/react/src/components/Menu/MenuContext.js +++ b/packages/react/src/components/Menu/MenuContext.js @@ -8,6 +8,7 @@ import React from 'react'; const menuDefaultState = { + isRoot: true, hasIcons: false, size: null, items: [], @@ -31,6 +32,9 @@ function menuReducer(state, action) { const MenuContext = React.createContext({ state: menuDefaultState, + + // 'dispatch' is populated by the root menu + dispatch: () => {}, }); export { MenuContext, menuReducer }; From e7dc7819f60960a71deed2c338bdbfa6fe1cbc59 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 08:41:52 +0100 Subject: [PATCH 07/11] fix(menu): clear list of registered items when menu is closed --- packages/react/src/components/Menu/Menu.js | 2 ++ packages/react/src/components/Menu/MenuContext.js | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index 210c1830dd0c..0a4a81fbed86 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -83,6 +83,8 @@ const Menu = React.forwardRef(function Menu( focusReturn.current.focus(); } + childDispatch({ type: 'clearRegisteredItems' }); + if (onClose) { onClose(); } diff --git a/packages/react/src/components/Menu/MenuContext.js b/packages/react/src/components/Menu/MenuContext.js index 6e05f12faea5..a545168d02ae 100644 --- a/packages/react/src/components/Menu/MenuContext.js +++ b/packages/react/src/components/Menu/MenuContext.js @@ -27,6 +27,11 @@ function menuReducer(state, action) { ...state, items: [...state.items, action.payload], }; + case 'clearRegisteredItems': + return { + ...state, + items: [], + }; } } From 9d9a81f6c6c4e6d2a901c881373f5c2dcf9c8a1c Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 09:17:22 +0100 Subject: [PATCH 08/11] fix(menu): prevent calling 'handleClose' twice --- packages/react/src/components/Menu/Menu.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index 0a4a81fbed86..50d27fa776fb 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -132,8 +132,14 @@ const Menu = React.forwardRef(function Menu( } function handleBlur(e) { - if (onClose && isRoot && !menu.current.contains(e.relatedTarget)) { - handleClose(); + if ( + open && + onClose && + isRoot && + !menu.current.contains(e.relatedTarget) && + e.relatedTarget !== focusReturn.current + ) { + handleClose(e); } } From 5bff9ad184444ba1958756d06e74b941d25145b3 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 09:17:55 +0100 Subject: [PATCH 09/11] fix(menu): fix focus return --- packages/react/src/components/Menu/Menu.js | 18 ++++++++++++++---- packages/react/src/components/Menu/MenuItem.js | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index 50d27fa776fb..660595c0f1d1 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -70,6 +70,12 @@ const Menu = React.forwardRef(function Menu( (item) => !item.disabled ); + function returnFocus() { + if (focusReturn.current) { + focusReturn.current.focus(); + } + } + function handleOpen() { if (menu.current) { focusReturn.current = document.activeElement; @@ -78,9 +84,13 @@ const Menu = React.forwardRef(function Menu( } } - function handleClose() { - if (focusReturn.current) { - focusReturn.current.focus(); + function handleClose(e) { + if (/^key/.test(e.type)) { + window.addEventListener('keyup', returnFocus, { once: true }); + } else if (e.type === 'click' && menu.current) { + menu.current.addEventListener('focusout', returnFocus, { once: true }); + } else { + returnFocus(); } childDispatch({ type: 'clearRegisteredItems' }); @@ -104,7 +114,7 @@ const Menu = React.forwardRef(function Menu( (match(e, keys.Escape) || (!isRoot && match(e, keys.ArrowLeft))) && onClose ) { - handleClose(); + handleClose(e); } else { // if currentItem is -1, the menu itself is focused. // in this case, the arrow keys define the first item diff --git a/packages/react/src/components/Menu/MenuItem.js b/packages/react/src/components/Menu/MenuItem.js index 39132d6ebd06..00c06ae9f40f 100644 --- a/packages/react/src/components/Menu/MenuItem.js +++ b/packages/react/src/components/Menu/MenuItem.js @@ -74,7 +74,7 @@ const MenuItem = React.forwardRef(function MenuItem( if (hasChildren) { openSubmenu(); } else { - context.state.requestCloseRoot(); + context.state.requestCloseRoot(e); if (onClick) { onClick(e); From 48cd19a8d7fa8faf8a2462157ba77778393e310d Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Thu, 16 Feb 2023 11:10:19 +0100 Subject: [PATCH 10/11] docs(menu): fix ContextMenu story actions --- .../components/ContextMenu/ContextMenu.stories.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/ContextMenu/ContextMenu.stories.js b/packages/react/src/components/ContextMenu/ContextMenu.stories.js index b7ee4ab894be..8e52e6673146 100644 --- a/packages/react/src/components/ContextMenu/ContextMenu.stories.js +++ b/packages/react/src/components/ContextMenu/ContextMenu.stories.js @@ -76,13 +76,13 @@ export const _useContextMenu = () => { /> - - - - - + + + + + - +
    From 263251c17d51585919c7bb2a03401568ee3e8967 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Fri, 17 Feb 2023 09:04:34 +0100 Subject: [PATCH 11/11] fix(menu): close overflowmenuv2 on blur --- packages/react/src/components/Menu/Menu.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js index 660595c0f1d1..fc5ea6ed5f47 100644 --- a/packages/react/src/components/Menu/Menu.js +++ b/packages/react/src/components/Menu/Menu.js @@ -142,13 +142,7 @@ const Menu = React.forwardRef(function Menu( } function handleBlur(e) { - if ( - open && - onClose && - isRoot && - !menu.current.contains(e.relatedTarget) && - e.relatedTarget !== focusReturn.current - ) { + if (open && onClose && isRoot && !menu.current.contains(e.relatedTarget)) { handleClose(e); } }