@react-md/menu
Version:
Create menus that auto-position themselves within the viewport and adhere to the accessibility guidelines
313 lines • 15.5 kB
JavaScript
;
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