@navikt/ds-react
Version:
React components from the Norwegian Labour and Welfare Administration.
425 lines • 25.1 kB
JavaScript
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;
};
import React, { forwardRef, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { Portal } from "../../portal/index.js";
import { useId } from "../../utils-external/index.js";
import { FocusBoundary } from "../../utils/components/focus-boundary/FocusBoundary.js";
import { composeEventHandlers, createStrictContext } from "../../utils/helpers/index.js";
import { createDescendantContext, useEventCallback, useMergeRefs, } from "../../utils/hooks/index.js";
import { DismissableLayer } from "../dismissablelayer/DismissableLayer.js";
import { Floating } from "../floating/Floating.js";
import { RovingFocus } from "./parts/RovingFocus.js";
import { SlottedDivElement, } from "./parts/SlottedDivElement.js";
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"];
const LAST_KEYS = ["ArrowUp", "PageDown", "End"];
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
const [MenuDescendantsProvider, useMenuDescendantsContext, useMenuDescendants, useMenuDescendant,] = createDescendantContext();
const { Provider: MenuProvider, useContext: useMenuContext } = createStrictContext({
name: "MenuContext",
});
const { Provider: MenuRootProvider, useContext: useMenuRootContext } = createStrictContext({
name: "MenuRootContext",
});
const MenuRoot = ({ open = false, children, onOpenChange, modal = true, }) => {
const [content, setContent] = useState(null);
const isUsingKeyboardRef = useRef(false);
const handleOpenChange = useEventCallback(onOpenChange);
useEffect(() => {
const globalDocument = globalThis.document;
// Capturephase ensures we set the boolean before any side effects execute
// in response to the key or pointer event as they might depend on this value.
const handlePointer = () => {
isUsingKeyboardRef.current = false;
};
const handleKeyDown = () => {
isUsingKeyboardRef.current = true;
globalDocument.addEventListener("pointerdown", handlePointer, {
capture: true,
once: true,
});
globalDocument.addEventListener("pointermove", handlePointer, {
capture: true,
once: true,
});
};
globalDocument.addEventListener("keydown", handleKeyDown, {
capture: true,
});
return () => {
globalDocument.removeEventListener("keydown", handleKeyDown, {
capture: true,
});
globalDocument.removeEventListener("pointerdown", handlePointer, {
capture: true,
});
globalDocument.removeEventListener("pointermove", handlePointer, {
capture: true,
});
};
}, []);
return (React.createElement(Floating, null,
React.createElement(MenuProvider, { open: open, onOpenChange: handleOpenChange, content: content, onContentChange: setContent },
React.createElement(MenuRootProvider, { onClose: React.useCallback(() => handleOpenChange(false), [handleOpenChange]), isUsingKeyboardRef: isUsingKeyboardRef, modal: modal }, children))));
};
const Menu = MenuRoot;
const MenuAnchor = forwardRef((props, forwardedRef) => {
return React.createElement(Floating.Anchor, Object.assign({}, props, { ref: forwardedRef }));
});
const MenuContent = React.forwardRef((props, ref) => {
const descendants = useMenuDescendants();
const rootContext = useMenuRootContext();
return (React.createElement(MenuDescendantsProvider, { value: descendants }, rootContext.modal ? (React.createElement(MenuRootContentModal, Object.assign({}, props, { ref: ref }))) : (React.createElement(MenuRootContentNonModal, Object.assign({}, props, { ref: ref })))));
});
/* ---------------------------- Non-modal content --------------------------- */
const MenuRootContentNonModal = React.forwardRef((props, ref) => {
const context = useMenuContext();
return (React.createElement(MenuContentInternal, Object.assign({}, props, { ref: ref, disableOutsidePointerEvents: false, onDismiss: () => context.onOpenChange(false) })));
});
/* ------------------------------ Modal content ----------------------------- */
const MenuRootContentModal = forwardRef((props, ref) => {
const context = useMenuContext();
return (React.createElement(MenuContentInternal, Object.assign({}, props, { ref: ref,
// make sure to only disable pointer events when open
// this avoids blocking interactions while animating out
disableOutsidePointerEvents: context.open,
// When focus is trapped, a `focusout` event may still happen.
// We make sure we don't trigger our `onDismiss` in such case.
onFocusOutside: composeEventHandlers(props.onFocusOutside, (event) => event.preventDefault(), { checkForDefaultPrevented: false }), onDismiss: () => context.onOpenChange(false) })));
});
const MenuContentInternal = forwardRef((_a, forwardedRef) => {
var { initialFocus, returnFocus, disableOutsidePointerEvents, onEntryFocus, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onDismiss, safeZone } = _a, rest = __rest(_a, ["initialFocus", "returnFocus", "disableOutsidePointerEvents", "onEntryFocus", "onEscapeKeyDown", "onPointerDownOutside", "onFocusOutside", "onInteractOutside", "onDismiss", "safeZone"]);
const descendants = useMenuDescendantsContext();
const context = useMenuContext();
const contentRef = useRef(null);
const composedRefs = useMergeRefs(forwardedRef, contentRef, context.onContentChange);
return (React.createElement(FocusBoundary, { initialFocus: initialFocus !== null && initialFocus !== void 0 ? initialFocus : contentRef, returnFocus: returnFocus,
/* Focus trapping is handled in `Floating.Content: onKeyDown */
trapped: false, loop: false },
React.createElement(DismissableLayer, { asChild: true, disableOutsidePointerEvents: disableOutsidePointerEvents, onEscapeKeyDown: onEscapeKeyDown, onPointerDownOutside: onPointerDownOutside, onFocusOutside: onFocusOutside, onInteractOutside: onInteractOutside, onDismiss: onDismiss, safeZone: safeZone },
React.createElement(RovingFocus, { asChild: true, descendants: descendants, onEntryFocus: composeEventHandlers(onEntryFocus, (event) => {
event.preventDefault();
}) },
React.createElement(Floating.Content, Object.assign({ role: "menu", "aria-orientation": "vertical", "data-state": getOpenState(context.open), "data-aksel-menu-content": "", dir: "ltr" }, rest, { ref: composedRefs, style: Object.assign({ outline: "none" }, rest.style), onKeyDown: composeEventHandlers(rest.onKeyDown, (event) => {
var _a, _b, _c, _d;
// submenu key events bubble through portals. We only care about keys in this menu.
const target = event.target;
const isKeyDownInside = target.closest("[data-aksel-menu-content]") ===
event.currentTarget;
if (isKeyDownInside) {
// menus should not be navigated using tab key so we prevent it
if (event.key === "Tab")
event.preventDefault();
}
// focus first/last item based on key pressed
const content = contentRef.current;
if (event.target !== content)
return;
if (!FIRST_LAST_KEYS.includes(event.key))
return;
event.preventDefault();
if (LAST_KEYS.includes(event.key)) {
(_b = (_a = descendants.lastEnabled()) === null || _a === void 0 ? void 0 : _a.node) === null || _b === void 0 ? void 0 : _b.focus();
return;
}
(_d = (_c = descendants.firstEnabled()) === null || _c === void 0 ? void 0 : _c.node) === null || _d === void 0 ? void 0 : _d.focus();
}) }))))));
});
/* -------------------------------------------------------------------------- */
/* Menu item */
/* -------------------------------------------------------------------------- */
const ITEM_SELECT_EVENT = "menu.itemSelect";
const MenuItem = forwardRef((_a, forwardedRef) => {
var { disabled = false, onSelect, onClick, onPointerUp, onPointerDown, onKeyDown, onKeyUp } = _a, rest = __rest(_a, ["disabled", "onSelect", "onClick", "onPointerUp", "onPointerDown", "onKeyDown", "onKeyUp"]);
const ref = useRef(null);
const rootContext = useMenuRootContext();
const composedRefs = useMergeRefs(forwardedRef, ref);
const isPointerDownRef = useRef(false);
const handleSelect = () => {
const menuItem = ref.current;
if (!disabled && menuItem && onSelect) {
const itemSelectEvent = new CustomEvent(ITEM_SELECT_EVENT, {
bubbles: true,
cancelable: true,
});
menuItem.addEventListener(ITEM_SELECT_EVENT, onSelect, { once: true });
/**
* We flush the event synchronously to ensure that the event is dispatched before other events react to side-effect from event.
* This is necessary to prevent the menu from potentially closing before we are able to prevent it.
*/
ReactDOM.flushSync(() => menuItem.dispatchEvent(itemSelectEvent));
if (itemSelectEvent.defaultPrevented) {
isPointerDownRef.current = false;
}
else {
rootContext.onClose();
}
}
else if (!disabled && menuItem) {
rootContext.onClose();
}
};
const handleKey = (event, key) => {
if (disabled || event.repeat) {
return;
}
if (key === event.key) {
event.currentTarget.click();
/**
* We prevent default browser behaviour for selection keys as they should only trigger
* selection.
* - Prevents space from scrolling the page.
* - If keydown causes focus to move, prevents keydown from firing on the new target.
*/
event.preventDefault();
}
};
return (React.createElement(MenuItemInternal, Object.assign({}, rest, { tabIndex: disabled ? -1 : 0, ref: composedRefs, disabled: disabled, onClick: composeEventHandlers(onClick, handleSelect, {
/**
* Nextjs prevents default on click when using Link component, so we have to force click-event
* https://github.com/vercel/next.js/blob/77dcd4c66a35d0e8ef639bda4d05873bd3c0f52d/packages/next/src/client/link.tsx#L211
*/
checkForDefaultPrevented: false,
}), onPointerDown: composeEventHandlers(onPointerDown, () => {
isPointerDownRef.current = true;
}, { checkForDefaultPrevented: false }), onPointerUp: composeEventHandlers(onPointerUp, (event) => {
var _a;
// Pointer down can move to a different menu item which should activate it on pointer up.
// We dispatch a click for selection to allow composition with click based triggers and to
// prevent Firefox from getting stuck in text selection mode when the menu closes.
if (!isPointerDownRef.current)
(_a = event.currentTarget) === null || _a === void 0 ? void 0 : _a.click();
}), onKeyDown: composeEventHandlers(onKeyDown, (event) => handleKey(event, "Enter")), onKeyUp: composeEventHandlers(onKeyUp, (event) => handleKey(event, " ")) })));
});
const MenuItemInternal = forwardRef((_a, forwardedRef) => {
var { disabled = false, onPointerMove, onPointerLeave } = _a, rest = __rest(_a, ["disabled", "onPointerMove", "onPointerLeave"]);
const context = useMenuContext();
const { register } = useMenuDescendant({
disabled,
closeMenu: () => {
rest["data-submenu-trigger"] &&
context.open &&
context.onOpenChange(false);
},
});
const ref = useRef(null);
const composedRefs = useMergeRefs(forwardedRef, ref, register);
return (React.createElement(SlottedDivElement, Object.assign({ role: "menuitem", "aria-disabled": disabled || undefined, "data-disabled": disabled ? "" : undefined, tabIndex: -1 }, rest, { style: Object.assign({ userSelect: "none" }, rest === null || rest === void 0 ? void 0 : rest.style), ref: composedRefs,
/**
* We focus items on `pointerMove` make sure that the item is focused or re-focused
* when the mouse wiggles. If we used `mouseOver`/`mouseEnter` it would not re-focus.
* This is mostly to handle edgecases where the user uses mouse and keyboard together.
*/
onPointerMove: composeEventHandlers(onPointerMove, whenMouse((event) => {
var _a;
if (disabled) {
/**
* In the edgecase the focus is still stuck on a previous item, we make sure to reset it
* even when the disabled item can't be focused itself to reset it.
*/
(_a = context.content) === null || _a === void 0 ? void 0 : _a.focus();
}
else {
event.currentTarget.focus();
}
})), onPointerLeave: composeEventHandlers(onPointerLeave, whenMouse(() => { var _a; return (_a = context.content) === null || _a === void 0 ? void 0 : _a.focus(); })) })));
});
const MenuGroup = forwardRef((props, ref) => {
return React.createElement(SlottedDivElement, Object.assign({ role: "group" }, props, { ref: ref }));
});
const MenuPortal = forwardRef(({ children, rootElement }, ref) => {
const context = useMenuContext();
if (!context.open) {
return null;
}
return (React.createElement(Portal, { rootElement: rootElement, ref: ref }, children));
});
/* -------------------------------------------------------------------------- */
/* Menu Radio */
/* -------------------------------------------------------------------------- */
const { Provider: RadioGroupProvider, useContext: useMenuRadioGroupContext } = createStrictContext({
name: "MenuRadioGroupContext",
defaultValue: {
value: undefined,
onValueChange: () => { },
},
});
const MenuRadioGroup = React.forwardRef((_a, ref) => {
var { value, onValueChange } = _a, rest = __rest(_a, ["value", "onValueChange"]);
const handleValueChange = useEventCallback(onValueChange);
return (React.createElement(RadioGroupProvider, { value: value, onValueChange: handleValueChange },
React.createElement(MenuGroup, Object.assign({}, rest, { ref: ref }))));
});
/* -------------------------------------------------------------------------- */
/* Menu Item Indicator */
/* -------------------------------------------------------------------------- */
const { Provider: MenuItemIndicatorProvider, useContext: useMenuItemIndicatorContext, } = createStrictContext({
name: "MenuItemIndicatorContext",
});
const MenuItemIndicator = forwardRef((_a, ref) => {
var { asChild } = _a, rest = __rest(_a, ["asChild"]);
const ctx = useMenuItemIndicatorContext();
return (React.createElement(SlottedDivElement, Object.assign({}, rest, { ref: ref, "data-state": getCheckedState(ctx.state), "aria-hidden": true, asChild: asChild })));
});
const MenuRadioItem = forwardRef((_a, forwardedRef) => {
var { value, onSelect } = _a, rest = __rest(_a, ["value", "onSelect"]);
const context = useMenuRadioGroupContext();
const checked = value === context.value;
return (React.createElement(MenuItemIndicatorProvider, { state: checked },
React.createElement(MenuItem, Object.assign({ role: "menuitemradio", "aria-checked": checked }, rest, { ref: forwardedRef, "data-state": getCheckedState(checked), onSelect: composeEventHandlers(onSelect, () => { var _a; return (_a = context.onValueChange) === null || _a === void 0 ? void 0 : _a.call(context, value); }, { checkForDefaultPrevented: false }) }))));
});
const MenuCheckboxItem = forwardRef((_a, forwardedRef) => {
var { checked = false, onCheckedChange, onSelect } = _a, rest = __rest(_a, ["checked", "onCheckedChange", "onSelect"]);
return (React.createElement(MenuItemIndicatorProvider, { state: checked },
React.createElement(MenuItem, Object.assign({ role: "menuitemcheckbox", "aria-checked": isIndeterminate(checked) ? "mixed" : checked }, rest, { ref: forwardedRef, "data-state": getCheckedState(checked), onSelect: composeEventHandlers(onSelect, () => onCheckedChange === null || onCheckedChange === void 0 ? void 0 : onCheckedChange(isIndeterminate(checked) ? true : !checked), { checkForDefaultPrevented: false }) }))));
});
const MenuDivider = forwardRef((props, ref) => {
return (React.createElement(SlottedDivElement, Object.assign({ role: "separator", "aria-orientation": "horizontal" }, props, { ref: ref })));
});
const { Provider: MenuSubProvider, useContext: useMenuSubContext } = createStrictContext({
name: "MenuSubContext",
});
const MenuSub = ({ children, onOpenChange, open = false, }) => {
const parentMenuContext = useMenuContext();
const { values } = useMenuDescendantsContext();
const [trigger, setTrigger] = useState(null);
const [content, setContent] = useState(null);
const handleOpenChange = useEventCallback(onOpenChange);
// Prevent the parent menu from reopening with open submenus.
useEffect(() => {
if (parentMenuContext.open === false) {
handleOpenChange(false);
}
return () => handleOpenChange(false);
}, [parentMenuContext.open, handleOpenChange]);
return (React.createElement(Floating, null,
React.createElement(MenuProvider, { open: open, onOpenChange: (_open) => {
handleOpenChange(_open);
if (_open) {
/* Makes sure to close all adjacent submenus if they are open */
values().forEach((descendant) => {
if (descendant.node !== trigger) {
descendant.closeMenu();
}
});
}
}, content: content, onContentChange: setContent },
React.createElement(MenuSubProvider, { contentId: useId(), triggerId: useId(), trigger: trigger, onTriggerChange: setTrigger }, children))));
};
const MenuSubTrigger = forwardRef((props, forwardedRef) => {
const context = useMenuContext();
const subContext = useMenuSubContext();
const composedRefs = useMergeRefs(forwardedRef, subContext.onTriggerChange);
const handleKey = (event, keys) => {
var _a;
if (props.disabled) {
return;
}
if (keys.includes(event.key)) {
context.onOpenChange(true);
// The trigger may hold focus if opened via pointer interaction
// so we ensure content is given focus again when switching to keyboard.
(_a = context.content) === null || _a === void 0 ? void 0 : _a.focus();
// prevent window from scrolling
event.preventDefault();
}
};
return (React.createElement(MenuAnchor, { asChild: true },
React.createElement(MenuItemInternal, Object.assign({ id: subContext.triggerId, "aria-haspopup": "menu", "aria-expanded": context.open, "aria-controls": subContext.contentId, "data-state": getOpenState(context.open) }, props, { ref: composedRefs, "data-submenu-trigger": true, onClick: (event) => {
var _a;
if (props.disabled || event.defaultPrevented) {
return;
}
(_a = props.onClick) === null || _a === void 0 ? void 0 : _a.call(props, event);
/*
* Solves edgecase where the user clicks the trigger,
* but the focus is outside browser-window or viewport at first.
*/
event.currentTarget.focus();
context.onOpenChange(!context.open);
}, onKeyDown: composeEventHandlers(props.onKeyDown, (event) => handleKey(event, ["Enter", "ArrowRight"])), onKeyUp: composeEventHandlers(props.onKeyUp, (event) => handleKey(event, [" "])) }))));
});
const MenuSubContent = forwardRef((props, forwardedRef) => {
const descendants = useMenuDescendants();
const context = useMenuContext();
const rootContext = useMenuRootContext();
const subContext = useMenuSubContext();
const ref = useRef(null);
const composedRefs = useMergeRefs(forwardedRef, ref);
return (React.createElement(MenuDescendantsProvider, { value: descendants },
React.createElement(MenuContentInternal, Object.assign({ id: subContext.contentId, "aria-labelledby": subContext.triggerId }, props, { ref: composedRefs, align: "start", side: "right", disableOutsidePointerEvents: false, initialFocus: () => {
if (rootContext.isUsingKeyboardRef.current) {
return ref.current;
}
return false;
},
/* Since we manually focus Subtrigger, we prevent use of auto-focus */
returnFocus: false, onEscapeKeyDown: composeEventHandlers(props.onEscapeKeyDown, (event) => {
rootContext.onClose();
// Ensure pressing escape in submenu doesn't escape full screen mode
event.preventDefault();
}), onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
var _a, _b;
// Submenu key events bubble through portals. We only care about keys in this menu.
const isKeyDownInside = event.currentTarget.contains(event.target);
let isCloseKey = event.key === "ArrowLeft";
/* When submenu opens to the left, we allow closing it with ArrowRight */
if (((_a = context.content) === null || _a === void 0 ? void 0 : _a.dataset.side) === "left") {
isCloseKey = isCloseKey || event.key === "ArrowRight";
}
if (isKeyDownInside && isCloseKey) {
context.onOpenChange(false);
// We focus manually because we prevented it in `onCloseAutoFocus`
(_b = subContext.trigger) === null || _b === void 0 ? void 0 : _b.focus();
// Prevent window from scrolling
event.preventDefault();
}
}) }))));
});
/* -------------------------------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------------------------------- */
function getOpenState(open) {
return open ? "open" : "closed";
}
function isIndeterminate(checked) {
return checked === "indeterminate";
}
function getCheckedState(checked) {
return isIndeterminate(checked)
? "indeterminate"
: checked
? "checked"
: "unchecked";
}
function whenMouse(handler) {
return (event) => event.pointerType === "mouse" ? handler(event) : undefined;
}
/* -------------------------------------------------------------------------- */
Menu.Anchor = MenuAnchor;
Menu.Portal = MenuPortal;
Menu.Content = MenuContent;
Menu.Group = MenuGroup;
Menu.Item = MenuItem;
Menu.CheckboxItem = MenuCheckboxItem;
Menu.RadioGroup = MenuRadioGroup;
Menu.RadioItem = MenuRadioItem;
Menu.Divider = MenuDivider;
Menu.Sub = MenuSub;
Menu.SubTrigger = MenuSubTrigger;
Menu.SubContent = MenuSubContent;
Menu.ItemIndicator = MenuItemIndicator;
export { Menu, MenuAnchor, MenuCheckboxItem, MenuContent, MenuDivider, MenuGroup, MenuItem, MenuItemIndicator, MenuPortal, MenuRadioGroup, MenuRadioItem, MenuSub, MenuSubContent, MenuSubTrigger, };
//# sourceMappingURL=Menu.js.map