@grove-dev/astro 0.5.0-next.2 → 0.5.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.
Files changed (80) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +9 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +140 -63
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +11 -0
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +170 -42
  59. package/src/styles.css +128 -16
  60. package/src/ui/Badge.astro +38 -0
  61. package/src/ui/Button.astro +42 -0
  62. package/src/ui/EmptyState.astro +38 -0
  63. package/src/ui/FilterDrawer.astro +104 -0
  64. package/src/ui/PageHeader.astro +39 -0
  65. package/src/ui/SearchField.astro +50 -0
  66. package/src/ui/button.test.ts +54 -0
  67. package/src/ui/button.ts +98 -0
  68. package/dist/lib/scores.d.ts +0 -2
  69. package/dist/lib/scores.d.ts.map +0 -1
  70. package/dist/lib/scores.js +0 -2
  71. package/dist/lib/scores.js.map +0 -1
  72. package/src/components/CurationGrid.astro +0 -41
  73. package/src/components/DecisionRow.astro +0 -89
  74. package/src/components/ExploreByCategory.astro +0 -67
  75. package/src/components/ExploreByStack.astro +0 -78
  76. package/src/components/GroveDocumentHead.astro +0 -28
  77. package/src/components/ItemCard.astro +0 -324
  78. package/src/components/MinimalAbout.astro +0 -82
  79. package/src/components/ScoreBars.astro +0 -102
  80. package/src/lib/scores.ts +0 -1
@@ -8,7 +8,10 @@ interface Props {
8
8
  }
9
9
 
10
10
  const { clientItemsJson, slug, singular, plural, taxonomy } = Astro.props;
11
- const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).replace(/</g, "\\u003c");
11
+ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).replace(
12
+ /</g,
13
+ '\\u003c',
14
+ );
12
15
  ---
13
16
 
14
17
  <script is:inline id="grove-index-data" type="application/json" set:html={clientItemsJson}></script>
@@ -19,17 +22,26 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
19
22
  PAGE_SIZE,
20
23
  activeFilterChips,
21
24
  applySort,
25
+ buildFacets,
22
26
  effectivePage,
23
27
  effectiveSort,
24
28
  filterRecords,
25
29
  filtersFromSearchParams,
26
30
  hrefForLens,
27
31
  isLensActive,
32
+ paginationPageList,
28
33
  removeFilter,
29
34
  searchParamsFromFilters,
30
35
  sortDisplay,
31
36
  totalPages,
32
37
  } from "@grove-dev/core/directory";
38
+ import {
39
+ PAGINATION_EXTRA,
40
+ buttonClass,
41
+ chipClass,
42
+ filterTriggerClass,
43
+ lensTabClass,
44
+ } from "../ui/button.js";
33
45
 
