PageSourceSearch

https://cdn2.futurefund.com/assets/honey/block_builder/controllers/icon_picker_controller-8bdb0052.js

js futurefund.com collected 2026-09-24 11:52:53 UTC 5,678 bytes, 199 lines download raw bytes

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.