Skip to content

RFC: tag-filter sidebar navigation item — faceted tag filtering (automatic + programmable) #2718

Description

@ntotten

Summary

Add a tag-filter navigation item — a sidebar facet group (tag chips with live counts), composable like the existing section/filter items — so the sidebar can act as the search/filter panel for listing-style content.

Motivation: sites built with Zudoku often have library/listing pages (a "Document Library" with dozens of assets, resource hubs, guide collections) where search + tag facets (e.g. TYPE / PRODUCT / THEME, each tag showing a count) currently have to live inline in the page content. These pages look much better — and match familiar faceted-browsing UX — when the sidebar hosts the search box and tag filters.

The feature has two consumption modes built on one set of primitives:

  1. Automatic (nav-driven, zero code): navigation items — the subpages themselves — carry tags (declared in config or in doc frontmatter). tag-filter items automatically derive their chip options and live counts from sibling items, and selection filters the sidebar items exactly like the existing text filter item filters the tree.
  2. Programmable (page-driven): a custom page (e.g. a Document Library card grid) registers its own collection via a new useTagFilter() hook, which filters the collection and publishes live counts to the same sidebar chips.

The automatic mode is a pre-built application of the programmable primitives: both share URL-param selection state, the same matching semantics (AND across groups / OR within a group), the same count computation, and the same chip UI. The existing filter item gains an opt-in filterKey so its search query can also reach page content.

Counts are always dynamic — computed from the live collection against the current query/selection (standard disjunctive faceting) — never authored statically in config.

Configuration API

New item: type: "tag-filter" — one facet group

type NavigationTagFilter = {
  type: "tag-filter";
  label: string;              // group heading, rendered like a `section` (e.g. "Type")
  filterKey?: string;         // URL param name + tag group key; defaults to slugify(label)
  tags?: Array<string | { label: string; value?: string }>;
                              // curated list + order; value defaults to label.
                              // Omitted → derived automatically (from sibling nav items'
                              // tags, or from the page-registered collection).
  multiple?: boolean;         // default true — OR within group, AND across groups
  collapsible?: boolean;      // default false
  collapsed?: boolean;        // default false
  display?: Display;          // same as every other nav item
};

New tags property on doc, link, and custom-page items (automatic mode)

tags?: Record<string, string | string[]>;  // keys = filterKeys
// e.g. { type: "Battle Card", product: ["API Gateway", "MCP"] }

For doc items (including string shorthands), tags can also come from frontmatter — merged with config tags at resolve time:

---
title: Quantum Cargo Battle Card
tags:
  type: Battle Card
  product: [API Gateway, MCP]
---

Extended item: type: "filter" gains filterKey

type NavigationFilter = {
  type: "filter";
  placeholder?: string;
  filterKey?: string;   // NEW: when set, the query also syncs (debounced) to this URL
                        // param so page content can react; omitted = current behavior
                        // (sidebar-tree filtering only), fully backwards compatible
  display?: Display;
};

Example A — automatic mode (zero code): tagged subpages, self-filtering sidebar

{
  type: "category",
  label: "Resources",
  items: [
    { type: "filter", placeholder: "Search resources…" },
    { type: "tag-filter", label: "Type" },     // options + counts derived from siblings
    { type: "tag-filter", label: "Product", multiple: false },
    { type: "separator" },
    { type: "doc", file: "resources/gateway-battle-card",
      tags: { type: "Battle Card", product: "API Gateway" } },
    { type: "doc", file: "resources/ai-gateway-brief",
      tags: { type: "Product Brief", product: "AI Gateway" } },
    "resources/mcp-case-study", // tags from its frontmatter
  ],
}

Selecting "Battle Card" hides non-matching sibling items (recursing into nested categories, same semantics as the text filter) and every group's counts update live. No custom page involved.

Example B — programmable mode: Document Library custom page

