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.
-
-
-
-
- );
-};
-
-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.
-
-
-
-
- );
-};
-
-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 = ;
+ const rendered = (
+
+
+
+ );
- 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 (
-
-
-
- );
-}
-
-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 (
+
+
+
+ );
+});
+
+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) => (
+
+
+ );
+});
+
+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 (
-
- );
- 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);
}
}