@navikt/ds-react
Version:
React components from the Norwegian Labour and Welfare Administration.
409 lines • 16.7 kB
JavaScript
;
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