Offer the searches from before while you type
Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.
The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.
That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -297,6 +297,181 @@ App.ui = App.ui || {};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Recent searches
|
||||
//
|
||||
// The searches from before, offered under the box as you type. Which ones
|
||||
// and in what order is App.storage's business (suggestSearches); this is
|
||||
// the list itself: when it shows, how it is walked, and what taking one
|
||||
// does. Five at a time, because a list you have to read is not a shortcut.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.searchHistory = (function() {
|
||||
const SHOWN = 5;
|
||||
let offered = [];
|
||||
let activeIndex = -1;
|
||||
let bound = false;
|
||||
|
||||
const input = () => document.getElementById('search-input');
|
||||
const list = () => document.getElementById('search-suggest');
|
||||
|
||||
const isOpen = function() {
|
||||
const el = list();
|
||||
return !!el && !el.hidden;
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const el = list();
|
||||
if (el) {
|
||||
el.hidden = true;
|
||||
el.textContent = ''; // no rows left behind for anything to find
|
||||
}
|
||||
offered = [];
|
||||
activeIndex = -1;
|
||||
const box = input();
|
||||
if (box) {
|
||||
box.setAttribute('aria-expanded', 'false');
|
||||
box.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
|
||||
// The typed part, picked out of the suggestion it matched. Built as
|
||||
// text nodes rather than markup: these strings are the reader's own,
|
||||
// and they are going back on screen.
|
||||
const rowLabel = function(entry, typed) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'search-suggest-label';
|
||||
const at = typed ? entry.toLowerCase().indexOf(typed.toLowerCase()) : -1;
|
||||
if (at < 0) {
|
||||
label.textContent = entry;
|
||||
return label;
|
||||
}
|
||||
label.appendChild(document.createTextNode(entry.slice(0, at)));
|
||||
const hit = document.createElement('span');
|
||||
hit.className = 'search-suggest-hit';
|
||||
hit.textContent = entry.slice(at, at + typed.length);
|
||||
label.appendChild(hit);
|
||||
label.appendChild(document.createTextNode(entry.slice(at + typed.length)));
|
||||
return label;
|
||||
};
|
||||
|
||||
const highlight = function(next) {
|
||||
const el = list();
|
||||
if (!el) return;
|
||||
const rows = Array.from(el.querySelectorAll('.search-suggest-row'));
|
||||
activeIndex = next;
|
||||
let activeId = '';
|
||||
rows.forEach((row, i) => {
|
||||
const on = i === activeIndex;
|
||||
row.classList.toggle('is-active', on);
|
||||
row.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
if (on) activeId = row.id;
|
||||
});
|
||||
const box = input();
|
||||
if (!box) return;
|
||||
if (activeId) box.setAttribute('aria-activedescendant', activeId);
|
||||
else box.removeAttribute('aria-activedescendant');
|
||||
};
|
||||
|
||||
const choose = function(query) {
|
||||
const box = input();
|
||||
close();
|
||||
if (!query) return;
|
||||
if (box) {
|
||||
box.value = query;
|
||||
box.blur(); // the list has served its purpose; give the page back
|
||||
}
|
||||
App.videos.handleSearch(query);
|
||||
};
|
||||
|
||||
// Shows what the box's current contents suggest, or hides the list when
|
||||
// nothing does.
|
||||
const refresh = function() {
|
||||
const box = input();
|
||||
const el = list();
|
||||
if (!box || !el) return;
|
||||
const typed = box.value.trim();
|
||||
offered = App.storage.suggestSearches(typed, SHOWN);
|
||||
if (!offered.length) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
el.textContent = '';
|
||||
offered.forEach((entry, i) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'search-suggest-row';
|
||||
row.id = 'search-suggest-' + i;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', 'false');
|
||||
row.dataset.query = entry;
|
||||
row.appendChild(rowLabel(entry, typed));
|
||||
// Chosen on pointerdown, before the box loses focus: a click
|
||||
// would arrive after the blur that closes the list.
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
choose(entry);
|
||||
});
|
||||
row.addEventListener('mousemove', () => highlight(i));
|
||||
el.appendChild(row);
|
||||
});
|
||||
el.hidden = false;
|
||||
box.setAttribute('aria-expanded', 'true');
|
||||
highlight(-1);
|
||||
};
|
||||
|
||||
const onKeyDown = function(event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (!isOpen()) {
|
||||
refresh();
|
||||
if (!isOpen()) return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const count = offered.length;
|
||||
highlight(((activeIndex + step) % count + count) % count);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const box = input();
|
||||
if (isOpen() && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
choose(offered[activeIndex]);
|
||||
return;
|
||||
}
|
||||
// Typed and entered: a search the reader meant, so it is kept
|
||||
// now rather than waiting for the typing to settle.
|
||||
close();
|
||||
if (box) {
|
||||
App.storage.rememberSearch(box.value);
|
||||
App.videos.handleSearch(box.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && isOpen()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Escape closes drawers too
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
const box = input();
|
||||
if (bound || !box) return;
|
||||
bound = true;
|
||||
box.addEventListener('focus', refresh);
|
||||
box.addEventListener('input', refresh);
|
||||
box.addEventListener('keydown', onKeyDown);
|
||||
box.addEventListener('blur', () => { window.setTimeout(close, 0); });
|
||||
document.addEventListener('pointerdown', (event) => {
|
||||
if (!isOpen()) return;
|
||||
if (event.target.closest('.search-container')) return;
|
||||
close();
|
||||
});
|
||||
};
|
||||
|
||||
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
@@ -1125,6 +1300,7 @@ App.ui = App.ui || {};
|
||||
// Update the clear button immediately for snappy feedback, but
|
||||
// debounce the actual reload so typing doesn't wipe the grid and
|
||||
// fire a backend request on every keystroke.
|
||||
App.ui.searchHistory.bind();
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
searchInput.addEventListener('input', () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
|
||||
Reference in New Issue
Block a user