slim-select 3.4.2 → 3.5.0

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.
Files changed (73) hide show
  1. package/dist/classes.d.ts +46 -0
  2. package/dist/helpers.d.ts +5 -0
  3. package/dist/index.d.ts +53 -318
  4. package/dist/index.umd.d.ts +2 -0
  5. package/dist/react/index.d.ts +4 -318
  6. package/dist/react/index.js +1459 -1696
  7. package/dist/react/index.umd.js +2 -2
  8. package/dist/react/react.d.ts +17 -0
  9. package/dist/react/react.test.d.ts +1 -0
  10. package/dist/react/slim-select/react/index.d.ts +4 -0
  11. package/dist/render.d.ts +88 -0
  12. package/dist/select.d.ts +38 -0
  13. package/dist/settings.d.ts +39 -0
  14. package/dist/slimselect.cjs.js +2 -2
  15. package/dist/slimselect.cjs.js.map +1 -1
  16. package/dist/slimselect.css +1 -1
  17. package/dist/slimselect.css.map +1 -1
  18. package/dist/slimselect.es.js +1233 -1384
  19. package/dist/slimselect.es.js.map +1 -1
  20. package/dist/slimselect.iife.js +2 -2
  21. package/dist/slimselect.iife.js.map +1 -1
  22. package/dist/slimselect.js +2 -2
  23. package/dist/slimselect.js.map +1 -1
  24. package/dist/src/docs/main.d.ts +0 -0
  25. package/dist/src/docs/router.d.ts +2 -0
  26. package/dist/src/docs/store.d.ts +12 -0
  27. package/dist/store.d.ts +53 -0
  28. package/dist/vue/index.d.ts +4 -318
  29. package/dist/vue/index.js +1335 -1503
  30. package/dist/vue/index.umd.js +1 -1
  31. package/dist/vue/slim-select/vue/index.d.ts +4 -0
  32. package/package.json +30 -36
  33. package/src/slim-select/slimselect.scss +34 -5
  34. package/.nvmrc +0 -1
  35. package/.prettierignore +0 -1
  36. package/.prettierrc +0 -7
  37. package/CODE_OF_CONDUCT.md +0 -46
  38. package/CONTRIBUTING.md +0 -1
  39. package/cdn.html +0 -21
  40. package/docs.go +0 -12
  41. package/example.html +0 -25
  42. package/index.html +0 -76
  43. package/release.js +0 -191
  44. package/sitemap.js +0 -108
  45. package/src/slim-select/accessibility.test.ts +0 -782
  46. package/src/slim-select/classes.test.ts +0 -73
  47. package/src/slim-select/classes.ts +0 -117
  48. package/src/slim-select/helpers.test.ts +0 -162
  49. package/src/slim-select/helpers.ts +0 -61
  50. package/src/slim-select/index.test.ts +0 -1356
  51. package/src/slim-select/index.ts +0 -594
  52. package/src/slim-select/index.umd.ts +0 -8
  53. package/src/slim-select/react/index.tsx +0 -9
  54. package/src/slim-select/react/react.test.tsx +0 -498
  55. package/src/slim-select/react/react.tsx +0 -209
  56. package/src/slim-select/render.test.ts +0 -1877
  57. package/src/slim-select/render.ts +0 -1765
  58. package/src/slim-select/select.test.ts +0 -683
  59. package/src/slim-select/select.ts +0 -659
  60. package/src/slim-select/settings.test.ts +0 -285
  61. package/src/slim-select/settings.ts +0 -78
  62. package/src/slim-select/store.test.ts +0 -1007
  63. package/src/slim-select/store.ts +0 -433
  64. package/src/slim-select/vue/index.ts +0 -9
  65. package/src/slim-select/vue/vue.test.ts +0 -1091
  66. package/src/slim-select/vue/vue.vue +0 -224
  67. package/tsconfig.json +0 -26
  68. package/vite.config.docs.mts +0 -29
  69. package/vite.config.lib.es-cjs.ts +0 -40
  70. package/vite.config.lib.umd-iife.ts +0 -34
  71. package/vite.config.react.mts +0 -41
  72. package/vite.config.vue.mts +0 -40
  73. package/vitest.config.ts +0 -30
