-
Notifications
You must be signed in to change notification settings - Fork 1
/
szn-select--options.js
620 lines (556 loc) · 21.9 KB
/
szn-select--options.js
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
'use strict'
;(global => {
const SznElements = global.SznElements = global.SznElements || {}
const CSS_STYLES = `
%{CSS_STYLES}%
`
class SznSelectOptions {
/**
* Initializes the szn-options element's implementation.
*
* @param {Element} rootElement The root HTML element of this custom element's implementation.
*/
constructor(rootElement) {
rootElement.setOptions = options => this.setOptions(options)
rootElement.updateUi = () => updateUi(this)
/**
* The root HTML element of this custom element's implementation.
*
* @type {Element}
*/
this._root = rootElement
/**
* The container of the options this element provides the UI for.
*
* @type {?HTMLElement}
*/
this._options = null
/**
* The option represented the element over which the user started to drag the mouse cursor to perform a
* multiple-items selection.
*
* This field is used only for multi-selects.
*
* @type {?HTMLOptionElement}
*/
this._dragSelectionStartOption = null
/**
* Flag signalling whether the element is currently mounted.
*
* @type {boolean}
*/
this._mounted = false
/**
* The DOM mutation observer used to observe modifications to the associated options.
*
* @type {MutationObserver}
*/
this._observer = new MutationObserver(rootElement.updateUi)
/**
* The previously used indexes when the <code>scrollToSelection</code> function has been called for this
* instance.
*
* @type {{start: number, end: number}}
* @see scrollToSelection
*/
this._lastSelectionIndexes = {
start: -1,
end: -1,
}
/**
* The indexes of options that are to be selected as well while performing an additive multi-select (dragging the
* mouse over a multi-select while holding the Ctrl key).
*
* @type {Array<number>}
*/
this._additionalSelectedIndexes = []
/**
* Set to <code>true</code> if the user started to drag the mouse pointer over an already selected item while
* holding the Ctrl key. The items selected by the user using the current action will be deselected.
*
* @type {boolean}
*/
this._invertSelection = false
/**
* The index of the option at which the multi-items selection started the last time.
*
* @type {number}
*/
this._previousSelectionStartIndex = -1
/**
* The ID of the current touch being observed for possible option interaction.
*
* @type {?number}
*/
this._observedTouchId = null
this._onItemHovered = event => onItemHovered(this, event.target)
this._onItemClicked = event => onItemClicked(this, event.target)
this._onItemSelectionStart = event => onItemSelectionStart(this, event.target, event)
this._onTouchStart = onTouchStart.bind(null, this)
this._onTouchEnd = onTouchEnd.bind(null, this)
this._onSelectionEnd = () => {
this._dragSelectionStartOption = null
this._additionalSelectedIndexes = []
}
this._onSelectionChange = () => {
this._root.removeAttribute('data-szn-select--options--highlighting')
updateUi(this)
}
SznElements.injectStyles(CSS_STYLES, 'szn-options')
}
onMount() {
this._mounted = true
addEventListeners(this)
updateUi(this)
registerOptionsObserver(this)
if (this._options) {
scrollToSelection(this, this._options.selectedIndex, this._options.selectedIndex)
}
}
onUnmount() {
removeEventListeners(this)
this._root.removeAttribute('data-szn-select--options--highlighting')
this._mounted = false
this._observer.disconnect()
}
/**
* Sets the element containing the options to display in this szn-options element.
*
* @param {HTMLElement} options The element containing the options to display.
*/
setOptions(options) {
if (options === this._options) {
return
}
if (this._options) {
removeEventListeners(this)
this._observer.disconnect()
}
this._options = options
addEventListeners(this)
updateUi(this)
registerOptionsObserver(this)
const selectedIndex = typeof options.selectedIndex === 'number' ? options.selectedIndex : -1
this._previousSelectionStartIndex = selectedIndex
if (this._mounted) {
scrollToSelection(this, selectedIndex, selectedIndex)
}
}
}
/**
* Registers the provided szn-options element's DOM mutation observer to observe the related options for changes.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function registerOptionsObserver(instance) {
if (!instance._mounted || !instance._options) {
return
}
instance._observer.observe(instance._options, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeFilter: ['disabled', 'label', 'selected', 'title', 'multiple'],
})
}
/**
* Registers event listeners that the provided szn-options instance requires to function correctly.
*
* The function has no effect if the provided szn-options element is not mounted into the document or has not been
* provided with its options yet.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function addEventListeners(instance) {
if (!instance._mounted || !instance._options) {
return
}
instance._options.addEventListener('change', instance._onSelectionChange)
instance._root.addEventListener('mouseover', instance._onItemHovered)
instance._root.addEventListener('mousedown', instance._onItemSelectionStart)
instance._root.addEventListener('mouseup', instance._onItemClicked)
instance._root.addEventListener('touchstart', instance._onTouchStart)
addEventListener('mouseup', instance._onSelectionEnd)
addEventListener('touchend', instance._onTouchEnd)
}
/**
* Deregisters all event listeners used by the provided szn-options element.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function removeEventListeners(instance) {
if (instance._options) {
instance._options.removeEventListener('change', instance._onSelectionChange)
}
instance._root.removeEventListener('mouseover', instance._onItemHovered)
instance._root.removeEventListener('mousedown', instance._onItemSelectionStart)
instance._root.removeEventListener('mouseup', instance._onItemClicked)
instance._root.removeEventListener('touchstart', instance._onTouchStart)
removeEventListener('mouseup', instance._onSelectionEnd)
removeEventListener('touchend', instance._onTouchEnd)
}
/**
* @param {SznSelectOptions} instance
* @param {TouchEvent} event
*/
function onTouchStart(instance, event) {
if (instance._observedTouchId) {
return
}
const touch = event.changedTouches[0]
instance._observedTouchId = touch.identifier
}
function onTouchEnd(instance, event) {
if (!instance._options.multiple) {
return
}
const touch = Array.from(event.changedTouches).find(
someTouch => someTouch.identifier === instance._observedTouchId,
)
if (!touch) {
return
}
instance._observedTouchId = null
event.preventDefault() // prevent mouse events
const option = event.target._option
if (!isOptionEnabled(option)) {
return
}
option.selected = !option.selected
instance._options.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}))
}
/**
* Handles the user moving the mouse pointer over an option in the szn-options element's UI. The function updates the
* current multiple-items selection if the element represents a multi-select, or updates the currently highlighted
* item in the UI of a single-select.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element which's area the mouse pointer entered.
*/
function onItemHovered(instance, itemUi) {
if (instance._options.disabled || !isEnabledOptionUi(itemUi)) {
return
}
if (instance._options.multiple) {
if (instance._dragSelectionStartOption) {
updateMultiSelection(instance, itemUi)
}
return
}
instance._root.setAttribute('data-szn-select--options--highlighting', '')
const previouslyHighlighted = instance._root.querySelector('[data-szn-select--options--highlighted]')
if (previouslyHighlighted) {
previouslyHighlighted.removeAttribute('data-szn-select--options--highlighted')
}
itemUi.setAttribute('data-szn-select--options--highlighted', '')
}
/**
* Handles the user releasing the primary mouse button over an element representing an item.
*
* The function ends multiple-items selection for multi-selects, ends options highlighting and marks the the selected
* option for single-selects.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element at which the user released the primary mouse button.
*/
function onItemClicked(instance, itemUi) {
if (instance._dragSelectionStartOption) { // multi-select
instance._dragSelectionStartOption = null
return
}
if (instance._options.disabled || !isEnabledOptionUi(itemUi)) {
return
}
instance._root.removeAttribute('data-szn-select--options--highlighting')
instance._options.selectedIndex = itemUi._option.index
instance._options.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}))
}
/**
* Handles start of the user dragging the mouse pointer over the UI of a multi-selection szn-options element. The
* function marks the starting item.
*
* The function marks the starting item used previously as the current starting item if the Shift key is pressed. The
* function marks the indexes of the currently selected items if the Ctrl key is pressed and the Shift key is not.
* Also, if the Ctrl key pressed, the Shift key is not, and the user starts at an already selected item, the function
* will mark this as inverted selection.
*
* The function has no effect for single-selects.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element at which the user pressed the primary mouse button down.
* @param {MouseEvent} event The mouse event representing the user's action.
*/
function onItemSelectionStart(instance, itemUi, event) {
if (instance._options.disabled || !instance._options.multiple || !isEnabledOptionUi(itemUi)) {
return
}
const options = instance._options.options
if (event.shiftKey && instance._previousSelectionStartIndex > -1) {
instance._dragSelectionStartOption = options.item(instance._previousSelectionStartIndex)
} else {
if (event.ctrlKey) {
instance._additionalSelectedIndexes = []
for (let i = 0, length = options.length; i < length; i++) {
if (options.item(i).selected) {
instance._additionalSelectedIndexes.push(i)
}
}
instance._invertSelection = itemUi._option.selected
} else {
instance._invertSelection = false
}
instance._dragSelectionStartOption = itemUi._option
}
instance._previousSelectionStartIndex = instance._dragSelectionStartOption.index
updateMultiSelection(instance, itemUi)
}
/**
* Scrolls, only if necessary, the UI of the provided szn-options element to make the last selected option visible.
* Which option is the last selected one is determined by comparing the provided index with the indexes passed to the
* previous call of this function.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {number} selectionStartIndex The index of the first selected option. The index must be a non-negative
* integer and cannot be greater that the total number of options; or set to <code>-1</code> if there is no
* option currently selected.
* @param {number} selectionEndIndex The index of the last selected option. The index must be a non-negative integer,
* cannot be greater than the total number of options and must not be lower than the
* <code>selectionStartIndex</code>; or set to <code>-1</code> if there is no option currently selected.
*/
function scrollToSelection(instance, selectionStartIndex, selectionEndIndex) {
const lastSelectionIndexes = instance._lastSelectionIndexes
if (
selectionStartIndex !== -1 &&
(selectionStartIndex !== lastSelectionIndexes.start || selectionEndIndex !== lastSelectionIndexes.end)
) {
const changedIndex = selectionStartIndex !== lastSelectionIndexes.start ? selectionStartIndex : selectionEndIndex
scrollToOption(instance, changedIndex)
}
lastSelectionIndexes.start = selectionStartIndex
lastSelectionIndexes.end = selectionEndIndex
}
/**
* Scrolls, only if necessary, the UI of the provided szn-options element to make the option at the specified index
* fully visible.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {number} optionIndex The index of the option to select. The index must be a non-negative integer and cannot
* be greater than the total number of options.
*/
function scrollToOption(instance, optionIndex) {
const ui = instance._root
if (ui.clientHeight >= ui.scrollHeight) {
return
}
const uiBounds = ui.getBoundingClientRect()
const options = instance._root.querySelectorAll('[data-szn-select--options--option]')
const optionBounds = options[optionIndex].getBoundingClientRect()
if (optionBounds.top >= uiBounds.top && optionBounds.bottom <= uiBounds.bottom) {
return
}
const delta = optionBounds.top < uiBounds.top ?
optionBounds.top - uiBounds.top
:
optionBounds.bottom - uiBounds.bottom
ui.scrollTop += delta
}
/**
* Updates the multiple-items selection. This function is meant to be used with multi-selects when the user is
* selecting multiple items by dragging the mouse pointer over them.
*
* Any item which's index is in the provided instance's list of additionally selected items will be marked as
* selected as well.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} lastHoveredItem The element representing the UI of the last option the user has hovered using
* their mouse pointer.
*/
function updateMultiSelection(instance, lastHoveredItem) {
const startIndex = instance._dragSelectionStartOption.index
const lastIndex = lastHoveredItem._option.index
const minIndex = Math.min(startIndex, lastIndex)
const maxIndex = Math.max(startIndex, lastIndex)
const options = instance._options.options
const additionalIndexes = instance._additionalSelectedIndexes
for (let i = 0, length = options.length; i < length; i++) {
const option = options.item(i)
if (isOptionEnabled(option)) {
let isOptionSelected = additionalIndexes.indexOf(i) > -1
if (i >= minIndex && i <= maxIndex) {
isOptionSelected = !instance._invertSelection
}
option.selected = isOptionSelected
}
}
instance._options.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}))
}
/**
* Tests whether the provided elements represents the UI of an enabled option.
*
* @param {Element} optionUi The UI element to test.
* @return {boolean} <code>true</code> iff the option is enabled and can be interacted with.
* @see isOptionEnabled
*/
function isEnabledOptionUi(optionUi) {
return (
optionUi.hasAttribute('data-szn-select--options--option') &&
isOptionEnabled(optionUi._option)
)
}
/**
* Tests whether the provided option is enabled - it is not disabled nor it is a child of a disabled options group.
* The provided option cannot be an orphan.
*
* @param {HTMLOptionElement} option The option element to test.
* @return {boolean} <code>true</code> iff the option is enabled and can be interacted with.
*/
function isOptionEnabled(option) {
return (
!option.disabled &&
!option.parentNode.disabled
)
}
/**
* Updates the UI, if the provided szn-options element has already been provided with the options to display. The
* functions synchronizes the displayed UI to reflect the available options, their status, and scrolls to the last
* selected option if it is not visible.
*
* @param {SznSelectOptions} instance The szn-options element's instance.
*/
function updateUi(instance) {
if (!instance._options) {
return
}
if (instance._options.disabled) {
instance._root.setAttribute('disabled', '')
} else {
instance._root.removeAttribute('disabled')
}
if (instance._options.multiple) {
instance._root.setAttribute('data-szn-select--options--multiple', '')
} else {
instance._root.removeAttribute('data-szn-select--options--multiple')
}
updateGroupUi(instance._root, instance._options)
if (instance._mounted) {
const options = instance._options.options
let lastSelectedIndex = -1
for (let i = options.length - 1; i >= 0; i--) {
if (options.item(i).selected) {
lastSelectedIndex = i
break
}
}
scrollToSelection(instance, instance._options.selectedIndex, lastSelectedIndex)
}
}
/**
* Updates the contents of the provided UI to reflect the options in the provided options container. The function
* removes removed options from the UI, updates the existing and adds the missing ones.
*
* @param {Element} uiContainer The element containing the constructed UI reflecting the provided options.
* @param {HTMLElement} optionsGroup The element containing the options to be reflected in the UI.
*/
function updateGroupUi(uiContainer, optionsGroup) {
removeRemovedItems(uiContainer, optionsGroup)
updateExistingItems(uiContainer)
addMissingItems(uiContainer, optionsGroup)
}
/**
* Removes UI items from the UI that have been representing the options and option groups that have been removed from
* the provided container of options.
*
* @param {Element} uiContainer The element containing the elements reflecting the provided options and providing the
* UI for the options.
* @param {HTMLElement} optionsGroup The element containing the options for which this szn-options element is
* providing the UI.
*/
function removeRemovedItems(uiContainer, optionsGroup) {
const options = Array.prototype.slice.call(optionsGroup.children)
let currentItemUi = uiContainer.firstElementChild
while (currentItemUi) {
if (options.indexOf(currentItemUi._option) > -1) {
currentItemUi = currentItemUi.nextElementSibling
continue
}
const itemToRemove = currentItemUi
currentItemUi = currentItemUi.nextElementSibling
uiContainer.removeChild(itemToRemove)
}
}
/**
* Updates all items in the provided UI container to reflect the current state of their associated options.
*
* @param {Element} groupUi The element containing the elements representing the UIs of the options.
*/
function updateExistingItems(groupUi) {
let itemUi = groupUi.firstElementChild
while (itemUi) {
updateItem(itemUi)
itemUi = itemUi.nextElementSibling
}
}
/**
* Updates the UI item to reflect the current state of its associated <code>option</code>/<code>optgroup</code>
* element.
*
* If the element represents an option group (<code>optgroup</code>), the children options will be updated as well.
*
* @param {HTMLElement} itemUi The element representing the UI of an <code>option</code>/<code>optgroup</code>
* element.
*/
function updateItem(itemUi) {
const option = itemUi._option
if (option.disabled) {
itemUi.setAttribute('disabled', '')
} else {
itemUi.removeAttribute('disabled')
}
if (option.tagName === 'OPTGROUP') {
updateGroupUi(itemUi, option)
itemUi.setAttribute('data-szn-select--options--optgroup-label', option.label)
return
}
itemUi.innerText = option.text
if (option.title) {
itemUi.setAttribute('title', option.title)
} else {
itemUi.removeAttribute('title')
}
if (option.selected) {
itemUi.setAttribute('data-szn-select--options--selected', '')
} else {
itemUi.removeAttribute('data-szn-select--options--selected')
}
}
/**
* Adds the options present in the options container missing the UI into the UI, while preserving the order of the
* options. Option groups are added recursively.
*
* @param {Element} groupUi The element containing the UIs of the options. The new options will be inserted into this
* element's children.
* @param {HTMLElement} options An element containing the <code>option</code> and <code>optgroup</code> elements that
* the UI reflects.
*/
function addMissingItems(groupUi, options) {
let nextItemUi = groupUi.firstElementChild
let nextOption = options.firstElementChild
while (nextOption) {
if (!nextItemUi || nextItemUi._option !== nextOption) {
const newItemUi = document.createElement('szn-')
newItemUi._option = nextOption
newItemUi.setAttribute(
'data-szn-select--options--' + (nextOption.tagName === 'OPTGROUP' ? 'optgroup' : 'option'),
'',
)
updateItem(newItemUi)
groupUi.insertBefore(newItemUi, nextItemUi)
} else {
nextItemUi = nextItemUi && nextItemUi.nextElementSibling
}
nextOption = nextOption.nextElementSibling
}
}
SznElements['szn-select--options'] = SznSelectOptions
})(self)