lightning_ui_kit 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/Rakefile +9 -2
  4. data/app/assets/builds/lightning_ui_kit.css +588 -31
  5. data/app/assets/builds/lightning_ui_kit.js +9 -2
  6. data/app/assets/builds/lightning_ui_kit.js.map +4 -4
  7. data/app/assets/vendor/lightning_ui_kit.css +582 -88
  8. data/app/assets/vendor/lightning_ui_kit.js +9 -2
  9. data/app/components/lightning_ui_kit/button_component.html.erb +4 -0
  10. data/app/components/lightning_ui_kit/button_component.rb +24 -3
  11. data/app/components/lightning_ui_kit/combobox_component.html.erb +137 -0
  12. data/app/components/lightning_ui_kit/combobox_component.rb +205 -0
  13. data/app/components/lightning_ui_kit/dropdown_component.html.erb +1 -1
  14. data/app/components/lightning_ui_kit/dropdown_component.rb +1 -1
  15. data/app/components/lightning_ui_kit/dropzone_component.html.erb +13 -38
  16. data/app/components/lightning_ui_kit/dropzone_component.rb +43 -16
  17. data/app/components/lightning_ui_kit/file_input_component.html.erb +4 -34
  18. data/app/components/lightning_ui_kit/file_input_component.rb +54 -20
  19. data/app/components/lightning_ui_kit/input_component.html.erb +14 -98
  20. data/app/components/lightning_ui_kit/input_component.rb +154 -19
  21. data/app/components/lightning_ui_kit/layout_component.html.erb +118 -0
  22. data/app/components/lightning_ui_kit/layout_component.rb +26 -0
  23. data/app/components/lightning_ui_kit/modal_component.html.erb +2 -2
  24. data/app/components/lightning_ui_kit/select_component.html.erb +6 -27
  25. data/app/components/lightning_ui_kit/select_component.rb +65 -23
  26. data/app/components/lightning_ui_kit/sidebar_link_component.html.erb +6 -0
  27. data/app/components/lightning_ui_kit/sidebar_link_component.rb +33 -0
  28. data/app/components/lightning_ui_kit/sidebar_section_component.html.erb +8 -0
  29. data/app/components/lightning_ui_kit/sidebar_section_component.rb +18 -0
  30. data/app/components/lightning_ui_kit/textarea_component.html.erb +5 -37
  31. data/app/components/lightning_ui_kit/textarea_component.rb +50 -17
  32. data/app/components/lightning_ui_kit/tooltip_component.html.erb +15 -0
  33. data/app/components/lightning_ui_kit/tooltip_component.rb +26 -0
  34. data/app/javascript/lightning_ui_kit/controllers/combobox_controller.js +704 -0
  35. data/app/javascript/lightning_ui_kit/controllers/field_controller.js +23 -0
  36. data/app/javascript/lightning_ui_kit/controllers/layout_controller.js +19 -0
  37. data/app/javascript/lightning_ui_kit/controllers/modal_controller.js +7 -1
  38. data/app/javascript/lightning_ui_kit/controllers/tooltip_controller.js +235 -0
  39. data/app/javascript/lightning_ui_kit/index.js +8 -0
  40. data/config/deploy.yml +2 -5
  41. data/lib/lightning_ui_kit/engine.rb +1 -6
  42. data/lib/lightning_ui_kit/version.rb +1 -1
  43. metadata +17 -17
