slim-select 3.0.6 → 3.1.1

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.
@@ -439,4 +439,238 @@ describe('select module', () => {
439
439
  expect(dataOption[0].options[0].text).toBe('New One')
440
440
  })
441
441
  })
442
+
443
+ describe('label handling', () => {
444
+ test('setupLabelHandlers finds label with for attribute and adds click handler', async () => {
445
+ document.body.innerHTML = `
446
+ <label for="test-select">Select Label</label>
447
+ <select id="test-select">
448
+ <option value="1">One</option>
449
+ <option value="2">Two</option>
450
+ </select>
451
+ `
452
+
453
+ const selectElement = document.getElementById('test-select') as HTMLSelectElement
454
+ const select = new Select(selectElement)
455
+ select.hideUI()
456
+
457
+ const onLabelClickMock = vi.fn()
458
+ select.onLabelClick = onLabelClickMock
459
+
460
+ select.setupLabelHandlers()
461
+
462
+ const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
463
+ expect(label).toBeTruthy()
464
+
465
+ // Click the label
466
+ label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
467
+
468
+ // Wait for setTimeout in the label handler
469
+ await new Promise((r) => setTimeout(r, 10))
470
+
471
+ // Should have prevented default and called the callback
472
+ expect(onLabelClickMock).toHaveBeenCalled()
473
+ })
474
+
475
+ test('setupLabelHandlers finds wrapped label and adds click handler', async () => {
476
+ document.body.innerHTML = `
477
+ <label>
478
+ Select Label
479
+ <select id="test-select">
480
+ <option value="1">One</option>
481
+ <option value="2">Two</option>
482
+ </select>
483
+ </label>
484
+ `
485
+
486
+ const selectElement = document.getElementById('test-select') as HTMLSelectElement
487
+ const select = new Select(selectElement)
488
+ select.hideUI()
489
+
490
+ const onLabelClickMock = vi.fn()
491
+ select.onLabelClick = onLabelClickMock
492
+
493
+ select.setupLabelHandlers()
494
+
495
+ const label = selectElement.parentElement as HTMLLabelElement
496
+ expect(label.tagName).toBe('LABEL')
497
+
498
+ // Click the label
499
+ label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
500
+
501
+ // Wait for setTimeout in the label handler
502
+ await new Promise((r) => setTimeout(r, 10))
503
+
504
+ // Should have called the callback
505
+ expect(onLabelClickMock).toHaveBeenCalled()
506
+ })
507
+
508
+ test('setupLabelHandlers handles multiple labels', async () => {
509
+ document.body.innerHTML = `
510
+ <label for="test-select">Label 1</label>
511
+ <label for="test-select">Label 2</label>
512
+ <select id="test-select">
513
+ <option value="1">One</option>
514
+ </select>
515
+ `
516
+
517
+ const selectElement = document.getElementById('test-select') as HTMLSelectElement
518
+ const select = new Select(selectElement)
519
+ select.hideUI()
520
+
521
+ const onLabelClickMock = vi.fn()
522
+ select.onLabelClick = onLabelClickMock
523
+
524
+ select.setupLabelHandlers()
525
+
526
+ const labels = document.querySelectorAll('label[for="test-select"]')
527
+ expect(labels).toHaveLength(2)
528
+
529
+ // Click first label
530
+ labels[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
531
+ await new Promise((r) => setTimeout(r, 10))
532
+ expect(onLabelClickMock).toHaveBeenCalledTimes(1)
533
+
534
+ // Click second label
535
+ labels[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
536
+ await new Promise((r) => setTimeout(r, 10))
537
+ expect(onLabelClickMock).toHaveBeenCalledTimes(2)
538
+ })
539
+
540
+ test('removeLabelHandlers removes click handlers from labels', () => {
541
+ document.body.innerHTML = `
542
+ <label for="test-select">Select Label</label>
543
+ <select id="test-select">
544
+ <option value="1">One</option>
545
+ </select>
546
+ `
547
+
548
+ const selectElement = document.getElementById('test-select') as HTMLSelectElement
549
+ const select = new Select(selectElement)
550
+ select.hideUI()
551
+
552
+ const onLabelClickMock = vi.fn()
553
+ select.onLabelClick = onLabelClickMock
554
+
555
+ select.setupLabelHandlers()
556
+ select.removeLabelHandlers()
557
+
558
+ const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
559
+ label.dispatchEvent(new MouseEvent('click', { bubbles: true }))
560
+
561
+ // Should not have been called after removal
562
+ expect(onLabelClickMock).not.toHaveBeenCalled()
563
+ })
564
+
565
+ test('hideUI prevents native select from opening on click', () => {
566
+ document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
567
+
568
+ const selectElement = document.getElementById('test') as HTMLSelectElement
569
+ const select = new Select(selectElement)
570
+ select.hideUI()
571
+
572
+ const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
573
+ const prevented = !selectElement.dispatchEvent(clickEvent)
574
+
575
+ // The event should be prevented by our handler
576
+ expect(clickEvent.defaultPrevented).toBe(true)
577
+ })
578
+
579
+ test('hideUI prevents native select from opening on focus', () => {
580
+ document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
581
+
582
+ const selectElement = document.getElementById('test') as HTMLSelectElement
583
+ const select = new Select(selectElement)
584
+ select.hideUI()
585
+
586
+ const focusEvent = new FocusEvent('focus', { bubbles: true, cancelable: true })
587
+ selectElement.dispatchEvent(focusEvent)
588
+
589
+ // The event should be prevented by our handler
590
+ expect(focusEvent.defaultPrevented).toBe(true)
591
+ })
592
+
593
+ test('hideUI prevents native select from opening on mousedown', () => {
594
+ document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
595
+
596
+ const selectElement = document.getElementById('test') as HTMLSelectElement
597
+ const select = new Select(selectElement)
598
+ select.hideUI()
599
+
600
+ const mousedownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
601
+ selectElement.dispatchEvent(mousedownEvent)
602
+
603
+ // The event should be prevented by our handler
604
+ expect(mousedownEvent.defaultPrevented).toBe(true)
605
+ })
606
+
607
+ test('showUI removes event handlers that prevent native select', () => {
608
+ document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
609
+
610
+ const selectElement = document.getElementById('test') as HTMLSelectElement
611
+ const select = new Select(selectElement)
612
+ select.hideUI()
613
+ select.showUI()
614
+
615
+ // After showUI, events should not be prevented
616
+ const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
617
+ selectElement.dispatchEvent(clickEvent)
618
+
619
+ // Note: The event may still propagate normally, but defaultPrevented should be false
620
+ // We test this by ensuring the select doesn't have our prevent handlers attached
621
+ // The actual behavior depends on browser implementation
622
+ })
623
+
624
+ test('destroy removes label handlers', async () => {
625
+ document.body.innerHTML = `
626
+ <label for="test-select">Select Label</label>
627
+ <select id="test-select">
628
+ <option value="1">One</option>
629
+ </select>
630
+ `
631
+
632
+ const selectElement = document.getElementById('test-select') as HTMLSelectElement
633
+ const select = new Select(selectElement)
634
+ select.hideUI()
635
+
636
+ const onLabelClickMock = vi.fn()
637
+ select.onLabelClick = onLabelClickMock
638
+
639
+ select.setupLabelHandlers()
640
+
641
+ // Verify handler works
642
+ const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
643
+ label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
644
+ await new Promise((r) => setTimeout(r, 10))
645
+ expect(onLabelClickMock).toHaveBeenCalledTimes(1)
646
+
647
+ // Destroy and verify handler removed
648
+ select.destroy()
649
+ onLabelClickMock.mockClear()
650
+
651
+ label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
652
+ await new Promise((r) => setTimeout(r, 10))
653
+ expect(onLabelClickMock).not.toHaveBeenCalled()
654
+ })
655
+
656
+ test('setupLabelHandlers does nothing if select has no id and no wrapping label', () => {
657
+ document.body.innerHTML = `
658
+ <div>
659
+ <select>
660
+ <option value="1">One</option>
661
+ </select>
662
+ </div>
663
+ `
664
+
665
+ const selectElement = document.querySelector('select') as HTMLSelectElement
666
+ const select = new Select(selectElement)
667
+ select.hideUI()
668
+
669
+ const onLabelClickMock = vi.fn()
670
+ select.onLabelClick = onLabelClickMock
671
+
672
+ // Should not throw or error
673
+ expect(() => select.setupLabelHandlers()).not.toThrow()
674
+ })
675
+ })
442
676
  })
