import {
getNodeName,
isHTMLElement,
isShadowRoot,
} from "@floating-ui/utils/dom";
import { ownerDocument } from "../../dom/owner-document";
import { isElementVisible } from "./composite";
import { activeElement, contains } from "./element";
/**
* Tabbable / focusable detection. Ported 1:1 from the React source.
*
* The `isOutsideEvent` signature loses `React.FocusEvent` — Solid's
* handlers see the native `FocusEvent` directly, so callers pass that
* through as-is.
*/
export type FocusableElement = HTMLElement | SVGElement;
const CANDIDATE_SELECTOR =
'a[href],button,input,select,textarea,summary,details,iframe,object,embed,[tabindex],[contenteditable]:not([contenteditable="false"]),audio[controls],video[controls]';
function getParentElement(element: Element): Element | null {
const assignedSlot = (
element as Element & {
assignedSlot?: HTMLSlotElement | null | undefined;
}
).assignedSlot;
if (assignedSlot) {
return assignedSlot;
}
if (element.parentElement) {
return element.parentElement;
}
const rootNode = element.getRootNode();
return isShadowRoot(rootNode) ? rootNode.host : null;
}
function getDetailsSummary(details: Element): Element | null {
for (const child of Array.from(details.children)) {
if (getNodeName(child) === "summary") {
return child;
}
}
return null;
}
function isWithinOpenDetailsSummary(
element: Element,
details: Element,
): boolean {
const summary = getDetailsSummary(details);
return !!summary && (element === summary || contains(summary, element));
}
function isFocusableCandidate(
element: Element | null,
): element is FocusableElement {
const nodeName = element ? getNodeName(element) : "";
// Type predicates narrow by explicit `!= null`; optional-chain collapse
// would erase that narrowing.
return (
// biome-ignore lint/complexity/useOptionalChain: needed for type-predicate narrowing.
element != null &&
element.matches(CANDIDATE_SELECTOR) &&
(nodeName !== "summary" ||
(element.parentElement != null &&
getNodeName(element.parentElement) === "details" &&
getDetailsSummary(element.parentElement) === element)) &&
(nodeName !== "details" || getDetailsSummary(element) == null) &&
(nodeName !== "input" || (element as HTMLInputElement).type !== "hidden")
);
}
function isFocusableElement(
element: Element | null,
): element is FocusableElement {
if (
!isFocusableCandidate(element) ||
!element.isConnected ||
element.matches(":disabled")
) {
return false;
}
for (
let current: Element | null = element;
current;
current = getParentElement(current)
) {
const isSlot = getNodeName(current) === "slot";
if (current.hasAttribute("inert")) {
return false;
}
if (
(current !== element &&
getNodeName(current) === "details" &&
!(current as HTMLDetailsElement).open &&
!isWithinOpenDetailsSummary(element, current)) ||
current.hasAttribute("hidden") ||
(!isSlot && !isElementVisible(current))
) {
return false;
}
}
return true;
}
function getTabIndex(element: FocusableElement): number {
const tabIndex = element.tabIndex;
if (tabIndex < 0) {
const nodeName = getNodeName(element);
if (
nodeName === "details" ||
nodeName === "audio" ||
nodeName === "video" ||
(isHTMLElement(element) && element.isContentEditable)
) {
return 0;
}
}
return tabIndex;
}
function getNamedRadioInput(
element: FocusableElement,
): HTMLInputElement | null {
if (getNodeName(element) !== "input") {
return null;
}
const input = element as HTMLInputElement;
return input.type === "radio" && input.name !== "" ? input : null;
}
function isTabbableRadio(
element: FocusableElement,
candidates: FocusableElement[],
): boolean {
const input = getNamedRadioInput(element);
if (!input) {
return true;
}
const checkedRadio = candidates.find((candidate) => {
const radio = getNamedRadioInput(candidate);
return (
radio?.name === input.name && radio.form === input.form && radio.checked
);
});
if (checkedRadio) {
return checkedRadio === input;
}
return (
candidates.find((candidate) => {
const radio = getNamedRadioInput(candidate);
return radio?.name === input.name && radio.form === input.form;
}) === input
);
}
function getComposedChildren(container: ParentNode): Element[] {
if (isHTMLElement(container) && getNodeName(container) === "slot") {
const assignedElements = (container as HTMLSlotElement).assignedElements({
flatten: true,
});
if (assignedElements.length > 0) {
return assignedElements;
}
}
if (isHTMLElement(container) && container.shadowRoot) {
return Array.from(container.shadowRoot.children);
}
return Array.from(container.children);
}
function appendCandidates(container: ParentNode, list: FocusableElement[]) {
getComposedChildren(container).forEach((child) => {
if (isFocusableCandidate(child)) {
list.push(child);
}
appendCandidates(child, list);
});
}
function appendMatchingElements(
container: ParentNode,
selector: string,
list: HTMLElement[],
) {
getComposedChildren(container).forEach((child) => {
if (isHTMLElement(child) && child.matches(selector)) {
list.push(child);
}
appendMatchingElements(child, selector, list);
});
}
export function isTabbable(element: Element | null): boolean {
return isFocusableElement(element) && getTabIndex(element) >= 0;
}
export function focusable(container: Element): FocusableElement[] {
const candidates: FocusableElement[] = [];
appendCandidates(container, candidates);
return candidates.filter(isFocusableElement);
}
export function tabbable(container: Element): FocusableElement[] {
const candidates = focusable(container);
return candidates.filter(
(element) =>
getTabIndex(element) >= 0 && isTabbableRadio(element, candidates),
);
}
function getTabbableIn(
container: HTMLElement,
dir: 1 | -1,
): FocusableElement | undefined {
const list = tabbable(container);
const len = list.length;
if (len === 0) {
return undefined;
}
const active = activeElement(ownerDocument(container)) as FocusableElement;
const index = list.indexOf(active);
const nextIndex = index === -1 ? (dir === 1 ? 0 : len - 1) : index + dir;
return list[nextIndex];
}
export function getNextTabbable(
referenceElement: Element | null,
): FocusableElement | null {
return (
getTabbableIn(ownerDocument(referenceElement).body, 1) ||
(referenceElement as FocusableElement)
);
}
export function getPreviousTabbable(
referenceElement: Element | null,
): FocusableElement | null {
return (
getTabbableIn(ownerDocument(referenceElement).body, -1) ||
(referenceElement as FocusableElement)
);
}
function getTabbableNearElement(
referenceElement: Element | null,
dir: 1 | -1,
): FocusableElement | null {
if (!referenceElement) {
return null;
}
const list = tabbable(ownerDocument(referenceElement).body);
const elementCount = list.length;
if (elementCount === 0) {
return null;
}
const index = list.indexOf(referenceElement as FocusableElement);
if (index === -1) {
return null;
}
const nextIndex = (index + dir + elementCount) % elementCount;
return list[nextIndex] ?? null;
}
export function getTabbableAfterElement(
referenceElement: Element | null,
): FocusableElement | null {
return getTabbableNearElement(referenceElement, 1);
}
export function getTabbableBeforeElement(
referenceElement: Element | null,
): FocusableElement | null {
return getTabbableNearElement(referenceElement, -1);
}
export function isOutsideEvent(
event: FocusEvent,
container?: Element,
): boolean {
const containerElement = (container || event.currentTarget) as Element;
const relatedTarget = event.relatedTarget as HTMLElement | null;
return !relatedTarget || !contains(containerElement, relatedTarget);
}
export function disableFocusInside(container: HTMLElement): void {
const tabbableElements = tabbable(container);
tabbableElements.forEach((element) => {
element.dataset.tabindex = element.getAttribute("tabindex") || "";
element.setAttribute("tabindex", "-1");
});
}
export function enableFocusInside(container: HTMLElement): void {
const elements: HTMLElement[] = [];
appendMatchingElements(container, "[data-tabindex]", elements);
elements.forEach((element) => {
const tabindex = element.dataset.tabindex;
delete element.dataset.tabindex;
if (tabindex) {
element.setAttribute("tabindex", tabindex);
} else {
element.removeAttribute("tabindex");
}
});
}