import { describe, expect, it } from "vitest"; import { FOCUSABLE_SELECTOR, isFocusable, nextFocusable, trapFocus } from "./focus.js"; describe("FOCUSABLE_SELECTOR", () => { it("covers the standard focusable set", () => { expect(FOCUSABLE_SELECTOR).toContain("a[href]"); expect(FOCUSABLE_SELECTOR).toContain("button:not([disabled])"); expect(FOCUSABLE_SELECTOR).toContain("input"); expect(FOCUSABLE_SELECTOR).toContain("select"); expect(FOCUSABLE_SELECTOR).toContain("textarea"); expect(FOCUSABLE_SELECTOR).toContain('[tabindex]:not([tabindex="-1"])'); expect(FOCUSABLE_SELECTOR).toContain("[contenteditable]"); }); }); describe("isFocusable (duck-typed)", () => { it("focusable tags without disabled", () => { expect(isFocusable({ tagname: "button" })).toBe(true); expect(isFocusable({ tagname: "a", getAttribute: (n) => (n === "href" ? "#" : null) })).toBe( true, ); expect(isFocusable({ tagname: "input" })).toBe(true); expect(isFocusable({ tagname: "select" })).toBe(true); expect(isFocusable({ tagname: "textarea" })).toBe(true); }); it("disabled / hrefless / tabindex=-1 are not focusable", () => { expect( isFocusable({ tagname: "button", getAttribute: (n) => (n === "disabled" ? "" : null) }), ).toBe(false); expect(isFocusable({ tagname: "a", getAttribute: () => null })).toBe(false); expect( isFocusable({ tagname: "div", getAttribute: (n) => (n === "tabindex" ? "-1" : null) }), ).toBe(false); }); it("valid tabindex and contenteditable make anything focusable", () => { expect( isFocusable({ tagname: "div", getAttribute: (n) => (n === "tabindex" ? "0" : null) }), ).toBe(true); expect(isFocusable({ tagname: "div", isContentEditable: true })).toBe(true); expect( isFocusable({ tagname: "div", getAttribute: (n) => (n === "contenteditable" ? "true" : null), }), ).toBe(true); expect( isFocusable({ tagname: "div", getAttribute: (n) => (n === "contenteditable" ? "false" : null), }), ).toBe(false); }); }); describe("nextFocusable (pure decision logic)", () => { const c = ["a", "b", "c", "d"]; const allTrue = [true, true, true, true]; it("moves forward and wraps", () => { expect(nextFocusable(c, "a", false, allTrue)).toBe(1); expect(nextFocusable(c, "b", false, allTrue)).toBe(2); expect(nextFocusable(c, "d", false, allTrue)).toBe(0); }); it("moves backward with shift and wraps", () => { expect(nextFocusable(c, "b", true, allTrue)).toBe(0); expect(nextFocusable(c, "a", true, allTrue)).toBe(3); expect(nextFocusable(c, "d", true, allTrue)).toBe(2); }); it("skips non-focusable candidates", () => { const flags = [true, false, true, false]; expect(nextFocusable(c, "a", false, flags)).toBe(2); expect(nextFocusable(c, "c", false, flags)).toBe(0); // wraps past d (unfocusable) expect(nextFocusable(c, "c", true, flags)).toBe(0); expect(nextFocusable(c, "a", true, flags)).toBe(2); // wraps backward past d, b }); it("returns -1 when nothing is focusable", () => { expect(nextFocusable(c, "a", false, [false, false, false, false])).toBe(-1); expect(nextFocusable([], "a", false, [])).toBe(-1); }); it("handles unknown current focus (treated as outside)", () => { expect(nextFocusable(c, "z", false, allTrue)).toBe(0); expect(nextFocusable(c, "z", true, allTrue)).toBe(3); }); it("all-true with a single candidate wraps to itself", () => { expect(nextFocusable(["x"], "x", false, [true])).toBe(0); expect(nextFocusable(["x"], "x", true, [true])).toBe(0); }); }); describe("trapFocus (thin adapter)", () => { it("activate focuses the first focusable child; deactivate cleans up", () => { const focused: string[] = []; const btn = { tagname: "button" as const, focus: () => focused.push("btn") }; const listeners: Array<() => void> = []; const container = { querySelectorAll: () => [btn] as unknown as ArrayLike<{ tagname?: string }>, addEventListener: (_t: string, h: (e: unknown) => void) => { listeners.push(h as () => void); }, removeEventListener: (_t: string, h: (e: unknown) => void) => { const i = listeners.indexOf(h as () => void); if (i >= 0) listeners.splice(i, 1); }, }; const trap = trapFocus({ container, returnFocus: true }); trap.activate(); expect(focused).toEqual(["btn"]); expect(listeners).toHaveLength(1); trap.deactivate(); expect(listeners).toHaveLength(0); }); it("deactivate with no prior activate is a safe no-op", () => { const trap = trapFocus({ container: null }); expect(() => trap.deactivate()).not.toThrow(); }); });