From cf65ac3992fdf18548d707b678ec26e2d21e2a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:07:24 -0300 Subject: [PATCH 1/3] chore: start #579 From 79276dca629beba648f6241d1f11dff27f56fe36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:38:52 -0300 Subject: [PATCH 2/3] feat: faceted gallery search with url state and instant filtering --- .changeset/gallery-facet-search.md | 5 + site/src/components/gallery/Gallery.svelte | 154 +++++++++++++++++++-- site/src/pages/gallery/index.astro | 3 +- site/tests/gallery.spec.ts | 47 +++++++ site/tests/routes.spec.ts | 10 ++ 5 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 .changeset/gallery-facet-search.md diff --git a/.changeset/gallery-facet-search.md b/.changeset/gallery-facet-search.md new file mode 100644 index 00000000..b0b9d04a --- /dev/null +++ b/.changeset/gallery-facet-search.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +The gallery filters by facet. Chips for kind, subtype, style, motion, interaction, layout, colour mode, density, tech and cost narrow the cards instantly, show how many entries each value would match, and drop combinations that match nothing. Words match titles, aliases, tags and components. Facets, words and the group live in the URL (`?kind=motion&style=aurora&q=fade`), so every filter is a shareable link. Only published facets are offered. diff --git a/site/src/components/gallery/Gallery.svelte b/site/src/components/gallery/Gallery.svelte index b4cda200..eb8d1cce 100644 --- a/site/src/components/gallery/Gallery.svelte +++ b/site/src/components/gallery/Gallery.svelte @@ -3,6 +3,8 @@ announce, Button, Checkbox, + Chip, + Collapsible, CopyButton, StickyActions, TextField, @@ -10,6 +12,17 @@ } from "@codeatlasdev/ui"; import { ArrowUpRight } from "@lucide/svelte"; import { onMount } from "svelte"; + import type { IndexEntry } from "../../../../packages/ui/scripts/index-build"; + import { + FILTER_FACETS, + type FilterFacet, + type Filters, + chipCounts, + haystack, + parseFilters, + searchIndex, + writeFilters, + } from "../../../../packages/ui/scripts/index-search"; import type { GalleryItem } from "~/lib/gallery-items"; import { ensureDemoSession } from "./demo-session"; import GalleryPreview from "./GalleryPreview.svelte"; @@ -20,12 +33,31 @@ interface Props { elements: GalleryItem[]; groups: string[]; + /** `api/v1/index.json` entries: published facets only. Full pages have no entry. */ + entries: IndexEntry[]; } - let { elements, groups }: Props = $props(); + let { elements, groups, entries }: Props = $props(); + const FACET_TITLES: Record = { + kind: "Kind", + subtype: "Subtype", + style: "Style", + motion: "Motion", + interaction: "Interaction", + layout: "Layout", + colorMode: "Colour mode", + density: "Density", + tech: "Tech", + cost: "Cost", + component: "Component", + }; + /** `component` has ~200 values: it is reached through the search words, not chips. */ + const CHIP_FACETS = FILTER_FACETS.filter((f) => f !== "component"); const pageCount = $derived(elements.filter((e) => e.kind === "page").length); let query = $state(""); let group = $state("all"); + let filters = $state({}); + let facetsOpen = $state(false); let picks = $state([]); let callback = $state(null); let scheme = $state<"light" | "dark">("dark"); @@ -37,21 +69,34 @@ ...groups.map((g) => ({ value: g, label: g })), ]); - const haystacks = $derived( + const entryHay = new Map(entries.map((e) => [e.id, haystack(e)])); + // Pages have no index entry; they match the words of their own card and no facet. + const pageHay = $derived( new Map( - elements.map((e) => [ - e.id, - `${e.id} ${e.title} ${e.summary} ${e.tags.join(" ")} ${e.components.join(" ")}`.toLowerCase(), - ]) + elements + .filter((e) => e.kind === "page") + .map((e) => [ + e.id, + `${e.title} ${e.summary} ${e.tags.join(" ")} ${e.components.join(" ")}`.toLowerCase(), + ]) ) ); + const facetActive = $derived(FILTER_FACETS.some((f) => filters[f]?.length)); + /** Entries that pass the words and every chosen facet, best match first. */ + const hits = $derived(searchIndex(entries, { query, filters, haystacks: entryHay })); + const hitRank = $derived(new Map(hits.map((e, i) => [e.id, i]))); + const counts = $derived(chipCounts(entries, { query, filters, haystacks: entryHay })); const visible = $derived.by(() => { const words = query.toLowerCase().split(/\s+/).filter(Boolean); - return elements.filter( - (e) => - (group === "all" || e.group === group) && - words.every((w) => haystacks.get(e.id)?.includes(w)) - ); + const list = elements.filter((e) => { + if (group !== "all" && e.group !== group) return false; + if (e.kind === "page") return !facetActive && words.every((w) => pageHay.get(e.id)?.includes(w)); + return hitRank.has(e.id); + }); + // With words the best match leads; without, the catalog order stays. + return words.length + ? list.sort((a, b) => (hitRank.get(a.id) ?? -1) - (hitRank.get(b.id) ?? -1)) + : list; }); const sorted = $derived([...picks].sort()); const picked = $derived(new Set(picks)); @@ -73,6 +118,33 @@ history.replaceState(history.state, "", url); } + /** Facets, words and group live in the URL, so every filter combination is a shareable link. */ + function syncUrl() { + const url = new URL(location.href); + writeFilters(url.searchParams, filters); + if (query.trim()) url.searchParams.set("q", query.trim()); + else url.searchParams.delete("q"); + if (group !== "all") url.searchParams.set("group", group); + else url.searchParams.delete("group"); + history.replaceState(history.state, "", url); + } + + function toggleValue(facet: FilterFacet, value: string) { + const has = filters[facet] ?? []; + filters = { + ...filters, + [facet]: has.includes(value) ? has.filter((v) => v !== value) : [...has, value], + }; + syncUrl(); + } + + function clearAll() { + filters = {}; + query = ""; + group = "all"; + syncUrl(); + } + function sendToAi() { if (callback) location.href = `${callback}${callback.includes("?") ? "&" : "?"}ids=${sorted.join(",")}`; } @@ -80,6 +152,11 @@ onMount(() => { const params = new URLSearchParams(location.search); picks = parsePicks(params.get("pick"), known); + filters = parseFilters(params); + facetsOpen = FILTER_FACETS.some((f) => filters[f]?.length); + query = params.get("q") ?? ""; + const g = params.get("group"); + if (g && groups.includes(g)) group = g; callback = safeCallback(params.get("cb")); // Thumbnails of the admin pages render signed in with the public demo account. ensureDemoSession(); @@ -105,14 +182,43 @@ @@ -153,7 +259,9 @@ {#if visible.length === 0} - + {/if} 0} align="end"> @@ -194,6 +302,24 @@ display: grid; gap: var(--ca-space-3); } + .gallery__facet { + display: flex; + flex-wrap: wrap; + gap: var(--ca-space-2); + align-items: center; + } + .gallery__facet-title { + min-width: 6rem; + } + .gallery__link { + background: none; + border: 0; + padding: 0; + color: var(--ca-text-primary); + text-decoration: underline; + cursor: pointer; + font: inherit; + } .gallery__filters :global(.ca-toggle-group) { flex-wrap: wrap; justify-self: start; diff --git a/site/src/pages/gallery/index.astro b/site/src/pages/gallery/index.astro index 32643900..44c6d25a 100644 --- a/site/src/pages/gallery/index.astro +++ b/site/src/pages/gallery/index.astro @@ -6,6 +6,7 @@ import Base from "~/layouts/Base.astro"; import { ITEMS } from "~/lib/gallery-items"; import { SECTIONS } from "~/lib/nav"; import catalog from "../../../public/api/v1/catalog.json"; +import index from "../../../public/api/v1/index.json"; // Only what a card renders: the full catalog (imports, needs, spec, ...) stays in the API. // Pages lead the category list: they are the first thing an owner looks for. @@ -17,7 +18,7 @@ const groups = ["pages", ...catalog.groups.map((g) => g.slug)]; skipLink={false} > - + diff --git a/site/tests/gallery.spec.ts b/site/tests/gallery.spec.ts index 973505ef..7f397342 100644 --- a/site/tests/gallery.spec.ts +++ b/site/tests/gallery.spec.ts @@ -70,6 +70,53 @@ test.describe("gallery", () => { expect(await cards.count()).toBeGreaterThan(0); }); + test("facet chips filter instantly, with counts, and the state lives in the URL", async ({ + page, + }) => { + await gotoReady(page, "/gallery/"); + const cards = page.locator("article.gallery-card"); + const all = await cards.count(); + // The facets sit behind a disclosure so the cards stay near the top. + await expect(page.getByRole("group", { name: "Kind" })).toHaveCount(0); + await page.getByRole("button", { name: "Filters" }).click(); + const chip = page + .getByRole("group", { name: "Kind" }) + .getByRole("button", { name: /^motion \(\d+\)$/ }); + const count = Number((await chip.textContent())?.match(/\((\d+)\)/)?.[1]); + await chip.click(); + await expect(chip).toHaveAttribute("aria-pressed", "true"); + await expect(cards).toHaveCount(count); + expect(count).toBeLessThan(all); + await expect(page).toHaveURL(/[?&]kind=motion(&|$)/); + // A second facet narrows further. + await page.getByRole("group", { name: "Style" }).getByRole("button").first().click(); + expect(await cards.count()).toBeLessThanOrEqual(count); + // Industry is not a published facet. + await expect(page.getByRole("group", { name: "Industry" })).toHaveCount(0); + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(cards).toHaveCount(all); + await expect(page).not.toHaveURL(/kind=/); + }); + + test("a shared facet URL restores the filters and the query", async ({ page }) => { + await gotoReady(page, "/gallery/?kind=motion&q=fade"); + await expect( + page.getByRole("group", { name: "Kind" }).getByRole("button", { name: /^motion / }) + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("textbox", { name: /filter/i })).toHaveValue("fade"); + await expect(page.locator("article.gallery-card").first()).toBeVisible(); + for (const group of await page.locator(".gallery-card__group").allTextContents()) + expect(group).toBe("motion"); + await expect(page.locator("[id='256']")).toBeVisible(); + }); + + test("an impossible facet combination shows the empty state with a way out", async ({ page }) => { + await gotoReady(page, "/gallery/?kind=component&motion=immersive&q=zzzqqq"); + await expect(page.locator("article.gallery-card")).toHaveCount(0); + await page.getByRole("button", { name: "Clear the filters" }).click(); + await expect(page.locator("article.gallery-card").first()).toBeVisible(); + }); + test("a motion group filter lists motion demos, and a card plays only while in view", async ({ page, }) => { diff --git a/site/tests/routes.spec.ts b/site/tests/routes.spec.ts index d44a9e9f..115ceaec 100644 --- a/site/tests/routes.spec.ts +++ b/site/tests/routes.spec.ts @@ -101,3 +101,13 @@ for (const panel of ["Controls", "Code"]) { expect(await audit(page, isMobile)).toEqual([]); }); } + +// The gallery's facet panel is closed on load: check it open, where ten rows of chips must still fit. +test("/gallery/ (filters open)", async ({ page, isMobile }) => { + if (isMobile) await page.setViewportSize({ width: 360, height: 780 }); + await gotoReady(page, "/gallery/?kind=motion"); + await expect(page.getByRole("group", { name: "Kind" })).toBeVisible(); + // The cards put a stretched "Open" link over live demo content on purpose; this test is about the panel. + await page.addStyleTag({ content: ".gallery__grid { display: none !important }" }); + expect(await audit(page, isMobile)).toEqual([]); +}); From f9bd95b1abd5339819e58064f128e2aa8b40db39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:41:11 -0300 Subject: [PATCH 3/3] fix: type the gallery index entries --- site/src/pages/gallery/index.astro | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/gallery/index.astro b/site/src/pages/gallery/index.astro index 44c6d25a..95618c17 100644 --- a/site/src/pages/gallery/index.astro +++ b/site/src/pages/gallery/index.astro @@ -6,6 +6,7 @@ import Base from "~/layouts/Base.astro"; import { ITEMS } from "~/lib/gallery-items"; import { SECTIONS } from "~/lib/nav"; import catalog from "../../../public/api/v1/catalog.json"; +import type { IndexEntry } from "../../../../packages/ui/scripts/index-build"; import index from "../../../public/api/v1/index.json"; // Only what a card renders: the full catalog (imports, needs, spec, ...) stays in the API. @@ -18,7 +19,7 @@ const groups = ["pages", ...catalog.groups.map((g) => g.slug)]; skipLink={false} > - +