PageSourceSearch

https://www.millsborochamber.com/assets/controllers/search_select_controller-953186f9.js

js millsborochamber.com collected 2026-09-29 09:51:48 UTC 21,234 bytes, 629 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3const DEFAULT_RESULTS_LIMIT = 12
4const DEFAULT_RECENT_LIMIT = 8
5const REMOTE_SEARCH_DELAY = 200
6
7export default class extends Controller {
8  static targets = ["label", "fallback", "source", "enhanced", "hidden", "input", "panel", "list", "status", "pagination", "loadMore"]
9  static values = {
10    recentIds: Array,
11    required: Boolean,
12    searchPlaceholder: String,
13    emptyMessage: String,
14    requiredMessage: String,
15    recentHeading: String,
16    paginated: Boolean,
17    floating: Boolean,
18    searchUrl: String,
19  }
20
21  connect() {
22    this.options = Array.from(this.sourceTarget.options).map((option) => ({
23      label: option.textContent.trim(),
24      value: option.value.toString(),
25      blank: option.value.toString().trim() === "",
26    }))
27    this.remoteOptions = []
28    this.remoteQuery = null
29    this.remoteNextPage = null
30    this.remoteSearchAsOf = null
31    this.remoteSearchTimer = null
32    this.remoteAbortController = null
33    this.remoteSearchPending = false
34    this.remoteSearchFailed = false
35    this.rebuildOptionIndex()
36    this.blankOption = this.options.find((option) => option.blank)
37    this.activeIndex = -1
38    this.renderedOptions = []
39    this.form = this.element.closest("form")
40
41    this.outsideClickHandler = this.handleOutsideClick.bind(this)
42    this.submitHandler = this.handleSubmit.bind(this)
43    this.positionHandler = this.syncFloatingPanel.bind(this)
44
45    document.addEventListener("mousedown", this.outsideClickHandler)
46    this.form?.addEventListener("submit", this.submitHandler)
47    if (this.floatingValue) {
48      window.addEventListener("resize", this.positionHandler)
49      window.addEventListener("scroll", this.positionHandler, true)
50    }
51
52    this.hiddenTarget.name = this.sourceTarget.name
53    this.hiddenTarget.value = this.sourceTarget.value.toString()
54    // Keep the name for change listeners and Turbo reconnects. Disabled
55    // controls are omitted from FormData, so only the hidden input submits.
56    this.sourceTarget.disabled = true
57
58    this.fallbackTarget.classList.add("hidden")
59    this.enhancedTarget.classList.remove("hidden")
60    this.inputTarget.placeholder = this.searchPlaceholderValue || this.inputTarget.placeholder
61
62    if (this.hasLabelTarget) this.labelTarget.htmlFor = this.inputTarget.id
63
64    this.syncInputToSelection()
65    this.syncExpandedState(false)
66  }
67
68  disconnect() {
69    document.removeEventListener("mousedown", this.outsideClickHandler)
70    this.form?.removeEventListener("submit", this.submitHandler)
71    if (this.floatingValue) {
72      window.removeEventListener("resize", this.positionHandler)
73      window.removeEventListener("scroll", this.positionHandler, true)
74    }
75    window.clearTimeout(this.focusOutTimer)
76    window.clearTimeout(this.remoteSearchTimer)
77    this.remoteAbortController?.abort()
78    this.remoteAbortController = null
79    this.clearFloatingPanel()
80  }
81
82  open() {
83    if (this.paginatedValue && this.remoteQuery !== this.inputTarget.value.trim()) this.scheduleRemoteSearch()
84    this.renderList()
85    this.panelTarget.classList.remove("hidden")
86    this.syncExpandedState(true)
87    this.syncFloatingPanel()
88  }
89
90  close() {
91    this.panelTarget.classList.add("hidden")
92    this.activeIndex = -1
93    this.syncExpandedState(false)
94    this.syncActiveDescendant()
95    this.clearFloatingPanel()
96  }
97
98  input() {
99    if (this.inputTarget.value.trim() !== this.selectedLabel()) {
100      const hadSelection = this.hiddenTarget.value.toString().trim() !== ""
101      this.clearSelection({ preserveInput: true, notify: hadSelection })
102    }
103
104    this.clearValidation()
105    this.scheduleRemoteSearch()
106    this.open()
107  }
108
109  scheduleRemoteSearch() {
110    if (!this.hasSearchUrlValue) return
111
112    window.clearTimeout(this.remoteSearchTimer)
113    this.remoteAbortController?.abort()
114    this.remoteAbortController = null
115    this.remoteOptions = []
116    this.remoteNextPage = null
117    this.remoteSearchAsOf = null
118    this.remoteSearchFailed = false
119    this.activeIndex = -1
120    this.listTarget.scrollTop = 0
121
122    const query = this.inputTarget.value.toString().trim()
123    this.remoteQuery = query
124    if (query === "" && !this.paginatedValue) {
125      this.remoteSearchPending = false
126      this.rebuildOptionIndex()
127      return
128    }
129
130    this.remoteSearchPending = true
131    this.rebuildOptionIndex()
132    this.remoteSearchTimer = window.setTimeout(() => this.runRemoteSearch(query), REMOTE_SEARCH_DELAY)
133  }
134
135  async runRemoteSearch(query, page = 1) {
136    const abortController = new AbortController()
137    this.remoteAbortController = abortController
138    const url = new URL(this.searchUrlValue, window.location.origin)
139    url.searchParams.set("q", query)
140    if (this.paginatedValue) url.searchParams.set("page", page)
141    if (this.remoteSearchAsOf) url.searchParams.set("as_of", this.remoteSearchAsOf)
142
143    try {
144      const response = await fetch(url.toString(), {
145        headers: { Accept: "application/json" },
146        credentials: "same-origin",
147        signal: abortController.signal,
148      })
149      if (!response.ok) throw new Error(`Search request failed with ${response.status}`)
150
151      const payload = await response.json()
152      if (abortController !== this.remoteAbortController || query !== this.inputTarget.value.toString().trim()) return
153
154      const options = Array.isArray(payload.options)
155        ? payload.options.map((option) => ({
156            label: option.label.toString(),
157            value: option.value.toString(),
158            blank: false,
159          }))
160        : []
161      if (page > 1) {
162        this.activeIndex = this.remoteOptions.length
163        const seen = new Set(this.remoteOptions.map((option) => option.value))
164        this.remoteOptions.push(...options.filter((option) => !seen.has(option.value)))
165      } else {
166        this.remoteOptions = options
167      }
168      this.remoteNextPage = this.paginatedValue ? payload.next_page : null
169      this.remoteSearchAsOf = this.paginatedValue ? payload.as_of : null
170      this.remoteSearchFailed = false
171    } catch (error) {
172      if (error.name === "AbortError" || abortController !== this.remoteAbortController) return
173
174      if (page === 1) this.remoteOptions = []
175      if (this.paginatedValue) this.remoteNextPage = page
176      this.remoteSearchFailed = true
177    } finally {
178      if (abortController !== this.remoteAbortController) return
179
180      this.remoteAbortController = null
181      this.remoteSearchPending = false
182      this.rebuildOptionIndex()
183      this.renderList()
184      if (page > 1 && !this.remoteSearchFailed) this.scrollActiveOptionIntoView()
185    }
186  }
187
188  loadMore() {
189    if (!this.remoteNextPage || this.remoteSearchPending) return
190
191    this.inputTarget.focus({ preventScroll: true })
192    this.remoteSearchPending = true
193    this.remoteSearchFailed = false
194    this.renderList()
195    this.runRemoteSearch(this.remoteQuery, this.remoteNextPage)
196  }
197
198  paginationKeydown(event) {
199    if (!["ArrowUp", "Escape"].includes(event.key)) return
200
201    event.preventDefault()
202    this.inputTarget.focus({ preventScroll: true })
203    if (event.key === "Escape") {
204      this.close()
205    } else {
206      this.activeIndex = this.renderedOptions.length - 1
207      this.renderList()
208      this.scrollActiveOptionIntoView()
209    }
210  }
211
212  keydown(event) {
213    switch (event.key) {
214      case "ArrowDown":
215        event.preventDefault()
216        this.moveActive(1)
217        break
218      case "ArrowUp":
219        event.preventDefault()
220        this.moveActive(-1)
221        break
222      case "Enter": {
223        if (this.panelTarget.classList.contains("hidden")) return
224
225        const option = this.activeOption() || this.renderedOptions[0]
226        if (!option) return
227
228        event.preventDefault()
229        this.selectValue(option.value)
230        break
231      }
232      case "Escape":
233        if (this.panelTarget.classList.contains("hidden")) return
234
235        event.preventDefault()
236        this.close()
237        break
238    }
239  }
240
241  focusOut() {
242    window.clearTimeout(this.focusOutTimer)
243    this.focusOutTimer = window.setTimeout(() => {
244      if (!this.element.contains(document.activeElement)) this.close()
245    }, 0)
246  }
247
248  handleSubmit(event) {
249    // A submitter that opts out of validation (e.g. a secondary "Save changes" button) skips this guard.
250    if (event.submitter?.formNoValidate) {
251      this.clearValidation()
252      return
253    }
254
255    if (this.hiddenTarget.disabled || this.inputTarget.disabled) {
256      this.clearValidation()
257      return
258    }
259
260    const hasSelection = this.hiddenTarget.value.toString().trim() !== ""
261    const hasTypedQuery = this.inputTarget.value.toString().trim() !== ""
262
263    if (hasSelection || (!this.requiredValue && !hasTypedQuery)) {
264      this.clearValidation()
265      return
266    }
267
268    event.preventDefault()
269
270    const message = hasTypedQuery
271      ? `Choose ${this.fieldLabel()} from the list.`
272      : (this.requiredMessageValue || `Select ${this.fieldLabel()}.`)
273
274    this.inputTarget.setCustomValidity(message)
275    this.inputTarget.reportValidity()
276    this.open()
277  }
278
279  handleOutsideClick(event) {
280    if (this.element.contains(event.target)) return
281
282    this.close()
283  }
284
285  moveActive(direction) {
286    if (this.panelTarget.classList.contains("hidden")) this.open()
287    if (this.renderedOptions.length === 0) return
288
289    if (direction > 0 && this.activeIndex === this.renderedOptions.length - 1 && this.remoteNextPage && !this.remoteSearchPending) {
290      this.loadMoreTarget.focus({ preventScroll: true })
291      return
292    }
293
294    let nextIndex
295    if (this.activeIndex < 0) {
296      nextIndex = direction > 0 ? 0 : this.renderedOptions.length - 1
297    } else {
298      nextIndex = (this.activeIndex + direction) % this.renderedOptions.length
299      if (nextIndex < 0) nextIndex += this.renderedOptions.length
300    }
301
302    this.activeIndex = nextIndex
303    this.renderList()
304    if (this.paginatedValue) this.scrollActiveOptionIntoView()
305  }
306
307  renderList() {
308    const sections = this.optionSections()
309    this.renderedOptions = sections.flatMap((section) => section.options)
310
311    if (this.activeIndex >= this.renderedOptions.length) this.activeIndex = this.renderedOptions.length - 1
312
313    const fragment = document.createDocumentFragment()
314    let optionOffset = 0
315
316    sections.forEach((section) => {
317      if (section.heading) {
318        const heading = document.createElement("li")
319        heading.className = "px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500"
320        heading.textContent = section.heading
321        fragment.appendChild(heading)
322      }
323
324      section.options.forEach((option, localIndex) => {
325        const globalIndex = optionOffset + localIndex
326        fragment.appendChild(this.optionElement(option, globalIndex))
327      })
328
329      optionOffset += section.options.length
330    })
331
332    if (this.renderedOptions.length === 0) {
333      const emptyState = document.createElement("li")
334      emptyState.className = "px-3 py-2 text-sm text-slate-500"
335      emptyState.textContent = this.emptyStateMessage()
336      fragment.appendChild(emptyState)
337    } else if (this.remoteSearchPending || this.remoteSearchFailed) {
338      const remoteStatus = document.createElement("li")
339      remoteStatus.setAttribute("aria-hidden", "true")
340      remoteStatus.className = "border-t border-zinc-100 px-3 py-2 text-xs text-slate-500"
341      remoteStatus.textContent = this.remoteSearchPending
342        ? "Searching for more matches..."
343        : (this.paginatedValue ? "Could not load more results. Try again." : "Search is unavailable. Initial matches may be incomplete.")
344      fragment.appendChild(remoteStatus)
345    }
346
347    const scrollTop = this.listTarget.scrollTop
348    this.listTarget.replaceChildren(fragment)
349    this.listTarget.scrollTop = scrollTop
350    if (this.hasPaginationTarget) {
351      this.paginationTarget.classList.toggle("hidden", !this.remoteNextPage)
352      this.loadMoreTarget.disabled = this.remoteSearchPending
353      this.loadMoreTarget.textContent = this.remoteSearchPending ? "Loading..." : (this.remoteSearchFailed ? "Try again" : "Load more")
354    }
355    this.syncRemoteStatus()
356    this.syncActiveDescendant()
357    this.syncFloatingPanel()
358  }
359
360  optionSections() {
361    const query = this.normalizedQuery()
362    if (this.paginatedValue) {
363      return [{ heading: query === "" ? this.recentHeadingValue : null, options: this.remoteOptions }]
364    }
365    const availableOptions = this.searchableOptions().filter((option) => !option.blank)
366
367    if (query === "") {
368      const sections = []
369
370      if (!this.requiredValue && this.blankOption) {
371        sections.push({ heading: null, options: [this.blankOption] })
372      }
373
374      const recentOptions = this.recentOptions()
375      if (recentOptions.length > 0) {
376        sections.push({ heading: this.recentHeadingValue || "Recent organizations", options: recentOptions })
377      } else {
378        sections.push({ heading: null, options: availableOptions.slice(0, DEFAULT_RECENT_LIMIT) })
379      }
380
381      return sections
382    }
383
384    const matches = availableOptions
385      .filter((option) => option.label.toLowerCase().includes(query))
386      .sort((left, right) => this.sortForQuery(left, right, query))
387
388    return [{ heading: null, options: matches.slice(0, DEFAULT_RESULTS_LIMIT) }]
389  }
390
391  recentOptions() {
392    const seen = new Set()
393    const options = []
394
395    this.recentIdsValue.forEach((value) => {
396      const option = this.optionByValue.get(value.toString())
397      if (!option || option.blank || seen.has(option.value)) return
398
399      seen.add(option.value)
400      options.push(option)
401    })
402
403    if (options.length < DEFAULT_RECENT_LIMIT) {
404      this.options.forEach((option) => {
405        if (option.blank || seen.has(option.value) || options.length >= DEFAULT_RECENT_LIMIT) return
406
407        seen.add(option.value)
408        options.push(option)
409      })
410    }
411
412    return options
413  }
414
415  sortForQuery(left, right, query) {
416    const leftLabel = left.label.toLowerCase()
417    const rightLabel = right.label.toLowerCase()
418    const leftStarts = leftLabel.startsWith(query) ? 0 : 1
419    const rightStarts = rightLabel.startsWith(query) ? 0 : 1
420
421    if (leftStarts !== rightStarts) return leftStarts - rightStarts
422
423    return leftLabel.localeCompare(rightLabel)
424  }
425
426  optionElement(option, index) {
427    const listItem = document.createElement("li")
428    const button = document.createElement("button")
429    const selected = option.value === this.hiddenTarget.value.toString()
430    const active = index === this.activeIndex
431
432    button.type = "button"
433    if (this.paginatedValue) button.tabIndex = -1
434    button.id = this.optionId(index)
435    button.dataset.index = index.toString()
436    button.dataset.value = option.value
437    button.setAttribute("role", "option")
438    button.setAttribute("aria-selected", selected ? "true" : "false")
439    button.className = [
440      "flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm",
441      active ? "bg-slate-100 text-slate-900" : "text-slate-700 hover:bg-slate-50",
442      selected ? "font-medium text-slate-900" : "",
443    ].filter(Boolean).join(" ")
444
445    button.addEventListener("mousedown", (event) => event.preventDefault())
446    button.addEventListener("mouseenter", () => {
447      this.activeIndex = index
448      this.syncActiveDescendant()
449    })
450    button.addEventListener("click", () => this.selectValue(option.value))
451
452    const label = document.createElement("span")
453    label.className = "truncate"
454    label.textContent = option.label
455    button.appendChild(label)
456
457    if (selected) {
458      const selectedTag = document.createElement("span")
459      selectedTag.className = "shrink-0 text-xs font-medium text-amber-700"
460      selectedTag.textContent = "Selected"
461      button.appendChild(selectedTag)
462    }
463
464    listItem.appendChild(button)
465    return listItem
466  }
467
468  selectValue(value) {
469    const option = this.optionByValue.get(value.toString())
470    if (!option) return
471
472    window.clearTimeout(this.remoteSearchTimer)
473    this.remoteSearchTimer = null
474    this.remoteAbortController?.abort()
475    this.remoteAbortController = null
476    this.remoteSearchPending = false
477
478    if (!this.options.some((candidate) => candidate.value === option.value)) {
479      this.options.push(option)
480      const sourceOption = document.createElement("option")
481      sourceOption.value = option.value
482      sourceOption.textContent = option.label
483      this.sourceTarget.appendChild(sourceOption)
484      this.rebuildOptionIndex()
485    }
486
487    this.hiddenTarget.value = option.value
488    this.sourceTarget.value = option.value
489    this.inputTarget.value = option.blank ? "" : option.label
490    this.clearValidation()
491    this.sourceTarget.dispatchEvent(new Event("change", { bubbles: true }))
492    this.close()
493  }
494
495  clearSelection({ preserveInput = false, notify = true } = {}) {
496    this.hiddenTarget.value = ""
497    this.sourceTarget.value = ""
498    if (!preserveInput) this.inputTarget.value = ""
499    if (notify) this.sourceTarget.dispatchEvent(new Event("change", { bubbles: true }))
500  }
501
502  syncInputToSelection() {
503    this.inputTarget.value = this.selectedLabel()
504  }
505
506  selectedLabel() {
507    const selected = this.optionByValue.get(this.hiddenTarget.value.toString())
508    if (!selected || selected.blank) return ""
509
510    return selected.label
511  }
512
513  normalizedQuery() {
514    return this.inputTarget.value.toString().trim().toLowerCase()
515  }
516
517  activeOption() {
518    if (this.activeIndex < 0) return null
519
520    return this.renderedOptions[this.activeIndex]
521  }
522
523  optionId(index) {
524    return `${this.inputTarget.id}_option_${index}`
525  }
526
527  fieldLabel() {
528    const label = this.hasLabelTarget ? this.labelTarget.textContent.toString().trim().toLowerCase() : "an option"
529    return label.replace(/:$/, "")
530  }
531
532  clearValidation() {
533    this.inputTarget.setCustomValidity("")
534  }
535
536  searchableOptions() {
537    const seen = new Set()
538
539    return [...this.remoteOptions, ...this.options].filter((option) => {
540      if (seen.has(option.value)) return false
541
542      seen.add(option.value)
543      return true
544    })
545  }
546
547  rebuildOptionIndex() {
548    this.optionByValue = new Map(this.searchableOptions().map((option) => [option.value, option]))
549  }
550
551  emptyStateMessage() {
552    if (this.remoteSearchPending) return "Searching..."
553    if (this.remoteSearchFailed) return "Search is unavailable. Try again."
554
555    return this.emptyMessageValue || "No matches found."
556  }
557
558  syncRemoteStatus() {
559    const remoteSearchActive = this.hasSearchUrlValue && (this.paginatedValue || this.normalizedQuery() !== "")
560    let message = ""
561
562    if (remoteSearchActive && this.remoteSearchPending) {
563      message = "Searching."
564    } else if (remoteSearchActive && this.remoteSearchFailed) {
565      message = this.paginatedValue ? "Search is unavailable. Try again." : "Search is unavailable. Initial matches may be incomplete."
566    } else if (remoteSearchActive) {
567      const count = this.renderedOptions.length
568      message = `${count} ${count === 1 ? "match" : "matches"} available.`
569    }
570
571    this.listTarget.setAttribute("aria-busy", this.remoteSearchPending ? "true" : "false")
572    if (this.statusTarget.textContent !== message) this.statusTarget.textContent = message
573  }
574
575  syncExpandedState(expanded) {
576    this.inputTarget.setAttribute("aria-expanded", expanded ? "true" : "false")
577  }
578
579  syncActiveDescendant() {
580    if (this.activeIndex < 0 || this.activeIndex >= this.renderedOptions.length) {
581      this.inputTarget.removeAttribute("aria-activedescendant")
582      return
583    }
584
585    this.inputTarget.setAttribute("aria-activedescendant", this.optionId(this.activeIndex))
586  }
587
588  scrollActiveOptionIntoView() {
589    if (this.panelTarget.classList.contains("hidden")) return
590
591    this.listTarget.querySelector(`#${this.optionId(this.activeIndex)}`)?.scrollIntoView({ block: "nearest" })
592  }
593
594  syncFloatingPanel() {
595    if (!this.floatingValue || this.panelTarget.classList.contains("hidden")) return
596
597    const rect = this.inputTarget.getBoundingClientRect()
598    const viewportPadding = 16
599    const gap = 8
600    const availableBelow = window.innerHeight - rect.bottom - gap - viewportPadding
601    const availableAbove = rect.top - gap - viewportPadding
602    const paginationHeight = this.hasPaginationTarget ? this.paginationTarget.offsetHeight : 0
603    const preferredHeight = Math.min(288, this.listTarget.scrollHeight + paginationHeight + 2)
604    const useAbove = availableBelow < 180 && availableAbove > availableBelow
605    const maxHeight = Math.max(0, Math.min(preferredHeight, useAbove ? availableAbove : availableBelow))
606    const top = useAbove
607      ? Math.max(viewportPadding, rect.top - gap - maxHeight)
608      : Math.min(rect.bottom + gap, window.innerHeight - viewportPadding - maxHeight)
609
610    Object.assign(this.panelTarget.style, {
611      position: "fixed",
612      left: `${Math.max(viewportPadding, rect.left)}px`,
613      right: "auto",
614      top: `${top}px`,
615      marginTop: "0",
616      width: `${rect.width}px`,
617      maxHeight: `${maxHeight}px`,
618      zIndex: "80",
619    })
620    this.listTarget.style.maxHeight = `${Math.max(0, maxHeight - paginationHeight - 2)}px`
621  }
622
623  clearFloatingPanel() {
624    if (!this.floatingValue) return
625
626    this.panelTarget.removeAttribute("style")
627    this.listTarget.style.removeProperty("max-height")
628  }
629}

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.