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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/Gemfile +17 -0
  4. data/Gemfile.lock +299 -0
  5. data/README.md +526 -0
  6. data/app/assets/javascripts/stimulus_table_filter/filter_list.js +17 -0
  7. data/app/assets/javascripts/stimulus_table_filter/page_navigator.js +25 -0
  8. data/app/assets/javascripts/stimulus_table_filter/paginator.js +29 -0
  9. data/app/assets/javascripts/stimulus_table_filter/row_matcher.js +36 -0
  10. data/app/assets/javascripts/stimulus_table_filter/row_sorter.js +33 -0
  11. data/app/assets/javascripts/stimulus_table_filter/row_stats.js +9 -0
  12. data/app/assets/javascripts/stimulus_table_filter/table_filter.js +75 -0
  13. data/app/assets/javascripts/stimulus_table_filter/table_filter_controller.js +234 -0
  14. data/app/assets/javascripts/stimulus_table_filter/table_filter_view.js +149 -0
  15. data/app/assets/javascripts/stimulus_table_filter/url_state.js +51 -0
  16. data/app/assets/stylesheets/stimulus_table_filter/table_filter.css +8 -0
  17. data/config/importmap.rb +2 -0
  18. data/eslint.config.js +18 -0
  19. data/lib/spec/shared_examples/table_filter_footer.rb +6 -0
  20. data/lib/spec/shared_examples/table_filter_rows.rb +13 -0
  21. data/lib/spec/shared_examples/table_filter_view.rb +87 -0
  22. data/lib/stimulus_table_filter/engine.rb +23 -0
  23. data/lib/stimulus_table_filter/error.rb +5 -0
  24. data/lib/stimulus_table_filter/rspec/helpers.rb +18 -0
  25. data/lib/stimulus_table_filter/rspec/matchers.rb +43 -0
  26. data/lib/stimulus_table_filter/rspec.rb +19 -0
  27. data/lib/stimulus_table_filter/version.rb +5 -0
  28. data/lib/stimulus_table_filter/view_helper/container.rb +28 -0
  29. data/lib/stimulus_table_filter/view_helper/controls.rb +42 -0
  30. data/lib/stimulus_table_filter/view_helper/rows.rb +32 -0
  31. data/lib/stimulus_table_filter/view_helper/sort.rb +31 -0
  32. data/lib/stimulus_table_filter/view_helper/stats.rb +47 -0
  33. data/lib/stimulus_table_filter/view_helper.rb +16 -0
  34. data/lib/stimulus_table_filter.rb +6 -0
  35. data/package-lock.json +1112 -0
  36. data/package.json +14 -0
  37. data/stimulus_table_filter.gemspec +29 -0
  38. data/test/javascript/controller.test.mjs +10 -0
  39. data/test/javascript/controller_stub.mjs +1 -0
  40. data/test/javascript/paginator.test.mjs +35 -0
  41. data/test/javascript/register.mjs +20 -0
  42. data/test/javascript/row_matcher.test.mjs +71 -0
  43. data/test/javascript/row_sorter.test.mjs +43 -0
  44. data/test/javascript/row_stats_and_url_state.test.mjs +69 -0
  45. data/test/javascript/stimulus_stub.mjs +6 -0
  46. metadata +107 -0
data/README.md ADDED
@@ -0,0 +1,526 @@
1
+ stimulus_table_filter
2
+ =====================
3
+
4
+ [![CI](https://github.com/icoluccio/stimulus-table-filter/actions/workflows/ci.yml/badge.svg)](https://github.com/icoluccio/stimulus-table-filter/actions/workflows/ci.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/stimulus_table_filter.svg)](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
+ }
@@ -0,0 +1,9 @@
1
+ export class RowStats {
2
+ static compute(rows, matchedRows) {
3
+ return { matched: matchedRows.length, total: rows.length }
4
+ }
5
+
6
+ static percentage(matched, total) {
7
+ return total > 0 ? Math.round(matched / total * 100) : 0
8
+ }
9
+ }