Skip to content

Commit 620704b

Browse files
authored
Merge pull request #2 from plausible/multiselect
Multiselect
2 parents b87e492 + 3cf96c3 commit 620704b

16 files changed

+1067
-64
lines changed

assets/js/hooks/combobox.js

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ export default {
55
mounted() {
66
this.initializeDOMCache()
77
this.mode = this.getMode()
8+
this.isMultiple = this.el.hasAttribute('data-multiple')
89
this.hasCreateOption = !!this.createOption
10+
911
this.setupEventListeners()
1012
this.initializeCreateOption()
11-
this.updateSelectedOption()
13+
this.syncSelectedAttributes()
1214
if (this.mode === 'async') {
1315
this.searchInput.dispatchEvent(new Event("input", {bubbles: true}))
1416
}
@@ -17,9 +19,15 @@ export default {
1719

1820
initializeDOMCache() {
1921
this.searchInput = this.el.querySelector('input[data-prima-ref=search_input]')
20-
this.submitInput = this.el.querySelector('input[data-prima-ref=submit_input]')
22+
this.submitContainer = this.el.querySelector('[data-prima-ref=submit_container]')
2123
this.optionsContainer = this.getOptionsContainer()
2224
this.createOption = this.optionsContainer?.querySelector('[data-prima-ref=create-option]')
25+
this.selectionsContainer = this.el.querySelector('[data-prima-ref=selections]')
26+
this.selectionTemplate = this.selectionsContainer?.querySelector('[data-prima-ref=selection-template]')
27+
28+
// Cache reference element for positioning
29+
const field = this.el.querySelector('[data-prima-ref="field"]')
30+
this.referenceElement = field || this.searchInput
2331
},
2432

2533
setupEventListeners() {
@@ -29,7 +37,14 @@ export default {
2937
this.optionsContainer?.addEventListener('mouseover', this.onHover.bind(this))
3038
this.searchInput.addEventListener('focus', () => {
3139
this.searchInput.select()
32-
this.showOptions()
40+
})
41+
this.searchInput.addEventListener('click', (e) => {
42+
// Toggle options visibility on click
43+
if (this.isOptionsVisible()) {
44+
this.hideOptions()
45+
} else {
46+
this.showOptions()
47+
}
3348
})
3449
this.searchInput.addEventListener('input', this.onInput.bind(this))
3550
},
@@ -42,7 +57,7 @@ export default {
4257
} else {
4358
this.focusFirstOption()
4459
}
45-
this.updateSelectedOption()
60+
this.syncSelectedAttributes()
4661
},
4762

4863
destroyed() {
@@ -63,6 +78,71 @@ export default {
6378
return Array.from(this.optionsContainer?.querySelectorAll('[role=option]:not([data-hidden])') || [])
6479
},
6580

81+
getRegularOptions() {
82+
return this.optionsContainer?.querySelectorAll('[role=option]:not([data-prima-ref=create-option])') || []
83+
},
84+
85+
isOptionsVisible() {
86+
if (!this.optionsContainer) return false
87+
return this.optionsContainer.style.display !== 'none'
88+
},
89+
90+
getSelectedValues() {
91+
const inputs = this.submitContainer?.querySelectorAll('input[type="hidden"]') || []
92+
return Array.from(inputs).map(input => input.value)
93+
},
94+
95+
getInputName() {
96+
if (!this.submitContainer) return ''
97+
const baseName = this.submitContainer.getAttribute('data-input-name')
98+
return this.isMultiple ? baseName + '[]' : baseName
99+
},
100+
101+
// === SELECTION MANAGEMENT ===
102+
addSelection(value) {
103+
if (!this.submitContainer) return
104+
105+
const selectedValues = this.getSelectedValues()
106+
107+
// Don't add if already selected
108+
if (selectedValues.includes(value)) return
109+
110+
// Single-select: clear existing selections first
111+
if (!this.isMultiple) {
112+
this.submitContainer.innerHTML = ''
113+
}
114+
115+
// Create and append hidden input
116+
const input = document.createElement('input')
117+
input.type = 'hidden'
118+
input.name = this.getInputName()
119+
input.value = value
120+
this.submitContainer.appendChild(input)
121+
122+
// Render pill for multi-select
123+
if (this.isMultiple) {
124+
this.appendSelectionPill(value)
125+
}
126+
127+
this.syncSelectedAttributes()
128+
},
129+
130+
removeSelection(value) {
131+
// Remove hidden input
132+
const inputs = Array.from(this.submitContainer.querySelectorAll('input[type="hidden"]'))
133+
const input = inputs.find(input => input.value === value)
134+
input?.remove()
135+
136+
if (this.isMultiple) {
137+
const pill = this.selectionsContainer?.querySelector(
138+
`[data-prima-ref="selection-item"][data-value="${value}"]`
139+
)
140+
pill?.remove()
141+
}
142+
143+
this.syncSelectedAttributes()
144+
},
145+
66146
// === FOCUS MANAGEMENT ===
67147
setFocus(el) {
68148
this.optionsContainer?.querySelector('[role=option][data-focus=true]')?.removeAttribute('data-focus')
@@ -100,38 +180,68 @@ export default {
100180

101181
// === OPTION SELECTION ===
102182
selectOption(el) {
103-
const value = el.getAttribute('data-value')
183+
if (!el) return
184+
185+
let value = el.getAttribute('data-value')
104186

187+
// Handle create option
105188
if (value === '__CREATE__') {
106-
const searchValue = this.searchInput.value
107-
this.submitInput.value = searchValue
108-
this.searchInput.value = searchValue
189+
value = this.searchInput.value
190+
}
191+
192+
this.addSelection(value)
193+
194+
if (this.isMultiple) {
195+
this.searchInput.value = ''
109196
} else {
110-
this.submitInput.value = value
111197
this.searchInput.value = value
112198
}
113199

114-
this.updateSelectedOption()
115200
this.hideOptions()
201+
202+
this.searchInput.focus()
116203
},
117204

118-
updateSelectedOption() {
205+
syncSelectedAttributes() {
119206
if (!this.optionsContainer) return
120207

121-
const selectedValue = this.submitInput.value
122-
const allOptions = this.optionsContainer.querySelectorAll('[role=option]:not([data-prima-ref=create-option])')
208+
const allOptions = this.getRegularOptions()
209+
const selectedValues = this.getSelectedValues()
123210

124211
for (const option of allOptions) {
125-
if (option.getAttribute('data-value') === selectedValue && selectedValue !== '') {
212+
const value = option.getAttribute('data-value')
213+
if (selectedValues.includes(value)) {
126214
option.setAttribute('data-selected', 'true')
127215
} else {
128216
option.removeAttribute('data-selected')
129217
}
130218
}
131219
},
132220

221+
appendSelectionPill(value) {
222+
if (!this.selectionsContainer || !this.selectionTemplate) return
223+
224+
const pill = this.selectionTemplate.content.cloneNode(true)
225+
const item = pill.querySelector('[data-prima-ref="selection-item"]')
226+
item.dataset.value = value
227+
228+
// Replace all occurrences of __VALUE__ with actual value
229+
item.innerHTML = item.innerHTML.replaceAll('__VALUE__', value)
230+
231+
this.selectionsContainer.appendChild(pill)
232+
},
233+
133234
// === EVENT HANDLERS ===
134235
onClick(e) {
236+
// Check for remove button click first
237+
const removeButton = e.target.closest('[data-prima-ref="remove-selection"]')
238+
if (removeButton) {
239+
const value = removeButton.getAttribute('data-value')
240+
this.removeSelection(value)
241+
this.searchInput.focus()
242+
return
243+
}
244+
135245
// Use event delegation to find the closest option element
136246
const optionElement = e.target.closest('[role="option"]')
137247
if (optionElement) {
@@ -148,7 +258,16 @@ export default {
148258
} else if (e.key === "Enter" || e.key === "Tab") {
149259
e.preventDefault()
150260
this.selectOption(this.currentlyFocusedOption())
261+
} else if (e.key === "Escape") {
262+
e.preventDefault()
151263
this.hideOptions()
264+
} else if (e.key === "Backspace" && this.isMultiple && this.searchInput.value === '') {
265+
// Remove last selection when backspace is pressed on empty input
266+
e.preventDefault()
267+
const values = this.getSelectedValues()
268+
if (values.length > 0) {
269+
this.removeSelection(values[values.length - 1])
270+
}
152271
}
153272
},
154273

@@ -194,7 +313,7 @@ export default {
194313
// === OPTION FILTERING & VISIBILITY ===
195314
filterOptions(searchValue) {
196315
const q = searchValue.toLowerCase()
197-
const allOptions = this.optionsContainer?.querySelectorAll('[role=option]:not([data-prima-ref=create-option])') || []
316+
const allOptions = this.getRegularOptions()
198317
let previouslyFocusedOptionIsHidden = false
199318

200319
for (const option of allOptions) {
@@ -246,7 +365,7 @@ export default {
246365
}
247366

248367
try {
249-
const {x, y} = await computePosition(this.searchInput, this.optionsContainer, {
368+
const {x, y} = await computePosition(this.referenceElement, this.optionsContainer, {
250369
placement: placement,
251370
middleware: middleware
252371
})
@@ -261,14 +380,6 @@ export default {
261380
}
262381
},
263382

264-
setupAutoUpdate() {
265-
if (!this.optionsContainer) return
266-
267-
this.cleanup = autoUpdate(this.searchInput, this.optionsContainer, () => {
268-
this.positionOptions()
269-
})
270-
},
271-
272383
cleanupAutoUpdate() {
273384
if (this.cleanup) {
274385
this.cleanup()
@@ -287,9 +398,9 @@ export default {
287398
this.positionOptions()
288399
})
289400

290-
// Setup automatic repositioning with floating-ui's autoUpdate
291-
this.setupAutoUpdate()
292-
401+
this.cleanup = autoUpdate(this.referenceElement, this.optionsContainer, () => {
402+
this.positionOptions()
403+
})
293404
this.setupClickOutsideHandler()
294405
},
295406

@@ -298,20 +409,23 @@ export default {
298409
if (!this.optionsContainer.contains(event.target) && !this.searchInput.contains(event.target)) {
299410
this.resetOnBlur()
300411
document.removeEventListener('click', handleClickOutside)
301-
this.cleanupAutoUpdate()
302412
}
303413
}
304414

305415
document.addEventListener('click', handleClickOutside)
306416
},
307417

308418
resetOnBlur() {
309-
if (this.submitInput.value.length > 0 && this.searchInput.value.length > 0) {
310-
this.searchInput.value = this.submitInput.value
419+
const selectedValues = this.getSelectedValues()
420+
const currentValue = selectedValues[0] || ''
421+
422+
if (currentValue.length > 0 && this.searchInput.value.length > 0) {
423+
this.searchInput.value = currentValue
311424
} else if (this.searchInput.value.length > 0) {
312425
this.searchInput.value = ''
313426
this.searchInput.dispatchEvent(new Event("input", {bubbles: true}))
314-
this.submitInput.value = ''
427+
// Clear selections (for single-select)
428+
this.submitContainer.innerHTML = ''
315429
}
316430

317431
this.hideOptions()
@@ -326,8 +440,10 @@ export default {
326440
this.cleanupAutoUpdate()
327441

328442
this.optionsContainer.addEventListener('phx:hide-end', () => {
329-
for (const option of this.optionsContainer.querySelectorAll('[role=option]')) {
330-
this.showOption(option) // reset the state
443+
// Reset regular options to visible, but exclude create option since its visibility is managed separately
444+
const regularOptions = this.getRegularOptions()
445+
for (const option of regularOptions) {
446+
this.showOption(option)
331447
}
332448
})
333449
},
@@ -363,13 +479,14 @@ export default {
363479
},
364480

365481
hasExactMatch(searchValue) {
366-
const regularOptions = this.optionsContainer?.querySelectorAll('[role=option]:not([data-prima-ref=create-option])') || []
482+
const regularOptions = this.getRegularOptions()
367483
const hasStaticMatch = Array.from(regularOptions).some(option =>
368484
option.getAttribute('data-value') === searchValue
369485
)
370486

371-
// Also check if search value matches current selected value (submit input)
372-
const hasSelectedMatch = this.submitInput.value === searchValue
487+
// Also check if search value matches any currently selected value
488+
const selectedValues = this.getSelectedValues()
489+
const hasSelectedMatch = selectedValues.includes(searchValue)
373490

374491
return hasStaticMatch || hasSelectedMatch
375492
}

0 commit comments

Comments
 (0)