Skip to content

Commit

Permalink
feat(context-menu): dynamically reverse menu direction
Browse files Browse the repository at this point in the history
  • Loading branch information
janhassel committed Nov 30, 2020
1 parent f6db40e commit 0f6c22d
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
}

.#{$prefix}--context-menu--open {
position: fixed;
display: block;
}

Expand All @@ -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;
Expand Down
67 changes: 45 additions & 22 deletions packages/react/src/components/ContextMenu/ContextMenu-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -22,26 +22,49 @@ export default {
},
};

export const _ContextMenu = () => (
<ContextMenu open>
<ContextMenuOption label="Share with" renderIcon={FolderShared16}>
<ContextMenuRadioGroup
items={['None', 'Product team', 'Organization', 'Company']}
initialSelectedItem="Product team"
/>
</ContextMenuOption>
<ContextMenuDivider />
<ContextMenuOption label="Cut" shortcut="⌘X" />
<ContextMenuOption label="Copy" shortcut="⌘C" />
<ContextMenuOption label="Copy path" shortcut="⌥⌘C" />
<ContextMenuOption label="Paste" shortcut="⌘V" disabled />
<ContextMenuOption label="Duplicate" />
<ContextMenuDivider />
<SelectableContextMenuOption label="Publish" initialChecked />
<ContextMenuDivider />
<ContextMenuOption label="Rename" shortcut="↩︎" renderIcon={Edit16} />
<ContextMenuOption label="Delete" shortcut="⌘⌫" renderIcon={TrashCan16} />
</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 open={open} x={position[0]} y={position[1]}>
<ContextMenuOption label="Share with" renderIcon={FolderShared16}>
<ContextMenuRadioGroup
label="Share with"
items={['None', 'Product team', 'Organization', 'Company']}
initialSelectedItem="Product team"
/>
</ContextMenuOption>
<ContextMenuDivider />
<ContextMenuOption label="Cut" shortcut="⌘X" />
<ContextMenuOption label="Copy" shortcut="⌘C" />
<ContextMenuOption label="Copy path" shortcut="⌥⌘C" />
<ContextMenuOption label="Paste" shortcut="⌘V" disabled />
<ContextMenuOption label="Duplicate" />
<ContextMenuDivider />
<SelectableContextMenuOption label="Publish" initialChecked />
<ContextMenuDivider />
<ContextMenuOption label="Rename" shortcut="↩︎" renderIcon={Edit16} />
<ContextMenuOption label="Delete" shortcut="⌘⌫" renderIcon={TrashCan16} />
</ContextMenu>
);
};

_ContextMenu.storyName = 'ContextMenu';
87 changes: 77 additions & 10 deletions packages/react/src/components/ContextMenu/ContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -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();
}
}
}

Expand Down Expand Up @@ -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 ||
Expand All @@ -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
<ul ref={rootRef} className={classes} onKeyDown={handleKeyDown}>
<ul
ref={rootRef}
className={classes}
onKeyDown={handleKeyDown}
data-level={level}
style={open ? { left: `${x}px`, top: `${y}px` } : null}
role="menu">
{options}
</ul>
);
Expand All @@ -149,10 +201,25 @@ ContextMenu.propTypes = {
*/
children: PropTypes.node,

/**
* Internal: keeps track of the nesting level of the menu
*/
level: PropTypes.number,

/**
* Specify whether the ContextMenu is currently open
*/
open: PropTypes.bool,

/**
* Specify the x position where this menu is rendered
*/
x: PropTypes.number,

/**
* Specify the y position where this menu is rendered
*/
y: PropTypes.number,
};

export default ContextMenu;
25 changes: 23 additions & 2 deletions packages/react/src/components/ContextMenu/ContextMenuOption.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function ContextMenuOption({
shortcut,
renderIcon,
indented,
level,
menuX,
...rest
}) {
const subOptions = React.Children.map(children, (node) => {
Expand All @@ -60,7 +62,7 @@ function ContextMenuOption({
});

return (
<li {...rest} className={classes}>
<li {...rest} className={classes} role="menuitem">
{subOptions ? (
<>
<ContextMenuOptionContent
Expand All @@ -69,7 +71,9 @@ function ContextMenuOption({
info={<CaretRight16 />}
indented={indented}
/>
<ContextMenu>{subOptions}</ContextMenu>
<ContextMenu level={level + 1} x={menuX}>
{subOptions}
</ContextMenu>
</>
) : (
<ContextMenuOptionContent
Expand Down Expand Up @@ -109,6 +113,11 @@ ContextMenuOptionContent.propTypes = {
* Rendered label for the ContextMenuOptionContent
*/
label: PropTypes.node.isRequired,

/**
* Which nested level this option is located in.
*/
level: PropTypes.number,
};

ContextMenuOption.propTypes = {
Expand All @@ -133,6 +142,18 @@ ContextMenuOption.propTypes = {
*/
label: PropTypes.node.isRequired,

/**
* Which nested level this option is located in.
* Is automatically set by ContextMenu
*/
level: PropTypes.number,

/**
* The x position of the root menu.
* Is automatically set by ContextMenu
*/
menuX: PropTypes.number,

/**
* Rendered icon for the ContextMenuOption.
* Can be a React component class
Expand Down

0 comments on commit 0f6c22d

Please sign in to comment.