Skip to main content

max / quasi

4.7 KB · 123 lines History Blame Raw
1 // `focus.js`: keeping the reader's place across an out-of-band swap.
2 //
3 // `98548a35`'s done condition asked for exactly this and it has been open
4 // since: swap a region holding a focused input, and assert focus and the caret
5 // survive. The failure is silent to anyone not navigating by keyboard, which is
6 // why nobody would have reported it.
7 //
8 // htmx is not loaded here, deliberately. What `focus.js` owns is the pair of
9 // events -- record on `htmx:before:request`, spend on `htmx:after:settle` --
10 // and the swap between them is the browser replacing a node. Loading htmx to
11 // raise those two events would be testing htmx.
12
13 /** A region holding one named text box, as `oob_html` would emit it. */
14 const region = (value) =>
15 fixture(
16 `<div id="filters"><input id="q" name="q" type="text" value="${value}"></div>`,
17 );
18
19 /** The request going out, which is when `focus.js` takes its record. */
20 const asking = () => document.dispatchEvent(new CustomEvent("htmx:before:request"));
21
22 /** Everything settled, including the regions htmx did not target. */
23 const settled = () => document.dispatchEvent(new CustomEvent("htmx:after:settle"));
24
25 /**
26 * Replace the region the way an out-of-band swap does: a new element, not an
27 * edited one. That is the whole of the problem -- the focused control is
28 * destroyed, so focus falls to `body`.
29 */
30 const swap = (value) => {
31 document.getElementById("filters").remove();
32 const held = document.getElementById("fixture");
33 held.insertAdjacentHTML(
34 "beforeend",
35 `<div id="filters"><input id="q" name="q" type="text" value="${value}"></div>`,
36 );
37 };
38
39 describe("focus.js, as shipped", () => {
40 it("puts focus and the caret back after a swap destroys the control", () => {
41 region("dru");
42 const box = document.getElementById("q");
43 box.focus();
44 box.setSelectionRange(2, 2);
45
46 asking();
47 swap("dru");
48 expect(document.activeElement, "the swap dropped focus").to.equal(document.body);
49
50 settled();
51
52 const found = document.getElementById("q");
53 expect(document.activeElement, "focus is back on the box").to.equal(found);
54 expect(found.selectionStart, "and so is the caret").to.equal(2);
55 expect(found.selectionEnd).to.equal(2);
56 });
57
58 it("leaves focus alone where htmx already put it somewhere", () => {
59 // htmx restores focus for the main swap target itself, correctly and
60 // with its own record of the caret. A second party moving it afterwards
61 // is two scripts fighting over one frame.
62 region("dru");
63 document.getElementById("q").focus();
64
65 asking();
66 swap("dru");
67 const elsewhere = document.getElementById("fixture");
68 elsewhere.tabIndex = -1;
69 elsewhere.focus();
70
71 settled();
72 expect(document.activeElement).to.equal(elsewhere);
73 elsewhere.removeAttribute("tabindex");
74 });
75
76 it("leaves focus alone where the control is genuinely gone", () => {
77 // A row the reader deleted, a field a reveal took away. Focus stays
78 // where htmx left it rather than being moved somewhere arbitrary.
79 region("dru");
80 document.getElementById("q").focus();
81
82 asking();
83 document.getElementById("filters").remove();
84
85 settled();
86 expect(document.activeElement).to.equal(document.body);
87 });
88
89 it("finds a nameless control by nothing, and does not guess", () => {
90 // Neither an id nor a name is not addressable, and guessing at a
91 // neighbour by position is how focus lands on the wrong control.
92 const held = fixture('<div id="wrap"><input type="text"></div>');
93 held.querySelector("input").focus();
94
95 asking();
96 held.innerHTML = '<div id="wrap"><input type="text"></div>';
97
98 settled();
99 expect(document.activeElement).to.equal(document.body);
100 });
101
102 it("keeps focus where a control has no caret to put back", () => {
103 // `selectionStart` throws a DOMException on input types that have no
104 // text selection, and which types those are differs between engines.
105 // The guard is the point: focus still lands, the caret simply does not
106 // apply.
107 fixture('<div id="filters"><input id="q" name="q" type="checkbox"></div>');
108 document.getElementById("q").focus();
109
110 asking();
111 document.getElementById("filters").remove();
112 document
113 .getElementById("fixture")
114 .insertAdjacentHTML(
115 "beforeend",
116 '<div id="filters"><input id="q" name="q" type="checkbox"></div>',
117 );
118
119 settled();
120 expect(document.activeElement).to.equal(document.getElementById("q"));
121 });
122 });
123