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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +388 -0
- data/Rakefile +4 -0
- data/app/javascript/rails_notion_multiselect_controller.js +740 -0
- data/config/importmap.rb +3 -0
- data/lib/generators/rails_notion_like_multiselect/install/install_generator.rb +63 -0
- data/lib/generators/rails_notion_like_multiselect/install/templates/rails_notion_multiselect_controller.js +740 -0
- data/lib/rails_notion_like_multiselect/engine.rb +27 -0
- data/lib/rails_notion_like_multiselect/helpers/multiselect_helper.rb +288 -0
- data/lib/rails_notion_like_multiselect/version.rb +5 -0
- data/lib/rails_notion_like_multiselect.rb +24 -0
- metadata +152 -0
|
@@ -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
|
+
}
|