Focus trap utility

This commit is contained in:
Mats Bosson 2026-03-29 04:36:00 +07:00
parent 697e80ef72
commit 5b86cb5650
3 changed files with 133 additions and 0 deletions

View File

@ -0,0 +1,71 @@
import type { Accessor } from "solid-js";
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
"details > summary",
].join(",");
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS));
}
export interface FocusTrap {
activate: () => void;
deactivate: () => void;
}
/**
* Creates a focus trap that confines Tab focus within a container.
* Restores focus to the previously focused element on deactivate.
*/
export function createFocusTrap(getContainer: Accessor<HTMLElement | null>): FocusTrap {
let previouslyFocused: HTMLElement | null = null;
const handleKeyDown = (e: KeyboardEvent) => {
const container = getContainer();
if (!container || e.key !== "Tab") return;
const focusable = getFocusableElements(container);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
return {
activate() {
const container = getContainer();
if (!container) return;
previouslyFocused = document.activeElement as HTMLElement | null;
const focusable = getFocusableElements(container);
focusable[0]?.focus();
document.addEventListener("keydown", handleKeyDown);
},
deactivate() {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
previouslyFocused = null;
},
};
}

View File

@ -0,0 +1,2 @@
export { createFocusTrap } from "./create-focus-trap";
export type { FocusTrap } from "./create-focus-trap";

View File

@ -0,0 +1,60 @@
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);
});
});