max / makenotwork
8 files changed,
+131 insertions,
-13 deletions
| @@ -356,6 +356,7 @@ async fn build_sidebar( | |||
| 356 | 356 | has_source: has_source_code, | |
| 357 | 357 | active_filter_count, | |
| 358 | 358 | viewer_authenticated: viewer_id.is_some(), | |
| 359 | + | browse_cursor: query.browse.clone().unwrap_or_default(), | |
| 359 | 360 | }) | |
| 360 | 361 | } | |
| 361 | 362 |
| @@ -151,4 +151,7 @@ pub struct SidebarView { | |||
| 151 | 151 | pub active_filter_count: u32, | |
| 152 | 152 | /// Follow controls are only meaningful for a signed-in viewer. | |
| 153 | 153 | pub viewer_authenticated: bool, | |
| 154 | + | /// The drill cursor, carried as a hidden input so it survives a filter | |
| 155 | + | /// interaction rather than snapping back to the tag roots. | |
| 156 | + | pub browse_cursor: String, | |
| 154 | 157 | } |
| @@ -1,3 +1,26 @@ | |||
| 1 | + | // Restore focus across the out-of-band sidebar swap. | |
| 2 | + | // | |
| 3 | + | // htmx does restore focus after a swap, but only for the element it saved | |
| 4 | + | // around the MAIN swap target. The sidebar is replaced out-of-band, so a | |
| 5 | + | // focused filter control is destroyed outside that window and focus falls | |
| 6 | + | // to <body> — which drops a keyboard user out of the sidebar on every | |
| 7 | + | // single filter change. Verified in the browser, not assumed. | |
| 8 | + | (function () { | |
| 9 | + | var pending = null; | |
| 10 | + | document.body.addEventListener('htmx:beforeRequest', function () { | |
| 11 | + | var el = document.activeElement; | |
| 12 | + | pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null; | |
| 13 | + | }); | |
| 14 | + | document.body.addEventListener('htmx:afterSettle', function () { | |
| 15 | + | if (!pending) return; | |
| 16 | + | var el = document.getElementById(pending); | |
| 17 | + | pending = null; | |
| 18 | + | // Only if it actually went away and came back; a surviving element | |
| 19 | + | // kept its focus already. | |
| 20 | + | if (el && el !== document.activeElement) el.focus({ preventScroll: true }); | |
| 21 | + | }); | |
| 22 | + | })(); | |
| 23 | + | ||
| 1 | 24 | // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is | |
| 2 | 25 | // reachable by name from any depth without knowing where it sits. | |
| 3 | 26 | (function() { |
| @@ -7519,8 +7519,10 @@ textarea:focus-visible { | |||
| 7519 | 7519 | ||
| 7520 | 7520 | /* A zero-count facet stays visible but inert, so the shape of the catalog | |
| 7521 | 7521 | stays legible as filters narrow. No existing rule covers a disabled input | |
| 7522 | - | inside a label, so this is the checkbox counterpart of the button rule. */ | |
| 7523 | - | .filter-item:has(input:disabled) { opacity: 0.5; } | |
| 7522 | + | inside a label, so this is the checkbox counterpart of the button rule. | |
| 7523 | + | `.is-empty` covers the drill rungs, which are links and carry no input at | |
| 7524 | + | all — they were rendering identically to rungs with items. */ | |
| 7525 | + | .filter-item:has(input:disabled), .filter-item.is-empty { opacity: 0.5; } | |
| 7524 | 7526 | .filter-item:has(input:disabled) .filter-check { cursor: not-allowed; } | |
| 7525 | 7527 | ||
| 7526 | 7528 | /* Price buckets are links carrying a range, not form state. */ |
| @@ -83,16 +83,6 @@ | |||
| 83 | 83 | <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option> | |
| 84 | 84 | {% endif %} | |
| 85 | 85 | </select> | |
| 86 | - | {# Only selections with no visible control on screen. The drill-down | |
| 87 | - | shows one rung at a time, so a tag chosen elsewhere in the tree | |
| 88 | - | has no checkbox to carry it. Anything that DOES have a checked | |
| 89 | - | checkbox is deliberately absent here, or it would submit twice. #} | |
| 90 | - | {% for value in sidebar.hidden_types %} | |
| 91 | - | <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type"> | |
| 92 | - | {% endfor %} | |
| 93 | - | {% for value in sidebar.hidden_tags %} | |
| 94 | - | <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag"> | |
| 95 | - | {% endfor %} | |
| 96 | 86 | </div> | |
| 97 | 87 | </form> | |
| 98 | 88 |
| @@ -11,6 +11,12 @@ | |||
| 11 | 11 | Guarded on `oob_sidebar`: the full page includes this same partial inside | |
| 12 | 12 | #results-container, and rendering the sidebar there too would emit it twice | |
| 13 | 13 | with duplicate ids. Only the standalone /discover/results response sets it. #} | |
| 14 | + | {# #total-count sits in the page form, outside #results-container, so a plain | |
| 15 | + | swap leaves it reading the previous filter's total. It said "13 items" beside | |
| 16 | + | a list showing 3. #} | |
| 17 | + | {% if oob_sidebar %} | |
| 18 | + | <span class="table-meta" id="total-count" hx-swap-oob="true">{{ total_items }} {% if mode == "projects" %}projects{% else %}items{% endif %}</span> | |
| 19 | + | {% endif %} | |
| 14 | 20 | {% if oob_sidebar && mode == "items" %} | |
| 15 | 21 | <div hx-swap-oob="outerHTML:#discover-sidebar"> | |
| 16 | 22 | {% include "partials/discover_sidebar.html" %} |
| @@ -2,6 +2,25 @@ | |||
| 2 | 2 | swap, by partials/discover_results.html. Both templates expose the same | |
| 3 | 3 | `sidebar` field so this include works unchanged in either context. #} | |
| 4 | 4 | <aside class="discover-sidebar" id="discover-sidebar" aria-label="Filters"> | |
| 5 | + | {# State carriers live INSIDE the sidebar so the out-of-band swap keeps | |
| 6 | + | them in step with the controls. They used to sit in the page form, | |
| 7 | + | which the swap never replaces: a tag selected from one drill rung was | |
| 8 | + | silently dropped as soon as the drill navigated away from its | |
| 9 | + | checkbox, because nothing carried it any more. | |
| 10 | + | ||
| 11 | + | Only selections with no visible control are listed; anything with a | |
| 12 | + | checked checkbox on screen is absent, or it would submit twice. #} | |
| 13 | + | {% for value in sidebar.hidden_types %} | |
| 14 | + | <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type"> | |
| 15 | + | {% endfor %} | |
| 16 | + | {% for value in sidebar.hidden_tags %} | |
| 17 | + | <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag"> | |
| 18 | + | {% endfor %} | |
| 19 | + | {# The drill cursor. Without this every filter interaction rebuilt the | |
| 20 | + | request from .discover-filter alone, which carries no cursor, so the | |
| 21 | + | sidebar snapped back to the tag roots on every click. #} | |
| 22 | + | <input type="hidden" name="browse" value="{{ sidebar.browse_cursor }}" class="discover-filter"> | |
| 23 | + | ||
| 5 | 24 | {% if mode == "projects" %} | |
| 6 | 25 | <div class="filter-section"> | |
| 7 | 26 | <fieldset class="filter-fieldset"> | |
| @@ -100,7 +119,7 @@ | |||
| 100 | 119 | {% endif %} | |
| 101 | 120 | <ul class="filter-list"> | |
| 102 | 121 | {% for row in sidebar.tag_drill %} | |
| 103 | - | <li class="filter-item{% if row.selected %} is-selected{% endif %}"> | |
| 122 | + | <li class="filter-item{% if row.selected %} is-selected{% endif %}{% if row.count == 0 %} is-empty{% endif %}"> | |
| 104 | 123 | {% if row.assignable %} | |
| 105 | 124 | {# A real checkbox: multi-select is the point, and it | |
| 106 | 125 | is keyboard-operable without any hx-trigger work. #} |
| @@ -519,6 +519,80 @@ async fn following_a_tag_is_reflected_in_the_sidebar() { | |||
| 519 | 519 | } | |
| 520 | 520 | ||
| 521 | 521 | // --------------------------------------------------------------------------- | |
| 522 | + | // State survives the out-of-band swap | |
| 523 | + | // | |
| 524 | + | // All three were found by driving the page in a browser, not by the suite. The | |
| 525 | + | // sidebar is replaced wholesale on every filter change, so anything carrying | |
| 526 | + | // state has to live inside it; anything left outside silently stops tracking. | |
| 527 | + | // --------------------------------------------------------------------------- | |
| 528 | + | ||
| 529 | + | /// The state carriers live inside the swapped element. | |
| 530 | + | /// | |
| 531 | + | /// They used to sit in the page form, which the out-of-band swap never | |
| 532 | + | /// replaces. A tag selected from one drill rung was dropped as soon as the | |
| 533 | + | /// drill navigated away from its checkbox, because the hidden input that should | |
| 534 | + | /// have carried it was never re-rendered. | |
| 535 | + | #[tokio::test] | |
| 536 | + | async fn state_carriers_live_inside_the_swapped_sidebar() { | |
| 537 | + | let mut h = TestHarness::new().await; | |
| 538 | + | let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await; | |
| 539 | + | tag_item(&h, &item, "audio.genre.electronic").await; | |
| 540 | + | ||
| 541 | + | // Browsing elsewhere while a tag is selected: the tag has no checkbox on | |
| 542 | + | // screen, so a hidden input must carry it, and it must be in the sidebar. | |
| 543 | + | let resp = h | |
| 544 | + | .client | |
| 545 | + | .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing") | |
| 546 | + | .await; | |
| 547 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 548 | + | ||
| 549 | + | let sidebar_start = resp.text.find(r#"id="discover-sidebar""#).expect("sidebar renders"); | |
| 550 | + | let sidebar_end = resp.text[sidebar_start..].find("</aside>").expect("sidebar closes") + sidebar_start; | |
| 551 | + | let sidebar = &resp.text[sidebar_start..sidebar_end]; | |
| 552 | + | ||
| 553 | + | assert!( | |
| 554 | + | sidebar.contains(r#"name="tag" value="audio.genre.electronic""#), | |
| 555 | + | "the off-screen tag must be carried by a hidden input inside the sidebar" | |
| 556 | + | ); | |
| 557 | + | assert!( | |
| 558 | + | sidebar.contains(r#"name="browse""#), | |
| 559 | + | "the drill cursor must be carried inside the sidebar too" | |
| 560 | + | ); | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | /// The drill cursor survives a filter interaction. | |
| 564 | + | /// | |
| 565 | + | /// Without a `browse` input in the `.discover-filter` set, every filter click | |
| 566 | + | /// rebuilt the request without a cursor and the sidebar snapped back to the tag | |
| 567 | + | /// roots, making the drill-down unusable for picking more than one tag. | |
| 568 | + | #[tokio::test] | |
| 569 | + | async fn drill_cursor_is_carried_as_a_filter_input() { | |
| 570 | + | let mut h = TestHarness::new().await; | |
| 571 | + | let resp = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 572 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 573 | + | assert!( | |
| 574 | + | resp.text.contains(r#"name="browse" value="audio.genre""#), | |
| 575 | + | "the cursor must round-trip as a filter input, not only as a URL param" | |
| 576 | + | ); | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | /// The results partial refreshes the total count out of band. | |
| 580 | + | /// | |
| 581 | + | /// `#total-count` sits in the page form, outside `#results-container`, so a | |
| 582 | + | /// plain swap left it reading the previous filter's total: the header said | |
| 583 | + | /// "13 items" beside a list showing 3. | |
| 584 | + | #[tokio::test] | |
| 585 | + | async fn results_partial_refreshes_the_total_count() { | |
| 586 | + | let mut h = TestHarness::new().await; | |
| 587 | + | let resp = h.client.get("/discover/results?mode=items").await; | |
| 588 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 589 | + | assert!( | |
| 590 | + | resp.text.contains(r#"id="total-count" hx-swap-oob="true""#), | |
| 591 | + | "the total count must be swapped out of band or it goes stale" | |
| 592 | + | ); | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // --------------------------------------------------------------------------- | |
| 522 | 596 | // Markup/stylesheet contract. | |
| 523 | 597 | // --------------------------------------------------------------------------- | |
| 524 | 598 |