PageSourceSearch

https://saii.kr/assets/controllers/searchable_select_controller-994245ff.js

js saii.kr collected 2026-10-06 03:46:11 UTC 6,961 bytes, 232 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4  static targets = ["query", "select", "empty", "field", "menu", "option", "selection", "trigger"]
5  static values = { invalidMessage: String, placeholder: String }
6
7  connect() {
8    this.indexOptions()
9    if (this.hasMenuTarget) this.enablePicker()
10  }
11
12  disconnect() {
13    if (!this.hasMenuTarget) return
14
15    this.selectTarget.required = this.selectWasRequired
16    this.selectTarget.classList.remove("hidden")
17    this.triggerTarget.hidden = true
18    this.triggerTarget.classList.add("hidden")
19    this.queryTarget.setCustomValidity("")
20  }
21
22  filter() {
23    if (this.hasMenuTarget) {
24      this.queryTarget.setCustomValidity("")
25      this.filterMenu(this.queryTarget.value)
26      return
27    }
28
29    this.filterNativeSelect()
30  }
31
32  open() {
33    if (!this.hasMenuTarget) return
34
35    this.queryTarget.value = ""
36    this.queryTarget.setCustomValidity("")
37    this.filterMenu("")
38    this.showMenu()
39    this.setActiveOption(this.optionForValue(this.selectTarget.value))
40    this.queryTarget.focus({ preventScroll: true })
41  }
42
43  dismiss(event) {
44    event?.preventDefault()
45    this.hideMenu()
46    this.triggerTarget.focus({ preventScroll: true })
47  }
48
49  closeOnOutside(event) {
50    if (!this.hasMenuTarget || this.menuTarget.hidden) return
51
52    const boundary = this.hasFieldTarget ? this.fieldTarget : this.element
53    if (!boundary.contains(event.target)) this.hideMenu()
54  }
55
56  navigate(event) {
57    if (!this.hasMenuTarget) return
58
59    if (event.key === "Tab") {
60      this.hideMenu()
61      return
62    }
63
64    if (event.key === "Escape") {
65      this.dismiss(event)
66      return
67    }
68
69    if (event.key === "Enter" && this.activeOption) {
70      event.preventDefault()
71      this.choose(this.activeOption)
72      return
73    }
74
75    if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
76
77    event.preventDefault()
78    const visibleOptions = this.visibleOptions()
79    if (visibleOptions.length === 0) return
80
81    const currentIndex = visibleOptions.indexOf(this.activeOption)
82    const offset = event.key === "ArrowDown" ? 1 : -1
83    const nextIndex = currentIndex === -1
84      ? (event.key === "ArrowDown" ? 0 : visibleOptions.length - 1)
85      : (currentIndex + offset + visibleOptions.length) % visibleOptions.length
86    this.setActiveOption(visibleOptions[nextIndex])
87  }
88
89  select(event) {
90    this.choose(event.currentTarget)
91  }
92
93  validate(event) {
94    if (!this.hasMenuTarget || this.selectTarget.value !== "") return
95
96    event.preventDefault()
97    this.open()
98    this.queryTarget.setCustomValidity(this.invalidMessageValue)
99    this.queryTarget.reportValidity()
100  }
101
102  enablePicker() {
103    this.selectWasRequired = this.selectTarget.required
104    this.selectTarget.required = false
105    this.selectTarget.classList.add("hidden")
106    this.triggerTarget.hidden = false
107    this.triggerTarget.classList.remove("hidden")
108    this.syncSelection()
109    this.hideMenu()
110  }
111
112  choose(option) {
113    if (!option) return
114
115    this.selectTarget.value = option.dataset.value
116    this.queryTarget.setCustomValidity("")
117    this.syncSelection()
118    this.hideMenu()
119    this.selectTarget.dispatchEvent(new Event("change", { bubbles: true }))
120    this.triggerTarget.focus({ preventScroll: true })
121  }
122
123  filterMenu(value) {
124    const query = this.normalized(value)
125    const visibleOptions = this.optionTargets.filter((option) => {
126      const visible = query === "" || this.normalized(option.dataset.searchText).includes(query)
127      option.hidden = !visible
128      return visible
129    })
130
131    if (this.hasEmptyTarget) {
132      const empty = visibleOptions.length === 0
133      this.emptyTarget.hidden = !empty
134      this.emptyTarget.classList.toggle("hidden", !empty)
135    }
136
137    if (this.activeOption?.hidden) this.setActiveOption()
138    return visibleOptions
139  }
140
141  showMenu() {
142    this.menuTarget.hidden = false
143    this.menuTarget.classList.remove("hidden")
144    this.triggerTarget.setAttribute("aria-expanded", "true")
145    this.queryTarget.setAttribute("aria-expanded", "true")
146  }
147
148  hideMenu() {
149    this.menuTarget.hidden = true
150    this.menuTarget.classList.add("hidden")
151    this.triggerTarget.setAttribute("aria-expanded", "false")
152    this.queryTarget.setAttribute("aria-expanded", "false")
153    this.queryTarget.removeAttribute("aria-activedescendant")
154    this.setActiveOption()
155  }
156
157  visibleOptions() {
158    return this.optionTargets.filter((option) => !option.hidden)
159  }
160
161  setActiveOption(option) {
162    this.activeOption = option
163    this.optionTargets.forEach((candidate) => this.renderOptionState(candidate))
164
165    if (option) {
166      this.queryTarget.setAttribute("aria-activedescendant", option.id)
167      option.scrollIntoView({ block: "nearest" })
168    } else {
169      this.queryTarget.removeAttribute("aria-activedescendant")
170    }
171  }
172
173  syncSelection() {
174    const selectedOption = this.selectedNativeOption()
175    const selectedLabel = selectedOption?.value ? selectedOption.textContent.trim() : this.placeholderValue
176    this.selectionTarget.textContent = selectedLabel
177    this.selectionTarget.style.color = selectedOption?.value ? "var(--kb-ink)" : "var(--kb-caption)"
178
179    this.optionTargets.forEach((option) => {
180      const selected = option.dataset.value === this.selectTarget.value
181      option.setAttribute("aria-selected", String(selected))
182      this.renderOptionState(option)
183    })
184  }
185
186  renderOptionState(option) {
187    const selected = option.dataset.value === this.selectTarget.value
188    const active = option === this.activeOption
189    option.style.background = selected
190      ? "var(--kb-primary-container)"
191      : (active ? "var(--kb-fill)" : "")
192  }
193
194  optionForValue(value) {
195    return this.optionTargets.find((option) => option.dataset.value === value)
196  }
197
198  selectedNativeOption() {
199    return this.optionIndex.find(({ option }) => option.value === this.selectTarget.value)?.option
200  }
201
202  filterNativeSelect() {
203    const query = this.normalized(this.queryTarget.value)
204    let matchingOptions = 0
205
206    this.optionIndex.forEach(({ option, text, value, searchText }) => {
207      const blank = option.value === ""
208      const selected = option.selected
209      const matches = text.includes(query) || value.includes(query) || searchText.includes(query)
210      const visible = blank || selected || query === "" || matches
211
212      option.hidden = !visible
213      option.disabled = !visible
214      if (!blank && (query === "" || matches)) matchingOptions += 1
215    })
216
217    if (this.hasEmptyTarget) this.emptyTarget.classList.toggle("hidden", matchingOptions > 0)
218  }
219
220  indexOptions() {
221    this.optionIndex = Array.from(this.selectTarget.options).map((option) => ({
222      option,
223      text: this.normalized(option.textContent),
224      value: this.normalized(option.value),
225      searchText: this.normalized(option.dataset.searchText || "")
226    }))
227  }
228
229  normalized(value) {
230    return value.toString().trim().toLowerCase()
231  }
232}

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.