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.