Skip to content

Commit c35f772

Browse files
TMHSDigitalclaude
andcommitted
site: category grouping, chip counts and wayfinding (batch L)
Navigation QoL. The catalog was one flat list of 298 rows with no way to tell where you were in it. Fixes a real defect first: `.topnav a:nth-child(-n + 3) { display: none }` hid Catalog, Starters and Guides below 640px, so the mobile primary nav contained only "GitHub". The links now shrink to fit instead; all four are visible at 375px with no horizontal overflow. Added: Category grouping Headings with counts between rows, prerendered by build.mjs and reproduced identically on hydrate. Chip counts Faceted: each chip shows how many entries that category yields under the *other* active filters, so the numbers move as you filter rather than reading 0 for every category except the selected one. Scroll position The chip for the section you are scrolled into gets a quiet marker plus aria-current. Back to top Appears past 600px, 44px target, respects prefers-reduced-motion, returns focus to the search box. Keyboard "/" focuses search, Escape clears it. Both suppressed while typing in a field or with the dialog open. Grouping is conditional on purpose: default sort, no category filter. Sorting by verified date or license inside category buckets would hide the ordering the reader just asked for, so those render flat. Chips stay pure filters and the scroll marker is visually distinct from the active-filter state, because one control carrying two meanings is how a filter row stops being readable. Without JavaScript the chips cannot filter, so the prerender emits them as jump links to the group headings and app.js swaps in real buttons on hydrate. The no-JS page gains navigation it did not have. Anchored targets used to land underneath the sticky filter bar, which the chip links and #entry- permalinks made worse. The bar's height varies with wrapping, so app.js measures it into --sticky-h and CSS scroll-margin-top uses it, with a fallback for the no-JS page. Verified in a browser at 1200px and 375px, both colour schemes: grouping and counts correct and responsive to other filters, grouping falls back on sort and category change, scroll-spy tracks the right section, anchors clear the sticky bar, "/" and Escape behave and do not hijack typing, all four nav links visible on mobile, no horizontal overflow, every control at least 44px, WCAG AA on all new text in light (worst 5.29) and dark (worst 7.52), no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ
1 parent 9239988 commit c35f772

4 files changed

Lines changed: 323 additions & 9 deletions

File tree

‎site/build.mjs‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,56 @@ function entryRowHtml(entry, repo, now) {
254254
</article>`;
255255
}
256256

257+
/**
258+
* The catalog grouped under category headings. 300 rows in one flat list is
259+
* not navigable; headings give the page structure a reader can scan and an
260+
* anchor they can link to. app.js reproduces this exact shape on hydrate.
261+
*
262+
* Grouping is only correct under the default sort. Sorting by verified date
263+
* or license across category buckets would be meaningless, so app.js falls
264+
* back to a flat list in those cases; the prerender is always the default.
265+
*/
266+
function groupedRowsHtml(entries, categories, repo, now) {
267+
const order = Object.keys(categories);
268+
const seen = new Set(entries.map((e) => e.category));
269+
const out = [];
270+
for (const cat of order) {
271+
if (!seen.has(cat)) continue;
272+
const group = entries.filter((e) => e.category === cat);
273+
if (!group.length) continue;
274+
const label = categories[cat]?.label || cat;
275+
out.push(
276+
`<h3 class="group-heading" id="group-${esc(cat)}" data-cat="${esc(cat)}">` +
277+
`<span class="group-name">${esc(label)}</span>` +
278+
`<span class="group-count">${group.length}</span>` +
279+
`</h3>`
280+
);
281+
out.push(...group.map((e) => entryRowHtml(e, repo, now)));
282+
}
283+
return out.join("\n");
284+
}
285+
286+
/**
287+
* Without JavaScript the chips cannot filter, so the prerender emits them as
288+
* jump links to the group headings instead. app.js replaces them with real
289+
* filter buttons on hydrate.
290+
*/
291+
function categoryChipsHtml(entries, categories) {
292+
const counts = new Map();
293+
for (const e of entries) counts.set(e.category, (counts.get(e.category) || 0) + 1);
294+
const chips = [
295+
`<a class="chip" href="#catalog">All <span class="chip-count">${entries.length}</span></a>`,
296+
];
297+
for (const [cat, meta] of Object.entries(categories)) {
298+
const n = counts.get(cat) || 0;
299+
if (!n) continue;
300+
chips.push(
301+
`<a class="chip" href="#group-${esc(cat)}">${esc(meta.label || cat)} <span class="chip-count">${n}</span></a>`
302+
);
303+
}
304+
return chips.join("");
305+
}
306+
257307
function starterRowsHtml(featured) {
258308
return featured
259309
.map(
@@ -406,9 +456,8 @@ function main() {
406456
const substitutions = {
407457
HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt),
408458
STARTER_ROWS: starterRowsHtml(featured),
409-
ENTRY_ROWS: visible
410-
.map((e) => entryRowHtml(e, repoUrl, now))
411-
.join("\n"),
459+
ENTRY_ROWS: groupedRowsHtml(visible, config.categories, repoUrl, now),
460+
CATEGORY_CHIPS: categoryChipsHtml(visible, config.categories),
412461
GUIDE_ROWS: guideRowsHtml(config.guides, repoUrl),
413462
CATALOG_BLURB: `${stats.total} sources &middot; ${stats.active} active &middot; ${stats.commercialOk} commercial-ok &middot; ${stats.commercialVaries} per-file &middot; ${stats.deprecated} deprecated`,
414463
RESULT_COUNT: `${visible.length} / ${stats.total}`,

‎site/public/app.js‎

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,31 @@
192192
</article>`;
193193
}
194194