@@ -1,1765 +0,0 @@
1
- import { debounce } from './helpers'
2
- import Settings from './settings'
3
- import Store, { Optgroup, Option } from './store'
4
- import CssClasses from './classes'
5
-
6
- export interface Callbacks {
7
- open: () => void
8
- close: () => void
9
- addable?: (
10
- value: string
11
- ) => Promise<Partial<Option> | string> | Partial<Option> | string | false | undefined | null | Error
12
- setSelected: (value: string | string[], runAfterChange: boolean) => void
13
- addOption: (option: Option) => void
14
- search: (search: string) => void
15
- beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void
16
- afterChange?: (newVal: Option[]) => void
17
- }
18
-
19
- export interface Main {
20
- main: HTMLDivElement
21
- values: HTMLDivElement
22
- deselect: {
23
- main: HTMLDivElement
24
- svg: SVGSVGElement
25
- path: SVGPathElement
26
- }
27
- arrow: {
28
- main: SVGSVGElement
29
- path: SVGPathElement
30
- }
31
- }
32
-
33
- export interface Content {
34
- main: HTMLDivElement
35
- search: Search
36
- list: HTMLDivElement
37
- }
38
-
39
- export interface Search {
40
- main: HTMLDivElement
41
- input: HTMLInputElement
42
- addable?: {
43
- main: HTMLDivElement
44
- svg: SVGSVGElement
45
- path: SVGPathElement
46
- }
47
- }
48
-
49
- export default class Render {
50
- public settings: Settings
51
- public store: Store
52
- public callbacks: Callbacks
53
-
54
- // Used to compute the range selection
55
- private lastSelectedOption: Option | null
56
-
57
- // Timeout tracking for cleanup
58
- private closeAnimationTimeout: ReturnType<typeof setTimeout> | null = null
59
-
60
- // Elements
61
- public main: Main
62
- public content: Content
63
-
64
- // Classes
65
- public classes: CssClasses
66
-
67
- constructor(settings: Required<Settings>, classes: Required<CssClasses>, store: Store, callbacks: Callbacks) {
68
- this.store = store
69
- this.settings = settings
70
- this.classes = classes
71
- this.callbacks = callbacks
72
- this.lastSelectedOption = null
73
-
74
- this.main = this.mainDiv()
75
- this.content = this.contentDiv()
76
-
77
- // Add classes and styles to main/content
78
- this.updateClassStyles()
79
- this.updateAriaAttributes()
80
-
81
- // Position content off-screen initially to prevent scrollbars
82
- // This must be after updateClassStyles() since it removes all style attributes
83
- // The main element isn't in the DOM yet, so getBoundingClientRect() would return zeros
84
- // Once opened, moveContent() will position it correctly
85
- if (this.settings.contentPosition !== 'relative') {
86
- this.content.main.style.top = '-9999px'
87
- this.content.main.style.left = '-9999px'
88
- this.content.main.style.margin = '0'
89
- this.content.main.style.width = 'auto'
90
- }
91
-
92
- // Add content to the content location settings
93
- if (this.settings.contentLocation) {
94
- this.settings.contentLocation.appendChild(this.content.main)
95
- }
96
- }
97
-
98
- // Helper method to add classes that may contain spaces
99
- // Splits by spaces and adds each class individually to avoid DOMException
100
- private addClasses(element: HTMLElement | SVGElement, classValue: string): void {
101
- if (!classValue || classValue.trim() === '') {
102
- return
103
- }
104
- const classes = classValue.split(' ').filter((c) => c.trim() !== '')
105
- for (const cls of classes) {
106
- element.classList.add(cls.trim())
107
- }
108
- }
109
-
110
- // Helper method to remove classes that may contain spaces
111
- private removeClasses(element: HTMLElement | SVGElement, classValue: string): void {
112
- if (!classValue || classValue.trim() === '') {
113
- return
114
- }
115
- const classes = classValue.split(' ').filter((c) => c.trim() !== '')
116
- for (const cls of classes) {
117
- element.classList.remove(cls.trim())
118
- }
119
- }
120
-
121
- // Remove disabled classes
122
- public enable(): void {
123
- // Remove disabled class
124
- this.removeClasses(this.main.main, this.classes.disabled)
125
- this.main.main.setAttribute('aria-disabled', 'false')
126
-
127
- // Set search input to "enabled"
128
- this.content.search.input.disabled = false
129
- }
130
-
131
- // Set disabled classes
132
- public disable(): void {
133
- // Add disabled class
134
- this.addClasses(this.main.main, this.classes.disabled)
135
- this.main.main.setAttribute('aria-disabled', 'true')
136
-
137
- // Set search input to disabled
138
- this.content.search.input.disabled = true
139
- }
140
-
141
- public open(): void {
142
- this.main.arrow.path.setAttribute('d', this.classes.arrowOpen)
143
- this.main.main.setAttribute('aria-expanded', 'true')
144
-
145
- // Clear any pending close animation timeout to prevent race conditions
146
- if (this.closeAnimationTimeout) {
147
- clearTimeout(this.closeAnimationTimeout)
148
- this.closeAnimationTimeout = null
149
- }
150
-
151
- // Set direction class on both main and content (persists, never removed)
152
- const isAbove = this.settings.openPosition === 'up'
153
- const dirClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
154
- this.addClasses(this.main.main, dirClass)
155
- this.addClasses(this.content.main, dirClass)
156
-
157
- // Add open class to content to trigger open animation
158
- this.addClasses(this.content.main, this.classes.contentOpen)
159
-
160
- // Make search visible to screen readers when opened
161
- this.content.search.input.removeAttribute('aria-hidden')
162
-
163
- // move the content in to the right location
164
- this.moveContent()
165
-
166
- // Move to last selected option
167
- const selectedOptions = this.store.getSelectedOptions()
168
- if (selectedOptions.length) {
169
- const selectedId = selectedOptions[selectedOptions.length - 1].id
170
- const selectedOption = this.content.list.querySelector('[data-id="' + selectedId + '"]') as HTMLElement
171
- if (selectedOption) {
172
- this.ensureElementInView(this.content.list, selectedOption)
173
- }
174
- }
175
- }
176
-
177
- public close(): void {
178
- this.main.main.setAttribute('aria-expanded', 'false')
179
- this.main.arrow.path.setAttribute('d', this.classes.arrowClose)
180
-
181
- // Remove open class from content to trigger close animation
182
- // Direction class (dirAbove/dirBelow) persists to maintain correct transform-origin
183
- this.removeClasses(this.content.main, this.classes.contentOpen)
184
-
185
- // Hide search from screen readers when closed
186
- this.content.search.input.setAttribute('aria-hidden', 'true')
187
-
188
- // Clear active descendant when closed
189
- this.main.main.removeAttribute('aria-activedescendant')
190
-
191
- // Remove direction class from main and content after animation is complete
192
- const animationTiming = this.getAnimationTiming()
193
- this.closeAnimationTimeout = setTimeout(() => {
194
- this.removeClasses(this.main.main, this.classes.dirAbove)
195
- this.removeClasses(this.main.main, this.classes.dirBelow)
196
- this.removeClasses(this.content.main, this.classes.dirAbove)
197
- this.removeClasses(this.content.main, this.classes.dirBelow)
198
- this.closeAnimationTimeout = null
199
- }, animationTiming)
200
- }
201
-
202
- private getAnimationTiming(): number {
203
- const computedStyle = getComputedStyle(this.content.main)
204
- const cssValue = computedStyle.getPropertyValue('--ss-animation-timing').trim()
205
-
206
- if (cssValue) {
207
- // Parse CSS time value (e.g., "0.2s" or "200ms")
208
- if (cssValue.endsWith('ms')) {
209
- return parseFloat(cssValue)
210
- } else if (cssValue.endsWith('s')) {
211
- return parseFloat(cssValue) * 1000
212
- }
213
- }
214
-
215
- // Fall back to default 200ms
216
- return 200
217
- }
218
-
219
- public updateClassStyles(): void {
220
- // Clear all classes and styles
221
- this.main.main.className = ''
222
- this.main.main.removeAttribute('style')
223
- this.content.main.className = ''
224
- this.content.main.removeAttribute('style')
225
-
226
- // Make sure main/content has its base class
227
- this.addClasses(this.main.main, this.classes.main)
228
- this.addClasses(this.content.main, this.classes.content)
229
-
230
- // Add styles
231
- if (this.settings.style !== '') {
232
- this.main.main.style.cssText = this.settings.style
233
- this.content.main.style.cssText = this.settings.style
234
- }
235
-
236
- // Add classes
237
- if (this.settings.class.length) {
238
- for (const c of this.settings.class) {
239
- if (c.trim() !== '') {
240
- this.main.main.classList.add(c.trim())
241
- this.content.main.classList.add(c.trim())
242
- }
243
- }
244
- }
245
-
246
- // Misc classes
247
- // Add content position class
248
- if (this.settings.contentPosition === 'relative' || this.settings.contentPosition === 'fixed') {
249
- this.content.main.classList.add('ss-' + this.settings.contentPosition)
250
- }
251
- }
252
-
253
- public updateAriaAttributes() {
254
- const listboxId = this.content.list.id
255
-
256
- // Main combobox
257
- this.main.main.role = 'combobox'
258
- this.main.main.setAttribute('aria-haspopup', 'listbox')
259
- this.main.main.setAttribute('aria-controls', listboxId)
260
- this.main.main.setAttribute('aria-expanded', 'false')
261
-
262
- this.content.list.setAttribute('role', 'listbox')
263
- this.content.list.setAttribute('aria-label', this.settings.ariaLabel + ' listbox')
264
-
265
- // Add aria-multiselectable for multiple selects
266
- if (this.settings.isMultiple) {
267
- this.content.list.setAttribute('aria-multiselectable', 'true')
268
- }
269
-
270
- // Search input should also control the listbox
271
- this.content.search.input.setAttribute('aria-controls', listboxId)
272
- }
273
-
274
- public mainDiv(): Main {
275
- // Create main container
276
- const main = document.createElement('div')
277
-
278
- // Add id to data-id
279
- main.dataset.id = this.settings.id
280
- // main.id = this.settings.id+'-main' // Remove for now as it is not needed and add duplicate id errors
281
-
282
- // Add label
283
- main.setAttribute('aria-label', this.settings.ariaLabel)
284
-
285
- // Set tabable to allow tabbing to the element
286
- main.tabIndex = 0
287
-
288
- // Deal with keyboard events on the main div
289
- // This is to allow for normal selecting
290
- // when you may not have a search bar
291
- main.onkeydown = (e: KeyboardEvent): boolean => {
292
- // Convert above if else statemets to switch
293
- switch (e.key) {
294
- case 'ArrowUp':
295
- case 'ArrowDown':
296
- this.callbacks.open()
297
- e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
298
- return false
299
- case 'Tab':
300
- this.callbacks.close()
301
- return true // Continue doing normal tabbing
302
- case 'Enter':
303
- case ' ':
304
- this.callbacks.open()
305
- const highlighted = this.content.list.querySelector(
306
- '.' + this.classes.getFirst('highlighted')
307
- ) as HTMLDivElement
308
- if (highlighted) {
309
- highlighted.click()
310
- }
311
- return false
312
- case 'Escape':
313
- this.callbacks.close()
314
- return false
315
- }
316
-
317
- // Check if they type a-z, A-Z and 0-9
318
- if (e.key.length === 1) {
319
- this.callbacks.open()
320
- }
321
-
322
- return true
323
- }
324
-
325
- // Add onclick for main div
326
- main.onclick = (e: Event): void => {
327
- // Dont do anything if disabled
328
- if (this.settings.disabled) {
329
- return
330
- }
331
-
332
- this.settings.isOpen ? this.callbacks.close() : this.callbacks.open()
333
- }
334
-
335
- // Add values
336
- const values = document.createElement('div')
337
- this.addClasses(values, this.classes.values)
338
- main.appendChild(values)
339
-
340
- // Add deselect
341
- const deselect = document.createElement('div')
342
- this.addClasses(deselect, this.classes.deselect)
343
-
344
- // Check if deselect is to be shown or not
345
- const selectedOptions = this.store?.getSelectedOptions()
346
- if (!this.settings.allowDeselect || (this.settings.isMultiple && selectedOptions && selectedOptions.length <= 0)) {
347
- this.addClasses(deselect, this.classes.hide)
348
- } else {
349
- this.removeClasses(deselect, this.classes.hide)
350
- }
351
-
352
- // Add deselect onclick event
353
- deselect.onclick = (e: Event) => {
354
- e.stopPropagation()
355
-
356
- // Dont do anything if disabled
357
- if (this.settings.disabled) {
358
- return
359
- }
360
-
361
- // By Default we will delete
362
- let shouldDelete = true
363
- const before = this.store.getSelectedOptions()
364
- const after = [] as Option[]
365
-
366
- // Add beforeChange callback
367
- if (this.callbacks.beforeChange) {
368
- shouldDelete = this.callbacks.beforeChange(after, before) === true
369
- }
370
-
371
- if (shouldDelete) {
372
- if (this.settings.isMultiple) {
373
- this.callbacks.setSelected([], false)
374
- this.updateDeselectAll()
375
- } else {
376
- // Get first option and set it as selected
377
- const firstOption = this.store.getFirstOption()
378
- const id = firstOption ? firstOption.id : ''
379
-
380
- this.callbacks.setSelected(id, false)
381
- }
382
-
383
- // Check if we need to close the dropdown
384
- if (this.settings.closeOnSelect) {
385
- this.callbacks.close()
386
- }
387
-
388
- // Run afterChange callback
389
- if (this.callbacks.afterChange) {
390
- this.callbacks.afterChange(this.store.getSelectedOptions())
391
- }
392
- }
393
- }
394
-
395
- // Add deselect svg
396
- const deselectSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
397
- deselectSvg.setAttribute('viewBox', '0 0 100 100')
398
- const deselectPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
399
- deselectPath.setAttribute('d', this.classes.deselectPath)
400
- deselectSvg.appendChild(deselectPath)
401
- deselect.appendChild(deselectSvg)
402
- main.appendChild(deselect)
403
-
404
- // Add arrow
405
- const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
406
- this.addClasses(arrow, this.classes.arrow)
407
- arrow.setAttribute('viewBox', '0 0 100 100')
408
- const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
409
- arrowPath.setAttribute('d', this.classes.arrowClose)
410
- if (this.settings.alwaysOpen) {
411
- this.addClasses(arrow, this.classes.hide)
412
- }
413
- arrow.appendChild(arrowPath)
414
- main.appendChild(arrow)
415
-
416
- return {
417
- main: main,
418
- values: values,
419
- deselect: {
420
- main: deselect,
421
- svg: deselectSvg,
422
- path: deselectPath
423
- },
424
- arrow: {
425
- main: arrow,
426
- path: arrowPath
427
- }
428
- }
429
- }
430
-
431
- public mainFocus(eventType: string | null): void {
432
- // Trigger focus but dont scroll to it
433
- // Need for prevent refocus the element if event is not keyboard event.
434
- // For example if event is mouse click or tachpad click this condition prevent refocus on element
435
- // because click by mouse change focus position and not need return focus to element.
436
- if (eventType !== 'click') {
437
- this.main.main.focus({ preventScroll: true })
438
- }
439
- }
440
-
441
- public placeholder(): HTMLDivElement {
442
- // Figure out if there is a placeholder option
443
- const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
444
-
445
- // If there is a placeholder option use that
446
- // If placeholder has an html value, use that
447
- // If placeholder has a text, use that
448
- // If nothing is set, use the placeholder text
449
- let placeholderText = this.settings.placeholderText
450
- if (placeholderOption.length) {
451
- if (placeholderOption[0].html !== '') {
452
- placeholderText = placeholderOption[0].html
453
- } else if (placeholderOption[0].text !== '') {
454
- placeholderText = placeholderOption[0].text
455
- }
456
- }
457
-
458
- // Create placeholder div
459
- const placeholder = document.createElement('div')
460
- this.addClasses(placeholder, this.classes.placeholder)
461
- placeholder.innerHTML = placeholderText
462
- return placeholder
463
- }
464
-
465
- // Get selected values and append to multiSelected values container
466
- // and remove those who shouldnt exist
467
- public renderValues(): void {
468
- // If single select set placeholder or selected value
469
- if (!this.settings.isMultiple) {
470
- this.renderSingleValue()
471
- return
472
- }
473
-
474
- this.renderMultipleValues()
475
-
476
- this.updateDeselectAll()
477
- }
478
-
479
- private renderSingleValue(): void {
480
- const selected = this.store.filter((o: Option): boolean => {
481
- return o.selected && !o.placeholder
482
- }, false) as Option[]
483
- const selectedSingle = selected.length > 0 ? selected[0] : null
484
-
485
- // If nothing is seleected use settings placeholder text
486
- if (!selectedSingle) {
487
- this.main.values.innerHTML = this.placeholder().outerHTML
488
- } else {
489
- // Create single value container
490
- const singleValue = document.createElement('div')
491
- this.addClasses(singleValue, this.classes.single)
492
- if (selectedSingle.html) {
493
- singleValue.innerHTML = selectedSingle.html
494
- } else {
495
- singleValue.innerText = selectedSingle.text
496
- }
497
-
498
- // If there is a selected value, set a single div
499
- this.main.values.innerHTML = singleValue.outerHTML
500
- }
501
-
502
- // If allowDeselect is false or selected value is empty just hide deselect
503
- if (!this.settings.allowDeselect || !selected.length) {
504
- this.addClasses(this.main.deselect.main, this.classes.hide)
505
- } else {
506
- this.removeClasses(this.main.deselect.main, this.classes.hide)
507
- }
508
- }
509
-
510
- private renderMultipleValues(): void {
511
- // Get various pieces of data
512
- let currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
513
- let selectedOptions = this.store.filter((opt: Option) => {
514
- // Only grab options that are selected and display is true
515
- return opt.selected && opt.display
516
- }, false) as Option[]
517
-
518
- // If selectedOptions is empty set placeholder
519
- if (selectedOptions.length === 0) {
520
- this.main.values.innerHTML = this.placeholder().outerHTML
521
- return
522
- } else {
523
- // If there is a placeholder, remove it
524
- const placeholder = this.main.values.querySelector('.' + this.classes.getFirst('placeholder'))
525
- if (placeholder) {
526
- placeholder.remove()
527
- }
528
- }
529
-
530
- // If selectedOptions is greater than maxItems, set maxValuesMessage
531
- if (selectedOptions.length > this.settings.maxValuesShown) {
532
- // Creating the element that shows the number of selected items
533
- const singleValue = document.createElement('div')
534
- this.addClasses(singleValue, this.classes.max)
535
- singleValue.textContent = this.settings.maxValuesMessage.replace('{number}', selectedOptions.length.toString())
536
-
537
- // If there is a selected value, set a single div
538
- this.main.values.innerHTML = singleValue.outerHTML
539
- return
540
- } else {
541
- // If there is a message, remove it
542
- const maxValuesMessage = this.main.values.querySelector('.' + this.classes.getFirst('max'))
543
- if (maxValuesMessage) {
544
- maxValuesMessage.remove()
545
- }
546
- }
547
-
548
- // Lets check for data selected order
549
- if (this.settings.keepOrder) {
550
- selectedOptions = this.store.selectedOrderOptions(selectedOptions)
551
- }
552
-
553
- // Loop through currentNodes and only include ones that are not in selectedIDs
554
- let removeNodes: HTMLDivElement[] = []
555
- for (let i = 0; i < currentNodes.length; i++) {
556
- const node = currentNodes[i]
557
- const id = node.getAttribute('data-id')
558
- if (id) {
559
- // Check if id is in selectedOptions
560
- const found = selectedOptions.filter((opt: Option) => {
561
- return opt.id === id
562
- }, false)
563
-
564
- // If not found, add to removeNodes
565
- if (!found.length) {
566
- removeNodes.push(node)
567
- }
568
- }
569
- }
570
-
571
- // Loop through and remove
572
- for (const n of removeNodes) {
573
- this.addClasses(n, this.classes.valueOut)
574
- setTimeout(() => {
575
- if (this.main.values.hasChildNodes() && this.main.values.contains(n)) {
576
- this.main.values.removeChild(n)
577
- }
578
- }, 100)
579
- }
580
-
581
- // Add values that dont currently exist
582
- currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
583
- for (let d = 0; d < selectedOptions.length; d++) {
584
- let shouldAdd = true
585
- for (let i = 0; i < currentNodes.length; i++) {
586
- if (selectedOptions[d].id === String(currentNodes[i].dataset.id)) {
587
- shouldAdd = false
588
- }
589
- }
590
-
591
- // If shouldAdd, insertAdjacentElement it to the values container in the order of the selectedOptions
592
- if (shouldAdd) {
593
- // If keepOrder is true, we will just append it to the end
594
- if (this.settings.keepOrder) {
595
- this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
596
- } else {
597
- // else we will insert it in the order of the selectedOptions
598
- if (currentNodes.length === 0) {
599
- this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
600
- } else if (d === 0) {
601
- this.main.values.insertBefore(this.multipleValue(selectedOptions[d]), currentNodes[d])
602
- } else {
603
- currentNodes[d - 1].insertAdjacentElement('afterend', this.multipleValue(selectedOptions[d]))
604
- }
605
- }
606
- }
607
- }
608
- }
609
-
610
- public multipleValue(option: Option): HTMLDivElement {
611
- const value = document.createElement('div')
612
- this.addClasses(value, this.classes.value)
613
- value.dataset.id = option.id
614
-
615
- const text = document.createElement('div')
616
- this.addClasses(text, this.classes.valueText)
617
- text.textContent = option.text // For multiple values always use text
618
- value.appendChild(text)
619
-
620
- // Only add deletion if the option is not mandatory
621
- if (!option.mandatory) {
622
- // Create delete div element
623
- const deleteDiv = document.createElement('div')
624
- this.addClasses(deleteDiv, this.classes.valueDelete)
625
- deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
626
-
627
- // Add delete onclick event
628
- deleteDiv.onclick = (e: Event) => {
629
- e.preventDefault()
630
- e.stopPropagation()
631
-
632
- // Dont do anything if disabled
633
- if (this.settings.disabled) {
634
- return
635
- }
636
-
637
- // By Default we will delete
638
- let shouldDelete = true
639
- const before = this.store.getSelectedOptions()
640
- const after = before.filter((o) => {
641
- return o.selected && o.id !== option.id
642
- }, true)
643
-
644
- // Check if minSelected is set and if after length so, return
645
- if (this.settings.minSelected && after.length < this.settings.minSelected) {
646
- return
647
- }
648
-
649
- // If there is a beforeDeselect function run it
650
- if (this.callbacks.beforeChange) {
651
- shouldDelete = this.callbacks.beforeChange(after, before) === true
652
- }
653
-
654
- if (shouldDelete) {
655
- // Loop through after and append ids to a variable called selected
656
- let selectedIds: string[] = []
657
- for (const o of after) {
658
- if (o instanceof Optgroup) {
659
- for (const c of o.options) {
660
- if (c.id) {
661
- selectedIds.push(c.id)
662
- }
663
- }
664
- }
665
-
666
- if (o instanceof Option) {
667
- selectedIds.push(o.id)
668
- }
669
- }
670
- this.callbacks.setSelected(selectedIds, false)
671
-
672
- // Check if we need to close the dropdown
673
- if (this.settings.closeOnSelect) {
674
- this.callbacks.close()
675
- }
676
-
677
- // Run afterChange callback
678
- if (this.callbacks.afterChange) {
679
- this.callbacks.afterChange(after)
680
- }
681
-
682
- this.updateDeselectAll()
683
- }
684
- }
685
-
686
- // Add delete svg
687
- const deleteSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
688
- deleteSvg.setAttribute('viewBox', '0 0 100 100')
689
- const deletePath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
690
- deletePath.setAttribute('d', this.classes.optionDelete)
691
- deleteSvg.appendChild(deletePath)
692
- deleteDiv.appendChild(deleteSvg)
693
-
694
- value.appendChild(deleteDiv)
695
-
696
- // Add keydown event listener for keyboard navigation (Enter key)
697
- deleteDiv.onkeydown = (e) => {
698
- if (e.key === 'Enter') {
699
- deleteDiv.click() // Trigger the click event when Enter is pressed
700
- }
701
- }
702
- }
703
-
704
- return value
705
- }
706
-
707
- public contentDiv(): Content {
708
- const main = document.createElement('div')
709
-
710
- // Add id to data-id
711
- main.dataset.id = this.settings.id
712
- // main.id = this.settings.id + '-content' // Remove for now as it is not needed and add duplicate id errors
713
-
714
- // Add search
715
- const search = this.searchDiv()
716
- main.appendChild(search.main)
717
-
718
- // Add list
719
- const list = this.listDiv()
720
- main.appendChild(list)
721
-
722
- return {
723
- main: main,
724
- search: search,
725
- list: list
726
- }
727
- }
728
-
729
- public moveContent(): void {
730
- // If contentPosition is relative, dont move the content anywhere other than below
731
- if (this.settings.contentPosition === 'relative') {
732
- this.moveContentBelow()
733
- return
734
- }
735
-
736
- // If openContent is not auto set content
737
- if (this.settings.openPosition === 'down') {
738
- this.moveContentBelow()
739
- return
740
- } else if (this.settings.openPosition === 'up') {
741
- this.moveContentAbove()
742
- return
743
- }
744
-
745
- // Auto - Determine where to put the content
746
- if (this.putContent() === 'up') {
747
- this.moveContentAbove()
748
- } else {
749
- this.moveContentBelow()
750
- }
751
- }
752
-
753
- public searchDiv(): Search {
754
- const main = document.createElement('div')
755
- const input = document.createElement('input')
756
- const addable = document.createElement('div')
757
- this.addClasses(main, this.classes.search)
758
-
759
- // Setup search return object
760
- const searchReturn: Search = {
761
- main,
762
- input
763
- }
764
-
765
- // We still want the search to be tabable but not shown
766
- if (!this.settings.showSearch) {
767
- this.addClasses(main, this.classes.hide)
768
- input.readOnly = true
769
- }
770
-
771
- input.type = 'search'
772
- input.placeholder = this.settings.searchPlaceholder
773
- input.tabIndex = -1
774
- input.setAttribute('aria-label', this.settings.searchPlaceholder)
775
- input.setAttribute('aria-autocomplete', 'list')
776
- input.setAttribute('autocapitalize', 'off')
777
- input.setAttribute('autocomplete', 'off')
778
- input.setAttribute('autocorrect', 'off')
779
- // Hide from screen readers by default (shown when opened)
780
- input.setAttribute('aria-hidden', 'true')
781
-
782
- input.oninput = debounce((e: Event) => {
783
- this.callbacks.search((e.target as HTMLInputElement).value)
784
- }, 100)
785
-
786
- // Deal with keyboard events on search input field
787
- input.onkeydown = (e: KeyboardEvent): boolean => {
788
- // Convert above if else statemets to switch
789
- switch (e.key) {
790
- case 'ArrowUp':
791
- case 'ArrowDown':
792
- e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
793
- return false
794
- case 'Tab':
795
- // When tabbing close the dropdown
796
- // which will also focus on main div
797
- // and then continuing normal tabbing
798
- this.callbacks.close()
799
- return true // Continue doing normal tabbing
800
- case 'Escape':
801
- this.callbacks.close()
802
- return false
803
- case ' ':
804
- const highlighted = this.content.list.querySelector(
805
- '.' + this.classes.getFirst('highlighted')
806
- ) as HTMLDivElement
807
- if (highlighted) {
808
- highlighted.click()
809
- return false
810
- }
811
- return true
812
- case 'Enter':
813
- // Check if there's a highlighted option first
814
- const highlightedEnter = this.content.list.querySelector(
815
- '.' + this.classes.getFirst('highlighted')
816
- ) as HTMLDivElement
817
- if (highlightedEnter) {
818
- // If an option is highlighted, select it (even if addable is enabled)
819
- highlightedEnter.click()
820
- return false
821
- } else if (this.callbacks.addable) {
822
- // If no option is highlighted and addable is enabled, add new item
823
- addable.click()
824
- return false
825
- }
826
- return true
827
- }
828
-
829
- return true // Allow normal typing
830
- }
831
-
832
- main.appendChild(input)
833
-
834
- // If addable is enabled, add the addable div
835
- if (this.callbacks.addable) {
836
- // Add main class
837
- this.addClasses(addable, this.classes.addable)
838
-
839
- // Add svg icon
840
- const plus = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
841
- plus.setAttribute('viewBox', '0 0 100 100')
842
- const plusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
843
- plusPath.setAttribute('d', this.classes.addablePath)
844
- plus.appendChild(plusPath)
845
- addable.appendChild(plus)
846
-
847
- // Add click event to addable div
848
- addable.onclick = (e: Event) => {
849
- e.preventDefault()
850
- e.stopPropagation()
851
-
852
- // Do nothing if addable is not set
853
- if (!this.callbacks.addable) {
854
- return
855
- }
856
-
857
- // Grab input value
858
- const inputValue = this.content.search.input.value.trim()
859
- if (inputValue === '') {
860
- this.content.search.input.focus()
861
- return
862
- }
863
-
864
- // Run finish will be ran at the end of the addable function.
865
- // Reason its in a function is so we can run it after the
866
- // addable function is done for promise based addables
867
- const runFinish = (oo: Partial<Option>) => {
868
- let newOption = new Option(oo)
869
-
870
- // Call addOption to add the new option
871
- this.callbacks.addOption(newOption)
872
-
873
- // set selected value for single and multiple
874
- if (this.settings.isMultiple) {
875
- let ids = this.store.getSelected()
876
- ids.push(newOption.id)
877
- this.callbacks.setSelected(ids, true)
878
- } else {
879
- this.callbacks.setSelected([newOption.id], true)
880
- }
881
-
882
- // Clear search
883
- this.callbacks.search('')
884
-
885
- // Close it only if closeOnSelect = true
886
- if (this.settings.closeOnSelect) {
887
- setTimeout(() => {
888
- // Give it a little padding for a better looking animation
889
- this.callbacks.close()
890
- }, 100)
891
- }
892
- }
893
-
894
- // Call addable callback
895
- const addableValue = this.callbacks.addable(inputValue)
896
-
897
- // If addableValue is false, undefined or null, do nothing
898
- if (addableValue === false || addableValue === undefined || addableValue === null) {
899
- return
900
- }
901
-
902
- // If addableValue is a promise, wait for it to resolve
903
- if (addableValue instanceof Promise) {
904
- addableValue.then((value) => {
905
- if (typeof value === 'string') {
906
- runFinish({
907
- text: value,
908
- value: value
909
- })
910
- } else if (addableValue instanceof Error) {
911
- this.renderError(addableValue.message)
912
- } else {
913
- runFinish(value)
914
- }
915
- })
916
- } else if (typeof addableValue === 'string') {
917
- runFinish({
918
- text: addableValue,
919
- value: addableValue
920
- })
921
- } else if (addableValue instanceof Error) {
922
- this.renderError(addableValue.message)
923
- } else {
924
- runFinish(addableValue)
925
- }
926
-
927
- return
928
- }
929
- main.appendChild(addable)
930
-
931
- // Add the addable to the search return
932
- searchReturn.addable = {
933
- main: addable,
934
- svg: plus,
935
- path: plusPath
936
- }
937
- }
938
-
939
- return searchReturn
940
- }
941
-
942
- public searchFocus(): void {
943
- this.content.search.input.focus({ preventScroll: true })
944
- }
945
-
946
- public getOptions(notPlaceholder = false, notDisabled = false, notHidden = false): HTMLDivElement[] {
947
- // Put together query string
948
- let query = '.' + this.classes.getFirst('option')
949
- if (notPlaceholder) {
950
- query += ':not(.' + this.classes.getFirst('placeholder') + ')'
951
- }
952
- if (notDisabled) {
953
- query += ':not(.' + this.classes.getFirst('disabled') + ')'
954
- }
955
- if (notHidden) {
956
- query += ':not(.' + this.classes.getFirst('hide') + ')'
957
- }
958
-
959
- return Array.from(this.content.list.querySelectorAll(query))
960
- }
961
-
962
- // highlightUp is used to highlight the previous option in the list
963
- public highlight(dir: 'up' | 'down'): void {
964
- // Get full list of options in list
965
- const options = this.getOptions(true, true, true)
966
-
967
- // If there are no options, do nothing
968
- if (options.length === 0) {
969
- return
970
- }
971
-
972
- // If length is 1, highlight it
973
- if (options.length === 1) {
974
- // Check if option doesnt already have highlighted class
975
- if (!options[0].classList.contains(this.classes.getFirst('highlighted'))) {
976
- this.addClasses(options[0], this.classes.highlighted)
977
- return
978
- }
979
- }
980
-
981
- // Loop through options and see if there are no highlighted ones
982
- let highlighted = false
983
- for (const o of options) {
984
- if (o.classList.contains(this.classes.getFirst('highlighted'))) {
985
- highlighted = true
986
- }
987
- }
988
-
989
- // If no highlighted, see if any are selected and if so highlight selected first one
990
- if (!highlighted) {
991
- for (const o of options) {
992
- if (o.classList.contains(this.classes.getFirst('selected'))) {
993
- this.addClasses(o, this.classes.highlighted)
994
- break
995
- }
996
- }
997
- }
998
-
999
- // Loop through options and find the highlighted one
1000
- for (let i = 0; i < options.length; i++) {
1001
- // Found highlighted option
1002
- if (options[i].classList.contains(this.classes.getFirst('highlighted'))) {
1003
- const prevOption = options[i]
1004
- // Remove highlighted class from current one
1005
- this.removeClasses(prevOption, this.classes.highlighted)
1006
-
1007
- // If previous option has parent classes ss-optgroup with ss-open then click it
1008
- const prevParent = prevOption.parentElement
1009
- if (prevParent && prevParent.classList.contains(this.classes.getFirst('mainOpen'))) {
1010
- const optgroupLabel = prevParent.querySelector('.' + this.classes.getFirst('optgroupLabel')) as HTMLDivElement
1011
- if (optgroupLabel) {
1012
- optgroupLabel.click()
1013
- }
1014
- }
1015
-
1016
- // Highlight the next one
1017
- let selectOption =
1018
- options[dir === 'down' ? (i + 1 < options.length ? i + 1 : 0) : i - 1 >= 0 ? i - 1 : options.length - 1]
1019
- this.addClasses(selectOption, this.classes.highlighted)
1020
- this.ensureElementInView(this.content.list, selectOption)
1021
-
1022
- // Update aria-activedescendant for screen readers
1023
- if (selectOption.id) {
1024
- this.main.main.setAttribute('aria-activedescendant', selectOption.id)
1025
- }
1026
-
1027
- // If selected option has parent classes ss-optgroup with ss-close then click it
1028
- const selectParent = selectOption.parentElement
1029
- if (selectParent && selectParent.classList.contains(this.classes.getFirst('close'))) {
1030
- const optgroupLabel = selectParent.querySelector(
1031
- '.' + this.classes.getFirst('optgroupLabel')
1032
- ) as HTMLDivElement
1033
- if (optgroupLabel) {
1034
- optgroupLabel.click()
1035
- }
1036
- }
1037
-
1038
- return
1039
- }
1040
- }
1041
-
1042
- // If we get here, there is no highlighted option
1043
- // So we will highlight the first or last based upon direction
1044
- const firstHighlight = options[dir === 'down' ? 0 : options.length - 1]
1045
- this.addClasses(firstHighlight, this.classes.highlighted)
1046
-
1047
- // Update aria-activedescendant for screen readers
1048
- if (firstHighlight.id) {
1049
- this.main.main.setAttribute('aria-activedescendant', firstHighlight.id)
1050
- }
1051
-
1052
- // Scroll to highlighted one
1053
- this.ensureElementInView(this.content.list, firstHighlight)
1054
- }
1055
-
1056
- // Create main container that options will reside
1057
- public listDiv(): HTMLDivElement {
1058
- const options = document.createElement('div')
1059
- this.addClasses(options, this.classes.list)
1060
-
1061
- // Add id for ARIA controls reference
1062
- const listId = this.settings.id + '-list'
1063
- options.id = listId
1064
- options.dataset.id = listId
1065
-
1066
- return options
1067
- }
1068
-
1069
- public renderError(error: string) {
1070
- // Clear out innerHtml
1071
- this.content.list.innerHTML = ''
1072
-
1073
- const errorDiv = document.createElement('div')
1074
- this.addClasses(errorDiv, this.classes.error)
1075
- errorDiv.textContent = error
1076
- this.content.list.appendChild(errorDiv)
1077
- }
1078
-
1079
- public renderSearching() {
1080
- // Clear out innerHtml
1081
- this.content.list.innerHTML = ''
1082
-
1083
- const searchingDiv = document.createElement('div')
1084
- this.addClasses(searchingDiv, this.classes.searching)
1085
- searchingDiv.textContent = this.settings.searchingText
1086
- this.content.list.appendChild(searchingDiv)
1087
- }
1088
-
1089
- // Take in data and add options to
1090
- public renderOptions(data: (Option | Optgroup)[]): void {
1091
- // Clear out innerHtml
1092
- this.content.list.innerHTML = ''
1093
-
1094
- // If no results show no results text
1095
- if (data.length === 0) {
1096
- const noResults = document.createElement('div')
1097
- this.addClasses(noResults, this.classes.search)
1098
-
1099
- //
1100
- if (this.callbacks.addable) {
1101
- noResults.innerHTML = this.settings.addableText.replace('{value}', this.content.search.input.value)
1102
- } else {
1103
- noResults.innerHTML = this.settings.searchText
1104
- }
1105
- this.content.list.appendChild(noResults)
1106
- return
1107
- }
1108
-
1109
- // If settings has allowDeselect and isSingle, add empty placeholder in the event they want to deselect
1110
- if (this.settings.allowDeselect && !this.settings.isMultiple) {
1111
- // Check if store options have a placeholder
1112
- const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
1113
- if (!placeholderOption.length) {
1114
- this.store.addOption(
1115
- new Option({
1116
- text: '',
1117
- value: '',
1118
- selected: false,
1119
- placeholder: true
1120
- }),
1121
- true
1122
- )
1123
- }
1124
- }
1125
-
1126
- // Append individual options to div container
1127
- const fragment = document.createDocumentFragment()
1128
- for (const d of data) {
1129
- // Create optgroup
1130
- if (d instanceof Optgroup) {
1131
- // Create optgroup
1132
- const optgroupEl = document.createElement('div')
1133
- this.addClasses(optgroupEl, this.classes.optgroup)
1134
-
1135
- // Create label
1136
- const optgroupLabel = document.createElement('div')
1137
- this.addClasses(optgroupLabel, this.classes.optgroupLabel)
1138
- optgroupEl.appendChild(optgroupLabel)
1139
-
1140
- // Create label text div element
1141
- const optgroupLabelText = document.createElement('div')
1142
- this.addClasses(optgroupLabelText, this.classes.optgroupLabelText)
1143
- optgroupLabelText.textContent = d.label
1144
- optgroupLabel.appendChild(optgroupLabelText)
1145
-
1146
- // Create options container
1147
- const optgroupActions = document.createElement('div')
1148
- this.addClasses(optgroupActions, this.classes.optgroupActions)
1149
- optgroupLabel.appendChild(optgroupActions)
1150
-
1151
- // If selectByGroup is true and isMultiple then add click event to label
1152
- if (this.settings.isMultiple && d.selectAll) {
1153
- // Create new div to hold a checkbox svg
1154
- const selectAll = document.createElement('div')
1155
- this.addClasses(selectAll, this.classes.optgroupSelectAll)
1156
-
1157
- // Check options and if all are selected, if so add class selected
1158
- let allSelected = true
1159
- for (const o of d.options) {
1160
- if (!o.selected) {
1161
- allSelected = false
1162
- break
1163
- }
1164
- }
1165
-
1166
- // Add class if all selected
1167
- if (allSelected) {
1168
- this.addClasses(selectAll, this.classes.selected)
1169
- }
1170
-
1171
- // Add select all text span
1172
- const selectAllText = document.createElement('span')
1173
- selectAllText.textContent = d.selectAllText
1174
- selectAll.appendChild(selectAllText)
1175
-
1176
- // Create new svg for checkbox
1177
- const selectAllSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1178
- selectAllSvg.setAttribute('viewBox', '0 0 100 100')
1179
- selectAll.appendChild(selectAllSvg)
1180
-
1181
- // Create new path for box
1182
- const selectAllBox = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1183
- selectAllBox.setAttribute('d', this.classes.optgroupSelectAllBox)
1184
- selectAllSvg.appendChild(selectAllBox)
1185
-
1186
- // Create new path for check
1187
- const selectAllCheck = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1188
- selectAllCheck.setAttribute('d', this.classes.optgroupSelectAllCheck)
1189
- selectAllSvg.appendChild(selectAllCheck)
1190
-
1191
- // Add click event listener to select all
1192
- selectAll.addEventListener('click', (e: MouseEvent) => {
1193
- e.preventDefault()
1194
- e.stopPropagation()
1195
-
1196
- // Get the store current selected values
1197
- const currentSelected = this.store.getSelected()
1198
-
1199
- // If all selected, remove all options from selected
1200
- // call setSelected and return
1201
- if (allSelected) {
1202
- // Put together new list minus all options in this optgroup
1203
- const newSelected = currentSelected.filter((s) => {
1204
- for (const o of d.options) {
1205
- if (s === o.id) {
1206
- return false
1207
- }
1208
- }
1209
-
1210
- return true
1211
- })
1212
-
1213
- this.callbacks.setSelected(newSelected, true)
1214
- return
1215
- } else {
1216
- // Put together new list with all options in this optgroup
1217
- let optionIds = d.options.map((o) => o.id).filter((id) => id !== undefined)
1218
- const newSelected = currentSelected.concat(optionIds)
1219
-
1220
- // Loop through options and if they don't exist in the store
1221
- // run addOption callback
1222
- for (const o of d.options) {
1223
- if (o.id && !this.store.getOptionByID(o.id)) {
1224
- this.callbacks.addOption(new Option(o))
1225
- }
1226
- }
1227
-
1228
- this.callbacks.setSelected(newSelected, true)
1229
- return
1230
- }
1231
- })
1232
-
1233
- // Append select all to label
1234
- optgroupActions.appendChild(selectAll)
1235
- }
1236
-
1237
- // If optgroup has collapsable
1238
- if (d.closable !== 'off') {
1239
- // Create new div to hold a checkbox svg
1240
- const optgroupClosable = document.createElement('div')
1241
- this.addClasses(optgroupClosable, this.classes.optgroupClosable)
1242
-
1243
- // Create svg arrow
1244
- const optgroupClosableSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1245
- optgroupClosableSvg.setAttribute('viewBox', '0 0 100 100')
1246
- this.addClasses(optgroupClosableSvg, this.classes.arrow)
1247
- optgroupClosable.appendChild(optgroupClosableSvg)
1248
-
1249
- // Create new path for arrow
1250
- const optgroupClosableArrow = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1251
- optgroupClosableSvg.appendChild(optgroupClosableArrow)
1252
-
1253
- // If any options are selected or someone is searching, set optgroup to open
1254
- if (d.options.some((o) => o.selected) || this.content.search.input.value.trim() !== '') {
1255
- this.addClasses(optgroupClosable, this.classes.mainOpen)
1256
- optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1257
- } else if (d.closable === 'open') {
1258
- this.addClasses(optgroupEl, this.classes.mainOpen)
1259
- optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1260
- } else if (d.closable === 'close') {
1261
- this.addClasses(optgroupEl, this.classes.close)
1262
- optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
1263
- }
1264
-
1265
- // Add click event listener to close
1266
- optgroupLabel.addEventListener('click', (e: MouseEvent) => {
1267
- e.preventDefault()
1268
- e.stopPropagation()
1269
-
1270
- // If optgroup is closed, open it
1271
- if (optgroupEl.classList.contains(this.classes.getFirst('close'))) {
1272
- this.removeClasses(optgroupEl, this.classes.close)
1273
- this.addClasses(optgroupEl, this.classes.mainOpen)
1274
- optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1275
- } else {
1276
- this.removeClasses(optgroupEl, this.classes.mainOpen)
1277
- this.addClasses(optgroupEl, this.classes.close)
1278
- optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
1279
- }
1280
- })
1281
-
1282
- // Append close to label
1283
- optgroupActions.appendChild(optgroupClosable)
1284
- }
1285
-
1286
- // Add optgroup label
1287
- optgroupEl.appendChild(optgroupLabel)
1288
-
1289
- // Loop through options
1290
- for (const option of d.options) {
1291
- optgroupEl.appendChild(this.option(new Option(option)))
1292
- fragment.appendChild(optgroupEl)
1293
- }
1294
- }
1295
-
1296
- // Create option
1297
- if (d instanceof Option) {
1298
- fragment.appendChild(this.option(d as Option))
1299
- }
1300
- }
1301
-
1302
- // Append fragment to list
1303
- this.content.list.appendChild(fragment)
1304
- }
1305
-
1306
- // Create option div element
1307
- public option(option: Option): HTMLDivElement {
1308
- // Add hidden placeholder
1309
- if (option.placeholder) {
1310
- const placeholder = document.createElement('div')
1311
- this.addClasses(placeholder, this.classes.option)
1312
- this.addClasses(placeholder, this.classes.hide)
1313
- return placeholder
1314
- }
1315
-
1316
- // Create option
1317
- const optionEl = document.createElement('div')
1318
- optionEl.dataset.id = option.id // Dataset id for identifying an option
1319
- optionEl.id = this.settings.id + '-' + option.id // Unique ID for ARIA references
1320
- this.addClasses(optionEl, this.classes.option)
1321
- optionEl.setAttribute('role', 'option') // WCAG attribute
1322
- if (option.class) {
1323
- option.class.split(' ').forEach((dataClass: string) => {
1324
- optionEl.classList.add(dataClass)
1325
- })
1326
- }
1327
- if (option.style) {
1328
- optionEl.style.cssText = option.style
1329
- }
1330
-
1331
- // Set option content
1332
- if (this.settings.searchHighlight && this.content.search.input.value.trim() !== '') {
1333
- optionEl.innerHTML = this.highlightText(
1334
- option.html !== '' ? option.html : option.text,
1335
- this.content.search.input.value,
1336
- this.classes.searchHighlighter
1337
- )
1338
- } else if (option.html !== '') {
1339
- optionEl.innerHTML = option.html
1340
- } else {
1341
- optionEl.textContent = option.text
1342
- }
1343
-
1344
- // Set title attribute
1345
- if (this.settings.showOptionTooltips && optionEl.textContent) {
1346
- optionEl.setAttribute('title', optionEl.textContent)
1347
- }
1348
-
1349
- // If option is disabled
1350
- if (!option.display) {
1351
- this.addClasses(optionEl, this.classes.hide)
1352
- }
1353
-
1354
- // If allowed to deselect, null onclick and add disabled
1355
- if (option.disabled) {
1356
- this.addClasses(optionEl, this.classes.disabled)
1357
- }
1358
-
1359
- // If option is selected and hideSelectedOption is true, hide it
1360
- if (option.selected && this.settings.hideSelected) {
1361
- this.addClasses(optionEl, this.classes.hide)
1362
- }
1363
-
1364
- // If option is selected
1365
- if (option.selected) {
1366
- this.addClasses(optionEl, this.classes.selected)
1367
- optionEl.setAttribute('aria-selected', 'true')
1368
- this.main.main.setAttribute('aria-activedescendant', optionEl.id)
1369
- } else {
1370
- this.removeClasses(optionEl, this.classes.selected)
1371
- optionEl.setAttribute('aria-selected', 'false')
1372
- }
1373
-
1374
- // Add click event listener
1375
- optionEl.addEventListener('click', (e: MouseEvent) => {
1376
- e.preventDefault()
1377
- e.stopPropagation()
1378
-
1379
- // Setup variables
1380
- const selectedOptions = this.store.getSelected()
1381
- const element = e.currentTarget as HTMLDivElement
1382
- const elementID = String(element.dataset.id)
1383
- const isCmd = e.ctrlKey || e.metaKey // Cmd (Mac) or Ctrl (Windows/Linux)
1384
-
1385
- // If the option is disabled, do nothing
1386
- if (option.disabled) {
1387
- return
1388
- }
1389
-
1390
- // allowDeselect only applies to single-select mode
1391
- // In multi-select, you can always toggle options on/off
1392
- if (!this.settings.isMultiple && option.selected && !this.settings.allowDeselect) {
1393
- return
1394
- }
1395
-
1396
- // Prevent deselection of mandatory options
1397
- if (option.selected && option.mandatory) {
1398
- return
1399
- }
1400
-
1401
- // Check limit and do nothing if limit is reached and the option is not selected
1402
- // Also check reverse for min limit and is selected (allow Cmd to bypass minSelected)
1403
- if (
1404
- (this.settings.isMultiple && this.settings.maxSelected <= selectedOptions.length && !option.selected) ||
1405
- (this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected && !isCmd)
1406
- ) {
1407
- return
1408
- }
1409
-
1410
- // Setup variables
1411
- let shouldUpdate = false
1412
- const before = this.store.getSelectedOptions()
1413
- let after = [] as Option[]
1414
-
1415
- // If multiple - mimic native browser multi-select behavior
1416
- if (this.settings.isMultiple) {
1417
- const isCurrentlySelected = before.some((o: Option) => o.id === elementID)
1418
- const isShift = e.shiftKey
1419
-
1420
- // Shift+Click: Select range from last clicked to current
1421
- if (isShift && this.lastSelectedOption) {
1422
- const options = this.store.getDataOptions()
1423
- const lastIndex = options.findIndex((o: Option) => o.id === this.lastSelectedOption!.id)
1424
- const currentIndex = options.findIndex((o: Option) => o.id === option.id)
1425
-
1426
- if (lastIndex >= 0 && currentIndex >= 0) {
1427
- const startIndex = Math.min(lastIndex, currentIndex)
1428
- const endIndex = Math.max(lastIndex, currentIndex)
1429
- const rangeOptions = options.slice(startIndex, endIndex + 1)
1430
-
1431
- // Check if range would exceed maxSelected
1432
- const newSelections = rangeOptions.filter((opt) => !before.find((b) => b.id === opt.id))
1433
- if (before.length + newSelections.length <= this.settings.maxSelected) {
1434
- // Add range to existing selections
1435
- after = before.concat(newSelections)
1436
- } else {
1437
- // Range too large, keep existing selections
1438
- after = before
1439
- }
1440
- } else {
1441
- after = before
1442
- }
1443
- }
1444
- // Cmd/Ctrl+Click: Toggle selection without affecting others (keeps dropdown open)
1445
- else if (isCmd) {
1446
- if (isCurrentlySelected) {
1447
- // Deselect this option
1448
- after = before.filter((o: Option) => o.id !== elementID)
1449
- } else {
1450
- // Add this option to selection
1451
- after = before.concat(option)
1452
- }
1453
- this.lastSelectedOption = option
1454
- }
1455
- // Regular Click: Toggle this option (add/remove), will close dropdown
1456
- else {
1457
- if (isCurrentlySelected) {
1458
- // Deselect this option
1459
- after = before.filter((o: Option) => o.id !== elementID)
1460
- } else {
1461
- // Add this option to selection
1462
- after = before.concat(option)
1463
- }
1464
- this.lastSelectedOption = option
1465
- }
1466
- }
1467
-
1468
- // If single
1469
- if (!this.settings.isMultiple) {
1470
- if (option.selected) {
1471
- // If selected after would remove
1472
- after = []
1473
- } else {
1474
- // If not selected after would add
1475
- after = [option]
1476
- }
1477
- }
1478
-
1479
- // If no beforeOnChange is set automatically update at end
1480
- if (!this.callbacks.beforeChange) {
1481
- shouldUpdate = true
1482
- }
1483
-
1484
- if (this.callbacks.beforeChange) {
1485
- // Check if beforeChange returns false
1486
- if (this.callbacks.beforeChange(after, before) === false) {
1487
- shouldUpdate = false
1488
- } else {
1489
- shouldUpdate = true
1490
- }
1491
- }
1492
-
1493
- if (shouldUpdate) {
1494
- // Check if the option exists in the store
1495
- // if not run addOption callback
1496
- if (!this.store.getOptionByID(elementID)) {
1497
- this.callbacks.addOption(option)
1498
- }
1499
-
1500
- // Get values from after and set as selected
1501
- this.callbacks.setSelected(
1502
- after.map((o: Option) => o.id),
1503
- false
1504
- )
1505
-
1506
- // Close dropdown unless using modifier keys in multi-select
1507
- // (mimics native multi-select behavior where you can keep selecting)
1508
- const isModifierKey = e.ctrlKey || e.metaKey || e.shiftKey // Cmd/Ctrl or Shift
1509
- const shouldClose = this.settings.closeOnSelect && !(this.settings.isMultiple && isModifierKey)
1510
-
1511
- if (shouldClose) {
1512
- this.callbacks.close()
1513
- }
1514
-
1515
- // callback that the value has changed
1516
- if (this.callbacks.afterChange) {
1517
- this.callbacks.afterChange(after)
1518
- }
1519
- }
1520
- })
1521
-
1522
- return optionEl
1523
- }
1524
-
1525
- public destroy(): void {
1526
- // Clear any pending timeouts
1527
- if (this.closeAnimationTimeout) {
1528
- clearTimeout(this.closeAnimationTimeout)
1529
- this.closeAnimationTimeout = null
1530
- }
1531
-
1532
- // Remove main
1533
- this.main.main.remove()
1534
-
1535
- // Remove content
1536
- this.content.main.remove()
1537
- }
1538
-
1539
- private highlightText(str: string, search: any, className: string) {
1540
- const searchTerm = search.trim()
1541
- if (searchTerm === '') {
1542
- return str
1543
- }
1544
-
1545
- // Escape special regex characters in the search term to prevent regex injection
1546
- const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1547
-
1548
- // Create a temporary div to parse HTML and work with text nodes only
1549
- const tempDiv = document.createElement('div')
1550
- tempDiv.innerHTML = str
1551
-
1552
- // Function to recursively process text nodes
1553
- const highlightTextNodes = (node: Node) => {
1554
- if (node.nodeType === Node.TEXT_NODE) {
1555
- const text = node.textContent || ''
1556
- const regex = new RegExp('(' + escapedSearch + ')', 'i')
1557
-
1558
- if (regex.test(text)) {
1559
- // Create a temporary container for the highlighted content
1560
- const wrapper = document.createElement('span')
1561
- const parts = text.split(regex)
1562
-
1563
- parts.forEach((part, index) => {
1564
- if (part && regex.test(part)) {
1565
- // This is the matched part - wrap it in mark
1566
- const mark = document.createElement('mark')
1567
- mark.className = className
1568
- mark.textContent = part
1569
- wrapper.appendChild(mark)
1570
- } else if (part) {
1571
- // This is not the matched part - keep as text
1572
- wrapper.appendChild(document.createTextNode(part))
1573
- }
1574
- })
1575
-
1576
- // Replace the text node with the wrapper
1577
- node.parentNode?.replaceChild(wrapper, node)
1578
- }
1579
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1580
- // Recursively process child nodes
1581
- Array.from(node.childNodes).forEach((child) => highlightTextNodes(child))
1582
- }
1583
- }
1584
-
1585
- // Process all text nodes in the temporary div
1586
- Array.from(tempDiv.childNodes).forEach((node) => highlightTextNodes(node))
1587
-
1588
- return tempDiv.innerHTML
1589
- }
1590
-
1591
- private setContentDirection(direction: 'above' | 'below'): void {
1592
- const isAbove = direction === 'above'
1593
- const addClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
1594
- const removeClass = isAbove ? this.classes.dirBelow : this.classes.dirAbove
1595
-
1596
- // Set direction classes on both main and content
1597
- this.removeClasses(this.main.main, removeClass)
1598
- this.addClasses(this.main.main, addClass)
1599
- this.removeClasses(this.content.main, removeClass)
1600
- this.addClasses(this.content.main, addClass)
1601
-
1602
- // Set margin to position content
1603
- if (isAbove) {
1604
- const mainHeight = this.main.main.offsetHeight
1605
- const contentHeight = this.content.main.offsetHeight
1606
- this.content.main.style.margin = '-' + (mainHeight + contentHeight - 1) + 'px 0px 0px 0px'
1607
- } else {
1608
- this.content.main.style.margin = '-1px 0px 0px 0px'
1609
- }
1610
- }
1611
-
1612
- private setContentPosition(): void {
1613
- if (this.settings.contentPosition === 'relative') {
1614
- return
1615
- }
1616
-
1617
- // getBoundingClientRect() returns viewport-relative coordinates.
1618
- const containerRect = this.main.main.getBoundingClientRect()
1619
- let top: number
1620
- let left: number
1621
-
1622
- if (this.settings.contentPosition === 'fixed') {
1623
- // position:fixed is relative to the viewport, so viewport coords work directly.
1624
- top = containerRect.top + containerRect.height
1625
- left = containerRect.left
1626
- } else {
1627
- // position:absolute is relative to the containing block. By default the content
1628
- // is appended to document.body (static), so the containing block is the initial
1629
- // containing block (document origin). We must convert viewport coords to document
1630
- // coords by adding scroll offsets. Using offsetParent subtraction was unreliable
1631
- // because offsetParent for body children varies across browsers (body vs html)
1632
- // and body margin caused misalignment.
1633
- top = containerRect.top + window.scrollY + containerRect.height
1634
- left = containerRect.left + window.scrollX
1635
- }
1636
-
1637
- this.content.main.style.top = top + 'px'
1638
- this.content.main.style.left = left + 'px'
1639
-
1640
- // Apply content width based on contentWidth setting
1641
- // "" → width matches trigger (default/current behavior)
1642
- // "500px" → exact width of 500px
1643
- // ">500px" → min-width of 500px, width auto (content can grow)
1644
- // "<500px" → max-width of 500px, width auto (content can shrink)
1645
- const cw = this.settings.contentWidth
1646
- this.content.main.style.width = ''
1647
- this.content.main.style.minWidth = ''
1648
- this.content.main.style.maxWidth = ''
1649
-
1650
- if (!cw) {
1651
- // Default: match trigger width exactly
1652
- this.content.main.style.width = containerRect.width + 'px'
1653
- } else if (cw.startsWith('>')) {
1654
- // Min-width mode: at least this wide, can grow to fit content
1655
- this.content.main.style.minWidth = cw.slice(1)
1656
- } else if (cw.startsWith('<')) {
1657
- // Max-width mode: no wider than this, content wraps if longer
1658
- this.content.main.style.maxWidth = cw.slice(1)
1659
- } else {
1660
- // Exact width
1661
- this.content.main.style.width = cw
1662
- }
1663
-
1664
- // If content overflows the right side of the viewport, shift it left so the right edge
1665
- // lines up with the trigger (or stays inside the viewport). Use rAF so we measure after
1666
- // layout (and after width:auto / min-width have been applied).
1667
- const padding = 20
1668
- const viewportRight = window.innerWidth - padding
1669
-
1670
- const applyOverflowShift = (): void => {
1671
- const contentRect = this.content.main.getBoundingClientRect()
1672
- const contentRight = contentRect.right
1673
- if (contentRight <= viewportRight) return
1674
-
1675
- const overflow = contentRight - viewportRight
1676
- const currentLeft = parseFloat(this.content.main.style.left) || 0
1677
-
1678
- if (this.settings.contentPosition === 'fixed') {
1679
- const newLeft = Math.max(padding, currentLeft - overflow)
1680
- this.content.main.style.left = newLeft + 'px'
1681
- } else {
1682
- const newLeft = Math.max(window.scrollX + padding, currentLeft - overflow)
1683
- this.content.main.style.left = newLeft + 'px'
1684
- }
1685
- }
1686
-
1687
- // First rAF: layout done (width/min-width applied). Second rAF: catch scrollbar etc.
1688
- requestAnimationFrame(() => {
1689
- applyOverflowShift()
1690
- requestAnimationFrame(applyOverflowShift)
1691
- })
1692
- }
1693
-
1694
- public moveContentAbove(): void {
1695
- this.setContentDirection('above')
1696
- this.setContentPosition()
1697
- }
1698
-
1699
- public moveContentBelow(): void {
1700
- this.setContentDirection('below')
1701
- this.setContentPosition()
1702
- }
1703
-
1704
- public ensureElementInView(container: HTMLElement, element: HTMLElement): void {
1705
- // Determine container top and bottom
1706
- const cTop = container.scrollTop + container.offsetTop // Make sure to have offsetTop
1707
- const cBottom = cTop + container.clientHeight
1708
-
1709
- // Determine element top and bottom
1710
- const eTop = element.offsetTop
1711
- const eBottom = eTop + element.clientHeight
1712
-
1713
- // Check if out of view
1714
- if (eTop < cTop) {
1715
- container.scrollTop -= cTop - eTop
1716
- } else if (eBottom > cBottom) {
1717
- container.scrollTop += eBottom - cBottom
1718
- }
1719
- }
1720
-
1721
- public putContent(): 'up' | 'down' {
1722
- // Get main and content height
1723
- const mainHeight = this.main.main.offsetHeight
1724
- const mainRect = this.main.main.getBoundingClientRect()
1725
- const contentHeight = this.content.main.offsetHeight
1726
-
1727
- // From bottom of mainHeight figure out if content will fit below without going below the window
1728
- const spaceBelow = window.innerHeight - (mainRect.top + mainHeight)
1729
-
1730
- // If space below is less than content height
1731
- if (spaceBelow <= contentHeight) {
1732
- // If space above is more than content height
1733
- if (mainRect.top > contentHeight) {
1734
- // Move content above
1735
- return 'up'
1736
- } else {
1737
- // Move content below
1738
- return 'down'
1739
- }
1740
- }
1741
-
1742
- // Move content below
1743
- return 'down'
1744
- }
1745
-
1746
- // Updates deselect based on item count and allowDeselect setting
1747
- public updateDeselectAll(): void {
1748
- if (!this.store || !this.settings) {
1749
- return
1750
- }
1751
- const selected = this.store.getSelectedOptions()
1752
- const hasSelectedItems = selected && selected.length > 0
1753
- const isMultiple = this.settings.isMultiple
1754
- const allowDeselect = this.settings.allowDeselect
1755
-
1756
- const deselectButton = this.main.deselect.main
1757
- const hideClass = this.classes.hide
1758
-
1759
- if (allowDeselect && !(isMultiple && !hasSelectedItems)) {
1760
- this.removeClasses(deselectButton, hideClass)
1761
- } else {
1762
- this.addClasses(deselectButton, hideClass)
1763
- }
1764
- }
1765
- }