max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
7 files changed,
+283 insertions,
-50 deletions
| @@ -2875,7 +2875,7 @@ | |||
| 2875 | 2875 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 2876 | 2876 | dependencies = [ | |
| 2877 | 2877 | "libc", | |
| 2878 | - | "windows-sys 0.61.2", | |
| 2878 | + | "windows-sys 0.52.0", | |
| 2879 | 2879 | ] | |
| 2880 | 2880 | ||
| 2881 | 2881 | [[package]] | |
| @@ -3660,7 +3660,7 @@ | |||
| 3660 | 3660 | checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" | |
| 3661 | 3661 | dependencies = [ | |
| 3662 | 3662 | "bstr", | |
| 3663 | - | "hashbrown 0.17.1", | |
| 3663 | + | "hashbrown 0.15.5", | |
| 3664 | 3664 | ] | |
| 3665 | 3665 | ||
| 3666 | 3666 | [[package]] | |
| @@ -4811,7 +4811,7 @@ | |||
| 4811 | 4811 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4812 | 4812 | checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" | |
| 4813 | 4813 | dependencies = [ | |
| 4814 | - | "windows-sys 0.61.2", | |
| 4814 | + | "windows-sys 0.59.0", | |
| 4815 | 4815 | ] | |
| 4816 | 4816 | ||
| 4817 | 4817 | [[package]] | |
| @@ -5722,7 +5722,7 @@ | |||
| 5722 | 5722 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5723 | 5723 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 5724 | 5724 | dependencies = [ | |
| 5725 | - | "windows-sys 0.61.2", | |
| 5725 | + | "windows-sys 0.59.0", | |
| 5726 | 5726 | ] | |
| 5727 | 5727 | ||
| 5728 | 5728 | [[package]] | |
| @@ -6636,7 +6636,7 @@ | |||
| 6636 | 6636 | ||
| 6637 | 6637 | [[package]] | |
| 6638 | 6638 | name = "quasi-axum" | |
| 6639 | - | version = "0.79.0" | |
| 6639 | + | version = "0.80.0" | |
| 6640 | 6640 | dependencies = [ | |
| 6641 | 6641 | "axum", | |
| 6642 | 6642 | "http 1.4.2", | |
| @@ -6647,7 +6647,7 @@ | |||
| 6647 | 6647 | ||
| 6648 | 6648 | [[package]] | |
| 6649 | 6649 | name = "quasi-basics" | |
| 6650 | - | version = "0.79.0" | |
| 6650 | + | version = "0.80.0" | |
| 6651 | 6651 | dependencies = [ | |
| 6652 | 6652 | "makeover-layout", | |
| 6653 | 6653 | "quasi-router", | |
| @@ -6655,7 +6655,7 @@ | |||
| 6655 | 6655 | ||
| 6656 | 6656 | [[package]] | |
| 6657 | 6657 | name = "quasi-http" | |
| 6658 | - | version = "0.79.0" | |
| 6658 | + | version = "0.80.0" | |
| 6659 | 6659 | dependencies = [ | |
| 6660 | 6660 | "form_urlencoded", | |
| 6661 | 6661 | "http 1.4.2", | |
| @@ -6664,14 +6664,14 @@ | |||
| 6664 | 6664 | ||
| 6665 | 6665 | [[package]] | |
| 6666 | 6666 | name = "quasi-router" | |
| 6667 | - | version = "0.79.0" | |
| 6667 | + | version = "0.80.0" | |
| 6668 | 6668 | dependencies = [ | |
| 6669 | 6669 | "makeover-layout", | |
| 6670 | 6670 | ] | |
| 6671 | 6671 | ||
| 6672 | 6672 | [[package]] | |
| 6673 | 6673 | name = "quasi-type" | |
| 6674 | - | version = "0.1.2" | |
| 6674 | + | version = "0.1.3" | |
| 6675 | 6675 | dependencies = [ | |
| 6676 | 6676 | "brotli", | |
| 6677 | 6677 | "font-types", | |
| @@ -6687,7 +6687,7 @@ | |||
| 6687 | 6687 | ||
| 6688 | 6688 | [[package]] | |
| 6689 | 6689 | name = "quasi-webview" | |
| 6690 | - | version = "0.79.0" | |
| 6690 | + | version = "0.80.0" | |
| 6691 | 6691 | dependencies = [ | |
| 6692 | 6692 | "docengine", | |
| 6693 | 6693 | "makeover-layout", | |
| @@ -7339,7 +7339,7 @@ | |||
| 7339 | 7339 | "errno", | |
| 7340 | 7340 | "libc", | |
| 7341 | 7341 | "linux-raw-sys 0.12.1", | |
| 7342 | - | "windows-sys 0.61.2", | |
| 7342 | + | "windows-sys 0.52.0", | |
| 7343 | 7343 | ] | |
| 7344 | 7344 | ||
| 7345 | 7345 | [[package]] | |
| @@ -7419,7 +7419,7 @@ | |||
| 7419 | 7419 | "security-framework 3.7.0", | |
| 7420 | 7420 | "security-framework-sys", | |
| 7421 | 7421 | "webpki-root-certs", | |
| 7422 | - | "windows-sys 0.61.2", | |
| 7422 | + | "windows-sys 0.52.0", | |
| 7423 | 7423 | ] | |
| 7424 | 7424 | ||
| 7425 | 7425 | [[package]] | |
| @@ -8545,7 +8545,7 @@ | |||
| 8545 | 8545 | "getrandom 0.4.3", | |
| 8546 | 8546 | "once_cell", | |
| 8547 | 8547 | "rustix 1.1.4", | |
| 8548 | - | "windows-sys 0.61.2", | |
| 8548 | + | "windows-sys 0.52.0", | |
| 8549 | 8549 | ] | |
| 8550 | 8550 | ||
| 8551 | 8551 | [[package]] | |
| @@ -9971,7 +9971,7 @@ | |||
| 9971 | 9971 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9972 | 9972 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 9973 | 9973 | dependencies = [ | |
| 9974 | - | "windows-sys 0.61.2", | |
| 9974 | + | "windows-sys 0.48.0", | |
| 9975 | 9975 | ] | |
| 9976 | 9976 | ||
| 9977 | 9977 | [[package]] | |
| @@ -10759,13 +10759,21 @@ | |||
| 10759 | 10759 | "pkg-config", | |
| 10760 | 10760 | ] | |
| 10761 | 10761 | ||
| 10762 | + | [[patch.unused]] | |
| 10763 | + | name = "synckit-client" | |
| 10764 | + | version = "0.10.0" | |
| 10765 | + | ||
| 10766 | + | [[patch.unused]] | |
| 10767 | + | name = "synckit-config" | |
| 10768 | + | version = "0.2.0" | |
| 10769 | + | ||
| 10762 | 10770 | [[patch.unused]] | |
| 10763 | 10771 | name = "quasi-immediate" | |
| 10764 | - | version = "0.79.0" | |
| 10772 | + | version = "0.80.0" | |
| 10765 | 10773 | ||
| 10766 | 10774 | [[patch.unused]] | |
| 10767 | 10775 | name = "quasi-notifs" | |
| 10768 | - | version = "0.79.0" | |
| 10776 | + | version = "0.80.0" | |
| 10769 | 10777 | ||
| 10770 | 10778 | [[patch.unused]] | |
| 10771 | 10779 | name = "quasi-store" | |
| @@ -10773,15 +10781,7 @@ | |||
| 10773 | 10781 | ||
| 10774 | 10782 | [[patch.unused]] | |
| 10775 | 10783 | name = "quasi-tauri" | |
| 10776 | - | version = "0.79.0" | |
| 10777 | - | ||
| 10778 | - | [[patch.unused]] | |
| 10779 | - | name = "synckit-client" | |
| 10780 | - | version = "0.10.0" | |
| 10781 | - | ||
| 10782 | - | [[patch.unused]] | |
| 10783 | - | name = "synckit-config" | |
| 10784 | - | version = "0.2.0" | |
| 10784 | + | version = "0.80.0" | |
| 10785 | 10785 | ||
| 10786 | 10786 | [[patch.unused]] | |
| 10787 | 10787 | name = "kberg" |
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" } | |
| 167 | 167 | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 168 | 168 | # rather than reached through quasi-router's re-export because a described | |
| 169 | 169 | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| @@ -179,13 +179,13 @@ | |||
| 179 | 179 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 180 | 180 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 181 | 181 | http = "1.3.1" | |
| 182 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" } | |
| 183 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" } | |
| 182 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" } | |
| 183 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" } | |
| 184 | 184 | # The first-party widget set. The carousel was proved here against three pages | |
| 185 | 185 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 186 | 186 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 187 | 187 | # sits above quasi-router rather than inside the description suite. | |
| 188 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" } | |
| 188 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" } | |
| 189 | 189 | ||
| 190 | 190 | # Git source browser | |
| 191 | 191 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -277,7 +277,7 @@ | |||
| 277 | 277 | # Written from the crate rather than copied into the repo for the reason the | |
| 278 | 278 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 279 | 279 | # bar, and the version has to be the one the emitter agrees with. | |
| 280 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" } | |
| 280 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" } | |
| 281 | 281 | ||
| 282 | 282 | [dev-dependencies] | |
| 283 | 283 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -563,7 +563,7 @@ | |||
| 563 | 563 | /// and unserved -- a 404 per document, with nothing in the log -- and | |
| 564 | 564 | /// `quasi-awaiting.js` would have joined them the moment it shipped. Found | |
| 565 | 565 | /// adding the third, which is the only reason the other two were. | |
| 566 | - | const GENERATED_STATIC_JS: [(&str, &str); 11] = [ | |
| 566 | + | const GENERATED_STATIC_JS: [(&str, &str); 12] = [ | |
| 567 | 567 | ("quasi-selection.js", quasi_webview::SELECTION_JS), | |
| 568 | 568 | ("quasi-clock.js", quasi_webview::CLOCK_JS), | |
| 569 | 569 | ("quasi-download.js", quasi_webview::DOWNLOAD_JS), | |
| @@ -573,6 +573,7 @@ | |||
| 573 | 573 | ("quasi-repeat.js", quasi_webview::REPEAT_JS), | |
| 574 | 574 | ("quasi-awaiting.js", quasi_webview::AWAITING_JS), | |
| 575 | 575 | ("quasi-instant.js", quasi_webview::INSTANT_JS), | |
| 576 | + | ("quasi-focus.js", quasi_webview::FOCUS_JS), | |
| 576 | 577 | ("quasi-menu.js", quasi_webview::MENU_JS), | |
| 577 | 578 | ("quasi-outline.js", quasi_webview::OUTLINE_JS), | |
| 578 | 579 | ]; |
| @@ -364,9 +364,14 @@ | |||
| 364 | 364 | // What `pricing.html` said as `class="centered-page"`. The renderer | |
| 365 | 365 | // turns it back into that class; see `renderer`. | |
| 366 | 366 | .measured(MEASURE) | |
| 367 | + | // The whole of what `base.html` put in `<meta name="description">`, | |
| 368 | + | // including the fee sentence, because this one string is now all three | |
| 369 | + | // tags: the social pair and the plain one. It carried only the first | |
| 370 | + | // sentence while the plain tag was appended separately. | |
| 367 | 371 | .summarised( | |
| 368 | 372 | "Work out what you keep on every sale here, against whatever the \ | |
| 369 | - | platform you sell on now deducts.", | |
| 373 | + | platform you sell on now deducts. 0% platform fee, only the \ | |
| 374 | + | payment processor's ~3%.", | |
| 370 | 375 | ) | |
| 371 | 376 | .with( | |
| 372 | 377 | Slot::new(PAGE, RegionKind::Pane) | |
| @@ -660,17 +665,13 @@ | |||
| 660 | 665 | let mut shell = crate::shell::described() | |
| 661 | 666 | .with_body_first(crate::shell::skip_link(PAGE)) | |
| 662 | 667 | .with_body_last(crate::shell::body_last()); | |
| 663 | - | // Appended rather than set: `described` already put the favicon, the | |
| 664 | - | // history-cache extension and the core module in the head, and `with_head` | |
| 665 | - | // replaces. A description meta tag is the one thing `base.html` emitted for | |
| 666 | - | // this page that the description layer has no word for -- | |
| 667 | - | // `Screen::summarised` reaches `og:description` and stops there. | |
| 668 | - | shell.head = Some(format!( | |
| 669 | - | "{}<meta name=\"description\" content=\"Work out what you keep on every \ | |
| 670 | - | sale here, against whatever the platform you sell on now deducts. \ | |
| 671 | - | 0% platform fee, only the payment processor's ~3%.\">", | |
| 672 | - | shell.head.take().unwrap_or_default() | |
| 673 | - | )); | |
| 668 | + | // The `Shell::head` append that stood here is gone as of quasi 0.80 | |
| 669 | + | // (quasicoherent `a0e16839`). It wrote the plain `<meta name="description">` | |
| 670 | + | // by hand because `Screen::summarised` reached `og:description` and | |
| 671 | + | // `twitter:description` and stopped; it reaches all three now, off the one | |
| 672 | + | // string the screen already declares. An escape hatch spending itself on | |
| 673 | + | // something a described property carries is the escape hatch going unused. | |
| 674 | + | // | |
| 674 | 675 | // What `pricing.html` wrote as `class="centered-page"`, read off the same | |
| 675 | 676 | // measure the screen declares rather than off a route table. | |
| 676 | 677 | shell.body_class = Some(crate::shell::measure(MEASURE).to_owned()); |
| @@ -555,13 +555,8 @@ | |||
| 555 | 555 | // The same holds for any other method mismatch. | |
| 556 | 556 | let resp = h.client.post_form("/login", "").await; | |
| 557 | 557 | assert_ne!(resp.status, 405, "/login accepts POST"); | |
| 558 | - | // `/creators` rather than `/pricing`, which used to stand here. `/pricing` | |
| 559 | - | // is a described screen since `1e35bc8a` and the description layer routes | |
| 560 | - | // itself: a method it does not hold on a path it does answers 404 with no | |
| 561 | - | // page, not 405 with the error template. That is quasi's routing rather | |
| 562 | - | // than this server's, and it is filed as quasicoherent `370c0849`; what this test is | |
| 563 | - | // about is what axum's own method fallback renders, so it asks a route axum | |
| 564 | - | // still owns. | |
| 558 | + | // `/creators` is a route axum still owns, so this is what axum's own method | |
| 559 | + | // fallback renders and is what this test is about. | |
| 565 | 560 | let resp = h.client.delete("/creators").await; | |
| 566 | 561 | assert_eq!(resp.status, 405, "DELETE /creators should be 405"); | |
| 567 | 562 | assert!( | |
| @@ -569,6 +564,23 @@ | |||
| 569 | 564 | "405 should render the error template, got: {}", | |
| 570 | 565 | resp.text | |
| 571 | 566 | ); | |
| 567 | + | ||
| 568 | + | // And `/pricing` again, which stood here until `1e35bc8a` made it a | |
| 569 | + | // described screen and the description layer answered 404: the path existed | |
| 570 | + | // and only the verb did not, which is what quasicoherent `370c0849` split | |
| 571 | + | // apart. It answers 405 with `Allow` since quasi 0.80. | |
| 572 | + | // | |
| 573 | + | // No error template here, and that is the difference worth keeping in one | |
| 574 | + | // test rather than two: the described screen refuses inside quasi and never | |
| 575 | + | // reaches axum's fallback, so what comes back is the description layer's | |
| 576 | + | // own notice. Both are 405; only one is this server's page. | |
| 577 | + | let resp = h.client.delete("/pricing").await; | |
| 578 | + | assert_eq!(resp.status, 405, "DELETE /pricing should be 405"); | |
| 579 | + | assert_eq!( | |
| 580 | + | resp.headers.get("allow").and_then(|v| v.to_str().ok()), | |
| 581 | + | Some("GET"), | |
| 582 | + | "and it names the verb the address does take" | |
| 583 | + | ); | |
| 572 | 584 | } | |
| 573 | 585 | ||
| 574 | 586 | /// A 401 page offers the actions that resolve it, not only "Go Home". |
| @@ -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 | + | })(); |
| @@ -1,0 +1,88 @@ | |||
| 1 | + | // Folding a branch of an outline, which is the reader tidying their own view. | |
| 2 | + | // | |
| 3 | + | // `Row::depth` and `Row::open` describe a hierarchy as a flat list of rows, | |
| 4 | + | // each saying how deep it sits. Which rows a shut branch hides falls out of | |
| 5 | + | // that reading, and the renderer already did it once: every row is in the | |
| 6 | + | // document and the ones under a shut branch arrived `hidden`. What is left is | |
| 7 | + | // the gesture, and the gesture is a browser's. | |
| 8 | + | // | |
| 9 | + | // Nothing here asks the app anything. Folding changes what the reader is | |
| 10 | + | // looking at and nothing anyone else can observe -- see `Row::open` for why | |
| 11 | + | // that is not a write -- so a round trip would be a request for rows the page | |
| 12 | + | // is already holding. | |
| 13 | + | // | |
| 14 | + | // It reads two hooks the renderer emits and nothing else: `[data-disclose]` on | |
| 15 | + | // a branch's chevron, and `data-depth` on the rows. A page that never runs this | |
| 16 | + | // keeps the outline in the shape the description gave it, with every row still | |
| 17 | + | // reachable -- the shut branches simply stay shut. | |
| 18 | + | (() => { | |
| 19 | + | "use strict"; | |
| 20 | + | ||
| 21 | + | const DISCLOSE = "[data-disclose]"; | |
| 22 | + | const ROW = "[data-row]"; | |
| 23 | + | ||
| 24 | + | /** How deep a row sits. Absent means top level, which is what it means. */ | |
| 25 | + | const depth = (row) => Number(row.getAttribute("data-depth") ?? 0); | |
| 26 | + | ||
| 27 | + | /** The row a chevron belongs to. */ | |
| 28 | + | const rowOf = (el) => el.closest(ROW); | |
| 29 | + | ||
| 30 | + | /** | |
| 31 | + | * The rows under `row`: the ones after it that are deeper, up to the first | |
| 32 | + | * one no deeper than it. | |
| 33 | + | * | |
| 34 | + | * The same reading `quasi_router::folded` makes on the other side, and it | |
| 35 | + | * has to be: a reader who folds a branch here and reloads the page must see | |
| 36 | + | * the list the server would have folded. | |
| 37 | + | */ | |
| 38 | + | const under = (row) => { | |
| 39 | + | const rows = [...row.parentElement.querySelectorAll(`:scope > ${ROW}`)]; | |
| 40 | + | const start = rows.indexOf(row); | |
| 41 | + | const level = depth(row); | |
| 42 | + | const kin = []; | |
| 43 | + | for (const next of rows.slice(start + 1)) { | |
| 44 | + | if (depth(next) <= level) break; | |
| 45 | + | kin.push(next); | |
| 46 | + | } | |
| 47 | + | return kin; | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * Draw the outline under `row` as its chevrons say it should be. | |
| 52 | + | * | |
| 53 | + | * Walked rather than toggled row by row: a branch inside a shut branch | |
| 54 | + | * keeps its own state and stays shut when the outer one opens, which is | |
| 55 | + | * what a reader who left it shut expects to come back to. `folded` is the | |
| 56 | + | * same one-variable walk the Rust side makes. | |
| 57 | + | */ | |
| 58 | + | const settle = (row) => { | |
| 59 | + | let shut = null; | |
| 60 | + | for (const next of under(row)) { | |
| 61 | + | const level = depth(next); | |
| 62 | + | if (shut !== null && level <= shut) shut = null; | |
| 63 | + | const hidden = shut !== null; | |
| 64 | + | next.hidden = hidden; | |
| 65 | + | const chevron = next.querySelector(DISCLOSE); | |
| 66 | + | if (!hidden && chevron?.getAttribute("aria-expanded") === "false") { | |
| 67 | + | shut = level; | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | }; | |
| 71 | + | ||
| 72 | + | document.addEventListener("click", (event) => { | |
| 73 | + | const chevron = event.target.closest(DISCLOSE); | |
| 74 | + | if (!chevron) return; | |
| 75 | + | const row = rowOf(chevron); | |
| 76 | + | if (!row) return; | |
| 77 | + | // Never the row's own act. A chevron is a separate hit target from the | |
| 78 | + | // label precisely so that pressing it is not pressing the row, and a | |
| 79 | + | // row that activates on click would otherwise navigate underneath the | |
| 80 | + | // fold. | |
| 81 | + | event.preventDefault(); | |
| 82 | + | event.stopPropagation(); | |
| 83 | + | const open = chevron.getAttribute("aria-expanded") === "true"; | |
| 84 | + | chevron.setAttribute("aria-expanded", String(!open)); | |
| 85 | + | chevron.setAttribute("aria-label", open ? "Expand" : "Collapse"); | |
| 86 | + | settle(row); | |
| 87 | + | }); | |
| 88 | + | })(); |