@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.
Files changed (110) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +22 -33
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.d.ts +1 -1
  6. package/dist/lib/display.js +1 -1
  7. package/dist/lib/format.d.ts +1 -1
  8. package/dist/lib/format.js +1 -1
  9. package/dist/lib/icon-kinds.d.ts +1 -1
  10. package/dist/lib/icon-kinds.js +43 -43
  11. package/dist/lib/icon-registry.d.ts +2 -2
  12. package/dist/lib/icon-registry.js +23 -23
  13. package/dist/lib/index.d.ts +9 -9
  14. package/dist/lib/index.d.ts.map +1 -1
  15. package/dist/lib/index.js +9 -9
  16. package/dist/lib/index.js.map +1 -1
  17. package/dist/lib/lenses.d.ts +1 -1
  18. package/dist/lib/lenses.js +1 -1
  19. package/dist/lib/packaged-icons.d.ts +1 -1
  20. package/dist/lib/packaged-icons.js +4 -4
  21. package/dist/lib/repo.d.ts +1 -1
  22. package/dist/lib/repo.js +1 -1
  23. package/dist/lib/search.d.ts +1 -1
  24. package/dist/lib/search.js +1 -1
  25. package/dist/lib/taxonomy-counts.d.ts +1 -1
  26. package/dist/lib/taxonomy-counts.js +1 -1
  27. package/package.json +7 -17
  28. package/src/index.ts +23 -38
  29. package/src/lib/display.ts +1 -1
  30. package/src/lib/format.ts +1 -1
  31. package/src/lib/icon-kinds.ts +44 -44
  32. package/src/lib/icon-registry.ts +25 -25
  33. package/src/lib/index.ts +9 -9
  34. package/src/lib/lenses.ts +1 -1
  35. package/src/lib/packaged-icons.ts +8 -8
  36. package/src/lib/repo.ts +1 -1
  37. package/src/lib/search.ts +1 -1
  38. package/src/lib/taxonomy-counts.ts +1 -1
  39. package/src/server/collections.ts +53 -42
  40. package/src/server/contrast.ts +14 -9
  41. package/src/server/directory.ts +22 -17
  42. package/src/server/index.ts +3 -2
  43. package/src/server/models.ts +425 -218
  44. package/src/server/seo.ts +34 -31
  45. package/src/styles.css +235 -91
  46. package/dist/layouts/index.d.ts +0 -25
  47. package/dist/layouts/index.d.ts.map +0 -1
  48. package/dist/layouts/index.js +0 -25
  49. package/dist/layouts/index.js.map +0 -1
  50. package/dist/ui/button.d.ts +0 -53
  51. package/dist/ui/button.d.ts.map +0 -1
  52. package/dist/ui/button.js +0 -90
  53. package/dist/ui/button.js.map +0 -1
  54. package/src/components/CardGrid.astro +0 -41
  55. package/src/components/CardIcon.astro +0 -67
  56. package/src/components/CategoryGrid.astro +0 -114
  57. package/src/components/CollectionCard.astro +0 -56
  58. package/src/components/CollectionIndex.astro +0 -102
  59. package/src/components/CollectionPage.astro +0 -128
  60. package/src/components/CollectionRow.astro +0 -81
  61. package/src/components/CollectionTeaser.astro +0 -47
  62. package/src/components/ContributorsGrid.astro +0 -143
  63. package/src/components/DirectoryIndexClient.astro +0 -510
  64. package/src/components/EditorialSummary.astro +0 -58
  65. package/src/components/FilterGroupMenu.astro +0 -69
  66. package/src/components/FilterOptions.astro +0 -49
  67. package/src/components/FinalCta.astro +0 -43
  68. package/src/components/Hero.astro +0 -243
  69. package/src/components/Icon.astro +0 -191
  70. package/src/components/IndexRow.astro +0 -37
  71. package/src/components/LanguageBreakdown.astro +0 -67
  72. package/src/components/MarkdownBody.astro +0 -37
  73. package/src/components/OriginalCollection.astro +0 -117
  74. package/src/components/Pagination.astro +0 -50
  75. package/src/components/PoweredBy.astro +0 -60
  76. package/src/components/ProjectCard.astro +0 -321
  77. package/src/components/RecordHeader.astro +0 -221
  78. package/src/components/RecordSection.astro +0 -128
  79. package/src/components/RecordSidebar.astro +0 -387
  80. package/src/components/RefinePanel.astro +0 -168
  81. package/src/components/SmartLensTabs.astro +0 -81
  82. package/src/components/StackGrid.astro +0 -151
  83. package/src/components/StackPlatformChips.astro +0 -73
  84. package/src/components/SubmissionClient.astro +0 -254
  85. package/src/components/TableOfContents.astro +0 -140
  86. package/src/components/WhyThisExists.astro +0 -116
  87. package/src/layouts/BaseLayout.astro +0 -415
  88. package/src/layouts/Container.astro +0 -18
  89. package/src/layouts/Footer.astro +0 -231
  90. package/src/layouts/Header.astro +0 -214
  91. package/src/layouts/SectionHeader.astro +0 -43
  92. package/src/layouts/Seo.astro +0 -171
  93. package/src/layouts/ThemeToggle.astro +0 -118
  94. package/src/layouts/astro-shim.d.ts +0 -29
  95. package/src/layouts/index.ts +0 -24
  96. package/src/server/collections.test.ts +0 -212
  97. package/src/server/contrast.test.ts +0 -82
  98. package/src/server/directory.test.ts +0 -177
  99. package/src/server/github-repo.test.ts +0 -88
  100. package/src/server/models-home.test.ts +0 -101
  101. package/src/server/models.test.ts +0 -135
  102. package/src/server/seo.test.ts +0 -123
  103. package/src/ui/Badge.astro +0 -38
  104. package/src/ui/Button.astro +0 -42
  105. package/src/ui/EmptyState.astro +0 -38
  106. package/src/ui/FilterDrawer.astro +0 -124
  107. package/src/ui/PageHeader.astro +0 -39
  108. package/src/ui/SearchField.astro +0 -106
  109. package/src/ui/button.test.ts +0 -54
  110. 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>