@grove-dev/astro 0.7.0 → 0.9.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.
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -33
- package/dist/index.js.map +1 -1
- package/dist/lib/display.d.ts +1 -1
- package/dist/lib/display.js +1 -1
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/icon-kinds.d.ts +1 -1
- package/dist/lib/icon-kinds.js +43 -43
- package/dist/lib/icon-registry.d.ts +2 -2
- package/dist/lib/icon-registry.js +23 -23
- package/dist/lib/index.d.ts +9 -9
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +9 -9
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/lenses.d.ts +1 -1
- package/dist/lib/lenses.js +1 -1
- package/dist/lib/packaged-icons.d.ts +1 -1
- package/dist/lib/packaged-icons.js +4 -4
- package/dist/lib/repo.d.ts +1 -1
- package/dist/lib/repo.js +1 -1
- package/dist/lib/search.d.ts +1 -1
- package/dist/lib/search.js +1 -1
- package/dist/lib/taxonomy-counts.d.ts +1 -1
- package/dist/lib/taxonomy-counts.js +1 -1
- package/package.json +7 -17
- package/src/index.ts +23 -38
- package/src/lib/display.ts +1 -1
- package/src/lib/format.ts +1 -1
- package/src/lib/icon-kinds.ts +44 -44
- package/src/lib/icon-registry.ts +25 -25
- package/src/lib/index.ts +9 -9
- package/src/lib/lenses.ts +1 -1
- package/src/lib/packaged-icons.ts +8 -8
- package/src/lib/repo.ts +1 -1
- package/src/lib/search.ts +1 -1
- package/src/lib/taxonomy-counts.ts +1 -1
- package/src/server/collections.ts +53 -42
- package/src/server/contrast.ts +14 -9
- package/src/server/directory.ts +22 -17
- package/src/server/index.ts +3 -2
- package/src/server/models.ts +425 -218
- package/src/server/seo.ts +34 -31
- package/src/styles.css +235 -91
- package/dist/layouts/index.d.ts +0 -25
- package/dist/layouts/index.d.ts.map +0 -1
- package/dist/layouts/index.js +0 -25
- package/dist/layouts/index.js.map +0 -1
- package/dist/ui/button.d.ts +0 -53
- package/dist/ui/button.d.ts.map +0 -1
- package/dist/ui/button.js +0 -90
- package/dist/ui/button.js.map +0 -1
- package/src/components/CardGrid.astro +0 -41
- package/src/components/CardIcon.astro +0 -67
- package/src/components/CategoryGrid.astro +0 -114
- package/src/components/CollectionCard.astro +0 -56
- package/src/components/CollectionIndex.astro +0 -102
- package/src/components/CollectionPage.astro +0 -128
- package/src/components/CollectionRow.astro +0 -81
- package/src/components/CollectionTeaser.astro +0 -47
- package/src/components/ContributorsGrid.astro +0 -143
- package/src/components/DirectoryIndexClient.astro +0 -510
- package/src/components/EditorialSummary.astro +0 -58
- package/src/components/FilterGroupMenu.astro +0 -69
- package/src/components/FilterOptions.astro +0 -49
- package/src/components/FinalCta.astro +0 -43
- package/src/components/Hero.astro +0 -243
- package/src/components/Icon.astro +0 -191
- package/src/components/IndexRow.astro +0 -37
- package/src/components/LanguageBreakdown.astro +0 -67
- package/src/components/MarkdownBody.astro +0 -37
- package/src/components/OriginalCollection.astro +0 -117
- package/src/components/Pagination.astro +0 -50
- package/src/components/PoweredBy.astro +0 -60
- package/src/components/ProjectCard.astro +0 -321
- package/src/components/RecordHeader.astro +0 -221
- package/src/components/RecordSection.astro +0 -128
- package/src/components/RecordSidebar.astro +0 -387
- package/src/components/RefinePanel.astro +0 -168
- package/src/components/SmartLensTabs.astro +0 -81
- package/src/components/StackGrid.astro +0 -151
- package/src/components/StackPlatformChips.astro +0 -73
- package/src/components/SubmissionClient.astro +0 -254
- package/src/components/TableOfContents.astro +0 -140
- package/src/components/WhyThisExists.astro +0 -116
- package/src/layouts/BaseLayout.astro +0 -415
- package/src/layouts/Container.astro +0 -18
- package/src/layouts/Footer.astro +0 -231
- package/src/layouts/Header.astro +0 -214
- package/src/layouts/SectionHeader.astro +0 -43
- package/src/layouts/Seo.astro +0 -171
- package/src/layouts/ThemeToggle.astro +0 -118
- package/src/layouts/astro-shim.d.ts +0 -29
- package/src/layouts/index.ts +0 -24
- package/src/server/collections.test.ts +0 -212
- package/src/server/contrast.test.ts +0 -82
- package/src/server/directory.test.ts +0 -177
- package/src/server/github-repo.test.ts +0 -88
- package/src/server/models-home.test.ts +0 -101
- package/src/server/models.test.ts +0 -135
- package/src/server/seo.test.ts +0 -123
- package/src/ui/Badge.astro +0 -38
- package/src/ui/Button.astro +0 -42
- package/src/ui/EmptyState.astro +0 -38
- package/src/ui/FilterDrawer.astro +0 -124
- package/src/ui/PageHeader.astro +0 -39
- package/src/ui/SearchField.astro +0 -106
- package/src/ui/button.test.ts +0 -54
- package/src/ui/button.ts +0 -107
|
@@ -1,510 +0,0 @@
|
|
|
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
|
-
*/
|
|
18
|
-
interface Props {
|
|
19
|
-
slug: string;
|
|
20
|
-
singular: string;
|
|
21
|
-
plural: string;
|
|
22
|
-
taxonomy: Record<string, unknown>;
|
|
23
|
-
/** Page this route prerendered; the client starts from it. */
|
|
24
|
-
routePage?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const { slug, singular, plural, taxonomy, routePage = 1 } = Astro.props;
|
|
28
|
-
const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy, routePage }).replace(
|
|
29
|
-
/</g,
|
|
30
|
-
'\\u003c',
|
|
31
|
-
);
|
|
32
|
-
---
|
|
33
|
-
|
|
34
|
-
<script is:inline id="grove-index-config" type="application/json" set:html={clientConfigJson}></script>
|
|
35
|
-
<script>
|
|
36
|
-
import {
|
|
37
|
-
PAGE_SIZE,
|
|
38
|
-
activeFilterChips,
|
|
39
|
-
applySort,
|
|
40
|
-
buildFacets,
|
|
41
|
-
effectivePage,
|
|
42
|
-
effectiveSort,
|
|
43
|
-
filterRecords,
|
|
44
|
-
filtersFromSearchParams,
|
|
45
|
-
hasAnyFilter,
|
|
46
|
-
hrefForClearedFilters,
|
|
47
|
-
hrefForFilters,
|
|
48
|
-
pagePathHref,
|
|
49
|
-
paginationPageList,
|
|
50
|
-
searchParamsFromFilters,
|
|
51
|
-
sortDisplay,
|
|
52
|
-
totalPages,
|
|
53
|
-
} from "@grove-dev/core/directory";
|
|
54
|
-
import {
|
|
55
|
-
PAGINATION_DISABLED,
|
|
56
|
-
PAGINATION_EXTRA,
|
|
57
|
-
buttonClass,
|
|
58
|
-
chipClass,
|
|
59
|
-
filterTriggerClass,
|
|
60
|
-
} from "../ui/button.js";
|
|
61
|
-
|
|
62
|
-
(() => {
|
|
63
|
-
const configSource = document.querySelector("#grove-index-config");
|
|
64
|
-
const list = document.querySelector("#results-list");
|
|
65
|
-
if (!configSource || !list) return;
|
|
66
|
-
|
|
67
|
-
const { slug, singular, plural, taxonomy, routePage } = JSON.parse(
|
|
68
|
-
configSource.textContent || "{}",
|
|
69
|
-
);
|
|
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}`;
|
|
74
|
-
|
|
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
|
-
}
|
|
85
|
-
|
|
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
|
-
});
|
|
106
|
-
}
|
|
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
|
-
});
|
|
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));
|
|
190
|
-
|
|
191
|
-
const count = document.querySelector("#results-count");
|
|
192
|
-
const pageLabel = document.querySelector("#results-page");
|
|
193
|
-
const empty = document.querySelector("#empty-state");
|
|
194
|
-
if (count) count.textContent = `${filtered.length} ${filtered.length === 1 ? singular : plural}`;
|
|
195
|
-
const sortLabel = document.querySelector("#results-sort");
|
|
196
|
-
if (sortLabel) sortLabel.textContent = ` · sorted by ${sortDisplay(sort)}`;
|
|
197
|
-
if (pageLabel) pageLabel.textContent = pageCount > 1 ? ` · page ${page} of ${pageCount}` : "";
|
|
198
|
-
if (empty) empty.classList.toggle("hidden", filtered.length > 0);
|
|
199
|
-
|
|
200
|
-
// Rebuild the pagination controls, mirroring the server-rendered
|
|
201
|
-
// `Pagination.astro` (Previous/Next + windowed page list via the
|
|
202
|
-
// shared `paginationPageList`) so client filtering never degrades
|
|
203
|
-
// the affordances of the initial render.
|
|
204
|
-
const pagination = document.querySelector("#results-pagination");
|
|
205
|
-
if (pagination) {
|
|
206
|
-
pagination.innerHTML = "";
|
|
207
|
-
if (pageCount > 1) {
|
|
208
|
-
const linkClass = buttonClass("secondary", "sm", PAGINATION_EXTRA);
|
|
209
|
-
const nav = document.createElement("nav");
|
|
210
|
-
nav.className = "flex flex-wrap items-center justify-center gap-1.5";
|
|
211
|
-
nav.setAttribute("aria-label", "Pagination");
|
|
212
|
-
const control = (label: string, target: number | null, rel?: string) => {
|
|
213
|
-
if (target === null) {
|
|
214
|
-
const span = document.createElement("span");
|
|
215
|
-
span.className = `${linkClass} ${PAGINATION_DISABLED}`;
|
|
216
|
-
span.textContent = label;
|
|
217
|
-
return span;
|
|
218
|
-
}
|
|
219
|
-
const link = document.createElement("a");
|
|
220
|
-
link.href = hrefForResultPage(target);
|
|
221
|
-
link.textContent = label;
|
|
222
|
-
link.className = linkClass;
|
|
223
|
-
if (rel) link.rel = rel;
|
|
224
|
-
return link;
|
|
225
|
-
};
|
|
226
|
-
nav.append(control("Previous", page > 1 ? page - 1 : null, "prev"));
|
|
227
|
-
for (const entry of paginationPageList(page, pageCount)) {
|
|
228
|
-
if (entry === "ellipsis") {
|
|
229
|
-
const dots = document.createElement("span");
|
|
230
|
-
dots.className = "px-1 text-2xs text-ink-400";
|
|
231
|
-
dots.setAttribute("aria-hidden", "true");
|
|
232
|
-
dots.textContent = "...";
|
|
233
|
-
nav.append(dots);
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const link = control(String(entry), entry);
|
|
237
|
-
link.setAttribute("aria-label", `Page ${entry}`);
|
|
238
|
-
if (entry === page) {
|
|
239
|
-
link.setAttribute("aria-current", "page");
|
|
240
|
-
link.className = buttonClass("selected", "sm", PAGINATION_EXTRA);
|
|
241
|
-
}
|
|
242
|
-
nav.append(link);
|
|
243
|
-
}
|
|
244
|
-
nav.append(control("Next", page < pageCount ? page + 1 : null, "next"));
|
|
245
|
-
pagination.append(nav);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const searchInput = document.querySelector("#search-input");
|
|
251
|
-
const searchClear = document.querySelector("[data-search-clear]");
|
|
252
|
-
if (searchInput instanceof HTMLInputElement) searchInput.value = filters.q || "";
|
|
253
|
-
document.querySelectorAll('select[name="sort"]').forEach((select) => {
|
|
254
|
-
if (select instanceof HTMLSelectElement) select.value = sort;
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const filterKeys = {
|
|
258
|
-
stacks: "stack",
|
|
259
|
-
platforms: "platform",
|
|
260
|
-
categories: "category",
|
|
261
|
-
tags: "tag",
|
|
262
|
-
licenses: "license",
|
|
263
|
-
};
|
|
264
|
-
const filterLabels = {
|
|
265
|
-
stacks: "Stack",
|
|
266
|
-
platforms: "Platform",
|
|
267
|
-
categories: "Category",
|
|
268
|
-
tags: "Tag",
|
|
269
|
-
licenses: "License",
|
|
270
|
-
};
|
|
271
|
-
const taxonomyKinds = {
|
|
272
|
-
stacks: "stacks",
|
|
273
|
-
platforms: "platforms",
|
|
274
|
-
categories: "categories",
|
|
275
|
-
tags: "topics",
|
|
276
|
-
licenses: "licenses",
|
|
277
|
-
};
|
|
278
|
-
const taxonomyName = (groupKey, value) =>
|
|
279
|
-
(taxonomy[taxonomyKinds[groupKey]] || []).find((entry) => entry.id === value)?.name ??
|
|
280
|
-
value;
|
|
281
|
-
|
|
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);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Mobile filter-drawer badge + its clear-all mirror the chips.
|
|
316
|
-
const drawerCount = document.querySelector("[data-drawer-count]");
|
|
317
|
-
if (drawerCount) {
|
|
318
|
-
drawerCount.textContent = String(chips.length);
|
|
319
|
-
drawerCount.classList.toggle("hidden", chips.length === 0);
|
|
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
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
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);
|
|
354
|
-
}
|
|
355
|
-
group.querySelector(".grove-filter-clear")?.classList.toggle("hidden", selected.length === 0);
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Facet counts were rendered at build time from the UNFILTERED
|
|
360
|
-
// record set (the route is prerendered, so the server never sees
|
|
361
|
-
// the live query string). Recompute intersection counts against
|
|
362
|
-
// the current filters and patch each count span in place —
|
|
363
|
-
// never re-render the option lists, which would destroy
|
|
364
|
-
// checkbox, focus, and scroll state.
|
|
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");
|
|
453
|
-
});
|
|
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();
|
|
458
|
-
});
|
|
459
|
-
|
|
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
|
-
}
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
renderAll();
|
|
509
|
-
})();
|
|
510
|
-
</script>
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* EditorialSummary.astro
|
|
4
|
-
*
|
|
5
|
-
* Compact card at the top of the record body that surfaces the
|
|
6
|
-
* curator's "Best for" and "Consider before using" notes. Renders
|
|
7
|
-
* nothing when both arrays are empty.
|
|
8
|
-
*
|
|
9
|
-
* This is the editorial half of the page: the sidebar (right) is
|
|
10
|
-
* auto-generated facts; the summary (left) is human-authored
|
|
11
|
-
* curation.
|
|
12
|
-
*/
|
|
13
|
-
interface Props {
|
|
14
|
-
bestFor: string[];
|
|
15
|
-
caveats: string[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const { bestFor, caveats } = Astro.props;
|
|
19
|
-
const hasAny = bestFor.length > 0 || caveats.length > 0;
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
{hasAny && (
|
|
23
|
-
<section class="grove-editorial mb-10 rounded-[var(--radius-lg)] border border-ink-200 bg-card p-5 dark:border-ink-800" aria-label="Editorial summary">
|
|
24
|
-
<h2 class="sr-only">Editorial summary</h2>
|
|
25
|
-
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
|
26
|
-
{bestFor.length > 0 && (
|
|
27
|
-
<div>
|
|
28
|
-
<h3 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
|
|
29
|
-
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
|
|
30
|
-
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
|
|
31
|
-
</svg>
|
|
32
|
-
Best for
|
|
33
|
-
</h3>
|
|
34
|
-
<ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
|
|
35
|
-
{bestFor.map((b) => (
|
|
36
|
-
<li class="leading-relaxed">{b}</li>
|
|
37
|
-
))}
|
|
38
|
-
</ul>
|
|
39
|
-
</div>
|
|
40
|
-
)}
|
|
41
|
-
{caveats.length > 0 && (
|
|
42
|
-
<div>
|
|
43
|
-
<h3 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
|
44
|
-
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
|
|
45
|
-
<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 .4 1.04l1.6.8a.75.75 0 1 0 .67-1.34l-1.17-.59V4.75z" />
|
|
46
|
-
</svg>
|
|
47
|
-
Consider before using
|
|
48
|
-
</h3>
|
|
49
|
-
<ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
|
|
50
|
-
{caveats.map((c) => (
|
|
51
|
-
<li class="leading-relaxed">{c}</li>
|
|
52
|
-
))}
|
|
53
|
-
</ul>
|
|
54
|
-
</div>
|
|
55
|
-
)}
|
|
56
|
-
</div>
|
|
57
|
-
</section>
|
|
58
|
-
)}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import FilterOptions from "./FilterOptions.astro";
|
|
3
|
-
import { buttonClass, filterTriggerClass } from "../ui/button.js";
|
|
4
|
-
|
|
5
|
-
interface FacetValue {
|
|
6
|
-
value: string;
|
|
7
|
-
label?: string;
|
|
8
|
-
count?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface Props {
|
|
12
|
-
label: string;
|
|
13
|
-
filterKey: string;
|
|
14
|
-
options: FacetValue[];
|
|
15
|
-
initial: string[];
|
|
16
|
-
single?: boolean;
|
|
17
|
-
active: boolean;
|
|
18
|
-
popoverId: string;
|
|
19
|
-
trigger: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const group = Astro.props;
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
<div class="grove-filter relative" data-filter-group-key={group.filterKey} data-filter-single={group.single ? "true" : "false"}>
|
|
26
|
-
<button
|
|
27
|
-
type="button"
|
|
28
|
-
class={filterTriggerClass(group.active)}
|
|
29
|
-
aria-expanded="false"
|
|
30
|
-
aria-controls={group.popoverId}
|
|
31
|
-
>
|
|
32
|
-
<span>{group.trigger}</span>
|
|
33
|
-
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="opacity-70">
|
|
34
|
-
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"/>
|
|
35
|
-
</svg>
|
|
36
|
-
</button>
|
|
37
|
-
{/* A group of native checkboxes/radios — the old listbox role over
|
|
38
|
-
label/input children had no valid accessibility tree. */}
|
|
39
|
-
<div
|
|
40
|
-
id={group.popoverId}
|
|
41
|
-
class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-popover text-popover-foreground shadow-xl grove-filter-popover absolute left-0 top-full z-40 mt-1 hidden w-72 max-w-[calc(100vw-2rem)] p-1.5"
|
|
42
|
-
role="group"
|
|
43
|
-
aria-label={`${group.label} filters`}
|
|
44
|
-
hidden
|
|
45
|
-
>
|
|
46
|
-
<FilterOptions filterKey={group.filterKey} options={group.options} initial={group.initial} single={group.single} />
|
|
47
|
-
{/* Single-select groups navigate on change, so Apply would be a
|
|
48
|
-
dead affordance — they keep only Clear, and the whole footer
|
|
49
|
-
hides itself while Clear is hidden (the client script toggles
|
|
50
|
-
Clear's `hidden` class from the live URL state). */}
|
|
51
|
-
<div
|
|
52
|
-
class:list={[
|
|
53
|
-
"mt-1 flex items-center justify-between border-t border-ink-100 px-2 pt-1.5 dark:border-ink-900",
|
|
54
|
-
group.single && "has-[.grove-filter-clear.hidden]:hidden",
|
|
55
|
-
]}
|
|
56
|
-
>
|
|
57
|
-
<button
|
|
58
|
-
type="button"
|
|
59
|
-
class:list={[
|
|
60
|
-
"grove-filter-clear text-2xs font-medium text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100",
|
|
61
|
-
!group.active && "hidden",
|
|
62
|
-
]}
|
|
63
|
-
>Clear {group.label.toLowerCase()}</button>
|
|
64
|
-
{!group.single && (
|
|
65
|
-
<button type="button" class={buttonClass("primary", "sm", "grove-filter-apply ml-auto px-2.5 text-2xs")}>Apply</button>
|
|
66
|
-
)}
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
interface FacetValue {
|
|
3
|
-
value: string;
|
|
4
|
-
label?: string;
|
|
5
|
-
count?: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
filterKey: string;
|
|
10
|
-
options: FacetValue[];
|
|
11
|
-
initial: string[];
|
|
12
|
-
single?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const { filterKey, options, initial, single = false } = Astro.props;
|
|
16
|
-
|
|
17
|
-
function pretty(value: string): string {
|
|
18
|
-
return value
|
|
19
|
-
.split(/[-_]/)
|
|
20
|
-
.map((part) => part ? part[0].toUpperCase() + part.slice(1) : part)
|
|
21
|
-
.join(" ");
|
|
22
|
-
}
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
<ul class="m-0 flex max-h-72 list-none flex-col gap-0.5 overflow-y-auto p-0">
|
|
26
|
-
{options.map((option) => (
|
|
27
|
-
<li>
|
|
28
|
-
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 text-[0.8125rem] text-ink-700 transition-colors hover:bg-ink-50 dark:text-ink-300 dark:hover:bg-ink-900/60">
|
|
29
|
-
<span class="flex min-w-0 items-center gap-2">
|
|
30
|
-
<input
|
|
31
|
-
type={single ? "radio" : "checkbox"}
|
|
32
|
-
name={single ? filterKey : undefined}
|
|
33
|
-
class="grove-filter-input h-3.5 w-3.5 shrink-0 cursor-pointer accent-ink-900 dark:accent-ink-100"
|
|
34
|
-
data-filter-key={filterKey}
|
|
35
|
-
data-filter-value={option.value}
|
|
36
|
-
checked={initial.includes(option.value)}
|
|
37
|
-
/>
|
|
38
|
-
<span class="truncate">{option.label ?? pretty(option.value)}</span>
|
|
39
|
-
</span>
|
|
40
|
-
<span
|
|
41
|
-
class="font-mono text-2xs tabular-nums text-ink-500 dark:text-ink-400"
|
|
42
|
-
data-facet-count
|
|
43
|
-
data-facet={filterKey}
|
|
44
|
-
data-value={option.value}
|
|
45
|
-
>{option.count ?? 0}</span>
|
|
46
|
-
</label>
|
|
47
|
-
</li>
|
|
48
|
-
))}
|
|
49
|
-
</ul>
|