34
46
  (() => {
35
47
  const source = document.querySelector("#grove-index-data");
@@ -39,8 +51,12 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
39
51
 
40
52
  const items = JSON.parse(source.textContent || "[]");
41
53
  const { slug, singular, plural, taxonomy } = JSON.parse(configSource.textContent || "{}");
54
+ // `:scope >` keeps the lookup on the page's own `<li>` wrappers —
55
+ // the card component inside each item also carries
56
+ // `data-record-slug`, and appending/hiding the inner card instead
57
+ // of its wrapper would tear the list apart.
42
58
  const nodes = new Map(
43
- [...list.querySelectorAll("[data-record-slug]")].map((node) => [
59
+ [...list.querySelectorAll(":scope > [data-record-slug]")].map((node) => [
44
60
  (node as HTMLElement).dataset.recordSlug,
45
61
  node,
46
62
  ]),
@@ -64,9 +80,22 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
64
80
  .map((record) => record.slug),
65
81
  );
66
82
 
67
- for (const record of filtered) {
68
- const node = nodes.get(record.slug) as HTMLElement | undefined;
69
- if (node) list.append(node);
83
+ // Re-append only when the visible order has changed. On every
84
+ // keystroke / filter change this would otherwise thrash the
85
+ // DOM order even when the sort + filter result is identical
86
+ // to the previous run.
87
+ const currentOrder = [...list.querySelectorAll(":scope > [data-record-slug]")].map(
88
+ (node) => (node as HTMLElement).dataset.recordSlug,
89
+ );
90
+ const nextOrder = filtered.map((record) => record.slug);
91
+ const orderChanged =
92
+ currentOrder.length !== nextOrder.length ||
93
+ currentOrder.some((slug, index) => slug !== nextOrder[index]);
94
+ if (orderChanged) {
95
+ for (const record of filtered) {
96
+ const node = nodes.get(record.slug) as HTMLElement | undefined;
97
+ if (node) list.append(node);
98
+ }
70
99
  }
71
100
  for (const [recordSlug, node] of nodes) {
72
101
  (node as HTMLElement).hidden = !visible.has(recordSlug);
@@ -81,20 +110,56 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
81
110
  if (pageLabel) pageLabel.textContent = pageCount > 1 ? ` · page ${page} of ${pageCount}` : "";
82
111
  if (empty) empty.classList.toggle("hidden", filtered.length > 0);
83
112
 
113
+ // Rebuild the pagination controls, mirroring the server-rendered
114
+ // `Pagination.astro` (Previous/Next + windowed page list via the
115
+ // shared `paginationPageList`) so client filtering never degrades
116
+ // the affordances of the initial render.
84
117
  const pagination = document.querySelector("#results-pagination");
85
118
  if (pagination) {
86
119
  pagination.innerHTML = "";
87
120
  if (pageCount > 1) {
88
- for (let target = 1; target <= pageCount; target += 1) {
121
+ const linkClass = buttonClass("secondary", "sm", PAGINATION_EXTRA);
122
+ const hrefFor = (target: number) => {
89
123
  const next = searchParamsFromFilters({ ...filters, page: target });
124
+ return `/${slug}${next.size ? `?${next}` : ""}`;
125
+ };
126
+ const nav = document.createElement("nav");
127
+ nav.className = "flex flex-wrap items-center justify-center gap-1.5";
128
+ nav.setAttribute("aria-label", "Pagination");
129
+ const control = (label: string, target: number | null, rel?: string) => {
130
+ if (target === null) {
131
+ const span = document.createElement("span");
132
+ span.className = `${linkClass} pointer-events-none opacity-40`;
133
+ span.textContent = label;
134
+ return span;
135
+ }
90
136
  const link = document.createElement("a");
91
- link.href = `/${slug}${next.size ? `?${next}` : ""}`;
92
- link.textContent = String(target);
93
- link.setAttribute("aria-label", `Page ${target}`);
94
- if (target === page) link.setAttribute("aria-current", "page");
95
- link.className = "inline-flex h-8 min-w-8 items-center justify-center rounded-md border border-border px-2 text-sm";
96
- pagination.append(link);
137
+ link.href = hrefFor(target);
138
+ link.textContent = label;
139
+ link.className = linkClass;
140
+ if (rel) link.rel = rel;
141
+ return link;
142
+ };
143
+ nav.append(control("Previous", page > 1 ? page - 1 : null, "prev"));
144
+ for (const entry of paginationPageList(page, pageCount)) {
145
+ if (entry === "ellipsis") {
146
+ const dots = document.createElement("span");
147
+ dots.className = "px-1 text-2xs text-ink-400";
148
+ dots.setAttribute("aria-hidden", "true");
149
+ dots.textContent = "...";
150
+ nav.append(dots);
151
+ continue;
152
+ }
153
+ const link = control(String(entry), entry);
154
+ link.setAttribute("aria-label", `Page ${entry}`);
155
+ if (entry === page) {
156
+ link.setAttribute("aria-current", "page");
157
+ link.className = buttonClass("selected", "sm", PAGINATION_EXTRA);
158
+ }
159
+ nav.append(link);
97
160
  }
161
+ nav.append(control("Next", page < pageCount ? page + 1 : null, "next"));
162
+ pagination.append(nav);
98
163
  }
99
164
  }
100
165
  }
@@ -133,6 +198,8 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
133
198
  stacks: "stacks",
134
199
  platforms: "platforms",
135
200
  categories: "categories",
201
+ tags: "topics",
202
+ licenses: "licenses",
136
203
  };
137
204
  const taxonomyName = (groupKey, value) =>
138
205
  (taxonomy[taxonomyKinds[groupKey]] || []).find((entry) => entry.id === value)?.name ??
@@ -152,14 +219,22 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
152
219
  ? `Platform: ${taxonomyName("platforms", chip.value)}`
153
220
  : chip.key === "categories"
154
221
  ? `Category: ${taxonomyName("categories", chip.value)}`
155
- : chip.label,
222
+ : chip.key === "tags"
223
+ ? `Tag: ${taxonomyName("tags", chip.value)}`
224
+ : chip.key === "licenses"
225
+ ? `License: ${taxonomyName("licenses", chip.value)}`
226
+ : chip.label,
156
227
  }));
157
228
 
158
229
  for (const chip of chips) {
159
230
  const next = searchParamsFromFilters(removeFilter(filters, chip.key, chip.value));
160
231
  const link = document.createElement("a");
161
232
  link.href = `/${slug}${next.size ? `?${next}` : ""}`;
162
- link.className = "inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400";
233
+ // The whole anchor is the remove control; the visible label
234
+ // announces the filter value, and the aria-label spells out
235
+ // the action for assistive tech.
236
+ link.setAttribute("aria-label", `Remove filter: ${chip.label}`);
237
+ link.className = chipClass();
163
238
  link.append(document.createTextNode(`${chip.label} `));
164
239
  const close = document.createElement("span");
165
240
  close.setAttribute("aria-hidden", "true");
@@ -172,10 +247,18 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
172
247
  const clearParams = searchParamsFromFilters({ sort });
173
248
  clear.href = `/${slug}${clearParams.size ? `?${clearParams}` : ""}`;
174
249
  clear.className = "text-2xs font-medium text-ink-500 hover:text-ink-900";
250
+ clear.setAttribute("aria-label", "Clear all active filters");
175
251
  clear.textContent = "Clear all";
176
252
  activeFilters.append(clear);
177
253
  }
178
254
  activeFilters.classList.toggle("hidden", chips.length === 0);
255
+
256
+ // Mobile filter-drawer badge mirrors the live chip count.
257
+ const drawerCount = document.querySelector("[data-drawer-count]");
258
+ if (drawerCount) {
259
+ drawerCount.textContent = String(chips.length);
260
+ drawerCount.classList.toggle("hidden", chips.length === 0);
261
+ }
179
262
  }
180
263
 
181
264
  document.querySelectorAll(".grove-filter").forEach((group) => {
@@ -199,18 +282,39 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
199
282
  : `${label}: Any`;
200
283
  }
201
284
  if (triggerButton) {
202
- triggerButton.classList.toggle("bg-primary", selected.length > 0);
203
- triggerButton.classList.toggle("text-primary-foreground", selected.length > 0);
204
- triggerButton.classList.toggle("hover:bg-primary/90", selected.length > 0);
205
- triggerButton.classList.toggle("hover:text-primary-foreground", selected.length > 0);
206
- triggerButton.classList.toggle("border-border", selected.length === 0);
207
- triggerButton.classList.toggle("bg-secondary", selected.length === 0);
208
- triggerButton.classList.toggle("text-foreground", selected.length === 0);
209
- triggerButton.classList.toggle("hover:bg-accent", selected.length === 0);
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);
210
288
  }
211
289
  group.querySelector(".grove-filter-clear")?.classList.toggle("hidden", selected.length === 0);
212
290
  });
