import "./menu.scss"; import React, { Fragment, ReactElement, ReactNode } from "react"; import { createPortal } from "react-dom"; import { autobind, cssNames, noop } from "../../utils"; import { Animate } from "../animate"; import { Icon, IconProps } from "../icon"; import debounce from "lodash/debounce"; export const MenuContext = React.createContext(null); export type MenuContextValue = Menu; export interface MenuPosition { left?: boolean; top?: boolean; right?: boolean; bottom?: boolean; } export interface MenuProps { isOpen?: boolean; open(): void; close(): void; id?: string; className?: string; htmlFor?: string; autoFocus?: boolean; usePortal?: boolean | HTMLElement; closeOnClickItem?: boolean; // close menu on item click closeOnClickOutside?: boolean; // use false value for sub-menus closeOnScroll?: boolean; // applicable when usePortal={true} position?: MenuPosition; // applicable when usePortal={false} children?: ReactNode; } interface State { position?: MenuPosition; } const defaultPropsMenu: Partial = { position: { right: true, bottom: true }, autoFocus: false, usePortal: false, closeOnClickItem: true, closeOnClickOutside: true, closeOnScroll: false, }; @autobind() export class Menu extends React.Component { static defaultProps = defaultPropsMenu as object; public opener: HTMLElement; public elem: HTMLUListElement; protected items: { [index: number]: MenuItem } = {}; public state: State = {}; get isOpen() { return !!this.props.isOpen; } componentDidMount() { if (!this.props.usePortal) { const parent = this.elem.parentElement; const position = window.getComputedStyle(parent).position; if (position === "static") parent.style.position = "relative"; } else if (this.isOpen) { this.refreshPosition(); } this.opener = document.getElementById(this.props.htmlFor); // might not exist in sub-menus if (this.opener) { this.opener.addEventListener("click", this.toggle); this.opener.addEventListener("keydown", this.onKeyDown); } this.elem.addEventListener("keydown", this.onKeyDown); window.addEventListener("resize", this.onWindowResize); window.addEventListener("click", this.onClickOutside, true); window.addEventListener("scroll", this.onScrollOutside, true); } componentWillUnmount() { if (this.opener) { this.opener.removeEventListener("click", this.toggle); this.opener.removeEventListener("keydown", this.onKeyDown); } this.elem.removeEventListener("keydown", this.onKeyDown); window.removeEventListener("resize", this.onWindowResize); window.removeEventListener("click", this.onClickOutside, true); window.removeEventListener("scroll", this.onScrollOutside, true); } protected get focusableItems() { return Object.values(this.items).filter(item => item.isFocusable); } protected get focusedItem() { return this.focusableItems.find(item => item.elem === document.activeElement); } protected focusNextItem(reverse = false) { const items = this.focusableItems; const activeIndex = items.findIndex(item => item === this.focusedItem); if (!items.length) { return; } if (activeIndex > -1) { let nextItem = reverse ? items[activeIndex - 1] : items[activeIndex + 1]; if (!nextItem) nextItem = items[activeIndex]; nextItem.elem.focus(); } else { items[0].elem.focus(); } } refreshPosition = debounce(() => { if (!this.props.usePortal || !this.opener) return; const { width, height } = this.opener.getBoundingClientRect(); let { left, top, bottom, right } = this.opener.getBoundingClientRect(); const withScroll = window.getComputedStyle(this.elem).position !== "fixed"; // window global scroll corrections if (withScroll) { left += window.pageXOffset; top += window.pageYOffset; right = left + width; bottom = top + height; } // setup initial position const position: MenuPosition = { left: true, bottom: true }; this.elem.style.left = `${left}px`; this.elem.style.top = `${bottom}px`; // correct position if menu doesn't fit to viewport const menuPos = this.elem.getBoundingClientRect(); if (menuPos.right > window.innerWidth) { this.elem.style.left = `${right - this.elem.offsetWidth}px`; position.right = true; delete position.left; } if (menuPos.bottom > window.innerHeight) { this.elem.style.top = `${top - this.elem.offsetHeight}px`; position.top = true; delete position.bottom; } this.setState({ position }); }, Animate.VISIBILITY_DELAY_MS); open() { if (this.isOpen) return; this.props.open(); this.refreshPosition(); if (this.props.autoFocus) this.focusNextItem(); } close() { if (!this.isOpen) return; this.props.close(); } toggle() { this.isOpen ? this.close() : this.open(); } onKeyDown(evt: KeyboardEvent) { if (!this.isOpen) return; switch (evt.code) { case "Escape": this.close(); break; case "Space": case "Enter": const focusedItem = this.focusedItem; if (focusedItem) { focusedItem.elem.click(); evt.preventDefault(); } break; case "ArrowUp": this.focusNextItem(true); break; case "ArrowDown": this.focusNextItem(); break; } } onWindowResize() { if (!this.isOpen) return; this.refreshPosition(); } onScrollOutside(evt: UIEvent) { if (!this.isOpen) return; const target = evt.target as HTMLElement; const { usePortal, closeOnScroll } = this.props; if (usePortal && closeOnScroll && !target.contains(this.elem)) { this.close(); } } onClickOutside(evt: MouseEvent) { if (!this.props.closeOnClickOutside) return; if (!this.isOpen || evt.target === document.body) return; const target = evt.target as HTMLElement; const clickInsideMenu = this.elem.contains(target); const clickOnOpener = this.opener && this.opener.contains(target); if (!clickInsideMenu && !clickOnOpener) { this.close(); } } protected bindRef(elem: HTMLUListElement) { this.elem = elem; } protected bindItemRef(item: MenuItem, index: number) { this.items[index] = item; } render() { const { position, id } = this.props; let { className, usePortal } = this.props; className = cssNames("Menu", className, this.state.position || position, { portal: usePortal, }); let children = this.props.children as ReactElement; if (children.type === Fragment) { children = children.props.children; } const menuItems = React.Children.toArray(children).map((item: ReactElement, index) => { if (item.type === MenuItem) { return React.cloneElement(item, { ref: (item: MenuItem) => this.bindItemRef(item, index) }); } return item; }); const menu = (
    {menuItems}
); if (usePortal === true) usePortal = document.body; return usePortal instanceof HTMLElement ? createPortal(menu, usePortal) : menu; } } export function SubMenu(props: Partial) { const { className, ...menuProps } = props; return ( ); } export interface MenuItemProps extends React.HTMLProps { icon?: string | Partial; disabled?: boolean; active?: boolean; spacer?: boolean; href?: string; } const defaultPropsMenuItem: Partial = { onClick: noop, }; @autobind() export class MenuItem extends React.Component { static defaultProps = defaultPropsMenuItem as object; static contextType = MenuContext; public context: MenuContextValue; public elem: HTMLElement; get isFocusable() { const { disabled, spacer } = this.props; return !(disabled || spacer); } get isLink() { return !!this.props.href; } onClick(evt: React.MouseEvent) { const menu = this.context; const { spacer, onClick } = this.props; if (spacer) return; onClick(evt); if (menu.props.closeOnClickItem && !evt.defaultPrevented) { menu.close(); } } protected bindRef(elem: HTMLElement) { this.elem = elem; } render() { const { className, disabled, active, spacer, icon, children, ...props } = this.props; let iconProps: Partial; if (icon) { iconProps = {}; if (typeof icon === "string") iconProps.material = icon; else Object.assign(iconProps, icon); } const elemProps: React.HTMLProps = { tabIndex: this.isFocusable ? 0 : -1, ...props, className: cssNames("MenuItem", className, { disabled, active, spacer }), onClick: this.onClick, children: icon ? <> {children} : children, ref: this.bindRef, }; if (this.isLink) { return ; } return
  • ; } }