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
@@ -0,0 +1,75 @@
1
+ // Full data-attribute contract: README.md, "Data-attribute contract".
2
+
3
+ export const ALL_FILTER = "all"
4
+ export const HIDDEN_CLASS = "hidden"
5
+ export const HIDDEN_STYLE = "none"
6
+ export const ACTIVE_BUTTON_CLASS = "btn-active"
7
+ export const ARIA_SORT_NONE = "none"
8
+ export const NEUTRAL_ICON = " ↕"
9
+
10
+ export const SELECTORS = {
11
+ filterButton: "[data-filter-btn]",
12
+ sortButton: "[data-sort-btn]",
13
+ sortIcon: "[data-sort-icon]",
14
+ prevPageButton: "[data-prev-page]",
15
+ nextPageButton: "[data-next-page]",
16
+ count: "[data-count-dimension]"
17
+ }
18
+
19
+ export const PAGE_STEPS = [[SELECTORS.prevPageButton, -1], [SELECTORS.nextPageButton, 1]]
20
+
21
+ export const TARGETS = {
22
+ search: "search",
23
+ row: "row",
24
+ matchCount: "matchCount",
25
+ totalCount: "totalCount",
26
+ matchPct: "matchPct",
27
+ emptyRow: "emptyRow",
28
+ prevPage: "prevPage",
29
+ nextPage: "nextPage",
30
+ pageInfo: "pageInfo",
31
+ groupHeader: "groupHeader",
32
+ filterDisplay: "filterDisplay",
33
+ filterSelect: "filterSelect"
34
+ }
35
+
36
+ export const DIRECTIONS = {
37
+ asc: { opposite: "desc", multiplier: 1, aria: "ascending", icon: " ↑" },
38
+ desc: { opposite: "asc", multiplier: -1, aria: "descending", icon: " ↓" }
39
+ }
40
+
41
+ export const pascalCase = (value) => value
42
+ .split("-")
43
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
44
+ .join("")
45
+
46
+ // Dataset key carrying a row's value for a filter dimension:
47
+ // data-filter-payment-status → dataset.filterPaymentStatus
48
+ export const dimensionDatasetKey = (dimension) => `filter${pascalCase(dimension)}`
49
+
50
+ export const dimensionValue = (row, dimension) => row.dataset[dimensionDatasetKey(dimension)]
51
+
52
+ const parseSlashDate = (value, dayFirst) => {
53
+ const [first, second, year] = value.split("/")
54
+ const day = dayFirst ? first : second
55
+ const month = dayFirst ? second : first
56
+ return new Date(+year, month - 1, +day).getTime()
57
+ }
58
+
59
+ // data-sort-type → parser: turns a cell string into a comparable number; strings stay locale-aware.
60
+ export const SORT_TYPES = {
61
+ string: { locale: true },
62
+ numeric: { parse: value => (value === "" ? null : parseFloat(value)) },
63
+ date: { parse: value => new Date(value).getTime() },
64
+ "date-dmy": { parse: value => parseSlashDate(value, true) },
65
+ "date-mdy": { parse: value => parseSlashDate(value, false) }
66
+ }
67
+
68
+ // Defaults are dropped from the URL when equal; parse normalizes values read back.
69
+ // Filter dimensions serialize separately as tf_filter_{dimension} params.
70
+ export const URL_FIELDS = [
71
+ { key: "sort", default: "name" },
72
+ { key: "dir", default: "asc", parse: value => (DIRECTIONS[value] ? value : undefined) },
73
+ { key: "page", default: 1, parse: value => parseInt(value) || 1 },
74
+ { key: "search", default: "" }
75
+ ]
@@ -0,0 +1,234 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ ALL_FILTER,
4
+ DIRECTIONS,
5
+ HIDDEN_CLASS,
6
+ PAGE_STEPS,
7
+ SELECTORS,
8
+ TARGETS
9
+ } from "stimulus_table_filter/table_filter"
10
+ import { PageNavigator } from "stimulus_table_filter/page_navigator"
11
+ import { RowMatcher } from "stimulus_table_filter/row_matcher"
12
+ import { RowSorter } from "stimulus_table_filter/row_sorter"
13
+ import { RowStats } from "stimulus_table_filter/row_stats"
14
+ import { TableView } from "stimulus_table_filter/table_filter_view"
15
+ import { UrlState } from "stimulus_table_filter/url_state"
16
+
17
+ export default class extends Controller {
18
+ static targets = Object.values(TARGETS)
19
+ static values = {
20
+ filterDimensions: { type: Object, default: {} },
21
+ sort: { type: String, default: "name" },
22
+ dir: { type: String, default: "asc" },
23
+ page: { type: Number, default: 1 },
24
+ search: { type: String, default: "" },
25
+ pageSize: { type: Number, default: 0 },
26
+ urlKey: { type: String, default: "tf" },
27
+ debounceMs: { type: Number, default: 0 }
28
+ }
29
+
30
+ #debounceTimer = null
31
+ #interacted = false // URL state is only written after the first user interaction
32
+ #view = new TableView(this)
33
+
34
+ // Bound handlers stored so disconnect() can remove the same references
35
+ #onClick = (e) => this.#handleClick(e)
36
+ #onInput = (e) => this.#handleInput(e)
37
+ #onChange = (e) => this.#handleChange(e)
38
+
39
+ connect() {
40
+ this.#readUrlState()
41
+ this.element.addEventListener("click", this.#onClick)
42
+ this.element.addEventListener("input", this.#onInput)
43
+ this.element.addEventListener("change", this.#onChange)
44
+ this.refresh()
45
+ }
46
+
47
+ disconnect() {
48
+ this.element.removeEventListener("click", this.#onClick)
49
+ this.element.removeEventListener("input", this.#onInput)
50
+ this.element.removeEventListener("change", this.#onChange)
51
+ clearTimeout(this.#debounceTimer)
52
+ }
53
+
54
+ // ── Public API (also callable from external elements as data-action handlers) ────
55
+
56
+ setFilter(event) { this.#applyFilter(event.currentTarget.dataset.filterBtn, event.currentTarget.dataset.filterDimension) }
57
+ sortBy(event) { this.#applySort(event.currentTarget.dataset.sortBtn) }
58
+ search() { this.pageValue = 1; this.refresh() }
59
+
60
+ refresh() {
61
+ this.#filterRows()
62
+ this.#reorder()
63
+ this.#renderStats() // counts the full filtered set; must run before #applyPage
64
+ this.#applyPage()
65
+ this.#view.renderGroupHeaders(this.rowTargets) // only header groups with rows on this page stay visible
66
+ this.#view.renderFilterButtons(this.filterDimensionsValue)
67
+ this.#view.renderSortTriggers(this.sortValue, this.dirValue)
68
+ this.#view.renderFilterDisplay(this.filterDimensionsValue)
69
+ this.#view.renderFilterSelect(this.filterDimensionsValue)
70
+ this.#view.renderCounts(this.#matchedRows())
71
+ this.#view.renderPagination(this.pageValue, this.#matchedRows().length, this.#navigator())
72
+ if (this.#interacted) this.#writeUrlState()
73
+ }
74
+
75
+ #handleClick(event) {
76
+ if (this.#clickFilter(event)) return
77
+ if (this.#clickSort(event)) return
78
+ this.#clickPage(event)
79
+ }
80
+
81
+ #clickFilter(event) {
82
+ const btn = event.target.closest(SELECTORS.filterButton)
83
+ if (!btn) return false
84
+ this.#applyFilter(btn.dataset.filterBtn, btn.dataset.filterDimension)
85
+ return true
86
+ }
87
+
88
+ #clickSort(event) {
89
+ const btn = event.target.closest(SELECTORS.sortButton)
90
+ if (!btn) return false
91
+ this.#applySort(btn.dataset.sortBtn)
92
+ return true
93
+ }
94
+
95
+ #clickPage(event) {
96
+ for (const [selector, step] of PAGE_STEPS) {
97
+ if (!event.target.closest(selector)) continue
98
+ this.#goToPage(step)
99
+ return true
100
+ }
101
+ return false
102
+ }
103
+
104
+ // Every user-driven change resets pagination, marks the session as
105
+ // interactive and re-renders the table.
106
+ #rerun(update) {
107
+ update()
108
+ this.pageValue = 1
109
+ this.#interacted = true
110
+ this.refresh()
111
+ }
112
+
113
+ #applyFilter(value, dimension) {
114
+ if (!dimension) return
115
+ this.#rerun(() => this.#toggleDimensionFilter(dimension, value))
116
+ }
117
+
118
+ #applySort(col) {
119
+ this.#rerun(() => {
120
+ this.dirValue = this.sortValue === col ? DIRECTIONS[this.dirValue].opposite : "asc"
121
+ this.sortValue = col
122
+ })
123
+ }
124
+
125
+ #handleInput(event) {
126
+ if (!this.#hasTargetToken(event.target, "search")) return
127
+ this.searchValue = event.target.value
128
+ this.#interacted = true
129
+ this.pageValue = 1
130
+ this.debounceMsValue ? this.#deferRefresh() : this.refresh()
131
+ }
132
+
133
+ #deferRefresh() {
134
+ clearTimeout(this.#debounceTimer)
135
+ this.#debounceTimer = setTimeout(() => this.refresh(), this.debounceMsValue)
136
+ }
137
+
138
+ // Select always replaces (single-choice); it cannot represent multi-value state.
139
+ #handleChange(event) {
140
+ const select = event.target
141
+ if (!this.#hasTargetToken(select, "filterSelect")) return
142
+ this.#rerun(() => this.#setDimensionFilter(select.dataset.filterDimension, select.value))
143
+ }
144
+
145
+ // An element may carry several space-separated target names; match tokens, not the whole string.
146
+ #hasTargetToken(el, token) {
147
+ return (el.dataset?.tableFilterTarget || "").split(" ").includes(token)
148
+ }
149
+
150
+ // Toggles a value within a dimension's list; "all" resets, and an emptied
151
+ // list removes the dimension.
152
+ #toggleDimensionFilter(dimension, value) {
153
+ const current = this.filterDimensionsValue[dimension] ?? ALL_FILTER
154
+ this.#setDimensionFilter(dimension, RowMatcher.toggledFilter(current, value))
155
+ }
156
+
157
+ // A missing/empty value removes the dimension from the filter state.
158
+ #setDimensionFilter(dimension, value) {
159
+ if (!dimension) return
160
+ const { [dimension]: _omitted, ...rest } = this.filterDimensionsValue
161
+ const inactive = value === undefined || value === ALL_FILTER
162
+ this.filterDimensionsValue = inactive ? rest : { ...rest, [dimension]: value }
163
+ }
164
+
165
+ #navigator() {
166
+ return new PageNavigator(this.pageSizeValue)
167
+ }
168
+
169
+ #matchedRows() {
170
+ return this.rowTargets.filter(r => !r.classList.contains(HIDDEN_CLASS))
171
+ }
172
+
173
+ #filterRows() {
174
+ const matcher = new RowMatcher(this.searchValue, this.filterDimensionsValue)
175
+ this.#view.renderRowVisibility(this.rowTargets, matcher)
176
+ }
177
+
178
+ #renderStats() {
179
+ const stats = RowStats.compute(this.rowTargets, this.#matchedRows())
180
+ this.#view.renderStats(stats)
181
+ this.#view.renderEmptyRow(stats.matched)
182
+ }
183
+
184
+ #applyPage() {
185
+ const navigator = this.#navigator()
186
+ this.#view.clearPageWindow(this.rowTargets)
187
+ if (!navigator.enabled) return
188
+
189
+ const matched = this.#matchedRows()
190
+ this.pageValue = navigator.clamp(this.pageValue, matched.length) // stale ?tf_page= from the URL
191
+ this.#view.showPageWindow(matched, navigator.window(this.pageValue))
192
+ }
193
+
194
+ #goToPage(step) {
195
+ const navigator = this.#navigator()
196
+ const target = navigator.step(this.pageValue, step, this.#matchedRows().length)
197
+ if (target === this.pageValue) return
198
+ this.pageValue = target
199
+ this.#interacted = true
200
+ this.#applyPage()
201
+ this.#view.renderPagination(this.pageValue, this.#matchedRows().length, navigator)
202
+ }
203
+
204
+ #reorder() {
205
+ this.#view.reorderRows(this.#sorter().sort(this.rowTargets))
206
+ }
207
+
208
+ #sorter() {
209
+ // Match by value instead of an interpolated attribute selector, since the sort
210
+ // column can come from the URL and selector injection must stay impossible.
211
+ const trigger = [...this.element.querySelectorAll(SELECTORS.sortButton)]
212
+ .find(btn => btn.dataset.sortBtn === this.sortValue)
213
+ return new RowSorter(this.sortValue, trigger?.dataset.sortType, this.dirValue)
214
+ }
215
+
216
+ #readUrlState() {
217
+ this.#applyUrlState(UrlState.read(this.urlKeyValue))
218
+ }
219
+
220
+ // URL_FIELDS keys are Stimulus values, so state applies by name.
221
+ #applyUrlState(state) {
222
+ for (const [key, value] of Object.entries(state)) this[`${key}Value`] = value
223
+ }
224
+
225
+ #writeUrlState() {
226
+ UrlState.write(this.urlKeyValue, {
227
+ filterDimensions: this.filterDimensionsValue,
228
+ sort: this.sortValue,
229
+ dir: this.dirValue,
230
+ search: this.searchValue.trim(),
231
+ page: this.pageValue
232
+ })
233
+ }
234
+ }
@@ -0,0 +1,149 @@
1
+ import {
2
+ ACTIVE_BUTTON_CLASS,
3
+ ALL_FILTER,
4
+ ARIA_SORT_NONE,
5
+ DIRECTIONS,
6
+ HIDDEN_CLASS,
7
+ HIDDEN_STYLE,
8
+ NEUTRAL_ICON,
9
+ SELECTORS,
10
+ TARGETS,
11
+ dimensionValue,
12
+ pascalCase
13
+ } from "stimulus_table_filter/table_filter"
14
+ import { FilterList } from "stimulus_table_filter/filter_list"
15
+ import { RowStats } from "stimulus_table_filter/row_stats"
16
+
17
+ export class TableView {
18
+ constructor(controller) {
19
+ this.controller = controller
20
+ }
21
+
22
+ renderRowVisibility(rows, matcher) {
23
+ rows.forEach(row => row.classList.toggle(HIDDEN_CLASS, !matcher.matches(row)))
24
+ }
25
+
26
+ reorderRows(rows) {
27
+ Map.groupBy(rows, row => row.parentElement)
28
+ .forEach((groupRows, parent) => parent.append(...groupRows))
29
+ }
30
+
31
+ renderGroupHeaders(rows) {
32
+ this.controller.groupHeaderTargets.forEach(header => {
33
+ const hasVisibleRows = rows.some(r => r.dataset.group === header.dataset.group && this.isVisible(r))
34
+ header.classList.toggle(HIDDEN_CLASS, !hasVisibleRows)
35
+ })
36
+ }
37
+
38
+ clearPageWindow(rows) {
39
+ rows.forEach(row => row.style.removeProperty("display"))
40
+ }
41
+
42
+ showPageWindow(rows, { start, end }) {
43
+ rows.forEach((row, index) => {
44
+ if (index < start || index >= end) row.style.display = HIDDEN_STYLE
45
+ })
46
+ }
47
+
48
+ renderStats({ matched, total }) {
49
+ const c = this.controller
50
+ window.__targetDebug = Object.fromEntries(
51
+ ['matchCount', 'totalCount', 'matchPct'].map((name) => {
52
+ const key = pascalCase(name)
53
+ const dom = c.element.querySelector(`[data-table-filter-target~="${name}"]`)
54
+ return [name, {
55
+ has: c[`has${key}Target`],
56
+ get: c[`${key}Target`] ? 'el' : String(c[`${key}Target`]),
57
+ dom: dom ? 'el' : 'missing'
58
+ }]
59
+ })
60
+ )
61
+ this.#target(TARGETS.matchCount).textContent = matched
62
+ this.#target(TARGETS.totalCount).textContent = total
63
+ this.#target(TARGETS.matchPct).textContent = `${RowStats.percentage(matched, total)}%`
64
+ }
65
+
66
+ renderCounts(matchedRows) {
67
+ this.controller.element.querySelectorAll(SELECTORS.count).forEach(el => {
68
+ el.textContent = matchedRows
69
+ .filter(row => dimensionValue(row, el.dataset.countDimension) === el.dataset.countValue).length
70
+ })
71
+ }
72
+
73
+ renderEmptyRow(matched) {
74
+ this.#target(TARGETS.emptyRow).classList.toggle(HIDDEN_CLASS, matched > 0)
75
+ }
76
+
77
+ renderFilterButtons(dimensions) {
78
+ this.controller.element.querySelectorAll(SELECTORS.filterButton).forEach(btn => {
79
+ const list = this.listFor(dimensions, this.dimensionOf(btn))
80
+ const isActive = btn.dataset.filterBtn === ALL_FILTER ? list.isEmpty : list.has(btn.dataset.filterBtn)
81
+ this.setActiveState(btn, isActive)
82
+ })
83
+ }
84
+
85
+ renderSortTriggers(sortValue, dirValue) {
86
+ const direction = DIRECTIONS[dirValue]
87
+ this.controller.element.querySelectorAll(SELECTORS.sortButton).forEach(btn => {
88
+ const active = btn.dataset.sortBtn === sortValue
89
+ this.renderSortTriggerState(btn, active, direction)
90
+ this.#sortIcon(btn).textContent = active ? direction.icon : NEUTRAL_ICON
91
+ })
92
+ }
93
+
94
+ renderSortTriggerState(btn, active, direction) {
95
+ if (btn.tagName === "TH") {
96
+ btn.setAttribute("aria-sort", active ? direction.aria : ARIA_SORT_NONE)
97
+ return
98
+ }
99
+ this.setActiveState(btn, active)
100
+ }
101
+
102
+ renderFilterDisplay(dimensions) {
103
+ const display = this.#target(TARGETS.filterDisplay)
104
+ const list = this.listFor(dimensions, this.dimensionOf(display))
105
+ display.hidden = list.isEmpty
106
+ display.textContent = list.toString().replace(/,/g, ", ")
107
+ }
108
+
109
+ renderFilterSelect(dimensions) {
110
+ const select = this.#target(TARGETS.filterSelect)
111
+ select.value = this.listFor(dimensions, this.dimensionOf(select)).toString()
112
+ }
113
+
114
+ renderPagination(page, matchedTotal, navigator) {
115
+ if (!navigator.enabled) return
116
+ this.#target(TARGETS.prevPage).disabled = page <= 1
117
+ this.#target(TARGETS.nextPage).disabled = page >= navigator.pageCount(matchedTotal)
118
+ this.#target(TARGETS.pageInfo).textContent = navigator.info(page, matchedTotal)
119
+ }
120
+
121
+ isVisible(row) {
122
+ return !row.classList.contains(HIDDEN_CLASS) && row.style.display !== HIDDEN_STYLE
123
+ }
124
+
125
+ setActiveState(btn, isActive) {
126
+ btn.classList.toggle(ACTIVE_BUTTON_CLASS, isActive)
127
+ btn.setAttribute("aria-pressed", isActive ? "true" : "false")
128
+ }
129
+
130
+ // A trigger's data-filter-dimension names the dimension it acts on.
131
+ dimensionOf(el) {
132
+ return el.dataset.filterDimension
133
+ }
134
+
135
+ listFor(dimensions, dimension) {
136
+ return new FilterList(dimensions[dimension] ?? ALL_FILTER)
137
+ }
138
+
139
+ // Writes to a missing target land on a detached sink element and are discarded.
140
+ // has is PascalCase (hasMatchCountTarget); the value getter is camelCase (matchCountTarget).
141
+ #target(name) {
142
+ const controller = this.controller
143
+ return controller[`has${pascalCase(name)}Target`] ? controller[`${name}Target`] : document.createElement("template")
144
+ }
145
+
146
+ #sortIcon(btn) {
147
+ return btn.querySelector(SELECTORS.sortIcon) ?? document.createElement("template")
148
+ }
149
+ }
@@ -0,0 +1,51 @@
1
+ import { URL_FIELDS } from "stimulus_table_filter/table_filter"
2
+
3
+ export class UrlState {
4
+ static read(prefix) {
5
+ const state = {}
6
+ const params = this.withParams(prefix, (params, field, param) => {
7
+ if (!params.has(param)) return
8
+ const value = field.parse ? field.parse(params.get(param)) : params.get(param)
9
+ if (value !== undefined) state[field.key] = value
10
+ })
11
+ const dimensions = this.readDimensions(params, prefix)
12
+ if (dimensions.size) state.filterDimensions = Object.fromEntries(dimensions)
13
+ return state
14
+ }
15
+
16
+ static write(prefix, state) {
17
+ const params = this.withParams(prefix, (params, field, param) => {
18
+ const value = state[field.key]
19
+ if (value === undefined || value === field.default) params.delete(param)
20
+ else params.set(param, String(value))
21
+ })
22
+ const dimensions = state.filterDimensions ?? {}
23
+ const marker = `${prefix}_filter_`
24
+ for (const key of [...params.keys()]) {
25
+ if (key.startsWith(marker) && !Object.hasOwn(dimensions, key.slice(marker.length))) params.delete(key)
26
+ }
27
+ for (const [dimension, value] of Object.entries(dimensions)) {
28
+ const param = `${prefix}_filter_${dimension}`
29
+ if (!value || value === "all") params.delete(param)
30
+ else params.set(param, value)
31
+ }
32
+ history.replaceState(null, "", `${location.pathname}?${params}`)
33
+ }
34
+
35
+ static withParams(prefix, apply) {
36
+ const params = new URLSearchParams(location.search)
37
+ for (const field of URL_FIELDS) apply(params, field, `${prefix}_${field.key}`)
38
+ return params
39
+ }
40
+
41
+ static readDimensions(params, prefix) {
42
+ const dimensions = new Map()
43
+ const marker = `${prefix}_filter_`
44
+ for (const [key, value] of params) {
45
+ if (!key.startsWith(marker)) continue
46
+ const dimension = key.slice(marker.length)
47
+ if (dimension) dimensions.set(dimension, value)
48
+ }
49
+ return dimensions
50
+ }
51
+ }
@@ -0,0 +1,8 @@
1
+ /* Required by the JS controller for row/group visibility toggling.
2
+ Scoped to the controller so the widget doesn't define a global utility class. */
3
+ [data-controller="table-filter"] .hidden { display: none; }
4
+
5
+ [data-sort-btn] {
6
+ cursor: pointer;
7
+ user-select: none;
8
+ }
@@ -0,0 +1,2 @@
1
+ pin_all_from File.expand_path('../app/assets/javascripts/stimulus_table_filter', __dir__),
2
+ under: 'stimulus_table_filter', to: 'stimulus_table_filter'
data/eslint.config.js ADDED
@@ -0,0 +1,18 @@
1
+ import js from "@eslint/js"
2
+ import globals from "globals"
3
+
4
+ export default [
5
+ { ignores: ["vendor/**", "node_modules/**", "coverage/**", "gemfiles/**", "tmp/**"] },
6
+ {
7
+ files: ["app/assets/javascripts/**/*.js", "test/javascript/**/*.mjs", "eslint.config.js"],
8
+ languageOptions: {
9
+ ecmaVersion: 2024,
10
+ sourceType: "module",
11
+ globals: { ...globals.browser, ...globals.node }
12
+ },
13
+ rules: {
14
+ ...js.configs.recommended.rules,
15
+ "no-unused-vars": ["error", { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }]
16
+ }
17
+ }
18
+ ]
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'a table filter footer' do
4
+ it('has a match count target') { expect(html).to have_data_target('matchCount') }
5
+ it('has a total count target') { expect(html).to have_data_target('totalCount') }
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'table filter row named' do |name|
4
+ it("has a filterable row for '#{name}'") { expect(html).to have_data('name', name) }
5
+ end
6
+
7
+ RSpec.shared_examples 'table filter rows with status' do |status|
8
+ it("has at least one row with status '#{status}'") { expect(html).to have_data('filter-status', status) }
9
+ end
10
+
11
+ RSpec.shared_examples 'table filter rows include names' do |*names|
12
+ names.each { |name| it_behaves_like 'table filter row named', name }
13
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'a table filter view' do
4
+ it('has a table-filter controller') { expect(html).to have_data('controller', 'table-filter') }
5
+ it('has a search input target') { expect(html).to have_data_target('search') }
6
+ it('has at least one filterable row') { expect(html).to have_data_target('row') }
7
+ it('has filter status buttons') { expect(html).to have_data('filter-btn') }
8
+ it('has sort buttons') { expect(html).to have_data('sort-btn') }
9
+ end
10
+
11
+ RSpec.shared_examples 'a table filter with sortable th headers' do
12
+ it('uses th elements as sort triggers') { expect(html).to have_data('sort-btn').on('th') }
13
+ end
14
+
15
+ RSpec.shared_examples 'a table filter with sort column' do |col|
16
+ it("has a sort trigger for column '#{col}'") { expect(html).to have_data('sort-btn', col) }
17
+ end
18
+
19
+ RSpec.shared_examples 'a table filter with empty row' do
20
+ it('has an empty-state row target') { expect(html).to have_data_target('emptyRow') }
21
+ end
22
+
23
+ RSpec.shared_examples 'a table filter with pagination controls' do
24
+ it('has a prev page button') { expect(html).to have_data('prev-page') }
25
+ it('has a next page button') { expect(html).to have_data('next-page') }
26
+ it('has a page info target') { expect(html).to have_data_target('pageInfo') }
27
+ end
28
+
29
+ RSpec.shared_examples 'a table filter with group headers' do
30
+ it('has a groupHeader target') { expect(html).to have_data_target('groupHeader') }
31
+ end
32
+
33
+ RSpec.shared_examples 'a table filter with group' do |group|
34
+ it("has rows in group '#{group}'") { expect(html).to have_data('group', group) }
35
+ end
36
+
37
+ RSpec.shared_examples 'a table filter with filter select' do
38
+ it('has a filterSelect target') { expect(html).to have_data_target('filterSelect') }
39
+ end
40
+
41
+ RSpec.shared_examples 'a table filter with filter display' do
42
+ it('has a filterDisplay target') { expect(html).to have_data_target('filterDisplay') }
43
+ end
44
+
45
+ RSpec.shared_examples 'a table filter with initial sort' do |col, dir: 'asc'|
46
+ it("has sort-value '#{col}'") { expect(html).to have_data('table-filter-sort-value', col) }
47
+ it("has dir-value '#{dir}'") { expect(html).to have_data('table-filter-dir-value', dir) }
48
+ end
49
+
50
+ RSpec.shared_examples 'a table filter with page size' do |n|
51
+ it("has page-size-value '#{n}'") { expect(html).to have_data('table-filter-page-size-value', n) }
52
+ end
53
+
54
+ RSpec.shared_examples 'a table filter with url key' do |key|
55
+ it("has url-key-value '#{key}'") { expect(html).to have_data('table-filter-url-key-value', key) }
56
+ end
57
+
58
+ RSpec.shared_examples 'a table filter with debounce' do |ms|
59
+ it("has debounce-ms-value '#{ms}'") { expect(html).to have_data('table-filter-debounce-ms-value', ms) }
60
+ end
61
+
62
+ RSpec.shared_examples 'a table filter with count' do |dimension, value|
63
+ it("has a count for #{dimension} '#{value}'") {
64
+ expect(html).to have_data('count-dimension', dimension).and have_data('count-value', value)
65
+ }
66
+ end
67
+
68
+ RSpec.shared_examples 'a table filter with filter btn' do |value|
69
+ it("has a filter button for '#{value}'") { expect(html).to have_data('filter-btn', value) }
70
+ end
71
+
72
+ RSpec.shared_examples 'a table filter with sort type' do |col, type|
73
+ it("sort trigger '#{col}' has type '#{type}'") {
74
+ expect(html).to have_data('sort-btn', col).and have_data('sort-type', type)
75
+ }
76
+ end
77
+
78
+ RSpec.shared_examples 'a table filter row with sort column' do |col|
79
+ it("rows have data-sort-#{col} attribute") { expect(html).to have_data("sort-#{col}") }
80
+ end
81
+
82
+ RSpec.shared_examples 'a table filter with accessible sort headers' do
83
+ let(:sortable_ths) { Nokogiri::HTML.fragment(html).css('th[data-sort-btn]') }
84
+
85
+ it('has th elements as sort triggers') { expect(sortable_ths).not_to be_empty }
86
+ it('sort th triggers have scope="col"') { expect(sortable_ths.map { |th| th['scope'] }).to all(eq('col')) }
87
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ class Engine < ::Rails::Engine
5
+ initializer 'stimulus_table_filter.helpers' do
6
+ ActiveSupport.on_load(:action_view) { include StimulusTableFilter::ViewHelper }
7
+ end
8
+
9
+ initializer 'stimulus_table_filter.assets' do |app|
10
+ if app.config.respond_to?(:assets)
11
+ app.config.assets.paths << root.join('app/assets/javascripts')
12
+ app.config.assets.paths << root.join('app/assets/stylesheets')
13
+ end
14
+ end
15
+
16
+ initializer 'stimulus_table_filter.importmap', before: 'importmap' do |app|
17
+ if app.config.respond_to?(:importmap)
18
+ app.config.importmap.paths << root.join('config/importmap.rb')
19
+ app.config.importmap.cache_sweepers << root.join('app/assets/javascripts')
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ class Error < StandardError; end
5
+ end