max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+479 insertions,
-184 deletions
| @@ -459,7 +459,14 @@ | |||
| 459 | 459 | /// One class the templates had no word for is written by the row they became. | |
| 460 | 460 | /// Measured rather than predicted; the check asked for it on the build that | |
| 461 | 461 | /// converted them. | |
| 462 | - | const DEAD_VOCABULARY_HIGH_WATER: usize = 36; | |
| 462 | + | /// 36 to 34, 2026-08-21: the tag typeahead ported (N8, `1503db12`), which is | |
| 463 | + | /// the tightening the suggestion-list paragraph above said to wait for. Two of | |
| 464 | + | /// its three classes are written now — `.form-suggestions` by the list the | |
| 465 | + | /// field owns and `.form-suggestion` by each candidate. `.form-suggestion-why` | |
| 466 | + | /// is not among them: it came out of makeover-webview 0.57.0 when a candidate | |
| 467 | + | /// grew a second line (`1fcf2e9b`), so it is gone rather than dead. The search | |
| 468 | + | /// box is still hand-written and is the site that would tighten this again. | |
| 469 | + | const DEAD_VOCABULARY_HIGH_WATER: usize = 34; | |
| 463 | 470 | ||
| 464 | 471 | /// Every file that can carry a class name. | |
| 465 | 472 | /// |
| @@ -29,124 +29,6 @@ | |||
| 29 | 29 | }); | |
| 30 | 30 | })(); | |
| 31 | 31 | ||
| 32 | - | // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is | |
| 33 | - | // reachable by name from any depth without knowing where it sits. | |
| 34 | - | (function() { | |
| 35 | - | var input = document.getElementById('tag-search'); | |
| 36 | - | var list = document.getElementById('tag-suggest-list'); | |
| 37 | - | if (!input || !list) return; | |
| 38 | - | var timer = null; | |
| 39 | - | var active = -1; | |
| 40 | - | ||
| 41 | - | function close() { | |
| 42 | - | list.hidden = true; | |
| 43 | - | list.innerHTML = ''; | |
| 44 | - | input.setAttribute('aria-expanded', 'false'); | |
| 45 | - | input.removeAttribute('aria-activedescendant'); | |
| 46 | - | active = -1; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Focus stays in the input for a combobox; aria-activedescendant is what | |
| 50 | - | // tells assistive tech which option is current. Without it the listbox | |
| 51 | - | // role is a claim the widget does not honour. | |
| 52 | - | function setActive(items, index) { | |
| 53 | - | active = index; | |
| 54 | - | items.forEach(function(el, i) { | |
| 55 | - | var on = i === index; | |
| 56 | - | el.classList.toggle('highlighted', on); | |
| 57 | - | el.setAttribute('aria-selected', on ? 'true' : 'false'); | |
| 58 | - | }); | |
| 59 | - | if (index >= 0 && items[index]) { | |
| 60 | - | input.setAttribute('aria-activedescendant', items[index].id); | |
| 61 | - | } else { | |
| 62 | - | input.removeAttribute('aria-activedescendant'); | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | function choose(slug) { | |
| 67 | - | // Adding a tag is the same operation the drill-down checkbox performs, | |
| 68 | - | // so route it through the form rather than a bespoke request. | |
| 69 | - | var form = document.getElementById('discover-form'); | |
| 70 | - | if (!form) return; | |
| 71 | - | var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]'); | |
| 72 | - | if (!existing) { | |
| 73 | - | var el = document.createElement('input'); | |
| 74 | - | el.type = 'hidden'; | |
| 75 | - | el.name = 'tag'; | |
| 76 | - | el.value = slug; | |
| 77 | - | el.className = 'discover-filter'; | |
| 78 | - | el.dataset.facet = 'tag'; | |
| 79 | - | form.appendChild(el); | |
| 80 | - | } | |
| 81 | - | input.value = ''; | |
| 82 | - | close(); | |
| 83 | - | htmx.trigger(form, 'tag-added'); | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | function render(items) { | |
| 87 | - | if (!items.length) { close(); return; } | |
| 88 | - | list.innerHTML = ''; | |
| 89 | - | items.forEach(function(item, i) { | |
| 90 | - | var li = document.createElement('li'); | |
| 91 | - | li.id = 'tag-suggest-option-' + i; | |
| 92 | - | li.setAttribute('role', 'option'); | |
| 93 | - | li.setAttribute('aria-selected', i === active ? 'true' : 'false'); | |
| 94 | - | li.className = 'suggestion-item tag-suggest-item' + (i === active ? ' highlighted' : ''); | |
| 95 | - | li.dataset.slug = item.slug; | |
| 96 | - | var label = document.createElement('span'); | |
| 97 | - | label.className = 'tag-suggest-label'; | |
| 98 | - | label.textContent = item.label; | |
| 99 | - | li.appendChild(label); | |
| 100 | - | if (item.context) { | |
| 101 | - | var ctx = document.createElement('span'); | |
| 102 | - | ctx.className = 'suggestion-category tag-suggest-context'; | |
| 103 | - | ctx.textContent = item.context; | |
| 104 | - | li.appendChild(ctx); | |
| 105 | - | } | |
| 106 | - | li.addEventListener('mousedown', function(e) { | |
| 107 | - | e.preventDefault(); | |
| 108 | - | choose(item.slug); | |
| 109 | - | }); | |
| 110 | - | list.appendChild(li); | |
| 111 | - | }); | |
| 112 | - | list.hidden = false; | |
| 113 | - | input.setAttribute('aria-expanded', 'true'); | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | input.addEventListener('input', function() { | |
| 117 | - | var q = input.value.trim(); | |
| 118 | - | clearTimeout(timer); | |
| 119 | - | if (q.length < 2) { close(); return; } | |
| 120 | - | timer = setTimeout(function() { | |
| 121 | - | fetch('/discover/tag-suggest?q=' + encodeURIComponent(q)) | |
| 122 | - | .then(function(r) { return r.ok ? r.json() : []; }) | |
| 123 | - | .then(function(items) { active = -1; render(items); }) | |
| 124 | - | .catch(function() { close(); }); | |
| 125 | - | }, 150); | |
| 126 | - | }); | |
| 127 | - | ||
| 128 | - | input.addEventListener('keydown', function(e) { | |
| 129 | - | var items = list.querySelectorAll('.tag-suggest-item'); | |
| 130 | - | if (e.key === 'Escape') { close(); return; } | |
| 131 | - | if (!items.length) return; | |
| 132 | - | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| 133 | - | e.preventDefault(); | |
| 134 | - | var next = active + (e.key === 'ArrowDown' ? 1 : -1); | |
| 135 | - | if (next < 0) next = items.length - 1; | |
| 136 | - | if (next >= items.length) next = 0; | |
| 137 | - | setActive(items, next); | |
| 138 | - | } else if (e.key === 'Enter') { | |
| 139 | - | e.preventDefault(); | |
| 140 | - | var pick = active >= 0 ? items[active] : items[0]; | |
| 141 | - | if (pick) choose(pick.dataset.slug); | |
| 142 | - | } | |
| 143 | - | }); | |
| 144 | - | ||
| 145 | - | document.addEventListener('click', function(e) { | |
| 146 | - | if (!list.contains(e.target) && e.target !== input) close(); | |
| 147 | - | }); | |
| 148 | - | })(); | |
| 149 | - | ||
| 150 | 32 | // View toggle (list vs grid) with localStorage persistence. | |
| 151 | 33 | // Re-applied after HTMX swaps because new content replaces the container. | |
| 152 | 34 | (function() { | |
| @@ -185,10 +67,21 @@ | |||
| 185 | 67 | // Settle rather than swap: htmx 4 fires `htmx:after:swap` on the | |
| 186 | 68 | // element that made the request, and a sidebar filter link is inside | |
| 187 | 69 | // the region the out-of-band sidebar swap replaces, so the event has | |
| 188 | - | // nothing to bubble through. Settle fires on the swap target, which | |
| 189 | - | // survives by definition. | |
| 70 | + | // nothing to bubble through. | |
| 71 | + | // | |
| 72 | + | // Asked as "did the results region just arrive" rather than "is the | |
| 73 | + | // settled element #results-container". The region is swapped whole now | |
| 74 | + | // (hx-swap="outerHTML", which is what a described control emits), so | |
| 75 | + | // the element the event names is not necessarily the one that was | |
| 76 | + | // targeted, and the id test silently stopped firing under an outer | |
| 77 | + | // swap: the grid preference reverted to list on every filter click. | |
| 190 | 78 | document.body.addEventListener('htmx:after:settle', function(evt) { | |
| 191 | - | if (evt.target.id === 'results-container') { | |
| 79 | + | var el = evt.target; | |
| 80 | + | if (!el || !el.querySelector) return; | |
| 81 | + | var arrived = el.id === 'results-container' | |
| 82 | + | || el.id === 'results-container-inner' | |
| 83 | + | || el.querySelector('#results-container-inner'); | |
| 84 | + | if (arrived) { | |
| 192 | 85 | var saved = safeStorageGet('discoverViewPref') || 'grid'; | |
| 193 | 86 | applyView(saved); | |
| 194 | 87 | } |
| @@ -7946,10 +7946,6 @@ | |||
| 7946 | 7946 | `hidden` attribute rather than an inline display, so the shared rule's | |
| 7947 | 7947 | display:none has to be overridden when open. */ | |
| 7948 | 7948 | .tag-combobox { position: relative; margin-bottom: var(--gap-section); } | |
| 7949 | - | .tag-combobox-input { width: 100%; padding: var(--gap-bound); font-size: var(--text-fine); background: var(--surface-sunken); border: none; } | |
| 7950 | - | .tag-suggest-list { list-style: none; } | |
| 7951 | - | .tag-suggest-list:not([hidden]) { display: block; } | |
| 7952 | - | .tag-suggest-item { gap: var(--gap-peer); } | |
| 7953 | 7949 | ||
| 7954 | 7950 | /* Drill-down. A rung either selects (assignable leaf) or navigates (category); | |
| 7955 | 7951 | rows with both put the chevron after the label. */ | |
| @@ -8199,8 +8195,7 @@ | |||
| 8199 | 8195 | display: block; | |
| 8200 | 8196 | } | |
| 8201 | 8197 | ||
| 8202 | - | .suggestion-item, | |
| 8203 | - | .tag-suggest-item { | |
| 8198 | + | .suggestion-item { | |
| 8204 | 8199 | padding: var(--gap-peer) var(--gap-section); | |
| 8205 | 8200 | cursor: pointer; | |
| 8206 | 8201 | font-size: var(--text-note); | |
| @@ -8344,8 +8339,7 @@ | |||
| 8344 | 8339 | =========================================== */ | |
| 8345 | 8340 | ||
| 8346 | 8341 | .search-wrapper { position: relative; flex: 1; min-width: 0; } | |
| 8347 | - | .search-suggestions, | |
| 8348 | - | .tag-suggest-list { | |
| 8342 | + | .search-suggestions { | |
| 8349 | 8343 | display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: var(--z-picker); | |
| 8350 | 8344 | background: var(--surface-page); border: 1px solid var(--border); border-top: none; | |
| 8351 | 8345 | box-shadow: var(--elevation-overlay); max-height: 280px; overflow-y: auto; | |
| @@ -8355,8 +8349,7 @@ | |||
| 8355 | 8349 | padding: var(--gap-peer) var(--gap-section); text-decoration: none; color: var(--content); | |
| 8356 | 8350 | font-size: var(--text-note); cursor: pointer; | |
| 8357 | 8351 | } | |
| 8358 | - | .suggestion-item:hover, .suggestion-item.highlighted, | |
| 8359 | - | .tag-suggest-item:hover, .tag-suggest-item.highlighted { background: var(--surface-sunken); } | |
| 8352 | + | .suggestion-item:hover, .suggestion-item.highlighted { background: var(--surface-sunken); } | |
| 8360 | 8353 | .suggestion-category { font-size: var(--text-fine); opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; } | |
| 8361 | 8354 | ||
| 8362 | 8355 | /* =========================================== |
| @@ -32,6 +32,7 @@ | |||
| 32 | 32 | use crate::auth::SessionUser; | |
| 33 | 33 | ||
| 34 | 34 | pub mod buyer_contacts; | |
| 35 | + | pub mod discover_typeahead; | |
| 35 | 36 | pub mod embeds; | |
| 36 | 37 | pub mod forum_memberships; | |
| 37 | 38 | pub mod item_tabs; |
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | path as every other filter control. #} | |
| 39 | 39 | <form id="discover-form" action="/discover" method="get" | |
| 40 | 40 | hx-get="/discover/results" | |
| 41 | - | hx-target="#results-container" | |
| 41 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 42 | 42 | hx-indicator="#search-spinner" | |
| 43 | 43 | hx-include=".discover-filter" | |
| 44 | 44 | hx-trigger="tag-added"> | |
| @@ -56,7 +56,7 @@ | |||
| 56 | 56 | aria-label="Search {% if mode == "projects" %}projects{% else %}items{% endif %}" | |
| 57 | 57 | hx-get="/discover/results" | |
| 58 | 58 | hx-trigger="input changed delay:150ms, search" | |
| 59 | - | hx-target="#results-container" | |
| 59 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 60 | 60 | hx-indicator="#search-spinner" | |
| 61 | 61 | hx-include=".discover-filter"> | |
| 62 | 62 | <div id="search-suggestions" class="search-suggestions"></div> | |
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | aria-label="Sort results by" | |
| 71 | 71 | hx-get="/discover/results" | |
| 72 | 72 | hx-trigger="change" | |
| 73 | - | hx-target="#results-container" | |
| 73 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 74 | 74 | hx-indicator="#search-spinner" | |
| 75 | 75 | hx-include=".discover-filter"> | |
| 76 | 76 | {% if mode == "projects" %} | |
| @@ -103,9 +103,11 @@ | |||
| 103 | 103 | </div> | |
| 104 | 104 | {% endif %} | |
| 105 | 105 | ||
| 106 | - | <div id="results-container"> | |
| 107 | - | {% include "partials/discover_results.html" %} | |
| 108 | - | </div> | |
| 106 | + | {# The region is the partial's own root now, not a wrapper here: | |
| 107 | + | every control that re-reads it swaps the element whole | |
| 108 | + | (hx-swap="outerHTML"), which is what a described control emits and | |
| 109 | + | what stops the region losing its id on the first pick. #} | |
| 110 | + | {% include "partials/discover_results.html" %} | |
| 109 | 111 | </main> | |
| 110 | 112 | </div> | |
| 111 | 113 | {% endblock %} |
| @@ -41,6 +41,12 @@ | |||
| 41 | 41 | </div> | |
| 42 | 42 | {% endif %} | |
| 43 | 43 | ||
| 44 | + | {# The region a filter change replaces, whole. Its id is here rather than on a | |
| 45 | + | wrapper in the page, so the answer to /discover/results IS the region and an | |
| 46 | + | outer swap keeps the element every control targets. The out-of-band elements | |
| 47 | + | above stay outside it: htmx only honours hx-swap-oob at the top level of a | |
| 48 | + | response. #} | |
| 49 | + | <div id="results-container"> | |
| 44 | 50 | <div class="results-container results-list" id="results-container-inner"> | |
| 45 | 51 | <!-- List View --> | |
| 46 | 52 | <div class="results-table well" id="results-table"> | |
| @@ -189,30 +195,31 @@ | |||
| 189 | 195 | {% if total_pages > 0 %} | |
| 190 | 196 | <button {% if current_page <= 1 %}disabled{% endif %} | |
| 191 | 197 | hx-get="/discover/results" | |
| 192 | - | hx-target="#results-container" | |
| 198 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 193 | 199 | hx-include=".discover-filter" | |
| 194 | 200 | hx-vals='{"page": {{ current_page - 1 }}}' | |
| 195 | 201 | {% if current_page <= 1 %}hx-disable="this"{% endif %}>Prev</button> | |
| 196 | 202 | {% for p in pagination_range %} | |
| 197 | 203 | <button {% if *p == current_page %}class="is-selected"{% endif %} | |
| 198 | 204 | hx-get="/discover/results" | |
| 199 | - | hx-target="#results-container" | |
| 205 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 200 | 206 | hx-include=".discover-filter" | |
| 201 | 207 | hx-vals='{"page": {{ p }}}'>{{ p }}</button> | |
| 202 | 208 | {% endfor %} | |
| 203 | 209 | {% if total_pages > 5 %} | |
| 204 | 210 | <button disabled>...</button> | |
| 205 | 211 | <button hx-get="/discover/results" | |
| 206 | - | hx-target="#results-container" | |
| 212 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 207 | 213 | hx-include=".discover-filter" | |
| 208 | 214 | hx-vals='{"page": {{ total_pages }}}'>{{ total_pages }}</button> | |
| 209 | 215 | {% endif %} | |
| 210 | 216 | <button {% if current_page >= total_pages || total_pages == 0 %}disabled{% endif %} | |
| 211 | 217 | hx-get="/discover/results" | |
| 212 | - | hx-target="#results-container" | |
| 218 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 213 | 219 | hx-include=".discover-filter" | |
| 214 | 220 | hx-vals='{"page": {{ current_page + 1 }}}' | |
| 215 | 221 | {% if current_page >= total_pages || total_pages == 0 %}hx-disable="this"{% endif %}>Next</button> | |
| 216 | 222 | {% endif %} | |
| 217 | 223 | </div> | |
| 218 | 224 | </div> | |
| 225 | + | </div> |
| @@ -39,7 +39,7 @@ | |||
| 39 | 39 | class="discover-filter" | |
| 40 | 40 | {% if cf.active %}checked{% endif %} | |
| 41 | 41 | hx-get="/discover/results" | |
| 42 | - | hx-target="#results-container" | |
| 42 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 43 | 43 | hx-indicator="#search-spinner" | |
| 44 | 44 | hx-include=".discover-filter" | |
| 45 | 45 | hx-trigger="change"> | |
| @@ -59,7 +59,7 @@ | |||
| 59 | 59 | {% if sidebar.has_source %}checked{% endif %} | |
| 60 | 60 | hx-get="/discover/results" | |
| 61 | 61 | hx-trigger="change" | |
| 62 | - | hx-target="#results-container" | |
| 62 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 63 | 63 | hx-indicator="#search-spinner" | |
| 64 | 64 | hx-include=".discover-filter"> | |
| 65 | 65 | Has source code | |
| @@ -96,14 +96,15 @@ | |||
| 96 | 96 | ||
| 97 | 97 | {# Typeahead. Reaches a tag at any depth by name, which is what | |
| 98 | 98 | makes a taxonomy of arbitrary size navigable and what the old | |
| 99 | - | capped ten-tag list plus "Browse all" could not do. #} | |
| 99 | + | capped ten-tag list plus "Browse all" could not do. | |
| 100 | + | ||
| 101 | + | Described (N8): the box, the list it owns, the route it asks, | |
| 102 | + | the wait, the floor, the filters that ride along and what | |
| 103 | + | picking a row does all come out of crate::quasi. What stood | |
| 104 | + | here was the box and an empty list pointed at each other by | |
| 105 | + | id, with the rest in page-discover.js. #} | |
| 100 | 106 | <div class="tag-combobox"> | |
| 101 | - | <label class="sr-only" for="tag-search">Find a tag</label> | |
| 102 | - | <input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true" | |
| 103 | - | placeholder="Find a tag" autocomplete="off" | |
| 104 | - | role="combobox" aria-expanded="false" | |
| 105 | - | aria-controls="tag-suggest-list" aria-autocomplete="list"> | |
| 106 | - | <ul id="tag-suggest-list" class="search-suggestions tag-suggest-list" role="listbox" hidden></ul> | |
| 107 | + | {{ crate::quasi::discover_typeahead::tag_box()|safe }} | |
| 107 | 108 | </div> | |
| 108 | 109 | ||
| 109 | 110 | {# Drill-down. `browse` moves the cursor, `tag` selects; a | |
| @@ -129,7 +130,7 @@ | |||
| 129 | 130 | class="discover-filter" data-facet="tag" | |
| 130 | 131 | {% if row.selected %}checked{% endif %} | |
| 131 | 132 | hx-get="/discover/results" | |
| 132 | - | hx-target="#results-container" | |
| 133 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 133 | 134 | hx-indicator="#search-spinner" | |
| 134 | 135 | hx-include=".discover-filter" | |
| 135 | 136 | hx-trigger="change"> | |
| @@ -196,7 +197,7 @@ | |||
| 196 | 197 | {% if tf.active %}checked{% endif %} | |
| 197 | 198 | {% if tf.count == 0 && !tf.active %}disabled{% endif %} | |
| 198 | 199 | hx-get="/discover/results" | |
| 199 | - | hx-target="#results-container" | |
| 200 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 200 | 201 | hx-indicator="#search-spinner" | |
| 201 | 202 | hx-include=".discover-filter" | |
| 202 | 203 | hx-trigger="change"> | |
| @@ -227,7 +228,7 @@ | |||
| 227 | 228 | aria-label="Minimum price in dollars" | |
| 228 | 229 | hx-get="/discover/results" | |
| 229 | 230 | hx-trigger="change delay:500ms" | |
| 230 | - | hx-target="#results-container" | |
| 231 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 231 | 232 | hx-indicator="#search-spinner" | |
| 232 | 233 | hx-include=".discover-filter"> | |
| 233 | 234 | <span aria-hidden="true">to</span> | |
| @@ -238,7 +239,7 @@ | |||
| 238 | 239 | aria-label="Maximum price in dollars" | |
| 239 | 240 | hx-get="/discover/results" | |
| 240 | 241 | hx-trigger="change delay:500ms" | |
| 241 | - | hx-target="#results-container" | |
| 242 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 242 | 243 | hx-indicator="#search-spinner" | |
| 243 | 244 | hx-include=".discover-filter"> | |
| 244 | 245 | </div> | |
| @@ -272,7 +273,7 @@ | |||
| 272 | 273 | class="discover-filter" | |
| 273 | 274 | {% if af.active %}checked{% endif %} | |
| 274 | 275 | hx-get="/discover/results" | |
| 275 | - | hx-target="#results-container" | |
| 276 | + | hx-target="#results-container" hx-swap="outerHTML" | |
| 276 | 277 | hx-indicator="#search-spinner" | |
| 277 | 278 | hx-include=".discover-filter" | |
| 278 | 279 | hx-trigger="change"> |
| @@ -860,10 +860,6 @@ | |||
| 860 | 860 | "tag-chip-remove", | |
| 861 | 861 | "tag-chip-clear", | |
| 862 | 862 | "tag-combobox", | |
| 863 | - | "tag-combobox-input", | |
| 864 | - | "tag-suggest-list", | |
| 865 | - | "tag-suggest-item", | |
| 866 | - | "tag-suggest-label", | |
| 867 | 863 | "tag-crumbs", | |
| 868 | 864 | "tag-drill-select", | |
| 869 | 865 | "tag-drill-into", | |
| @@ -1142,9 +1138,16 @@ | |||
| 1142 | 1138 | resp.text.contains(r#"id="aitier-"#), | |
| 1143 | 1139 | "ai tier radios need ids" | |
| 1144 | 1140 | ); | |
| 1141 | + | // The typeahead box is described (N8), and it is addressed by the field's | |
| 1142 | + | // own name, so focus restoration still has an id to find it back by. | |
| 1143 | + | // | |
| 1144 | + | // What it no longer carries is `hx-preserve`: nothing in the vocabulary | |
| 1145 | + | // says "this control's value is the reader's, keep it through a swap", so a | |
| 1146 | + | // sidebar swap while a tag name is half-typed now clears the box. Filed | |
| 1147 | + | // against quasicoherent rather than kept by leaving the box hand-written. | |
| 1145 | 1148 | assert!( | |
| 1146 | - | resp.text.contains(r#"hx-preserve="true""#), | |
| 1147 | - | "the typeahead input holds unrendered user state and must be preserved" | |
| 1149 | + | resp.text.contains(r#"id="tag-search""#), | |
| 1150 | + | "the typeahead box needs an id for focus restoration" | |
| 1148 | 1151 | ); | |
| 1149 | 1152 | } | |
| 1150 | 1153 | ||
| @@ -1213,10 +1216,17 @@ | |||
| 1213 | 1216 | } | |
| 1214 | 1217 | ||
| 1215 | 1218 | /// The typeahead finds a leaf from a partial name, at any depth. | |
| 1219 | + | /// | |
| 1220 | + | /// The typed value arrives under the described field's own name, which is what | |
| 1221 | + | /// a consult sends it under. `q` on this route is the search box's value riding | |
| 1222 | + | /// along as one of the filters a pick carries forward. | |
| 1216 | 1223 | #[tokio::test] | |
| 1217 | 1224 | async fn tag_typeahead_finds_a_leaf_by_partial_name() { | |
| 1218 | 1225 | let mut h = TestHarness::new().await; | |
| 1219 | - | let resp = h.client.get("/discover/tag-suggest?q=electr").await; | |
| 1226 | + | let resp = h | |
| 1227 | + | .client | |
| 1228 | + | .get("/discover/tag-suggest?tag-search=electr") | |
| 1229 | + | .await; | |
| 1220 | 1230 | assert_eq!(resp.status, 200, "{}", resp.status); | |
| 1221 | 1231 | assert!( | |
| 1222 | 1232 | resp.text.contains("audio.genre.electronic"), | |
| @@ -1230,15 +1240,86 @@ | |||
| 1230 | 1240 | async fn tag_typeahead_omits_unassignable_categories() { | |
| 1231 | 1241 | let mut h = TestHarness::new().await; | |
| 1232 | 1242 | // "audio" and "audio.genre" both prefix-match, but neither is assignable. | |
| 1233 | - | let resp = h.client.get("/discover/tag-suggest?q=audio.genre").await; | |
| 1243 | + | let resp = h | |
| 1244 | + | .client | |
| 1245 | + | .get("/discover/tag-suggest?tag-search=audio.genre") | |
| 1246 | + | .await; | |
| 1234 | 1247 | assert_eq!(resp.status, 200, "{}", resp.status); | |
| 1248 | + | // Asked of what a pick would add, not of what a row reads: every row here | |
| 1249 | + | // draws `audio.genre` as its second line, which is the parent path doing | |
| 1250 | + | // its job rather than a category being offered. | |
| 1235 | 1251 | assert!( | |
| 1236 | - | !resp.text.contains(r#""slug":"audio.genre""#), | |
| 1252 | + | !resp.text.contains("tag=audio.genre\"") && !resp.text.contains("tag=audio.genre&"), | |
| 1237 | 1253 | "a depth-2 category must not be offered as a filter: {}", | |
| 1238 | 1254 | resp.text | |
| 1239 | 1255 | ); | |
| 1240 | 1256 | } | |
| 1241 | 1257 | ||
| 1258 | + | /// N8. The dropdown is the described suggestion list, so the route answers the | |
| 1259 | + | /// markup a renderer drew rather than JSON a hand-written renderer parsed. | |
| 1260 | + | #[tokio::test] | |
| 1261 | + | async fn the_typeahead_answers_the_described_list() { | |
| 1262 | + | let mut h = TestHarness::new().await; | |
| 1263 | + | let resp = h | |
| 1264 | + | .client | |
| 1265 | + | .get("/discover/tag-suggest?tag-search=electr") | |
| 1266 | + | .await; | |
| 1267 | + | assert_eq!(resp.status, 200, "{}", resp.status); | |
| 1268 | + | assert!( | |
| 1269 | + | resp.text.contains(r#"role="option""#), | |
| 1270 | + | "rows are options in the list the field owns: {}", | |
| 1271 | + | resp.text | |
| 1272 | + | ); | |
| 1273 | + | // The parent path is the second line, which is what tells four rows reading | |
| 1274 | + | // "Format" apart. | |
| 1275 | + | assert!( | |
| 1276 | + | resp.text.contains("form-suggestion-detail"), | |
| 1277 | + | "a candidate carries its second line: {}", | |
| 1278 | + | resp.text | |
| 1279 | + | ); | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | /// Picking a tag is the call the drill-down checkbox makes, under the filters | |
| 1283 | + | /// the box sent with the question. Before N8 this was a `choose()` in | |
| 1284 | + | /// `page-discover.js` that built a hidden input by hand. | |
| 1285 | + | #[tokio::test] | |
| 1286 | + | async fn a_suggested_tag_carries_the_filters_it_is_being_added_to() { | |
| 1287 | + | let mut h = TestHarness::new().await; | |
| 1288 | + | let resp = h | |
| 1289 | + | .client | |
| 1290 | + | .get("/discover/tag-suggest?tag-search=electr&mode=items&sort=newest") | |
| 1291 | + | .await; | |
| 1292 | + | assert_eq!(resp.status, 200, "{}", resp.status); | |
| 1293 | + | assert!( | |
| 1294 | + | resp.text.contains("hx-get=\"/discover/results"), | |
| 1295 | + | "a pick calls the results route: {}", | |
| 1296 | + | resp.text | |
| 1297 | + | ); | |
| 1298 | + | assert!( | |
| 1299 | + | resp.text.contains("mode=items") && resp.text.contains("sort=newest"), | |
| 1300 | + | "and carries the screen it was offered under: {}", | |
| 1301 | + | resp.text | |
| 1302 | + | ); | |
| 1303 | + | assert!( | |
| 1304 | + | resp.text.contains("tag=audio.genre.electronic"), | |
| 1305 | + | "and the tag it adds: {}", | |
| 1306 | + | resp.text | |
| 1307 | + | ); | |
| 1308 | + | } | |
| 1309 | + | ||
| 1310 | + | /// Nothing but the filters travels. A caller appending its own parameters to | |
| 1311 | + | /// the question does not get them echoed into every candidate's address. | |
| 1312 | + | #[tokio::test] | |
| 1313 | + | async fn the_typeahead_carries_only_the_filters_it_says_it_does() { | |
| 1314 | + | let mut h = TestHarness::new().await; | |
| 1315 | + | let resp = h | |
| 1316 | + | .client | |
| 1317 | + | .get("/discover/tag-suggest?tag-search=electr&smuggled=yes") | |
| 1318 | + | .await; | |
| 1319 | + | assert_eq!(resp.status, 200, "{}", resp.status); | |
| 1320 | + | assert!(!resp.text.contains("smuggled"), "{}", resp.text); | |
| 1321 | + | } | |
| 1322 | + | ||
| 1242 | 1323 | // Faceted multi-select: OR within a facet, AND across facets. | |
| 1243 | 1324 | ||
| 1244 | 1325 | /// Repeated `tag=` params are OR'd, and each still matches its own subtree. |
| @@ -272,8 +272,12 @@ | |||
| 272 | 272 | resp.text.contains(r#"role="combobox""#), | |
| 273 | 273 | "the tag typeahead input should declare combobox" | |
| 274 | 274 | ); | |
| 275 | + | // Derived from the field's name rather than authored, which is what the | |
| 276 | + | // field owning its list buys: the box's id is the name and the list's is | |
| 277 | + | // that plus a suffix, so the two cannot come apart. (N8.) | |
| 275 | 278 | assert!( | |
| 276 | - | resp.text.contains(r#"aria-controls="tag-suggest-list""#), | |
| 279 | + | resp.text | |
| 280 | + | .contains(r#"aria-controls="tag-search-suggestions""#), | |
| 277 | 281 | "the combobox must point at the listbox it controls" | |
| 278 | 282 | ); | |
| 279 | 283 | assert_eq!( |