max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
5 files changed,
+237 insertions,
-4 deletions
| @@ -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 | + | })(); |