@@ -0,0 +1,704 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { computePosition, autoUpdate, offset, flip, shift, size } from "@floating-ui/dom"
3
+
4
+ export default class extends Controller {
5
+ static targets = [
6
+ "input",
7
+ "inputWrapper",
8
+ "listbox",
9
+ "optionsContainer",
10
+ "optionTemplate",
11
+ "option",
12
+ "selectedTags",
13
+ "hiddenField",
14
+ "hiddenFields",
15
+ "loading",
16
+ "noResults",
17
+ "createOption",
18
+ "createOptionText"
19
+ ]
20
+
21
+ static values = {
22
+ multiple: { type: Boolean, default: false },
23
+ allowCustom: { type: Boolean, default: false },
24
+ url: String,
25
+ minChars: { type: Number, default: 0 },
26
+ debounce: { type: Number, default: 300 },
27
+ options: { type: Array, default: [] },
28
+ selected: { type: Array, default: [] },
29
+ foreignKey: String,
30
+ nestedModel: String
31
+ }
32
+
33
+ connect() {
34
+ this.isOpen = false
35
+ this.highlightedIndex = -1
36
+ this.filteredOptions = []
37
+ this.cleanup = null
38
+ this.debounceTimer = null
39
+
40
+ // Store original selected for detecting removals in association mode
41
+ this.originalSelected = this.deepClone(this.selectedValue)
42
+
43
+ this.initializeSelected()
44
+ this.setupHoverHandlers()
45
+
46
+ if (!this.hasUrlValue) {
47
+ this.filteredOptions = this.optionsValue
48
+ }
49
+ }
50
+
51
+ setupHoverHandlers() {
52
+ // For single mode, add hover to input
53
+ if (this.hasInputTarget) {
54
+ this.inputTarget.addEventListener("mouseenter", this.handleMouseEnter)
55
+ this.inputTarget.addEventListener("mouseleave", this.handleMouseLeave)
56
+ }
57
+ // For multiple mode, add hover to inputWrapper
58
+ if (this.hasInputWrapperTarget) {
59
+ this.inputWrapperTarget.addEventListener("mouseenter", this.handleWrapperMouseEnter)
60
+ this.inputWrapperTarget.addEventListener("mouseleave", this.handleWrapperMouseLeave)
61
+ }
62
+ }
63
+
64
+ handleMouseEnter = (event) => {
65
+ event.target.dataset.hover = ""
66
+ }
67
+
68
+ handleMouseLeave = (event) => {
69
+ delete event.target.dataset.hover
70
+ }
71
+
72
+ handleWrapperMouseEnter = (event) => {
73
+ event.currentTarget.dataset.hover = ""
74
+ }
75
+
76
+ handleWrapperMouseLeave = (event) => {
77
+ delete event.currentTarget.dataset.hover
78
+ }
79
+
80
+ get isAssociationMode() {
81
+ return this.hasForeignKeyValue
82
+ }
83
+
84
+ disconnect() {
85
+ this.destroyFloating()
86
+ this.clearDebounce()
87
+ this.cleanupHoverHandlers()
88
+ }
89
+
90
+ cleanupHoverHandlers() {
91
+ if (this.hasInputTarget) {
92
+ this.inputTarget.removeEventListener("mouseenter", this.handleMouseEnter)
93
+ this.inputTarget.removeEventListener("mouseleave", this.handleMouseLeave)
94
+ }
95
+ if (this.hasInputWrapperTarget) {
96
+ this.inputWrapperTarget.removeEventListener("mouseenter", this.handleWrapperMouseEnter)
97
+ this.inputWrapperTarget.removeEventListener("mouseleave", this.handleWrapperMouseLeave)
98
+ }
99
+ }
100
+
101
+ // Event Handlers
102
+
103
+ onInput(event) {
104
+ const query = event.target.value.trim()
105
+
106
+ if (this.hasUrlValue) {
107
+ this.clearDebounce()
108
+ if (query.length >= this.minCharsValue) {
109
+ this.debounceTimer = setTimeout(() => {
110
+ this.fetchOptions(query)
111
+ }, this.debounceValue)
112
+ } else {
113
+ this.filteredOptions = []
114
+ this.renderOptions()
115
+ }
116
+ } else {
117
+ this.filterClientSide(query)
118
+ }
119
+
120
+ this.open()
121
+ this.updateCreateOption(query)
122
+ }
123
+
124
+ onFocus() {
125
+ if (!this.hasUrlValue || this.inputTarget.value.length >= this.minCharsValue) {
126
+ if (!this.hasUrlValue) {
127
+ this.filteredOptions = this.optionsValue
128
+ this.filterClientSide(this.inputTarget.value.trim())
129
+ }
130
+ this.open()
131
+ }
132
+ }
133
+
134
+ onKeydown(event) {
135
+ // Handle Ctrl+N and Ctrl+P for navigation
136
+ if (event.ctrlKey) {
137
+ if (event.key === "n") {
138
+ event.preventDefault()
139
+ if (!this.isOpen) {
140
+ this.open()
141
+ } else {
142
+ this.highlightNext()
143
+ }
144
+ return
145
+ }
146
+ if (event.key === "p") {
147
+ event.preventDefault()
148
+ if (!this.isOpen) {
149
+ this.open()
150
+ } else {
151
+ this.highlightPrevious()
152
+ }
153
+ return
154
+ }
155
+ }
156
+
157
+ switch (event.key) {
158
+ case "ArrowDown":
159
+ event.preventDefault()
160
+ if (!this.isOpen) {
161
+ this.open()
162
+ } else {
163
+ this.highlightNext()
164
+ }
165
+ break
166
+ case "ArrowUp":
167
+ event.preventDefault()
168
+ if (!this.isOpen) {
169
+ this.open()
170
+ } else {
171
+ this.highlightPrevious()
172
+ }
173
+ break
174
+ case "Enter":
175
+ event.preventDefault()
176
+ this.selectHighlighted()
177
+ break
178
+ case "Escape":
179
+ this.close()
180
+ this.inputTarget.value = ""
181
+ break
182
+ case "Backspace":
183
+ if (this.multipleValue && this.inputTarget.value === "") {
184
+ this.removeLastSelected()
185
+ }
186
+ break
187
+ case "Tab":
188
+ this.close()
189
+ break
190
+ }
191
+ }
192
+
193
+ clickOutside(event) {
194
+ if (!this.element.contains(event.target)) {
195
+ this.close()
196
+ }
197
+ }
198
+
199
+ // Selection Management
200
+
201
+ selectOption(event) {
202
+ event.stopPropagation() // Prevent clickOutside from firing
203
+
204
+ const optionEl = event.currentTarget
205
+ if (optionEl.dataset.disabled === "true") return
206
+
207
+ const value = optionEl.dataset.value
208
+ const label = optionEl.dataset.label
209
+
210
+ if (this.multipleValue) {
211
+ this.toggleSelection(value, label)
212
+ } else {
213
+ this.setSingleSelection(value, label)
214
+ this.close()
215
+ }
216
+ }
217
+
218
+ toggleSelection(value, _label, isCustom = false) {
219
+ const selectedIdx = this.findSelectedIndex(value)
220
+
221
+ if (selectedIdx > -1) {
222
+ // Remove from selection
223
+ this.selectedValue = this.selectedValue.filter((_, i) => i !== selectedIdx)
224
+ } else {
225
+ // Add to selection
226
+ if (this.isAssociationMode) {
227
+ // In association mode, store as object
228
+ const newItem = { value: value }
229
+ if (isCustom) {
230
+ newItem.custom = true
231
+ }
232
+ this.selectedValue = [...this.selectedValue, newItem]
233
+ } else {
234
+ this.selectedValue = [...this.selectedValue, value]
235
+ }
236
+ }
237
+
238
+ this.updateSelectedTags()
239
+ this.updateHiddenFields()
240
+ this.renderOptions()
241
+ this.inputTarget.value = ""
242
+ this.inputTarget.focus()
243
+ }
244
+
245
+ findSelectedIndex(value) {
246
+ return this.selectedValue.findIndex(item => {
247
+ const itemValue = typeof item === 'object' ? item.value : item
248
+ return String(itemValue) === String(value)
249
+ })
250
+ }
251
+
252
+ setSingleSelection(value, label) {
253
+ this.selectedValue = [value]
254
+ this.inputTarget.value = label
255
+ this.updateHiddenField(value)
256
+ }
257
+
258
+ removeLastSelected() {
259
+ if (this.selectedValue.length > 0) {
260
+ this.selectedValue = this.selectedValue.slice(0, -1)
261
+ this.updateSelectedTags()
262
+ this.updateHiddenFields()
263
+ this.renderOptions()
264
+ }
265
+ }
266
+
267
+ removeTag(event) {
268
+ event.preventDefault()
269
+ event.stopPropagation()
270
+ const value = event.currentTarget.dataset.value
271
+ const selectedIdx = this.findSelectedIndex(value)
272
+ if (selectedIdx > -1) {
273
+ this.selectedValue = this.selectedValue.filter((_, i) => i !== selectedIdx)
274
+ }
275
+ this.updateSelectedTags()
276
+ this.updateHiddenFields()
277
+ this.renderOptions()
278
+ this.inputTarget.focus()
279
+ }
280
+
281
+ createCustomOption() {
282
+ const value = this.inputTarget.value.trim()
283
+ if (value && this.allowCustomValue) {
284
+ if (this.multipleValue) {
285
+ this.toggleSelection(value, value, true) // isCustom = true
286
+ } else {
287
+ this.setSingleSelection(value, value)
288
+ }
289
+ this.close()
290
+ }
291
+ }
292
+
293
+ // Filtering
294
+
295
+ filterClientSide(query) {
296
+ if (!query) {
297
+ this.filteredOptions = this.optionsValue
298
+ } else {
299
+ const lowerQuery = query.toLowerCase()
300
+ this.filteredOptions = this.optionsValue.filter(opt =>
301
+ opt.label.toLowerCase().includes(lowerQuery)
302
+ )
303
+ }
304
+ this.highlightedIndex = this.filteredOptions.length > 0 ? 0 : -1
305
+ this.renderOptions()
306
+ }
307
+
308
+ async fetchOptions(query) {
309
+ this.showLoading()
310
+
311
+ try {
312
+ const url = new URL(this.urlValue, window.location.origin)
313
+ url.searchParams.set("q", query)
314
+
315
+ const response = await fetch(url, {
316
+ headers: {
317
+ "Accept": "application/json",
318
+ "X-Requested-With": "XMLHttpRequest"
319
+ }
320
+ })
321
+
322
+ if (!response.ok) throw new Error("Network error")
323
+
324
+ const data = await response.json()
325
+ this.filteredOptions = data
326
+ this.highlightedIndex = data.length > 0 ? 0 : -1
327
+ this.renderOptions()
328
+ } catch (error) {
329
+ console.error("Combobox fetch error:", error)
330
+ this.filteredOptions = []
331
+ this.renderOptions()
332
+ } finally {
333
+ this.hideLoading()
334
+ }
335
+ }
336
+
337
+ // Rendering
338
+
339
+ renderOptions() {
340
+ const container = this.optionsContainerTarget
341
+ container.innerHTML = ""
342
+
343
+ if (this.filteredOptions.length === 0) {
344
+ this.showNoResults()
345
+ return
346
+ }
347
+
348
+ this.hideNoResults()
349
+
350
+ this.filteredOptions.forEach((opt, index) => {
351
+ const template = this.optionTemplateTarget.content.cloneNode(true)
352
+ const optionEl = template.querySelector("[role='option']")
353
+
354
+ optionEl.dataset.value = opt.value
355
+ optionEl.dataset.label = opt.label
356
+ optionEl.dataset.index = index
357
+ optionEl.querySelector("[data-label]").textContent = opt.label
358
+
359
+ const isSelected = this.isValueSelected(opt.value)
360
+ if (isSelected) {
361
+ optionEl.dataset.selected = "true"
362
+ optionEl.setAttribute("aria-selected", "true")
363
+ optionEl.querySelector("[data-checkmark]").classList.remove("lui:hidden")
364
+ }
365
+
366
+ if (index === this.highlightedIndex) {
367
+ optionEl.dataset.highlighted = "true"
368
+ }
369
+
370
+ if (opt.disabled) {
371
+ optionEl.dataset.disabled = "true"
372
+ optionEl.setAttribute("aria-disabled", "true")
373
+ }
374
+
375
+ container.appendChild(template)
376
+ })
377
+ }
378
+
379
+ updateSelectedTags() {
380
+ if (!this.multipleValue || !this.hasSelectedTagsTarget) return
381
+
382
+ // Remove existing tags (but keep input and chevron)
383
+ const existingTags = this.selectedTagsTarget.querySelectorAll("[data-combobox-tag]")
384
+ existingTags.forEach(tag => tag.remove())
385
+
386
+ // Insert new tags before the input
387
+ this.selectedValue.forEach(item => {
388
+ const value = this.getItemValue(item)
389
+ const opt = this.findOptionByValue(value)
390
+ const label = opt ? opt.label : value
391
+
392
+ const tag = document.createElement("span")
393
+ tag.setAttribute("data-combobox-tag", "true")
394
+ tag.className = "lui:inline-flex lui:items-center lui:gap-1 lui:rounded-md lui:bg-zinc-100 lui:px-2 lui:py-0.5 lui:text-sm lui:text-zinc-700"
395
+ tag.innerHTML = `
396
+ <span class="lui:truncate lui:max-w-[150px]">${this.escapeHtml(label)}</span>
397
+ <button type="button" data-value="${this.escapeHtml(String(value))}" data-action="click->lui-combobox#removeTag" class="lui:text-zinc-500 lui:hover:text-zinc-700 lui:flex-shrink-0 lui:ml-0.5">
398
+ <svg class="lui:h-3.5 lui:w-3.5" viewBox="0 0 16 16" fill="currentColor">
399
+ <path d="M5.28 4.22a.75.75 0 00-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 101.06 1.06L8 9.06l2.72 2.72a.75.75 0 101.06-1.06L9.06 8l2.72-2.72a.75.75 0 00-1.06-1.06L8 6.94 5.28 4.22z"/>
400
+ </svg>
401
+ </button>
402
+ `
403
+ this.inputTarget.insertAdjacentElement("beforebegin", tag)
404
+ })
405
+ }
406
+
407
+ // Hidden Field Management
408
+
409
+ updateHiddenField(value) {
410
+ if (this.hasHiddenFieldTarget) {
411
+ this.hiddenFieldTarget.value = value
412
+ }
413
+ }
414
+
415
+ updateHiddenFields() {
416
+ if (!this.hasHiddenFieldsTarget) return
417
+
418
+ if (this.isAssociationMode) {
419
+ this.updateNestedHiddenFields()
420
+ } else {
421
+ this.updateSimpleHiddenFields()
422
+ }
423
+ }
424
+
425
+ updateSimpleHiddenFields() {
426
+ const container = this.hiddenFieldsTarget
427
+ const name = container.dataset.name
428
+
429
+ container.innerHTML = ""
430
+
431
+ this.selectedValue.forEach(item => {
432
+ const value = this.getItemValue(item)
433
+ const input = document.createElement("input")
434
+ input.type = "hidden"
435
+ input.name = name
436
+ input.value = value
437
+ container.appendChild(input)
438
+ })
439
+ }
440
+
441
+ updateNestedHiddenFields() {
442
+ const container = this.hiddenFieldsTarget
443
+ const baseName = container.dataset.name
444
+ const fk = this.foreignKeyValue
445
+ const nestedModel = this.hasNestedModelValue ? this.nestedModelValue : null
446
+
447
+ container.innerHTML = ""
448
+ let idx = 0
449
+
450
+ // Current selections (preserved existing + new)
451
+ this.selectedValue.forEach(item => {
452
+ const isObject = typeof item === 'object'
453
+ const joinId = isObject ? item.join_id : null
454
+ const value = isObject ? item.value : item
455
+ const isCustom = isObject ? item.custom : !this.isExistingOption(value)
456
+
457
+ if (joinId) {
458
+ // Existing record - include join table id
459
+ this.addHiddenInput(container, `${baseName}[${idx}][id]`, joinId)
460
+ }
461
+
462
+ if (isCustom && nestedModel) {
463
+ // New record via nested attributes (e.g., tag_attributes[name])
464
+ this.addHiddenInput(container, `${baseName}[${idx}][${nestedModel}_attributes][name]`, value)
465
+ } else {
466
+ // Existing record by foreign key (e.g., tag_id)
467
+ this.addHiddenInput(container, `${baseName}[${idx}][${fk}]`, value)
468
+ }
469
+ idx++
470
+ })
471
+
472
+ // Removed records - mark for destruction
473
+ this.originalSelected.forEach(orig => {
474
+ const origValue = typeof orig === 'object' ? orig.value : orig
475
+ const origJoinId = typeof orig === 'object' ? orig.join_id : null
476
+
477
+ const stillSelected = this.selectedValue.some(sel => {
478
+ const selValue = typeof sel === 'object' ? sel.value : sel
479
+ return String(selValue) === String(origValue)
480
+ })
481
+
482
+ if (!stillSelected && origJoinId) {
483
+ this.addHiddenInput(container, `${baseName}[${idx}][id]`, origJoinId)
484
+ this.addHiddenInput(container, `${baseName}[${idx}][_destroy]`, 'true')
485
+ idx++
486
+ }
487
+ })
488
+ }
489
+
490
+ addHiddenInput(container, name, value) {
491
+ const input = document.createElement("input")
492
+ input.type = "hidden"
493
+ input.name = name
494
+ input.value = value
495
+ container.appendChild(input)
496
+ }
497
+
498
+ // Dropdown Positioning (Floating UI)
499
+
500
+ open() {
501
+ if (this.isOpen) return
502
+
503
+ this.isOpen = true
504
+ this.listboxTarget.classList.remove("lui:hidden")
505
+ this.inputTarget.setAttribute("aria-expanded", "true")
506
+
507
+ // Use inputWrapper for positioning in multiple mode, otherwise use input
508
+ const referenceEl = this.hasInputWrapperTarget ? this.inputWrapperTarget : this.inputTarget
509
+
510
+ this.cleanup = autoUpdate(
511
+ referenceEl,
512
+ this.listboxTarget,
513
+ () => this.updatePosition()
514
+ )
515
+ }
516
+
517
+ close() {
518
+ if (!this.isOpen) return
519
+
520
+ this.isOpen = false
521
+ this.listboxTarget.classList.add("lui:hidden")
522
+ this.inputTarget.setAttribute("aria-expanded", "false")
523
+ this.highlightedIndex = -1
524
+ this.destroyFloating()
525
+ }
526
+
527
+ updatePosition() {
528
+ const referenceEl = this.hasInputWrapperTarget ? this.inputWrapperTarget : this.inputTarget
529
+
530
+ computePosition(referenceEl, this.listboxTarget, {
531
+ placement: "bottom-start",
532
+ middleware: [
533
+ offset(4),
534
+ flip({ padding: 8 }),
535
+ shift({ padding: 8 }),
536
+ size({
537
+ apply({ availableHeight, rects, elements }) {
538
+ elements.floating.style.maxHeight = `${Math.min(240, availableHeight)}px`
539
+ elements.floating.style.width = `${rects.reference.width}px`
540
+ }
541
+ })
542
+ ]
543
+ }).then(({ x, y }) => {
544
+ Object.assign(this.listboxTarget.style, {
545
+ position: "absolute",
546
+ left: `${x}px`,
547
+ top: `${y}px`
548
+ })
549
+ })
550
+ }
551
+
552
+ destroyFloating() {
553
+ if (this.cleanup) {
554
+ this.cleanup()
555
+ this.cleanup = null
556
+ }
557
+ }
558
+
559
+ // Keyboard Navigation
560
+
561
+ highlightNext() {
562
+ if (this.filteredOptions.length === 0) return
563
+ this.highlightedIndex = (this.highlightedIndex + 1) % this.filteredOptions.length
564
+ this.updateHighlight()
565
+ }
566
+
567
+ highlightPrevious() {
568
+ if (this.filteredOptions.length === 0) return
569
+ this.highlightedIndex = this.highlightedIndex <= 0
570
+ ? this.filteredOptions.length - 1
571
+ : this.highlightedIndex - 1
572
+ this.updateHighlight()
573
+ }
574
+
575
+ updateHighlight() {
576
+ this.optionTargets.forEach((el, index) => {
577
+ if (index === this.highlightedIndex) {
578
+ el.dataset.highlighted = "true"
579
+ el.scrollIntoView({ block: "nearest" })
580
+ } else {
581
+ delete el.dataset.highlighted
582
+ }
583
+ })
584
+ }
585
+
586
+ selectHighlighted() {
587
+ if (this.highlightedIndex >= 0 && this.highlightedIndex < this.filteredOptions.length) {
588
+ const opt = this.filteredOptions[this.highlightedIndex]
589
+ if (opt.disabled) return
590
+
591
+ if (this.multipleValue) {
592
+ this.toggleSelection(String(opt.value), opt.label)
593
+ } else {
594
+ this.setSingleSelection(String(opt.value), opt.label)
595
+ this.close()
596
+ }
597
+ } else if (this.allowCustomValue && this.inputTarget.value.trim()) {
598
+ this.createCustomOption()
599
+ }
600
+ }
601
+
602
+ highlightOption(event) {
603
+ const index = parseInt(event.currentTarget.dataset.index, 10)
604
+ if (!isNaN(index)) {
605
+ this.highlightedIndex = index
606
+ this.updateHighlight()
607
+ }
608
+ }
609
+
610
+ // Utility Methods
611
+
612
+ findOptionByValue(value) {
613
+ return this.optionsValue.find(opt => String(opt.value) === String(value)) ||
614
+ this.filteredOptions.find(opt => String(opt.value) === String(value))
615
+ }
616
+
617
+ initializeSelected() {
618
+ if (this.multipleValue) {
619
+ this.updateSelectedTags()
620
+ this.updateHiddenFields()
621
+ } else if (this.selectedValue.length > 0) {
622
+ const opt = this.findOptionByValue(this.selectedValue[0])
623
+ if (opt) {
624
+ this.inputTarget.value = opt.label
625
+ } else {
626
+ this.inputTarget.value = this.selectedValue[0]
627
+ }
628
+ }
629
+ }
630
+
631
+ updateCreateOption(query) {
632
+ if (!this.allowCustomValue || !this.hasCreateOptionTarget) return
633
+
634
+ const valueExists = this.filteredOptions.some(opt =>
635
+ String(opt.value).toLowerCase() === query.toLowerCase() ||
636
+ opt.label.toLowerCase() === query.toLowerCase()
637
+ )
638
+
639
+ if (query && !valueExists) {
640
+ this.createOptionTarget.classList.remove("lui:hidden")
641
+ this.createOptionTextTarget.textContent = query
642
+ } else {
643
+ this.createOptionTarget.classList.add("lui:hidden")
644
+ }
645
+ }
646
+
647
+ showLoading() {
648
+ if (this.hasLoadingTarget) {
649
+ this.loadingTarget.classList.remove("lui:hidden")
650
+ this.hideNoResults()
651
+ }
652
+ }
653
+
654
+ hideLoading() {
655
+ if (this.hasLoadingTarget) {
656
+ this.loadingTarget.classList.add("lui:hidden")
657
+ }
658
+ }
659
+
660
+ showNoResults() {
661
+ if (this.hasNoResultsTarget) {
662
+ this.noResultsTarget.classList.remove("lui:hidden")
663
+ }
664
+ }
665
+
666
+ hideNoResults() {
667
+ if (this.hasNoResultsTarget) {
668
+ this.noResultsTarget.classList.add("lui:hidden")
669
+ }
670
+ }
671
+
672
+ clearDebounce() {
673
+ if (this.debounceTimer) {
674
+ clearTimeout(this.debounceTimer)
675
+ this.debounceTimer = null
676
+ }
677
+ }
678
+
679
+ escapeHtml(text) {
680
+ const div = document.createElement("div")
681
+ div.textContent = text
682
+ return div.innerHTML
683
+ }
684
+
685
+ // Helper to get the value from an item (handles both simple values and objects)
686
+ getItemValue(item) {
687
+ return typeof item === 'object' ? item.value : item
688
+ }
689
+
690
+ // Check if a value is currently selected
691
+ isValueSelected(value) {
692
+ return this.findSelectedIndex(value) > -1
693
+ }
694
+
695
+ // Check if a value exists in the options (not a custom value)
696
+ isExistingOption(value) {
697
+ return this.optionsValue.some(opt => String(opt.value) === String(value))
698
+ }
699
+
700
+ // Deep clone for storing original selected state
701
+ deepClone(obj) {
702
+ return JSON.parse(JSON.stringify(obj))
703
+ }
704
+ }