@grove-dev/astro 0.5.4 → 0.6.1
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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +8 -0
- package/dist/ui/button.d.ts.map +1 -1
- package/dist/ui/button.js +8 -0
- package/dist/ui/button.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DirectoryIndexClient.astro +351 -178
- package/src/components/Hero.astro +15 -11
- package/src/components/Pagination.astro +3 -3
- package/src/components/PoweredBy.astro +60 -0
- package/src/components/ProjectCard.astro +42 -25
- package/src/components/RecordHeader.astro +14 -9
- package/src/components/RefinePanel.astro +11 -2
- package/src/components/TableOfContents.astro +11 -12
- package/src/index.ts +8 -0
- package/src/layouts/BaseLayout.astro +14 -1
- package/src/layouts/Footer.astro +18 -2
- package/src/layouts/Header.astro +1 -1
- package/src/layouts/Seo.astro +64 -38
- package/src/lib/index.ts +0 -1
- package/src/server/collections.ts +96 -67
- package/src/server/index.ts +1 -0
- package/src/server/models.ts +456 -25
- package/src/server/seo.test.ts +123 -0
- package/src/server/seo.ts +141 -0
- package/src/styles.css +23 -6
- package/src/ui/FilterDrawer.astro +25 -5
- package/src/ui/SearchField.astro +58 -2
- package/src/ui/button.test.ts +1 -1
- package/src/ui/button.ts +9 -0
- package/dist/lib/load-collections.d.ts +0 -12
- package/dist/lib/load-collections.d.ts.map +0 -1
- package/dist/lib/load-collections.js +0 -34
- package/dist/lib/load-collections.js.map +0 -1
- 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 {
|
|
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
|
-
|
|
31
|
-
|
|
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 (!
|
|
65
|
+
if (!configSource || !list) return;
|
|
51
66
|
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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}
|
|
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 =
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
if (!
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
508
|
+
renderAll();
|
|
336
509
|
})();
|
|
337
510
|
</script>
|