UNPKG

@react-md/menu

Version:

Create menus that auto-position themselves within the viewport and adhere to the accessibility guidelines

313 lines 15.5 kB
"use strict"; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.useMenu = void 0; var react_1 = require("react"); var transition_1 = require("@react-md/transition"); var utils_1 = require("@react-md/utils"); var MenuBarProvider_1 = require("./MenuBarProvider"); var utils_2 = require("./utils"); /** * This hook provides all the functionality for a menu to: * - toggle the `Menu`'s visibility when the `MenuButton` or `MenuItemButton` * has been clicked * - hide the `Menu` when an element outside of the `Menu` has been clicked * - hide the `Menu` when the `Escape` or `Tab` key has been pressed * - focus the `Menu` element when it gains visibility * - refocus the `MenuButton` or `MenuItemButton` when the menu loses visibility * - position the menu within the viewport with {@link useFixedPositioning} * - show the `Menu` when the `ArrowRight` key is pressed for a vertical * `MenuItemButton` * - show the `Menu` when the `ArrowDown` key is pressed for a horizontal * `MenuItemButton` * - hide the `Menu` when the `ArrowRight` key is pressed in a vertical submenu * - hide the `Menu` when the `ArrowDown` key is pressed in a horizontal * submenu * - conditionally hide the `Menu` if the page is scrolled while the `Menu` is * visible * - conditionally hide the `Menu` if the page is resized while the `Menu` is * visible * - conditionally move focus to the next `DropdownMenu` with keyboard movement * when inside of a `MenuBar` * - conditionally enable the visibility for a `DropdownMenu` when the mouse * hovers over a `MenuItemButton` with a parent `MenuBar` that has been * activated * - conditionally show/hide the `Menu` based on a parent `MenuBar`'s `activeId` * * This hook will probably never need to be used externally since it has been * integrated into the `DropdownMenu` component and `useContextMenu` hook. * * @example * Simple Example * ```tsx * import { ReactElement, useState } from "react"; * import { useMenu, Menu, MenuButton, MenuItem } from "@react-md/menu"; * * function Example(): ReactElement { * const [visible, setVisible] = useState(false); * const { menuRef, menuProps, toggleRef, toggleProps } = useMenu< * HTMLButtonElement * >({ * baseId: "custom-menu-button", * visible, * setVisible, * }); * * return ( * <> * <MenuButton ref={toggleRef} {...toggleProps}> * Button * </MenuButton> * <Menu ref={menuRef} {...menuProps}> * <MenuItem>Item 1</MenuItem> * <MenuItem>Item 2</MenuItem> * <MenuItem>Item 3</MenuItem> * </Menu> * </> * ); * } * ``` * * @remarks \@since 5.0.0 */ function useMenu(options) { var baseId = options.baseId, _a = options.disabled, disabled = _a === void 0 ? false : _a, propStyle = options.style, menuLabel = options.menuLabel, visible = options.visible, setVisible = options.setVisible, _b = options.floating, floating = _b === void 0 ? null : _b, _c = options.onMenuClick, onMenuClick = _c === void 0 ? utils_2.noop : _c, _d = options.onMenuKeyDown, onMenuKeyDown = _d === void 0 ? utils_2.noop : _d, _e = options.onToggleClick, onToggleClick = _e === void 0 ? utils_2.noop : _e, _f = options.onToggleKeyDown, onToggleKeyDown = _f === void 0 ? utils_2.noop : _f, _g = options.onToggleMouseEnter, onToggleMouseEnter = _g === void 0 ? utils_2.noop : _g, _h = options.onToggleMouseLeave, onToggleMouseLeave = _h === void 0 ? utils_2.noop : _h, _j = options.menuitem, menuitem = _j === void 0 ? false : _j, _k = options.horizontal, horizontal = _k === void 0 ? false : _k, propAnchor = options.anchor, fixedPositionOptions = options.fixedPositionOptions, getFixedPositionOptions = options.getFixedPositionOptions, _l = options.closeOnResize, closeOnResize = _l === void 0 ? false : _l, _m = options.closeOnScroll, closeOnScroll = _m === void 0 ? false : _m, onEnter = options.onEnter, onEntering = options.onEntering, _o = options.onEntered, onEntered = _o === void 0 ? utils_2.noop : _o, _p = options.onExited, onExited = _p === void 0 ? utils_2.noop : _p, _q = options.onFixedPositionScroll, onFixedPositionScroll = _q === void 0 ? utils_2.noop : _q, _r = options.onFixedPositionResize, onFixedPositionResize = _r === void 0 ? utils_2.noop : _r, _s = options.preventScroll, preventScroll = _s === void 0 ? false : _s, _t = options.disableFocusOnMount, disableFocusOnMount = _t === void 0 ? false : _t, _u = options.disableFocusOnUnmount, disableFocusOnUnmount = _u === void 0 ? false : _u; var _v = (0, MenuBarProvider_1.useMenuBarContext)(), root = _v.root, menubar = _v.menubar, activeId = _v.activeId, setActiveId = _v.setActiveId, hoverTimeout = _v.hoverTimeout, setAnimatedOnce = _v.setAnimatedOnce; var touch = (0, utils_1.useIsUserInteractionMode)("touch"); var timeout = (0, react_1.useRef)(); (0, react_1.useEffect)(function () { return function () { window.clearTimeout(timeout.current); }; }, []); // if the menu hides because the user scrolls the page or the page is resized, // the focus toggle behavior should be disabled since the user is no longer // interacting with the menu var cancelExitFocus = (0, react_1.useRef)(false); var anchor = propAnchor !== null && propAnchor !== void 0 ? propAnchor : (0, utils_2.getDefaultAnchor)({ menubar: menubar, menuitem: menuitem, floating: floating, horizontal: horizontal }); var menuNodeRef = (0, react_1.useRef)(null); var toggleRef = (0, react_1.useRef)(null); var _w = (0, transition_1.useFixedPositioning)(__assign(__assign({ nodeRef: menuNodeRef, style: propStyle, fixedTo: toggleRef, onEnter: onEnter, onEntering: onEntering, onEntered: function (appearing) { var _a; cancelExitFocus.current = false; onEntered(appearing); setAnimatedOnce(true); if (!disableFocusOnMount) { (_a = menuNodeRef.current) === null || _a === void 0 ? void 0 : _a.focus(); } }, onExited: function () { var _a; onExited(); // this has to be done onExited or else the toggle component will be // clicked if the user pressed the "Enter" key which makes it look like // the menu never closes. if (!disableFocusOnUnmount && !cancelExitFocus.current) { (_a = toggleRef.current) === null || _a === void 0 ? void 0 : _a.focus(); } }, anchor: anchor, transformOrigin: true }, fixedPositionOptions), { getFixedPositionOptions: getFixedPositionOptions, onScroll: function (event, data) { onFixedPositionScroll(event, data); if (!data.visible || closeOnScroll) { cancelExitFocus.current = true; setVisible(false); } }, onResize: function (event) { onFixedPositionResize(event); if (closeOnResize) { cancelExitFocus.current = true; setVisible(false); } } })), style = _w.style, _x = _w.transitionOptions, nodeRef = _x.nodeRef, transitionOptions = __rest(_x, ["nodeRef"]); (0, utils_1.useScrollLock)(preventScroll && visible); (0, react_1.useEffect)(function () { if (!visible) { return; } var handler = function (_a) { var _b, _c; var target = _a.target; if (!(target instanceof Element) || (!((_b = menuNodeRef.current) === null || _b === void 0 ? void 0 : _b.contains(target)) && !((_c = toggleRef.current) === null || _c === void 0 ? void 0 : _c.contains(target)))) { setVisible(false); } }; window.addEventListener("click", handler); return function () { window.removeEventListener("click", handler); }; }, [menuNodeRef, setVisible, toggleRef, visible]); (0, react_1.useEffect)(function () { var _a; if (visible) { return; } // this is to fix keyboard movement behavior when navigating between // different root-level menuitems with the `ArrowLeft` and `ArrowRight` keys // while menus are visible. If the exit focus behavior is not cancelled, the // next menu's menu will be visible, but the current menu's menuitem would // be the current focus which breaks everything cancelExitFocus.current = cancelExitFocus.current || !((_a = menuNodeRef.current) === null || _a === void 0 ? void 0 : _a.contains(document.activeElement)); setActiveId(function (prevActiveId) { return baseId === prevActiveId ? "" : prevActiveId; }); }, [baseId, root, setActiveId, visible]); (0, react_1.useEffect)(function () { setVisible(baseId === activeId); }, [activeId, baseId, root, setVisible]); return { menuRef: nodeRef, menuProps: __assign(__assign({ // typecast to string so that it passes the RequireAtLeastOne<LabelA11y> // TS won't pass otherwise "aria-label": menuLabel, "aria-labelledby": menuLabel ? undefined : baseId, id: "".concat(baseId, "-menu"), style: style }, transitionOptions), { visible: visible, onClick: function (event) { onMenuClick(event); if (event.isPropagationStopped()) { return; } // this makes it so you can click on the menu/list without closing the // menu if (event.currentTarget === event.target) { return; } // This might be a test only workaround since clicking links move focus // somewhere else if (event.target instanceof HTMLElement) { cancelExitFocus.current = (0, utils_1.containsElement)(event.currentTarget, event.target.closest("a")); } setVisible(false); }, onKeyDown: function (event) { onMenuKeyDown(event); if (event.isPropagationStopped()) { return; } switch (event.key) { case "Escape": // prevent parent components that have an "Escape" keypress event // from being triggered as well event.stopPropagation(); setVisible(false); break; case "Tab": // since menus are portalled, tab index is kinda broke so just close // the menu instead of doing default tab behavior event.preventDefault(); if (!menuitem) { // pressing the tab key should still cascade close all menus event.stopPropagation(); } setVisible(false); break; case "ArrowUp": if (menuitem && horizontal) { event.stopPropagation(); event.preventDefault(); setVisible(false); } break; case "ArrowLeft": if (menuitem && !horizontal) { event.stopPropagation(); event.preventDefault(); setVisible(false); } break; } } }), menuNodeRef: menuNodeRef, toggleRef: toggleRef, toggleProps: { "aria-haspopup": "menu", "aria-expanded": visible || undefined, id: baseId, onClick: function (event) { onToggleClick(event); if (event.isPropagationStopped()) { return; } if (menuitem || menubar) { // do not allow the default menu close behavior from // triggering for parent menus event.stopPropagation(); } setVisible(function (prevVisible) { return !prevVisible; }); setActiveId(function (prevActiveId) { return (baseId === prevActiveId ? "" : baseId); }); }, onKeyDown: function (event) { onToggleKeyDown(event); if (event.isPropagationStopped() || disabled) { return; } if (menubar && !menuitem && event.key === "ArrowDown") { event.preventDefault(); event.stopPropagation(); setActiveId(baseId); return; } if (!menuitem) { return; } switch (event.key) { case "ArrowDown": if (horizontal) { event.stopPropagation(); event.preventDefault(); setVisible(true); } break; case "ArrowRight": if (!horizontal) { event.stopPropagation(); event.preventDefault(); setVisible(true); } break; } }, onMouseEnter: function (event) { onToggleMouseEnter(event); if (event.isPropagationStopped() || disabled || !menubar || !activeId || touch) { if (typeof hoverTimeout === "number") { timeout.current = window.setTimeout(function () { setActiveId(baseId); }, hoverTimeout); } return; } setActiveId(baseId); }, onMouseLeave: function (event) { onToggleMouseLeave(event); window.clearTimeout(timeout.current); }, }, }; } exports.useMenu = useMenu; //# sourceMappingURL=useMenu.js.map