// Keeping the reader's place in the tab order across a swap htmx does not // restore focus for. // // htmx saves and restores focus around the *main* swap target, and only that // one. A focused control inside an out-of-band region is destroyed outside that // window, so focus falls to `body`: the caret vanishes, and the next Tab starts // from the top of the document rather than from where the reader was. This // renderer emits out-of-band swaps -- `node::oob_html`, which is how a response // updates a second region it did not target -- so it owns the gap. // // The failure is silent to anyone not navigating by keyboard, which is the // reason to fix it rather than leave it: nobody will report it. // // # Why it triggers on focus actually being lost // // The obvious shape is "record what was focused inside a region about to be // replaced, put it back afterwards". This does that, with one narrowing: it // only restores when focus has genuinely fallen to `body` or out of the // document. htmx already restores focus for the main target, correctly and // with its own record of the caret, and a second party putting focus somewhere // after it would be two scripts fighting over the same frame. So the record is // taken from anything focused, and spent only where htmx left nothing. (() => { "use strict"; // What was focused when the request went out, and where its caret was. // One slot: a document makes one focused element, and a second request // starting before the first settles is the reader having moved on. let held = null; /** * How to find this element again in markup that has replaced it. * * An `id` is the description's own address for the thing and survives a * re-render, so it is preferred. A `name` is the next best: a form control * that carries one is being identified by the description too, since that * is the key the route reads it under. Anything with neither is not * addressable and is left alone -- guessing at a neighbour by position is * how focus lands on the wrong control, which is worse than losing it. */ const address = (element) => { if (element.id) return `#${CSS.escape(element.id)}`; const name = element.getAttribute?.("name"); if (name) { const tag = element.tagName.toLowerCase(); return `${tag}[name="${CSS.escape(name)}"]`; } return null; }; /** * The caret, for a control that has one. * * `selectionStart` throws on input types that have no text selection * (`email`, `number`, and others depending on the browser), which is a * `DOMException` rather than a `null`, so this is guarded rather than * checked: the list of which types support it differs between engines and * hard-coding it here would be a second thing to keep in step. */ const caret = (element) => { try { const { selectionStart, selectionEnd, selectionDirection } = element; if (selectionStart === null || selectionStart === undefined) return null; return { selectionStart, selectionEnd, selectionDirection }; } catch { return null; } }; /** Put a caret back, ignoring a control that has stopped accepting one. */ const restoreCaret = (element, at) => { if (!at) return; try { element.setSelectionRange( at.selectionStart, at.selectionEnd, at.selectionDirection ?? "none", ); } catch { // The replacement is a different kind of control than the one that // was there. Focus is still right; the caret is not applicable. } }; // Recorded on the way out rather than on the swap: by the time a swap // fires, an out-of-band region may already have been replaced and the // element read from `document.activeElement` would be detached, with its // caret gone with it. // // `htmx:before:request` is htmx 4's name; 2.x spelled it // `htmx:beforeRequest`. const remember = () => { const active = document.activeElement; if (!active || active === document.body) { held = null; return; } const selector = address(active); held = selector ? { selector, at: caret(active) } : null; }; // Spent after everything has settled, including out-of-band regions, and // only where focus was actually lost. `htmx:after:settle` is htmx 4's name; // 2.x spelled it `htmx:afterSettle`. const restore = () => { const record = held; held = null; if (!record) return; // htmx put focus somewhere itself, which is the main target's case and // is already right. Leave it alone. const active = document.activeElement; if (active && active !== document.body) return; const found = document.querySelector(record.selector); // No match means the control the reader was in is genuinely gone -- // a row they deleted, a field a reveal took away. Focus stays where // htmx left it rather than being moved somewhere arbitrary. if (!found || typeof found.focus !== "function") return; // `preventScroll`, because the reader has not asked to go anywhere: the // point is that nothing visible changed for them. Restoring focus and // jumping the viewport to it would be a second surprise on top of the // one being fixed. found.focus({ preventScroll: true }); restoreCaret(found, record.at); }; document.addEventListener("htmx:before:request", remember); document.addEventListener("htmx:after:settle", restore); })();