Skip to main content

max / quasi

Keep the reader's place across an out-of-band swap ad9c5615. htmx saves and restores focus only around the main swap target. A focused control inside an out-of-band region is destroyed outside that window, so focus falls to body: the caret goes with it and the next Tab starts from the top of the document. This renderer emits out-of-band swaps, so it owns the gap. The script records what was focused when a request goes out and puts it back after everything settles, only where focus was actually lost. htmx restores the main target itself and a second party moving focus after it would be two scripts fighting over one frame. An element is found again by id, or by name where it has no id -- both addresses the description chose. Anything with neither is left alone, and so is a control that is genuinely gone: guessing at a neighbour by position lands focus on the wrong thing, which is worse than losing it. The failure is silent to anyone not navigating by keyboard, which is why it is on by default and why it went unnoticed until it was looked for.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 17:54 UTC
Signed with PGP, not checked
Commit: a469bdcd01d5fe8cfc478513ec8b6c173d0885fa
Parent: 91c21f1
5 files changed, +237 insertions, -4 deletions
M Cargo.lock +4 -4
@@ -6211,10 +6211,6 @@
6211 6211 "winnow 1.0.4",
6212 6212 ]
6213 6213
6214 - [[patch.unused]]
6215 - name = "synckit-client"
6216 - version = "0.10.0"
6217 -
6218 6214 [[patch.unused]]
6219 6215 name = "kberg"
6220 6216 version = "0.1.0"
@@ -6231,6 +6227,10 @@
6231 6227 name = "tagtree"
6232 6228 version = "0.4.1"
6233 6229
6230 + [[patch.unused]]
6231 + name = "synckit-client"
6232 + version = "0.10.0"
6233 +
6234 6234 [[patch.unused]]
6235 6235 name = "quasi-type"
6236 6236 version = "0.1.3"
@@ -326,6 +326,44 @@
326 326 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
327 327 pub const INSTANT_JS: &str = include_str!("instant.js");
328 328
329 + /// The focus script, to serve beside the renderer's other assets.
330 + ///
331 + /// `ad9c5615`. htmx saves and restores focus only around the *main* swap
332 + /// target. A focused control inside an out-of-band region is destroyed outside
333 + /// that window, so focus falls to `body`, the caret goes with it, and the next
334 + /// Tab starts from the top of the document. This renderer emits out-of-band
335 + /// swaps -- see [`Serves::with_oob`] -- so it owns the gap.
336 + ///
337 + /// It records what was focused when a request went out and puts it back after
338 + /// everything settles, **only where focus was actually lost**. htmx restores
339 + /// the main target itself, correctly, and a second party moving focus after it
340 + /// would be two scripts fighting over one frame.
341 + ///
342 + /// An element is found again by `id`, or by `name` where it has no `id`. Both
343 + /// are addresses the description chose. Anything with neither is left alone,
344 + /// and so is a control that is genuinely gone: guessing at a neighbour by
345 + /// position lands focus on the wrong thing, which is worse than losing it.
346 + ///
347 + /// Not independent of htmx: it reads the request lifecycle, so
348 + /// [`Shell::without_htmx`] drops it. Nothing is lost there, since without htmx
349 + /// every response is a navigation and a navigation has no focus to preserve.
350 + ///
351 + /// `None` on [`Shell::focus_src`] drops it, and the loss falls entirely on
352 + /// readers navigating by keyboard. It is silent to everyone else, which is both
353 + /// why it is on by default and why it went unnoticed until it was looked for.
354 + ///
355 + /// ```no_run
356 + /// # fn main() -> std::io::Result<()> {
357 + /// std::fs::write("static/quasi-focus.js", quasi_webview::FOCUS_JS)?;
358 + /// # Ok(())
359 + /// # }
360 + /// ```
361 + ///
362 + /// [`Serves::with_oob`]: quasi_http::Serves::with_oob
363 + /// [`Shell::without_htmx`]: crate::Shell::without_htmx
364 + /// [`Shell::focus_src`]: crate::Shell::focus_src
365 + pub const FOCUS_JS: &str = include_str!("focus.js");
366 +
329 367 /// This crate's menu script, for a host to serve beside its other assets.
330 368 ///
331 369 /// [`makeover_layout::Fallback::Menu`] says the members a
@@ -233,6 +233,21 @@
233 233 /// reason: what it listens to is the request lifecycle, so
234 234 /// [`without_htmx`](Self::without_htmx) drops it with the rest.
235 235 pub instant_src: Option<String>,
236 + /// Where the focus script is served from, or `None` to leave it out.
237 + ///
238 + /// `ad9c5615`. htmx restores focus around the *main* swap target and only
239 + /// that one, so a focused control inside an out-of-band region is destroyed
240 + /// outside that window and focus falls to `body`. This renderer emits
241 + /// out-of-band swaps, so it owns the gap.
242 + ///
243 + /// **Not** independent of htmx, for [`awaiting_src`](Self::awaiting_src)'s
244 + /// reason: it reads the request lifecycle.
245 + ///
246 + /// `None` drops it, and the loss falls entirely on readers navigating by
247 + /// keyboard: after a response that updates a second region, the caret is
248 + /// gone and the next Tab starts from the top of the document. Silent to
249 + /// everyone else, which is the reason it is on by default.
250 + pub focus_src: Option<String>,
236 251 /// Where this crate's menu script is served from.
237 252 ///
238 253 /// [`MENU_JS`](crate::MENU_JS) is its source.
@@ -390,6 +405,7 @@
390 405 repeat_src: Some("/static/quasi-repeat.js".into()),
391 406 awaiting_src: Some("/static/quasi-awaiting.js".into()),
392 407 instant_src: Some("/static/quasi-instant.js".into()),
408 + focus_src: Some("/static/quasi-focus.js".into()),
393 409 menu_src: Some("/static/quasi-menu.js".into()),
394 410 outline_src: Some("/static/quasi-outline.js".into()),
395 411 stylesheets: Vec::new(),
@@ -425,6 +441,7 @@
425 441 repeat_src: Some(format!("{prefix}/quasi-repeat.js")),
426 442 awaiting_src: Some(format!("{prefix}/quasi-awaiting.js")),
427 443 instant_src: Some(format!("{prefix}/quasi-instant.js")),
444 + focus_src: Some(format!("{prefix}/quasi-focus.js")),
428 445 menu_src: Some(format!("{prefix}/quasi-menu.js")),
429 446 outline_src: Some(format!("{prefix}/quasi-outline.js")),
430 447 ..Self::default()
@@ -538,6 +555,9 @@
538 555 // wall-clock string, so a host dropping htmx and keeping a described
539 556 // datetime field converts on the route instead.
540 557 self.instant_src = None;
558 + // Nothing to listen to, and nothing to fix: without htmx every
559 + // response is a navigation, and a navigation has no focus to preserve.
560 + self.focus_src = None;
541 561 self
542 562 }
543 563
@@ -890,6 +910,16 @@
890 910 out.push_str("\" defer></script>");
891 911 }
892 912
913 + // Deferred like the rest, delegated on the document like the awaiting
914 + // script, and not independent of htmx for the same reason: it reads the
915 + // request lifecycle. Nothing about it is per-region, so one listener
916 + // pair covers every out-of-band swap the renderer will ever emit.
917 + if let Some(src) = &self.focus_src {
918 + out.push_str("<script src=\"");
919 + escape_into(src, out);
920 + out.push_str("\" defer></script>");
921 + }
922 +
893 923 // Deferred like the rest, delegated on the body like the awaiting
894 924 // script, and not independent of htmx for the same reason: it rewrites
895 925 // the request htmx is about to make.
@@ -6243,6 +6243,40 @@
6243 6243 assert!(html.contains(r#"name="title""#), "{html}");
6244 6244 }
6245 6245
6246 + /// Linked by default, and it goes when htmx does: without htmx every response
6247 + /// is a navigation, and a navigation has no focus to preserve.
6248 + ///
6249 + /// `ad9c5615`. The failure it fixes is silent to anyone not navigating by
6250 + /// keyboard, which is why the default matters more here than elsewhere: nobody
6251 + /// would report the script being absent.
6252 + #[test]
6253 + fn the_focus_script_is_linked_by_default_and_goes_with_htmx() {
6254 + let with = Webview::new().screen(&Screen::list_detail("A", false));
6255 + assert!(with.contains("quasi-focus.js"), "{with}");
6256 +
6257 + let without = Webview::new()
6258 + .with_shell(Shell::default().without_htmx())
6259 + .screen(&Screen::list_detail("A", false));
6260 + assert!(!without.contains("quasi-focus.js"), "{without}");
6261 + }
6262 +
6263 + /// Every script this renderer ships has to be served by whoever embeds it, so
6264 + /// the constant and the default path have to agree. They are written in two
6265 + /// files and nothing else ties them together.
6266 + #[test]
6267 + fn the_focus_script_is_served_under_the_name_the_shell_asks_for() {
6268 + let shell = Shell::default();
6269 + assert_eq!(shell.focus_src.as_deref(), Some("/static/quasi-focus.js"));
6270 + assert!(
6271 + crate::FOCUS_JS.contains("htmx:after:settle"),
6272 + "the settle hook"
6273 + );
6274 + assert!(
6275 + crate::FOCUS_JS.contains("htmx:before:request"),
6276 + "and the record taken before it"
6277 + );
6278 + }
6279 +
6246 6280 /// Linked by default, and it stays when htmx goes: folding a branch is the
6247 6281 /// reader tidying their own view, and the rows it hides and shows are already
6248 6282 /// in the page.
@@ -1,0 +1,131 @@
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 + })();