Harden focus trap and add tests
This commit is contained in:
parent
5b86cb5650
commit
7e201f6af6
@ -3,7 +3,7 @@ import type { Accessor } from "solid-js";
|
|||||||
const FOCUSABLE_SELECTORS = [
|
const FOCUSABLE_SELECTORS = [
|
||||||
"a[href]",
|
"a[href]",
|
||||||
"button:not([disabled])",
|
"button:not([disabled])",
|
||||||
"input:not([disabled])",
|
"input:not([disabled]):not([type='hidden'])",
|
||||||
"select:not([disabled])",
|
"select:not([disabled])",
|
||||||
"textarea:not([disabled])",
|
"textarea:not([disabled])",
|
||||||
"[tabindex]:not([tabindex='-1'])",
|
"[tabindex]:not([tabindex='-1'])",
|
||||||
@ -52,10 +52,14 @@ export function createFocusTrap(getContainer: Accessor<HTMLElement | null>): Foc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let isActive = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activate() {
|
activate() {
|
||||||
|
if (isActive) return;
|
||||||
const container = getContainer();
|
const container = getContainer();
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
isActive = true;
|
||||||
|
|
||||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
const focusable = getFocusableElements(container);
|
const focusable = getFocusableElements(container);
|
||||||
@ -63,6 +67,8 @@ export function createFocusTrap(getContainer: Accessor<HTMLElement | null>): Foc
|
|||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
},
|
},
|
||||||
deactivate() {
|
deactivate() {
|
||||||
|
if (!isActive) return;
|
||||||
|
isActive = false;
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
previouslyFocused?.focus();
|
previouslyFocused?.focus();
|
||||||
previouslyFocused = null;
|
previouslyFocused = null;
|
||||||
|
|||||||
@ -57,4 +57,93 @@ describe("createFocusTrap", () => {
|
|||||||
document.body.removeChild(outside);
|
document.body.removeChild(outside);
|
||||||
document.body.removeChild(container);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user