rails_notion_like_multiselect 0.1.1

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.
@@ -0,0 +1,740 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Rails Notion-like Multiselect Controller
4
+ // Provides a beautiful multiselect with search, creation, and keyboard navigation
5
+ export default class extends Controller {
6
+ static targets = ["input", "selectedItems", "hiddenInputs", "dropdown", "optionsList", "createOption"]
7
+
8
+ static values = {
9
+ allowCreate: Boolean, // Whether to allow creating new items
10
+ itemType: String, // Type of items (e.g., 'category', 'tag')
11
+ inputName: String, // Name for hidden inputs (e.g., 'game[category_ids][]')
12
+ placeholder: String, // Placeholder text for input
13
+ createPrompt: String, // Prompt for creating new items (e.g., 'Create tag')
14
+ badgeClass: String, // CSS classes for badges
15
+ apiEndpoint: String, // Optional API endpoint for fetching items
16
+ theme: String // Theme mode: 'light', 'dark', or 'auto'
17
+ }
18
+
19
+ connect() {
20
+ this.selectedItems = new Map()
21
+ this.allOptions = new Map()
22
+ this.isOpen = false
23
+ this.highlightedIndex = -1
24
+
25
+ // Set default values
26
+ this.itemTypeValue = this.itemTypeValue || 'item'
27
+ this.placeholderValue = this.placeholderValue || 'Search or select...'
28
+ this.createPromptValue = this.createPromptValue || `Create "${this.itemTypeValue}"`
29
+ this.themeValue = this.themeValue || 'auto'
30
+ this.inputNameValue = this.inputNameValue || 'item_ids[]'
31
+
32
+ // Set default badge classes based on theme
33
+ if (!this.badgeClassValue) {
34
+ if (this.themeValue === 'dark') {
35
+ this.badgeClassValue = 'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium gap-1 ' +
36
+ 'bg-blue-900/30 text-blue-200 border border-blue-800'
37
+ } else if (this.themeValue === 'light') {
38
+ this.badgeClassValue = 'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium gap-1 ' +
39
+ 'bg-blue-100 text-blue-800 border border-blue-200'
40
+ } else {
41
+ this.badgeClassValue = 'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium gap-1 ' +
42
+ 'bg-blue-100 text-blue-800 border border-blue-200 ' +
43
+ 'dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-800'
44
+ }
45
+ }
46
+
47
+ // Initialize existing selections
48
+ this.initializeExistingSelections()
49
+
50
+ // Store all available options
51
+ this.storeAllOptions()
52
+
53
+ // Set up event listeners
54
+ this.setupEventListeners()
55
+
56
+ // Close dropdown when clicking outside
57
+ this.handleClickOutside = this.closeOnClickOutside.bind(this)
58
+ document.addEventListener('click', this.handleClickOutside)
59
+ }
60
+
61
+ disconnect() {
62
+ document.removeEventListener('click', this.handleClickOutside)
63
+ }
64
+
65
+ initializeExistingSelections() {
66
+ // Get existing hidden inputs to restore selections
67
+ const existingInputs = this.hiddenInputsTarget.querySelectorAll('input[type="hidden"]')
68
+ const existingBadges = this.selectedItemsTarget.querySelectorAll('[data-item-id]')
69
+
70
+ existingInputs.forEach((input, index) => {
71
+ const itemId = String(input.value) // Ensure ID is a string
72
+ if (itemId) {
73
+ const badge = existingBadges[index]
74
+ if (badge) {
75
+ const nameText = badge.querySelector('[data-item-name]')?.textContent?.trim() ||
76
+ badge.childNodes[0]?.textContent?.trim() || ''
77
+ if (nameText) {
78
+ this.selectedItems.set(itemId, nameText)
79
+ }
80
+ }
81
+ }
82
+ })
83
+
84
+ this.updateSelectedState()
85
+ }
86
+
87
+ storeAllOptions() {
88
+ // Store all options from the dropdown
89
+ if (!this.hasOptionsListTarget) {
90
+ return
91
+ }
92
+
93
+ const options = this.optionsListTarget.querySelectorAll('[data-option-id]')
94
+ options.forEach(option => {
95
+ const id = String(option.dataset.optionId) // Ensure ID is a string
96
+ const name = option.dataset.optionName ||
97
+ option.querySelector('span.flex-1')?.textContent?.trim() ||
98
+ option.textContent.trim()
99
+ if (id && name) {
100
+ this.allOptions.set(id, name)
101
+ }
102
+ })
103
+ }
104
+
105
+ setupEventListeners() {
106
+ // Input field events
107
+ if (this.hasInputTarget) {
108
+ this.inputTarget.addEventListener('focus', () => this.openDropdown())
109
+ this.inputTarget.addEventListener('input', () => this.handleSearch())
110
+ this.inputTarget.addEventListener('keydown', (e) => this.handleKeydown(e))
111
+ }
112
+
113
+ // Option click and hover events
114
+ if (this.hasOptionsListTarget) {
115
+ const options = this.optionsListTarget.querySelectorAll('[data-option-id]')
116
+ options.forEach((option, index) => {
117
+ option.addEventListener('click', (e) => {
118
+ e.preventDefault()
119
+ e.stopPropagation()
120
+ const name = option.dataset.optionName ||
121
+ option.querySelector('span.flex-1')?.textContent?.trim() ||
122
+ option.textContent.trim()
123
+ this.toggleOption(String(option.dataset.optionId), name) // Ensure ID is a string
124
+ })
125
+
126
+ // Update highlight on mouse enter
127
+ option.addEventListener('mouseenter', () => {
128
+ const visibleOptions = this.getVisibleOptions()
129
+ const optionIndex = visibleOptions.indexOf(option)
130
+ if (optionIndex !== -1) {
131
+ this.clearHighlight()
132
+ this.highlightedIndex = optionIndex
133
+ this.applyHighlight(option)
134
+ }
135
+ })
136
+ })
137
+ }
138
+ }
139
+
140
+ openDropdown() {
141
+ if (!this.isOpen && this.hasDropdownTarget) {
142
+ // Use style.display instead of classes for better reliability
143
+ this.dropdownTarget.style.display = 'block'
144
+ this.isOpen = true
145
+ this.highlightedIndex = -1 // Reset highlight
146
+ this.handleSearch() // Filter based on current input
147
+ }
148
+ }
149
+
150
+ closeDropdown() {
151
+ if (this.isOpen && this.hasDropdownTarget) {
152
+ // Use style.display instead of classes for better reliability
153
+ this.dropdownTarget.style.display = 'none'
154
+ this.isOpen = false
155
+ this.highlightedIndex = -1 // Reset highlight
156
+ this.clearHighlight()
157
+ if (this.hasInputTarget) {
158
+ this.inputTarget.value = ''
159
+ }
160
+ this.resetSearch()
161
+ }
162
+ }
163
+
164
+ closeOnClickOutside(event) {
165
+ if (!this.element.contains(event.target)) {
166
+ this.closeDropdown()
167
+ }
168
+ }
169
+
170
+ handleSearch() {
171
+ if (!this.hasInputTarget || !this.hasOptionsListTarget) {
172
+ return
173
+ }
174
+
175
+ const query = this.inputTarget.value.toLowerCase().trim()
176
+ const options = this.optionsListTarget.querySelectorAll('[data-option-id]')
177
+ let hasVisibleOptions = false
178
+
179
+ // Reset highlight when search changes
180
+ this.highlightedIndex = -1
181
+ this.clearHighlight()
182
+
183
+ // Filter existing options
184
+ options.forEach(option => {
185
+ const text = (option.dataset.optionName || option.textContent).toLowerCase()
186
+ const isVisible = text.includes(query)
187
+ option.style.display = isVisible ? '' : 'none'
188
+ if (isVisible) hasVisibleOptions = true
189
+ })
190
+
191
+ // Handle create option
192
+ if (this.allowCreateValue && query.length > 0) {
193
+ // Check if exact match exists
194
+ const exactMatch = Array.from(this.allOptions.values()).some(
195
+ name => name.toLowerCase() === query
196
+ )
197
+
198
+ if (!exactMatch) {
199
+ this.showCreateOption(query)
200
+ } else {
201
+ this.hideCreateOption()
202
+ }
203
+ } else {
204
+ this.hideCreateOption()
205
+ }
206
+ }
207
+
208
+ showCreateOption(query) {
209
+ if (!this.hasCreateOptionTarget) {
210
+ // Create the create option element if it doesn't exist
211
+ const createDiv = document.createElement('div')
212
+ createDiv.dataset.railsNotionMultiselectTarget = 'createOption'
213
+ createDiv.dataset.isCreateOption = 'true'
214
+ createDiv.dataset.createQuery = query // Store the query
215
+ // Apply theme-specific classes for create option
216
+ if (this.themeValue === 'dark') {
217
+ createDiv.className = 'px-3 py-2 text-sm cursor-pointer border-t text-gray-300 hover:bg-gray-700 border-gray-700'
218
+ } else if (this.themeValue === 'light') {
219
+ createDiv.className = 'px-3 py-2 text-sm cursor-pointer border-t text-gray-700 hover:bg-gray-100 border-gray-200'
220
+ } else {
221
+ createDiv.className = 'px-3 py-2 text-sm cursor-pointer border-t ' +
222
+ 'text-gray-700 dark:text-gray-300 ' +
223
+ 'hover:bg-gray-100 dark:hover:bg-gray-700 ' +
224
+ 'border-gray-200 dark:border-gray-700'
225
+ }
226
+ createDiv.innerHTML = `
227
+ <div class="flex items-center">
228
+ <svg class="w-4 h-4 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
229
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
230
+ </svg>
231
+ <span>Create "<span class="font-medium text-white">${this.escapeHtml(query)}</span>"</span>
232
+ </div>
233
+ `
234
+ this.optionsListTarget.appendChild(createDiv)
235
+
236
+ createDiv.addEventListener('click', (e) => {
237
+ e.preventDefault()
238
+ e.stopPropagation()
239
+ // Use the stored query from dataset
240
+ const storedQuery = createDiv.dataset.createQuery
241
+ if (storedQuery) {
242
+ this.createNewItem(storedQuery)
243
+ }
244
+ })
245
+
246
+ // Update highlight on mouse enter for create option
247
+ createDiv.addEventListener('mouseenter', () => {
248
+ const visibleOptions = this.getVisibleOptions()
249
+ const optionIndex = visibleOptions.indexOf(createDiv)
250
+ if (optionIndex !== -1) {
251
+ this.clearHighlight()
252
+ this.highlightedIndex = optionIndex
253
+ this.applyHighlight(createDiv)
254
+ }
255
+ })
256
+ } else {
257
+ // Update existing create option
258
+ this.createOptionTarget.style.display = ''
259
+ this.createOptionTarget.dataset.createQuery = query // Update stored query
260
+ const querySpan = this.createOptionTarget.querySelector('.font-medium')
261
+ if (querySpan) {
262
+ querySpan.textContent = query
263
+ }
264
+ }
265
+ }
266
+
267
+ hideCreateOption() {
268
+ if (this.hasCreateOptionTarget) {
269
+ this.createOptionTarget.style.display = 'none'
270
+ }
271
+ }
272
+
273
+ addOptionToDropdown(itemId, itemName, isSelected = false) {
274
+ // Check if option already exists
275
+ const existingOption = this.optionsListTarget.querySelector(`[data-option-id="${itemId}"]`)
276
+ if (existingOption) {
277
+ return // Option already exists
278
+ }
279
+
280
+ // Create new option element
281
+ const optionDiv = document.createElement('div')
282
+ optionDiv.dataset.optionId = itemId
283
+ optionDiv.dataset.optionName = itemName
284
+
285
+ // Determine classes based on selection state and theme
286
+ let optionClasses = 'px-3 py-2 text-sm cursor-pointer flex items-center '
287
+ if (isSelected) {
288
+ if (this.themeValue === 'dark') {
289
+ optionClasses += 'bg-blue-600 text-white hover:bg-blue-700'
290
+ } else if (this.themeValue === 'light') {
291
+ optionClasses += 'bg-blue-500 text-white hover:bg-blue-600'
292
+ } else {
293
+ optionClasses += 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
294
+ }
295
+ } else {
296
+ if (this.themeValue === 'dark') {
297
+ optionClasses += 'text-gray-300 hover:bg-gray-700'
298
+ } else if (this.themeValue === 'light') {
299
+ optionClasses += 'text-gray-700 hover:bg-gray-100'
300
+ } else {
301
+ optionClasses += 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
302
+ }
303
+ }
304
+
305
+ optionDiv.className = optionClasses
306
+ optionDiv.innerHTML = `<span class="flex-1">${this.escapeHtml(itemName)}</span>`
307
+
308
+ // Add click handler
309
+ optionDiv.addEventListener('click', (e) => {
310
+ e.preventDefault()
311
+ e.stopPropagation()
312
+ this.toggleOption(String(itemId), itemName)
313
+ })
314
+
315
+ // Add hover handler for highlighting
316
+ optionDiv.addEventListener('mouseenter', () => {
317
+ const visibleOptions = this.getVisibleOptions()
318
+ const optionIndex = visibleOptions.indexOf(optionDiv)
319
+ if (optionIndex !== -1) {
320
+ this.clearHighlight()
321
+ this.highlightedIndex = optionIndex
322
+ this.applyHighlight(optionDiv)
323
+ }
324
+ })
325
+
326
+ // Insert before create option if it exists, otherwise append
327
+ if (this.hasCreateOptionTarget) {
328
+ this.optionsListTarget.insertBefore(optionDiv, this.createOptionTarget)
329
+ } else {
330
+ this.optionsListTarget.appendChild(optionDiv)
331
+ }
332
+ }
333
+
334
+ handleKeydown(event) {
335
+ const visibleOptions = this.getVisibleOptions()
336
+
337
+ switch(event.key) {
338
+ case 'ArrowDown':
339
+ event.preventDefault()
340
+ if (!this.isOpen) {
341
+ this.openDropdown()
342
+ } else {
343
+ this.highlightNext(visibleOptions)
344
+ }
345
+ break
346
+
347
+ case 'ArrowUp':
348
+ event.preventDefault()
349
+ if (this.isOpen) {
350
+ this.highlightPrevious(visibleOptions)
351
+ }
352
+ break
353
+
354
+ case 'Enter':
355
+ event.preventDefault()
356
+ if (this.isOpen && this.highlightedIndex >= 0 && this.highlightedIndex < visibleOptions.length) {
357
+ // Select highlighted option
358
+ const highlightedOption = visibleOptions[this.highlightedIndex]
359
+
360
+ if (highlightedOption) {
361
+ if (highlightedOption.dataset?.isCreateOption) {
362
+ // It's the create option - use the stored query
363
+ const storedQuery = highlightedOption.dataset.createQuery
364
+ if (storedQuery) {
365
+ this.createNewItem(storedQuery)
366
+ }
367
+ } else {
368
+ // Regular option
369
+ const id = highlightedOption.dataset.optionId
370
+ // Use dataset.optionName which is reliable, or get the first span's text
371
+ const name = highlightedOption.dataset.optionName ||
372
+ highlightedOption.querySelector('span.flex-1')?.textContent?.trim() ||
373
+ highlightedOption.textContent.trim()
374
+ this.toggleOption(id, name)
375
+ }
376
+ }
377
+ } else {
378
+ // Handle create if no option is highlighted
379
+ const query = this.inputTarget.value.trim()
380
+
381
+ if (this.allowCreateValue && query.length > 0) {
382
+ // Check if exact match exists
383
+ const exactMatch = Array.from(this.allOptions.entries()).find(
384
+ ([id, name]) => name.toLowerCase() === query.toLowerCase()
385
+ )
386
+
387
+ if (exactMatch) {
388
+ // Select existing item
389
+ this.toggleOption(exactMatch[0], exactMatch[1])
390
+ } else {
391
+ // Create new item
392
+ this.createNewItem(query)
393
+ }
394
+ }
395
+ }
396
+ break
397
+
398
+ case 'Escape':
399
+ event.preventDefault()
400
+ this.closeDropdown()
401
+ break
402
+
403
+ case 'Backspace':
404
+ if (this.inputTarget.value === '') {
405
+ // Remove last selected item when backspace on empty input
406
+ event.preventDefault()
407
+ this.removeLastSelected()
408
+ }
409
+ break
410
+
411
+ case 'Tab':
412
+ // Close dropdown on tab
413
+ this.closeDropdown()
414
+ break
415
+
416
+ default:
417
+ // Open dropdown on any text input
418
+ if (!this.isOpen && event.key.length === 1) {
419
+ this.openDropdown()
420
+ }
421
+ }
422
+ }
423
+
424
+ getVisibleOptions() {
425
+ if (!this.hasOptionsListTarget) return []
426
+
427
+ // Get all option elements and filter out hidden ones
428
+ const allOptions = Array.from(this.optionsListTarget.querySelectorAll('[data-option-id]'))
429
+ .filter(option => {
430
+ // Check if element is visible (not hidden)
431
+ return option.style.display !== 'none'
432
+ })
433
+
434
+ // Add create option at the end if it exists and is visible
435
+ const createOption = this.hasCreateOptionTarget && this.createOptionTarget.style.display !== 'none'
436
+ ? this.createOptionTarget
437
+ : null
438
+
439
+ if (createOption) {
440
+ allOptions.push(createOption)
441
+ }
442
+
443
+ return allOptions
444
+ }
445
+
446
+ highlightNext(visibleOptions) {
447
+ if (visibleOptions.length === 0) return
448
+
449
+ // Clear previous highlight
450
+ this.clearHighlight()
451
+
452
+ // Move to next index
453
+ this.highlightedIndex = (this.highlightedIndex + 1) % visibleOptions.length
454
+
455
+ // Apply highlight
456
+ this.applyHighlight(visibleOptions[this.highlightedIndex])
457
+ }
458
+
459
+ highlightPrevious(visibleOptions) {
460
+ if (visibleOptions.length === 0) return
461
+
462
+ // Clear previous highlight
463
+ this.clearHighlight()
464
+
465
+ // Move to previous index
466
+ this.highlightedIndex = this.highlightedIndex <= 0
467
+ ? visibleOptions.length - 1
468
+ : this.highlightedIndex - 1
469
+
470
+ // Apply highlight
471
+ this.applyHighlight(visibleOptions[this.highlightedIndex])
472
+ }
473
+
474
+ applyHighlight(element) {
475
+ if (!element) return
476
+
477
+ if (this.themeValue === 'dark') {
478
+ element.classList.add('bg-gray-600', 'ring-1', 'ring-blue-500')
479
+ } else if (this.themeValue === 'light') {
480
+ element.classList.add('bg-gray-100', 'ring-1', 'ring-blue-500')
481
+ } else {
482
+ element.classList.add('bg-gray-100', 'dark:bg-gray-600', 'ring-1', 'ring-blue-500')
483
+ }
484
+ element.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
485
+ }
486
+
487
+ clearHighlight() {
488
+ if (!this.hasOptionsListTarget) return
489
+
490
+ // Clear all highlights based on theme
491
+ const highlightClasses = this.themeValue === 'dark'
492
+ ? ['bg-gray-600', 'ring-1', 'ring-blue-500']
493
+ : this.themeValue === 'light'
494
+ ? ['bg-gray-100', 'ring-1', 'ring-blue-500']
495
+ : ['bg-gray-100', 'dark:bg-gray-600', 'ring-1', 'ring-blue-500']
496
+
497
+ this.optionsListTarget.querySelectorAll('.bg-gray-100, .bg-gray-600, .dark\\:bg-gray-600').forEach(el => {
498
+ highlightClasses.forEach(cls => el.classList.remove(cls))
499
+ })
500
+
501
+ if (this.hasCreateOptionTarget) {
502
+ highlightClasses.forEach(cls => this.createOptionTarget.classList.remove(cls))
503
+ }
504
+ }
505
+
506
+ toggleOption(itemId, itemName) {
507
+ const id = String(itemId) // Ensure ID is a string
508
+ if (this.selectedItems.has(id)) {
509
+ this.removeItem(id)
510
+ } else {
511
+ this.addItem(id, itemName)
512
+ }
513
+ }
514
+
515
+ addItem(itemId, itemName) {
516
+ this.selectedItems.set(String(itemId), itemName) // Ensure ID is a string
517
+ this.updateDisplay()
518
+ this.updateHiddenInputs()
519
+ this.updateSelectedState()
520
+
521
+ // Clear input and reset search
522
+ if (this.hasInputTarget) {
523
+ this.inputTarget.value = ''
524
+ }
525
+ this.handleSearch()
526
+
527
+ // Reset highlight index after adding
528
+ this.highlightedIndex = -1
529
+ this.clearHighlight()
530
+ }
531
+
532
+ removeItem(itemId) {
533
+ this.selectedItems.delete(String(itemId)) // Ensure ID is a string
534
+ this.updateDisplay()
535
+ this.updateHiddenInputs()
536
+ this.updateSelectedState()
537
+ }
538
+
539
+ removeLastSelected() {
540
+ if (this.selectedItems.size > 0) {
541
+ const lastItem = Array.from(this.selectedItems.keys()).pop()
542
+ this.removeItem(lastItem)
543
+ }
544
+ }
545
+
546
+ createNewItem(name) {
547
+ if (!name || name.trim() === '') return
548
+
549
+ // Generate a temporary ID for new items
550
+ const tempId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
551
+
552
+ // Add to all options for future reference first
553
+ this.allOptions.set(tempId, name)
554
+
555
+ // Add the new item to the dropdown
556
+ this.addOptionToDropdown(tempId, name, true)
557
+
558
+ // Add to selected items (this will clear the input)
559
+ this.addItem(tempId, name)
560
+
561
+ // Optionally, you can trigger an AJAX request here to create the item on the server
562
+ if (this.apiEndpointValue) {
563
+ this.createItemOnServer(tempId, name)
564
+ }
565
+
566
+ // Hide create option after creating
567
+ this.hideCreateOption()
568
+ }
569
+
570
+ async createItemOnServer(tempId, name) {
571
+ // This is optional - implement server-side creation if needed
572
+ try {
573
+ const response = await fetch(this.apiEndpointValue, {
574
+ method: 'POST',
575
+ headers: {
576
+ 'Content-Type': 'application/json',
577
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
578
+ },
579
+ body: JSON.stringify({ [this.itemTypeValue]: { name: name } })
580
+ })
581
+
582
+ if (response.ok) {
583
+ const data = await response.json()
584
+ // Replace temp ID with real ID
585
+ if (data.id) {
586
+ const itemName = this.selectedItems.get(tempId)
587
+ this.selectedItems.delete(tempId)
588
+ this.allOptions.delete(tempId)
589
+
590
+ const realId = data.id.toString()
591
+ this.selectedItems.set(realId, itemName)
592
+ this.allOptions.set(realId, itemName)
593
+
594
+ // Update the dropdown option with real ID
595
+ const tempOption = this.optionsListTarget.querySelector(`[data-option-id="${tempId}"]`)
596
+ if (tempOption) {
597
+ tempOption.dataset.optionId = realId
598
+ }
599
+
600
+ this.updateHiddenInputs()
601
+ this.updateSelectedState()
602
+ }
603
+ }
604
+ } catch (error) {
605
+ console.error('Error creating item:', error)
606
+ }
607
+ }
608
+
609
+ updateDisplay() {
610
+ // Clear current display
611
+ this.selectedItemsTarget.innerHTML = ''
612
+
613
+ // Add badges for each selected item
614
+ this.selectedItems.forEach((name, id) => {
615
+ const badge = this.createBadge(id, name)
616
+ this.selectedItemsTarget.appendChild(badge)
617
+ })
618
+
619
+ // Update placeholder visibility
620
+ if (this.hasInputTarget) {
621
+ this.inputTarget.placeholder = this.selectedItems.size > 0 ? '' : this.placeholderValue
622
+ }
623
+ }
624
+
625
+ createBadge(itemId, itemName) {
626
+ const span = document.createElement('span')
627
+ span.className = this.badgeClassValue
628
+ span.dataset.itemId = itemId
629
+
630
+ // Apply theme-specific button classes
631
+ const buttonClass = this.themeValue === 'dark'
632
+ ? 'ml-1 group relative h-3.5 w-3.5 rounded-sm hover:bg-gray-400/20'
633
+ : this.themeValue === 'light'
634
+ ? 'ml-1 group relative h-3.5 w-3.5 rounded-sm hover:bg-gray-600/20'
635
+ : 'ml-1 group relative h-3.5 w-3.5 rounded-sm hover:bg-gray-600/20 dark:hover:bg-gray-400/20'
636
+
637
+ span.innerHTML = `
638
+ <span data-item-name>${this.escapeHtml(itemName)}</span>
639
+ <button type="button"
640
+ data-action="click->rails-notion-multiselect#handleRemove"
641
+ data-item-id="${itemId}"
642
+ class="${buttonClass}">
643
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5 opacity-60 group-hover:opacity-100">
644
+ <path d="M4 4l6 6m0-6l-6 6" />
645
+ </svg>
646
+ </button>
647
+ `
648
+
649
+ return span
650
+ }
651
+
652
+ handleRemove(event) {
653
+ event.preventDefault()
654
+ event.stopPropagation()
655
+ const itemId = event.currentTarget.dataset.itemId
656
+ this.removeItem(itemId)
657
+ }
658
+
659
+ focusInput(event) {
660
+ // Focus the input when clicking on the container (but not on badges)
661
+ if (event.target === event.currentTarget || event.target.classList.contains('flex-wrap')) {
662
+ if (this.hasInputTarget) {
663
+ this.inputTarget.focus()
664
+ }
665
+ }
666
+ }
667
+
668
+ updateHiddenInputs() {
669
+ // Clear current hidden inputs
670
+ this.hiddenInputsTarget.innerHTML = ''
671
+
672
+ // Add hidden input for each selected item
673
+ this.selectedItems.forEach((name, id) => {
674
+ const input = document.createElement('input')
675
+ input.type = 'hidden'
676
+ input.name = this.inputNameValue
677
+ input.value = id
678
+ this.hiddenInputsTarget.appendChild(input)
679
+ })
680
+
681
+ // Add empty input if no items selected (to ensure Rails receives an empty array)
682
+ if (this.selectedItems.size === 0) {
683
+ const input = document.createElement('input')
684
+ input.type = 'hidden'
685
+ input.name = this.inputNameValue
686
+ input.value = ''
687
+ this.hiddenInputsTarget.appendChild(input)
688
+ }
689
+ }
690
+
691
+ updateSelectedState() {
692
+ // Update visual state of options in dropdown
693
+ const options = this.optionsListTarget.querySelectorAll('[data-option-id]')
694
+ options.forEach(option => {
695
+ const isSelected = this.selectedItems.has(String(option.dataset.optionId)) // Ensure ID is a string
696
+
697
+ // Remove all possible state classes first
698
+ option.classList.remove('text-gray-700', 'text-gray-300', 'dark:text-gray-300',
699
+ 'hover:bg-gray-100', 'hover:bg-gray-700', 'dark:hover:bg-gray-700',
700
+ 'bg-blue-500', 'bg-blue-600', 'text-white', 'hover:bg-blue-600', 'hover:bg-blue-700',
701
+ 'dark:bg-blue-600', 'dark:hover:bg-blue-700',
702
+ 'hover:bg-gray-700/50', 'bg-blue-600/30', 'text-blue-100', 'hover:bg-blue-600/40')
703
+
704
+ if (isSelected) {
705
+ // Add selected state classes based on theme
706
+ if (this.themeValue === 'dark') {
707
+ option.classList.add('bg-blue-600', 'text-white', 'hover:bg-blue-700')
708
+ } else if (this.themeValue === 'light') {
709
+ option.classList.add('bg-blue-500', 'text-white', 'hover:bg-blue-600')
710
+ } else {
711
+ option.classList.add('bg-blue-500', 'text-white', 'hover:bg-blue-600', 'dark:bg-blue-600', 'dark:hover:bg-blue-700')
712
+ }
713
+ } else {
714
+ // Add unselected state classes based on theme
715
+ if (this.themeValue === 'dark') {
716
+ option.classList.add('text-gray-300', 'hover:bg-gray-700')
717
+ } else if (this.themeValue === 'light') {
718
+ option.classList.add('text-gray-700', 'hover:bg-gray-100')
719
+ } else {
720
+ option.classList.add('text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-100', 'dark:hover:bg-gray-700')
721
+ }
722
+ }
723
+ })
724
+ }
725
+
726
+ resetSearch() {
727
+ // Show all options
728
+ const options = this.optionsListTarget.querySelectorAll('[data-option-id]')
729
+ options.forEach(option => {
730
+ option.style.display = ''
731
+ })
732
+ this.hideCreateOption()
733
+ }
734
+
735
+ escapeHtml(text) {
736
+ const div = document.createElement('div')
737
+ div.textContent = text
738
+ return div.innerHTML
739
+ }
740
+ }