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.