Skip to main content

max / quasi

5.7 KB · 132 lines History Blame Raw
1 // Keeping the reader's place in the tab order across a swap htmx does not
2 // restore focus for.
3 //
4 // htmx saves and restores focus around the *main* swap target, and only that
5 // one. A focused control inside an out-of-band region is destroyed outside that
6 // window, so focus falls to `body`: the caret vanishes, and the next Tab starts
7 // from the top of the document rather than from where the reader was. This
8 // renderer emits out-of-band swaps -- `node::oob_html`, which is how a response
9 // updates a second region it did not target -- so it owns the gap.
10 //
11 // The failure is silent to anyone not navigating by keyboard, which is the
12 // reason to fix it rather than leave it: nobody will report it.
13 //
14 // # Why it triggers on focus actually being lost
15 //
16 // The obvious shape is "record what was focused inside a region about to be
17 // replaced, put it back afterwards". This does that, with one narrowing: it
18 // only restores when focus has genuinely fallen to `body` or out of the
19 // document. htmx already restores focus for the main target, correctly and
20 // with its own record of the caret, and a second party putting focus somewhere
21 // after it would be two scripts fighting over the same frame. So the record is
22 // taken from anything focused, and spent only where htmx left nothing.
23 (() => {
24 "use strict";
25
26 // What was focused when the request went out, and where its caret was.
27 // One slot: a document makes one focused element, and a second request
28 // starting before the first settles is the reader having moved on.
29 let held = null;
30
31 /**
32 * How to find this element again in markup that has replaced it.
33 *
34 * An `id` is the description's own address for the thing and survives a
35 * re-render, so it is preferred. A `name` is the next best: a form control
36 * that carries one is being identified by the description too, since that
37 * is the key the route reads it under. Anything with neither is not
38 * addressable and is left alone -- guessing at a neighbour by position is
39 * how focus lands on the wrong control, which is worse than losing it.
40 */
41 const address = (element) => {
42 if (element.id) return `#${CSS.escape(element.id)}`;
43 const name = element.getAttribute?.("name");
44 if (name) {
45 const tag = element.tagName.toLowerCase();
46 return `${tag}[name="${CSS.escape(name)}"]`;
47 }
48 return null;
49 };
50
51 /**
52 * The caret, for a control that has one.
53 *
54 * `selectionStart` throws on input types that have no text selection
55 * (`email`, `number`, and others depending on the browser), which is a
56 * `DOMException` rather than a `null`, so this is guarded rather than
57 * checked: the list of which types support it differs between engines and
58 * hard-coding it here would be a second thing to keep in step.
59 */
60 const caret = (element) => {
61 try {
62 const { selectionStart, selectionEnd, selectionDirection } = element;
63 if (selectionStart === null || selectionStart === undefined) return null;
64 return { selectionStart, selectionEnd, selectionDirection };
65 } catch {
66 return null;
67 }
68 };
69
70 /** Put a caret back, ignoring a control that has stopped accepting one. */
71 const restoreCaret = (element, at) => {
72 if (!at) return;
73 try {
74 element.setSelectionRange(
75 at.selectionStart,
76 at.selectionEnd,
77 at.selectionDirection ?? "none",
78 );
79 } catch {
80 // The replacement is a different kind of control than the one that
81 // was there. Focus is still right; the caret is not applicable.
82 }
83 };
84
85 // Recorded on the way out rather than on the swap: by the time a swap
86 // fires, an out-of-band region may already have been replaced and the
87 // element read from `document.activeElement` would be detached, with its
88 // caret gone with it.
89 //
90 // `htmx:before:request` is htmx 4's name; 2.x spelled it
91 // `htmx:beforeRequest`.
92 const remember = () => {
93 const active = document.activeElement;
94 if (!active || active === document.body) {
95 held = null;
96 return;
97 }
98 const selector = address(active);
99 held = selector ? { selector, at: caret(active) } : null;
100 };
101
102 // Spent after everything has settled, including out-of-band regions, and
103 // only where focus was actually lost. `htmx:after:settle` is htmx 4's name;
104 // 2.x spelled it `htmx:afterSettle`.
105 const restore = () => {
106 const record = held;
107 held = null;
108 if (!record) return;
109
110 // htmx put focus somewhere itself, which is the main target's case and
111 // is already right. Leave it alone.
112 const active = document.activeElement;
113 if (active && active !== document.body) return;
114
115 const found = document.querySelector(record.selector);
116 // No match means the control the reader was in is genuinely gone --
117 // a row they deleted, a field a reveal took away. Focus stays where
118 // htmx left it rather than being moved somewhere arbitrary.
119 if (!found || typeof found.focus !== "function") return;
120
121 // `preventScroll`, because the reader has not asked to go anywhere: the
122 // point is that nothing visible changed for them. Restoring focus and
123 // jumping the viewport to it would be a second surprise on top of the
124 // one being fixed.
125 found.focus({ preventScroll: true });
126 restoreCaret(found, record.at);
127 };
128
129 document.addEventListener("htmx:before:request", remember);
130 document.addEventListener("htmx:after:settle", restore);
131 })();
132