Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gallery-facet-search.md
Original file line number Diff line number Diff line change
@@ -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.
154 changes: 140 additions & 14 deletions site/src/components/gallery/Gallery.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@
announce,
Button,
Checkbox,
Chip,
Collapsible,
CopyButton,
StickyActions,
TextField,
ToggleGroup,
} 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";
Expand All @@ -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<FilterFacet, string> = {
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<Filters>({});
let facetsOpen = $state(false);
let picks = $state<string[]>([]);
let callback = $state<string | null>(null);
let scheme = $state<"light" | "dark">("dark");
Expand All @@ -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));
Expand All @@ -73,13 +118,45 @@
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(",")}`;
}

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();
Expand All @@ -105,14 +182,43 @@
</aside>

<div class="gallery__filters">
<TextField label="Filter demos" placeholder="Search title, tag or component" bind:value={query} />
<TextField
label="Filter demos"
placeholder="Search title, alias, tag or component"
bind:value={query}
oninput={syncUrl}
/>
<ToggleGroup
type="single"
aria-label="Group"
items={groupItems}
value={group}
onchange={(v) => (group = (v as string) || "all")}
onchange={(v) => {
group = (v as string) || "all";
syncUrl();
}}
/>
<Collapsible title="Filters" bind:open={facetsOpen}>
{#each CHIP_FACETS as facet (facet)}
<!-- chipCounts lists only values that still match something, so an empty combination has no chip; a chosen value stays so it can be unchosen. -->
{@const values = [...new Set([...Object.keys(counts[facet]), ...(filters[facet] ?? [])])].sort()}
{#if values.length}
<div class="gallery__facet" role="group" aria-label={FACET_TITLES[facet]}>
<span class="gallery__facet-title sk-caption" aria-hidden="true">{FACET_TITLES[facet]}</span>
{#each values as value (value)}
<Chip
label={`${value} (${counts[facet][value] ?? 0})`}
selected={filters[facet]?.includes(value) ?? false}
onclick={() => toggleValue(facet, value)}
/>
{/each}
</div>
{/if}
{/each}
</Collapsible>
{#if facetActive || query || group !== "all"}
<Button variant="outlined" onclick={clearAll}>Clear filters</Button>
{/if}
</div>

<p class="gallery__count">{visible.length} of {elements.length} shown</p>
Expand Down Expand Up @@ -153,7 +259,9 @@
</ul>

{#if visible.length === 0}
<p class="gallery__empty">No demo matches. Clear the filter to see all {elements.length}.</p>
<p class="gallery__empty">
No demo matches. <button type="button" class="gallery__link" onclick={clearAll}>Clear the filters</button> to see all {elements.length}.
</p>
{/if}

<StickyActions show={picks.length > 0} align="end">
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion site/src/pages/gallery/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,7 +19,7 @@ const groups = ["pages", ...catalog.groups.map((g) => g.slug)];
skipLink={false}
>
<SiteDocsShell client:load sections={SECTIONS} active="gallery" pathname={Astro.url.pathname}>
<Gallery client:load elements={ITEMS} {groups} />
<Gallery client:load elements={ITEMS} {groups} entries={index.entries as IndexEntry[]} />
</SiteDocsShell>
<SiteFooter />
</Base>
47 changes: 47 additions & 0 deletions site/tests/gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
10 changes: 10 additions & 0 deletions site/tests/routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});