PettyUI/packages/core/tests/utilities/focus-trap.test.ts
Mats Bosson 7dd8615757 Package entry point and lint fixes
Adds packages/core/src/index.ts as the convenience re-export barrel for
all Dialog parts, Presence, Portal, VisuallyHidden, createFocusTrap,
createScrollLock, and createDismiss. Also resolves pre-existing biome
formatter and noNonNullAssertion violations across five files so CI passes.
2026-03-29 06:01:41 +07:00

157 lines
5.1 KiB
TypeScript

import { createRoot } from "solid-js";
import { describe, expect, it } from "vitest";
import { createFocusTrap } from "../../src/utilities/focus-trap/create-focus-trap";
describe("createFocusTrap", () => {
it("focuses the first focusable element when activated", () => {
const container = document.createElement("div");
const button1 = document.createElement("button");
const button2 = document.createElement("button");
button1.textContent = "First";
button2.textContent = "Second";
container.appendChild(button1);
container.appendChild(button2);
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
expect(document.activeElement).toBe(button1);
trap.deactivate();
dispose();
});
document.body.removeChild(container);
});
it("does nothing when container is null", () => {
createRoot((dispose) => {
const trap = createFocusTrap(() => null);
// Should not throw
expect(() => trap.activate()).not.toThrow();
dispose();
});
});
it("returns focus to previously focused element on deactivate", () => {
const outside = document.createElement("button");
outside.textContent = "Outside";
document.body.appendChild(outside);
outside.focus();
const container = document.createElement("div");
const inner = document.createElement("button");
inner.textContent = "Inner";
container.appendChild(inner);
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
expect(document.activeElement).toBe(inner);
trap.deactivate();
expect(document.activeElement).toBe(outside);
dispose();
});
document.body.removeChild(outside);
document.body.removeChild(container);
});
it("wraps Tab forward from last focusable to first", () => {
const container = document.createElement("div");
const button1 = document.createElement("button");
const button2 = document.createElement("button");
button1.textContent = "First";
button2.textContent = "Second";
container.appendChild(button1);
container.appendChild(button2);
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
button2.focus();
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
document.dispatchEvent(event);
expect(document.activeElement).toBe(button1);
trap.deactivate();
dispose();
});
document.body.removeChild(container);
});
it("wraps Shift+Tab backward from first focusable to last", () => {
const container = document.createElement("div");
const button1 = document.createElement("button");
const button2 = document.createElement("button");
button1.textContent = "First";
button2.textContent = "Second";
container.appendChild(button1);
container.appendChild(button2);
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
// button1 is already focused after activate
const event = new KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
expect(document.activeElement).toBe(button2);
trap.deactivate();
dispose();
});
document.body.removeChild(container);
});
it("does not throw when container has no focusable elements", () => {
const container = document.createElement("div");
container.textContent = "No focusable children";
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
// Tab should be prevented but not throw
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
expect(() => document.dispatchEvent(event)).not.toThrow();
trap.deactivate();
dispose();
});
document.body.removeChild(container);
});
it("second activate() is a no-op — does not attach duplicate listeners", () => {
const container = document.createElement("div");
const button = document.createElement("button");
container.appendChild(button);
document.body.appendChild(container);
createRoot((dispose) => {
const trap = createFocusTrap(() => container);
trap.activate();
trap.activate(); // second call should be ignored
button.focus();
// Tab from the only element — Tab wraps back to itself (first === last)
// What matters is it doesn't fire twice causing erratic behaviour
expect(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }),
);
}).not.toThrow();
trap.deactivate();
dispose();
});
document.body.removeChild(container);
});
});