UNPKG

@navikt/ds-react

Version:

React components from the Norwegian Labour and Welfare Administration.

409 lines 16.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); 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.FocusBoundary = void 0; const react_1 = __importStar(require("react")); const Slot_1 = require("../../slot/Slot"); const hooks_1 = require("../../util/hooks"); const hideNonTargetElements_1 = require("../hideNonTargetElements"); const useLatestRef_1 = require("../hooks/useLatestRef"); const owner_1 = require("../owner"); const FocusBoundary = (0, react_1.forwardRef)((_a, forwardedRef) => { var { loop = false, trapped = false, initialFocus = true, returnFocus = true, modal = false } = _a, restProps = __rest(_a, ["loop", "trapped", "initialFocus", "returnFocus", "modal"]); const initialFocusRef = (0, useLatestRef_1.useLatestRef)(initialFocus); const returnFocusRef = (0, useLatestRef_1.useLatestRef)(returnFocus); const lastFocusedElementRef = (0, react_1.useRef)(null); const [container, setContainer] = (0, react_1.useState)(null); const mergedRefs = (0, hooks_1.useMergeRefs)(forwardedRef, setContainer); const focusBoundary = (0, react_1.useRef)({ paused: false, pause() { this.paused = true; }, resume() { this.paused = false; }, }).current; /* Handles trapped state */ (0, react_1.useEffect)(() => { if (!trapped || !container) { return; } function handleFocusIn(event) { if (focusBoundary.paused || container === null) { return; } const target = event.target; if (container.contains(target)) { lastFocusedElementRef.current = target; } else { focus(lastFocusedElementRef.current, { select: true }); } } function handleFocusOut(event) { if (focusBoundary.paused || container === null) { return; } const relatedTarget = event.relatedTarget; /* * `focusout` event with a `null` `relatedTarget` will happen in a few known cases: * 1. When the user switches app/tabs/windows/the browser itself loses focus. * 2. In Google Chrome, when the focused element is removed from the DOM. * 3. When clicking on an element that cannot receive focus. * * We let the browser do its thing here because: * 1. The browser already keeps a memory of what's focused for when the page gets refocused. * 2. In Google Chrome, if we try to focus the deleted focused element (as per below), it * throws the CPU to 100%, so we avoid doing anything for this reason here too. */ if (relatedTarget === null) { return; } /* * If the focus has moved to an element outside the container, we move focus to the last valid focused element inside. * This makes sure to "trap" focus inside the container. * We handle focus on focusout instead of focusin to avoid elements recieving focusin events * when they are not supposed to (like when clicking on elements outside the container */ if (!container.contains(relatedTarget)) { focus(lastFocusedElementRef.current, { select: true }); } } /** * When the currently focused element is removed from the DOM, browsers move focus * to the document.body. In this case, we move focus to the container * to keep focus trapped correctly instead. */ const handleMutations = (mutations) => { if (document.activeElement !== document.body) { return; } if (mutations.some((mutation) => mutation.removedNodes.length > 0)) { focus(container); } }; document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleFocusOut); const observer = new MutationObserver(handleMutations); observer.observe(container, { childList: true, subtree: true }); return () => { document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); observer.disconnect(); }; }, [trapped, container, focusBoundary.paused]); /* Adds element to focus-stack */ (0, react_1.useEffect)(() => { if (!container) { return; } focusBoundarysStack.add(focusBoundary); return () => { setTimeout(() => { focusBoundarysStack.remove(focusBoundary); }, 0); }; }, [container, focusBoundary]); (0, react_1.useEffect)(() => { if (!container || !modal) { return; } return (0, hideNonTargetElements_1.hideNonTargetElements)([container]); }, [container, modal]); /* Handles mount focus */ (0, hooks_1.useClientLayoutEffect)(() => { if (!container || initialFocusRef.current === false) { return; } const ownerDoc = (0, owner_1.ownerDocument)(container); const previouslyFocusedElement = ownerDoc.activeElement; queueMicrotask(() => { const focusableElements = removeLinks(getTabbableCandidates(container)); const initialFocusValueOrFn = initialFocusRef.current; const resolvedInitialFocus = typeof initialFocusValueOrFn === "function" ? initialFocusValueOrFn() : initialFocusValueOrFn; if (resolvedInitialFocus === undefined || resolvedInitialFocus === false) { return; } let elToFocus; const fallbackelements = focusableElements[0] || container; /* `null` should fallback to default behavior in case of an empty ref. */ if (resolvedInitialFocus === true || resolvedInitialFocus === null) { elToFocus = fallbackelements; } else { elToFocus = resolveRef(resolvedInitialFocus) || fallbackelements; } const focusAlreadyInsideFloatingEl = container.contains(previouslyFocusedElement); if (focusAlreadyInsideFloatingEl) { return; } focus(elToFocus, { preventScroll: elToFocus === container, sync: false, }); }); }, [container, initialFocusRef]); /* Handles unmount focus */ (0, hooks_1.useClientLayoutEffect)(() => { if (!container) { return; } const ownerDoc = (0, owner_1.ownerDocument)(container); const previouslyFocusedElement = ownerDoc.activeElement; function getReturnElement() { let resolvedReturnFocusValue = returnFocusRef.current; if (resolvedReturnFocusValue === undefined || resolvedReturnFocusValue === false) { return null; } /* `null` should fallback to default behavior in case of an empty ref. */ if (resolvedReturnFocusValue === null) { resolvedReturnFocusValue = true; } if (typeof resolvedReturnFocusValue === "boolean") { const el = previouslyFocusedElement; return (el === null || el === void 0 ? void 0 : el.isConnected) ? el : ownerDoc.body; } const fallback = previouslyFocusedElement || ownerDoc.body; return resolveRef(resolvedReturnFocusValue) || fallback; } return () => { const returnElement = getReturnElement(); const activeEl = ownerDoc.activeElement; queueMicrotask(() => { if ( // eslint-disable-next-line react-hooks/exhaustive-deps returnFocusRef.current && returnElement && returnElement !== activeEl) { returnElement.focus({ preventScroll: true }); } }); }; }, [container, returnFocusRef]); /* Takes care of looping focus */ const handleKeyDown = (0, react_1.useCallback)((event) => { if ((!loop && !trapped) || focusBoundary.paused) { return; } const isTabKey = event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey; const focusedElement = document.activeElement; if (isTabKey && focusedElement) { const containerTarget = event.currentTarget; const [first, last] = getTabbableEdges(containerTarget); /* We can only wrap focus if we have tabbable edges */ if (!(first && last)) { /* * No need to do anything if active element is the expected focus-target * Case: No tabbable elements, focus should stay on container. If we don't preventDefault, the container will lose focus * and potentially lose controll of focus to browser (like focusing address bar). */ if (focusedElement === containerTarget) { event.preventDefault(); } return; } /** * Since we are either trapped + looping, or one of them we will do nothing when trapped and focus first element when looping. */ if (!event.shiftKey && focusedElement === last) { event.preventDefault(); if (loop) { focus(first, { select: true }); } } else if (event.shiftKey && focusedElement === first) { event.preventDefault(); if (loop) { focus(last, { select: true }); } } } }, [loop, trapped, focusBoundary.paused]); return (react_1.default.createElement(Slot_1.Slot, Object.assign({ tabIndex: -1 }, restProps, { ref: mergedRefs, onKeyDown: handleKeyDown }))); }); exports.FocusBoundary = FocusBoundary; /* ---------------------------- FocusBoundary utils ---------------------------- */ /** * Returns the first and last tabbable elements inside a container as a tuple. */ function getTabbableEdges(container) { const candidates = getTabbableCandidates(container); return [ findFirstVisible(candidates, container), findFirstVisible(candidates.reverse(), container), ]; } /** * Returns a list of potential tabbable candidates. * We do not take into account tabindex values. * * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 */ function getTabbableCandidates(container) { const nodes = []; const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { const isHiddenInput = node.tagName === "INPUT" && node.type === "hidden"; if (node.disabled || node.hidden || isHiddenInput) { return NodeFilter.FILTER_SKIP; } /** * `.tabIndex` is not the same as the `tabindex` attribute. It works on the * runtime's understanding of tabbability, so this automatically accounts * for any kind of element that could be tabbed to. */ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); while (walker.nextNode()) { nodes.push(walker.currentNode); } return nodes; } /** * Returns the first visible element in a list. * NOTE: Only checks visibility up to the `container`. */ function findFirstVisible(elements, container) { for (const element of elements) { if (!isHidden(element, { upTo: container })) { return element; } } } function isHidden(node, { upTo }) { if (getComputedStyle(node).visibility === "hidden") { return true; } while (node) { /* we stop at `upTo` */ if (upTo !== undefined && node === upTo) { return false; } if (getComputedStyle(node).display === "none") { return true; } node = node.parentElement; } return false; } let rafId = 0; function focus(element, { select = false, preventScroll = true, sync = true } = {}) { if (!(element === null || element === void 0 ? void 0 : element.focus)) { return; } const previouslyFocusedElement = document.activeElement; cancelAnimationFrame(rafId); const exec = () => element.focus({ preventScroll }); if (sync) { exec(); } else { rafId = requestAnimationFrame(exec); } if (!select) { return; } /* By default, inputs that gets focus should select its contents */ if (element !== previouslyFocusedElement && element instanceof HTMLInputElement && "select" in element) element.select(); } const focusBoundarysStack = createFocusBoundarysStack(); function createFocusBoundarysStack() { /* A stack of focus-boundaries, with the active one at the top */ let stack = []; return { add(focusBoundary) { /* Pause the currently active focus-boundary (at the top of the stack) */ const activeFocusBoundary = stack[0]; if (focusBoundary !== activeFocusBoundary) { activeFocusBoundary === null || activeFocusBoundary === void 0 ? void 0 : activeFocusBoundary.pause(); } /* remove in case it already exists (because we'll re-add it at the top of the stack) */ stack = arrayRemove(stack, focusBoundary); stack.unshift(focusBoundary); }, remove(focusBoundary) { var _a; stack = arrayRemove(stack, focusBoundary); (_a = stack[0]) === null || _a === void 0 ? void 0 : _a.resume(); }, }; } function arrayRemove(array, item) { const updatedArray = [...array]; const index = updatedArray.indexOf(item); if (index !== -1) { updatedArray.splice(index, 1); } return updatedArray; } function removeLinks(items) { return items.filter((item) => item.tagName !== "A"); } /** * If the provided argument is a ref object, returns its `current` value. * Otherwise, returns the argument itself. * * Non-generic to safely handle refs whose `.current` may be `null`. */ function resolveRef(maybeRef) { return "current" in maybeRef ? maybeRef.current : maybeRef; } //# sourceMappingURL=FocusBoundary.js.map