The controls above a listing grid: a search box, dropdown filters, and chips for what's currently applied.
When to Use
Use when:
A listing needs GET-based search or filtering, with or without filters: exercise lists, workshop catalogues, saved-plan tables, a search box above "Your workshops". It standardises controls that were previously rebuilt per page. Its "Clear all" link beside the chips is the one clear control DESIGN.md 11.6 allows: it loads the unfiltered list, and Back undoes it. On a page routed by its query string, pass keep with the names that route it (inside /app/, ['view', 'step']), and give the no-match empty state's "Show all workshops" the same address: '?' . http_build_query(array_intersect_key($_GET, array_flip(['view', 'step']))). Put a one-line count under the bar with ws_text's caption variant ("Showing 3 of 12 workshops for “retro”") and leave focus where the reload puts it.
Don't use when:
The search field belongs to a form about something else, or sits in the site header rather than above a list — use Input with type: 'search'. For client-side switching between a few fixed sets, Tabs is lighter.
check_circleFixed July 2026. Chip remove buttons call wsFilterBarRemove(name), which the template referenced but nothing defined — every chip threw ReferenceError: wsFilterBarRemove is not defined. It now ships in components.js: it drops that parameter from the query string, resets page, and reloads, preserving all other filters and the hash. No consumer code required.
Variants
Search and filters
The common case. Filters render through Select internally, so they inherit its styling and focus treatment.
The search box above a list, with no filters: pass an empty filters array. The submit button reads "Search" rather than "Filter", and showSearchLabel puts the field's name above it as a visible label, which a placeholder is not.
A GET form. Submitting puts search and filter values in the query string, so filtered views are linkable and survive a reload.
Filter selected
Pass the filter's current value so the dropdown reflects the query string on redisplay.
Focus
Search and selects use the shared --focus-ring-width / --focus-ring-offset geometry.
Chips shown
Rendered whenever activeChips is non-empty, with a "Clear all" link: the one clear control DESIGN.md 11.6 allows, because it loads the unfiltered list and Back undoes it. Building the chip array is yours — the component doesn't derive it from the filters.
Chip removal
Handled by wsFilterBarRemove() in components.js: drops that one parameter, resets page, keeps the rest of the query string and the hash, then reloads.
Empty results
Not handled here. Pair the bar with Empty State below the grid.
Real-World Usage
The exercises listing. Chips are derived from the query string, and the missing remove function is supplied locally — it drops one parameter and reloads, which is all it needs to do.
<?php
$chips = [];
if (!empty($_GET['category_id'])) {
$chips[] = ['label' => $categoryNames[$_GET['category_id']],
'name' => 'category_id',
'value' => $_GET['category_id']];
}
if (!empty($_GET['duration'])) {
$chips[] = ['label' => $durationLabels[$_GET['duration']],
'name' => 'duration',
'value' => $_GET['duration']];
}
?>
<?= ws_filter_bar($filters, [
'search' => ['placeholder' => 'Search activities…', 'value' => $_GET['q'] ?? ''],
'activeChips' => $chips,
]) ?>
<!-- Chip removal needs no wiring — wsFilterBarRemove() ships in components.js -->
<?php if (!$results): ?>
<?= ws_empty_state('No activities match those filters', [
'description' => 'Drop a filter, or search for one word from the name.',
'icon' => 'filter_alt_off', 'action' => 'Show all activities', 'actionHref' => '?',
'actionVariant' => 'secondary',
]) ?>
<?php endif; ?>
Options
Bar options
Option
Type
Default
Purpose
$filters
array
[]
Positional. Dropdown configs; may be empty for search-only.
The search input's accessible name (its aria-label). Defaults to the placeholder less any trailing ellipsis, or "Search". Set it when the placeholder is an example rather than a description.
showSearchLabel
bool
false
Shows searchLabel as a visible <label> above the field; the row then lines up on the inputs.
submitLabel
string
'Search' or 'Filter'
The submit button's text: "Search" with no filters, "Filter" with them.
keep
array
[]
Query-string names the search and "Clear all" carry through, as hidden fields: inside /app/, ['view', 'step'], or a search sends the person back to Home. Inside a tab panel of a ws_tabs with a param, the panel's own tab is carried too, without listing it.
activeChips
array
[]
[['label' => …, 'name' => …, 'value' => …]]. You build this from the query string.
color
string
'learn'
learn | plan | facilitate | reflect. learn, the default, has no rule of its own: it is the base look, and since the Red Unification the other three render the same.
class
string
''
Additional CSS classes on the container
attrs
array
[]
Extra HTML attributes on the container
Filter options
Option
Type
Default
Purpose
name
string
''
Query-string parameter name
label
string
''
The select's accessible name (its aria-label) and the text of its empty "any" option
options
array
[]
Flat value => label pairs, as ws_select() takes
value
string
''
Currently selected value
Accessibility
Concern
Behavior
Semantics
A real GET <form> with native inputs, so filtered views are linkable and the back button behaves.
Names
Every control is named without changing the look. Each select's aria-label is its filter label; the search input's is searchLabel, or the placeholder. They are aria-label rather than a hidden <label for> because a select's id defaults to its field name, and two bars with the same filter on one page would point both labels at the first select. A placeholder alone is not a name: it disappears as soon as someone types.
Visible label
Set showSearchLabel when the brief asks for visible labels, when the placeholder is an example ("e.g. retrospective") rather than a description, or when no heading directly above says what the field searches. Without it, the placeholder stands in for the label, so it must describe the field ("Search your workshops"). With it, the <label> wraps the field and its aria-hidden icon: the computed name is searchLabel alone, though a tool reading textContent also shows the icon's ligature word. Check names in the accessibility tree, not in textContent.
Search landmark
With a search field the form is role="search", named like the field, so a screen reader can jump straight to it. The landmark and the field sharing one name is intended: the landmark list and the field both say what is searched. Two bars on one page need different searchLabels, or the landmark list shows two entries with the same name.
Keyboard
Native throughout: Tab across search and selects, Enter in the search field submits.
Chip remove buttons
Each names its filter, aria-label="Remove Design Sprints filter", so a row of chips does not announce the same words for every button.
Announcing results
Submitting loads a new page, and a new page is read from the top, so nothing is announced by itself: the count has to be on the page. Put one visible line between the bar and the results, ws_text($count, ['variant' => 'caption']), in every state ("12 workshops", "Showing 3 of 12 workshops for “retro”"), worded so it does not repeat the empty state's title. Its live option adds nothing after a reload; set it only when a script changes the count without one.
Focus after reload
Leave focus where the reload puts it, at the top of the page. Do not move it to the results on load: the field and its button come before them, so the next Tab would skip both, and a screen reader starts reading mid-page with no warning. The search landmark and the count line just below it are how a screen-reader user gets back to the results.
Search icon
Decorative and aria-hidden, so the icon's ligature text is not read. The field's own name carries the meaning.