{
  type: "category",
  label: "Document Library",
  items: [
    {
      type: "custom-page",
      path: "/document-library",
      label: "All Documents",
      element: <DocumentLibrary />,
    },
    { type: "filter", placeholder: "Search documents…", filterKey: "q" },
    { type: "tag-filter", label: "Type",
      tags: ["Product Brief", "Sales Guide", "Battle Card", "Use Case", "Case Study"] },
    { type: "tag-filter", label: "Product", multiple: false,
      tags: ["API Gateway", "AI Gateway", "MCP"] },
    { type: "tag-filter", label: "Theme", collapsible: true }, // derived from page data
  ],
}
import { useTagFilter } from "zudoku/hooks";
import { documents } from "./documents";

const DocumentLibrary = () => {
  const { results, query, selection, clear, hasActiveFilters } = useTagFilter({
    items: documents,
    tags: (doc) => doc.tags, // Record<string, string | string[]>, keys = filterKeys
    searchText: (doc) => `${doc.title} ${doc.description}`, // powers the `q` param
  });
  // "Showing {results.length} of {documents.length}" … render cards from `results`
  // empty state: <Button onClick={() => clear()}>Clear filters</Button>
};
useTagFilter<T>(options: {
  items: T[];
  tags: (item: T) => Record<string, string | string[] | undefined>;
  searchText?: (item: T) => string;       // enables text filtering via the search param
  keys?: string[];                        // override; default: derived from nav config
  searchKey?: string;                     // default: first `filter` item's filterKey, else "q"
}): {
  results: T[];                           // filtered by query + all tag selections
  query: string;
  setQuery: (q: string) => void;
  selection: Record<string, string[]>;    // { type: ["Battle Card"], product: [...] }
  isSelected: (key: string, value: string) => boolean;
  toggle: (key: string, value: string) => void;  // respects the group's `multiple`
  clear: (key?: string) => void;          // one group, or everything incl. query
  hasActiveFilters: boolean;
}

Architecture: one set of primitives, two modes

  • Selection state = URL search params in both modes, repeated params (?q=pricing&type=Battle+Card&type=Case+Study). Shareable deep links, SSR-safe (no per-request module state), back-button sane ({ replace: true, preventScrollReset: true }), desktop sidebar + mobile drawer stay in sync for free. Sidebar links already propagate location.search, so filters persist while browsing filtered subpages; top-nav links drop params, so cross-section navigation resets naturally.
  • Shared pure helpers (unit-testable, used by both modes): toggleTagValue(params, key, value, multiple), readTagSelection(params, keys), matchesTagFilter(itemTags, selection) (AND across groups / OR within), computeFacetCounts(collection, selection, query) (a tag's count = results if you toggled that chip, i.e. excluding its own group's selection).
  • Chip options + counts resolution in the new NavigationTagFilter component (per filterKey): a page-registered collection (programmable mode) takes precedence; otherwise derived from the current sidebar frame's items that carry tags (automatic mode); config tags fixes the list/order when provided, derived tags sort by count desc. No registered data and no tagged siblings → plain chips, no counts.
  • Sidebar item filtering (automatic mode): tag selection extends the existing text-filter machinery — shouldShowItem/itemMatchesFilter additionally check matchesTagFilter(item.tags, selection); items without tags are unaffected; categories show if any descendant matches (existing recursive semantics).
  • Programmable counts channel: useTagFilter computes results + counts and publishes { counts, discoveredTags } per filterKey to a small zustand store (precedent: sidebarStore.ts) from a useEffect with unmount cleanup. SSR-safe: effects never run server-side, so the store stays empty on the server — server HTML renders chips from config tags (or nav-derived tags + counts, which are available at SSR); page-driven counts hydrate in client-side.

Design decisions

  • Repeated params (?type=a&type=b), not comma-separated: URLSearchParams percent-encodes commas (%2C) and values may contain commas; getAll() handles repeats natively.
  • value defaults to label verbatim (not slugified) so item/page data matches config strings with zero transformation; filterKey defaults to slugify(label) since it doubles as a URL param name.
  • Page-registered data takes precedence over nav-derived for a given filterKey — the automatic mode is a default the programmable mode can override.

