Focus trap utility
This commit is contained in:
parent
697e80ef72
commit
5b86cb5650
71
packages/core/src/utilities/focus-trap/create-focus-trap.ts
Normal file
71
packages/core/src/utilities/focus-trap/create-focus-trap.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
2
packages/core/src/utilities/focus-trap/index.ts
Normal file
2
packages/core/src/utilities/focus-trap/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { createFocusTrap } from "./create-focus-trap";
|
||||||
|
export type { FocusTrap } from "./create-focus-trap";
|
||||||
60
packages/core/tests/utilities/focus-trap.test.ts
Normal file
60
packages/core/tests/utilities/focus-trap.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user