1import { describe, expect, it } from "vitest";
2import { FOCUSABLE_SELECTOR, isFocusable, nextFocusable, trapFocus } from "./focus.js";
3
4describe("FOCUSABLE_SELECTOR", () => {
5 it("covers the standard focusable set", () => {
6 expect(FOCUSABLE_SELECTOR).toContain("a[href]");
7 expect(FOCUSABLE_SELECTOR).toContain("button:not([disabled])");
8 expect(FOCUSABLE_SELECTOR).toContain("input");
9 expect(FOCUSABLE_SELECTOR).toContain("select");
10 expect(FOCUSABLE_SELECTOR).toContain("textarea");
11 expect(FOCUSABLE_SELECTOR).toContain('[tabindex]:not([tabindex="-1"])');
12 expect(FOCUSABLE_SELECTOR).toContain("[contenteditable]");
13 });
14});
15
16describe("isFocusable (duck-typed)", () => {
17 it("focusable tags without disabled", () => {
18 expect(isFocusable({ tagname: "button" })).toBe(true);
19 expect(isFocusable({ tagname: "a", getAttribute: (n) => (n === "href" ? "#" : null) })).toBe(
20 true,
21 );
22 expect(isFocusable({ tagname: "input" })).toBe(true);
23 expect(isFocusable({ tagname: "select" })).toBe(true);
24 expect(isFocusable({ tagname: "textarea" })).toBe(true);
25 });
26
27 it("disabled / hrefless / tabindex=-1 are not focusable", () => {
28 expect(
29 isFocusable({ tagname: "button", getAttribute: (n) => (n === "disabled" ? "" : null) }),
30 ).toBe(false);
31 expect(isFocusable({ tagname: "a", getAttribute: () => null })).toBe(false);
32 expect(
33 isFocusable({ tagname: "div", getAttribute: (n) => (n === "tabindex" ? "-1" : null) }),
34 ).toBe(false);
35 });
36
37 it("valid tabindex and contenteditable make anything focusable", () => {
38 expect(
39 isFocusable({ tagname: "div", getAttribute: (n) => (n === "tabindex" ? "0" : null) }),
40 ).toBe(true);
41 expect(isFocusable({ tagname: "div", isContentEditable: true })).toBe(true);
42 expect(
43 isFocusable({
44 tagname: "div",
45 getAttribute: (n) => (n === "contenteditable" ? "true" : null),
46 }),
47 ).toBe(true);
48 expect(
49 isFocusable({
50 tagname: "div",
51 getAttribute: (n) => (n === "contenteditable" ? "false" : null),
52 }),
53 ).toBe(false);
54 });
55});
56
57describe("nextFocusable (pure decision logic)", () => {
58 const c = ["a", "b", "c", "d"];
59 const allTrue = [true, true, true, true];
60
61 it("moves forward and wraps", () => {
62 expect(nextFocusable(c, "a", false, allTrue)).toBe(1);
63 expect(nextFocusable(c, "b", false, allTrue)).toBe(2);
64 expect(nextFocusable(c, "d", false, allTrue)).toBe(0);
65 });
66
67 it("moves backward with shift and wraps", () => {
68 expect(nextFocusable(c, "b", true, allTrue)).toBe(0);
69 expect(nextFocusable(c, "a", true, allTrue)).toBe(3);
70 expect(nextFocusable(c, "d", true, allTrue)).toBe(2);
71 });
72
73 it("skips non-focusable candidates", () => {
74 const flags = [true, false, true, false];
75 expect(nextFocusable(c, "a", false, flags)).toBe(2);
76 expect(nextFocusable(c, "c", false, flags)).toBe(0);
77 expect(nextFocusable(c, "c", true, flags)).toBe(0);
78 expect(nextFocusable(c, "a", true, flags)).toBe(2);
79 });
80
81 it("returns -1 when nothing is focusable", () => {
82 expect(nextFocusable(c, "a", false, [false, false, false, false])).toBe(-1);
83 expect(nextFocusable([], "a", false, [])).toBe(-1);
84 });
85
86 it("handles unknown current focus (treated as outside)", () => {
87 expect(nextFocusable(c, "z", false, allTrue)).toBe(0);
88 expect(nextFocusable(c, "z", true, allTrue)).toBe(3);
89 });
90
91 it("all-true with a single candidate wraps to itself", () => {
92 expect(nextFocusable(["x"], "x", false, [true])).toBe(0);
93 expect(nextFocusable(["x"], "x", true, [true])).toBe(0);
94 });
95});
96
97describe("trapFocus (thin adapter)", () => {
98 it("activate focuses the first focusable child; deactivate cleans up", () => {
99 const focused: string[] = [];
100 const btn = { tagname: "button" as const, focus: () => focused.push("btn") };
101 const listeners: Array<() => void> = [];
102 const container = {
103 querySelectorAll: () => [btn] as unknown as ArrayLike<{ tagname?: string }>,
104 addEventListener: (_t: string, h: (e: unknown) => void) => {
105 listeners.push(h as () => void);
106 },
107 removeEventListener: (_t: string, h: (e: unknown) => void) => {
108 const i = listeners.indexOf(h as () => void);
109 if (i >= 0) listeners.splice(i, 1);
110 },
111 };
112 const trap = trapFocus({ container, returnFocus: true });
113 trap.activate();
114 expect(focused).toEqual(["btn"]);
115 expect(listeners).toHaveLength(1);
116 trap.deactivate();
117 expect(listeners).toHaveLength(0);
118 });
119
120 it("deactivate with no prior activate is a safe no-op", () => {
121 const trap = trapFocus({ container: null });
122 expect(() => trap.deactivate()).not.toThrow();
123 });
124});
125