UNPKG

@navikt/ds-react

Version:

React components from the Norwegian Labour and Welfare Administration.

240 lines 12.5 kB
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 { Slot } from "../../slot/Slot.js"; import { useMergeRefs } from "../../util/hooks/index.js"; import { createDescendantContext } from "../../util/hooks/descendants/useDescendant.js"; import { ownerDocument } from "../../util/owner.js"; import { useEscapeKeydown } from "./util/useEscapeKeydown.js"; import { useFocusOutside } from "./util/useFocusOutside.js"; import { usePointerDownOutside } from "./util/usePointerDownOutside.js"; export const [DismissableDescendantsProvider, useDismissableDescendantsContext, useDismissableDescendants, useDismissableDescendant,] = createDescendantContext(); /** * Number of layers with `disableOutsidePointerEvents` set to `true` currently enabled. */ let bodyLockCount = 0; let originalBodyPointerEvents; const DismissableLayer = forwardRef((props, ref) => { const context = useDismissableDescendantsContext(false); /** * To correctly handle nested DismissableLayer, * we only initialize the `Descendants`-API for the root layer to aboid resetting context */ return context ? (React.createElement(DismissableLayerNode, Object.assign({ ref: ref }, props))) : (React.createElement(DismissableRoot, null, React.createElement(DismissableLayerNode, Object.assign({ ref: ref }, props)))); }); /** * DismissableRoot * * Used to initialize the `Descendants`-API at the root layer. * All subsequent layers will use the same context. */ const DismissableRoot = ({ children }) => { const descendants = useDismissableDescendants(); return (React.createElement(DismissableDescendantsProvider, { value: descendants }, children)); }; const DismissableLayerNode = forwardRef((_a, ref) => { var { children, asChild, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onDismiss, safeZone, disableOutsidePointerEvents = false, enabled = true } = _a, rest = __rest(_a, ["children", "asChild", "onEscapeKeyDown", "onPointerDownOutside", "onFocusOutside", "onInteractOutside", "onDismiss", "safeZone", "disableOutsidePointerEvents", "enabled"]); const [, setForce] = useState({}); const { register, index, descendants } = useDismissableDescendant({ disableOutsidePointerEvents, disabled: !enabled, forceUpdate: () => setForce({}), }); /** * `node` will be set to the ref of the component or nested component * Ex: If * ``` * <DismissableLayer asChild> * <Popover /> * </DismissableLayer> * ``` * `node` will in this case be the Popover-element. * We use State her and not ref since we want to trigger a rerender when the node changes. */ const [node, setNode] = useState(null); const mergedRefs = useMergeRefs(setNode, register, ref); const ownerDoc = ownerDocument(node); const hasInteractedOutsideRef = useRef(false); const hasPointerDownOutsideRef = useRef(false); const pointerState = (() => { let lastIndex = -1; const descendantNodes = descendants.enabledValues(); descendantNodes.forEach((obj, _index) => { if (obj.disableOutsidePointerEvents) { lastIndex = _index; } }); return { /** * Makes sure we stop events at the highest layer with pointer events disabled. * If not checked, we risk closing every layer when clicking outside the layer. */ isPointerEventsEnabled: index >= lastIndex, /** * If we find a node with `disableOutsidePointerEvents` we want to disable pointer events on the body. */ isBodyPointerEventsDisabled: bodyLockCount > 0, pointerStyle: (index >= lastIndex && bodyLockCount > 0 ? "auto" : undefined), }; })(); /** * We want to prevent the Layer from closing when the trigger, anchor element, or its child elements are interacted with. * * To achieve this, we check if the event target is the trigger, anchor or a child. If it is, we prevent default event behavior. * * The `pointerDownOutside` and `focusOutside` handlers already check if the event target is within the DismissableLayer (`node`). * However, since we don't add a `tabIndex` to the Popover/Tooltip, the `focusOutside` handler doesn't correctly handle focus events. * Therefore, we also need to check that neither the trigger (`anchor`) nor the DismissableLayer (`dismissable`) are the event targets. */ function handleOutsideEvent(event) { var _a, _b; if ((!(safeZone === null || safeZone === void 0 ? void 0 : safeZone.anchor) && !(safeZone === null || safeZone === void 0 ? void 0 : safeZone.dismissable)) || !enabled) { return; } if (!event.defaultPrevented) { hasInteractedOutsideRef.current = true; if (event.detail.originalEvent.type === "pointerdown") { hasPointerDownOutsideRef.current = true; } } const target = event.target; /** * pointerdown-events works as expected, but focus-events does not. * For focus-event we need to also check `safeZone.dismissable` (the Popover/Tooltip itself) since it does not have a tabIndex. */ if (event.detail.originalEvent.type === "pointerdown") { const targetIsTrigger = ((_a = safeZone === null || safeZone === void 0 ? void 0 : safeZone.anchor) === null || _a === void 0 ? void 0 : _a.contains(target)) || target === (safeZone === null || safeZone === void 0 ? void 0 : safeZone.anchor); targetIsTrigger && event.preventDefault(); } else { const targetIsNotTrigger = target instanceof HTMLElement && ![safeZone === null || safeZone === void 0 ? void 0 : safeZone.anchor, safeZone === null || safeZone === void 0 ? void 0 : safeZone.dismissable].some((element) => element === null || element === void 0 ? void 0 : element.contains(target)) && !target.contains((_b = safeZone === null || safeZone === void 0 ? void 0 : safeZone.dismissable) !== null && _b !== void 0 ? _b : null); !targetIsNotTrigger && event.preventDefault(); } /** * In Safari, if the trigger element is inside a container with tabIndex={0}, a click on the trigger * will first fire a 'pointerdownoutside' event on the trigger itself. However, it will then fire a * 'focusoutside' event on the container. * * To handle this, we ignore any 'focusoutside' events if a 'pointerdownoutside' event has already occurred. * 'pointerdownoutside' event is sufficient to indicate interaction outside the DismissableLayer. */ if (event.detail.originalEvent.type === "focusin" && hasPointerDownOutsideRef.current) { event.preventDefault(); } hasPointerDownOutsideRef.current = false; hasInteractedOutsideRef.current = false; } const pointerDownOutside = usePointerDownOutside((event) => { if (!pointerState.isPointerEventsEnabled || !enabled) { return; } /** * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases. */ onPointerDownOutside === null || onPointerDownOutside === void 0 ? void 0 : onPointerDownOutside(event); onInteractOutside === null || onInteractOutside === void 0 ? void 0 : onInteractOutside(event); /** * Add safeZone to prevent closing when interacting with trigger/anchor or its children. */ safeZone && handleOutsideEvent(event); /** * Both `onPointerDownOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`. */ if (!event.defaultPrevented && onDismiss) { onDismiss(); } }, ownerDoc); const focusOutside = useFocusOutside((event) => { if (!enabled) { return; } /** * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases. */ onFocusOutside === null || onFocusOutside === void 0 ? void 0 : onFocusOutside(event); onInteractOutside === null || onInteractOutside === void 0 ? void 0 : onInteractOutside(event); /** * Add safeZone to prevent closing when interacting with trigger/anchor or its children. */ safeZone && handleOutsideEvent(event); /** * Both `onFocusOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`. */ if (!event.defaultPrevented && onDismiss) { onDismiss(); } }, ownerDoc); useEscapeKeydown((event) => { if (!enabled) { return; } /** * The deepest nested element will always be last in the descendants list. * This allows us to only close the highest layer when pressing escape. * * In some cases a layer might still exist, but be disabled. We want to ignore these layers. */ const isHighestLayer = index === descendants.enabledCount() - 1; if (!isHighestLayer) { return; } /** * We call this before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases. */ onEscapeKeyDown === null || onEscapeKeyDown === void 0 ? void 0 : onEscapeKeyDown(event); /** * `onEscapeKeyDown` is able to preventDefault the event, thus stopping call for `onDismiss`. * We want to `preventDefault` the escape-event to avoid sideeffect from other elements on screen */ if (!event.defaultPrevented && onDismiss) { event.preventDefault(); onDismiss(); } }, ownerDoc); /** * If `disableOutsidePointerEvents` is true, * we want to disable pointer events on the body when the first layer is opened. */ // biome-ignore lint/correctness/useExhaustiveDependencies: Every time the descendants change, we want to update the body pointer events since we might have added or removed a layer. useEffect(() => { if (!node || !enabled || !disableOutsidePointerEvents) return; if (bodyLockCount === 0) { originalBodyPointerEvents = ownerDoc.body.style.pointerEvents; ownerDoc.body.style.pointerEvents = "none"; } bodyLockCount++; return () => { if (bodyLockCount === 1) { ownerDoc.body.style.pointerEvents = originalBodyPointerEvents; } bodyLockCount--; }; }, [node, ownerDoc, disableOutsidePointerEvents, descendants, enabled]); /** * To make sure pointerEvents are enabled for all parents and siblings when the layer is removed from the DOM */ // biome-ignore lint/correctness/useExhaustiveDependencies: We explicitly want to run this on unmount, including every time the node updates to make sure we don't lock the application behind pointer-events: none. useEffect(() => { return () => descendants.values().forEach((x) => x.forceUpdate()); }, [descendants, node]); const Comp = asChild ? Slot : "div"; return (React.createElement(Comp, Object.assign({ ref: mergedRefs }, rest, { onFocusCapture: focusOutside.onFocusCapture, onBlurCapture: focusOutside.onBlurCapture, onPointerDownCapture: pointerDownOutside.onPointerDownCapture, style: Object.assign({ pointerEvents: pointerState.pointerStyle }, rest.style) }), children)); }); export { DismissableLayer }; //# sourceMappingURL=DismissableLayer.js.map