Skip to content

Commit e6842ce

Browse files
committed
feat: support for option fetching from url in search-select and multi-select
1 parent 267d796 commit e6842ce

16 files changed

+314
-121
lines changed

.eslintrc.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module.exports = {
2+
extends: [
3+
'plugin:@typescript-eslint/eslint-recommended',
4+
'plugin:@typescript-eslint/recommended',
5+
'plugin:vue/strongly-recommended',
6+
'@vue/typescript/recommended',
7+
'prettier',
8+
],
9+
parserOptions: {
10+
parser: '@typescript-eslint/parser',
11+
sourceType: 'module',
12+
ecmaVersion: 2015,
13+
},
14+
plugins: ['prettier', '@typescript-eslint'],
15+
rules: {
16+
'prettier/prettier': [
17+
'warn',
18+
{
19+
singleQuote: true,
20+
trailingComma: 'all',
21+
semi: false,
22+
maxWidth: 120,
23+
},
24+
],
25+
26+
// Generics
27+
semi: ['warn', 'never'],
28+
'max-len': ['warn', { code: 120 }],
29+
'no-undef': ['off'],
30+
31+
// Typescript
32+
'@typescript-eslint/no-non-null-assertion': ['off'],
33+
'@typescript-eslint/no-inferrable-types': ['off'],
34+
35+
// Vue
36+
'vue/multi-word-component-names': 'off',
37+
},
38+
ignorePatterns: ['public/**/*'],
39+
}

.prettierrc.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
bracketSpacing: true,
3+
jsxBracketSameLine: true,
4+
singleQuote: true,
5+
trailingComma: 'all',
6+
semi: false,
7+
maxWidth: 120,
8+
tabWidth: 2,
9+
};

