@carbon/ibm-products-web-components
Version:
Carbon for IBM Products Web Components
139 lines (134 loc) • 5.28 kB
JavaScript
/**
* Copyright IBM Corp. 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { selectorTabbable, prefix } from '@carbon/web-components/es/globals/settings';
/**
* @license
*
* Copyright IBM Corp. 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* Traps focus within a set of containers, ensuring it stays within focusable elements.
* @param {NodeListOf<Element>} containers - The containers to trap focus within.
* @returns {object} An object with a `cleanup` method to remove event listeners.
*/
let _containers = [];
const registerFocusableContainers = (container) => {
if (container) {
_containers.push(container);
}
};
const clearFocusableContainers = () => {
_containers.length = 0;
};
const trapFocus = (containers) => {
const selectorTabbable$1 = selectorTabbable.replace(`${prefix}-button`, `${prefix}-button:not([disabled]), ${prefix}-selectable-tag`);
if (containers && containers.length > 0) {
_containers = containers;
}
if (!_containers.length) {
return { cleanup: () => { } };
}
/**
* Given a container element, return the best candidate to focus.
* - If element itself is tabbable, return it.
* - Otherwise, find the first tabbable descendant.
*/
const getFocusTarget = (el) => {
if (!el) {
return null;
}
if (el.tagName === 'BUTTON' || el.tabIndex >= 0) {
return el;
}
const inner = el.querySelector('button, [tabindex]:not([tabindex="-1"])');
return inner || el;
};
/**
* Returns the "real" active element in the document, resolving through
* Shadow DOM and slot boundaries when necessary.
*
* In some cases `document.activeElement` may not reflect the actual
* tabbable node that has focus:
* - If focus is inside a shadow root, `document.activeElement` is the host,
* not the element inside. This utility dives into the shadowRoot to find
* the inner active element.
* - If focus is on a `<slot>` element, this function resolves to the first
* tabbable assigned element within the slot.
*/
const getRealActiveElement = () => {
var _a;
let activeElement = document.activeElement;
// If inside shadow DOM
if (activeElement &&
selectorTabbable$1.indexOf(activeElement.tagName.toLocaleLowerCase()) ==
-1 &&
((_a = activeElement === null || activeElement === void 0 ? void 0 : activeElement.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement)) {
activeElement = activeElement.shadowRoot.activeElement;
}
// If active element is a slot
if ((activeElement === null || activeElement === void 0 ? void 0 : activeElement.tagName) === 'SLOT') {
let assigned;
if (activeElement instanceof HTMLSlotElement) {
assigned = activeElement.assignedElements({ flatten: true });
}
const candidate = assigned === null || assigned === void 0 ? void 0 : assigned.find((node) => node.matches('button, [tabindex]:not([tabindex="-1"])'));
if (candidate) {
activeElement = candidate;
}
}
return activeElement;
};
//Optionally focus first element immediately
requestAnimationFrame(() => {
var _a;
const elements = getAllFocusableElements(_containers);
(_a = getFocusTarget(elements[0])) === null || _a === void 0 ? void 0 : _a.focus({ preventScroll: true });
});
function onKeyDown(e) {
var _a, _b;
if (e.key !== 'Tab') {
return;
}
// Flatten all focusable elements from all containers
const elements = getAllFocusableElements(_containers);
const first = elements[0];
const last = elements[elements.length - 1];
const active = getRealActiveElement();
if (e.shiftKey) {
// Shift+Tab wrap
if (active === first || first.contains(active)) {
e.preventDefault();
(_a = getFocusTarget(last)) === null || _a === void 0 ? void 0 : _a.focus();
}
}
else {
// Tab wrap
if (active === last || last.contains(active)) {
e.preventDefault();
(_b = getFocusTarget(first)) === null || _b === void 0 ? void 0 : _b.focus();
}
}
}
/**
* Retrieves all focusable elements within the provided containers.
**/
const getAllFocusableElements = (containers) => containers
.flatMap((container) => [...container.querySelectorAll(selectorTabbable$1)])
.filter(Boolean);
document.addEventListener('keydown', onKeyDown, true);
return {
cleanup: () => {
document.removeEventListener('keydown', onKeyDown, true);
_containers.length = 0;
},
};
};
export { clearFocusableContainers, registerFocusableContainers, trapFocus };
//# sourceMappingURL=manageFocusTrap.js.map