UNPKG

@itwin/core-react

Version:

A react component library of iTwin.js UI general purpose components

370 lines 15.7 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module ContextMenu */ import "./ContextMenu.scss"; import classnames from "classnames"; import * as React from "react"; import { Key } from "ts-key-enum"; import { ConditionalBooleanValue } from "@itwin/appui-abstract"; import { DivWithOutsideClick } from "../base/DivWithOutsideClick.js"; import { ContextMenuDirection } from "./ContextMenuDirection.js"; import { ContextMenuItem } from "./ContextMenuItem.js"; import { ContextSubMenu } from "./ContextSubMenu.js"; /** A context menu populated with [[ContextMenuItem]] components. * Can be nested using [[ContextSubMenu]] component. * @public * @deprecated in 4.16.0. Use {@link https://itwinui.bentley.com/docs/dropdownmenu iTwinUI DropdownMenu} component instead. */ export class ContextMenu extends React.PureComponent { _rootElement = null; _menuElement = null; _selectedElement = null; _length = 0; _hotKeyMap = new Map(); _lastChildren; _lastDirection = ContextMenuDirection.BottomRight; _lastSelectedIndex = 0; _injectedChildren; _parentWindowHeight = 0; _parentWindowWidth = 0; static defaultProps = { direction: ContextMenuDirection.BottomRight, autoflip: true, edgeLimit: true, hotkeySelect: true, selectedIndex: -1, floating: true, }; state; constructor(props) { super(props); this.state = { selectedIndex: this.props.selectedIndex, direction: props.direction, ignoreNextKeyUp: props.ignoreNextKeyUp, }; } /** @internal */ static autoFlip = (dir, rect, windowWidth, windowHeight) => { if (rect.right > windowWidth) { switch (dir) { case ContextMenuDirection.TopRight: dir = ContextMenuDirection.TopLeft; break; case ContextMenuDirection.Right: dir = ContextMenuDirection.Left; break; case ContextMenuDirection.BottomRight: dir = ContextMenuDirection.BottomLeft; break; } } if (rect.left < 0) { switch (dir) { case ContextMenuDirection.TopLeft: dir = ContextMenuDirection.TopRight; break; case ContextMenuDirection.Left: dir = ContextMenuDirection.Right; break; case ContextMenuDirection.BottomLeft: dir = ContextMenuDirection.BottomRight; break; } } if (rect.bottom > windowHeight) { switch (dir) { case ContextMenuDirection.BottomLeft: dir = ContextMenuDirection.TopLeft; break; case ContextMenuDirection.Bottom: dir = ContextMenuDirection.Top; break; case ContextMenuDirection.BottomRight: dir = ContextMenuDirection.TopRight; break; } } if (rect.top < 0) { switch (dir) { case ContextMenuDirection.TopLeft: dir = ContextMenuDirection.BottomLeft; break; case ContextMenuDirection.Top: dir = ContextMenuDirection.Bottom; break; case ContextMenuDirection.TopRight: dir = ContextMenuDirection.BottomRight; break; } } return dir; }; _handleHotKeyParsed = (index, hotKey) => { this._hotKeyMap.set(index, hotKey); }; _handleOnOutsideClick = (event) => { if (this.props.opened && this.props.onOutsideClick) this.props.onOutsideClick(event); }; render() { const { opened, direction, onOutsideClick, onSelect, onEsc, autoflip, edgeLimit, hotkeySelect, selectedIndex, floating, parentMenu, parentSubmenu, children, className, ignoreNextKeyUp, offset = 0, ...props } = this.props; const renderDirection = parentMenu === undefined ? this.state.direction : direction; if (this._lastChildren !== children || this._lastDirection !== renderDirection || this._lastSelectedIndex !== this.state.selectedIndex) { this._injectedChildren = this._injectMenuItemProps(children, renderDirection, this.state.selectedIndex); this._lastChildren = children; this._lastDirection = renderDirection; this._lastSelectedIndex = this.state.selectedIndex; } return (React.createElement("div", { role: "presentation", className: classnames("core-context-menu", className), onKeyUp: this._handleKeyUp, onClick: this._handleClick, "data-testid": "core-context-menu-root", ...props, ref: this._rootRef }, React.createElement(DivWithOutsideClick, { onOutsideClick: this._handleOnOutsideClick }, React.createElement("div", { ref: this._menuRef, role: "menu", tabIndex: 0, "data-testid": "core-context-menu-container", className: classnames("core-context-menu-container", opened && "core-context-menu-opened", floating && "core-context-menu-floating", ContextMenu.getCSSClassNameFromDirection(renderDirection)), style: { "--_core-context-menu-offset": `${offset}px`, } }, this._injectedChildren)))); } /** @internal */ static getCSSClassNameFromDirection = (direction) => { let className = ""; if (direction === undefined) direction = ContextMenuDirection.BottomRight; if (direction === ContextMenuDirection.None) return ""; switch (direction) { case ContextMenuDirection.TopLeft: className = "core-context-menu-top core-context-menu-left"; break; case ContextMenuDirection.Top: className = "core-context-menu-top"; break; case ContextMenuDirection.TopRight: className = "core-context-menu-top core-context-menu-right"; break; case ContextMenuDirection.Left: className = "core-context-menu-left"; break; case ContextMenuDirection.Center: className = "core-context-menu-center"; break; case ContextMenuDirection.Right: className = "core-context-menu-right"; break; case ContextMenuDirection.BottomLeft: className = "core-context-menu-bottom core-context-menu-left"; break; case ContextMenuDirection.Bottom: className = "core-context-menu-bottom"; break; case ContextMenuDirection.BottomRight: className = "core-context-menu-bottom core-context-menu-right"; break; } return className; }; _injectMenuItemProps = (children, direction, selectedIndex) => { let index = 0; // add inheritance data to submenu children const ch = React.Children.map(children, (child) => { // Capture only ContextSubMenus and ContextMenuItems. if (child && typeof child === "object" && "props" in child && (child.type === ContextSubMenu || child.type === ContextMenuItem) && React.isValidElement(child) && !ConditionalBooleanValue.getValue(child.props.disabled) && !ConditionalBooleanValue.getValue(child.props.hidden)) { const id = index; // get separate id variable so value stays the same when onHover is called later. const onHover = () => { this.setState({ selectedIndex: id }); this.focus(); }; const ref = (el) => { if (selectedIndex === id) // only save to this._selectedElement if previously captured bool is true this._selectedElement = el; }; const boundHandleHotKeyParse = this._handleHotKeyParsed.bind(this, id); // bind local callback for specific index const childProps = { parentMenu: this, ref, onHover, isSelected: selectedIndex === id, onHotKeyParsed: boundHandleHotKeyParse, }; if (child.type === ContextSubMenu && React.isValidElement(child)) { // add direction only to sub-menus childProps.direction = child.props.direction || direction; } index++; return React.cloneElement(child, childProps); } else return child; // Else, pass through unmodified }); this._length = index; return ch; }; _rootRef = (el) => { this._rootElement = el; }; _menuRef = (el) => { this._menuElement = el; }; getWindow() { const el = this._rootElement ? this._rootElement : this._menuElement; const parentDocument = el.ownerDocument; return parentDocument.defaultView ?? window; } componentDidMount() { const parentWindow = this.getWindow(); parentWindow.addEventListener("focus", this._handleFocusChange); parentWindow.addEventListener("mouseup", this._handleFocusChange); this.checkRenderDirection(); if (this.props.opened) this.focus(); } componentWillUnmount() { const parentWindow = this.getWindow(); parentWindow.removeEventListener("focus", this._handleFocusChange); parentWindow.removeEventListener("mouseup", this._handleFocusChange); } checkRenderDirection() { const { direction, autoflip, parentMenu } = this.props; const parentWindow = this.getWindow(); let renderDirection = parentMenu === undefined ? this.state.direction : direction; if (parentWindow.innerHeight === this._parentWindowHeight && parentWindow.innerWidth === this._parentWindowWidth) return; this._parentWindowHeight = parentWindow.innerHeight; this._parentWindowWidth = parentWindow.innerWidth; // check if menu should flip if (parentWindow && autoflip && parentMenu === undefined) { const menuRect = this.getRect(); renderDirection = ContextMenu.autoFlip(renderDirection, menuRect, parentWindow.innerWidth, parentWindow.innerHeight); if (renderDirection !== this.state.direction) this.setState({ direction: renderDirection }); } } focus = () => { if (this._menuElement) this._menuElement.focus(); }; blur = () => { if (this._menuElement) this._menuElement.blur(); }; getRect = () => { let clientRect = DOMRect.fromRect({ x: 0, y: 0, width: 0, height: 0 }); if (this._menuElement) { clientRect = this._menuElement.getBoundingClientRect(); } return clientRect; }; _handleFocusChange = (event) => { if (this._rootElement && this.props.opened && event.target instanceof Node && this.props.onOutsideClick && !this._rootElement.contains(event.target)) this.props.onOutsideClick(event); }; _handleClick = (event) => { if (this.props.onSelect) this.props.onSelect(event); }; _handleKeyUp = (event) => { if (this.state.ignoreNextKeyUp) { this.setState({ ignoreNextKeyUp: false }); return; } if (event.key) { for (const [key, value] of this._hotKeyMap) { if (!this.props.hotkeySelect && key > this.state.selectedIndex) { // Start search at current index. if (event.key.toUpperCase() === value) { this.setState({ selectedIndex: key }); return; } } } for (const [key, value] of this._hotKeyMap) { if (event.key.toUpperCase() === value) { this.setState({ selectedIndex: key }, () => { if (this.props.hotkeySelect && this._selectedElement) { this._selectedElement.select(); } }); event.stopPropagation(); return; } } } if (event.key === Key.ArrowLeft.valueOf()) { event.stopPropagation(); if (this.props.parentMenu && this.props.parentSubmenu) { this.props.parentSubmenu.close(); this.props.parentMenu.focus(); } if (this.props.onEsc) this.props.onEsc(event); } if (event.key === Key.Escape.valueOf()) { if (this.props.onEsc) this.props.onEsc(event); } if ((event.key === Key.Enter.valueOf() || event.key === Key.ArrowRight.valueOf()) && this._selectedElement) { event.stopPropagation(); if (event.key === Key.Enter.valueOf() || this._selectedElement instanceof ContextSubMenu) { if (this._selectedElement.select) this._selectedElement.select(); } } let { selectedIndex } = this.state; if (event.key === Key.ArrowUp.valueOf() || event.key === Key.ArrowDown.valueOf()) { event.stopPropagation(); if (selectedIndex === -1) { selectedIndex = 0; } else { if (event.key === Key.ArrowUp.valueOf()) { if (this.state.selectedIndex === 0) selectedIndex = this._length - 1; else selectedIndex--; } if (event.key === Key.ArrowDown.valueOf()) { if (this.state.selectedIndex === this._length - 1) selectedIndex = 0; else selectedIndex++; } } } this.setState({ selectedIndex }); }; /** @internal */ componentDidUpdate(prevProps) { if (prevProps.selectedIndex !== this.props.selectedIndex) { this.setState((_, props) => ({ selectedIndex: props.selectedIndex })); } if (!prevProps.opened && this.props.opened) { this.setState((_, props) => ({ selectedIndex: props.selectedIndex })); } if (!this.props.parentMenu) { // const direction = this.props.direction!; // if ((!this.props.opened && prevProps.opened && direction !== this.state.direction) || prevProps.direction !== direction) this.checkRenderDirection(); } } } //# sourceMappingURL=ContextMenu.js.map