1import { Controller } from "@hotwired/stimulus" 2 3// Icon picker modal for selecting FontAwesome icons 4// Shows a searchable grid of icons with autocomplete filtering 5// Works with honey-ui modal helper (HTML5 dialog) 6export default class extends Controller { 7 static targets = ["search", "grid", "selectedIcon", "selectedName"] 8 static values = { 9 iconsUrl: { type: String, default: "/admin/icons" } 10 } 11 12 connect() { 13 this.icons = [] 14 this.filteredIcons = [] 15 this.selectedIcon = null 16 this.callback = null 17 this.loadIcons() 18 } 19 20 async loadIcons() { 21 try { 22 const response = await fetch(this.iconsUrlValue) 23 const data = await response.json() 24 this.icons = data.icons || [] 25 this.filteredIcons = this.icons.slice(0, 100) // Show first 100 by default 26 } catch (error) { 27 console.error("Failed to load icons:", error) 28 this.icons = [] 29 } 30 } 31 32 // Called externally to open the picker 33 open(currentValue, callback) { 34 this.callback = callback 35 this.selectedIcon = currentValue || null 36 37 // Reset search 38 if (this.hasSearchTarget) { 39 this.searchTarget.value = currentValue || "" 40 } 41 42 // Update selected preview 43 this.updateSelectedPreview() 44 45 // Filter icons based on current value or show defaults 46 if (currentValue) { 47 this.filterIcons(currentValue) 48 } else { 49 this.filteredIcons = this.icons.slice(0, 100) 50 } 51 52 this.renderGrid() 53 54 // Show the dialog (the controller is attached to the dialog element) 55 this.element.showModal() 56 57 // Focus search input 58 requestAnimationFrame(() => { 59 if (this.hasSearchTarget) { 60 this.searchTarget.focus() 61 } 62 }) 63 } 64 65 close() { 66 this.element.close() 67 this.callback = null 68 } 69 70 search(event) { 71 const query = event.target.value.toLowerCase().trim() 72 this.filterIcons(query) 73 this.renderGrid() 74 } 75 76 filterIcons(query) { 77 if (!query) { 78 this.filteredIcons = this.icons.slice(0, 100) 79 return 80 } 81 82 // Score icons by match quality 83 const scored = this.icons.map(icon => { 84 const name = icon.toLowerCase() 85 let score = 0 86 87 if (name === query) score = 100 88 else if (name.startsWith(query)) score = 80 89 else if (name.includes(query)) score = 50 90 else { 91 // Match individual words 92 const queryWords = query.split(/[-_\s]+/) 93 const nameWords = name.split("-") 94 const matchedWords = queryWords.filter(qw => 95 nameWords.some(nw => nw.startsWith(qw)) 96 ) 97 score = matchedWords.length * 20 98 } 99 100 return { icon, score } 101 }) 102 103 this.filteredIcons = scored 104 .filter(s => s.score > 0) 105 .sort((a, b) => b.score - a.score) 106 .slice(0, 100) 107 .map(s => s.icon) 108 } 109 110 renderGrid() { 111 if (!this.hasGridTarget) return 112 113 this.gridTarget.innerHTML = "" 114 115 if (this.filteredIcons.length === 0) { 116 const noResults = document.createElement("div") 117 noResults.className = "col-span-full text-center text-slate-300 py-8" 118 noResults.textContent = "No icons found. Try a different search term." 119 this.gridTarget.appendChild(noResults) 120 return 121 } 122 123 this.filteredIcons.forEach(iconName => { 124 const button = document.createElement("button") 125 button.type = "button" 126 button.className = this.getIconButtonClasses(iconName === this.selectedIcon) 127 button.dataset.action = "click->honey-block-builder--icon-picker#selectIcon" 128 button.dataset.icon = iconName 129 button.title = iconName 130 131 // Icon display 132 const iconWrapper = document.createElement("div") 133 iconWrapper.className = "h-8 flex items-center justify-center" 134 const icon = document.createElement("i") 135 icon.className = `fa-regular fa-${iconName} text-xl` 136 iconWrapper.appendChild(icon) 137 138 // Icon name 139 const label = document.createElement("span") 140 label.className = "text-xs text-slate-300 truncate w-full px-1" 141 label.textContent = this.truncateIconName(iconName) 142 143 button.appendChild(iconWrapper) 144 button.appendChild(label) 145 this.gridTarget.appendChild(button) 146 }) 147 } 148
149 getIconButtonClasses(isSelected) { 150 const base = "flex flex-col items-center justify-center p-2 rounded-xs border transition-all hover:border-blue-200 hover:bg-white" 151 if (isSelected) { 152 return `${base} border-blue-200 bg-white text-blue-300` 153 } 154 return `${base} border-warm-300 bg-warm-200 text-slate-400` 155 } 156 157 truncateIconName(name) { 158 if (name.length <= 10) return name 159 return name.substring(0, 9) + "..." 160 } 161 162 selectIcon(event) { 163 const iconName = event.currentTarget.dataset.icon 164 this.selectedIcon = iconName 165 this.updateSelectedPreview() 166 this.renderGrid() 167 } 168 169 updateSelectedPreview() { 170 if (this.hasSelectedIconTarget) { 171 // Update innerHTML of the container (FA JS converts <i> to SVG) 172 if (this.selectedIcon) { 173 this.selectedIconTarget.innerHTML = `<i class="fa-regular fa-${this.selectedIcon} text-xl text-blue-300"></i>` 174 } else { 175 this.selectedIconTarget.innerHTML = `<i class="fa-regular fa-icons text-xl text-slate-300"></i>` 176 } 177 } 178 if (this.hasSelectedNameTarget) { 179 this.selectedNameTarget.textContent = this.selectedIcon || "No icon selected" 180 } 181 } 182 183 confirm() { 184 if (this.selectedIcon && this.callback) { 185 this.callback(this.selectedIcon) 186 } 187 this.close() 188 } 189 190 // Handle keyboard events 191 handleKeydown(event) { 192 if (event.key === "Escape") { 193 this.close() 194 } else if (event.key === "Enter" && this.selectedIcon) { 195 event.preventDefault() 196 this.confirm() 197 } 198 } 199}
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.