forked from SolidOS/solid-ui
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmultiSelect.js
More file actions
645 lines (580 loc) · 21.6 KB
/
multiSelect.js
File metadata and controls
645 lines (580 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
/*
* IconicMultiSelect v0.7.0
* Licence: MIT
* (c) 2021 Sidney Wimart.
* repo & configuration: https://github.com/sidneywm/iconic-multiselect
*/
/**
* @version IconicMultiSelect v0.7.0
* @licence MIT
*/
import { style } from '../style_multiSelect'
export class IconicMultiSelect {
_data
_domElements
_event = () => {}
_itemTemplate
_multiselect
_noData
_noResults
_options = []
_placeholder
_select
_selectContainer
_selectedOptions = []
_tagTemplate
_textField
_valueField
_cross = `
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.2253 4.81108C5.83477 4.42056 5.20161 4.42056 4.81108 4.81108C4.42056 5.20161 4.42056 5.83477 4.81108 6.2253L10.5858 12L4.81114 17.7747C4.42062 18.1652 4.42062 18.7984 4.81114 19.1889C5.20167 19.5794 5.83483 19.5794 6.22535 19.1889L12 13.4142L17.7747 19.1889C18.1652 19.5794 18.7984 19.5794 19.1889 19.1889C19.5794 18.7984 19.5794 18.1652 19.1889 17.7747L13.4142 12L19.189 6.2253C19.5795 5.83477 19.5795 5.20161 19.189 4.81108C18.7985 4.42056 18.1653 4.42056 17.7748 4.81108L12 10.5858L6.2253 4.81108Z"
fill="currentColor"
/>
</svg>
`
/**
* Iconic Multiselect constructor.
* @param { Object[] } data - Array of objects.
* @param { string } noData - Defines the message when there is no data input.
* @param { string } noResults - Defines the message when there is no result if options are filtered.
* @param { string } placeholder - Defines the placeholder's text.
* @param { string } select - DOM element to be selected. It must be a HTML Select tag - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select
* @param { string } textField - Field to select in the object for the text.
* @param { string } valueField - Field to select in the object for the value.
*/
constructor ({ data, itemTemplate, noData, noResults, placeholder, select, container, tagTemplate, textField, valueField }) {
this._data = data ?? []
this._itemTemplate = itemTemplate ?? null
this._noData = noData ?? 'No data found.'
this._noResults = noResults ?? 'No results found.'
this._placeholder = placeholder ?? 'Select...'
this._select = select
// Timea added a container here
this._selectContainer = container
this._tagTemplate = tagTemplate ?? null
this._textField = textField ?? null
this._valueField = valueField ?? null
}
/**
* Initialize the Iconic Multiselect component.
* @public
*/
init () {
// Timea change to use this._select instead of this._selectContainer
if (this._select && this._select.nodeName === 'SELECT') {
if (this._itemTemplate && this._data.length === 0) { throw new Error('itemTemplate must be initialized with data from the component settings') }
if (this._tagTemplate && this._data.length === 0) { throw new Error('tagTemplate must be initialized with data from the component settings') }
this._options = this._data.length > 0 ? this._getDataFromSettings() : this._getDataFromSelectTag()
this._renderMultiselect()
this._renderOptionsList()
this._domElements = {
clear: this._multiselect.querySelector('.multiselect__clear-btn'),
input: this._multiselect.querySelector('.multiselect__input'),
optionsContainer: this._multiselect.querySelector('.multiselect__options'),
optionsContainerList: this._multiselect.querySelector('.multiselect__options > ul'),
options: {
list: this._multiselect.querySelectorAll('.multiselect__options > ul > li'),
find: function (callbackFn) {
for (let i = 0; i < this.list.length; i++) {
const node = this.list[i]
if (callbackFn(node)) return node
}
return undefined
},
some: function (callbackFn) {
for (let i = 0; i < this.list.length; i++) {
const node = this.list[i]
if (callbackFn(node, i)) return true
}
return false
}
}
}
this._enableEventListenners()
this._initSelectedList()
} else {
throw new Error(`The selector '${this._select}' did not select any valid select tag.`)
}
}
/**
* Subscribes to the emitted events.
* @param { Function } callback - Callback function which emits a custom event object.
* @public
*/
subscribe (callback) {
if (typeof callback === 'function') {
this._event = callback
} else {
throw new Error('parameter in the subscribe method is not a function')
}
}
/**
* Add an option to the selection list.
* @param { Object: { text: string; value: string; }} option
* @private
*/
_addOptionToList (option, index) {
const html = `<span class="multiselect__selected" style="${style.multiselect__selected}" data-value="${option.value}">${
this._tagTemplate ? this._processTemplate(this._tagTemplate, index) : option.text
}<span class="multiselect__remove-btn" style="${style.multiselect__remove_btn}">${this._cross}</span></span>`
this._domElements.input.insertAdjacentHTML('beforebegin', html)
const { lastElementChild: removeBtn } = this._multiselect.querySelector(`span[data-value="${option.value}"]`)
removeBtn.addEventListener('click', () => {
const target = this._domElements.options.find((el) => el.dataset.value === option.value)
this._handleOption(target)
})
}
/**
* Clears all selected options.
* @private
*/
_clearSelection () {
for (let i = 0; i < this._selectedOptions.length; i++) {
const option = this._selectedOptions[i]
const target = this._domElements.options.find((el) => el.dataset.value === option.value)
target.classList.remove('multiselect__options--selected')
target.setAttribute('style', style.multiselect__options)
this._removeOptionFromList(target.dataset.value)
}
this._selectedOptions = []
this._handleClearSelectionBtn()
this._handlePlaceholder()
this._dispatchEvent({
action: 'CLEAR_ALL_OPTIONS',
selection: this._selectedOptions
})
}
/**
* Close the options container.
* @private
*/
_closeList () {
this._domElements.input.value = ''
this._domElements.optionsContainer.classList.remove('visible')
this._domElements.optionsContainer.setAttribute('style', style.multiselect__options)
this._filterOptions('')
this._removeAllArrowSelected()
}
/**
* Dispatches new events.
* @param { object : { action: string; selection: { option: string; text: string; }[]; value?: string; } } event
* @private
*/
_dispatchEvent (event) {
this._event(event)
}
/**
* Enables all main event listenners.
* @private
*/
_enableEventListenners () {
document.addEventListener('mouseup', ({ target }) => {
if (!this._multiselect.contains(target)) {
this._filterOptions('')
this._closeList()
this._handlePlaceholder()
}
})
this._domElements.clear.addEventListener('click', () => {
this._clearSelection()
})
for (let i = 0; i < this._domElements.options.list.length; i++) {
const option = this._domElements.options.list[i]
option.addEventListener('click', ({ target }) => {
this._handleOption(target)
this._closeList()
})
}
this._domElements.input.addEventListener('focus', () => {
this._domElements.optionsContainer.classList.add('visible')
this._domElements.optionsContainer.setAttribute('style', style.multiselect__options_visible)
})
this._domElements.input.addEventListener('input', ({ target: { value } }) => {
if (this._domElements.options.list.length > 0) {
this._filterOptions(value)
}
})
this._domElements.input.addEventListener('keydown', (e) => {
this._handleArrows(e)
this._handleBackspace(e)
this._handleEnter(e)
})
}
/**
* Filters user input.
* @param { string } value
* @private
*/
_filterOptions (value) {
const isOpen = this._domElements.optionsContainer.classList.contains('visible')
const valueLowerCase = value.toLowerCase()
if (!isOpen && value.length > 0) {
this._domElements.optionsContainer.classList.add('visible')
this._domElements.optionsContainer.setAttribute('style', style.multiselect__options_visible)
}
if (this._domElements.options.list.length > 0) {
for (let i = 0; i < this._domElements.options.list.length; i++) {
const el = this._domElements.options.list[i]
const text = this._itemTemplate ? this._data[i][this._textField] : el.textContent
if (text.toLowerCase().substring(0, valueLowerCase.length) === valueLowerCase) {
this._domElements.optionsContainerList.appendChild(el)
} else {
el.parentNode && el.parentNode.removeChild(el)
}
}
const hasResults = this._domElements.options.some(
(el, index) =>
(this._itemTemplate ? this._data[index][this._textField] : el.textContent)
.toLowerCase()
.substring(0, valueLowerCase.length) === valueLowerCase
)
this._showNoResults(!hasResults)
}
}
_generateId (length) {
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}
/**
* Gets data from select tag.
* @private
*/
_getDataFromSelectTag () {
const arr = []
const { options } = this._select
for (let i = 0; i < options.length; i++) {
const item = options[i]
arr.push({
text: item.text,
value: item.value,
selected: item.hasAttribute('selected')
})
}
return arr
}
/**
* Gets data from settings.
* @private
*/
_getDataFromSettings () {
if (this._data.length > 0 && this._valueField && this._textField) {
const isValueFieldValid = typeof this._valueField === 'string'
const isTextFieldValid = typeof this._textField === 'string'
const arr = []
if (!isValueFieldValid || !isTextFieldValid) {
throw new Error('textField and valueField must be of type string')
}
for (let i = 0; i < this._data.length; i++) {
const item = this._data[i]
arr.push({
value: item[this._valueField],
text: item[this._textField],
selected: typeof item.selected === 'boolean' ? item.selected : false
})
}
return arr
} else {
return null
}
}
/**
* Handles Arrow up & Down. Selection of an option is also possible with these keys.
* @param { Event } event
* @private
*/
_handleArrows (event) {
if (event.keyCode === 40 || event.keyCode === 38) {
event.preventDefault()
const isOpen = this._domElements.optionsContainer.classList.contains('visible')
// An updated view of the container is needed because of the filtering option
const optionsContainerList = this._multiselect.querySelector('.multiselect__options > ul')
if (!isOpen) {
this._domElements.optionsContainer.classList.add('visible')
this._domElements.optionsContainer.setAttribute('style', style.multiselect__options_visible)
optionsContainerList.firstElementChild.classList.add('arrow-selected')
optionsContainerList.firstElementChild.setAttribute('style', style.multiselect__options_ul_li_arrow_selected)
optionsContainerList.firstElementChild.scrollIntoView(false)
} else {
let selected = this._multiselect.querySelector('.multiselect__options ul li.arrow-selected')
const action = {
ArrowUp: 'previous',
Up: 'previous',
ArrowDown: 'next',
Down: 'next'
}
if (!selected) {
optionsContainerList.firstElementChild.classList.add('arrow-selected')
optionsContainerList.firstElementChild.setAttribute('style', style.multiselect__options_ul_li_arrow_selected)
optionsContainerList.firstElementChild.scrollIntoView(false)
return
}
selected.classList.remove('arrow-selected')
selected.setAttribute('style', style.multiselect__options_ul_li)
selected = selected[action[event.key] + 'ElementSibling']
// Go to start or end of the popup list
if (!selected) {
selected =
optionsContainerList.children[action[event.key] === 'next' ? 0 : optionsContainerList.children.length - 1]
selected.classList.add('arrow-selected')
selected.setAttribute('style', style.multiselect__options_ul_li_arrow_selected)
this._scrollIntoView(optionsContainerList, selected)
return
}
selected.classList.add('arrow-selected')
selected.setAttribute('style', style.multiselect__options_ul_li_arrow_selected)
this._scrollIntoView(optionsContainerList, selected)
}
}
}
/**
* Handles the backspace key event - Deletes the preceding option in the selection list.
* @param { Event } e
* @private
*/
_handleBackspace (e) {
if (e.keyCode === 8 && e.target.value === '') {
const lastSelectedOption =
this._selectedOptions.length > 0 ? this._selectedOptions[this._selectedOptions.length - 1] : null
if (lastSelectedOption) {
const targetLastSelectedOption = this._multiselect.querySelector(
`li[data-value="${lastSelectedOption.value}"]`
)
this._handleOption(targetLastSelectedOption)
if (this._selectedOptions.length === 0) {
this._domElements.optionsContainer.classList.remove('visible')
this._domElements.optionsContainer.setAttribute('style', style.multiselect__options)
}
}
}
}
/**
* Shows clear selection button if some options are selected.
* @private
*/
_handleClearSelectionBtn () {
if (this._selectedOptions.length > 0) {
this._domElements.clear.style.display = 'flex'
} else {
this._domElements.clear.style.display = 'none'
}
}
/**
* Handles the enter key event.
* @param { Event } event
* @private
*/
_handleEnter (event) {
if (event.keyCode === 13) {
const selected = this._multiselect.querySelector('.multiselect__options ul li.arrow-selected')
if (selected) {
this._handleOption(selected)
this._closeList()
}
}
}
_handleOption (target, dispatchEvent = true) {
// Remove
for (let i = 0; i < this._selectedOptions.length; i++) {
const el = this._selectedOptions[i]
if (el.value === target.dataset.value) {
target.classList.remove('multiselect__options--selected')
target.setAttribute('style', style.multiselect__options)
this._selectedOptions.splice(i, 1)
this._removeOptionFromList(target.dataset.value)
this._handleClearSelectionBtn()
this._handlePlaceholder()
return (
dispatchEvent &&
this._dispatchEvent({
action: 'REMOVE_OPTION',
value: target.dataset.value,
selection: this._selectedOptions
})
)
}
}
// Add
for (let i = 0; i < this._options.length; i++) {
const option = this._options[i]
if (option.value === target.dataset.value) {
target.classList.add('multiselect__options--selected')
target.setAttribute('style', style.multiselect__options_selected)
this._selectedOptions = [...this._selectedOptions, option]
this._addOptionToList(option, i)
this._handleClearSelectionBtn()
this._handlePlaceholder()
return (
dispatchEvent &&
this._dispatchEvent({
action: 'ADD_OPTION',
value: target.dataset.value,
selection: this._selectedOptions
})
)
}
}
}
/**
* Shows the placeholder if no options are selected.
* @private
*/
_handlePlaceholder () {
this._domElements.input.placeholder = this._placeholder
}
_initSelectedList () {
let hasItemsSelected = false
for (let i = 0; i < this._options.length; i++) {
const option = this._options[i]
if (option.selected) {
hasItemsSelected = true
const target = this._domElements.options.find((el) => el.dataset.value === option.value)
target.classList.add('multiselect__options--selected')
target.setAttribute('style', style.multiselect__options_selected)
this._selectedOptions = [...this._selectedOptions, option]
this._addOptionToList(option, i)
}
}
if (hasItemsSelected) { this._handleClearSelectionBtn() }
this._handlePlaceholder()
}
/**
* Process the custom template.
* @param { string } template
* @private
*/
_processTemplate (template, index) {
let processedTemplate = template
const objAttr = template.match(/\$\{(\w+)\}/g).map((e) => e.replace(/\$\{|\}/g, ''))
for (let i = 0; i < objAttr.length; i++) {
const attr = objAttr[i]
// eslint-disable-next-line no-useless-escape
processedTemplate = processedTemplate.replace(`\$\{${attr}\}`, this._data[index][attr] ?? '')
}
return processedTemplate
}
_removeAllArrowSelected () {
const className = 'arrow-selected'
const target = this._domElements.options.find((el) => el.classList.contains(className))
target && target.classList.remove(className) && target.setAttribute('style', style.multiselect__options_ul_li)
}
/**
* Removes an option from the list.
* @param { string } value
* @private
*/
_removeOptionFromList (value) {
const optionDom = this._multiselect.querySelector(`span[data-value="${value}"]`)
optionDom && optionDom.parentNode && optionDom.parentNode.removeChild(optionDom)
}
/**
* Renders the multiselect options list view.
* @private
*/
_renderOptionsList () {
const html = `
<div class="multiselect__options" style="${style.multiselect__options}">
<ul style="${style.multiselect__options_ul}">
${
this._options.length > 0 && !this._itemTemplate
? this._options
.map((option) => {
return `
<li data-value="${option.value}" style="${style.multiselect__options_ul_li}">${option.text}</li>
`
})
.join('')
: ''
}
${
this._options.length > 0 && this._itemTemplate
? this._options
.map((option, index) => {
return `
<li data-value="${option.value}" style="${style.multiselect__options_ul_li}">${this._processTemplate(this._itemTemplate, index)}</li>
`
})
.join('')
: ''
}
${this._showNoData(this._options.length === 0)}
</ul>
</div>
`
this._multiselect.insertAdjacentHTML('beforeend', html)
}
/**
* Renders the multiselect view.
* @private
*/
_renderMultiselect () {
this._select.style.display = 'none'
const id = 'iconic-' + this._generateId(20)
// Timea created dedicated div element because previous code was not rendering
this._multiselect = document.createElement('div')
this._multiselect.setAttribute('id', id)
this._multiselect.setAttribute('class', 'multiselect__container')
this._multiselect.setAttribute('style', style.multiselect__container)
const html = `
<div class="multiselect__wrapper" style="${style.multiselect__wrapper}">
<input class="multiselect__input" style="${style.multiselect__input}" placeholder="${this._placeholder}" />
</div>
<span style="display: none;" class="multiselect__clear-btn" style="${style.multiselect__clear_btn}">${this._cross}</span>
`
this._multiselect.innerHTML = html
this._selectContainer.appendChild(this._multiselect)
}
/**
* ScrollIntoView - This small utility reproduces the behavior of .scrollIntoView({ block: "nearest", inline: "nearest" })
* This is for IE compatibility without a need of a polyfill
* @private
*/
_scrollIntoView (parent, child) {
const rectParent = parent.getBoundingClientRect()
const rectChild = child.getBoundingClientRect()
// Detect if not visible at top and then scroll to the top
if (!(rectParent.top < rectChild.bottom - child.offsetHeight)) {
parent.scrollTop = child.clientHeight + (child.offsetTop - child.offsetHeight)
}
// Detect if not visible at bottom and then scroll to the bottom
if (!(rectParent.bottom > rectChild.top + child.offsetHeight)) {
parent.scrollTop =
child.clientHeight +
(child.offsetTop - child.offsetHeight) -
(parent.offsetHeight - (child.offsetHeight + (child.offsetHeight - child.clientHeight)))
}
}
/**
* Shows a no data message.
* @param { boolean } condition
* @private
*/
_showNoData (condition) {
return condition ? `<p class="multiselect__options--no-data" style="${style.multiselect__options_ul_p_multiselect__options_no_data}">${this._noData}</p>` : ''
}
/**
* Shows a no results message.
* @param { boolean } condition
* @private
*/
_showNoResults (condition) {
const dom = this._multiselect.querySelector('.multiselect__options--no-results')
if (condition) {
const html = `<p class="multiselect__options--no-results" style="${style.multiselect__options_ul_p_multiselect__options_no_results}">${this._noResults}</p>`
!dom && this._domElements.optionsContainerList.insertAdjacentHTML('beforeend', html)
} else {
dom && dom.parentNode && dom.parentNode.removeChild(dom)
}
}
}