a11y-filter-summary
v1.0.0
Published
Accessible active-filter summary plugin with removable filter chips, clear-all behavior, icon text decoration, and opt-in addons.
Maintainers
Readme
A11yFilterSummary
Accessible active-filter summary plugin with removable filter chips, clear-all behavior, optional form-source integration, and opt-in addons.
A11yFilterSummary can work in two core modes. In manual mode, it renders the filter state your application gives it through update() and only emits remove/clear intent events. In form-source mode, it can read active values from a bound form, generate chips, and update that form when a chip or clear-all button is activated. URL query-state and grouped range summaries are available as separate addons so the main runtime does not read browser history or collapse form fields unless you opt in.
Installation
npm install a11y-filter-summarypnpm add a11y-filter-summaryyarn add a11y-filter-summaryUsage
Manual State Mode
import {
createFilterSummary,
type FilterSummaryItemInput
} from "a11y-filter-summary";
import "a11y-filter-summary/styles.css";
const root = document.querySelector<HTMLElement>("[data-a11y-filter-summary]");
if (root) {
const summary = createFilterSummary(root);
let filters: FilterSummaryItemInput[] = [
{ name: "type", value: "Image", groupLabel: "Type" }
];
root.addEventListener("a11y-filter-summary:remove", (event) => {
const { name, value } = event.detail;
filters = filters.filter(
(filter) => filter.name !== name || filter.value !== value
);
summary.update(filters);
});
root.addEventListener("a11y-filter-summary:clear", () => {
filters = [];
summary.update(filters);
});
summary.update(filters);
}Form Source Mode
Use form mode when you want the plug-and-play path: provide a normal HTML form and let the plugin derive the active filter summary from labeled controls.
import { createFormFilterSummary } from "a11y-filter-summary";
import "a11y-filter-summary/styles.css";
const root = document.querySelector<HTMLElement>("[data-a11y-filter-summary]");
const form = document.querySelector<HTMLFormElement>("#media-filters");
if (root && form) {
const summary = createFormFilterSummary(root, form, {
updateOn: "input",
ignoredDefaults: {
sort: "relevance"
}
});
summary.update();
}Equivalent setup through the main factory:
createFilterSummary(root, {
source: "form",
form: "#media-filters"
});URL State Addon
Use the URL-state addon when active filters should come from query parameters and chip removal or clear-all should update the address bar.
import { createFilterSummary } from "a11y-filter-summary";
import { createUrlStateAddon } from "a11y-filter-summary/addons/url-state";
import "a11y-filter-summary/styles.css";
const root = document.querySelector<HTMLElement>("[data-a11y-filter-summary]");
if (root) {
createFilterSummary(root, {
addons: [
createUrlStateAddon({
filterParams: ["category", "brand", "size", "sort", "page"],
ignoredDefaults: {
sort: "relevance",
page: "1"
},
labels: {
category: "Category",
brand: "Brand",
size: "Size"
}
})
]
});
}The addon is not imported by the main package entry. It reads window.location.search on init and popstate, renders repeated params as separate chips, and updates the URL with pushState by default when a chip or clear-all button is activated. Set historyMode: "replace" to use replaceState.
URL state is visible in the address bar, browser history, shared links, referrers, and potentially server logs. Do not use the addon for sensitive filters or personal data.
Range Groups Addon
Use the range-groups addon when paired form controls should appear as one readable active-filter chip, such as minimum and maximum price or start and end dates.
import { createFormFilterSummary } from "a11y-filter-summary";
import { createRangeGroupsAddon } from "a11y-filter-summary/addons/range-groups";
import "a11y-filter-summary/styles.css";
const root = document.querySelector<HTMLElement>("[data-a11y-filter-summary]");
const form = document.querySelector<HTMLFormElement>("#catalog-filters");
if (root && form) {
createFormFilterSummary(root, form, {
labels: {
minRating: "Rating"
},
formatFormValue: ({ name, value }) =>
name === "minRating" ? `${value} stars and up` : undefined,
addons: [
createRangeGroupsAddon({
form,
groups: [
{
key: "price",
label: "Price",
fields: ["minPrice", "maxPrice"],
formatter: ([min, max]) => {
if (min && max) return `€${min}-€${max}`;
if (min) return `€${min} and up`;
if (max) return `Up to €${max}`;
return "";
}
},
{
key: "date",
label: "Date",
fields: ["startDate", "endDate"],
formatter: ([start, end]) => {
if (start && end) return `${start} to ${end}`;
if (start) return `${start} and later`;
if (end) return `Until ${end}`;
return "";
}
}
]
})
]
});
}The addon is not imported by the main package entry. It transforms normalized form filters before render, keeps unrelated filters as separate chips, and clears every configured source control when a grouped chip is removed. If no form is provided and no ancestor form can be resolved, grouped chips can still render, but chip removal remains host-owned.
Icon Text Addon
Use the icon-text addon when chip labels should include decorative icons for faster scanning while keeping the text label as the source of truth.
import { createFilterSummary } from "a11y-filter-summary";
import { createIconTextAddon } from "a11y-filter-summary/addons/icon-text";
import "a11y-filter-summary/styles.css";
const root = document.querySelector("[data-a11y-filter-summary]");
const summary = createFilterSummary(root, {
addons: [
createIconTextAddon({
// Use icons for stable mappings by filter.name.
icons: {
assetType: "image",
license: "shield"
},
// Use resolveIcon when the icon depends on the current filter value.
resolveIcon: ({ filter }) => {
if (filter.name !== "resultState") return undefined;
switch (String(filter.value).toLowerCase()) {
case "syncing":
return "sync";
case "synced":
return "check";
case "sync failed":
case "error":
return "error";
case "needs review":
case "warning":
return "alert";
case "no matches":
case "empty":
return "empty";
default:
return "clock";
}
}
})
]
});
summary.update([
{ name: "assetType", value: "Image", groupLabel: "Type" },
{ name: "license", value: "Creative Commons", groupLabel: "License" },
{ name: "resultState", value: "Syncing", groupLabel: "State" }
]);The rendered chips keep their full text, such as State: Syncing; the addon only prepends decorative SVG icons with aria-hidden="true". It does not replace label text, remove button names, event detail, or live-region messages.
CSS
Import the default CSS when you want baseline layout, chip styling, visible focus states, disabled state styling, and polite live-region hiding.
import "a11y-filter-summary/styles.css";Public custom properties:
--a11y-filter-summary-gap--a11y-filter-summary-chip-background--a11y-filter-summary-chip-color--a11y-filter-summary-chip-border--a11y-filter-summary-focus-ring
HTML Structure
Start with a semantic section or region. The plugin creates a visible heading, list, clear-all button, empty message, and polite status region when renderMode is "replace".
<section data-a11y-filter-summary></section>For form-source mode, bind the summary to a form in JavaScript or with data attributes:
<form id="media-filters">
<section
data-a11y-filter-summary
data-a11y-filter-summary-source="form"
data-a11y-filter-summary-form="#media-filters"
></section>
</form>Add data-a11y-filter-summary-ignore to a control that should never become a visible chip.
If you use renderMode: "sync-existing", the plugin reuses matching elements where possible:
<section data-a11y-filter-summary>
<h2 data-a11y-filter-summary-heading>Active filters</h2>
<ul data-a11y-filter-summary-list></ul>
<p data-a11y-filter-summary-empty>No active filters.</p>
<button type="button" data-a11y-filter-summary-clear>Clear all filters</button>
<div data-a11y-filter-summary-status role="status" aria-live="polite" aria-atomic="true"></div>
</section>API
import {
A11yFilterSummary,
createFormFilterSummary,
createFilterSummary,
initFilterSummaryAll
} from "a11y-filter-summary";
import { createRangeGroupsAddon } from "a11y-filter-summary/addons/range-groups";
import { createUrlStateAddon } from "a11y-filter-summary/addons/url-state";
import { createIconTextAddon } from "a11y-filter-summary/addons/icon-text";new A11yFilterSummary(root, options?): creates an instance. Duplicate construction for the same root returns the existing instance.createFilterSummary(root, options?): creates and initializes one instance.createFormFilterSummary(root, form, options?): creates and initializes one form-source instance.initFilterSummaryAll(options?): initializes all[data-a11y-filter-summary]roots.update(filters?, context?): renders active filters from an array or object map. In form-source mode, calling without filters rereads the bound form.clear(): emits clear intent in manual mode. In form-source mode, resets or clears the bound form and rerenders.setEmptyState(message?): updates the empty-state message.enable()/disable(): toggles action availability.setLocale(locale, messages?): switches to a locale identifier or locale catalog and rerenders generated text.t(key, params?): formats a localized message.registerAddon(addon)/unregisterAddon(name): manages optional hooks.getAddon(name)/getAddons(): reads registered addons.destroy(): removes listeners, generated DOM, state classes, and the duplicate-init cache entry.createUrlStateAddon(options?): creates the optional URL-state addon froma11y-filter-summary/addons/url-state.createRangeGroupsAddon(options): creates the optional grouped range addon froma11y-filter-summary/addons/range-groups.createIconTextAddon(options?): creates the optional icon text addon froma11y-filter-summary/addons/icon-text.
Options
interface FilterSummaryOptions {
locale?: string | FilterSummaryLocale;
messages?: Partial<FilterSummaryMessages>;
source?: "manual" | "form";
form?: HTMLFormElement | string;
labels?: Record<string, string>;
ignoredDefaults?: Record<string, unknown | unknown[]>;
clearMode?: "reset" | "empty";
updateOn?: "change" | "input";
formatFormValue?: (context: FilterSummaryFormValueContext) => string | undefined;
renderMode?: "replace" | "sync-existing";
showWhenEmpty?: boolean;
visuallyHideGroupLabels?: boolean;
clearAll?: boolean;
disabled?: boolean;
restoreFocusAfterRemove?: boolean;
focusStrategy?: "next" | "previous" | "clear" | "root" | "none";
headingLevel?: 2 | 3 | 4 | 5 | 6;
announceUpdates?: boolean;
announcementClearDelay?: number;
formatFilterLabel?: (filter: FilterSummaryItem) => string;
addons?: FilterSummaryAddon[];
}Generated headings use headingLevel: 2 by default. Set visuallyHideGroupLabels: true to show chip values while keeping group labels available to assistive technologies, for example Image visually with Type: Image as the text alternative. Set clearMode: "empty" in form-source mode when clear-all should empty controls instead of using the form's native reset behavior. Set announceUpdates: false when another component owns filter or result announcements. announcementClearDelay controls how long transient status messages remain in the live region; the default is 1500 milliseconds.
Root data attributes can configure string, number, and boolean options for no-build markup. Use JavaScript options for messages, labels, ignoredDefaults, formatFormValue, formatFilterLabel, and addons.
Localization
English is built into the core. German, Spanish, Italian, French, and Greek are available as opt-in entry points, so applications only bundle the catalogs they import:
import { createFilterSummary } from "a11y-filter-summary";
import { el } from "a11y-filter-summary/locales/el";
const summary = createFilterSummary(document.querySelector("[data-a11y-filter-summary]"), {
locale: el
});
// Runtime language switching updates generated UI text and accessible names.
summary.setLocale(el);Available catalog imports are locales/de, locales/es, locales/it, locales/fr, and locales/el. Catalogs use Intl.PluralRules for active-filter count announcements. Message values may be strings with placeholders or formatter functions:
createFilterSummary(root, {
locale: "el",
messages: {
clearAll: "Καθαρισμός όλων των φίλτρων",
removeFilterWithGroup: ({ group, value }, { locale }) =>
`${locale}: ${group} — ${value}`
}
});The plugin translates only text it owns. Translate filter names and values through localized form labels and options, the labels option, explicit groupLabel/label values, formatFormValue, or formatFilterLabel. Object-map keys are machine identifiers and humanize() cannot translate them, so localized interfaces should not rely on inferred group labels.
Set the correct lang and, for right-to-left languages, dir on the document or containing element. The plugin does not change either attribute. The icon-text addon's inference recognizes English-like machine tokens; icons are decorative, and localized applications can provide explicit icons or resolveIcon mappings.
Input
Array input:
summary.update([
{ name: "type", value: "Image", groupLabel: "Type" },
{ name: "license", value: "Creative Commons", groupLabel: "License" }
]);Object-map input:
summary.update({
type: "Image",
license: ["Creative Commons", "Editorial"]
});The plugin normalizes each active filter to name, value, label, groupLabel, removeLabel, optional count, and optional meta.
Form-source mode reads text/search/number/date-like inputs, textareas, selects, checkboxes, and radios. It ignores disabled controls, buttons, submit/reset controls, file inputs, password inputs, hidden inputs, empty values, and controls marked with data-a11y-filter-summary-ignore. Labels are derived from labels, fieldset legends, explicit labels, wrapping labels, then a humanized control name.
URL-state addon mode reads query params from window.location.search. If filterParams is provided, only those params are summarized and their order is preserved. If it is omitted, all query keys are considered except empty values and configured ignoredDefaults. Repeated params become separate chips, and formatValue can customize visible chip text without changing the URL value used for removal.
Range-groups addon mode works with form-source summaries. It receives the normalized form filters, replaces configured field sets with one synthetic chip when the formatter returns text, and leaves unrelated filters unchanged. Removing a grouped chip clears each configured source control and rerenders the form summary.
Events
All public plugin events bubble and include { instance } in detail.
a11y-filter-summary:inita11y-filter-summary:readya11y-filter-summary:updatea11y-filter-summary:removea11y-filter-summary:cleara11y-filter-summary:emptya11y-filter-summary:errora11y-filter-summary:addon-errora11y-filter-summary:destroy
Update detail includes filters and optional context. Remove detail includes name, value, label, item, and trigger. Clear detail includes trigger when a clear button caused the event. Error and addon-error details include the thrown error; addon-error details also include the addon name and hook.
Selectors
- Root:
[data-a11y-filter-summary] - Heading:
[data-a11y-filter-summary-heading] - List:
[data-a11y-filter-summary-list] - Item:
[data-a11y-filter-summary-item] - Remove:
[data-a11y-filter-summary-remove] - Clear:
[data-a11y-filter-summary-clear] - Empty:
[data-a11y-filter-summary-empty] - Status:
[data-a11y-filter-summary-status] - Form source:
data-a11y-filter-summary-source="form" - Form selector:
data-a11y-filter-summary-form="#filters" - Render mode:
data-a11y-filter-summary-render-mode="sync-existing" - Clear mode:
data-a11y-filter-summary-clear-mode="empty" - Update trigger:
data-a11y-filter-summary-update-on="input" - Show when empty:
data-a11y-filter-summary-show-when-empty="false" - Clear all:
data-a11y-filter-summary-clear-all="false" - Disabled:
data-a11y-filter-summary-disabled="true" - Restore focus:
data-a11y-filter-summary-restore-focus-after-remove="false" - Focus strategy:
data-a11y-filter-summary-focus-strategy="previous" - Heading level:
data-a11y-filter-summary-heading-level="3" - Locale:
data-a11y-filter-summary-locale="en" - Visually hidden group labels:
data-a11y-filter-summary-visually-hide-group-labels="true" - Announcements:
data-a11y-filter-summary-announce-updates="false" - Announcement clear delay:
data-a11y-filter-summary-announcement-clear-delay="1500" - Ignored form control:
[data-a11y-filter-summary-ignore]
Boolean data attributes accept empty string, true, 1, yes, or on as true values and false, 0, no, or off as false values.
Accessibility Notes
- Uses a semantic list for active filters and real
button type="button"controls for removal and clear-all. - Each remove button receives a descriptive accessible name, such as
Remove Type: Image. - Does not use
aria-pressed, because removal is a momentary action. - Does not steal focus during ordinary
update()calls. - After the host removes a chip in response to a remove event, focus can move predictably based on
focusStrategy. - After clear-all removes the focused clear button, focus moves to a safe fallback when focus was already inside the summary.
- Uses a
role="status"polite, atomic live region for short transient status updates and avoids result-count announcements. - Enabled summaries do not carry
aria-disabled; disabled summaries addaria-disabled="true"on the root and nativedisabledon buttons. - Form-source mode only summarizes values that are already present in the bound form. Do not bind sensitive controls whose values should not become visible.
The included example demonstrates accessible structure and keyboard-friendly controls. Test with your target browsers and assistive technologies before shipping in a product.
Keyboard Behavior
Tabreaches each remove button and the clear-all button in DOM order.EnterandSpaceactivate focused buttons through native button behavior.- Focus restoration after removal can target the next remove button, previous remove button, clear-all button, the root region, or no forced target.
- Focus restoration after clear-all prefers a still-visible clear button, then the root region.
Addons
Addons can observe lifecycle hooks or transform normalized filters before render. Hook failures are isolated and dispatch a11y-filter-summary:addon-error.
summary.registerAddon({
name: "analytics",
afterRemove({ item }) {
console.log("remove requested", item.name, item.value);
}
});Supported hooks:
beforeInitafterInitbeforeUpdateafterUpdatetransformFiltersbeforeRenderafterRenderbeforeRemoveafterRemovebeforeClearafterClearbeforeDestroyafterDestroyonError
transformFilters receives normalized FilterSummaryItem[] and may return a replacement array. Return undefined to keep the current filter set.
URL-state addon options
interface UrlStateAddonOptions {
filterParams?: string[];
ignoredDefaults?: Record<string, string | string[]>;
labels?: Record<string, string>;
historyMode?: "push" | "replace";
formatValue?: (context: {
name: string;
value: string;
params: URLSearchParams;
}) => string | undefined;
}Range-groups addon options
interface RangeGroupsAddonOptions {
form?: HTMLFormElement | string;
groups: Array<{
key: string;
label: string;
fields: string[];
formatter: (values: unknown[], context: RangeGroupFormatterContext) => string | undefined;
removeLabel?: string | ((context: RangeGroupFormatterContext & {
displayValue: string;
labelText: string;
}) => string);
}>;
}Icon-text addon options
type IconTextIconName =
| "alert"
| "calendar"
| "check"
| "clock"
| "crop"
| "empty"
| "error"
| "file"
| "filter"
| "image"
| "palette"
| "search"
| "shield"
| "star"
| "sync"
| "tag"
| "video"
| "wallet";
interface IconTextAddonOptions {
icons?: Partial<Record<string, IconTextIconName>>;
resolveIcon?: (context: {
filter: FilterSummaryItem;
index: number;
root: HTMLElement;
}) => IconTextIconName | false | undefined;
}Limitations
A11yFilterSummary intentionally does not filter content, own application result state, group range fields by default, choose product-specific icon semantics, manage mobile filter drawers, or announce result counts. Pair it with your application state, search/filter logic, and any separate result-count component. Form-source mode mutates the bound form only for chip removal and clear-all behavior. URL parameters are read and written only when the separate URL-state addon is imported and registered. Range fields are collapsed only when the separate range-groups addon is imported and registered. Chip icons are added only when the separate icon-text addon is imported and registered.
Examples
- Basic manual-state summary
- Form-driven summary
- Visually hidden group labels
- URL-state summary addon
- Grouped range filters addon
- Icon text summary addon
- Localized product filters
GitHub Pages
This repository is prepared for GitHub Pages branch publishing from /docs.
npm run pages:buildbuilds package artifacts into root/distand regenerates the committed/docspublish folder.npm run buildruns the same Pages build for local verification.npm run build:distbuilds package artifacts only.prepackuses this package-only build so npm publishing does not depend on the Pages folder.
The generated /docs folder contains index.html, examples/, demo.css, .nojekyll, and dist/ files copied from the package build. In GitHub, set Settings -> Pages -> Build and deployment -> Source to Deploy from a branch, then select the publishing branch and /docs.
Docs Metadata
import { docs } from "a11y-filter-summary/docs";