import React, { createContext, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import DividerItem from './DividerItem'; import PanelSelector from './PanelSelector'; import classNames from 'classnames'; import BackItem from './BackItem'; /** * The vertical direction that the menu will be opened/used with. * * A TopToBottom menu would be used for cases where the menu is opened "near" * the top edge of its container. Likewise a BottomToTop menu would be used * for cases where the menu is opened "near" the bottom edge of its container. * * See IconMenu for more information. */ export enum VerticalDirection { TopToBottom, BottomToTop, } /** * The horizontal direction that the menu is opened/used with. * This direction dictates the general direction sub-menus and * back-to-menus are opened with. For example, a RightToLeft menu * will have sub-menu items indicated with a left pointing chevron * and aligned with the left edge of the menu. Similarly back-to items of a * RightToLeft menu are indicated with a right pointing chevron and * aligned with the right edge of the menu. * * It is also worth noting that a LeftToRight menu would be used for * cases where a menu is opened "near" the left edge of its container. * Likewise, a RightToLeft menu would be used for cases where a menu is opened * "near" the right edge of its container. * * See IconMenu for more information. */ export enum HorizontalDirection { LeftToRight, RightToLeft, } export interface MenuProps { menuStyle?: unknown; menuClassName?: string; isVisible?: boolean; preventHideMenu?: boolean; backLabel?: string; headerComponent?: ReactNode; showHeaderDivider?: boolean; activePanelIndex?: number; onVisibilityChange?: (isVisible: boolean) => void; // New props that can be used as alternatives to horizontalDirection and verticalDirection align?: 'start' | 'end' | 'center'; side?: 'top' | 'bottom' | 'left' | 'right'; children: ReactNode; } type MenuContextProps = { showSubMenu: (subMenuProps: MenuProps) => void; hideMenu: () => void; addItemPanel: (index: number, label: string) => void; horizontalDirection: HorizontalDirection; verticalDirection?: VerticalDirection; activePanelIndex: number; }; type MenuPathState = { props: MenuProps; activePanelIndex: number; }; export const MenuContext = createContext(null); const Menu = (props: MenuProps) => { const { isVisible, onVisibilityChange, activePanelIndex, preventHideMenu, menuClassName, menuStyle, align, side, } = props; // Derive horizontalDirection and verticalDirection from align and side if provided let horizontalDirection = HorizontalDirection.LeftToRight; let verticalDirection = VerticalDirection.BottomToTop; if (align !== undefined) { horizontalDirection = align === 'start' ? HorizontalDirection.LeftToRight : HorizontalDirection.RightToLeft; } if (side !== undefined) { verticalDirection = side === 'bottom' ? VerticalDirection.TopToBottom : VerticalDirection.BottomToTop; } const [isMenuVisible, setIsMenuVisible] = useState(isVisible); // The menuPath is an array consisting of this top Menu and every SubMenu // that has been traversed/opened by the user with the last item in the array // being the current (sub)menu that is currently visible. This allows for the previously // viewed menus to be returned to via the Back button at the top of the menu. const [menuPath, setMenuPath] = useState>([ { props, activePanelIndex: activePanelIndex || 0 }, ]); const [itemPanelLabels, setItemPanelLabels] = useState>([]); // Store latest function references in refs so we can use them in effects // without triggering re-runs when they change const onVisibilityChangeRef = useRef(onVisibilityChange); const preventHideMenuRef = useRef(preventHideMenu); // Keep refs up to date useEffect(() => { onVisibilityChangeRef.current = onVisibilityChange; }, [onVisibilityChange]); useEffect(() => { preventHideMenuRef.current = preventHideMenu; }, [preventHideMenu]); // If the props change for the this top level menu then we have to update the menu path // because the props to be rendered are maintained in the state. useEffect(() => { setMenuPath(menuPath => [ { props, activePanelIndex: activePanelIndex || 0 }, ...menuPath.slice(1), ]); }, [activePanelIndex, props]); const hideMenu = useCallback(() => { if (preventHideMenuRef.current) { return; } setMenuPath(path => [path[0]]); setItemPanelLabels([]); setIsMenuVisible(false); onVisibilityChangeRef.current?.(false); }, []); // Only run this effect when isVisible changes, not when functions change. // Note that hideMenu is stable with no dependencies and thus will not trigger the useEffect to run. useEffect(() => { if (isVisible) { setIsMenuVisible(isVisible); onVisibilityChangeRef.current?.(isVisible); } else { hideMenu(); } }, [isVisible, hideMenu]); const showSubMenu = useCallback((subMenuProps: MenuProps) => { setMenuPath(path => { return [ ...path, { props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 }, ]; }); setItemPanelLabels([]); }, []); const addItemPanel = useCallback((index, label) => { setItemPanelLabels(labels => { return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)]; }); }, []); const onActivePanelIndexChange = useCallback(index => { setMenuPath(path => { return [ ...path.slice(0, path.length - 1), { ...path[path.length - 1], activePanelIndex: index }, ]; }); }, []); const onBackClick = useCallback(() => { setMenuPath(path => [...path.slice(0, path.length - 1)]); setItemPanelLabels([]); }, []); const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } = menuPath[menuPath.length - 1]; return ( <> {isMenuVisible && (
{menuPath.length > 1 && ( )} {itemPanelLabels.length > 1 && ( )} {currentMenuProps.headerComponent} {currentMenuProps.showHeaderDivider && } {currentMenuProps.children}
)}
); }; export default Menu;