@@ -9,10 +9,18 @@ export default class Select {
9
9
  public onClassChange?: (classes: string[]) => void
10
10
  public onDisabledChange?: (disabled: boolean) => void
11
11
  public onOptionsChange?: (data: (Option | Optgroup)[]) => void
12
+ public onLabelClick?: () => void
12
13
 
13
14
  // Change observers
14
15
  private listen: boolean = false
15
16
  private observer: MutationObserver | null = null
17
+ private isUpdating: boolean = false
18
+ private pendingOptionsChange: (Option | Optgroup)[] | null = null
19
+
20
+ // Event handlers for preventing native select behavior (especially on iOS Safari)
21
+ private preventNativeSelect: ((e: Event) => void) | null = null
22
+ private preventNativeSelectMousedown: ((e: Event) => void) | null = null
23
+ private preventNativeSelectFocus: ((e: Event) => void) | null = null
16
24
 
17
25
  constructor(select: HTMLSelectElement) {
18
26
  this.select = select
@@ -56,6 +64,35 @@ export default class Select {
56
64
  // Clip to completely hide the 1px
57
65
  this.select.style.clip = 'rect(0 0 0 0)'
58
66
  this.select.setAttribute('aria-hidden', 'true')
67
+
68
+ // Prevent native select from opening when label is clicked (especially on iOS Safari)
69
+ // iOS Safari programmatically focuses/clicks the select when label is clicked,
70
+ // bypassing pointer-events: none, so we need to prevent these events
71
+ // Only add listeners if they haven't been added yet
72
+ if (!this.preventNativeSelect) {
73
+ this.preventNativeSelect = (e: Event) => {
74
+ e.preventDefault()
75
+ e.stopPropagation()
76
+ e.stopImmediatePropagation()
77
+ }
78
+
79
+ this.preventNativeSelectMousedown = (e: Event) => {
80
+ e.preventDefault()
81
+ e.stopPropagation()
82
+ e.stopImmediatePropagation()
83
+ }
84
+
85
+ this.preventNativeSelectFocus = (e: Event) => {
86
+ e.preventDefault()
87
+ e.stopPropagation()
88
+ e.stopImmediatePropagation()
89
+ }
90
+
91
+ // Add event listeners with capture phase to catch events before they reach the select
92
+ this.select.addEventListener('click', this.preventNativeSelect, { capture: true, passive: false })
93
+ this.select.addEventListener('mousedown', this.preventNativeSelectMousedown, { capture: true, passive: false })
94
+ this.select.addEventListener('focus', this.preventNativeSelectFocus, { capture: true, passive: false })
95
+ }
59
96
  }
60
97
 
61
98
  public showUI(): void {
@@ -72,6 +109,20 @@ export default class Select {
72
109
  this.select.style.borderWidth = ''
73
110
  this.select.style.clip = ''
74
111
  this.select.removeAttribute('aria-hidden')
112
+
113
+ // Remove event listeners that prevent native select behavior
114
+ if (this.preventNativeSelect) {
115
+ this.select.removeEventListener('click', this.preventNativeSelect, { capture: true })
116
+ this.preventNativeSelect = null
117
+ }
118
+ if (this.preventNativeSelectMousedown) {
119
+ this.select.removeEventListener('mousedown', this.preventNativeSelectMousedown, { capture: true })
120
+ this.preventNativeSelectMousedown = null
121
+ }
122
+ if (this.preventNativeSelectFocus) {
123
+ this.select.removeEventListener('focus', this.preventNativeSelectFocus, { capture: true })
124
+ this.preventNativeSelectFocus = null
125
+ }
75
126
  }
76
127
 
77
128
  public changeListen(listen: boolean) {
@@ -165,6 +216,20 @@ export default class Select {
165
216
 
166
217
  // If optgroup or option has changed then call the select change function
167
218
  if (optgroupOptionChanged && this.onOptionsChange) {
219
+ // If we're currently updating, queue this change to process after update completes
220
+ if (this.isUpdating) {
221
+ // Only queue if the select element actually has options (not empty from updateOptions clearing)
222
+ // Check the select element directly, not getData() which might return stale data
223
+ if (this.select.options.length > 0) {
224
+ const currentData = this.getData()
225
+ // Only queue if we have actual data
226
+ if (currentData.length > 0) {
227
+ this.pendingOptionsChange = currentData
228
+ }
229
+ }
230
+ return
231
+ }
232
+
168
233
  this.changeListen(false)
169
234
  this.onOptionsChange(this.getData())
170
235
  this.changeListen(true)
@@ -353,6 +418,16 @@ export default class Select {
353
418
  }
354
419
 
355
420
  public updateOptions(data: (Option | Optgroup)[]): void {
421
+ // Don't update if data is empty (prevents clearing select when no data)
422
+ if (!data || data.length === 0) {
423
+ return
424
+ }
425
+
426
+ // Set updating flag FIRST to prevent mutation observer from processing during update
427
+ // This must be set before stopping listening so any queued mutations see the flag
428
+ this.isUpdating = true
429
+ this.pendingOptionsChange = null
430
+
356
431
  // Stop listening to changes
357
432
  this.changeListen(false)
358
433
 
@@ -374,6 +449,25 @@ export default class Select {
374
449
 
375
450
  // Start listening to changes
376
451
  this.changeListen(true)
452
+
453
+ // Clear updating flag
454
+ this.isUpdating = false
455
+
456
+ // Process any pending options change that was queued during the update
457
+ // Only process if it has data (don't process empty data from innerHTML clearing)
458
+ if (this.pendingOptionsChange !== null) {
459
+ const pending: (Option | Optgroup)[] = this.pendingOptionsChange
460
+ if (pending.length > 0 && this.onOptionsChange) {
461
+ this.pendingOptionsChange = null
462
+ // Process the queued change
463
+ this.changeListen(false)
464
+ this.onOptionsChange(pending)
465
+ this.changeListen(true)
466
+ } else {
467
+ // Clear pending if it was empty
468
+ this.pendingOptionsChange = null
469
+ }
470
+ }
377
471
  }
378
472
 
379
473
  public createOptgroup(optgroup: Optgroup): HTMLOptGroupElement {
@@ -430,18 +524,120 @@ export default class Select {
430
524
  return optionEl
431
525
  }
432
526
 
527
+ // Find and handle labels associated with this select
528
+ public setupLabelHandlers(): void {
529
+ const labels: HTMLLabelElement[] = []
530
+
531
+ // Find labels that point to this select via 'for' attribute
532
+ const selectId = this.select.id
533
+ if (selectId) {
534
+ const labelsByFor = document.querySelectorAll<HTMLLabelElement>(`label[for="${selectId}"]`)
535
+ labelsByFor.forEach((label) => labels.push(label))
536
+ }
537
+
538
+ // Find labels that wrap this select (label contains select)
539
+ let parent = this.select.parentElement
540
+ while (parent && parent !== document.body) {
541
+ if (parent.tagName === 'LABEL') {
542
+ labels.push(parent as HTMLLabelElement)
543
+ break
544
+ }
545
+ parent = parent.parentElement
546
+ }
547
+
548
+ // Remove duplicates
549
+ const uniqueLabels = Array.from(new Set(labels))
550
+
551
+ uniqueLabels.forEach((label) => {
552
+ // Skip if already handled
553
+ if ((label as any).__slimSelectLabelHandler) {
554
+ return
555
+ }
556
+
557
+ // Add click handler to label
558
+ const labelClickHandler = (e: MouseEvent) => {
559
+ // Prevent default label behavior (focusing the select)
560
+ e.preventDefault()
561
+ e.stopPropagation()
562
+
563
+ // Trigger the callback to open SlimSelect
564
+ if (this.onLabelClick) {
565
+ // Use setTimeout to ensure this happens after any browser-initiated events
566
+ // are prevented by our prevent handlers
567
+ setTimeout(() => {
568
+ this.onLabelClick!()
569
+ }, 0)
570
+ }
571
+ }
572
+
573
+ // Store the handler on the label for cleanup later
574
+ ;(label as any).__slimSelectLabelHandler = labelClickHandler
575
+ label.addEventListener('click', labelClickHandler, { capture: true, passive: false })
576
+ })
577
+ }
578
+
579
+ // Remove label handlers
580
+ public removeLabelHandlers(): void {
581
+ const labels: HTMLLabelElement[] = []
582
+
583
+ // Find labels that point to this select via 'for' attribute
584
+ const selectId = this.select.id
585
+ if (selectId) {
586
+ const labelsByFor = document.querySelectorAll<HTMLLabelElement>(`label[for="${selectId}"]`)
587
+ labelsByFor.forEach((label) => labels.push(label))
588
+ }
589
+
590
+ // Find labels that wrap this select
591
+ let parent = this.select.parentElement
592
+ while (parent && parent !== document.body) {
593
+ if (parent.tagName === 'LABEL') {
594
+ labels.push(parent as HTMLLabelElement)
595
+ break
596
+ }
597
+ parent = parent.parentElement
598
+ }
599
+
600
+ // Remove duplicates
601
+ const uniqueLabels = Array.from(new Set(labels))
602
+
603
+ uniqueLabels.forEach((label) => {
604
+ const handler = (label as any).__slimSelectLabelHandler
605
+ if (handler) {
606
+ label.removeEventListener('click', handler, { capture: true })
607
+ delete (label as any).__slimSelectLabelHandler
608
+ }
609
+ })
610
+ }
611
+
433
612
  public destroy() {
434
613
  this.changeListen(false)
435
614
 
436
615
  // Remove event change listener
437
616
  this.select.removeEventListener('change', this.valueChange)
438
617
 
618
+ // Remove event listeners that prevent native select behavior
619
+ if (this.preventNativeSelect) {
620
+ this.select.removeEventListener('click', this.preventNativeSelect, { capture: true })
621
+ this.preventNativeSelect = null
622
+ }
623
+ if (this.preventNativeSelectMousedown) {
624
+ this.select.removeEventListener('mousedown', this.preventNativeSelectMousedown, { capture: true })
625
+ this.preventNativeSelectMousedown = null
626
+ }
627
+ if (this.preventNativeSelectFocus) {
628
+ this.select.removeEventListener('focus', this.preventNativeSelectFocus, { capture: true })
629
+ this.preventNativeSelectFocus = null
630
+ }
631
+
439
632
  // Disconnect observer and null
440
633
  if (this.observer) {
441
634
  this.observer.disconnect()
442
635
  this.observer = null
443
636
  }
444
637
 
638
+ // Remove label handlers
639
+ this.removeLabelHandlers()
640
+
445
641
  // Remove dataset id from original select
446
642
  delete this.select.dataset.id
447
643