diff --git a/packages/core/src/utilities/focus-trap/create-focus-trap.ts b/packages/core/src/utilities/focus-trap/create-focus-trap.ts new file mode 100644 index 0000000..bab93be --- /dev/null +++ b/packages/core/src/utilities/focus-trap/create-focus-trap.ts @@ -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(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): 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; + }, + }; +} diff --git a/packages/core/src/utilities/focus-trap/index.ts b/packages/core/src/utilities/focus-trap/index.ts new file mode 100644 index 0000000..06296db --- /dev/null +++ b/packages/core/src/utilities/focus-trap/index.ts @@ -0,0 +1,2 @@ +export { createFocusTrap } from "./create-focus-trap"; +export type { FocusTrap } from "./create-focus-trap"; diff --git a/packages/core/tests/utilities/focus-trap.test.ts b/packages/core/tests/utilities/focus-trap.test.ts new file mode 100644 index 0000000..fe50713 --- /dev/null +++ b/packages/core/tests/utilities/focus-trap.test.ts @@ -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); + }); +});