213
291
 
292
+ // Facet counts were rendered at build time from the UNFILTERED
293
+ // record set (the route is prerendered, so the server never sees
294
+ // the live query string). Recompute intersection counts against
295
+ // the current filters and patch each count span in place —
296
+ // never re-render the option lists, which would destroy
297
+ // checkbox, focus, and scroll state.
298
+ const liveFacets: Record<string, { value: string; count: number }[]> = buildFacets(items, {
299
+ filters,
300
+ curatedTagIds: (taxonomy.topics || []).map((topic: { id: string }) => topic.id),
301
+ });
302
+ document.querySelectorAll("[data-facet-count]").forEach((span) => {
303
+ if (!(span instanceof HTMLElement)) return;
304
+ const options = liveFacets[span.dataset.facet ?? ""] ?? [];
305
+ const count = options.find((option) => option.value === span.dataset.value)?.count ?? 0;
306
+ span.textContent = String(count);
307
+ const row = span.closest("label");
308
+ const input = row?.querySelector(".grove-filter-input");
309
+ const checked = input instanceof HTMLInputElement && input.checked;
310
+ // A zero-count option can't narrow anything — dim and disable
311
+ // it unless it's the currently selected value (which must stay
312
+ // interactive so the user can un-select it).
313
+ const inert = count === 0 && !checked;
314
+ row?.classList.toggle("opacity-45", inert);
315
+ if (input instanceof HTMLInputElement) input.disabled = inert;
316
+ });
317
+
214
318
  let activeLens = LENSES[0];
