diff --git a/packages/components/src/components/context-menu/_context-menu.scss b/packages/components/src/components/context-menu/_context-menu.scss index a9d0506d4c53..d73855050098 100644 --- a/packages/components/src/components/context-menu/_context-menu.scss +++ b/packages/components/src/components/context-menu/_context-menu.scss @@ -24,6 +24,7 @@ } .#{$prefix}--context-menu--open { + position: fixed; display: block; } @@ -46,6 +47,10 @@ z-index: 1; } + .#{$prefix}--context-menu-option > .#{$prefix}--context-menu--reverse { + left: -100%; + } + .#{$prefix}--context-menu-option:hover > .#{$prefix}--context-menu, .#{$prefix}--context-menu-option:focus-within > .#{$prefix}--context-menu { display: block; diff --git a/packages/react/src/components/ContextMenu/ContextMenu-story.js b/packages/react/src/components/ContextMenu/ContextMenu-story.js index 973c461d53ff..3c724d330cb4 100644 --- a/packages/react/src/components/ContextMenu/ContextMenu-story.js +++ b/packages/react/src/components/ContextMenu/ContextMenu-story.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { FolderShared16, Edit16, TrashCan16 } from '@carbon/icons-react'; import ContextMenu, { @@ -22,26 +22,49 @@ export default { }, }; -export const _ContextMenu = () => ( - - - - - - - - - - - - - - - - -); +export const _ContextMenu = () => { + const [open, setOpen] = useState(true); + const [position, setPosition] = useState([0, 0]); + + function openContextMenu(e) { + e.preventDefault(); + + const { x, y } = e; + + setPosition([x, y]); + setOpen(true); + } + + useEffect(() => { + document.addEventListener('contextmenu', openContextMenu); + + return () => { + document.removeEventListener('contextmenu', openContextMenu); + }; + }); + + return ( + + + + + + + + + + + + + + + + + ); +}; _ContextMenu.storyName = 'ContextMenu'; diff --git a/packages/react/src/components/ContextMenu/ContextMenu.js b/packages/react/src/components/ContextMenu/ContextMenu.js index b72fa99a3bff..6293b7e43046 100644 --- a/packages/react/src/components/ContextMenu/ContextMenu.js +++ b/packages/react/src/components/ContextMenu/ContextMenu.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import { settings } from 'carbon-components'; @@ -16,8 +16,18 @@ import ContextMenuRadioGroup from './ContextMenuRadioGroup'; const { prefix } = settings; -const ContextMenu = function ContextMenu({ children, open, ...rest }) { +const contextMenuWidth = 208; // in px + +const ContextMenu = function ContextMenu({ + children, + open, + level = 1, + x = 0, + y = 0, + ...rest +}) { const rootRef = useRef(null); + const [shouldReverse, setShouldReverse] = useState(false); function resetFocus() { Array.from( @@ -27,11 +37,14 @@ const ContextMenu = function ContextMenu({ children, open, ...rest }) { }); } - function focusNode(node) { + function focusNode(node, focus = true) { if (node) { resetFocus(); node.tabIndex = 0; - node.focus(); + + if (focus) { + node.focus(); + } } } @@ -107,16 +120,47 @@ const ContextMenu = function ContextMenu({ children, open, ...rest }) { } } + function willFit() { + if (rootRef?.current) { + const bodyWidth = document.body.clientWidth; + + const reverseMap = [...Array(level)].reduce( + (acc) => { + const endX = acc.lastX + contextMenuWidth * acc.direction; + const fits = + acc.direction === 1 ? endX < bodyWidth : endX > contextMenuWidth; + + const newDirection = fits ? acc.direction : acc.direction * -1; + const newLastX = fits + ? endX + : acc.lastX + contextMenuWidth * newDirection; + + return { + direction: newDirection, + lastX: newLastX, + map: [...acc.map, newDirection === 1], + }; + }, + { direction: 1, lastX: x, map: [] } + ); + + return reverseMap.map[level - 1]; + } + + return true; + } + useEffect(() => { const topLevelNodes = getValidNodes(rootRef?.current); if (topLevelNodes && topLevelNodes.length > 0) { - focusNode(topLevelNodes[0].firstChild); + focusNode(topLevelNodes[0].firstChild, false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - const someNodesHaveIcons = children.some( + setShouldReverse(!willFit()); + }, [open, x, y]); // eslint-disable-line react-hooks/exhaustive-deps + + const someNodesHaveIcons = React.Children.toArray(children).some( (node) => node.props.renderIcon || node.type === SelectableContextMenuOption || @@ -127,17 +171,25 @@ const ContextMenu = function ContextMenu({ children, open, ...rest }) { if (React.isValidElement(node)) { return React.cloneElement(node, { indented: someNodesHaveIcons, + level: level, + menuX: x, }); } }); const classes = classnames(`${prefix}--context-menu`, { [`${prefix}--context-menu--open`]: open, + [`${prefix}--context-menu--reverse`]: shouldReverse, }); return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -