Skip to content

Commit 56732f4

Browse files
TMHSDigitalclaude
andcommitted
Catalog controls: fold the docked bar, no history per arrow, keep typing
- On wide screens the sticky filter bar folds to the search row and the Filters button once it docks at the top (253 px to 155 px at 1440x900) and unfolds when scrolled back to it; phones are unchanged - Arrow keys in the category group replace the history entry instead of pushing one per press - popstate ignores hash-only navigation, so following #stacks or #catalog no longer clears a search still inside the debounce - q is capped at 200 characters from the URL and in the search box Checked in Chromium at 1440x900 and 375x667. Closes #157 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1007475 commit 56732f4

5 files changed

Lines changed: 74 additions & 5 deletions

File tree

‎site/lib/lib.test.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ import {
743743
searchFields,
744744
similarity,
745745
stateQuery,
746+
MAX_QUERY,
746747
} from "./search.mjs";
747748

748749
const kit = { id: "ui-kit", name: "UI Kit", publisher: "Kenney", tags: ["button", "first-person"], license: "CC0", category: "2d", camera_perspective: "isometric_3_4", formats: ["PNG"], subcategories: ["ui"], summary: "Panels for the earth biome." };
@@ -1071,6 +1072,8 @@ has("cards: title level option", entryCardHtml(cardBase, { root: "", repo: "http
10711072
eq("stack page: no h4", html.includes("<h4"), false);
10721073
}
10731074

1075+
eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q: "" }, {}).q.length, MAX_QUERY);
1076+
10741077
/* report (keep last) ------------------------------------------------------ */
10751078
if (failures.length) {
10761079
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/search.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,18 @@ const SWITCHES = [
107107
* page has no control for; each one falls back to its default, and the page
108108
* then rewrites the URL without it.
109109
*/
110+
/** Longest search query kept from a URL or the search box. */
111+
export const MAX_QUERY = 200;
112+
110113
export function readState(search, defaults, allowed) {
111114
const p = new URLSearchParams(search);
112115
const state = { ...defaults };
113116
for (const [key, field] of CHOICES) {
114117
const v = p.get(key);
115118
if (v !== null && (allowed[field] || []).includes(v)) state[field] = v;
116119
}
117-
if (p.has("q")) state.q = p.get("q");
120+
// A shared link cannot paste a novel into the search box and the chips.
121+
if (p.has("q")) state.q = p.get("q").slice(0, MAX_QUERY);
118122
for (const [key, field] of SWITCHES) {
119123
const v = p.get(key);
120124
if (v === "1" || v === "0") state[field] = v === "1";

‎site/public/app.js‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,13 @@ import { installViewToggles } from "./views.js";
9191
* keystrokes and checkbox toggles replace, so Back is not a hundred steps
9292
* of typing. Both in try/catch: Safari throws past 100 calls in 30 s.
9393
*/
94+
// The query string writeUrl last put in the address bar.
95+
let lastSearch = location.search;
96+
9497
function writeUrl({ push = false } = {}) {
9598
const qs = stateQuery(state, DEFAULTS);
9699
const next = `${location.pathname}${qs ? `?${qs}` : ""}${location.hash}`;
100+
lastSearch = qs ? `?${qs}` : "";
97101
try {
98102
const same = next === `${location.pathname}${location.search}${location.hash}`;
99103
if (push && !same) history.pushState(null, "", next);
@@ -238,7 +242,8 @@ import { installViewToggles } from "./views.js";
238242
e.preventDefault();
239243
chips[to].focus();
240244
state.category = chips[to].getAttribute("data-cat");
241-
apply({ push: true });
245+
// Moving through the group is browsing, not a step to go Back to.
246+
apply();
242247
});
243248
}
244249

@@ -691,6 +696,11 @@ import { installViewToggles } from "./views.js";
691696

692697
// Back and Forward step through the pushed views.
693698
window.addEventListener("popstate", () => {
699+
// A hash-only move (#stacks, #catalog) also fires popstate; the
700+
// filters did not change, and re-reading them would wipe typing
701+
// that the debounce has not written yet.
702+
if (location.search === lastSearch) return;
703+
lastSearch = location.search;
694704
readUrl();
695705
syncControls();
696706
apply();
@@ -779,6 +789,36 @@ import { installViewToggles } from "./views.js";
779789
toggle.setAttribute("aria-expanded", open ? "true" : "false");
780790
});
781791

792+
// On wider screens the bar stays sticky, and unfolded it covered about
793+
// a quarter of a laptop screen. Once it docks at the top it folds to the
794+
// search row and the Filters button, as on a phone; scrolling back up
795+
// to it unfolds it again.
796+
const wide = matchMedia("(min-width: 641px) and (min-height: 601px)");
797+
const sentinel = document.createElement("div");
798+
sentinel.setAttribute("aria-hidden", "true");
799+
controls.before(sentinel);
800+
const setDocked = (docked) => {
801+
if (!wide.matches) {
802+
controls.classList.remove("is-docked");
803+
return;
804+
}
805+
if (docked === controls.classList.contains("is-docked")) return;
806+
controls.classList.toggle("is-docked", docked);
807+
controls.classList.toggle("is-collapsed", docked);
808+
toggle.setAttribute("aria-expanded", docked ? "false" : "true");
809+
};
810+
if ("IntersectionObserver" in window) {
811+
new IntersectionObserver(([e]) => setDocked(!e.isIntersecting && e.boundingClientRect.top < 0)).observe(sentinel);
812+
}
813+
wide.addEventListener?.("change", () => {
814+
if (!wide.matches) controls.classList.remove("is-docked");
815+
else controls.classList.remove("is-collapsed");
816+
});
817+
if (wide.matches) {
818+
controls.classList.remove("is-collapsed");
819+
toggle.setAttribute("aria-expanded", "true");
820+
}
821+
782822
const top = $("#to-top");
783823
// Returns to the catalog's search and filters, which on small screens have
784824
// scrolled away. It used to scroll to the page top and then focus search

‎site/public/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ <h2 id="catalog-title">Catalog</h2>
9191
<span class="sr-only">Search catalog</span>
9292
<input
9393
id="search"
94+
maxlength="200"
9495
type="search"
9596
placeholder="Name, license, tag, format…"
9697
autocomplete="off"

‎site/public/styles.css‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,13 +1050,34 @@ input:focus-visible,
10501050
}
10511051
}
10521052

1053-
/* The filter disclosure exists for small screens only. app.js unhides it
1054-
and folds the panel; everything but the result count (the live region)
1055-
hides while folded. */
1053+
/* The filter disclosure exists for small screens, and for the docked bar on
1054+
wider ones (app.js adds .is-docked once the bar sticks to the top). app.js
1055+
unhides it and folds the panel; everything but the result count (the live
1056+
region) hides while folded. */
10561057
.filters-toggle {
10571058
display: none;
10581059
}
10591060

1061+
.controls.is-docked .filters-toggle:not([hidden]) {
1062+
display: inline-flex;
1063+
align-items: center;
1064+
justify-self: start;
1065+
min-height: 36px;
1066+
padding: 0.3rem 0.8rem;
1067+
border: 1px solid var(--line);
1068+
background: transparent;
1069+
color: var(--ink);
1070+
font: inherit;
1071+
font-size: 0.9rem;
1072+
font-weight: 600;
1073+
cursor: pointer;
1074+
}
1075+
1076+
.controls.is-docked.is-collapsed #category-filters,
1077+
.controls.is-docked.is-collapsed #meta-filters > :not(.result-count) {
1078+
display: none;
1079+
}
1080+
10601081
@media (max-width: 640px) {
10611082
.filters-toggle:not([hidden]) {
10621083
display: inline-flex;

0 commit comments

Comments
 (0)