Implementation sketch

Schema layer

  • packages/zudoku/src/config/validators/InputNavigationSchema.ts — add InputNavigationTagFilterSchema (+ tag union schema z.string() | { label, value? }); add optional tags record to doc/link/custom-page schemas; add filterKey to InputNavigationFilterSchema; extend the discriminated union + InputNavigationItem type exports.
  • packages/zudoku/src/config/validators/NavigationSchema.ts — resolved NavigationTag/NavigationTagFilter types (filterKey required, tags normalized); new case "tag-filter" in resolveItem defaulting filterKey: slugify(label) / value: label; merge frontmatter tags in resolveDoc. All plain JSON → survives the javascript-stringify serialization in plugin-navigation.ts.

Shared primitives + hook

  • New tagFilterStore.ts (zustand, written only from effects — client-only, cleared on unmount).
  • New packages/zudoku/src/lib/hooks/useTagFilter.ts — pure helpers + hook; group keys/multiple/searchKey derived by traversing the resolved navigation for tag-filter items so page and sidebar can't drift; exported from the zudoku/hooks barrel.

Navigation components

  • New NavigationTagFilter.tsx — heading styled like section items + clear-X + optional radix Collapsible; chips as flex-wrapped Toggle (sm/outline, same primitives as the api-catalog chips) with muted tabular-nums count suffix; zero-count chips dimmed but clickable; toggling must not close the mobile drawer.
  • NavigationItem.tsx — new case "tag-filter"; pass filterKey to NavigationFilterInput; feed tag selection into the shouldShowItem guard.
  • NavigationFilterInput.tsx — optional filterKey: context query stays the live source; hydrate from the URL param on mount/back-forward; debounced (~250 ms) write-through; clear clears both.
  • navigation/utils.tsitemMatchesFilter/shouldShowItem accept the tag selection; tag-filter joins the always-match list (but, unlike filter, still respects display); exclude tag-filter in getFirstMatchingPath (otherwise a category whose first child is a tag-filter resolves to "" and its top-nav link breaks) and in usePrevNext.
  • NavigationCategory.tsx — auto-expand when a tag selection is active (mirror the filterQuery auto-expand).
  • TopNavigation.tsx / MobileTopNavigation.tsx — add tag-filter to the skip lists.

Tests

  • Schema fixtures (string/object/omitted tags, tags on items, filter.filterKey, missing-label rejection); pure-helper tests (toggle multiple/single/delete-when-empty, AND/OR matching, exclude-own-group counts); shouldShowItem tag-visibility tests.

Docs + example

  • docs/pages/docs/configuration/navigation.mdx: new ### type: tag-filter section covering both modes, filterKey on filter, tags on item types, frontmatter tags.
  • examples/cosmo-cargo: both modes for preview — an automatic self-filtering category of tagged guide docs (frontmatter tags, zero code), and a programmable /document-library custom page (space-themed document data + card grid using useTagFilter, "Showing X of Y", clear-all empty state).

Edge cases

  • SSR/prerender: selection via router search params is per-request safe. Automatic-mode counts are computable during SSR; programmable-mode counts hydrate post-mount. SSG HTML is generated without query strings, so deep links may hydrate with mismatched aria-pressed — if React warns, defer pressed state to post-mount in NavigationTagFilter only.
  • Tree-filter + tag-filter combine with AND; typing in a plain filter never hides facet groups; the tree filter's frame-change reset touches only its context query, never URL params.
  • All items filtered out (automatic mode): filter controls stay visible (always-match list) so the user can back out; counts show 0.
  • Param collisions: filterKeys should be unique per page and avoid params used elsewhere (code/state auth callbacks); optional dev-time duplicate warning in the resolver.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions