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.