@grove-dev/astro 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +8 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/index.d.ts +0 -1
  5. package/dist/lib/index.d.ts.map +1 -1
  6. package/dist/lib/index.js +0 -1
  7. package/dist/lib/index.js.map +1 -1
  8. package/dist/ui/button.d.ts +8 -0
  9. package/dist/ui/button.d.ts.map +1 -1
  10. package/dist/ui/button.js +8 -0
  11. package/dist/ui/button.js.map +1 -1
  12. package/package.json +2 -2
  13. package/src/components/DirectoryIndexClient.astro +351 -178
  14. package/src/components/Hero.astro +15 -11
  15. package/src/components/Icon.astro +8 -0
  16. package/src/components/Pagination.astro +3 -3
  17. package/src/components/PoweredBy.astro +60 -0
  18. package/src/components/ProjectCard.astro +42 -25
  19. package/src/components/RecordHeader.astro +14 -9
  20. package/src/components/RefinePanel.astro +11 -2
  21. package/src/components/TableOfContents.astro +11 -12
  22. package/src/index.ts +8 -0
  23. package/src/layouts/BaseLayout.astro +14 -1
  24. package/src/layouts/Footer.astro +18 -2
  25. package/src/layouts/Header.astro +1 -1
  26. package/src/layouts/Seo.astro +64 -38
  27. package/src/lib/index.ts +0 -1
  28. package/src/server/collections.ts +96 -67
  29. package/src/server/index.ts +1 -0
  30. package/src/server/models.ts +456 -25
  31. package/src/server/seo.test.ts +123 -0
  32. package/src/server/seo.ts +141 -0
  33. package/src/styles.css +23 -6
  34. package/src/ui/FilterDrawer.astro +25 -5
  35. package/src/ui/SearchField.astro +58 -2
  36. package/src/ui/button.test.ts +1 -1
  37. package/src/ui/button.ts +9 -0
  38. package/dist/lib/load-collections.d.ts +0 -12
  39. package/dist/lib/load-collections.d.ts.map +0 -1
  40. package/dist/lib/load-collections.js +0 -34
  41. package/dist/lib/load-collections.js.map +0 -1
  42. package/src/lib/load-collections.ts +0 -33
@@ -1,24 +1,39 @@
1
1
  ---
2
+ /**
3
+ * DirectoryIndexClient — the browse page's client controller.
4
+ *
5
+ * The browse routes are prerendered per page (`/{slug}/`,
6
+ * `/{slug}/page/2/`), so the server never sees a query string. This
7
+ * script re-reads `location.search` and re-derives results, chips,
8
+ * facet counts, and pagination with the same `@grove-dev/core/directory`
9
+ * functions the server used.
10
+ *
11
+ * Two modes, one list:
12
+ * - **No filters** — the server-rendered page slice is already right.
13
+ * Pagination links stay the real `/page/n/` paths.
14
+ * - **Filtered** — the whole record set is filtered here, the page
15
+ * re-renders from `clientItemsJson`, and pagination switches to
16
+ * `?page=N` on the base route.
17
+ */
2
18
  interface Props {
3
- clientItemsJson: string;
4
19
  slug: string;
5
20
  singular: string;
6
21
  plural: string;
7
22
  taxonomy: Record<string, unknown>;
23
+ /** Page this route prerendered; the client starts from it. */
24
+ routePage?: number;
8
25
  }
9
26
 
10
- const { clientItemsJson, slug, singular, plural, taxonomy } = Astro.props;
11
- const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).replace(
27
+ const { slug, singular, plural, taxonomy, routePage = 1 } = Astro.props;
28
+ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy, routePage }).replace(
12
29
  /</g,
13
30
  '\\u003c',
14
31
  );
15
32
  ---
16
33
 
17
- <script is:inline id="grove-index-data" type="application/json" set:html={clientItemsJson}></script>
18
34
  <script is:inline id="grove-index-config" type="application/json" set:html={clientConfigJson}></script>
19
35
  <script>
