1// app/javascript/controllers/shared/image_select_controller.js 2 3import { Controller } from "@hotwired/stimulus" 4 5export default class extends Controller { 6 static targets = ["trigger", "dropdown", "input", "selectedImage", "displayText", "searchInput", "options", "createButton"] 7 static values = { 8 imageSize: { type: Number, default: 32 }, 9 searchable: { type: Boolean, default: false }, 10 creatableText: String 11 } 12 13 connect() { 14 // Initialize display if a value is pre-selected 15 this.initializeDisplay() 16 17 // Close dropdown on escape key 18 this.handleKeydown = this.handleKeydown.bind(this) 19 document.addEventListener('keydown', this.handleKeydown) 20 21 // Close dropdown when clicking outside 22 this.handleClickOutside = this.handleClickOutside.bind(this) 23 document.addEventListener('click', this.handleClickOutside) 24 } 25 26 disconnect() { 27 document.removeEventListener('keydown', this.handleKeydown) 28 document.removeEventListener('click', this.handleClickOutside) 29 } 30 31 initializeDisplay() { 32 const selectedId = this.inputTarget.value 33 if (selectedId) { 34 const selectedOption = this.dropdownTarget.querySelector(`[data-option-id='${selectedId}']`) 35 if (selectedOption) { 36 const optionLabel = selectedOption.dataset.optionLabel 37 const optionImage = selectedOption.dataset.optionImage 38 39 this.displayTextTarget.textContent = optionLabel 40 this.displayTextTarget.classList.remove('text-gray-500') 41 this.displayTextTarget.classList.add('text-gray-900') 42 43 this.updateImage(optionImage, optionLabel) 44 } 45 } 46 } 47 48 handleKeydown(e) { 49 if (e.key === 'Escape' && !this.dropdownTarget.classList.contains('hidden')) { 50 this.close() 51 } 52 } 53 54 handleClickOutside(e) { 55 if (!this.element.contains(e.target) && !this.dropdownTarget.classList.contains('hidden')) { 56 this.close() 57 } 58 } 59 60 /** 61 * Filter options based on search input 62 */ 63 filterOptions(event) { 64 const query = event.target.value.toLowerCase() 65 const options = this.optionsTargets 66 67 let visibleCount = 0 68 options.forEach(option => { 69 const label = option.dataset.optionLabel.toLowerCase() 70 const matches = label.includes(query) 71 option.classList.toggle('hidden', !matches) 72 if (matches) visibleCount++ 73 }) 74 75 // Show/hide create button based on results 76 if (this.hasCreateButtonTarget && this.creatableTextValue) { 77 const hasResults = visibleCount > 0 78 this.createButtonTarget.classList.toggle('hidden', hasResults) 79 80 // Update create button text with search query 81 if (!hasResults && query.length > 0) { 82 const createLabel = this.createButtonTarget.querySelector('[data-create-label]') 83 if (createLabel) { 84 createLabel.textContent = `Create "${query}"` 85 } 86 } 87 } 88 } 89 90 /** 91 * Handle create button click 92 */ 93 createOption(event) { 94 event.preventDefault() 95 event.stopPropagation() 96 97 const searchInput = this.hasSearchInputTarget ? this.searchInputTarget : null 98 if (!searchInput) return 99 100 const text = searchInput.value.trim() 101 if (!text) return 102 103 // Store the text to create in a data attribute 104 this.element.dataset.pendingClubName = text 105 106 // Update display to show the pending name 107 this.displayTextTarget.textContent = text 108 this.displayTextTarget.classList.remove('text-gray-500') 109 this.displayTextTarget.classList.add('text-gray-900') 110 111 // Clear input and close dropdown 112 searchInput.value = '' 113 this.close() 114 } 115 116 /** 117 * Toggle dropdown visibility 118 */ 119 toggle(event) { 120 event.preventDefault() 121 event.stopPropagation() 122 this.dropdownTarget.classList.contains('hidden') ? this.open() : this.close() 123 } 124 125 /** 126 * Open dropdown 127 */ 128 open() { 129 this.dropdownTarget.classList.remove('hidden') 130 // Focus search input if it exists 131 if (this.hasSearchInputTarget) { 132 setTimeout(() => this.searchInputTarget.focus(), 0) 133 } 134 } 135 136 /** 137 * Close dropdown 138 */ 139 close() { 140 this.dropdownTarget.classList.add('hidden') 141 } 142 143 /** 144 * Select an option from the dropdown 145 */ 146 select(event) { 147 event.preventDefault() 148 event.stopPropagation() 149 150 const button = event.currentTarget 151 const optionId = button.dataset.optionId 152 const optionLabel = button.dataset.optionLabel 153 const optionImage = button.dataset.optionImage 154 155 // Update hidden input value 156 this.inputTarget.value = optionId 157 this.inputTarget.dispatchEvent(new Event('change', { bubbles: true })) 158 159 // Update display text 160 this.displayTextTarget.textContent = optionLabel 161 if (optionId === "") { 162 this.displayTextTarget.classList.add('text-gray-500') 163 this.displayTextTarget.classList.remove('text-gray-900') 164 } else { 165 this.displayTextTarget.classList.remove('text-gray-500') 166 this.displayTextTarget.classList.add('text-gray-900') 167 } 168 169 // Update selected image 170 this.updateImage(optionImage, optionLabel) 171 172 // Close dropdown 173 this.close() 174 } 175 176 /** 177 * Update the selected image 178 */ 179 updateImage(imageUrl, altText) { 180 const container = this.selectedImageTarget 181 container.innerHTML = '' 182 183 if (imageUrl) { 184 const img = document.createElement('img') 185 img.src = imageUrl 186 img.alt = altText 187 img.className = 'object-contain rounded' 188 img.style.width = `${this.imageSizeValue}px` 189 img.style.height = `${this.imageSizeValue}px` 190 container.appendChild(img) 191 } 192 } 193}
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.