Filter
stimeo--filter
Narrows a list to the chosen conditions, whether they are toggled by checkboxes or buttons.
Narrows a list to the items matching the conditions that are currently on. How those conditions are toggled is up to you: ordinary checkboxes work, and so do buttons that hold a pressed state. With more than one condition on, you choose whether an item must match all of them or any of them. With none on, everything is shown. A group whose items have all gone can be hidden along with them, and a message can appear when nothing matches at all. A change in the filtering is reported. The state is read back from the page, so a markup swap leaves it correct. It applies no styling.
- Margherita Pizza
- Pepperoni Pizza
- Vegetable Curry
- Buffalo Wings
No dishes match the selected filters.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the filter (faceted list) demo. Behavior only — no consumer JS.
The toggle-group owns the chips' pressed state + roving focus; stimeo--filter
reads the pressed tokens (data-value) and shows/hides items by their
data-stimeo--filter-tokens. match="all" means an item must carry every active
token. The empty element appears when nothing matches; Clear resets the chips. %>
<div class="filter-demo" data-controller="stimeo--filter"
data-stimeo--filter-match-value="all"
data-action="stimeo--toggle-group:change->stimeo--filter#apply">
<div class="filter-demo__bar">
<div class="filter-demo__chips" role="group"
aria-label="<%= t('components.filter.demo.legend') %>"
data-controller="stimeo--toggle-group">
<button type="button" class="filter-demo__chip" aria-pressed="false" tabindex="0"
data-value="veg"
data-stimeo--filter-target="control"
data-stimeo--toggle-group-target="item"
data-action="click->stimeo--toggle-group#toggle
keydown->stimeo--toggle-group#onKeydown">
<%= t("components.filter.demo.vegetarian") %>
</button>
<button type="button" class="filter-demo__chip" aria-pressed="false" tabindex="-1"
data-value="spicy"
data-stimeo--filter-target="control"
data-stimeo--toggle-group-target="item"
data-action="click->stimeo--toggle-group#toggle
keydown->stimeo--toggle-group#onKeydown">
<%= t("components.filter.demo.spicy") %>
</button>
</div>
<button type="button" class="filter-demo__clear" data-action="click->stimeo--filter#clear">
<%= t("components.filter.demo.clear") %>
</button>
</div>
<%# Each item shows its facet tokens as tags so it is clear why a chip filters it in/out. %>
<%
token_labels = {
"veg" => t("components.filter.demo.vegetarian"),
"spicy" => t("components.filter.demo.spicy"),
}
dishes = [
{ key: "margherita", tokens: "veg" },
{ key: "pepperoni", tokens: "" },
{ key: "curry", tokens: "veg spicy" },
{ key: "wings", tokens: "spicy" },
]
%>
<ul class="filter-demo__list">
<% dishes.each do |dish| %>
<li class="filter-demo__item" data-stimeo--filter-target="item"
data-stimeo--filter-tokens="<%= dish[:tokens] %>">
<span class="filter-demo__name"><%= t("components.filter.demo.#{dish[:key]}") %></span>
<span class="filter-demo__tags">
<% dish[:tokens].split.each do |tok| %>
<span class="filter-demo__tag"><%= token_labels[tok] %></span>
<% end %>
</span>
</li>
<% end %>
</ul>
<p class="filter-demo__empty" role="status" data-stimeo--filter-target="empty" hidden>
<%= t("components.filter.demo.empty") %>
</p>
</div>
/*
* Presentation-only styles for the filter (faceted list) demo.
* The library only toggles `hidden` on items / the empty element and `aria-pressed`
* (via toggle-group) on the chips; everything below is the consumer's look.
*/
.filter-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 22rem;
}
.filter-demo__bar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-demo__chips {
display: inline-flex;
gap: 0.25rem;
}
.filter-demo__chip {
padding: 0.25rem 0.75rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.filter-demo__chip:hover {
background: var(--surface-subtle);
}
/* Pressed look is driven entirely by the library-managed aria-pressed. */
.filter-demo__chip[aria-pressed="true"] {
color: var(--white);
background: var(--accent);
border-color: var(--accent);
}
.filter-demo__clear {
margin-left: auto;
padding: 0.25rem 0.5rem;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 0.375rem;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.filter-demo__clear:hover {
color: var(--fg);
background: var(--surface-subtle);
}
.filter-demo__chip:focus-visible,
.filter-demo__clear:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.filter-demo__list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin: 0;
padding: 0;
list-style: none;
}
.filter-demo__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.filter-demo__tags {
display: inline-flex;
gap: 0.25rem;
flex: none;
}
/* The item's facet tokens, shown so it is clear why a chip filters it in or out. */
.filter-demo__tag {
padding: 0.05rem 0.45rem;
color: var(--muted);
background: var(--sidebar-bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.7rem;
}
/* The library hides non-matching items via the `hidden` attribute; the shared
base reset (base/reset.css) enforces `[hidden] { display: none !important }`,
so no per-demo re-declaration is needed. */
.filter-demo__empty {
margin: 0;
padding: 0.75rem 0.875rem;
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 0.5rem;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--filter"
Targets
| Name | Description | Attribute |
|---|---|---|
item
required
|
Element shown/hidden by its data-stimeo--filter-tokens. |
data-stimeo--filter-target="item" |
control
|
Element whose on-state (checked / aria-pressed) contributes its token. |
data-stimeo--filter-target="control" |
group
|
Optional container hidden once it holds no visible item. | data-stimeo--filter-target="group" |
empty
|
Optional element revealed when nothing matches. | data-stimeo--filter-target="empty" |
Values
| Name | Description | Attribute |
|---|---|---|
match
|
How active tokens combine: all (AND, default) or any (OR). |
data-stimeo--filter-match-value |
Actions
| Name | Description | Action |
|---|---|---|
apply
|
Re-evaluates visibility (wire button toggles' events here). | stimeo--filter#apply |
clear
|
Turns every control off and re-applies. | stimeo--filter#clear |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on every evaluation, with detail.active / detail.visible / detail.total. |
stimeo--filter:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
hidden |
Item / group / empty | Toggled on non-matching items, empty groups, and the empty element. |