PageSourceSearch

https://cdn2.futurefund.com/assets/honey/filters/controllers/filter_modal_controller-911ee634.js

js futurefund.com collected 2026-09-24 11:53:00 UTC 18,429 bytes, 529 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3/**
4 * Manages the two-panel filter modal dropdown.
5 *
6 * Targets:
7 * - trigger: the "Filters" button
8 * - panel: the dropdown panel container
9 * - category: category buttons in left panel
10 * - controls: control panels in right panel
11 * - badge: active filter count badge
12 * - dot: blue dot indicator on category buttons
13 * - resetButton: the Reset link in modal header
14 * - saveButton: the Save/Update button wrapper in modal header
15 * - dateRange: custom date range fields (shown/hidden by preset)
16 * - associationList: scrollable association checkbox list
17 *
18 * Values:
19 * - activeCategory: the currently selected category key
20 */
21export default class extends Controller {
22  static targets = ["trigger", "panel", "category", "controls", "badge", "dot", "resetButton", "saveButton", "applyButton", "dateRange", "associationList", "caret", "dependentPanel"]
23  static values = { activeCategory: String }
24
25  connect() {
26    this._boundClose = this._handleOutsideClick.bind(this)
27    this._boundKeydown = this._handleKeydown.bind(this)
28    this._boundOpenFromEvent = () => this._open()
29    this._boundCloseFromEvent = () => this._close()
30    document.addEventListener("filter-modal:open", this._boundOpenFromEvent)
31    document.addEventListener("filter-modal:close", this._boundCloseFromEvent)
32
33    // Run dependent visibility on connect so options are filtered based on URL params
34    this._updateDependentVisibility()
35
36    // Capture initial state so _hasChanges() returns false before _open()
37    this._initialSnapshot = this._snapshotState()
38
39    // Select first visible (non-hidden) category on connect
40    if (this.hasCategoryTarget) {
41      const firstVisible = this.categoryTargets.find(btn => !btn.classList.contains('hidden'))
42      const firstKey = (firstVisible || this.categoryTargets[0])?.dataset?.filterModalKeyParam
43      if (firstKey) {
44        this._activateCategory(firstKey)
45      }
46    }
47  }
48
49  disconnect() {
50    this._removeListeners()
51    document.removeEventListener("filter-modal:open", this._boundOpenFromEvent)
52    document.removeEventListener("filter-modal:close", this._boundCloseFromEvent)
53  }
54
55  /**
56   * Toggle the filter modal panel open/closed.
57   */
58  toggle(event) {
59    if (this.panelTarget.classList.contains("hidden")) {
60      this._open()
61    } else {
62      this._close()
63    }
64  }
65
66  /**
67   * Open the filter modal panel (no-op if already open).
68   */
69  open(event) {
70    if (this.panelTarget.classList.contains("hidden")) {
71      this._open()
72    }
73  }
74
75  /**
76   * Select a category in the left panel and show its controls.
77   * @param {Event} event
78   */
79  selectCategory(event) {
80    const key = event.currentTarget.dataset.filterModalKeyParam
81    if (key) {
82      this._activateCategory(key)
83    }
84  }
85
86  /**
87   * Handle input/change events from modal controls — update dots, badge, and buttons in real time.
88   */
89  inputChanged() {
90    this._updateDependentVisibility()
91    this._updateActiveState()
92  }
93
94  /**
95   * Apply filters — submit the parent filter form and close the modal.
96   */
97  apply() {
98    // Collect all checked/selected values from modal controls and inject into the filter form
99    const filterForm = document.querySelector('[data-controller="filter-form"]')
100    if (!filterForm) return
101
102    // Remove existing modal-injected hidden fields
103    filterForm.querySelectorAll('[data-modal-field]').forEach(el => el.remove())
104
105    // Collect names that the modal controls, so we can clear prior hidden fields
106    const modalNames = new Set()
107    this.controlsTargets.forEach(panel => {
108      panel.querySelectorAll('input, select').forEach(input => {
109        if (input.name) modalNames.add(input.name)
110      })
111    })
112
113    // Remove existing form hidden fields for names the modal manages
114    modalNames.forEach(name => {
115      filterForm.querySelectorAll(`input[type="hidden"][name="${CSS.escape(name)}"]`).forEach(el => {
116        if (!el.dataset.modalField) el.remove()
117      })
118    })
119
120    // Collect values from visible and hidden control panels (skip inactive dependents)
121    const modalValues = {}
122    this.controlsTargets.forEach(panel => {
123      // Skip dependent panels whose parent has no selected values
124      if (panel.dataset.filterModalDependsOn) {
125        const parentKey = panel.dataset.filterModalDependsOn
126        const parentPanel = this.controlsTargets.find(p => p.dataset.filterModalKey === parentKey)
127        if (parentPanel && !this._panelHasActiveValue(parentPanel)) return
128      }
129      const inputs = panel.querySelectorAll('input, select')
130      inputs.forEach(input => {
131        if (input.type === 'checkbox' && !input.checked) return
132        if (input.type === 'radio' && !input.checked) return
133        if (!input.name) return
134        // Skip blank values (e.g. "Any" radio) — clears the filter
135        if (input.value === '') {
136          modalValues[input.name] = ''
137          return
138        }
139
140        modalValues[input.name] = input.value
141        const hidden = document.createElement('input')
142        hidden.type = 'hidden'
143        hidden.name = input.name
144        hidden.value = input.value
145        hidden.dataset.modalField = 'true'
146        filterForm.appendChild(hidden)
147      })
148    })
149
150    // Sync visible form fields (e.g. inline search) with modal values
151    modalNames.forEach(name => {
152      const visibleField = filterForm.querySelector(`input:not([type="hidden"])[name="${CSS.escape(name)}"]`)
153      if (visibleField) {
154        visibleField.value = modalValues[name] || ''
155      }
156    })
157
158    // Reset page
159    const pageInput = filterForm.querySelector('input[name="page"]')
160    if (pageInput) pageInput.value = ""
161
162    // Submit and close
163    filterForm.requestSubmit()
164    this._close()
165  }
166
167  /**
168   * Handle date preset select change — show/hide custom range fields.
169   * @param {Event} event
170   */
171  onDatePresetChange(event) {
172    const value = event.currentTarget.value
173    if (!this.hasDateRangeTarget) return
174
175    // Find the dateRange target within the same controls panel
176    const panel = event.currentTarget.closest('[data-filter-modal-target="controls"]')
177    if (!panel) return
178
179    const dateRange = panel.querySelector('[data-filter-modal-target="dateRange"]')
180    if (dateRange) {
181      dateRange.style.display = value === 'custom' ? '' : 'none'
182    }
183  }
184
185  /**
186   * Filter association checkboxes by search text.
187   * @param {Event} event
188   */
189  searchAssociation(event) {
190    const query = event.currentTarget.value.toLowerCase()
191    const panel = event.currentTarget.closest('[data-filter-modal-target="controls"]')
192    if (!panel) return
193
194    const list = panel.querySelector('[data-filter-modal-target="associationList"]')
195    if (!list) return
196
197    list.querySelectorAll('label').forEach(label => {
198      const text = label.dataset.searchableText || label.textContent.toLowerCase()
199      label.style.display = text.includes(query) ? '' : 'none'
200    })
201  }
202
203  // --- Private ---
204
205  _open() {
206    this.panelTarget.classList.remove("hidden")
207    this._initialSnapshot = this._snapshotState()
208    // Use capture phase so clicks intercepted by stopPropagation still close the modal
209    requestAnimationFrame(() => {
210      document.addEventListener("click", this._boundClose, true)
211      document.addEventListener("keydown", this._boundKeydown)
212    })
213  }
214
215  _close() {
216    this.panelTarget.classList.add("hidden")
217    this._removeListeners()
218  }
219
220  _removeListeners() {
221    document.removeEventListener("click", this._boundClose, true)
222    document.removeEventListener("keydown", this._boundKeydown)
223  }
224
225  _handleOutsideClick(event) {
226    if (!this.element.contains(event.target)) {
227      this._close()
228    }
229  }
230
231  _handleKeydown(event) {
232    if (event.key === "Escape") {
233      this._close()
234    }
235  }
236
237  _activateCategory(key) {
238    this.activeCategoryValue = key
239
240    // Update category button styles
241    this.categoryTargets.forEach(btn => {
242      const btnKey = btn.dataset.filterModalKeyParam
243      const caret = btn.querySelector('[data-filter-modal-target="caret"]')
244      if (btnKey === key) {
245        btn.classList.add("bg-warm-200", "text-slate-400", "font-bold")
246        btn.classList.remove("text-slate-300")
247        if (caret) caret.classList.remove("hidden")
248      } else {
249        btn.classList.remove("bg-warm-200", "text-slate-400", "font-bold")
250        btn.classList.add("text-slate-300")
251        if (caret) caret.classList.add("hidden")
252      }
253    })
254
255    // Show/hide controls panels
256    this.controlsTargets.forEach(panel => {
257      if (panel.dataset.filterModalKey === key) {
258        panel.classList.remove("hidden")
259      } else {
260        panel.classList.add("hidden")
261      }
262    })
263  }
264
265  /**
266   * Recalculate which categories have active values and update dots, badge, and header buttons.
267   */
268  _updateActiveState() {
269    let totalActive = 0
270
271    this.categoryTargets.forEach(btn => {
272      const key = btn.dataset.filterModalKeyParam
273      const panel = this.controlsTargets.find(p => p.dataset.filterModalKey === key)
274      const dot = btn.querySelector('[data-filter-modal-target="dot"]')
275
276      if (!panel) return
277
278      const isActive = this._panelHasActiveValue(panel)
279      if (isActive) totalActive++
280
281      if (dot) {
282        dot.classList.toggle('hidden', !isActive)
283      }
284    })
285
286    const noActive = totalActive === 0
287
288    // Update badge on trigger button — create or remove dynamically
289    if (this.hasTriggerTarget) {
290      const existing = this.triggerTarget.querySelector('[data-filter-modal-target="badge"]')
291      if (totalActive > 0) {
292        if (existing) {
293          existing.textContent = totalActive
294        } else {
295          const badge = document.createElement('span')
296          badge.className = 'inline-flex items-center justify-center w-[18px] h-[18px] rounded-full bg-blue-200 text-white text-[11px] font-bold leading-none'
297          badge.dataset.filterModalTarget = 'badge'
298          badge.textContent = totalActive
299          this.triggerTarget.appendChild(badge)
300        }
301      } else if (existing) {
302        existing.remove()
303      }
304    }
305
306    // Update reset button visibility
307    if (this.hasResetButtonTarget) {
308      this.resetButtonTarget.classList.toggle('hidden', noActive)
309    }
310
311    // Show save/apply only when the modal state has changed from when it was opened
312    const hasChanges = this._hasChanges()
313
314    // Update save button visibility
315    if (this.hasSaveButtonTarget) {
316      this.saveButtonTarget.classList.toggle('hidden', !hasChanges)
317    }
318
319    // Update apply button visibility
320    if (this.hasApplyButtonTarget) {
321      this.applyButtonTarget.classList.toggle('hidden', !hasChanges)
322    }
323  }
324
325  /**
326   * Capture the current state of all modal inputs as a serialized string for comparison.
327   */
328  _snapshotState() {
329    const state = {}
330    this.controlsTargets.forEach(panel => {
331      panel.querySelectorAll('input, select').forEach(input => {
332        if (input.dataset.action && input.dataset.action.includes('searchAssociation')) return
333        if (!input.name) return
334
335        if (input.type === 'checkbox') {
336          if (input.checked) {
337            state[input.name] = state[input.name] || []
338            state[input.name].push(input.value)
339          }
340        } else if (input.type === 'radio') {
341          if (input.checked) state[input.name] = input.value
342        } else {
343          state[input.name] = input.value
344        }
345      })
346    })
347    return JSON.stringify(state)
348  }
349
350  /**
351   * Check whether the current modal state differs from the initial snapshot.
352   */
353  _hasChanges() {
354    return this._initialSnapshot !== this._snapshotState()
355  }
356
357  /**
358   * Show/hide dependent category buttons and filter their inline options based on parent selections.
359   * For remote mode, fetches options from the server when parent values change.
360   */
361  _updateDependentVisibility() {
362    this.categoryTargets.forEach(btn => {
363      const dependsOnKey = btn.dataset.filterModalDependsOnParam
364      if (!dependsOnKey) return
365
366      // Find parent panel and get selected values
367      const parentPanel = this.controlsTargets.find(p => p.dataset.filterModalKey === dependsOnKey)
368      if (!parentPanel) return
369
370      const parentValues = this._getSelectedValues(parentPanel)
371      const childKey = btn.dataset.filterModalKeyParam
372      const childPanel = this.controlsTargets.find(p => p.dataset.filterModalKey === childKey)
373
374      if (parentValues.length === 0) {
375        // No parent values — hide category button and clear child panel
376        btn.classList.add('hidden')
377        if (childPanel) this._clearPanel(childPanel)
378      } else {
379        // Parent has values — show category button
380        btn.classList.remove('hidden')
381        if (childPanel) {
382          // Inline mode: filter labels by data-parent-value
383          const labels = childPanel.querySelectorAll('label[data-parent-value]')
384          if (labels.length > 0) {
385            labels.forEac
385h(label => {
386              const pv = label.dataset.parentValue
387              if (parentValues.includes(pv)) {
388                label.style.display = ''
389              } else {
390                label.style.display = 'none'
391                // Uncheck hidden options
392                const input = label.querySelector('input')
393                if (input) input.checked = false
394              }
395            })
396          }
397
398          // Remote mode: fetch options from collection_url
399          const dependentPanel = childPanel.querySelector('[data-collection-url]')
400          if (dependentPanel) {
401            this._fetchDependentOptions(dependentPanel, parentValues)
402          }
403        }
404      }
405    })
406  }
407
408  /**
409   * Get selected checkbox/radio values from a panel.
410   */
411  _getSelectedValues(panel) {
412    const values = []
413    panel.querySelectorAll('input[type="checkbox"]:checked, input[type="radio"]:checked').forEach(input => {
414      if (input.value !== '') values.push(input.value)
415    })
416    return values
417  }
418
419  /**
420   * Clear all inputs in a panel (uncheck checkboxes, reset radios, clear text/select).
421   */
422  _clearPanel(panel) {
423    panel.querySelectorAll('input').forEach(input => {
424      if (input.type === 'checkbox') input.checked = false
425      else if (input.type === 'radio') {
426        // Reset to blank/"Any" option
427        input.checked = (input.value === '')
428      } else if (input.type !== 'hidden') {
429        input.value = ''
430      }
431    })
432    panel.querySelectorAll('select').forEach(select => { select.value = '' })
433  }
434
435  /**
436   * Fetch dependent options from a remote JSON endpoint.
437   * Debounced to avoid rapid requests when toggling parent checkboxes.
438   */
439  _fetchDependentOptions(panel, parentValues) {
440    const key = panel.dataset.collectionUrl
441    clearTimeout(this._fetchTimers?.[key])
442    this._fetchTimers = this._fetchTimers || {}
443    this._fetchTimers[key] = setTimeout(async () => {
444      try {
445        const url = new URL(panel.dataset.collectionUrl, window.location.origin)
446        if (url.origin !== window.location.origin) return
447        parentValues.forEach(v => url.searchParams.append('parent_values[]', v))
448        const response = await fetch(url, { headers: { 'Accept': 'application/json' } })
449        if (!response.ok) throw new Error(`HTTP ${response.status}`)
450        const options = await response.json()
451        this._renderDependentOptions(panel, options)
452      } catch (e) {
453        const msg = document.createElement('p')
454        msg.className = 'text-xs text-red-400 py-2'
455        msg.textContent = 'Failed to load options'
456        panel.replaceChildren(msg)
457      }
458    }, 300)
459  }
460
461  /**
462   * Render fetched dependent options as selectable rows (checkboxes).
463   */
464  _renderDependentOptions(panel, options) {
465    // Determine the input name from existing inputs, or construct from panel context
466    const existingInput = panel.querySelector('input[type="checkbox"]')
467    const inputName = existingInput?.name || panel.closest('[data-filter-modal-target="controls"]')?.querySelector('input[type="checkbox"]')?.name || ''
468
469    // Preserve currently checked values
470    const checked = new Set()
471    panel.querySelectorAll('input[type="checkbox"]:checked').forEach(input => checked.add(input.value))
472
473    const fragment = document.createDocumentFragment()
474
475    if (options.length === 0) {
476      const msg = document.createElement('p')
477      msg.className = 'text-xs text-slate-200 py-2'
478      msg.textContent = 'No options available'
479      fragment.appendChild(msg)
480    } else {
481      options.forEach(opt => {
482        const label = document.createElement('label')
483        label.className = 'flex items-center justify-between py-2 px-3 -mx-3 rounded-sm cursor-pointer hover:bg-warm-200 transition-colors'
484        label.dataset.searchableText = opt.label.toLowerCase()
485
486        const input = document.createElement('input')
487        input.type = 'checkbox'
488        input.name = inputName
489        input.value = opt.value
490        input.checked = checked.has(opt.value)
491        input.className = 'sr-only peer'
492
493        const text = document.createElement('span')
494        text.className = 'text-xs font-header font-semibold text-slate-300'
495        text.textContent = opt.label
496
497        const uncheckedDot = document.createElement('span')
498        uncheckedDot.className = 'w-5 h-5 rounded-full border-2 border-warm-300 peer-checked:hidden'
499
500        const checkedDot = document.createElement('span')
501        checkedDot.className = 'w-5 h-5 rounded-full bg-blue-300 hidden peer-checked:block'
502
503        label.append(input, text, uncheckedDot, checkedDot)
504        fragment.appendChild(label)
505      })
506    }
507
508    panel.replaceChildren(fragment)
509  }
510
511  /**
512   * Check whether a controls panel has any active (non-empty) filter value.
513   */
514  _panelHasActiveValue(panel) {
515    const inputs = panel.querySelectorAll('input, select')
516    for (const input of inputs) {
517      // Skip association search inputs — they filter the list, not the data
518      if (input.dataset.action && input.dataset.action.includes('searchAssociation')) {
519        continue
520      }
521
522      if (input.type === 'checkbox' && input.checked) return true
523      if (input.type === 'radio' && input.checked && input.value !== '') return true
524      if ((input.type === 'search' || input.type === 'text' || input.type === 'date') && input.value.trim() !== '') return true
525      if (input.tagName === 'SELECT' && input.value !== '') return true
526    }
527    return false
528  }
529}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.