stimulus_table_filter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +299 -0
- data/README.md +526 -0
- data/app/assets/javascripts/stimulus_table_filter/filter_list.js +17 -0
- data/app/assets/javascripts/stimulus_table_filter/page_navigator.js +25 -0
- data/app/assets/javascripts/stimulus_table_filter/paginator.js +29 -0
- data/app/assets/javascripts/stimulus_table_filter/row_matcher.js +36 -0
- data/app/assets/javascripts/stimulus_table_filter/row_sorter.js +33 -0
- data/app/assets/javascripts/stimulus_table_filter/row_stats.js +9 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter.js +75 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter_controller.js +234 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter_view.js +149 -0
- data/app/assets/javascripts/stimulus_table_filter/url_state.js +51 -0
- data/app/assets/stylesheets/stimulus_table_filter/table_filter.css +8 -0
- data/config/importmap.rb +2 -0
- data/eslint.config.js +18 -0
- data/lib/spec/shared_examples/table_filter_footer.rb +6 -0
- data/lib/spec/shared_examples/table_filter_rows.rb +13 -0
- data/lib/spec/shared_examples/table_filter_view.rb +87 -0
- data/lib/stimulus_table_filter/engine.rb +23 -0
- data/lib/stimulus_table_filter/error.rb +5 -0
- data/lib/stimulus_table_filter/rspec/helpers.rb +18 -0
- data/lib/stimulus_table_filter/rspec/matchers.rb +43 -0
- data/lib/stimulus_table_filter/rspec.rb +19 -0
- data/lib/stimulus_table_filter/version.rb +5 -0
- data/lib/stimulus_table_filter/view_helper/container.rb +28 -0
- data/lib/stimulus_table_filter/view_helper/controls.rb +42 -0
- data/lib/stimulus_table_filter/view_helper/rows.rb +32 -0
- data/lib/stimulus_table_filter/view_helper/sort.rb +31 -0
- data/lib/stimulus_table_filter/view_helper/stats.rb +47 -0
- data/lib/stimulus_table_filter/view_helper.rb +16 -0
- data/lib/stimulus_table_filter.rb +6 -0
- data/package-lock.json +1112 -0
- data/package.json +14 -0
- data/stimulus_table_filter.gemspec +29 -0
- data/test/javascript/controller.test.mjs +10 -0
- data/test/javascript/controller_stub.mjs +1 -0
- data/test/javascript/paginator.test.mjs +35 -0
- data/test/javascript/register.mjs +20 -0
- data/test/javascript/row_matcher.test.mjs +71 -0
- data/test/javascript/row_sorter.test.mjs +43 -0
- data/test/javascript/row_stats_and_url_state.test.mjs +69 -0
- data/test/javascript/stimulus_stub.mjs +6 -0
- metadata +107 -0
data/README.md
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
stimulus_table_filter
|
|
2
|
+
=====================
|
|
3
|
+
|
|
4
|
+
[](https://github.com/icoluccio/stimulus-table-filter/actions/workflows/ci.yml)
|
|
5
|
+
[](https://badge.fury.io/rb/stimulus_table_filter)
|
|
6
|
+
|
|
7
|
+
# Table of contents
|
|
8
|
+
- [Description](#description)
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Usage](#usage)
|
|
11
|
+
- [Quick start](#quick-start)
|
|
12
|
+
- [Container](#container)
|
|
13
|
+
- [Rows](#rows)
|
|
14
|
+
- [Search](#search)
|
|
15
|
+
- [Filter buttons](#filter-buttons)
|
|
16
|
+
- [Select and active-filter display](#select-and-active-filter-display)
|
|
17
|
+
- [Group headers](#group-headers)
|
|
18
|
+
- [Column headers](#column-headers)
|
|
19
|
+
- [Sort triggers](#sort-triggers)
|
|
20
|
+
- [Empty row](#empty-row)
|
|
21
|
+
- [Footer stats](#footer-stats)
|
|
22
|
+
- [Pagination](#pagination)
|
|
23
|
+
- [Data-attribute contract](#data-attribute-contract)
|
|
24
|
+
- [RSpec shared examples](#rspec-shared-examples)
|
|
25
|
+
- [Contributing](#contributing)
|
|
26
|
+
- [Releases](#releases)
|
|
27
|
+
- [About](#about)
|
|
28
|
+
- [License](#license)
|
|
29
|
+
|
|
30
|
+
-----------------------
|
|
31
|
+
|
|
32
|
+
## Description
|
|
33
|
+
|
|
34
|
+
stimulus-table-filter is a Stimulus controller for Rails that adds instant search, filter
|
|
35
|
+
dimensions, multi-column sort, pagination, and live footer stats to any table or list, with zero
|
|
36
|
+
JavaScript dependencies. Everything is driven by data attributes. It ships a Rails Engine
|
|
37
|
+
that registers the view helpers and wires the controller's importmap pin and assets.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Add the following line to your application's Gemfile:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
gem 'stimulus_table_filter'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
And then execute:
|
|
48
|
+
```bash
|
|
49
|
+
$ bundle
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or install it yourself as:
|
|
53
|
+
```bash
|
|
54
|
+
$ gem install stimulus_table_filter
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Ruby 3.2+ and Rails 7.0+ with importmap (the Rails 7+ default).
|
|
58
|
+
|
|
59
|
+
### CSS (optional)
|
|
60
|
+
|
|
61
|
+
Add the gem's stylesheet to your application layout to get
|
|
62
|
+
`cursor: pointer; user-select: none` on sort triggers:
|
|
63
|
+
|
|
64
|
+
```erb
|
|
65
|
+
<%= stylesheet_link_tag "stimulus_table_filter/table_filter", "data-turbo-track": "reload" %>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Omit if your own CSS already handles sort-trigger styling.
|
|
69
|
+
|
|
70
|
+
### Register the controller
|
|
71
|
+
|
|
72
|
+
The importmap pin is automatic. Registering the controller with your Stimulus application is a
|
|
73
|
+
one-liner in your controllers entrypoint:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
// app/javascript/controllers/index.js
|
|
77
|
+
import TableFilterController from "stimulus_table_filter/table_filter_controller"
|
|
78
|
+
application.register("table-filter", TableFilterController)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### RSpec
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# spec/rails_helper.rb
|
|
85
|
+
require "stimulus_table_filter/rspec"
|
|
86
|
+
StimulusTableFilter::RSpec.install!
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This includes the `html` helper in all example groups, loads the shared
|
|
90
|
+
examples (see [RSpec shared examples](#rspec-shared-examples)) and defines the
|
|
91
|
+
`have_data` / `have_data_target` matchers.
|
|
92
|
+
|
|
93
|
+
### RSpec matchers
|
|
94
|
+
|
|
95
|
+
The matchers parse the HTML with Nokogiri and assert on real attributes. On failure they list
|
|
96
|
+
the found attributes, instead of a raw string diff:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
expect(html).to have_data('sort-btn', 'amount') # any element with data-sort-btn="amount"
|
|
100
|
+
expect(html).to have_data('filter-btn') # attribute present, any value
|
|
101
|
+
expect(html).to have_data('sort-btn').on('th') # scoped to <th> elements
|
|
102
|
+
expect(html).to have_data_target('matchCount') # data-table-filter-target="matchCount"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
### Quick start
|
|
108
|
+
|
|
109
|
+
A complete filterable table in one view partial:
|
|
110
|
+
|
|
111
|
+
```erb
|
|
112
|
+
<%= table_filter_container_tag do %>
|
|
113
|
+
<%# Search + filter bar %>
|
|
114
|
+
<div>
|
|
115
|
+
<%= table_filter_search_tag(placeholder: "Search users…") %>
|
|
116
|
+
<%= table_filter_filter_btn_tag("all", "All", dimension: "status") %>
|
|
117
|
+
<%= table_filter_filter_btn_tag("active", "Active", dimension: "status") %>
|
|
118
|
+
<%= table_filter_filter_btn_tag("archived", "Archived", dimension: "status") %>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<table>
|
|
122
|
+
<thead>
|
|
123
|
+
<tr>
|
|
124
|
+
<%= table_filter_sort_th_tag("name", "Name") %>
|
|
125
|
+
<th>Status</th>
|
|
126
|
+
<%= table_filter_sort_th_tag("amount", "Amount", type: "numeric") %>
|
|
127
|
+
</tr>
|
|
128
|
+
</thead>
|
|
129
|
+
<tbody>
|
|
130
|
+
<% @users.each do |user| %>
|
|
131
|
+
<%= table_filter_row_tag(
|
|
132
|
+
name: user.username,
|
|
133
|
+
filters: { status: user.active? ? "active" : "archived" },
|
|
134
|
+
sort: { amount: user.balance }
|
|
135
|
+
) do %>
|
|
136
|
+
<td><%= user.username %></td>
|
|
137
|
+
<td><%= user.status %></td>
|
|
138
|
+
<td><%= user.balance %></td>
|
|
139
|
+
<% end %>
|
|
140
|
+
<% end %>
|
|
141
|
+
|
|
142
|
+
<%= table_filter_empty_row_tag do %>
|
|
143
|
+
<td colspan="3">No results</td>
|
|
144
|
+
<% end %>
|
|
145
|
+
</tbody>
|
|
146
|
+
<tfoot>
|
|
147
|
+
<tr>
|
|
148
|
+
<td colspan="3">
|
|
149
|
+
<%= table_filter_match_count_tag %> of <%= table_filter_total_count_tag %>
|
|
150
|
+
</td>
|
|
151
|
+
</tr>
|
|
152
|
+
</tfoot>
|
|
153
|
+
</table>
|
|
154
|
+
<% end %>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Register the controller once (see [Register the controller](#register-the-controller)).
|
|
158
|
+
Click a `<th>` to sort; click again to reverse; type to search.
|
|
159
|
+
|
|
160
|
+
### Container
|
|
161
|
+
|
|
162
|
+
Wraps the entire widget. Emits `data-controller="table-filter"` and the initial-value attributes
|
|
163
|
+
the controller reads on connect.
|
|
164
|
+
|
|
165
|
+
```erb
|
|
166
|
+
<%= table_filter_container_tag(sort: "name", dir: "asc") do %>
|
|
167
|
+
...
|
|
168
|
+
<% end %>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Option | Default | Description |
|
|
172
|
+
|--------|---------|-------------|
|
|
173
|
+
| `sort:` | `"name"` | Initial sort column |
|
|
174
|
+
| `dir:` | `"asc"` | Initial direction: `"asc"` or `"desc"` |
|
|
175
|
+
| `page:` | `nil` | Initial page number |
|
|
176
|
+
| `page_size:` | `nil` | Rows per page (omit or `0` to disable pagination) |
|
|
177
|
+
| `url_key:` | `nil` | URL param prefix (default `tf`); set per table to namespace multiple tables |
|
|
178
|
+
| `debounce_ms:` | `nil` | Search debounce in ms (default `0` = immediate) |
|
|
179
|
+
| `tag:` | `:div` | Wrapping HTML element |
|
|
180
|
+
|
|
181
|
+
Every helper accepts `**opts` and passes them through to the underlying tag.
|
|
182
|
+
Your `data:` attributes override the gem's own, so you can attach custom data without conflict.
|
|
183
|
+
|
|
184
|
+
### Rows
|
|
185
|
+
|
|
186
|
+
Each row carries a searchable name, any number of filter dimension values, and sort values
|
|
187
|
+
(keyed by column name):
|
|
188
|
+
|
|
189
|
+
```erb
|
|
190
|
+
<%= table_filter_row_tag(
|
|
191
|
+
name: user.username,
|
|
192
|
+
filters: { status: user.status },
|
|
193
|
+
sort: { amount: user.balance }
|
|
194
|
+
) do %>
|
|
195
|
+
<td>...</td>
|
|
196
|
+
<% end %>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Pass `tag: :div` for card-based layouts. `table_filter_row_attrs` returns the data hash without
|
|
200
|
+
wrapping it in a tag, for use with existing helpers.
|
|
201
|
+
|
|
202
|
+
**Sort values**: keyed by column name. `sort: { amount: 8.5 }` emits `data-sort-amount="8.5"`.
|
|
203
|
+
The JS reads `data-sort-{col}` when sorting on that column.
|
|
204
|
+
|
|
205
|
+
**Filter dimensions**: `filters:` maps dimension names to values, emitted as
|
|
206
|
+
`data-filter-{name}`. Any dimension name works; `status` is the conventional choice:
|
|
207
|
+
|
|
208
|
+
```erb
|
|
209
|
+
<%= table_filter_row_tag(name: "INV-7",
|
|
210
|
+
filters: { status: "open", payment: "paid", priority: "high" }) %>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
All dimensions are optional. Filter buttons and selects declare the dimension they act on with
|
|
214
|
+
the required `dimension:` argument; a row matches when **every** dimension with active filters
|
|
215
|
+
contains its value, and rows without a value for a dimension never match that dimension's
|
|
216
|
+
filters. Omit `filters:` and the filter helpers entirely for a search-and-sort-only table.
|
|
217
|
+
|
|
218
|
+
### Search
|
|
219
|
+
|
|
220
|
+
```erb
|
|
221
|
+
<%= table_filter_search_tag(placeholder: "Search…", class: "input input-sm") %>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Filters rows by matching `data-name`, or `data-searchable` when present. Case-insensitive
|
|
225
|
+
substring match.
|
|
226
|
+
|
|
227
|
+
### Filter buttons
|
|
228
|
+
|
|
229
|
+
```erb
|
|
230
|
+
<%= table_filter_filter_btn_tag("all", "All", dimension: "status", class: "btn btn-xs") %>
|
|
231
|
+
<%= table_filter_filter_btn_tag("active", "Active", dimension: "status", class: "btn btn-xs") %>
|
|
232
|
+
<%= table_filter_filter_btn_tag("archived", "Archived", dimension: "status", class: "btn btn-xs") %>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`dimension:` names the row dimension the button acts on. Multiple buttons of one dimension can
|
|
236
|
+
be active at once (click toggles); clicking `all` clears that dimension. A button receives
|
|
237
|
+
`btn-active` and `aria-pressed` while its value is among the dimension's active filters. The
|
|
238
|
+
`all` button is active only when none are.
|
|
239
|
+
|
|
240
|
+
### Select and active-filter display
|
|
241
|
+
|
|
242
|
+
Single-choice alternative to buttons (a select cannot represent multi-value):
|
|
243
|
+
|
|
244
|
+
```erb
|
|
245
|
+
<%= table_filter_select_tag({ "All" => "all", "Active" => "active" }, dimension: "status") %>
|
|
246
|
+
<%= table_filter_select_tag([["All", "all"], ["Archived", "archived"]], dimension: "status") %>
|
|
247
|
+
<%= table_filter_select_tag(%w[All Active], dimension: "status") %>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Accepts a hash, an array of `[label, value]` pairs, or an array of strings (used as both label
|
|
251
|
+
and value). Selecting an option replaces that dimension's filter with the chosen value. Pair it
|
|
252
|
+
with the active-filter display, which the controller fills with the dimension's active values
|
|
253
|
+
and hides when the dimension has none:
|
|
254
|
+
|
|
255
|
+
```erb
|
|
256
|
+
<%= table_filter_active_filter_tag(dimension: "status") %> <!-- e.g. "active, archived" -->
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Group headers
|
|
260
|
+
|
|
261
|
+
```erb
|
|
262
|
+
<%= table_filter_group_header_tag("category-a") do %>
|
|
263
|
+
<td colspan="5">Category A</td>
|
|
264
|
+
<% end %>
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Each header carries the group key. The controller hides the header whenever no visible row
|
|
268
|
+
shares its `data-group`. Pass `tag: :div` for non-table layouts.
|
|
269
|
+
|
|
270
|
+
### Column headers
|
|
271
|
+
|
|
272
|
+
Convenience wrapper that renders a sortable `<th>` and falls back to a plain `<th scope="col">`
|
|
273
|
+
when `sortable: false`:
|
|
274
|
+
|
|
275
|
+
```erb
|
|
276
|
+
<%= table_filter_column_tag(col: "amount", label: "Amount", type: "numeric") %>
|
|
277
|
+
<%= table_filter_column_tag(col: "actions", label: "Actions", sortable: false) %>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Sort triggers
|
|
281
|
+
|
|
282
|
+
Make a `<th>` the sort trigger, the recommended approach for tables:
|
|
283
|
+
|
|
284
|
+
```erb
|
|
285
|
+
<%= table_filter_sort_th_tag("name", "Name") %>
|
|
286
|
+
<%= table_filter_sort_th_tag("amount", "Amount", type: "numeric") %>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Or use a standalone `<button>`:
|
|
290
|
+
|
|
291
|
+
```erb
|
|
292
|
+
<%= table_filter_sort_btn_tag("name", "Name") %>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`type: "numeric"` sorts numerically with nulls pushed to the end (both directions). Omit for lexicographic sort.
|
|
296
|
+
Each trigger renders a `<span data-sort-icon>` that the controller fills with `↑`, `↓`, or `↕`.
|
|
297
|
+
`<th>` triggers also receive `aria-sort="ascending"`, `"descending"`, or `"none"`.
|
|
298
|
+
|
|
299
|
+
### Empty row
|
|
300
|
+
|
|
301
|
+
The controller shows this row when no rows pass the active filter. It starts hidden.
|
|
302
|
+
|
|
303
|
+
```erb
|
|
304
|
+
<%= table_filter_empty_row_tag do %>
|
|
305
|
+
<td colspan="5" class="text-center py-6">No results</td>
|
|
306
|
+
<% end %>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Footer stats
|
|
310
|
+
|
|
311
|
+
The default footer shows how many rows match the current filter and search, out of all rows:
|
|
312
|
+
|
|
313
|
+
```erb
|
|
314
|
+
<%= table_filter_footer_tag(colspan: 5) %>
|
|
315
|
+
<!-- renders: <span>12</span> of <span>87</span> -->
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Or build your own footer. The block receives the match-count and total-count spans:
|
|
319
|
+
|
|
320
|
+
```erb
|
|
321
|
+
<%= table_filter_footer_tag do |match_count, total_count| %>
|
|
322
|
+
<%= match_count %> of <%= total_count %>
|
|
323
|
+
<% end %>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Available stat spans:
|
|
327
|
+
|
|
328
|
+
- `table_filter_match_count_tag`: rows passing the active filter and search, across all pages
|
|
329
|
+
- `table_filter_total_count_tag`: every row in the table
|
|
330
|
+
- `table_filter_match_pct_tag`: match count as a percentage of total
|
|
331
|
+
- `table_filter_count_tag`: a live counter for any dimension and value, the hook for
|
|
332
|
+
domain stats; your app decides what each status means:
|
|
333
|
+
|
|
334
|
+
```erb
|
|
335
|
+
<%= table_filter_count_tag(dimension: "status", value: "active") %> active of
|
|
336
|
+
<%= table_filter_total_count_tag %>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Pagination
|
|
340
|
+
|
|
341
|
+
Set `page_size:` on the container, then add the controls:
|
|
342
|
+
|
|
343
|
+
```erb
|
|
344
|
+
<%= table_filter_prev_btn_tag %>
|
|
345
|
+
<%= table_filter_page_info_tag %>
|
|
346
|
+
<%= table_filter_next_btn_tag %>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Buttons disable themselves at the bounds; `pageInfo` fills with "1–25 of 87".
|
|
350
|
+
|
|
351
|
+
### Data-attribute contract
|
|
352
|
+
|
|
353
|
+
The Stimulus controller reads and writes the following attributes. The view helpers emit all
|
|
354
|
+
of them; this table is a reference for debugging and for callers that bypass the helpers.
|
|
355
|
+
|
|
356
|
+
| Attribute | Set by | Purpose |
|
|
357
|
+
|-----------|--------|---------|
|
|
358
|
+
| `data-controller="table-filter"` | Container | Mounts the controller |
|
|
359
|
+
| `data-table-filter-sort-value` | Container | Initial sort column |
|
|
360
|
+
| `data-table-filter-dir-value` | Container | Initial sort direction |
|
|
361
|
+
| `data-table-filter-page-size-value` | Container | Rows per page (0 = no pagination) |
|
|
362
|
+
| `data-table-filter-page-value` | Container | Initial page number |
|
|
363
|
+
| `data-table-filter-search-value` | Container | Initial search text |
|
|
364
|
+
| `data-table-filter-url-key-value` | Container | URL param prefix (default `tf`) |
|
|
365
|
+
| `data-table-filter-debounce-ms-value` | Container | Search debounce in ms (0 = immediate) |
|
|
366
|
+
| `data-table-filter-target="row"` | Row helpers | Marks filterable rows |
|
|
367
|
+
| `data-name="{name}"` | Row helpers | Searchable name (downcased); fallback sort value for `name` |
|
|
368
|
+
| `data-searchable="{text}"` | Row helpers | Explicit search text (overrides `data-name`) |
|
|
369
|
+
| `data-filter-status="{value}"` | Row helpers | Value for the `status` dimension |
|
|
370
|
+
| `data-filter-{name}="{value}"` | Row helpers | Value for the filter dimension `{name}` |
|
|
371
|
+
| `data-filter-dimension="{name}"` | Filter triggers, select, display | Which dimension the trigger acts on (required) |
|
|
372
|
+
| `data-sort-{col}="{value}"` | Row helpers | Sort value for column |
|
|
373
|
+
| `data-group="{key}"` | Row helpers | Group key; header hides when all siblings are hidden |
|
|
374
|
+
| `data-table-filter-target="search"` | Search helper | The search input |
|
|
375
|
+
| `data-table-filter-target="matchCount"` | Stat helper | Span for rows matching the filter and search |
|
|
376
|
+
| `data-table-filter-target="totalCount"` | Stat helper | Span for the total row count |
|
|
377
|
+
| `data-table-filter-target="matchPct"` | Stat helper | Span for the match percentage |
|
|
378
|
+
| `data-count-dimension` | Count helper | Dimension the span counts |
|
|
379
|
+
| `data-count-value` | Count helper | Value the span counts |
|
|
380
|
+
| `data-table-filter-target="emptyRow"` | Empty row helper | Shown when no rows match |
|
|
381
|
+
| `data-table-filter-target="groupHeader"` | Group header helper | Hidden when no visible row shares its `data-group` |
|
|
382
|
+
| `data-table-filter-target="filterSelect"` | Select helper | Single-choice filter alternative to buttons |
|
|
383
|
+
| `data-table-filter-target="filterDisplay"` | Active filter helper | Filled with active filter value(s) |
|
|
384
|
+
| `data-table-filter-target="prevPage"` / `"nextPage"` | Pagination helpers | Page buttons (auto-disabled at bounds) |
|
|
385
|
+
| `data-table-filter-target="pageInfo"` | Pagination helper | Filled with "1–25 of 87" |
|
|
386
|
+
| `data-filter-btn="{value}"` | Filter btn helper | Marks a filter button |
|
|
387
|
+
| `data-sort-btn="{col}"` | Sort helpers | Marks a sort trigger |
|
|
388
|
+
| `data-sort-type="..."` | Sort helpers | `numeric`, `string`, `date`, `date-dmy`, `date-mdy` (default `string`) |
|
|
389
|
+
| `data-sort-icon` | Sort helpers | Span inside a trigger, filled with ↑ / ↓ / ↕ |
|
|
390
|
+
| `data-prev-page` / `data-next-page` | Pagination helpers | Click delegation on the button element |
|
|
391
|
+
|
|
392
|
+
Behavior notes:
|
|
393
|
+
|
|
394
|
+
- **Filters**: buttons act on the dimension named by their `data-filter-dimension`. Multiple
|
|
395
|
+
buttons of one dimension can be active at once (click toggles); `data-filter-btn="all"` clears
|
|
396
|
+
that dimension. A select always replaces its dimension's filter.
|
|
397
|
+
- **Sorting**: the controller sets `aria-sort` on `<th>` triggers and toggles the `btn-active`
|
|
398
|
+
class on `<button>` triggers (DaisyUI).
|
|
399
|
+
- **Footer stats**: the match count covers every row that passes the active filters and search,
|
|
400
|
+
across all pages. Count tags populate with the number of matching rows whose dimension value
|
|
401
|
+
equals the tag's value.
|
|
402
|
+
- **URL state**: every filter dimension, sort, direction, page and search sync to
|
|
403
|
+
`URLSearchParams` on every change (dimensions as `tf_filter_{dimension}` params) and the
|
|
404
|
+
controller restores them on connect. Namespace multiple tables with `data-table-filter-url-key-value`.
|
|
405
|
+
- **Event handling**: the controller delegates all events itself; individual elements
|
|
406
|
+
need no `data-action`. `setFilter`, `sortBy` and `search` are also callable programmatically.
|
|
407
|
+
|
|
408
|
+
### RSpec shared examples
|
|
409
|
+
|
|
410
|
+
After calling `StimulusTableFilter::RSpec.install!`, the `html` helper, the shared examples below
|
|
411
|
+
and the `have_data` / `have_data_target` matchers are available in all example groups:
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
include_examples 'a table filter view'
|
|
415
|
+
# controller, search target, filterable row, filter/sort buttons
|
|
416
|
+
|
|
417
|
+
include_examples 'a table filter with sortable th headers'
|
|
418
|
+
# th elements used as sort triggers
|
|
419
|
+
|
|
420
|
+
include_examples 'a table filter with sort column', 'amount'
|
|
421
|
+
# specific column name has a data-sort-btn trigger
|
|
422
|
+
|
|
423
|
+
include_examples 'a table filter with sort type', 'amount', 'numeric'
|
|
424
|
+
# the sort trigger for that column declares the sort type
|
|
425
|
+
|
|
426
|
+
include_examples 'a table filter with accessible sort headers'
|
|
427
|
+
# every th sort trigger has scope="col"
|
|
428
|
+
|
|
429
|
+
include_examples 'a table filter with filter btn', 'active'
|
|
430
|
+
# a button marked with data-filter-btn="active"
|
|
431
|
+
|
|
432
|
+
include_examples 'a table filter with filter select'
|
|
433
|
+
# filterSelect target present
|
|
434
|
+
|
|
435
|
+
include_examples 'a table filter with filter display'
|
|
436
|
+
# filterDisplay target present
|
|
437
|
+
|
|
438
|
+
include_examples 'a table filter with initial sort', 'name'
|
|
439
|
+
# the container declares the initial sort column (dir: keyword also available)
|
|
440
|
+
|
|
441
|
+
include_examples 'a table filter with page size', 25
|
|
442
|
+
# the container declares the page size
|
|
443
|
+
|
|
444
|
+
include_examples 'a table filter with url key', 'items'
|
|
445
|
+
# the container declares the URL param prefix
|
|
446
|
+
|
|
447
|
+
include_examples 'a table filter with debounce', 200
|
|
448
|
+
# the container declares the search debounce
|
|
449
|
+
|
|
450
|
+
include_examples 'a table filter row with sort column', 'amount'
|
|
451
|
+
# rows carry data-sort-amount values
|
|
452
|
+
|
|
453
|
+
include_examples 'a table filter with count', 'status', 'active'
|
|
454
|
+
# a count span for that dimension and value is present
|
|
455
|
+
|
|
456
|
+
include_examples 'a table filter footer'
|
|
457
|
+
# matchCount and totalCount targets present
|
|
458
|
+
|
|
459
|
+
include_examples 'a table filter with empty row'
|
|
460
|
+
# emptyRow target present
|
|
461
|
+
|
|
462
|
+
include_examples 'a table filter with pagination controls'
|
|
463
|
+
# prev/next page buttons and pageInfo target present
|
|
464
|
+
|
|
465
|
+
include_examples 'a table filter with group headers'
|
|
466
|
+
# groupHeader target present
|
|
467
|
+
|
|
468
|
+
include_examples 'a table filter with group', 'category-a'
|
|
469
|
+
# rows carry that data-group
|
|
470
|
+
|
|
471
|
+
include_examples 'table filter rows with status', 'active'
|
|
472
|
+
# at least one row with data-filter-status="active"
|
|
473
|
+
|
|
474
|
+
include_examples 'table filter row named', 'alice'
|
|
475
|
+
# filterable row present for that name
|
|
476
|
+
|
|
477
|
+
include_examples 'table filter rows include names', 'alice', 'bob'
|
|
478
|
+
# filterable rows present for each name
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
The `html` helper returns `response.body` in request specs and `page.html` in system specs.
|
|
482
|
+
Override it with `let(:html) { ... }` when needed.
|
|
483
|
+
|
|
484
|
+
## Contributing
|
|
485
|
+
|
|
486
|
+
1. Fork it
|
|
487
|
+
2. Run `bundle install` to install dependencies
|
|
488
|
+
3. Run `bundle exec overcommit --install` once, to enable the pre-push hook (runs RuboCop and the full spec suite on every `git push`)
|
|
489
|
+
4. Create your feature branch (`git checkout -b my-new-feature`)
|
|
490
|
+
5. Commit your changes (`git commit -am 'Add some feature'`)
|
|
491
|
+
6. Run RuboCop lint (`bundle exec rubocop lib spec --format simple`)
|
|
492
|
+
7. Run rspec tests (`bundle exec rspec`)
|
|
493
|
+
8. Push your branch (`git push origin my-new-feature`). The pre-push hook re-verifies both
|
|
494
|
+
9. Create a new Pull Request to `main` branch
|
|
495
|
+
|
|
496
|
+
## Releases
|
|
497
|
+
📢 [See what's changed in a recent version](https://github.com/icoluccio/stimulus-table-filter/releases)
|
|
498
|
+
|
|
499
|
+
## About
|
|
500
|
+
|
|
501
|
+
The current maintainer of this gem is:
|
|
502
|
+
* [Ignacio Coluccio](https://github.com/icoluccio)
|
|
503
|
+
|
|
504
|
+
## License
|
|
505
|
+
|
|
506
|
+
**stimulus_table_filter** is available under the MIT [license](https://raw.githubusercontent.com/icoluccio/stimulus-table-filter/main/LICENSE.md).
|
|
507
|
+
|
|
508
|
+
Copyright (c) 2026 Ignacio Coluccio
|
|
509
|
+
|
|
510
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
511
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
512
|
+
in the Software without restriction, including without limitation the rights
|
|
513
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
514
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
515
|
+
furnished to do so, subject to the following conditions:
|
|
516
|
+
|
|
517
|
+
The above copyright notice and this permission notice shall be included in
|
|
518
|
+
all copies or substantial portions of the Software.
|
|
519
|
+
|
|
520
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
521
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
522
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
523
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
524
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
525
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
526
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ALL_FILTER } from "stimulus_table_filter/table_filter"
|
|
2
|
+
|
|
3
|
+
export class FilterList {
|
|
4
|
+
constructor(current) {
|
|
5
|
+
this.values = current === ALL_FILTER ? new Set() : new Set(current.split(","))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
toggle(value) {
|
|
9
|
+
this.values.has(value) ? this.values.delete(value) : this.values.add(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
has(value) { return this.values.has(value) }
|
|
13
|
+
|
|
14
|
+
get isEmpty() { return this.values.size === 0 }
|
|
15
|
+
|
|
16
|
+
toString() { return this.isEmpty ? ALL_FILTER : [...this.values].join(",") }
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Paginator } from "stimulus_table_filter/paginator"
|
|
2
|
+
|
|
3
|
+
export class PageNavigator {
|
|
4
|
+
constructor(pageSize) {
|
|
5
|
+
this.paginator = new Paginator(pageSize)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
get enabled() {
|
|
9
|
+
return this.paginator.enabled
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
step(page, step, matchedTotal) {
|
|
13
|
+
const target = page + step
|
|
14
|
+
const inRange = target >= 1 && target <= this.paginator.pageCount(matchedTotal)
|
|
15
|
+
return inRange ? target : page
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
clamp(page, matchedTotal) { return this.paginator.clamp(page, matchedTotal) }
|
|
19
|
+
|
|
20
|
+
window(page) { return this.paginator.window(page) }
|
|
21
|
+
|
|
22
|
+
pageCount(matchedTotal) { return this.paginator.pageCount(matchedTotal) }
|
|
23
|
+
|
|
24
|
+
info(page, matchedTotal) { return this.paginator.info(page, matchedTotal) }
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class Paginator {
|
|
2
|
+
constructor(size) {
|
|
3
|
+
this.size = Number(size) || 0
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
get enabled() {
|
|
7
|
+
return this.size > 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
pageCount(matchedTotal) {
|
|
11
|
+
return Math.ceil(matchedTotal / this.size) || 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
clamp(page, matchedTotal) {
|
|
15
|
+
return Math.min(page, this.pageCount(matchedTotal))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
window(page) {
|
|
19
|
+
const start = (page - 1) * this.size
|
|
20
|
+
return { start, end: start + this.size }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
info(page, matchedTotal) {
|
|
24
|
+
if (matchedTotal === 0) return ""
|
|
25
|
+
const start = (page - 1) * this.size + 1
|
|
26
|
+
const end = Math.min(page * this.size, matchedTotal)
|
|
27
|
+
return `${start}–${end} of ${matchedTotal}`
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ALL_FILTER, dimensionValue } from "stimulus_table_filter/table_filter"
|
|
2
|
+
import { FilterList } from "stimulus_table_filter/filter_list"
|
|
3
|
+
|
|
4
|
+
// dimensions: { name → comma-separated active values }. A row must match
|
|
5
|
+
// every dimension that has active values (AND), any value within it (OR).
|
|
6
|
+
export class RowMatcher {
|
|
7
|
+
constructor(query, dimensions) {
|
|
8
|
+
this.query = (query || "").toLowerCase().trim()
|
|
9
|
+
this.sets = Object.entries(dimensions ?? {})
|
|
10
|
+
.map(([dimension, value]) => ({ dimension, list: new FilterList(value) }))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
matches(row) {
|
|
14
|
+
const text = (row.dataset.searchable || row.dataset.name || "").toLowerCase()
|
|
15
|
+
return this.matchesQuery(text) && this.sets.every(set => this.matchesDimension(set, row))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
matchesQuery(text) {
|
|
19
|
+
return !this.query || text.includes(this.query)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A dimension with no active values matches every row; rows without a
|
|
23
|
+
// value for an active dimension never match it.
|
|
24
|
+
matchesDimension({ dimension, list }, row) {
|
|
25
|
+
return list.isEmpty || list.has(dimensionValue(row, dimension))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Multi-value: toggles the value in/out of a comma-separated list; "all" resets.
|
|
29
|
+
static toggledFilter(current, value) {
|
|
30
|
+
if (value === ALL_FILTER) return ALL_FILTER
|
|
31
|
+
if (current === ALL_FILTER) return value
|
|
32
|
+
const list = new FilterList(current)
|
|
33
|
+
list.toggle(value)
|
|
34
|
+
return list.toString()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DIRECTIONS, SORT_TYPES } from "stimulus_table_filter/table_filter"
|
|
2
|
+
|
|
3
|
+
export class RowSorter {
|
|
4
|
+
constructor(column, type, dir) {
|
|
5
|
+
this.column = column
|
|
6
|
+
this.spec = SORT_TYPES[type] ?? SORT_TYPES.string
|
|
7
|
+
this.dir = (DIRECTIONS[dir] ?? DIRECTIONS.desc).multiplier
|
|
8
|
+
this.key = `sort${column.charAt(0).toUpperCase()}${column.slice(1)}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
sort(rows) {
|
|
12
|
+
return [...rows].sort((a, b) => this.compare(a, b))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
value(row) {
|
|
16
|
+
return row.dataset[this.key] ?? row.dataset[this.column] ?? ""
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
compare(a, b) {
|
|
20
|
+
const av = this.value(a)
|
|
21
|
+
const bv = this.value(b)
|
|
22
|
+
if (this.spec.locale) return av.localeCompare(bv) * this.dir
|
|
23
|
+
return this.nullsLast(this.spec.parse(av), this.spec.parse(bv))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Present values compare numerically; missing values (null) sort last in both directions.
|
|
27
|
+
nullsLast(av, bv) {
|
|
28
|
+
if (av === null && bv === null) return 0
|
|
29
|
+
if (av === null) return 1
|
|
30
|
+
if (bv === null) return -1
|
|
31
|
+
return (av - bv) * this.dir
|
|
32
|
+
}
|
|
33
|
+
}
|