Skip to main content

max / makenotwork

mnw-server: make discover filters keyboard-operable and put filter state in the URL The filters were li[role=option][tabindex=0] carrying hx-get. They took focus but could not be activated: htmx defaults a non-form element to a click trigger, so Enter did nothing and the whole sidebar was dead to keyboard-only users. tabindex made it worse than omitting it, parking focus on a control that visibly refused to work. They are real buttons now, with aria-pressed for state and .is-selected still on the row. Enter and Space work with no scripting. This also resolves the tag list's Follow button, which was an interactive control nested inside role=option, invalid and unreachable; it is a sibling of the filter now rather than a child. Per the design-system charter the filters strip the bare-button chrome, since they switch the view rather than commit anything, while Follow keeps its depth because following is a write. A fourth filter list (AI Disclosure) had the same defect and is converted too. Filtering also left no history entry and no shareable URL: the address bar stayed at /discover, so a filtered view could not be sent to anyone or reloaded, and Back left the page entirely instead of undoing the filter. discover_results now sets HX-Push-Url for filter clicks, so Back undoes one filter, and HX-Replace-Url when HX-Trigger names the search box, whose debounce would otherwise leave an entry per keystroke pause. to_page_url drops the blank params hx-include sends on every request, sanitizes prices so the URL states what was actually applied, and leaves page=1 implicit. Verified in a real browser against the rendered page: Enter and Space fire, aria-pressed and .is-selected agree across every item, the URL pushes, and Back undoes the filter without leaving Discover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-16 15:55 UTC
Commit: 5f4d6ac816eafbb7dc9bcc4bbf56d973c090c3e0
Parent: 9c18d03
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