resources/css/components/custom-select.scss

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
.ss-wrapper, .ms-wrapper {
2-
1+
.ss-wrapper,
2+
.ms-wrapper {
33
--cs-active-color: var(--bs-primary-bg-subtle, #cfe2ff);
44
--cs-selected-color: var(--bs-gray-200, #e9ecef);
55

66
--cs-border-width: var(--bs-border-width, 1px);
77
--cs-border-color: var(--bs-border-color, #dee2e6);
88

9-
109
position: relative;
1110

1211
.ss-box {
@@ -64,6 +63,13 @@
6463
overflow: auto;
6564
}
6665

66+
&.loading {
67+
.ss-options {
68+
opacity: 0.5;
69+
pointer-events: none;
70+
}
71+
}
72+
6773
&.hidden {
6874
display: none;
6975
}
@@ -72,14 +78,14 @@
7278
background-color: white;
7379
border-bottom: 1px solid var(--cs-border-color);
7480

75-
padding: .5rem;
81+
padding: 0.5rem;
7682

7783
display: flex;
7884
flex-direction: row;
79-
gap: .5rem;
85+
gap: 0.5rem;
8086

8187
input {
82-
flex: 1
88+
flex: 1;
8389
}
8490
}
8591

@@ -92,26 +98,27 @@
9298
}
9399

94100
.ss-option {
95-
padding: .5rem 1rem;
101+
padding: 0.5rem 1rem;
96102
border-bottom: var(--cs-border-width) solid var(--cs-border-color);
97103
cursor: pointer;
98104

99-
.ss-remove-icon, .ss-check-icon {
105+
.ss-remove-icon,
106+
.ss-check-icon {
100107
float: right;
101-
margin-right: -.25rem;
108+
margin-right: -0.25rem;
102109
display: none;
103110
}
104111

105112
&.hidden {
106113
display: none;
107114
}
108115

109-
110116
&.selected {
111117
background-color: var(--cs-selected-color);
112118
font-weight: 600;
113119

114-
.ss-remove-icon, .ss-check-icon {
120+
.ss-remove-icon,
121+
.ss-check-icon {
115122
display: inline;
116123
}
117124
}

resources/js/components/custom-select.ts

Lines changed: 130 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import {debounce, throttle} from '@/utils/dom.ts'
1+
import { debounce, throttle } from '@/utils/dom.ts'
22

33
export class CustomSelect {
44
rootEl: Element
55

66
multiple: boolean
77

8+
fetchUrl: string | null = null
9+
private fetchAbortController: AbortController | null = null
10+
811
/* Dropdown elements */
912
dropdown: HTMLElement
1013
dropdownOptions: Map<string, HTMLElement> = new Map()
@@ -36,6 +39,8 @@ export class CustomSelect {
3639
constructor(rootEl: Element, multiple: boolean) {
3740
this.multiple = multiple
3841

42+
this.fetchUrl = rootEl.getAttribute('data-fetchurl')
43+
3944
this.rootEl = rootEl
4045

4146
this.dropdown = rootEl.querySelector('.ss-dropdown')!
@@ -114,7 +119,10 @@ export class CustomSelect {
114119

115120
this.dropdownSearch?.addEventListener(
116121
'input',
117-
debounce((_e) => this.search(), 250),
122+
debounce((_e) => {
123+
this.search()
124+
this.fetchOptions()
125+
}, 250),
118126
)
119127

120128
this.dropdownSearch?.addEventListener('keydown', (e) => {
@@ -148,8 +156,8 @@ export class CustomSelect {
148156
)
149157

150158
this.update()
151-
152159
this.initLivewire()
160+
this.fetchOptions()
153161
}
154162

155163
initLivewire = () => {
@@ -170,11 +178,11 @@ export class CustomSelect {
170178
return
171179
}
172180

173-
window['Livewire'].hook('morph.updated', ({el}) =>
181+
window['Livewire'].hook('morph.updated', ({ el }) =>
174182
this.onLivewireUpdate(el),
175183
)
176184

177-
window['Livewire'].hook('element.init', ({el}) =>
185+
window['Livewire'].hook('element.init', ({ el }) =>
178186
/* Timeout required because element.init is launched BEFORE wire:model takes effect */
179187
setTimeout(() => this.onLivewireUpdate(el), 50),
180188
)
@@ -198,18 +206,13 @@ export class CustomSelect {
198206
this.dropdown.classList.remove('hidden')
199207
setTimeout(() => this.dropdownSearch?.focus(), 25)
200208

201-
this.setActive(this.dropdownOptions.keys().next().value)
209+
this.setActive(this.dropdownOptions.keys().next().value ?? null)
202210
}
203211

204212
close = (withFocus: boolean = true) => {
205213
this.isOpen = false
206214
this.dropdown.classList.add('hidden')
207215

208-
if (this.dropdownSearch) {
209-
this.dropdownSearch.value = ''
210-
this.dropdownSearch.dispatchEvent(new Event('input'))
211-
}
212-
213216
if (withFocus) {
214217
this.uiBox.focus()
215218
}
@@ -219,8 +222,90 @@ export class CustomSelect {
219222
this.isOpen ? this.close() : this.open()
220223
}
221224

225+
fetchOptions = async () => {
226+
if (!this.fetchUrl) {
227+
return
228+
}
229+
230+
if (this.fetchAbortController) {
231+
this.fetchAbortController.abort() // Cancel the previous fetch
232+
}
233+
234+
this.fetchAbortController = new AbortController() // Create a new AbortController
235+
const signal = this.fetchAbortController.signal // Get the AbortSignal
236+
237+
let url = new URL(this.fetchUrl, window.location.origin) // Use URL constructor to handle existing params
238+
const searchString = this.dropdownSearch?.value.trim()
239+
240+
if (searchString) {
241+
url.searchParams.append('q', searchString)
242+
}
243+
244+
this.dropdown.classList.add('loading')
245+
246+
try {
247+
const response = await fetch(url.toString(), { signal })
248+
249+
if (!response.ok) {
250+
throw new Error(`HTTP error! Status: ${response.status}`)
251+
}
252+
253+
const data: { [key: string]: string } = await response.json() // Type assertion for the response
254+
255+
// Clear existing options (except the empty value)
256+
const emptyValue = this.emptyValue // Save it before removing the HTML
257+
258+
const selectedOptions = new Map<string, string>()
259+
260+
// Remove all options aside from the empty one, and keep track of the selected ones
261+
for (const optEl of this.select.options) {
262+
if (optEl.value === emptyValue) continue
263+
if (optEl.selected) selectedOptions.set(optEl.value, optEl.innerText)
264+
}
265+
266+
this.select.innerHTML = ''
267+
if (!this.multiple) {
268+
const emptyOption = document.createElement('option')
269+
emptyOption.value = emptyValue
270+
this.select.appendChild(emptyOption)
271+
}
272+
273+
// Add the fetched options
274+
for (const valueKey in data) {
275+
const value = valueKey.toString()
276+
const optEl = document.createElement('option')
277+
optEl.value = value
278+
optEl.innerText = data[value] // Ensure innerText is set
279+
if (selectedOptions.has(value)) {
280+
optEl.selected = true
281+
selectedOptions.delete(value)
282+
}
283+
this.select.appendChild(optEl)
284+
}
285+
286+
// If there are some selectedOptions not available anymore, we still need to add them to the select element or it would lose the reference
287+
for (const [value, label] of selectedOptions) {
288+
const optEl = document.createElement('option')
289+
optEl.value = value
290+
optEl.innerText = label // Ensure innerText is set
291+
optEl.selected = true
292+
optEl.setAttribute('data-hidden', 'true') // Those options should be hidden in the dropdown
293+
this.select.appendChild(optEl)
294+
}
295+
296+
this.populateDropdown() // Update the dropdown UI
297+
this.update() // Update the selected value display
298+
} catch (error) {
299+
console.error('[SearchSelect] Error fetching options:', error)
300+
// Optionally, display an error message to the user
301+
} finally {
302+
this.dropdown.classList.remove('loading')
303+
this.fetchAbortController = null
304+
}
305+
}
306+
222307
search = () => {
223-
if (!this.dropdownSearch) {
308+
if (!this.dropdownSearch || this.fetchUrl !== null) {
224309
return
225310
}
226311

@@ -235,10 +320,10 @@ export class CustomSelect {
235320
const toHide: HTMLElement[] = []
236321

237322
for (const [key, opt] of this.dropdownOptions) {
238-
const shouldShow = s === '' || this.optionsSearchText.get(key)?.includes(s)
323+
const shouldShow =
324+
s === '' || this.optionsSearchText.get(key)?.includes(s)
239325

240326
if (shouldShow) {
241-
242327
if (opt.classList.contains('hidden')) {
243328
toShow.push(opt)
244329
}
@@ -255,8 +340,8 @@ export class CustomSelect {
255340

256341
// Do all work in a single frame, avoiding multiple browser reflow & repaint
257342
requestAnimationFrame(() => {
258-
toShow.forEach(opt => opt.classList.remove('hidden'))
259-
toHide.forEach(opt => opt.classList.add('hidden'))
343+
toShow.forEach((opt) => opt.classList.remove('hidden'))
344+
toHide.forEach((opt) => opt.classList.add('hidden'))
260345

261346
this.setActive(newActive)
262347
})
@@ -271,30 +356,45 @@ export class CustomSelect {
271356

272357
const optionsWrapper = this.dropdown.querySelector('.ss-options')!
273358

274-
this.select.querySelectorAll('option').forEach((option) => {
275-
if (option.value === this.emptyValue) return
359+
for (const optEl of this.select.options) {
360+
if (optEl.value === this.emptyValue) continue
276361

277-
if (this.dropdownOptions.has(option.value)) {
278-
existingValues.delete(option.value)
362+
// For each options in the root select element, add the equivalent option in the dropdown
279363

280-
this.dropdownOptions
281-
.get(option.value)!
282-
.querySelector('span')!.innerText = option.innerText
283-
return
364+
// To improve performance, do not recreate element if it already exists
365+
if (this.dropdownOptions.has(optEl.value)) {
366+
existingValues.delete(optEl.value)
367+
368+
const dropdownOption = this.dropdownOptions.get(optEl.value)!
369+
dropdownOption.querySelector('span')!.innerText = optEl.innerText
370+
371+
if (optEl.hasAttribute('data-hidden')) {
372+
dropdownOption.classList.add('hidden')
373+
} else {
374+
dropdownOption.classList.remove('hidden')
375+
}
376+
continue
284377
}
285378

286379
const dropdownOption = (template.content.cloneNode(true) as HTMLElement)
287380
.firstElementChild as HTMLElement
288381

289-
dropdownOption.setAttribute('data-key', option.value)
290-
dropdownOption.querySelector('span')!.innerText = option.label
382+
dropdownOption.setAttribute('data-key', optEl.value)
383+
dropdownOption.querySelector('span')!.innerText = optEl.label
384+
385+
if (optEl.hasAttribute('data-hidden')) {
386+
dropdownOption.classList.add('hidden')
387+
} else {
388+
dropdownOption.classList.remove('hidden')
389+
}
291390

292391
optionsWrapper.appendChild(dropdownOption)
293392

294-
this.dropdownOptions.set(option.value, dropdownOption)
295-
this.optionsSearchText.set(option.value, option.label.toLowerCase())
296-
})
393+
this.dropdownOptions.set(optEl.value, dropdownOption)
394+
this.optionsSearchText.set(optEl.value, optEl.label.toLowerCase())
395+
}
297396

397+
// Existing values not removed at the previous step are no longer available, we can remove them
298398
existingValues.forEach((val) => {
299399
this.dropdownOptions.get(val)?.remove()
300400

@@ -355,7 +455,7 @@ export class CustomSelect {
355455

356456
this.dropdownOptions
357457
.get(key)
358-
?.scrollIntoView({block: 'nearest', behavior: 'smooth'})
458+
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
359459
}
360460

361461
this.active = key

0 commit comments

Comments
 (0)