195+
/**
196+
* Grouping is only meaningful under the default sort with no category
197+
* filter. Sorting by verified date or license inside category buckets would
198+
* hide the very ordering the reader asked for, so those fall back to a flat
199+
* list. The prerendered page is always the grouped default.
200+
*/
201+
function shouldGroup() {
202+
return state.sort === "name" && state.category === "all";
203+
}
204+
205+
/**
206+
* How many entries each category would yield under the *other* active
207+
* filters. Counting with the category filter applied would make every chip
208+
* read 0 except the selected one, which tells the reader nothing.
209+
*/
210+
function facetCounts() {
211+
const saved = state.category;
212+
state.category = "all";
213+
const pool = data.entries.filter(matches);
214+
state.category = saved;
215+
const counts = new Map();
216+
for (const e of pool) counts.set(e.category, (counts.get(e.category) || 0) + 1);
217+
return { counts, total: pool.length };
218+
}
219+
195220
function renderCategoryFilters() {
196221
const host = $("#category-filters");
197222
const buttons = [
@@ -204,7 +229,7 @@
204229
host.innerHTML = buttons
205230
.map(
206231
(b) =>
207-
`<button type="button" class="chip" data-cat="${escapeHtml(b.id)}" aria-pressed="false">${escapeHtml(b.label)}</button>`
232+
`<button type="button" class="chip" data-cat="${escapeHtml(b.id)}" aria-pressed="false">${escapeHtml(b.label)} <span class="chip-count" data-count-for="${escapeHtml(b.id)}"></span></button>`
208233
)
209234
.join("");
210235
host.addEventListener("click", (e) => {
@@ -216,10 +241,15 @@
216241
}
217242

218243
function syncCategoryChips() {
244+
const { counts, total } = facetCounts();
219245
$$("#category-filters .chip").forEach((chip) => {
220-
const on = chip.getAttribute("data-cat") === state.category;
246+
const cat = chip.getAttribute("data-cat");
247+
const on = cat === state.category;
221248
chip.classList.toggle("is-active", on);
222249
chip.setAttribute("aria-pressed", on ? "true" : "false");
250+
const slot = chip.querySelector(".chip-count");
251+
if (slot) slot.textContent = cat === "all" ? String(total) : String(counts.get(cat) || 0);
252+
chip.classList.toggle("is-empty", cat !== "all" && !(counts.get(cat) || 0));
223253
});
224254
}
225255

@@ -272,10 +302,83 @@
272302
if (!list.length) {
273303
grid.innerHTML = "";
274304
empty.hidden = false;
305+
groupHeadings = [];
275306
return;
276307
}
277308
empty.hidden = true;
278-
grid.innerHTML = list.map(cardHtml).join("");
309+
310+
if (!shouldGroup()) {
311+
grid.innerHTML = list.map(cardHtml).join("");
312+
groupHeadings = [];
313+
syncSpy();
314+
return;
315+
}
316+
317+
const out = [];
318+
for (const cat of Object.keys(categoryLabels)) {
319+
const group = list.filter((e) => e.category === cat);
320+
if (!group.length) continue;
321+
const label = categoryLabels[cat]?.label || cat;
322+
out.push(
323+
`<h3 class="group-heading" id="group-${escapeHtml(cat)}" data-cat="${escapeHtml(cat)}">` +
324+
`<span class="group-name">${escapeHtml(label)}</span>` +
325+
`<span class="group-count">${group.length}</span>` +
326+
`</h3>`
327+
);
328+
out.push(...group.map(cardHtml));
329+
}
330+
grid.innerHTML = out.join("");
331+
groupHeadings = $$("#entry-grid .group-heading");
332+
syncSpy();
333+
}
334+
335+
/* ------------------------------------------------- scroll position */
336+
337+
let groupHeadings = [];
338+
let currentGroup = null;
339+
let ticking = false;
340+
341+
/**
342+
* Marks which category you are currently scrolled into. The chips stay pure
343+
* filters; this is a separate, quieter signal, because one control carrying
344+
* two meanings is how a filter row stops being readable.
345+
*/
346+
function syncSpy() {
347+
const controls = $(".controls");
348+
// Anchored jumps (chip links, #entry- permalinks) would otherwise land
349+
// underneath the sticky filter bar. Its height varies with wrapping, so
350+
// publish the measured value and let CSS scroll-margin use it.
351+
if (controls) {
352+
document.documentElement.style.setProperty(
353+
"--sticky-h",
354+
`${Math.round(controls.getBoundingClientRect().height)}px`
355+
);
356+
}
357+
const cutoff = (controls ? controls.getBoundingClientRect().bottom : 0) + 8;
358+
let found = null;
359+
for (const h of groupHeadings) {
360+
if (h.getBoundingClientRect().top <= cutoff) found = h.getAttribute("data-cat");
361+
else break;
362+
}
363+
if (found === currentGroup) return;
364+
currentGroup = found;
365+
$$("#category-filters .chip").forEach((chip) => {
366+
const on = found !== null && chip.getAttribute("data-cat") === found;
367+
chip.classList.toggle("is-current", on);
368+
if (on) chip.setAttribute("aria-current", "true");
369+
else chip.removeAttribute("aria-current");
370+
});
371+
}
372+
373+
function onScroll() {
374+
if (ticking) return;
375+
ticking = true;
376+
requestAnimationFrame(() => {
377+
ticking = false;
378+
syncSpy();
379+
const btn = $("#to-top");
380+
if (btn) btn.hidden = window.scrollY < 600;
381+
});
279382
}
280383

281384
function apply() {
@@ -451,6 +554,39 @@
451554
apply();
452555
});
453556

557+
const top = $("#to-top");
558+
top.addEventListener("click", () => {
559+
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
560+
window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
561+
search.focus();
562+
});
563+
564+
window.addEventListener("scroll", onScroll, { passive: true });
565+
window.addEventListener("resize", onScroll, { passive: true });
566+
567+
// "/" focuses search, Escape leaves it. Suppressed whenever the user is
568+
// already typing somewhere or the entry dialog has focus.
569+
document.addEventListener("keydown", (e) => {
570+
const el = document.activeElement;
571+
const typing =
572+
el &&
573+
(el.tagName === "INPUT" ||
574+
el.tagName === "TEXTAREA" ||
575+
el.tagName === "SELECT" ||
576+
el.isContentEditable);
577+
if (e.key === "/" && !typing && !$("#entry-dialog").open) {
578+
e.preventDefault();
579+
search.focus();
580+
search.select();
581+
return;
582+
}
583+
if (e.key === "Escape" && el === search && search.value) {
584+
search.value = "";
585+
state.q = "";
586+
apply();
587+
}
588+
});
589+
454590
$("#repo-link").href = repo;
455591
$("#footer-repo").href = repo;
456592
}

‎site/public/index.html‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ <h2 id="catalog-title">Catalog</h2>
9090
placeholder="Name, license, tag, format…"
9191
autocomplete="off"
9292
/>
93+
<kbd class="search-key" aria-hidden="true">/</kbd>
9394
</label>
9495

95-
<div class="filter-row" id="category-filters" role="group" aria-label="Filter by category"></div>
96+
<div class="filter-row" id="category-filters" role="group" aria-label="Filter by category"><!--CATEGORY_CHIPS--></div>
9697

9798
<div class="filter-row meta-filters" role="group" aria-label="Filter by status and terms">
9899
<label class="check">
@@ -168,6 +169,10 @@ <h2 id="guides-title">Guides</h2>
168169
<p class="footer-stamp"><!--FOOTER_STAMP--></p>
169170
</footer>
170171

172+
<button type="button" class="to-top" id="to-top" hidden>
173+
<span aria-hidden="true">↑</span> Top
174+
</button>
175+
171176
<dialog class="entry-dialog" id="entry-dialog" aria-labelledby="dialog-title">
172177
<form method="dialog" class="dialog-chrome">
173178
<button value="close" class="dialog-close" aria-label="Close">Close</button>

0 commit comments

Comments
 (0)