215
319
  document.querySelectorAll("[data-lens-id]").forEach((node) => {
216
320
  if (!(node instanceof HTMLAnchorElement)) return;
@@ -220,14 +324,7 @@ const clientConfigJson = JSON.stringify({ slug, singular, plural, taxonomy }).re
220
324
  node.href = hrefForLens(lens.id, params, `/${slug}`);
221
325
  if (active) node.setAttribute("aria-current", "page");
222
326
  else node.removeAttribute("aria-current");
223
- node.classList.toggle("bg-primary", active);
224
- node.classList.toggle("text-primary-foreground", active);
225
- node.classList.toggle("hover:bg-primary/90", active);
226
- node.classList.toggle("hover:text-primary-foreground", active);
227
- node.classList.toggle("border-border", !active);
228
- node.classList.toggle("bg-secondary", !active);
229
- node.classList.toggle("text-foreground", !active);
230
- node.classList.toggle("hover:bg-accent", !active);
327
+ node.className = lensTabClass(active);
231
328
  if (active) activeLens = lens;
232
329
  });
233
330
  const heading = document.querySelector("#results-heading");
@@ -20,16 +20,17 @@ const hasAny = bestFor.length > 0 || caveats.length > 0;
20
20
  ---
21
21
 
22
22
  {hasAny && (
23
- <section class="grove-editorial mb-10 rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Editorial summary">
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>
24
25
  <div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
25
26
  {bestFor.length > 0 && (
26
27
  <div>
27
- <h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
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">
28
29
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
29
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" />
30
31
  </svg>
31
32
  Best for
32
- </h2>
33
+ </h3>
33
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">
34
35
  {bestFor.map((b) => (
35
36
  <li class="leading-relaxed">{b}</li>
@@ -39,12 +40,12 @@ const hasAny = bestFor.length > 0 || caveats.length > 0;
39
40
  )}
40
41
  {caveats.length > 0 && (
41
42
  <div>
42
- <h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
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">
43
44
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
44
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" />
45
46
  </svg>
46
47
  Consider before using
47
- </h2>
48
+ </h3>
48
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">
49
50
  {caveats.map((c) => (
50
51
  <li class="leading-relaxed">{c}</li>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import FilterOptions from "./FilterOptions.astro";
3
+ import { buttonClass, filterTriggerClass } from "../ui/button.js";
3
4
 
4
5
  interface FacetValue {
5
6
  value: string;
@@ -24,13 +25,7 @@ const group = Astro.props;
24
25
  <div class="grove-filter relative" data-filter-group-key={group.filterKey} data-filter-single={group.single ? "true" : "false"}>
25
26
  <button
26
27
  type="button"
27
- class:list={[
28
- "grove-filter-trigger inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-[0.8125rem] font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
29
- group.active
30
- ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
31
- : "border-border bg-secondary text-foreground hover:bg-accent",
32
- ]}
33
- aria-haspopup="listbox"
28
+ class={filterTriggerClass(group.active)}
34
29
  aria-expanded="false"
35
30
  aria-controls={group.popoverId}
36
31
  >
@@ -39,9 +34,26 @@ const group = Astro.props;
39
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"/>
40
35
  </svg>
41
36
  </button>
42
- <div id={group.popoverId} 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" role="listbox" hidden>
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
+ >
43
46
  <FilterOptions filterKey={group.filterKey} options={group.options} initial={group.initial} single={group.single} />
44
- <div class="mt-1 flex items-center justify-between border-t border-ink-100 px-2 pt-1.5 dark:border-ink-900">
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
+ >
45
57
  <button
46
58
  type="button"
47
59
  class:list={[
@@ -49,7 +61,9 @@ const group = Astro.props;
49
61
  !group.active && "hidden",
50
62
  ]}
51
63
  >Clear {group.label.toLowerCase()}</button>
52
- <button type="button" class="grove-filter-apply inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 bg-primary text-primary-foreground hover:bg-primary/90 ml-auto px-2.5 text-2xs">Apply</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
+ )}
53
67
  </div>
54
68
  </div>
55
69
  </div>
@@ -37,7 +37,12 @@ function pretty(value: string): string {
37
37
  />
38
38
  <span class="truncate">{option.label ?? pretty(option.value)}</span>
39
39
  </span>
40
- <span class="font-mono text-2xs tabular-nums text-ink-500 dark:text-ink-400">{option.count ?? 0}</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>
41
46
  </label>
42
47
  </li>
43
48
  ))}
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { buttonClass } from "../ui/button.js";
3
+
2
4
  interface Props {
3
5
  itemLabel?: string;
4
6
  itemsLabel?: string;
@@ -18,7 +20,7 @@ const {
18
20
 
19
21
  <section class:list={["border-t border-ink-200 py-10 sm:py-12 dark:border-ink-800", className]} aria-labelledby="grove-final-cta-heading">
20
22
  <div class="mx-auto w-full max-w-container px-5 sm:px-7">
21
- <div class="rounded-[calc(var(--radius)+0.25rem)] border border-ink-200 bg-muted px-6 py-7 sm:flex sm:items-center sm:justify-between sm:gap-10 sm:px-8 sm:py-8 dark:border-ink-800">
23
+ <div class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card px-6 py-7 sm:flex sm:items-center sm:justify-between sm:gap-10 sm:px-8 sm:py-8 dark:border-ink-800">
22
24
  <div class="max-w-2xl">
23
25
  <p class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">Keep the directory useful</p>
24
26
  <h2 id="grove-final-cta-heading" class="m-0 mt-1.5 text-[1.5rem] font-semibold tracking-tight text-ink-900 sm:text-[1.75rem] dark:text-ink-100">
@@ -29,10 +31,10 @@ const {
29
31
  </p>
30
32
  </div>
31
33
  <div class="mt-5 flex shrink-0 flex-wrap items-center gap-2 sm:mt-0 sm:justify-end">
32
- <a href={submitHref} class="inline-flex min-h-9 items-center justify-center rounded-[var(--radius)] bg-primary px-4 text-sm font-semibold text-primary-foreground no-underline hover:bg-primary/90">
34
+ <a href={submitHref} class={buttonClass("primary", "md", "px-4")}>
33
35
  Submit {itemLabel}
34
36
  </a>
35
- <a href={browseHref} class="inline-flex min-h-9 items-center justify-center rounded-[var(--radius)] border border-border bg-background px-4 text-sm font-semibold text-foreground no-underline hover:bg-accent">
37
+ <a href={browseHref} class={buttonClass("outline", "md", "px-4")}>
36
38
  Explore {itemsLabel}
37
39
  </a>
38
40
  </div>