@prismetic/article-filters
Built for Prismetic's ITE Strapi-backed Next.js sites. Examples assume that content shape; the filtering core is generic and takes accessors for any other.
Topic / year / sort filtering for CMS article lists.
Three layers, usable independently:
| Layer | Export | What it is |
|---|---|---|
| Pure | filterArticles, sortArticles, derive*, parse*/build* |
No React, no DOM. Plain functions over an array of articles. |
| State | useArticleFilters |
All state, derivation and URL sync. No markup, no design. Used by every site including the MUI ones. |
| UI | ArticleFilterBar |
The shadcn/Radix control row. Every class is a prop; shared defaults ship as plain CSS. |
A site can take all three, take the hook and render its own bar, or take only the pure functions and own everything above them.
Install
npm install @prismetic/article-filters
Then import the defaults once, next to your other global styles:
/* globals.css */
@import "@prismetic/article-filters/styles.css";
There is deliberately no @source directive to add. The defaults are plain
CSS, so Tailwind never has to scan this package — which matters because
node_modules/@prismetic/* is a pnpm symlink locally but a real directory
under Azure's npm install, and a @source path that resolves in one can
emit nothing in the other.
The stylesheet is optional. Without it the bar renders unstyled but fully functional; with it you get layout, the popover panel and the checkbox.
Every rule sits in @layer components. Tailwind v4 emits utilities in
@layer utilities, and an unlayered stylesheet beats any layered rule
regardless of specificity — so without the layer, a classNames utility on a
property the defaults already set (trigger font, panel background, …) would be
silently dead. @import "tailwindcss" declares the layer order first, so the
package rules land below utilities with no extra syntax on the import.
Requirements
| Peer | react >=18 — accurate as declared; nothing here uses a React 19-only API |
| Runtime deps | @radix-ui/react-checkbox, @radix-ui/react-popover (installed for you) |
| Next.js | not a peer dependency — this package never imports from next |
"use client" |
already in the source of useArticleFilters and ArticleFilterBar; you do not add it |
| Tested against | React 19 / Next 16 |
filters.ts is pure and has no React import at all, so the pure layer is safe
to call from a server component or a build script.
Quick start
import {
ArticleFilterBar,
useArticleFilters,
} from "@prismetic/article-filters";
const filters = useArticleFilters(articles);
return (
<>
<ArticleFilterBar
topicOptions={filters.topicOptions}
yearOptions={filters.yearOptions}
selectedTopics={filters.selectedTopics}
selectedYear={filters.selectedYear}
sort={filters.sort}
onToggleTopic={filters.toggleTopic}
onClearTopics={filters.clearTopics}
onYearChange={filters.setYear}
onSortChange={filters.setSort}
>
<button
type="button"
disabled={!filters.canReset}
onClick={filters.resetAll}
>
Reset filters
</button>
</ArticleFilterBar>
{filters.articles.map((article) => (
<Card key={article.id} {...article} />
))}
</>
);
filters.articles is the filtered and sorted list. You render that, not the
array you passed in. Children render last in the bar's row — pass the site's own
Button there for a Reset control.
Real-world usage
With site classes, pagination reset and non-default field names:
"use client";
import { useEffect, useState } from "react";
import {
ArticleFilterBar,
useArticleFilters,
} from "@prismetic/article-filters";
const PAGE_SIZE = 12;
const ArticleList = ({ articles }) => {
const filters = useArticleFilters(articles, {
getTitle: (a) => a.Heading,
getTags: (a) => a.Categories,
});
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
// Any change to the result set drops the reader back to page one.
const { signature } = filters;
useEffect(() => setVisibleCount(PAGE_SIZE), [signature]);
return (
<>
<ArticleFilterBar
topicOptions={filters.topicOptions}
yearOptions={filters.yearOptions}
selectedTopics={filters.selectedTopics}
selectedYear={filters.selectedYear}
sort={filters.sort}
onToggleTopic={filters.toggleTopic}
onClearTopics={filters.clearTopics}
onYearChange={filters.setYear}
onSortChange={filters.setSort}
classNames={{ trigger: "title-30 font-bebas", clear: "text-mainColor2" }}
/>
{filters.articles.slice(0, visibleCount).map((article) => (
<Card key={article.id} {...article} />
))}
{visibleCount < filters.articles.length && (
<button onClick={() => setVisibleCount((n) => n + PAGE_SIZE)}>
Load more
</button>
)}
</>
);
};
API reference
useArticleFilters(source, options?)
useArticleFilters<T>(
source: readonly T[],
options?: UseArticleFiltersOptions<T>,
): UseArticleFiltersResult<T>
| param | type | |
|---|---|---|
source |
readonly T[] |
the full, unfiltered article list |
options |
UseArticleFiltersOptions<T> |
accessor overrides plus syncUrl |
options is ArticleAccessors<T> with one extra key:
| option | type | default | |
|---|---|---|---|
syncUrl |
boolean |
true |
read filters from the query string on mount and write them back on change |
getId |
(a: T) => string | number | null | undefined |
a.id |
used only as a stable tie-break in date sorts |
getTitle |
(a: T) => string | null | undefined |
a.Title |
used by sort: "title" |
getDate |
(a: T) => string | null | undefined |
a.PublishedDate |
must be YYYY-MM-DD-prefixed |
getTags |
(a: T) => ReadonlyArray<{ Name?: string | null } | string> | null | undefined |
a.Tags |
tags may be objects or plain strings |
Returns UseArticleFiltersResult<T>:
| field | type | |
|---|---|---|
articles |
T[] |
filtered and sorted — render this |
topicOptions |
TopicOption[] |
{ slug, name, count }, alphabetical, counts scoped to the selected year |
yearOptions |
string[] |
distinct years, newest first |
selectedTopics |
string[] |
selected topic slugs |
selectedYear |
string |
"" means all years |
sort |
SortKey |
"newest" | "oldest" | "title" |
setTopics |
(slugs: string[]) => void |
replace the whole selection |
toggleTopic |
(slug: string) => void |
add or remove one |
clearTopics |
() => void |
clear topics only |
setYear |
(year: string) => void |
"" clears |
setSort |
(sort: SortKey) => void |
|
resetAll |
() => void |
topics, year and sort back to defaults |
isFiltered |
boolean |
true when a topic or year is active — sort is not counted |
canReset |
boolean |
true when anything differs from the defaults, sort included — drives a Reset button |
signature |
string |
changes whenever the result set changes; use as a useEffect dep to reset pagination |
const filters = useArticleFilters(articles, { syncUrl: false });
Accessors are captured on first render. They are held in a
useRefso inline arrow functions don't re-run every derivation on every render. A consequence: changing an accessor after mount has no effect. Accessors are expected to be structural, not reactive.
<ArticleFilterBar />
The presentational control row. Holds no filter state of its own — every value comes in as a prop and every change goes out as a callback, which is what lets the MUI sites reuse the hook without this component.
| prop | type | default | |
|---|---|---|---|
topicOptions |
TopicOption[] |
— | from filters.topicOptions |
yearOptions |
string[] |
— | from filters.yearOptions |
selectedTopics |
string[] |
— | slugs |
selectedYear |
string |
— | "" for all |
sort |
SortKey |
— | |
onToggleTopic |
(slug: string) => void |
— | |
onClearTopics |
() => void |
— | |
onYearChange |
(year: string) => void |
— | |
onSortChange |
(sort: SortKey) => void |
— | |
classNames |
ArticleFilterBarClassNames |
{} |
merged after the BEM defaults |
labels |
ArticleFilterBarLabels |
English | per-key override, see below |
sortOptions |
{ value: SortKey; label: string }[] |
SORT_OPTIONS |
site-specific or translated sort wording |
children |
ReactNode |
— | rendered last in the row, after Sort, with no wrapper — e.g. a Reset button |
classNames keys — each maps onto the BEM class it sits beside:
| key | element | default class |
|---|---|---|
root |
the bar | article-filter-bar |
control |
one control wrapper | article-filter-bar__control |
trigger |
the button that opens a panel | article-filter-bar__trigger |
triggerLabel |
text inside the trigger | article-filter-bar__trigger-label |
triggerIcon |
the chevron | article-filter-bar__icon |
panel |
the popover content | article-filter-bar__panel |
option |
one row in a panel | article-filter-bar__option |
optionLabel |
that row's text | article-filter-bar__option-label |
count |
the facet count | article-filter-bar__count |
checkbox |
the Radix checkbox | article-filter-bar__checkbox |
clear |
the Clear button | article-filter-bar__clear |
Your class is appended, so a Tailwind utility overrides the default without
!important as long as it wins on specificity.
labels keys — all optional, merged over the English defaults:
| key | type | default |
|---|---|---|
allTopics |
string |
"All topics" |
allYears |
string |
"All years" |
clear |
string |
"Clear" |
topicsSelected |
(count: number) => string |
(n) => `${n} topics` |
topicsAriaLabel |
string |
"Filter articles by topic" |
yearsAriaLabel |
string |
"Filter articles by year" |
sortAriaLabel |
string |
"Sort articles" |
<ArticleFilterBar
{...props}
labels={{
allTopics: "Все темы",
allYears: "Все годы",
clear: "Сбросить",
topicsSelected: (n) => `Выбрано: ${n}`,
}}
sortOptions={[
{ value: "newest", label: "Сначала новые" },
{ value: "oldest", label: "Сначала старые" },
{ value: "title", label: "По названию" },
]}
/>
Accessibility. Each panel is a role="listbox" labelled by its trigger, and
each row a role="option" carrying aria-selected. Arrow Up / Arrow Down move
focus between rows, Home and End jump to the ends, and focus is deliberately
not pulled into the panel on open — the trigger keeps it so the label stays
announced. The checkbox is aria-hidden because the row it sits in already
reports selection.
Accessors
defaultAccessors()
defaultAccessors<T>(): Required<ArticleAccessors<T>>
The fleet's dominant Strapi shape: id, Title, PublishedDate, Tags[].Name.
Takes no arguments — it returns a fresh object each call.
resolveAccessors(overrides?)
resolveAccessors<T>(overrides?: ArticleAccessors<T>): Required<ArticleAccessors<T>>
Fills any gaps in overrides from defaultAccessors(). Every pure function
below takes the resolved form, so this is the first call when using the pure
layer directly:
const acc = resolveAccessors<Article>({ getTitle: (a) => a.Heading });
filterArticles(articles, ["dairy"], "", acc);
Derivation
slugifyTopic(name)
slugifyTopic(name: string): string
Trims, lowercases, and collapses every run of non-alphanumerics to a single
dash, with leading and trailing dashes stripped. Latin and Cyrillic are kept;
the character class is written as explicit ranges rather than \p{L} so the
output is identical under every repo's tsconfig target.
slugifyTopic("Grocery "); // "grocery"
slugifyTopic("Tea, Coffee & Cacao"); // "tea-coffee-cacao"
slugifyTopic("Молочная продукция"); // "молочная-продукция"
slugifyTopic(" — "); // "" — nothing sluggable
articleYear(article, accessors)
articleYear<T>(article: T, accessors: Required<ArticleAccessors<T>>): string
The first four characters of getDate(article), or "". Returns a string, not
a number. For an ISO datetime (2025-01-01T03:30:00.000Z) that is the UTC year.
articleTopicSlugs(article, accessors)
articleTopicSlugs<T>(article: T, accessors: Required<ArticleAccessors<T>>): string[]
Every tag on one article, slugified, with empties dropped. Handles tags given as
{ Name } objects or as plain strings.
deriveTopicNames(articles, accessors)
deriveTopicNames<T>(
articles: readonly T[],
accessors: Required<ArticleAccessors<T>>,
): { slug: string; name: string }[]
Every distinct tag across the corpus, alphabetical by name. Two names that slug identically merge and the first one encountered wins the display name.
deriveTopicNames(articles, acc);
// [{ slug: "baking", name: "Baking" },
// { slug: "cheese", name: "Cheese" },
// { slug: "seafood", name: "Seafood" }]
// "Grocery " and "Grocery" both slug to "grocery":
// [{ slug: "grocery", name: "Grocery" }]
deriveYears(articles, accessors)
deriveYears<T>(
articles: readonly T[],
accessors: Required<ArticleAccessors<T>>,
): string[]
Distinct years, deduplicated, newest first.
deriveYears(articles, acc); // ["2026", "2024"]
deriveTopicOptions(articles, topicNames, selectedYear, accessors)
deriveTopicOptions<T>(
articles: readonly T[],
topicNames: { slug: string; name: string }[],
selectedYear: string,
accessors: Required<ArticleAccessors<T>>,
): TopicOption[]
Attaches a live facet count to each topic. Counts honour selectedYear but
ignore the topic selection. Every topic stays in the list even at count 0, so
the panel never changes length.
const names = deriveTopicNames(articles, acc);
deriveTopicOptions(articles, names, "", acc);
// [{ slug: "cheese", name: "Cheese", count: 2 },
// { slug: "dairy", name: "Dairy", count: 2 }]
deriveTopicOptions(articles, names, "2026", acc);
// [{ slug: "cheese", ..., count: 0 },
// { slug: "dairy", ..., count: 1 }]
Filtering and sorting
filterArticles(articles, selectedTopics, selectedYear, accessors)
filterArticles<T>(
articles: readonly T[],
selectedTopics: readonly string[],
selectedYear: string,
accessors: Required<ArticleAccessors<T>>,
): T[]
| param | ||
|---|---|---|
selectedTopics |
readonly string[] |
slugs, not names; [] means no topic filter |
selectedYear |
string |
"" means no year filter |
Topics union, the year intersects. Returns a new array.
filterArticles(articles, [], "", acc); // everything
filterArticles(articles, ["dairy", "cheese"], "", acc); // Dairy OR Cheese
filterArticles(articles, ["dairy", "cheese"], "2025", acc); // (Dairy OR Cheese) AND 2025
Once any topic is selected, untagged articles drop out.
sortArticles(articles, sort, accessors)
sortArticles<T>(
articles: readonly T[],
sort: SortKey,
accessors: Required<ArticleAccessors<T>>,
): T[]
Returns a new array; does not mutate its input.
sort |
order |
|---|---|
"newest" |
getDate descending, then getId ascending as a stable tie-break |
"oldest" |
getDate ascending, same tie-break — so same-day articles keep one fixed order in both directions |
"title" |
getTitle via localeCompare with numeric: true and sensitivity: "base" — case-insensitive, and Item 2 before Item 10 |
// ids "10" and "2" share 2026-07-27; "7" is 2024-01-01
sortArticles(articles, "newest", acc).map((a) => a.id); // ["2", "10", "7"]
sortArticles(articles, "oldest", acc).map((a) => a.id); // ["7", "2", "10"]
sortArticles(articles, "title", acc).map((a) => a.Title); // ["apple", "Mango", "Zebra"]
URL state
parseFilterParams(search, knownTopics, knownYears)
parseFilterParams(
search: string,
knownTopics: readonly string[],
knownYears: readonly string[],
): Partial<FilterState>
Reads ?topics=a,b&year=2025&sort=title. Returns a Partial — a key is
present only when the URL carried a value that survived validation, so the
result can be spread over current state without clobbering it.
Values absent from the current data are dropped rather than applied, which is what stops a stale shared link from rendering an empty list.
parseFilterParams("?topics=dairy,gone", topics, years).topics; // ["dairy"]
parseFilterParams("?year=1999", topics, years).year; // undefined
parseFilterParams("?sort=bogus", topics, years).sort; // undefined
parseFilterParams("?sort=title", topics, years).sort; // "title"
parseFilterParams("", topics, years); // {}
buildFilterSearch(search, state)
buildFilterSearch(search: string, state: FilterState): string
Takes the current query string and returns a new one with the filter params
brought in line with state. Defaults write no param, and unrelated params are
preserved. Returns "" (not "?") when nothing is left.
buildFilterSearch("", { topics: [], year: "", sort: "newest" });
// ""
buildFilterSearch("", { topics: ["dairy", "cheese"], year: "2025", sort: "oldest" });
// "?topics=dairy%2Ccheese&year=2025&sort=oldest"
buildFilterSearch("?utm_source=x", { topics: ["dairy"], year: "", sort: "newest" });
// "?utm_source=x&topics=dairy"
buildFilterSearch("?topics=dairy&year=2025", { topics: [], year: "", sort: "newest" });
// ""
The comma between slugs is percent-encoded to %2C by URLSearchParams;
parseFilterParams decodes it back.
canResetFilters(state)
canResetFilters(state: FilterState): boolean
True when topics, year or sort differ from the defaults. The hook exposes it
as canReset.
canResetFilters({ topics: [], year: "", sort: "newest" }); // false
canResetFilters({ topics: [], year: "", sort: "oldest" }); // true
Constants
SORT_OPTIONS
const SORT_OPTIONS: SortOption[] = [
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "title", label: "Title A–Z" },
];
The default for ArticleFilterBar's sortOptions prop. Pass your own for
translated wording rather than mutating this array.
DEFAULT_SORT
const DEFAULT_SORT: SortKey = "newest";
The initial sort, and the value buildFilterSearch treats as "write no param".
Type exports
SortKey · SortOption · TopicOption · ArticleAccessors<T> · FilterState ·
UseArticleFiltersOptions<T> · UseArticleFiltersResult<T> ·
ArticleFilterBarProps · ArticleFilterBarClassNames · ArticleFilterBarLabels
Each is expanded in the table of the function or component that consumes it.
FilterState is the only one not shown above:
interface FilterState {
topics: string[];
year: string;
sort: SortKey;
}
Note that parseFilterParams returns Partial<FilterState> while
buildFilterSearch requires a complete one.
Behaviour
- Multiple topics union (Dairy OR Cheese); the year intersects.
- Topic counts are live facets: they honour the selected year but ignore the topic selection, so ticking a box never collapses the other counts to zero.
- Topics are listed alphabetically, not by count, so the panel does not reshuffle when the year changes.
- A control with fewer than 2 options renders nothing. Sort always renders.
- The trigger label reflects the selection —
All topics→Dairy→2 topics— so an active filter is visible once the panel closes. - URL state is written with
history.replaceState, defaults omitted. Unrelated params (utm tags) are preserved. Values absent from the current data are dropped on read rather than applied. - The year is sliced off the ISO date or datetime string (
2025-01-01or2025-01-01T03:30:00.000Z— the UTC year), not read throughnew Date(), which would shift it by the viewer's timezone.
Why the URL is read in an effect, not during render
The incoming query string is applied after mount, behind a ref that fires once. Applying it during render would make the first client paint diverge from the statically exported HTML, and the write-back effect is guarded on the same ref so it cannot erase the incoming link before it has been read.
replaceState rather than pushState: a static export has no server to hand a
new URL to, and pushing an entry per click would make Back walk the filter
history instead of leaving the page.
Why the panel is a non-modal Popover
Radix's modal scroll lock pads <body> to compensate for a scrollbar it
removes. These sites scroll on <html> (their globals.css puts overflow-x
on body), so the scrollbar never goes away and the compensation lands as a
pure width change — shifting the centred container sideways every time a panel
opens. Non-modal avoids the whole class of bug. The MUI bar needs
disableScrollLock: true for the same reason.
The panel uses updatePositionStrategy="always": GSAP ScrollSmoother keeps
moving the trigger by transform after scroll events stop, which Radix's default
"optimized" strategy misses. The rAF loop runs only while a panel is open.
Limits
- Renaming a tag in Strapi changes its slug and breaks already-shared
?topics=links. The old slug is dropped on read, so the link degrades to an unfiltered list rather than an empty one — but it no longer filters. - Accessors are frozen at first render. See the note under
useArticleFilters; they are read once into a ref. slugifyTopickeeps Latin and Cyrillic only. A tag in any other script slugs to""and is dropped from the topic list entirely.getDatemust be an ISO (YYYY-MM-DD-prefixed) string. ADateobject or a non-ISO format yields a garbage year, because the year is sliced off the string rather than parsed.isFilteredignores sort. Changing sort alone leaves itfalse. UsecanResetfor a Reset button that should also undo a sort change.- No pagination.
signatureexists so you can reset your own.
Development
npm test -w article-filters # vitest — 31 over the pure layer, 2 over the bar's slot
npm run build -w article-filters # tsc, emits dist/
src/article-filters.test.ts covers filters.ts; src/ArticleFilterBar.test.tsx
renders the bar with renderToStaticMarkup to pin the children slot. The hook
has no test coverage. Every example in the API reference above is taken
from a passing assertion in that file.