// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from "vitest";
import {
ensureFocusWithin,
focusFirst,
focusLast,
getFocusableElements,
isElementDisabled,
isElementHidden,
isFocusable,
wrapFocus,
} from "../src/utils/focusManagement";
function renderFocusFixture(): HTMLElement {
document.body.innerHTML = `
No href
Catalogue
Programmatic only
Zero tab index
Editable
`;
return document.getElementById("fixture") as HTMLElement;
}
describe("focusManagement", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("returns keyboard-focusable descendants in browser tab order", () => {
const fixture = renderFocusFixture();
expect(getFocusableElements(fixture).map((element) => element.id)).toEqual([
"positive-two",
"positive-five",
"link",
"button",
"input",
"zero",
"editable",
]);
});
it("filters hidden, disabled, and programmatic-only elements by default", () => {
const fixture = renderFocusFixture();
expect(isElementDisabled(document.getElementById("disabled") as HTMLElement)).toBe(true);
expect(isElementDisabled(document.getElementById("aria-disabled") as HTMLElement)).toBe(true);
expect(isElementHidden(document.getElementById("aria-hidden-child") as HTMLElement)).toBe(true);
expect(isElementHidden(document.getElementById("inert-child") as HTMLElement)).toBe(true);
expect(isElementHidden(document.getElementById("display-none-child") as HTMLElement)).toBe(true);
expect(isFocusable(document.getElementById("nohref"))).toBe(false);
expect(isFocusable(document.getElementById("hidden-input"))).toBe(false);
expect(isFocusable(document.getElementById("negative"))).toBe(false);
expect(isFocusable(document.getElementById("negative"), { includeNegativeTabIndex: true })).toBe(
true,
);
expect(getFocusableElements(fixture)).not.toContain(document.getElementById("disabled"));
expect(getFocusableElements(fixture)).not.toContain(document.getElementById("aria-disabled"));
expect(getFocusableElements(fixture)).not.toContain(document.getElementById("negative"));
});
it("can include a programmatically focusable container after tabbable children", () => {
document.body.innerHTML = `
`;
const panel = document.getElementById("panel") as HTMLElement;
expect(getFocusableElements(panel).map((element) => element.id)).toEqual(["inside"]);
expect(
getFocusableElements(panel, {
includeContainer: true,
includeNegativeTabIndex: true,
}).map((element) => element.id),
).toEqual(["inside", "panel"]);
});
it("moves focus to first and last focusable controls", () => {
document.body.innerHTML = `
`;
const trap = document.getElementById("trap") as HTMLElement;
expect(focusFirst(trap)).toBe(true);
expect(document.activeElement?.id).toBe("first");
expect(focusLast(trap)).toBe(true);
expect(document.activeElement?.id).toBe("last");
});
it("falls back to the container when explicitly requested", () => {
document.body.innerHTML = ``;
const empty = document.getElementById("empty") as HTMLElement;
expect(focusFirst(empty)).toBe(false);
expect(
ensureFocusWithin(empty, {
includeContainer: true,
includeNegativeTabIndex: true,
}),
).toBe(true);
expect(document.activeElement?.id).toBe("empty");
});
it("wraps tab focus at the beginning and end of a trapped region", () => {
document.body.innerHTML = `
`;
const trap = document.getElementById("trap") as HTMLElement;
const first = document.getElementById("first") as HTMLButtonElement;
const last = document.getElementById("last") as HTMLButtonElement;
const outside = document.getElementById("outside") as HTMLButtonElement;
last.focus();
const forwardEvent = new KeyboardEvent("keydown", {
key: "Tab",
cancelable: true,
});
expect(wrapFocus(trap, forwardEvent)).toBe(true);
expect(forwardEvent.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(first);
first.focus();
const backwardEvent = new KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
cancelable: true,
});
expect(wrapFocus(trap, backwardEvent)).toBe(true);
expect(backwardEvent.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(last);
outside.focus();
const outsideEvent = new KeyboardEvent("keydown", {
key: "Tab",
cancelable: true,
});
expect(wrapFocus(trap, outsideEvent)).toBe(true);
expect(outsideEvent.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(first);
});
});