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:
- 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.
- 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.ts — itemMatchesFilter/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
Summary
Add a
tag-filternavigation item — a sidebar facet group (tag chips with live counts), composable like the existingsection/filteritems — 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:
tags(declared in config or in doc frontmatter).tag-filteritems automatically derive their chip options and live counts from sibling items, and selection filters the sidebar items exactly like the existing textfilteritem filters the tree.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
filteritem gains an opt-infilterKeyso 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 groupNew
tagsproperty ondoc,link, andcustom-pageitems (automatic mode)For
docitems (including string shorthands), tags can also come from frontmatter — merged with config tags at resolve time:Extended item:
type: "filter"gainsfilterKeyExample A — automatic mode (zero code): tagged subpages, self-filtering sidebar
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
Architecture: one set of primitives, two modes
?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 propagatelocation.search, so filters persist while browsing filtered subpages; top-nav links drop params, so cross-section navigation resets naturally.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).NavigationTagFiltercomponent (perfilterKey): a page-registered collection (programmable mode) takes precedence; otherwise derived from the current sidebar frame's items that carrytags(automatic mode); configtagsfixes the list/order when provided, derived tags sort by count desc. No registered data and no tagged siblings → plain chips, no counts.shouldShowItem/itemMatchesFilteradditionally checkmatchesTagFilter(item.tags, selection); items withouttagsare unaffected; categories show if any descendant matches (existing recursive semantics).useTagFiltercomputes results + counts and publishes{ counts, discoveredTags }per filterKey to a small zustand store (precedent:sidebarStore.ts) from auseEffectwith unmount cleanup. SSR-safe: effects never run server-side, so the store stays empty on the server — server HTML renders chips from configtags(or nav-derived tags + counts, which are available at SSR); page-driven counts hydrate in client-side.Design decisions
?type=a&type=b), not comma-separated:URLSearchParamspercent-encodes commas (%2C) and values may contain commas;getAll()handles repeats natively.valuedefaults tolabelverbatim (not slugified) so item/page data matches config strings with zero transformation;filterKeydefaults toslugify(label)since it doubles as a URL param name.filterKey— the automatic mode is a default the programmable mode can override.Implementation sketch
Schema layer
packages/zudoku/src/config/validators/InputNavigationSchema.ts— addInputNavigationTagFilterSchema(+ tag union schemaz.string() | { label, value? }); add optionaltagsrecord to doc/link/custom-page schemas; addfilterKeytoInputNavigationFilterSchema; extend the discriminated union +InputNavigationItemtype exports.packages/zudoku/src/config/validators/NavigationSchema.ts— resolvedNavigationTag/NavigationTagFiltertypes (filterKey required, tags normalized); newcase "tag-filter"inresolveItemdefaultingfilterKey: slugify(label)/value: label; merge frontmattertagsinresolveDoc. All plain JSON → survives thejavascript-stringifyserialization inplugin-navigation.ts.Shared primitives + hook
tagFilterStore.ts(zustand, written only from effects — client-only, cleared on unmount).packages/zudoku/src/lib/hooks/useTagFilter.ts— pure helpers + hook; group keys/multiple/searchKey derived by traversing the resolved navigation fortag-filteritems so page and sidebar can't drift; exported from thezudoku/hooksbarrel.Navigation components
NavigationTagFilter.tsx— heading styled likesectionitems + clear-X + optional radixCollapsible; chips as flex-wrappedToggle(sm/outline, same primitives as the api-catalog chips) with mutedtabular-numscount suffix; zero-count chips dimmed but clickable; toggling must not close the mobile drawer.NavigationItem.tsx— newcase "tag-filter"; passfilterKeytoNavigationFilterInput; feed tag selection into theshouldShowItemguard.NavigationFilterInput.tsx— optionalfilterKey: 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.ts—itemMatchesFilter/shouldShowItemaccept the tag selection;tag-filterjoins the always-match list (but, unlikefilter, still respectsdisplay); excludetag-filteringetFirstMatchingPath(otherwise a category whose first child is atag-filterresolves to""and its top-nav link breaks) and inusePrevNext.NavigationCategory.tsx— auto-expand when a tag selection is active (mirror thefilterQueryauto-expand).TopNavigation.tsx/MobileTopNavigation.tsx— addtag-filterto the skip lists.Tests
tagson items,filter.filterKey, missing-label rejection); pure-helper tests (toggle multiple/single/delete-when-empty, AND/OR matching, exclude-own-group counts);shouldShowItemtag-visibility tests.Docs + example
docs/pages/docs/configuration/navigation.mdx: new### type: tag-filtersection covering both modes,filterKeyonfilter,tagson item types, frontmattertags.examples/cosmo-cargo: both modes for preview — an automatic self-filtering category of tagged guide docs (frontmatter tags, zero code), and a programmable/document-librarycustom page (space-themed document data + card grid usinguseTagFilter, "Showing X of Y", clear-all empty state).Edge cases
aria-pressed— if React warns, defer pressed state to post-mount inNavigationTagFilteronly.filternever hides facet groups; the tree filter's frame-change reset touches only its context query, never URL params.filterKeys should be unique per page and avoid params used elsewhere (code/stateauth callbacks); optional dev-time duplicate warning in the resolver.🤖 Generated with Claude Code