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