// `focus.js`: keeping the reader's place across an out-of-band swap. // // `98548a35`'s done condition asked for exactly this and it has been open // since: swap a region holding a focused input, and assert focus and the caret // survive. The failure is silent to anyone not navigating by keyboard, which is // why nobody would have reported it. // // htmx is not loaded here, deliberately. What `focus.js` owns is the pair of // events -- record on `htmx:before:request`, spend on `htmx:after:settle` -- // and the swap between them is the browser replacing a node. Loading htmx to // raise those two events would be testing htmx. /** A region holding one named text box, as `oob_html` would emit it. */ const region = (value) => fixture( `
`, ); /** The request going out, which is when `focus.js` takes its record. */ const asking = () => document.dispatchEvent(new CustomEvent("htmx:before:request")); /** Everything settled, including the regions htmx did not target. */ const settled = () => document.dispatchEvent(new CustomEvent("htmx:after:settle")); /** * Replace the region the way an out-of-band swap does: a new element, not an * edited one. That is the whole of the problem -- the focused control is * destroyed, so focus falls to `body`. */ const swap = (value) => { document.getElementById("filters").remove(); const held = document.getElementById("fixture"); held.insertAdjacentHTML( "beforeend", `
`, ); }; describe("focus.js, as shipped", () => { it("puts focus and the caret back after a swap destroys the control", () => { region("dru"); const box = document.getElementById("q"); box.focus(); box.setSelectionRange(2, 2); asking(); swap("dru"); expect(document.activeElement, "the swap dropped focus").to.equal(document.body); settled(); const found = document.getElementById("q"); expect(document.activeElement, "focus is back on the box").to.equal(found); expect(found.selectionStart, "and so is the caret").to.equal(2); expect(found.selectionEnd).to.equal(2); }); it("leaves focus alone where htmx already put it somewhere", () => { // htmx restores focus for the main swap target itself, correctly and // with its own record of the caret. A second party moving it afterwards // is two scripts fighting over one frame. region("dru"); document.getElementById("q").focus(); asking(); swap("dru"); const elsewhere = document.getElementById("fixture"); elsewhere.tabIndex = -1; elsewhere.focus(); settled(); expect(document.activeElement).to.equal(elsewhere); elsewhere.removeAttribute("tabindex"); }); it("leaves focus alone where the control is genuinely gone", () => { // A row the reader deleted, a field a reveal took away. Focus stays // where htmx left it rather than being moved somewhere arbitrary. region("dru"); document.getElementById("q").focus(); asking(); document.getElementById("filters").remove(); settled(); expect(document.activeElement).to.equal(document.body); }); it("finds a nameless control by nothing, and does not guess", () => { // Neither an id nor a name is not addressable, and guessing at a // neighbour by position is how focus lands on the wrong control. const held = fixture('
'); held.querySelector("input").focus(); asking(); held.innerHTML = '
'; settled(); expect(document.activeElement).to.equal(document.body); }); it("keeps focus where a control has no caret to put back", () => { // `selectionStart` throws a DOMException on input types that have no // text selection, and which types those are differs between engines. // The guard is the point: focus still lands, the caret simply does not // apply. fixture('
'); document.getElementById("q").focus(); asking(); document.getElementById("filters").remove(); document .getElementById("fixture") .insertAdjacentHTML( "beforeend", '
', ); settled(); expect(document.activeElement).to.equal(document.getElementById("q")); }); });