stimulus_table_filter
Table of contents
Description
stimulus-table-filter is a Stimulus controller for Rails that adds instant search, filter dimensions, multi-column sort, pagination, and live footer stats to any table or list, with zero JavaScript dependencies. Everything is driven by data attributes. It ships a Rails Engine that registers the view helpers and wires the controller's importmap pin and assets.
Installation
Add the following line to your application's Gemfile:
gem 'stimulus_table_filter'
And then execute:
$ bundle
Or install it yourself as:
$ gem install stimulus_table_filter
Requires Ruby 3.2+ and Rails 7.0+ with importmap (the Rails 7+ default).
CSS (optional)
Add the gem's stylesheet to your application layout to get
cursor: pointer; user-select: none on sort triggers:
<%= stylesheet_link_tag "stimulus_table_filter/table_filter", "data-turbo-track": "reload" %>
Omit if your own CSS already handles sort-trigger styling.
Register the controller
The importmap pin is automatic. Registering the controller with your Stimulus application is a one-liner in your controllers entrypoint:
// app/javascript/controllers/index.js
import TableFilterController from "stimulus_table_filter/table_filter_controller"
application.register("table-filter", TableFilterController)
RSpec
# spec/rails_helper.rb
require "stimulus_table_filter/rspec"
StimulusTableFilter::RSpec.install!
This includes the html helper in all example groups, loads the shared
examples (see RSpec shared examples) and defines the
have_data / have_data_target matchers.
RSpec matchers
The matchers parse the HTML with Nokogiri and assert on real attributes. On failure they list the found attributes, instead of a raw string diff:
expect(html).to have_data('sort-btn', 'amount') # any element with data-sort-btn="amount"
expect(html).to have_data('filter-btn') # attribute present, any value
expect(html).to have_data('sort-btn').on('th') # scoped to <th> elements
expect(html).to have_data_target('matchCount') # data-table-filter-target="matchCount"
Usage
Quick start
A complete filterable table in one view partial:
<%= table_filter_container_tag do %>
<%# Search + filter bar %>
<div>
<%= table_filter_search_tag(placeholder: "Search users…") %>
<%= table_filter_filter_btn_tag("all", "All", dimension: "status") %>
<%= table_filter_filter_btn_tag("active", "Active", dimension: "status") %>
<%= table_filter_filter_btn_tag("archived", "Archived", dimension: "status") %>
</div>
<table>
<thead>
<tr>
<%= table_filter_sort_th_tag("name", "Name") %>
<th>Status</th>
<%= table_filter_sort_th_tag("amount", "Amount", type: "numeric") %>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<%= table_filter_row_tag(
name: user.username,
filters: { status: user.active? ? "active" : "archived" },
sort: { amount: user.balance }
) do %>
<td><%= user.username %></td>
<td><%= user.status %></td>
<td><%= user.balance %></td>
<% end %>
<% end %>
<%= table_filter_empty_row_tag do %>
<td colspan="3">No results</td>
<% end %>
</tbody>
<tfoot>
<tr>
<td colspan="3">
<%= table_filter_match_count_tag %> of <%= table_filter_total_count_tag %>
</td>
</tr>
</tfoot>
</table>
<% end %>
Register the controller once (see Register the controller).
Click a <th> to sort; click again to reverse; type to search.
Container
Wraps the entire widget. Emits data-controller="table-filter" and the initial-value attributes
the controller reads on connect.
<%= table_filter_container_tag(sort: "name", dir: "asc") do %>
...
<% end %>
| Option | Default | Description |
|---|---|---|
sort: |
"name" |
Initial sort column |
dir: |
"asc" |
Initial direction: "asc" or "desc" |
page: |
nil |
Initial page number |
page_size: |
nil |
Rows per page (omit or 0 to disable pagination) |
url_key: |
nil |
URL param prefix (default tf); set per table to namespace multiple tables |
debounce_ms: |
nil |
Search debounce in ms (default 0 = immediate) |
tag: |
:div |
Wrapping HTML element |
Every helper accepts **opts and passes them through to the underlying tag.
Your data: attributes override the gem's own, so you can attach custom data without conflict.
Rows
Each row carries a searchable name, any number of filter dimension values, and sort values (keyed by column name):
<%= table_filter_row_tag(
name: user.username,
filters: { status: user.status },
sort: { amount: user.balance }
) do %>
<td>...</td>
<% end %>
Pass tag: :div for card-based layouts. table_filter_row_attrs returns the data hash without
wrapping it in a tag, for use with existing helpers.
Sort values: keyed by column name. sort: { amount: 8.5 } emits data-sort-amount="8.5".
The JS reads data-sort-{col} when sorting on that column.
Filter dimensions: filters: maps dimension names to values, emitted as
data-filter-{name}. Any dimension name works; status is the conventional choice:
<%= table_filter_row_tag(name: "INV-7",
filters: { status: "open", payment: "paid", priority: "high" }) %>
All dimensions are optional. Filter buttons and selects declare the dimension they act on with
the required dimension: argument; a row matches when every dimension with active filters
contains its value, and rows without a value for a dimension never match that dimension's
filters. Omit filters: and the filter helpers entirely for a search-and-sort-only table.
Search
<%= table_filter_search_tag(placeholder: "Search…", class: "input input-sm") %>
Filters rows by matching data-name, or data-searchable when present. Case-insensitive
substring match.
Filter buttons
<%= table_filter_filter_btn_tag("all", "All", dimension: "status", class: "btn btn-xs") %>
<%= table_filter_filter_btn_tag("active", "Active", dimension: "status", class: "btn btn-xs") %>
<%= table_filter_filter_btn_tag("archived", "Archived", dimension: "status", class: "btn btn-xs") %>
dimension: names the row dimension the button acts on. Multiple buttons of one dimension can
be active at once (click toggles); clicking all clears that dimension. A button receives
btn-active and aria-pressed while its value is among the dimension's active filters. The
all button is active only when none are.
Select and active-filter display
Single-choice alternative to buttons (a select cannot represent multi-value):
<%= table_filter_select_tag({ "All" => "all", "Active" => "active" }, dimension: "status") %>
<%= table_filter_select_tag([["All", "all"], ["Archived", "archived"]], dimension: "status") %>
<%= table_filter_select_tag(%w[All Active], dimension: "status") %>
Accepts a hash, an array of [label, value] pairs, or an array of strings (used as both label
and value). Selecting an option replaces that dimension's filter with the chosen value. Pair it
with the active-filter display, which the controller fills with the dimension's active values
and hides when the dimension has none:
<%= table_filter_active_filter_tag(dimension: "status") %> <!-- e.g. "active, archived" -->
Group headers
<%= table_filter_group_header_tag("category-a") do %>
<td colspan="5">Category A</td>
<% end %>
Each header carries the group key. The controller hides the header whenever no visible row
shares its data-group. Pass tag: :div for non-table layouts.
Column headers
Convenience wrapper that renders a sortable <th> and falls back to a plain <th scope="col">
when sortable: false:
<%= table_filter_column_tag(col: "amount", label: "Amount", type: "numeric") %>
<%= table_filter_column_tag(col: "actions", label: "Actions", sortable: false) %>
Sort triggers
Make a <th> the sort trigger, the recommended approach for tables:
<%= table_filter_sort_th_tag("name", "Name") %>
<%= table_filter_sort_th_tag("amount", "Amount", type: "numeric") %>
Or use a standalone <button>:
<%= table_filter_sort_btn_tag("name", "Name") %>
type: "numeric" sorts numerically with nulls pushed to the end (both directions). Omit for lexicographic sort.
Each trigger renders a <span data-sort-icon> that the controller fills with ↑, ↓, or ↕.
<th> triggers also receive aria-sort="ascending", "descending", or "none".
Empty row
The controller shows this row when no rows pass the active filter. It starts hidden.
<%= table_filter_empty_row_tag do %>
<td colspan="5" class="text-center py-6">No results</td>
<% end %>
Footer stats
The default footer shows how many rows match the current filter and search, out of all rows:
<%= table_filter_footer_tag(colspan: 5) %>
<!-- renders: <span>12</span> of <span>87</span> -->
Or build your own footer. The block receives the match-count and total-count spans:
<%= table_filter_footer_tag do |match_count, total_count| %>
<%= match_count %> of <%= total_count %>
<% end %>
Available stat spans:
table_filter_match_count_tag: rows passing the active filter and search, across all pagestable_filter_total_count_tag: every row in the tabletable_filter_match_pct_tag: match count as a percentage of totaltable_filter_count_tag: a live counter for any dimension and value, the hook for domain stats; your app decides what each status means:
<%= table_filter_count_tag(dimension: "status", value: "active") %> active of
<%= table_filter_total_count_tag %>
Pagination
Set page_size: on the container, then add the controls:
<%= table_filter_prev_btn_tag %>
<%= table_filter_page_info_tag %>
<%= table_filter_next_btn_tag %>
Buttons disable themselves at the bounds; pageInfo fills with "1–25 of 87".
Data-attribute contract
The Stimulus controller reads and writes the following attributes. The view helpers emit all of them; this table is a reference for debugging and for callers that bypass the helpers.
| Attribute | Set by | Purpose |
|---|---|---|
data-controller="table-filter" |
Container | Mounts the controller |
data-table-filter-sort-value |
Container | Initial sort column |
data-table-filter-dir-value |
Container | Initial sort direction |
data-table-filter-page-size-value |
Container | Rows per page (0 = no pagination) |
data-table-filter-page-value |
Container | Initial page number |
data-table-filter-search-value |
Container | Initial search text |
data-table-filter-url-key-value |
Container | URL param prefix (default tf) |
data-table-filter-debounce-ms-value |
Container | Search debounce in ms (0 = immediate) |
data-table-filter-target="row" |
Row helpers | Marks filterable rows |
data-name="{name}" |
Row helpers | Searchable name (downcased); fallback sort value for name |
data-searchable="{text}" |
Row helpers | Explicit search text (overrides data-name) |
data-filter-status="{value}" |
Row helpers | Value for the status dimension |
data-filter-{name}="{value}" |
Row helpers | Value for the filter dimension {name} |
data-filter-dimension="{name}" |
Filter triggers, select, display | Which dimension the trigger acts on (required) |
data-sort-{col}="{value}" |
Row helpers | Sort value for column |
data-group="{key}" |
Row helpers | Group key; header hides when all siblings are hidden |
data-table-filter-target="search" |
Search helper | The search input |
data-table-filter-target="matchCount" |
Stat helper | Span for rows matching the filter and search |
data-table-filter-target="totalCount" |
Stat helper | Span for the total row count |
data-table-filter-target="matchPct" |
Stat helper | Span for the match percentage |
data-count-dimension |
Count helper | Dimension the span counts |
data-count-value |
Count helper | Value the span counts |
data-table-filter-target="emptyRow" |
Empty row helper | Shown when no rows match |
data-table-filter-target="groupHeader" |
Group header helper | Hidden when no visible row shares its data-group |
data-table-filter-target="filterSelect" |
Select helper | Single-choice filter alternative to buttons |
data-table-filter-target="filterDisplay" |
Active filter helper | Filled with active filter value(s) |
data-table-filter-target="prevPage" / "nextPage" |
Pagination helpers | Page buttons (auto-disabled at bounds) |
data-table-filter-target="pageInfo" |
Pagination helper | Filled with "1–25 of 87" |
data-filter-btn="{value}" |
Filter btn helper | Marks a filter button |
data-sort-btn="{col}" |
Sort helpers | Marks a sort trigger |
data-sort-type="..." |
Sort helpers | numeric, string, date, date-dmy, date-mdy (default string) |
data-sort-icon |
Sort helpers | Span inside a trigger, filled with ↑ / ↓ / ↕ |
data-prev-page / data-next-page |
Pagination helpers | Click delegation on the button element |
Behavior notes:
- Filters: buttons act on the dimension named by their
data-filter-dimension. Multiple buttons of one dimension can be active at once (click toggles);data-filter-btn="all"clears that dimension. A select always replaces its dimension's filter. - Sorting: the controller sets
aria-sorton<th>triggers and toggles thebtn-activeclass on<button>triggers (DaisyUI). - Footer stats: the match count covers every row that passes the active filters and search, across all pages. Count tags populate with the number of matching rows whose dimension value equals the tag's value.
- URL state: every filter dimension, sort, direction, page and search sync to
URLSearchParamson every change (dimensions astf_filter_{dimension}params) and the controller restores them on connect. Namespace multiple tables withdata-table-filter-url-key-value. - Event handling: the controller delegates all events itself; individual elements
need no
data-action.setFilter,sortByandsearchare also callable programmatically.
RSpec shared examples
After calling StimulusTableFilter::RSpec.install!, the html helper, the shared examples below
and the have_data / have_data_target matchers are available in all example groups:
include_examples 'a table filter view'
# controller, search target, filterable row, filter/sort buttons
include_examples 'a table filter with sortable th headers'
# th elements used as sort triggers
include_examples 'a table filter with sort column', 'amount'
# specific column name has a data-sort-btn trigger
include_examples 'a table filter with sort type', 'amount', 'numeric'
# the sort trigger for that column declares the sort type
include_examples 'a table filter with accessible sort headers'
# every th sort trigger has scope="col"
include_examples 'a table filter with filter btn', 'active'
# a button marked with data-filter-btn="active"
include_examples 'a table filter with filter select'
# filterSelect target present
include_examples 'a table filter with filter display'
# filterDisplay target present
include_examples 'a table filter with initial sort', 'name'
# the container declares the initial sort column (dir: keyword also available)
include_examples 'a table filter with page size', 25
# the container declares the page size
include_examples 'a table filter with url key', 'items'
# the container declares the URL param prefix
include_examples 'a table filter with debounce', 200
# the container declares the search debounce
include_examples 'a table filter row with sort column', 'amount'
# rows carry data-sort-amount values
include_examples 'a table filter with count', 'status', 'active'
# a count span for that dimension and value is present
include_examples 'a table filter footer'
# matchCount and totalCount targets present
include_examples 'a table filter with empty row'
# emptyRow target present
include_examples 'a table filter with pagination controls'
# prev/next page buttons and pageInfo target present
include_examples 'a table filter with group headers'
# groupHeader target present
include_examples 'a table filter with group', 'category-a'
# rows carry that data-group
include_examples 'table filter rows with status', 'active'
# at least one row with data-filter-status="active"
include_examples 'table filter row named', 'alice'
# filterable row present for that name
include_examples 'table filter rows include names', 'alice', 'bob'
# filterable rows present for each name
The html helper returns response.body in request specs and page.html in system specs.
Override it with let(:html) { ... } when needed.
Contributing
- Fork it
- Run
bundle installto install dependencies - Run
bundle exec overcommit --installonce, to enable the pre-push hook (runs RuboCop and the full spec suite on everygit push) - Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Run RuboCop lint (
bundle exec rubocop lib spec --format simple) - Run rspec tests (
bundle exec rspec) - Push your branch (
git push origin my-new-feature). The pre-push hook re-verifies both - Create a new Pull Request to
mainbranch
Releases
📢 See what's changed in a recent version
About
The current maintainer of this gem is:
License
stimulus_table_filter is available under the MIT license.
Copyright (c) 2026 Ignacio Coluccio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.