PageSourceSearch

https://onlinestore.summerclassics.com/assets/controllers/searchab…9c680ae365fa3645cc667bb876cf97492a5d22f4.js

js summerclassics.com collected 2026-09-26 04:21:17 UTC 4,406 bytes, 156 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3/**
4 * Client-side searchable combobox for large <select> elements.
5 * Replaces legacy jQuery Select2 on enrollment customer account picker.
6 */
7export default class extends Controller {
8  static targets = ["select", "input", "listbox"]
9  static values = {
10    placeholder: { type: String, default: "Search..." }
11  }
12
13  #options = []
14  #activeIndex = -1
15
16  connect() {
17    this.#options = Array.from(this.selectTarget.options).map((option) => ({
18      value: option.value,
19      label: option.textContent.trim(),
20      disabled: option.disabled
21    }))
22
23    const selected = this.selectTarget.selectedOptions[0]
24    if (selected?.value) {
25      this.inputTarget.value = selected.textContent.trim()
26    } else {
27      this.inputTarget.placeholder = this.placeholderValue
28    }
29
30    this.boundCloseOnClickOutside = this.closeOnClickOutside.bind(this)
31    document.addEventListener("click", this.boundCloseOnClickOutside)
32  }
33
34  disconnect() {
35    document.removeEventListener("click", this.boundCloseOnClickOutside)
36  }
37
38  open() {
39    this.#updateListbox()
40    this.#showListbox()
41  }
42
43  close() {
44    this.listboxTarget.classList.add("hidden")
45    this.inputTarget.setAttribute("aria-expanded", "false")
46    this.#activeIndex = -1
47  }
48
49  filter() {
50    this.#updateListbox()
51    this.#showListbox()
52  }
53
54  #updateListbox() {
55    const query = this.inputTarget.value.trim().toLowerCase()
56    const selected = this.selectTarget.selectedOptions[0]
57
58    if (query === "") {
59      this.selectTarget.value = ""
60    } else if (!selected || selected.textContent.trim().toLowerCase() !== query) {
61      this.selectTarget.value = ""
62    }
63
64    const matches = this.#options.filter((option) => {
65      if (option.disabled || option.value === "") return false
66      return query === "" || option.label.toLowerCase().includes(query)
67    })
68
69    this.#renderOptions(matches)
70  }
71
72  #showListbox() {
73    this.listboxTarget.classList.remove("hidden")
74    this.inputTarget.setAttribute("aria-expanded", "true")
75  }
76
77  selectOption(event) {
78    const button = event.currentTarget
79    const value = button.dataset.value
80    const label = button.dataset.label
81
82    this.selectTarget.value = value
83    this.inputTarget.value = label
84    this.selectTarget.dispatchEvent(new Event("change", { bubbles: true }))
85    this.close()
86  }
87
88  clearSelection() {
89    this.selectTarget.value = ""
90    this.inputTarget.value = ""
91    this.selectTarget.dispatchEvent(new Event("change", { bubbles: true }))
92    this.filter()
93  }
94
95  handleKeydown(event) {
96    const buttons = this.listboxTarget.querySelectorAll("[data-value]")
97    if (buttons.length === 0) return
98
99    if (event.key === "ArrowDown") {
100      event.preventDefault()
101      this.#activeIndex = Math.min(this.#activeIndex + 1, buttons.length - 1)
102      this.#highlightActive(buttons)
103    } else if (event.key === "ArrowUp") {
104      event.preventDefault()
105      this.#activeIndex = Math.max(this.#activeIndex - 1, 0)
106      this.#highlightActive(buttons)
107    } else if (event.key === "Enter" && this.#activeIndex >= 0) {
108      event.preventDefault()
109      buttons[this.#activeIndex].click()
110    } else if (event.key === "Escape") {
111      this.close()
112    }
113  }
114
115  closeOnClickOutside(event) {
116    if (!this.element.contains(event.target)) {
117      this.close()
118    }
119  }
120
121  #highlightActive(buttons) {
122    buttons.forEach((button, index) => {
123      button.classList.toggle("bg-sc-raised", index === this.#activeIndex)
124    })
125    buttons[this.#activeIndex]?.scrollIntoView({ block: "nearest" })
126  }
127
128  #renderOptions(matches) {
129    if (matches.length === 0) {
130      this.listboxTarget.innerHTML = '<p class="px-3 py-2 text-sm text-sc-text-3">No matches found</p>'
131      return
132    }
133
134    this.listboxTarget.innerHTML = matches.map((option) => `
135      <button type="button"
136              role="option"
137              data-action="searchable-select#selectOption"
138              data-value="${this.#escape(option.value)}"
139              data-label="${this.#escape(option.label)}"
140              class="block w-full px-3 py-2 text-left text-sm text-sc-text hover:bg-sc-raised">
141        ${this.#escape(option.label)}
142      </button>
143    `).join("")
144
145    this.#activeIndex = -1
146  }
147
148  #escape(text) {
149    return text
150      .replace(/&/g, "&amp;")
151      .replace(/"/g, "&quot;")
152      .replace(/</g, "&lt;")
153      .replace(/>/g, "&gt;")
154  }
155}
156;

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.