max / makenotwork
5 files changed,
+288 insertions,
-67 deletions
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Discover/search page with filterable, paginated items and projects. | |
| 2 | 2 | ||
| 3 | 3 | use axum::extract::{Query, State}; | |
| 4 | + | use axum::http::HeaderMap; | |
| 4 | 5 | use axum::response::IntoResponse; | |
| 5 | 6 | use axum::Json; | |
| 6 | 7 | use serde::{Deserialize, Serialize}; | |
| @@ -133,6 +134,51 @@ pub struct DiscoverQuery { | |||
| 133 | 134 | pub has_source: Option<String>, | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 137 | + | impl DiscoverQuery { | |
| 138 | + | /// Rebuild the canonical full-page URL for this filter selection, for the | |
| 139 | + | /// HTMX history headers. | |
| 140 | + | /// | |
| 141 | + | /// Blank values are dropped rather than echoed: every filter request carries | |
| 142 | + | /// the whole `.discover-filter` set via `hx-include`, so a verbatim | |
| 143 | + | /// round-trip would put `/discover?q=&tag=&category=&min_price=` in the | |
| 144 | + | /// address bar. Prices are sanitized so the URL states what was actually | |
| 145 | + | /// applied, and `page=1` is left implicit. | |
| 146 | + | fn to_page_url(&self) -> String { | |
| 147 | + | fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) { | |
| 148 | + | if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) { | |
| 149 | + | parts.push(format!("{key}={}", urlencoding::encode(v))); | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | let mut parts = Vec::new(); | |
| 154 | + | push_str(&mut parts, "mode", self.mode.as_ref()); | |
| 155 | + | push_str(&mut parts, "q", self.q.as_ref()); | |
| 156 | + | push_str(&mut parts, "item_type", self.item_type.as_ref()); | |
| 157 | + | push_str(&mut parts, "tag", self.tag.as_ref()); | |
| 158 | + | push_str(&mut parts, "category", self.category.as_ref()); | |
| 159 | + | push_str(&mut parts, "ai_tier", self.ai_tier.as_ref()); | |
| 160 | + | push_str(&mut parts, "has_source", self.has_source.as_ref()); | |
| 161 | + | push_str(&mut parts, "sort", self.sort.as_ref()); | |
| 162 | + | ||
| 163 | + | let (min_price, max_price) = sanitize_price_range(self.min_price, self.max_price); | |
| 164 | + | if let Some(v) = min_price { | |
| 165 | + | parts.push(format!("min_price={v}")); | |
| 166 | + | } | |
| 167 | + | if let Some(v) = max_price { | |
| 168 | + | parts.push(format!("max_price={v}")); | |
| 169 | + | } | |
| 170 | + | if let Some(p) = self.page.filter(|&p| p > 1) { | |
| 171 | + | parts.push(format!("page={p}")); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | if parts.is_empty() { | |
| 175 | + | "/discover".to_string() | |
| 176 | + | } else { | |
| 177 | + | format!("/discover?{}", parts.join("&")) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 136 | 182 | /// Shared result data for both the full discover page and the HTMX partial. | |
| 137 | 183 | struct DiscoverData { | |
| 138 | 184 | items: Vec<DiscoverItem>, | |
| @@ -176,6 +222,82 @@ mod price_range_tests { | |||
| 176 | 222 | } | |
| 177 | 223 | } | |
| 178 | 224 | ||
| 225 | + | #[cfg(test)] | |
| 226 | + | mod page_url_tests { | |
| 227 | + | use super::DiscoverQuery; | |
| 228 | + | ||
| 229 | + | fn query() -> DiscoverQuery { | |
| 230 | + | DiscoverQuery { | |
| 231 | + | q: None, | |
| 232 | + | item_type: None, | |
| 233 | + | tag: None, | |
| 234 | + | category: None, | |
| 235 | + | min_price: None, | |
| 236 | + | max_price: None, | |
| 237 | + | sort: None, | |
| 238 | + | page: None, | |
| 239 | + | mode: None, | |
| 240 | + | ai_tier: None, | |
| 241 | + | has_source: None, | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn bare_query_is_the_bare_page() { | |
| 247 | + | assert_eq!(query().to_page_url(), "/discover"); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn blank_filters_are_dropped() { | |
| 252 | + | // hx-include ships every filter on every request, so most arrive blank. | |
| 253 | + | let q = DiscoverQuery { | |
| 254 | + | q: Some(String::new()), | |
| 255 | + | tag: Some(" ".to_string()), | |
| 256 | + | category: Some(String::new()), | |
| 257 | + | mode: Some("items".to_string()), | |
| 258 | + | item_type: Some("preset".to_string()), | |
| 259 | + | ..query() | |
| 260 | + | }; | |
| 261 | + | assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset"); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn values_are_percent_encoded() { | |
| 266 | + | let q = DiscoverQuery { | |
| 267 | + | q: Some("field recording & tape".to_string()), | |
| 268 | + | ..query() | |
| 269 | + | }; | |
| 270 | + | assert_eq!(q.to_page_url(), "/discover?q=field%20recording%20%26%20tape"); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn first_page_stays_implicit() { | |
| 275 | + | let q = DiscoverQuery { page: Some(1), ..query() }; | |
| 276 | + | assert_eq!(q.to_page_url(), "/discover"); | |
| 277 | + | ||
| 278 | + | let q = DiscoverQuery { page: Some(3), ..query() }; | |
| 279 | + | assert_eq!(q.to_page_url(), "/discover?page=3"); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn url_states_the_prices_that_were_actually_applied() { | |
| 284 | + | // An inverted range is dropped from the query, so it must not linger in the URL. | |
| 285 | + | let q = DiscoverQuery { | |
| 286 | + | min_price: Some(500), | |
| 287 | + | max_price: Some(100), | |
| 288 | + | ..query() | |
| 289 | + | }; | |
| 290 | + | assert_eq!(q.to_page_url(), "/discover"); | |
| 291 | + | ||
| 292 | + | let q = DiscoverQuery { | |
| 293 | + | min_price: Some(100), | |
| 294 | + | max_price: Some(500), | |
| 295 | + | ..query() | |
| 296 | + | }; | |
| 297 | + | assert_eq!(q.to_page_url(), "/discover?min_price=100&max_price=500"); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 179 | 301 | /// Fetch items or projects with pagination; shared by both handlers. | |
| 180 | 302 | /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings | |
| 181 | 303 | /// collapse to `None` and enum-valued params are parsed. Both the data fetch and | |
| @@ -589,11 +711,23 @@ pub(super) async fn discover( | |||
| 589 | 711 | pub(super) async fn discover_results( | |
| 590 | 712 | State(db): State<PgPool>, | |
| 591 | 713 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 714 | + | headers: HeaderMap, | |
| 592 | 715 | Query(query): Query<DiscoverQuery>, | |
| 593 | 716 | ) -> Result<impl IntoResponse> { | |
| 594 | 717 | let data = fetch_discover_data(&db, &query).await?; | |
| 595 | 718 | ||
| 596 | - | Ok(DiscoverResultsTemplate { | |
| 719 | + | // Mirror the filter state into the address bar so a filtered view can be | |
| 720 | + | // shared, bookmarked, and reloaded. A discrete filter click pushes a history | |
| 721 | + | // entry, so Back undoes one filter; the search box fires on a debounce while | |
| 722 | + | // typing, so it replaces instead of burying the page under one entry per | |
| 723 | + | // keystroke pause. HX-Trigger carries the id of the element that fired. | |
| 724 | + | let history_header = match headers.get("HX-Trigger").and_then(|v| v.to_str().ok()) { | |
| 725 | + | Some("search-input") => "HX-Replace-Url", | |
| 726 | + | _ => "HX-Push-Url", | |
| 727 | + | }; | |
| 728 | + | let page_url = query.to_page_url(); | |
| 729 | + | ||
| 730 | + | Ok(([(history_header, page_url)], DiscoverResultsTemplate { | |
| 597 | 731 | items: data.items, | |
| 598 | 732 | projects: data.projects, | |
| 599 | 733 | mode: data.mode, | |
| @@ -605,7 +739,7 @@ pub(super) async fn discover_results( | |||
| 605 | 739 | showing_end: data.showing_end, | |
| 606 | 740 | current_category: query.category.unwrap_or_default(), | |
| 607 | 741 | is_authenticated: maybe_user.is_some(), | |
| 608 | - | }) | |
| 742 | + | })) | |
| 609 | 743 | } | |
| 610 | 744 | ||
| 611 | 745 | /// Query parameters for search suggestions. |
| @@ -2,19 +2,21 @@ | |||
| 2 | 2 | // the selected filter value into the hidden form input so subsequent | |
| 3 | 3 | // search/sort submissions include it. | |
| 4 | 4 | document.body.addEventListener('htmx:beforeRequest', function(evt) { | |
| 5 | - | if (evt.detail.elt.classList.contains('filter-item')) { | |
| 6 | - | // Only deactivate siblings in the same filter section. | |
| 7 | - | // aria-selected must track is-selected: these are role="option" in a | |
| 8 | - | // role="listbox", so assistive tech reads the attribute, not the class. | |
| 5 | + | if (evt.detail.elt.classList.contains('filter-btn')) { | |
| 6 | + | // Only deactivate siblings in the same filter section. aria-pressed must | |
| 7 | + | // track is-selected: the class is the visual state, the attribute is what | |
| 8 | + | // assistive tech reads, and letting them drift is worse than either alone. | |
| 9 | 9 | var section = evt.detail.elt.closest('.filter-list'); | |
| 10 | 10 | if (section) { | |
| 11 | 11 | section.querySelectorAll('.filter-item').forEach(function(i) { | |
| 12 | 12 | i.classList.remove('is-selected'); | |
| 13 | - | i.setAttribute('aria-selected', 'false'); | |
| 13 | + | }); | |
| 14 | + | section.querySelectorAll('.filter-btn').forEach(function(b) { | |
| 15 | + | b.setAttribute('aria-pressed', 'false'); | |
| 14 | 16 | }); | |
| 15 | 17 | } | |
| 16 | - | evt.detail.elt.classList.add('is-selected'); | |
| 17 | - | evt.detail.elt.setAttribute('aria-selected', 'true'); | |
| 18 | + | evt.detail.elt.closest('.filter-item').classList.add('is-selected'); | |
| 19 | + | evt.detail.elt.setAttribute('aria-pressed', 'true'); | |
| 18 | 20 | ||
| 19 | 21 | var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}'); | |
| 20 | 22 | if ('item_type' in hxVals) { |
| @@ -7452,11 +7452,17 @@ textarea:focus-visible { | |||
| 7452 | 7452 | .filter-title { font-family: var(--font-heading); font-weight: bold; font-size: 0.85rem; margin-bottom: 0.5rem; color: var(--content); } | |
| 7453 | 7453 | .filter-browse-link { font-size: 0.75rem; opacity: 0.5; font-weight: normal; } | |
| 7454 | 7454 | .filter-list { list-style: none; } | |
| 7455 | - | .filter-item { padding: 0.25rem 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: opacity 0.1s ease; } | |
| 7455 | + | /* The row carries selection (`.is-selected` recipe) and hover; the filter itself | |
| 7456 | + | is a real <button> child so Enter/Space work without scripting. */ | |
| 7457 | + | .filter-item { display: flex; align-items: center; gap: 0.3rem; transition: opacity 0.1s ease; } | |
| 7456 | 7458 | .filter-item:hover { opacity: 0.6; } | |
| 7457 | 7459 | .filter-item.is-selected { font-weight: bold; } | |
| 7458 | 7460 | .filter-item .count { opacity: 0.4; font-size: 0.8rem; } | |
| 7459 | - | .filter-item-right { display: flex; align-items: center; gap: 0.3rem; } | |
| 7461 | + | /* Strip the bare-button chrome: filters switch the view rather than commit | |
| 7462 | + | anything, so they carry no fill, border, or depth (charter: docs/design-system.md). */ | |
| 7463 | + | .filter-btn { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.25rem 0; background: none; border: none; box-shadow: none; font: inherit; color: inherit; text-align: left; cursor: pointer; } | |
| 7464 | + | .filter-btn:hover { box-shadow: none; } | |
| 7465 | + | .filter-btn:active { box-shadow: none; transform: none; } | |
| 7460 | 7466 | .tag-follow-btn { background: none; border: 1px solid var(--border); cursor: pointer; font-family: var(--font-mono); font-size: 0.7rem; opacity: 0.55; padding: 0.1rem 0.45rem; color: var(--content); } | |
| 7461 | 7467 | .tag-follow-btn:hover { opacity: 1; } | |
| 7462 | 7468 | .tag-follow-btn.is-selected { opacity: 0.85; border-color: var(--focus-ring); background: color-mix(in oklch, var(--action) 8%, transparent); } |
| @@ -13,19 +13,18 @@ | |||
| 13 | 13 | {% if mode == "projects" %} | |
| 14 | 14 | <div class="filter-section"> | |
| 15 | 15 | <div class="filter-title" id="category-label">Category</div> | |
| 16 | - | <ul class="filter-list" role="listbox" aria-labelledby="category-label"> | |
| 16 | + | <ul class="filter-list" aria-labelledby="category-label"> | |
| 17 | 17 | {% for cf in category_filters %} | |
| 18 | - | <li class="filter-item{% if cf.active %} is-selected{% endif %}" | |
| 19 | - | role="option" | |
| 20 | - | aria-selected="{% if cf.active %}true{% else %}false{% endif %}" | |
| 21 | - | tabindex="0" | |
| 22 | - | hx-get="/discover/results" | |
| 23 | - | hx-target="#results-container" | |
| 24 | - | hx-indicator="#search-spinner" | |
| 25 | - | hx-include=".discover-filter" | |
| 26 | - | hx-vals='{"category": "{{ cf.value }}"}' | |
| 27 | - | > | |
| 28 | - | <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> | |
| 18 | + | <li class="filter-item{% if cf.active %} is-selected{% endif %}"> | |
| 19 | + | <button type="button" class="filter-btn" | |
| 20 | + | aria-pressed="{% if cf.active %}true{% else %}false{% endif %}" | |
| 21 | + | hx-get="/discover/results" | |
| 22 | + | hx-target="#results-container" | |
| 23 | + | hx-indicator="#search-spinner" | |
| 24 | + | hx-include=".discover-filter" | |
| 25 | + | hx-vals='{"category": "{{ cf.value }}"}'> | |
| 26 | + | <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> | |
| 27 | + | </button> | |
| 29 | 28 | </li> | |
| 30 | 29 | {% endfor %} | |
| 31 | 30 | </ul> | |
| @@ -48,19 +47,18 @@ | |||
| 48 | 47 | {% if mode == "items" %} | |
| 49 | 48 | <div class="filter-section"> | |
| 50 | 49 | <div class="filter-title" id="type-label">Type</div> | |
| 51 | - | <ul class="filter-list" role="listbox" aria-labelledby="type-label"> | |
| 50 | + | <ul class="filter-list" aria-labelledby="type-label"> | |
| 52 | 51 | {% for tf in type_filters %} | |
| 53 | - | <li class="filter-item{% if tf.active %} is-selected{% endif %}" | |
| 54 | - | role="option" | |
| 55 | - | aria-selected="{% if tf.active %}true{% else %}false{% endif %}" | |
| 56 | - | tabindex="0" | |
| 57 | - | hx-get="/discover/results" | |
| 58 | - | hx-target="#results-container" | |
| 59 | - | hx-indicator="#search-spinner" | |
| 60 | - | hx-include=".discover-filter" | |
| 61 | - | hx-vals='{"item_type": "{{ tf.value }}"}' | |
| 62 | - | > | |
| 63 | - | <span>{{ tf.name }}</span> <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| 52 | + | <li class="filter-item{% if tf.active %} is-selected{% endif %}"> | |
| 53 | + | <button type="button" class="filter-btn" | |
| 54 | + | aria-pressed="{% if tf.active %}true{% else %}false{% endif %}" | |
| 55 | + | hx-get="/discover/results" | |
| 56 | + | hx-target="#results-container" | |
| 57 | + | hx-indicator="#search-spinner" | |
| 58 | + | hx-include=".discover-filter" | |
| 59 | + | hx-vals='{"item_type": "{{ tf.value }}"}'> | |
| 60 | + | <span>{{ tf.name }}</span> <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| 61 | + | </button> | |
| 64 | 62 | </li> | |
| 65 | 63 | {% endfor %} | |
| 66 | 64 | </ul> | |
| @@ -68,29 +66,28 @@ | |||
| 68 | 66 | ||
| 69 | 67 | <div class="filter-section"> | |
| 70 | 68 | <div class="filter-title" id="tag-label">Tags <a href="/discover/tags" class="filter-browse-link">Browse all</a></div> | |
| 71 | - | <ul class="filter-list" role="listbox" aria-labelledby="tag-label"> | |
| 69 | + | <ul class="filter-list" aria-labelledby="tag-label"> | |
| 72 | 70 | {% for tg in tag_filters %} | |
| 73 | - | <li class="filter-item{% if tg.active %} is-selected{% endif %}" | |
| 74 | - | role="option" | |
| 75 | - | aria-selected="{% if tg.active %}true{% else %}false{% endif %}" | |
| 76 | - | tabindex="0" | |
| 77 | - | hx-get="/discover/results" | |
| 78 | - | hx-target="#results-container" | |
| 79 | - | hx-indicator="#search-spinner" | |
| 80 | - | hx-include=".discover-filter" | |
| 81 | - | hx-vals='{"tag": "{{ tg.value }}"}' | |
| 82 | - | > | |
| 83 | - | <span>{{ tg.name }}</span> | |
| 84 | - | <span class="filter-item-right"> | |
| 71 | + | <li class="filter-item{% if tg.active %} is-selected{% endif %}"> | |
| 72 | + | <button type="button" class="filter-btn" | |
| 73 | + | aria-pressed="{% if tg.active %}true{% else %}false{% endif %}" | |
| 74 | + | hx-get="/discover/results" | |
| 75 | + | hx-target="#results-container" | |
| 76 | + | hx-indicator="#search-spinner" | |
| 77 | + | hx-include=".discover-filter" | |
| 78 | + | hx-vals='{"tag": "{{ tg.value }}"}'> | |
| 79 | + | <span>{{ tg.name }}</span> | |
| 85 | 80 | <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span> | |
| 86 | - | {% if session_user.is_some() && !tg.id.is_empty() %} | |
| 87 | - | {% if tg.following %} | |
| 88 | - | <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button> | |
| 89 | - | {% else %} | |
| 90 | - | <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button> | |
| 91 | - | {% endif %} | |
| 92 | - | {% endif %} | |
| 93 | - | </span> | |
| 81 | + | </button> | |
| 82 | + | {# Follow is a sibling of the filter, not a child: an interactive | |
| 83 | + | control nested inside another button is invalid and unreachable. #} | |
| 84 | + | {% if session_user.is_some() && !tg.id.is_empty() %} | |
| 85 | + | {% if tg.following %} | |
| 86 | + | <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button> | |
| 87 | + | {% else %} | |
| 88 | + | <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button> | |
| 89 | + | {% endif %} | |
| 90 | + | {% endif %} | |
| 94 | 91 | </li> | |
| 95 | 92 | {% endfor %} | |
| 96 | 93 | </ul> | |
| @@ -131,18 +128,18 @@ | |||
| 131 | 128 | {% if !ai_tier_filters.is_empty() %} | |
| 132 | 129 | <div class="filter-section"> | |
| 133 | 130 | <div class="filter-title" id="ai-tier-label">AI Disclosure</div> | |
| 134 | - | <ul class="filter-list" role="listbox" aria-labelledby="ai-tier-label"> | |
| 131 | + | <ul class="filter-list" aria-labelledby="ai-tier-label"> | |
| 135 | 132 | {% for af in ai_tier_filters %} | |
| 136 | - | <li class="filter-item{% if af.active %} is-selected{% endif %}" | |
| 137 | - | role="option" | |
| 138 | - | aria-selected="{% if af.active %}true{% else %}false{% endif %}" | |
| 139 | - | tabindex="0" | |
| 140 | - | hx-get="/discover/results" | |
| 141 | - | hx-target="#results-container" | |
| 142 | - | hx-indicator="#search-spinner" | |
| 143 | - | hx-include=".discover-filter" | |
| 144 | - | hx-vals='{"ai_tier": "{{ af.value }}"}'> | |
| 145 | - | <span>{{ af.name }}</span> <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> | |
| 133 | + | <li class="filter-item{% if af.active %} is-selected{% endif %}"> | |
| 134 | + | <button type="button" class="filter-btn" | |
| 135 | + | aria-pressed="{% if af.active %}true{% else %}false{% endif %}" | |
| 136 | + | hx-get="/discover/results" | |
| 137 | + | hx-target="#results-container" | |
| 138 | + | hx-indicator="#search-spinner" | |
| 139 | + | hx-include=".discover-filter" | |
| 140 | + | hx-vals='{"ai_tier": "{{ af.value }}"}'> | |
| 141 | + | <span>{{ af.name }}</span> <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> | |
| 142 | + | </button> | |
| 146 | 143 | </li> | |
| 147 | 144 | {% endfor %} | |
| 148 | 145 | </ul> |
| @@ -121,6 +121,88 @@ async fn discover_results_returns_html() { | |||
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | #[tokio::test] | |
| 124 | + | async fn discover_results_pushes_the_full_page_url() { | |
| 125 | + | let mut h = TestHarness::new().await; | |
| 126 | + | ||
| 127 | + | let resp = h | |
| 128 | + | .client | |
| 129 | + | .htmx_get("/discover/results?mode=items&item_type=preset") | |
| 130 | + | .await; | |
| 131 | + | assert_eq!(resp.status, 200); | |
| 132 | + | assert_eq!( | |
| 133 | + | resp.header("HX-Push-Url"), | |
| 134 | + | Some("/discover?mode=items&item_type=preset"), | |
| 135 | + | "A filter click should push the full page URL, not the partial's URL" | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[tokio::test] | |
| 140 | + | async fn discover_results_drops_blank_filters_from_the_pushed_url() { | |
| 141 | + | let mut h = TestHarness::new().await; | |
| 142 | + | ||
| 143 | + | // hx-include sends every .discover-filter on every request, so most arrive blank. | |
| 144 | + | let resp = h | |
| 145 | + | .client | |
| 146 | + | .htmx_get("/discover/results?q=&tag=&category=&ai_tier=&min_price=&max_price=&mode=items&item_type=preset") | |
| 147 | + | .await; | |
| 148 | + | assert_eq!(resp.status, 200); | |
| 149 | + | assert_eq!( | |
| 150 | + | resp.header("HX-Push-Url"), | |
| 151 | + | Some("/discover?mode=items&item_type=preset"), | |
| 152 | + | "Blank filters must not be echoed into the address bar" | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[tokio::test] | |
| 157 | + | async fn discover_search_replaces_rather_than_pushes_history() { | |
| 158 | + | let mut h = TestHarness::new().await; | |
| 159 | + | ||
| 160 | + | // The search box is debounced, so pushing would leave an entry per keystroke pause. | |
| 161 | + | let resp = h | |
| 162 | + | .client | |
| 163 | + | .request_with_headers( | |
| 164 | + | "GET", | |
| 165 | + | "/discover/results?mode=items&q=ambient", | |
| 166 | + | None, | |
| 167 | + | &[("HX-Request", "true"), ("HX-Trigger", "search-input")], | |
| 168 | + | ) | |
| 169 | + | .await; | |
| 170 | + | assert_eq!(resp.status, 200); | |
| 171 | + | assert_eq!( | |
| 172 | + | resp.header("HX-Replace-Url"), | |
| 173 | + | Some("/discover?mode=items&q=ambient"), | |
| 174 | + | "Typing in the search box should replace the URL" | |
| 175 | + | ); | |
| 176 | + | assert_eq!( | |
| 177 | + | resp.header("HX-Push-Url"), | |
| 178 | + | None, | |
| 179 | + | "Typing must not push a history entry" | |
| 180 | + | ); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[tokio::test] | |
| 184 | + | async fn discover_filters_are_buttons_not_fake_options() { | |
| 185 | + | let mut h = TestHarness::new().await; | |
| 186 | + | ||
| 187 | + | let resp = h.client.get("/discover?mode=items").await; | |
| 188 | + | assert_eq!(resp.status, 200); | |
| 189 | + | // Real buttons activate on Enter/Space with no scripting. The old | |
| 190 | + | // li[role=option][tabindex=0] took focus but could not be activated at all. | |
| 191 | + | assert!( | |
| 192 | + | resp.text.contains("class=\"filter-btn\""), | |
| 193 | + | "Filters should render as real buttons" | |
| 194 | + | ); | |
| 195 | + | assert!( | |
| 196 | + | !resp.text.contains("role=\"option\""), | |
| 197 | + | "Filters should not claim listbox semantics they don't implement" | |
| 198 | + | ); | |
| 199 | + | assert!( | |
| 200 | + | !resp.text.contains("role=\"listbox\""), | |
| 201 | + | "Filter lists should not claim listbox semantics they don't implement" | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[tokio::test] | |
| 124 | 206 | async fn discover_filters_by_item_type() { | |
| 125 | 207 | let mut h = TestHarness::new().await; | |
| 126 | 208 |