20
36
  import {
21
- LENSES,
22
37
  PAGE_SIZE,
23
38
  activeFilterChips,
24
39
  applySort,
@@ -27,79 +42,151 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
27
42
  effectiveSort,
28
43
  filterRecords,
29
44
  filtersFromSearchParams,
30
- hrefForLens,
31
- isLensActive,
45
+ hasAnyFilter,
46
+ hrefForClearedFilters,
47
+ hrefForFilters,
48
+ pagePathHref,
32
49
  paginationPageList,
33
- removeFilter,
34
50
  searchParamsFromFilters,
35
51
  sortDisplay,
36
52
  totalPages,
37
53
  } from "@grove-dev/core/directory";
38
54
  import {
55
+ PAGINATION_DISABLED,
39
56
  PAGINATION_EXTRA,
40
57
  buttonClass,
41
58
  chipClass,
42
59
  filterTriggerClass,
43
- lensTabClass,
44
60
  } from "../ui/button.js";
45
61
 
46
62
  (() => {
47
- const source = document.querySelector("#grove-index-data");
48
63
  const configSource = document.querySelector("#grove-index-config");
49
64
  const list = document.querySelector("#results-list");
50
- if (!source || !configSource || !list) return;
65
+ if (!configSource || !list) return;
51
66
 
52
- const items = JSON.parse(source.textContent || "[]");
53
- const { slug, singular, plural, taxonomy } = JSON.parse(configSource.textContent || "{}");
54
- // `:scope >` keeps the lookup on the page's own `<li>` wrappers —
55
- // the card component inside each item also carries
56
- // `data-record-slug`, and appending/hiding the inner card instead
57
- // of its wrapper would tear the list apart.
58
- const nodes = new Map(
59
- [...list.querySelectorAll(":scope > [data-record-slug]")].map((node) => [
60
- (node as HTMLElement).dataset.recordSlug,
61
- node,
62
- ]),
67
+ const { slug, singular, plural, taxonomy, routePage } = JSON.parse(
68
+ configSource.textContent || "{}",
63
69
  );
64
- const params = new URLSearchParams(location.search);
65
- const values = (key) => params.getAll(key).filter(Boolean);
66
- const stacks = values("stack");
67
- const platforms = values("platform");
68
- const categories = values("category");
69
- const filters = filtersFromSearchParams(params);
70
- const sort = effectiveSort(filters);
71
- const requestedPage = effectivePage(filters);
70
+ // Filled by `loadRecords()`. Until then the page shows what the
71
+ // server rendered, which for an unfiltered view is already right.
72
+ let items: any[] = [];
73
+ const pathPrefix = `/${slug}`;
72
74
 
73
- function applyClientFilters() {
74
- const filtered = applySort(filterRecords(items, filters), sort);
75
- const pageCount = totalPages(filtered.length);
76
- const page = Math.min(requestedPage, pageCount);
77
- const visible = new Set(
78
- filtered
79
- .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
80
- .map((record) => record.slug),
81
- );
75
+ // Cards for this page came with the document. A filtered view can
76
+ // surface any record in the directory, so the rest are fetched
77
+ // once from `/{slug}/page/cards/` — rendered there by the same
78
+ // server component, which is what keeps the card to a single
79
+ // definition instead of a second one written in JavaScript.
80
+ const nodes = new Map<string, HTMLElement>();
81
+ for (const node of list.querySelectorAll(":scope > [data-record-slug]")) {
82
+ const key = (node as HTMLElement).dataset.recordSlug;
83
+ if (key) nodes.set(key, node as HTMLElement);
84
+ }
82
85
 
83
- // Re-append only when the visible order has changed. On every
84
- // keystroke / filter change this would otherwise thrash the
85
- // DOM order even when the sort + filter result is identical
86
- // to the previous run.
87
- const currentOrder = [...list.querySelectorAll(":scope > [data-record-slug]")].map(
88
- (node) => (node as HTMLElement).dataset.recordSlug,
89
- );
90
- const nextOrder = filtered.map((record) => record.slug);
91
- const orderChanged =
92
- currentOrder.length !== nextOrder.length ||
93
- currentOrder.some((slug, index) => slug !== nextOrder[index]);
94
- if (orderChanged) {
95
- for (const record of filtered) {
96
- const node = nodes.get(record.slug) as HTMLElement | undefined;
97
- if (node) list.append(node);
98
- }
86
+ // The record index everything filtering, sorting, and facet
87
+ // counting reads. Fetched rather than inlined so a paginated page
88
+ // does not carry the whole directory in its HTML.
89
+ let recordSource: Promise<void> | null = null;
90
+ let recordsSettled = false;
91
+ function loadRecords(): Promise<void> {
92
+ if (!recordSource) {
93
+ recordSource = fetch(`${pathPrefix}/page/records.json`)
94
+ .then((response) => (response.ok ? response.json() : []))
95
+ .then((data) => {
96
+ items = Array.isArray(data) ? data : [];
97
+ })
98
+ .catch(() => {
99
+ items = [];
100
+ })
101
+ .then(() => {
102
+ // Settled either way: a failed fetch must not leave the
103
+ // page retrying forever behind a busy list.
104
+ recordsSettled = true;
105
+ });
99
106
  }
100
- for (const [recordSlug, node] of nodes) {
101
- (node as HTMLElement).hidden = !visible.has(recordSlug);
107
+ return recordSource;
108
+ }
109
+
110
+ let cardSource: Promise<void> | null = null;
111
+ let cardsSettled = false;
112
+ function loadAllCards(): Promise<void> {
113
+ if (!cardSource) {
114
+ cardSource = fetch(`${pathPrefix}/page/cards/`)
115
+ .then((response) => (response.ok ? response.text() : ""))
116
+ .then((html) => {
117
+ if (!html) return;
118
+ const parsed = new DOMParser().parseFromString(html, "text/html");
119
+ for (const node of parsed.querySelectorAll("#grove-index-cards > [data-record-slug]")) {
120
+ const key = (node as HTMLElement).dataset.recordSlug;
121
+ if (key && !nodes.has(key)) nodes.set(key, document.adoptNode(node) as HTMLElement);
122
+ }
123
+ })
124
+ .catch(() => {
125
+ // Offline or a stale deploy: the page keeps working with
126
+ // the records it already has.
127
+ })
128
+ .then(() => {
129
+ cardsSettled = true;
130
+ });
102
131
  }
132
+ return cardSource;
133
+ }
134
+ /** Everything a filtered view needs: the index and the cards. */
135
+ const loadAll = () => Promise.all([loadRecords(), loadAllCards()]);
136
+
137
+ // Warm on the first sign of intent — a pointer or focus reaching
138
+ // the controls — rather than on idle. Prefetching a whole
139
+ // directory during load competes with the page itself for
140
+ // bandwidth on a phone, and most visitors never filter at all.
141
+ const controls = document.querySelector("#search-form")?.parentElement ?? document;
142
+ for (const type of ["pointerdown", "focusin", "keydown"]) {
143
+ controls.addEventListener(type, () => loadAll(), { once: true, passive: true });
144
+ }
145
+
146
+ let params = new URLSearchParams(location.search);
147
+ let filters = filtersFromSearchParams(params);
148
+ let sort = effectiveSort(filters);
149
+
150
+ /**
151
+ * True when the URL asks for something the prerendered document
152
+ * does not already show. `hasAnyFilter` deliberately ignores
153
+ * `sort`, but a sorted view is just as much a client view: the
154
+ * `/page/N/` documents are all built with the default sort, so
155
+ * paging out of `?sort=most-starred` onto one of them would
156
+ * silently re-sort the list mid-journey.
157
+ */
158
+ const isClientView = () => hasAnyFilter(filters) || Boolean(filters.sort);
159
+
160
+ /** Client views own the page number; prerendered ones use the path. */
161
+ const currentPage = () => (isClientView() ? effectivePage(filters) : routePage || 1);
162
+
163
+ const hrefForResultPage = (target: number) =>
164
+ isClientView()
165
+ ? hrefForFilters({ ...filters, page: target }, pathPrefix)
166
+ : pagePathHref(pathPrefix, target);
167
+
168
+ /** Move the page's cards into the list, in result order. */
169
+ function renderList(visible: { slug: string }[]) {
170
+ const next: HTMLElement[] = [];
171
+ let missing = false;
172
+ for (const record of visible) {
173
+ const node = nodes.get(record.slug);
174
+ if (node) next.push(node);
175
+ else missing = true;
176
+ }
177
+ list.replaceChildren(...next);
178
+ // A record whose card has not arrived yet: render what we have
179
+ // and finish the moment the card source lands. Only while the
180
+ // fetch is still outstanding — once it has settled, a missing
181
+ // card is missing, and retrying would spin.
182
+ if (missing && !cardsSettled) loadAllCards().then(() => applyClientFilters());
183
+ }
184
+
185
+ function applyClientFilters() {
186
+ const filtered = applySort(filterRecords(items, filters), sort);
187
+ const pageCount = totalPages(filtered.length);
188
+ const page = Math.min(Math.max(1, currentPage()), pageCount);
189
+ renderList(filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
103
190
 
104
191
  const count = document.querySelector("#results-count");
105
192
  const pageLabel = document.querySelector("#results-page");
@@ -119,22 +206,18 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
119
206
  pagination.innerHTML = "";
120
207
  if (pageCount > 1) {
121
208
  const linkClass = buttonClass("secondary", "sm", PAGINATION_EXTRA);
122
- const hrefFor = (target: number) => {
123
- const next = searchParamsFromFilters({ ...filters, page: target });
124
- return `/${slug}${next.size ? `?${next}` : ""}`;
125
- };
126
209
  const nav = document.createElement("nav");
127
210
  nav.className = "flex flex-wrap items-center justify-center gap-1.5";
128
211
  nav.setAttribute("aria-label", "Pagination");
129
212
  const control = (label: string, target: number | null, rel?: string) => {
130
213
  if (target === null) {
131
214
  const span = document.createElement("span");
132
- span.className = `${linkClass} pointer-events-none opacity-40`;
215
+ span.className = `${linkClass} ${PAGINATION_DISABLED}`;
133
216
  span.textContent = label;
134
217
  return span;
135
218
  }
136
219
  const link = document.createElement("a");
137
- link.href = hrefFor(target);
220
+ link.href = hrefForResultPage(target);
138
221
  link.textContent = label;
139
222
  link.className = linkClass;
140
223
  if (rel) link.rel = rel;
@@ -165,21 +248,12 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
165
248
  }
166
249
 
167
250
  const searchInput = document.querySelector("#search-input");
251
+ const searchClear = document.querySelector("[data-search-clear]");
168
252
  if (searchInput instanceof HTMLInputElement) searchInput.value = filters.q || "";
169
253
  document.querySelectorAll('select[name="sort"]').forEach((select) => {
170
254
  if (select instanceof HTMLSelectElement) select.value = sort;
171
255
  });
172
256
 
173
- document.querySelector("#search-form")?.addEventListener("submit", (event) => {
174
- event.preventDefault();
175
- const next = new URLSearchParams(params);
176
- const query = searchInput instanceof HTMLInputElement ? searchInput.value.trim() : "";
177
- if (query) next.set("q", query);
178
- else next.delete("q");
179
- next.set("sort", sort);
180
- next.delete("page");
181
- location.assign(`/${slug}${next.size ? `?${next}` : ""}`);
182
- });
183
257
  const filterKeys = {
184
258
  stacks: "stack",
185
259
  platforms: "platform",
@@ -205,89 +279,82 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
205
279
  (taxonomy[taxonomyKinds[groupKey]] || []).find((entry) => entry.id === value)?.name ??
206
280
  value;
207
281
 
208
- // This route is prerendered, so Astro cannot see the browser's query
209
- // string. Rebuild active-filter controls from the live URL instead of
210
- // leaving the server-rendered (empty) state in place.
211
- const activeFilters = document.querySelector("#active-filters");
212
- if (activeFilters) {
213
- activeFilters.innerHTML = "";
214
- const chips = activeFilterChips(filters).map((chip) => ({
215
- ...chip,
216
- label: chip.key === "stacks"
217
- ? `Stack: ${taxonomyName("stacks", chip.value)}`
218
- : chip.key === "platforms"
219
- ? `Platform: ${taxonomyName("platforms", chip.value)}`
220
- : chip.key === "categories"
221
- ? `Category: ${taxonomyName("categories", chip.value)}`
222
- : chip.key === "tags"
223
- ? `Tag: ${taxonomyName("tags", chip.value)}`
224
- : chip.key === "licenses"
225
- ? `License: ${taxonomyName("licenses", chip.value)}`
226
- : chip.label,
227
- }));
228
-
229
- for (const chip of chips) {
230
- const next = searchParamsFromFilters(removeFilter(filters, chip.key, chip.value));
231
- const link = document.createElement("a");
232
- link.href = `/${slug}${next.size ? `?${next}` : ""}`;
233
- // The whole anchor is the remove control; the visible label
234
- // announces the filter value, and the aria-label spells out
235
- // the action for assistive tech.
236
- link.setAttribute("aria-label", `Remove filter: ${chip.label}`);
237
- link.className = chipClass();
238
- link.append(document.createTextNode(`${chip.label} `));
239
- const close = document.createElement("span");
240
- close.setAttribute("aria-hidden", "true");
241
- close.textContent = "x";
242
- link.append(close);
243
- activeFilters.append(link);
244
- }
245
- if (chips.length) {
246
- const clear = document.createElement("a");
247
- const clearParams = searchParamsFromFilters({ sort });
248
- clear.href = `/${slug}${clearParams.size ? `?${clearParams}` : ""}`;
249
- clear.className = "text-2xs font-medium text-ink-500 hover:text-ink-900";
250
- clear.setAttribute("aria-label", "Clear all active filters");
251
- clear.textContent = "Clear all";
252
- activeFilters.append(clear);
282
+ /** Chip labels and remove hrefs come from core one definition. */
283
+ function renderChips() {
284
+ const activeFilters = document.querySelector("#active-filters");
285
+ const chips = activeFilterChips(filters, { taxonomy, pathPrefix });
286
+ if (activeFilters) {
287
+ activeFilters.innerHTML = "";
288
+ for (const chip of chips) {
289
+ const link = document.createElement("a");
290
+ link.href = chip.href;
291
+ // The whole anchor is the remove control; the visible label
292
+ // announces the filter value, and the aria-label spells out
293
+ // the action for assistive tech.
294
+ link.setAttribute("aria-label", `Remove filter: ${chip.label}`);
295
+ link.className = chipClass();
296
+ link.append(document.createTextNode(`${chip.label} `));
297
+ const close = document.createElement("span");
298
+ close.setAttribute("aria-hidden", "true");
299
+ close.textContent = "×";
300
+ link.append(close);
301
+ activeFilters.append(link);
302
+ }
303
+ if (chips.length) {
304
+ const clear = document.createElement("a");
305
+ clear.href = hrefForClearedFilters(filters, pathPrefix);
306
+ clear.className =
307
+ "text-2xs font-medium text-ink-500 underline decoration-border underline-offset-2 hover:text-ink-900 dark:hover:text-ink-100";
308
+ clear.setAttribute("aria-label", "Clear all active filters");
309
+ clear.textContent = "Clear all";
310
+ activeFilters.append(clear);
311
+ }
312
+ activeFilters.classList.toggle("hidden", chips.length === 0);
253
313
  }
254
- activeFilters.classList.toggle("hidden", chips.length === 0);
255
314
 
256
- // Mobile filter-drawer badge mirrors the live chip count.
315
+ // Mobile filter-drawer badge + its clear-all mirror the chips.
257
316
  const drawerCount = document.querySelector("[data-drawer-count]");
258
317
  if (drawerCount) {
259
318
  drawerCount.textContent = String(chips.length);
260
319
  drawerCount.classList.toggle("hidden", chips.length === 0);
261
320
  }
321
+ const drawerClear = document.querySelector("[data-drawer-clear]");
322
+ if (drawerClear instanceof HTMLAnchorElement) {
323
+ drawerClear.href = hrefForClearedFilters(filters, pathPrefix);
324
+ drawerClear.classList.toggle("hidden", chips.length === 0);
325
+ }
262
326
  }
263
327
 
264
- document.querySelectorAll(".grove-filter").forEach((group) => {
265
- if (!(group instanceof HTMLElement)) return;
266
- const selected = values(filterKeys[group.dataset.filterGroupKey]);
267
- group.querySelectorAll(".grove-filter-input").forEach((input) => {
268
- if (input instanceof HTMLInputElement) {
269
- input.checked = selected.includes(input.dataset.filterValue || "");
328
+ function renderFilterTriggers() {
329
+ const values = (key: string) => params.getAll(key).filter(Boolean);
330
+ document.querySelectorAll(".grove-filter").forEach((group) => {
331
+ if (!(group instanceof HTMLElement)) return;
332
+ const selected = values(filterKeys[group.dataset.filterGroupKey]);
333
+ group.querySelectorAll(".grove-filter-input").forEach((input) => {
334
+ if (input instanceof HTMLInputElement) {
335
+ input.checked = selected.includes(input.dataset.filterValue || "");
336
+ }
337
+ });
338
+ const triggerButton = group.querySelector(".grove-filter-trigger");
339
+ const trigger = triggerButton?.querySelector("span");
340
+ const label = filterLabels[group.dataset.filterGroupKey];
341
+ if (trigger) {
342
+ trigger.textContent = selected.length
343
+ ? `${label}: ${
344
+ selected.length === 1
345
+ ? taxonomyName(group.dataset.filterGroupKey, selected[0])
346
+ : `${selected.length} selected`
347
+ }`
348
+ : `${label}: Any`;
349
+ }
350
+ if (triggerButton) {
351
+ // Same builder the server render used — the class string is
352
+ // reassigned wholesale so the two can never drift.
353
+ triggerButton.className = filterTriggerClass(selected.length > 0);
270
354
  }
355
+ group.querySelector(".grove-filter-clear")?.classList.toggle("hidden", selected.length === 0);
271
356
  });
272
- const triggerButton = group.querySelector(".grove-filter-trigger");
273
- const trigger = triggerButton?.querySelector("span");
274
- const label = filterLabels[group.dataset.filterGroupKey];
275
- if (trigger) {
276
- trigger.textContent = selected.length
277
- ? `${label}: ${
278
- selected.length === 1
279
- ? taxonomyName(group.dataset.filterGroupKey, selected[0])
280
- : `${selected.length} selected`
281
- }`
282
- : `${label}: Any`;
283
- }
284
- if (triggerButton) {
285
- // Same builder the server render used — the class string is
286
- // reassigned wholesale so the two can never drift.
287
- triggerButton.className = filterTriggerClass(selected.length > 0);
288
- }
289
- group.querySelector(".grove-filter-clear")?.classList.toggle("hidden", selected.length === 0);
290
- });
357
+ }
291
358
 
292
359
  // Facet counts were rendered at build time from the UNFILTERED
293
360
  // record set (the route is prerendered, so the server never sees
@@ -295,43 +362,149 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
295
362
  // the current filters and patch each count span in place —
296
363
  // never re-render the option lists, which would destroy
297
364
  // checkbox, focus, and scroll state.
298
- const liveFacets: Record<string, { value: string; count: number }[]> = buildFacets(items, {
299
- filters,
300
- curatedTagIds: (taxonomy.topics || []).map((topic: { id: string }) => topic.id),
365
+ function renderFacetCounts() {
366
+ const liveFacets: Record<string, { value: string; count: number }[]> = buildFacets(items, {
367
+ filters,
368
+ curatedTagIds: (taxonomy.topics || []).map((topic: { id: string }) => topic.id),
369
+ });
370
+ document.querySelectorAll("[data-facet-count]").forEach((span) => {
371
+ if (!(span instanceof HTMLElement)) return;
372
+ const options = liveFacets[span.dataset.facet ?? ""] ?? [];
373
+ const count = options.find((option) => option.value === span.dataset.value)?.count ?? 0;
374
+ span.textContent = String(count);
375
+ const row = span.closest("label");
376
+ const input = row?.querySelector(".grove-filter-input");
377
+ const checked = input instanceof HTMLInputElement && input.checked;
378
+ // A zero-count option can't narrow anything — dim and disable
379
+ // it unless it's the currently selected value (which must stay
380
+ // interactive so the user can un-select it).
381
+ const inert = count === 0 && !checked;
382
+ row?.classList.toggle("opacity-45", inert);
383
+ if (input instanceof HTMLInputElement) input.disabled = inert;
384
+ });
385
+ }
386
+
387
+ function renderAll() {
388
+ renderChips();
389
+ renderFilterTriggers();
390
+ if (searchClear instanceof HTMLElement) searchClear.hidden = !(filters.q || "").length;
391
+
392
+ if (recordsSettled) {
393
+ renderFacetCounts();
394
+ applyClientFilters();
395
+ return;
396
+ }
397
+ // Nothing to filter with yet. A plain view of this page is
398
+ // already correct as the server rendered it; anything the URL
399
+ // asks for beyond that — a filter, a search, a sort — waits for
400
+ // the index and the cards, and says so while it does.
401
+ if (!isClientView()) return;
402
+ list.setAttribute("aria-busy", "true");
403
+ loadAll().then(() => {
404
+ list.removeAttribute("aria-busy");
405
+ renderAll();
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Adopt a URL without a navigation, then re-render from it.
411
+ *
412
+ * A URL with no query is a prerendered document — clearing the
413
+ * last filter lands on `/{slug}/`, which on page 2+ is a
414
+ * *different* document than the one on screen. Adopting it there
415
+ * would leave records 21–40 rendered under the page-1 URL, so
416
+ * that case navigates for real.
417
+ */
418
+ function adopt(url: string, mode: "push" | "replace") {
419
+ const next = new URL(url, location.origin);
420
+ const samePath =
421
+ next.pathname.replace(/\/$/, "") === location.pathname.replace(/\/$/, "");
422
+ if (!next.search && (!samePath || (routePage || 1) > 1)) {
423
+ location.assign(next.toString());
424
+ return;
425
+ }
426
+ const state = { grove: true, scrollY: window.scrollY };
427
+ if (mode === "push") history.pushState(state, "", next);
428
+ else history.replaceState(state, "", next);
429
+ params = new URLSearchParams(next.search);
430
+ filters = filtersFromSearchParams(params);
431
+ sort = effectiveSort(filters);
432
+ renderAll();
433
+ }
434
+
435
+ // Search filters as you type. `replaceState` keeps a search
436
+ // session out of the history stack — Back should return to the
437
+ // previous result set, not to the previous keystroke.
438
+ let debounce = 0;
439
+ searchInput?.addEventListener("input", () => {
440
+ window.clearTimeout(debounce);
441
+ debounce = window.setTimeout(() => {
442
+ const query = searchInput instanceof HTMLInputElement ? searchInput.value.trim() : "";
443
+ adopt(hrefForFilters({ ...filters, q: query || undefined, page: 1 }, pathPrefix), "replace");
444
+ if (searchInput instanceof HTMLInputElement) searchInput.focus();
445
+ }, 150);
446
+ });
447
+ searchClear?.addEventListener("click", () => {
448
+ if (searchInput instanceof HTMLInputElement) {
449
+ searchInput.value = "";
450
+ searchInput.focus();
451
+ }
452
+ adopt(hrefForFilters({ ...filters, q: undefined, page: 1 }, pathPrefix), "replace");
301
453
  });
302
- document.querySelectorAll("[data-facet-count]").forEach((span) => {
303
- if (!(span instanceof HTMLElement)) return;
304
- const options = liveFacets[span.dataset.facet ?? ""] ?? [];
305
- const count = options.find((option) => option.value === span.dataset.value)?.count ?? 0;
306
- span.textContent = String(count);
307
- const row = span.closest("label");
308
- const input = row?.querySelector(".grove-filter-input");
309
- const checked = input instanceof HTMLInputElement && input.checked;
310
- // A zero-count option can't narrow anything — dim and disable
311
- // it unless it's the currently selected value (which must stay
312
- // interactive so the user can un-select it).
313
- const inert = count === 0 && !checked;
314
- row?.classList.toggle("opacity-45", inert);
315
- if (input instanceof HTMLInputElement) input.disabled = inert;
454
+ document.querySelector("#search-form")?.addEventListener("submit", (event) => {
455
+ // Enter already has its answer on screen; submitting would only
456
+ // reload the same state.
457
+ event.preventDefault();
316
458
  });
317
459
 
318
- let activeLens = LENSES[0];
319
- document.querySelectorAll("[data-lens-id]").forEach((node) => {
320
- if (!(node instanceof HTMLAnchorElement)) return;
321
- const lens = LENSES.find((candidate) => candidate.id === node.dataset.lensId);
322
- if (!lens) return;
323
- const active = isLensActive(lens.id, params);
324
- node.href = hrefForLens(lens.id, params, `/${slug}`);
325
- if (active) node.setAttribute("aria-current", "page");
326
- else node.removeAttribute("aria-current");
327
- node.className = lensTabClass(active);
328
- if (active) activeLens = lens;
460
+ // RefinePanel asks before it navigates; cancelling means "this
461
+ // page can apply the URL in place".
462
+ document.addEventListener("grove:navigate", (event) => {
463
+ const url = (event as CustomEvent<{ url: string }>).detail?.url;
464
+ if (!url) return;
465
+ event.preventDefault();
466
+ adopt(url, "push");
467
+ });
468
+
469
+ // Filtered navigation is a real step: Back returns to the
470
+ // previous result set and its scroll position.
471
+ //
472
+ // Only links that carry a query are adopted. Everything else that
473
+ // points at this list — `/{slug}/` from "Clear all" or the "1"
474
+ // pagination link, `/{slug}/page/2/`, the header's "Browse" nav —
475
+ // is a prerendered document and must actually navigate;
476
+ // swallowing those left page 2's records rendered under the
477
+ // page-1 URL. Hash links (the skip link) are never ours.
478
+ document.addEventListener("click", (event) => {
479
+ const link = (event.target as HTMLElement | null)?.closest("a");
480
+ if (!(link instanceof HTMLAnchorElement)) return;
481
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey) return;
482
+ if (link.target === "_blank" || !link.href.startsWith(location.origin)) return;
483
+ const url = new URL(link.href);
484
+ if (url.hash) return;
485
+ const isListUrl = url.pathname === `${pathPrefix}/` || url.pathname === pathPrefix;
486
+ if (!isListUrl || !url.search) return;
487
+ event.preventDefault();
488
+ history.replaceState({ grove: true, scrollY: window.scrollY }, "");
489
+ adopt(url.toString(), "push");
490
+ window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior });
491
+ });
492
+
493
+ window.addEventListener("popstate", (event) => {
494
+ params = new URLSearchParams(location.search);
495
+ filters = filtersFromSearchParams(params);
496
+ sort = effectiveSort(filters);
497
+ if (searchInput instanceof HTMLInputElement) searchInput.value = filters.q || "";
498
+ document.querySelectorAll('select[name="sort"]').forEach((select) => {
499
+ if (select instanceof HTMLSelectElement) select.value = sort;
500
+ });
501
+ renderAll();
502
+ const state = event.state as { scrollY?: number } | null;
503
+ if (typeof state?.scrollY === "number") {
504
+ window.scrollTo({ top: state.scrollY, behavior: "instant" as ScrollBehavior });
505
+ }
329
506
  });
330
- const heading = document.querySelector("#results-heading");
331
- const description = document.querySelector("#results-description");
332
- if (heading) heading.textContent = activeLens.id === "all" ? `All ${plural}` : activeLens.label;
333
- if (description) description.textContent = activeLens.description || `Every ${singular} in the directory.`;
334
507
 
335
- applyClientFilters();
508
+ renderAll();
336
509
  })();
337
510
  </script>