@carbon/ibm-products-web-components
Version:
Carbon for IBM Products Web Components
268 lines (266 loc) • 10.5 kB
JavaScript
/**
* Copyright IBM Corp. 2020, 2026
*
* 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 { prefix, selectorTabbable } from "@carbon/web-components/es/globals/settings";
//#region src/utilities/manageFocusTrap/manageFocusTrap.ts
/**
* @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.
*/
/**
* Simple object to store containers by uniqueId
* { id: [container1, container2, ...] }
*/
const containers = {};
/**
* Default key for backward compatibility when no uniqueId is provided
*/
const DEFAULT_KEY = "__default__";
/**
* Registers a focusable container to be included in the focus trap.
*
* This function allows child components to register their shadow roots or DOM elements
* so that the focus trap utility can query focusable elements within them. This is necessary
* because shadow DOM boundaries prevent direct querying from parent components.
*
* @param container - The HTMLElement or ShadowRoot to register as a focusable container.
* If null, the function returns early without registering.
* @param uniqueId - Optional unique identifier to scope containers to a specific component instance.
* If not provided, uses a default key for backward compatibility.
*
* @example
* // In a child component's firstUpdated lifecycle
* protected firstUpdated() {
* const uniqueId = tearsheetSignal.get().uniqueId;
* registerFocusableContainers(this.shadowRoot, uniqueId);
* }
*
* @example
* // Without uniqueId (uses default key)
* registerFocusableContainers(this.shadowRoot);
*/
const registerFocusableContainers = (container, uniqueId) => {
if (!container) return;
const id = uniqueId || DEFAULT_KEY;
if (!containers[id]) containers[id] = [];
if (!containers[id].includes(container)) containers[id].push(container);
};
/**
* UnRegisters a previously registered focusable container.
*
* This function should be called in the component's disconnectedCallback lifecycle method
* to clean up and prevent memory leaks when the component is removed from the DOM.
*
* @param container - The HTMLElement or ShadowRoot to unregister.
* If null, the function returns early without doing anything.
* @param uniqueId - Optional unique identifier that was used when registering the container.
* If not provided, uses the default key.
*
* @example
* // In a child component's disconnectedCallback
* disconnectedCallback() {
* super.disconnectedCallback();
* const uniqueId = tearsheetSignal.get().uniqueId;
* unregisterFocusableContainers(this.shadowRoot, uniqueId);
* }
*
* @example
* // Without uniqueId (uses default key)
* disconnectedCallback() {
* super.disconnectedCallback();
* unregisterFocusableContainers(this.shadowRoot);
* }
*/
const unregisterFocusableContainers = (container, uniqueId) => {
if (!container) return;
const id = uniqueId || DEFAULT_KEY;
if (containers[id]) {
containers[id] = containers[id].filter((c) => c !== container);
if (containers[id].length === 0) delete containers[id];
}
};
/**
* Clears all registered focusable containers, either globally or for a specific uniqueId.
*
* This function is typically called when a parent component (like a tearsheet or modal) is closing
* or being removed from the DOM. It ensures all registered containers are cleaned up to prevent
* memory leaks.
*
* @param uniqueId - Optional unique identifier to clear containers for a specific component instance.
* If provided, only containers registered with this uniqueId are cleared.
* If not provided, ALL containers across all uniqueIds are cleared.
*
* @example
* // Clear containers for a specific tearsheet instance
* clearFocusableContainers(this.uniqueId);
*
* @example
* // Clear all containers (typically in disconnectedCallback)
* disconnectedCallback() {
* super.disconnectedCallback();
* this._trapFocusAPI?.cleanup();
* clearFocusableContainers();
* }
*/
const clearFocusableContainers = (uniqueId) => {
if (uniqueId) delete containers[uniqueId];
else Object.keys(containers).forEach((key) => delete containers[key]);
};
/**
* Traps keyboard focus within registered focusable containers.
*
* This function creates a focus trap that prevents Tab/Shift+Tab navigation from leaving
* the registered containers. When the user reaches the last focusable element and presses Tab,
* focus wraps to the first element, and vice versa with Shift+Tab.
*
* The function works with Shadow DOM by querying focusable elements from all registered
* containers, which can include shadow roots from child components.
*
* @param wrapper - Optional wrapper element to attach the keydown listener to.
* If not provided, defaults to document for backward compatibility.
* @param uniqueId - Optional unique identifier to scope the focus trap to specific containers.
* If provided, only containers registered with this uniqueId are included.
* @param getFirstFocusable - Optional resolver called lazily on each keydown (and for initial
* focus) to determine the first element. Using a function rather than
* a value avoids a stale reference if header-action buttons are added
* or removed while the tearsheet is open.
* Falls back to the first element across registered containers.
*
* @returns An object with a `cleanup` method that removes event listeners.
*
* @example
* * // In a parent component when opening
* protected updated(changedProps) {
* if (changedProps.has('open') && this.open) {
* // Update signal so children can register
* updateTearsheetSignals({ uniqueId: this.uniqueId });
*
* // Wait for children to register, then trap focus
* requestAnimationFrame(() => {
* this._trapFocusAPI = trapFocus(this as HTMLElement, this.uniqueId);
* });
* }
* }
*
* @example
* // Without uniqueId (uses default key)
* this._trapFocusAPI = trapFocus();
*
* @example
* // Cleanup when closing
* disconnectedCallback() {
* this._trapFocusAPI?.cleanup();
* clearFocusableContainers(this.uniqueId);
* }
* this._trapFocusAPI = trapFocus(
* this as HTMLElement,
* this.uniqueId,
* () => this._getFirstFocusable()
* );
*/
const trapFocus = (wrapper, uniqueId, getFirstFocusable) => {
const selectorTabbable$1 = selectorTabbable.replace(`${prefix}-button`, `${prefix}-button:not([disabled]), ${prefix}-selectable-tag`);
const containerArray = containers[uniqueId || DEFAULT_KEY];
if (!containerArray || containerArray.length === 0) return { cleanup: () => {} };
const eventTarget = wrapper || document;
/**
* Determines the best focusable element within a given container.
*
* @param el - The element to check for ability to focus
* @returns The focusable element, or null if none found
*
* @internal
*/
const getFocusTarget = (el) => {
if (!el) return null;
if (el.tagName === "BUTTON" || el.tabIndex >= 0) {
if (el.shadowRoot) {
const shadowBtn = el.shadowRoot.querySelector("button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])");
if (shadowBtn) return shadowBtn;
}
return el;
}
return el.querySelector("button, [tabindex]:not([tabindex=\"-1\"])") || el;
};
/**
* Returns the actual focused element, resolving through Shadow DOM and slot boundaries.
*
* In standard DOM, `document.activeElement` returns the focused element. However, with
* Shadow DOM and slots, this can be misleading:
* - If focus is inside a shadow root, `document.activeElement` returns the host element,
* not the actual focused element inside the shadow root.
* - If focus is on a `<slot>` element, we need to resolve to the assigned element.
*
* This function traverses shadow roots to find the true focused element, stopping at
* tabbable elements like buttons to avoid going too deep into component internals.
*
* @returns The actual focused element in the document
*
* @internal
*/
const getRealActiveElement = () => {
let activeElement = document.activeElement;
while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) {
const nextElement = activeElement.shadowRoot.activeElement;
const tagName = activeElement.tagName.toLowerCase();
if (selectorTabbable$1.indexOf(tagName) !== -1 || tagName.includes("-button") || activeElement.hasAttribute("tabindex")) break;
activeElement = nextElement;
}
if (activeElement?.tagName === "SLOT") {
let assigned;
if (activeElement instanceof HTMLSlotElement) assigned = activeElement.assignedElements({ flatten: true });
const candidate = assigned?.find((node) => node.matches("button, [tabindex]:not([tabindex=\"-1\"])"));
if (candidate) activeElement = candidate;
}
return activeElement;
};
requestAnimationFrame(() => {
getFocusTarget(getFirstFocusable?.() ?? getAllFocusableElements(containerArray)[0])?.focus({ preventScroll: true });
});
/**
* Handles Tab and Shift+Tab key presses to trap focus within containers.
*
* @param e - The keyboard event
*
* @internal
*/
function onKeyDown(e) {
if (e.key !== "Tab") return;
const elements = getAllFocusableElements(containerArray);
const first = getFirstFocusable?.() ?? elements[0];
const last = elements[elements.length - 1];
const active = getRealActiveElement();
if (e.shiftKey) {
if (active === first || first?.contains(active)) {
e.preventDefault();
getFocusTarget(last)?.focus();
}
} else if (active === last || last?.contains(active)) {
e.preventDefault();
getFocusTarget(first)?.focus();
}
}
/**
* Retrieves all focusable elements from all registered containers.
*
* @param containers - Array of containers to query for focusable elements
* @returns Array of all focusable elements found
*
* @internal
*/
const getAllFocusableElements = (containers) => containers.flatMap((container) => [...container.querySelectorAll(selectorTabbable$1)]).filter(Boolean);
eventTarget.addEventListener("keydown", onKeyDown, true);
return { cleanup: () => {
eventTarget.removeEventListener("keydown", onKeyDown, true);
} };
};
//#endregion
export { clearFocusableContainers, registerFocusableContainers, trapFocus, unregisterFocusableContainers };
//# sourceMappingURL=manageFocusTrap.js.map