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..95618c17 100644 --- a/site/src/pages/gallery/index.astro +++ b/site/src/pages/gallery/index.astro @@ -6,6 +6,8 @@ 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. // Pages lead the category list: they are the first thing an owner looks for. @@ -17,7 +19,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([]); +});