@smilodon/core 1.2.0 → 1.2.2
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.
- package/dist/index.cjs +860 -732
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +860 -732
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/dist/index.umd.js +860 -732
- package/dist/index.umd.js.map +1 -1
- package/dist/index.umd.min.js +1 -1
- package/dist/index.umd.min.js.map +1 -1
- package/dist/types/src/components/native-select.d.ts +21 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -509,840 +509,968 @@ class Virtualizer {
|
|
|
509
509
|
}
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.
|
|
524
|
-
this.
|
|
525
|
-
this.
|
|
526
|
-
this._shadow = this.attachShadow({ mode: 'open' });
|
|
527
|
-
this._listRoot = document.createElement('div');
|
|
528
|
-
this._listRoot.setAttribute('role', 'listbox');
|
|
529
|
-
this._listRoot.setAttribute('tabindex', '0');
|
|
530
|
-
this._shadow.appendChild(this._listRoot);
|
|
531
|
-
// Live region for screen reader announcements
|
|
532
|
-
this._liveRegion = document.createElement('div');
|
|
533
|
-
this._liveRegion.setAttribute('role', 'status');
|
|
534
|
-
this._liveRegion.setAttribute('aria-live', 'polite');
|
|
535
|
-
this._liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;';
|
|
536
|
-
this._shadow.appendChild(this._liveRegion);
|
|
537
|
-
this._helpers = createRendererHelpers((item, index) => this._onSelect(item, index));
|
|
538
|
-
// Delegated click
|
|
539
|
-
this._listRoot.addEventListener('click', (e) => {
|
|
540
|
-
const el = e.target.closest('[data-selectable]');
|
|
541
|
-
if (!el)
|
|
542
|
-
return;
|
|
543
|
-
const idx = Number(el.dataset.index);
|
|
544
|
-
const item = this._items[idx];
|
|
545
|
-
this._onSelect(item, idx);
|
|
546
|
-
});
|
|
547
|
-
// Keyboard navigation
|
|
548
|
-
this._listRoot.addEventListener('keydown', (e) => this._onKeydown(e));
|
|
549
|
-
}
|
|
550
|
-
connectedCallback() {
|
|
551
|
-
// Initialize ARIA roles and open event
|
|
552
|
-
this._listRoot.setAttribute('role', 'listbox');
|
|
553
|
-
this._listRoot.setAttribute('aria-label', 'Options list');
|
|
554
|
-
if (this._multi)
|
|
555
|
-
this._listRoot.setAttribute('aria-multiselectable', 'true');
|
|
556
|
-
this._emit('open', {});
|
|
512
|
+
/**
|
|
513
|
+
* Custom Option Component Pool
|
|
514
|
+
*
|
|
515
|
+
* Manages lifecycle and recycling of custom option components for optimal performance.
|
|
516
|
+
* Uses object pooling pattern to minimize allocation/deallocation overhead.
|
|
517
|
+
*/
|
|
518
|
+
/**
|
|
519
|
+
* Manages a pool of reusable custom option component instances
|
|
520
|
+
*/
|
|
521
|
+
class CustomOptionPool {
|
|
522
|
+
constructor(maxPoolSize = 50) {
|
|
523
|
+
this._pool = new Map();
|
|
524
|
+
this._activeComponents = new Map();
|
|
525
|
+
this._maxPoolSize = maxPoolSize;
|
|
557
526
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Get or create a component instance for the given index
|
|
529
|
+
*
|
|
530
|
+
* @param factory - Factory function to create new instances
|
|
531
|
+
* @param item - The data item
|
|
532
|
+
* @param index - The option index
|
|
533
|
+
* @param context - Context for mounting
|
|
534
|
+
* @param container - DOM container for mounting
|
|
535
|
+
* @returns Component instance
|
|
536
|
+
*/
|
|
537
|
+
acquire(factory, item, index, context, container) {
|
|
538
|
+
const factoryKey = this._getFactoryKey(factory);
|
|
539
|
+
// Try to find an available component in the pool
|
|
540
|
+
const pooled = this._findAvailableComponent(factoryKey);
|
|
541
|
+
let component;
|
|
542
|
+
if (pooled) {
|
|
543
|
+
// Reuse pooled component
|
|
544
|
+
component = pooled.instance;
|
|
545
|
+
pooled.inUse = true;
|
|
546
|
+
pooled.lastUsedIndex = index;
|
|
547
|
+
console.log(`[CustomOptionPool] Reusing component for index ${index}`);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
// Create new component
|
|
551
|
+
try {
|
|
552
|
+
component = factory(item, index);
|
|
553
|
+
console.log(`[CustomOptionPool] Created new component for index ${index}`);
|
|
554
|
+
// Add to pool if under limit
|
|
555
|
+
const pool = this._pool.get(factoryKey) || [];
|
|
556
|
+
if (pool.length < this._maxPoolSize) {
|
|
557
|
+
pool.push({
|
|
558
|
+
instance: component,
|
|
559
|
+
inUse: true,
|
|
560
|
+
lastUsedIndex: index
|
|
561
|
+
});
|
|
562
|
+
this._pool.set(factoryKey, pool);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
console.error(`[CustomOptionPool] Failed to create component:`, error);
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Mount the component
|
|
571
|
+
try {
|
|
572
|
+
component.mountOption(container, context);
|
|
573
|
+
this._activeComponents.set(index, component);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
console.error(`[CustomOptionPool] Failed to mount component at index ${index}:`, error);
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
return component;
|
|
563
580
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Release a component back to the pool
|
|
583
|
+
*
|
|
584
|
+
* @param index - The index of the component to release
|
|
585
|
+
*/
|
|
586
|
+
release(index) {
|
|
587
|
+
const component = this._activeComponents.get(index);
|
|
588
|
+
if (!component)
|
|
589
|
+
return;
|
|
590
|
+
try {
|
|
591
|
+
component.unmountOption();
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
console.error(`[CustomOptionPool] Failed to unmount component at index ${index}:`, error);
|
|
595
|
+
}
|
|
596
|
+
this._activeComponents.delete(index);
|
|
597
|
+
// Mark as available in pool
|
|
598
|
+
for (const pool of this._pool.values()) {
|
|
599
|
+
const pooled = pool.find(p => p.instance === component);
|
|
600
|
+
if (pooled) {
|
|
601
|
+
pooled.inUse = false;
|
|
602
|
+
console.log(`[CustomOptionPool] Released component from index ${index}`);
|
|
574
603
|
break;
|
|
604
|
+
}
|
|
575
605
|
}
|
|
576
606
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
this.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
return this._items;
|
|
607
|
+
/**
|
|
608
|
+
* Release all active components
|
|
609
|
+
*/
|
|
610
|
+
releaseAll() {
|
|
611
|
+
console.log(`[CustomOptionPool] Releasing ${this._activeComponents.size} active components`);
|
|
612
|
+
const indices = Array.from(this._activeComponents.keys());
|
|
613
|
+
indices.forEach(index => this.release(index));
|
|
585
614
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
615
|
+
/**
|
|
616
|
+
* Update selection state for a component
|
|
617
|
+
*
|
|
618
|
+
* @param index - The index of the component
|
|
619
|
+
* @param selected - Whether it's selected
|
|
620
|
+
*/
|
|
621
|
+
updateSelection(index, selected) {
|
|
622
|
+
const component = this._activeComponents.get(index);
|
|
623
|
+
if (component) {
|
|
624
|
+
component.updateSelected(selected);
|
|
590
625
|
}
|
|
591
|
-
|
|
592
|
-
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Update focused state for a component
|
|
629
|
+
*
|
|
630
|
+
* @param index - The index of the component
|
|
631
|
+
* @param focused - Whether it has keyboard focus
|
|
632
|
+
*/
|
|
633
|
+
updateFocused(index, focused) {
|
|
634
|
+
const component = this._activeComponents.get(index);
|
|
635
|
+
if (component && component.updateFocused) {
|
|
636
|
+
component.updateFocused(focused);
|
|
593
637
|
}
|
|
594
638
|
}
|
|
595
|
-
|
|
596
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Get active component at index
|
|
641
|
+
*
|
|
642
|
+
* @param index - The option index
|
|
643
|
+
* @returns The component instance or undefined
|
|
644
|
+
*/
|
|
645
|
+
getComponent(index) {
|
|
646
|
+
return this._activeComponents.get(index);
|
|
597
647
|
}
|
|
598
|
-
|
|
599
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Clear the entire pool
|
|
650
|
+
*/
|
|
651
|
+
clear() {
|
|
652
|
+
this.releaseAll();
|
|
653
|
+
this._pool.clear();
|
|
654
|
+
console.log('[CustomOptionPool] Pool cleared');
|
|
600
655
|
}
|
|
601
|
-
|
|
602
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Get pool statistics for debugging
|
|
658
|
+
*/
|
|
659
|
+
getStats() {
|
|
660
|
+
let totalPooled = 0;
|
|
661
|
+
let availableComponents = 0;
|
|
662
|
+
for (const pool of this._pool.values()) {
|
|
663
|
+
totalPooled += pool.length;
|
|
664
|
+
availableComponents += pool.filter(p => !p.inUse).length;
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
totalPooled,
|
|
668
|
+
activeComponents: this._activeComponents.size,
|
|
669
|
+
availableComponents
|
|
670
|
+
};
|
|
603
671
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
672
|
+
/**
|
|
673
|
+
* Find an available component in the pool
|
|
674
|
+
*/
|
|
675
|
+
_findAvailableComponent(factoryKey) {
|
|
676
|
+
const pool = this._pool.get(factoryKey);
|
|
677
|
+
if (!pool)
|
|
678
|
+
return undefined;
|
|
679
|
+
return pool.find(p => !p.inUse);
|
|
607
680
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
681
|
+
/**
|
|
682
|
+
* Generate a unique key for a factory function
|
|
683
|
+
*/
|
|
684
|
+
_getFactoryKey(factory) {
|
|
685
|
+
// Use function name or create a symbol
|
|
686
|
+
return factory.name || `factory_${factory.toString().slice(0, 50)}`;
|
|
611
687
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Option Renderer
|
|
692
|
+
*
|
|
693
|
+
* Unified renderer that handles both lightweight (label/value) and
|
|
694
|
+
* custom component rendering with consistent performance characteristics.
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* Manages rendering of both lightweight and custom component options
|
|
698
|
+
*/
|
|
699
|
+
class OptionRenderer {
|
|
700
|
+
constructor(config) {
|
|
701
|
+
this._mountedElements = new Map();
|
|
702
|
+
this._config = config;
|
|
703
|
+
this._pool = new CustomOptionPool(config.maxPoolSize);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Render an option (lightweight or custom component)
|
|
707
|
+
*
|
|
708
|
+
* @param item - The data item
|
|
709
|
+
* @param index - The option index
|
|
710
|
+
* @param isSelected - Whether the option is selected
|
|
711
|
+
* @param isFocused - Whether the option has keyboard focus
|
|
712
|
+
* @param uniqueId - Unique ID for the select instance
|
|
713
|
+
* @returns The rendered DOM element
|
|
714
|
+
*/
|
|
715
|
+
render(item, index, isSelected, isFocused, uniqueId) {
|
|
716
|
+
const extendedItem = item;
|
|
717
|
+
const value = this._config.getValue(item);
|
|
718
|
+
const label = this._config.getLabel(item);
|
|
719
|
+
const isDisabled = this._config.getDisabled ? this._config.getDisabled(item) : false;
|
|
720
|
+
// Determine if this is a custom component or lightweight option
|
|
721
|
+
const hasCustomComponent = extendedItem.optionComponent && typeof extendedItem.optionComponent === 'function';
|
|
722
|
+
if (hasCustomComponent) {
|
|
723
|
+
return this._renderCustomComponent(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId, extendedItem.optionComponent);
|
|
724
|
+
}
|
|
620
725
|
else {
|
|
621
|
-
this.
|
|
726
|
+
return this._renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId);
|
|
622
727
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const wrapper = document.createElement('div');
|
|
634
|
-
wrapper.innerHTML = optionTemplate(item, i);
|
|
635
|
-
const el = wrapper.firstElementChild;
|
|
636
|
-
node.replaceChildren(el ?? document.createTextNode(String(item)));
|
|
637
|
-
}
|
|
638
|
-
else {
|
|
639
|
-
node.textContent = String(item);
|
|
640
|
-
}
|
|
641
|
-
});
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Update selection state for an option
|
|
731
|
+
*
|
|
732
|
+
* @param index - The option index
|
|
733
|
+
* @param selected - Whether it's selected
|
|
734
|
+
*/
|
|
735
|
+
updateSelection(index, selected) {
|
|
736
|
+
const element = this._mountedElements.get(index);
|
|
737
|
+
if (!element)
|
|
642
738
|
return;
|
|
739
|
+
// Check if this is a custom component
|
|
740
|
+
const component = this._pool.getComponent(index);
|
|
741
|
+
if (component) {
|
|
742
|
+
component.updateSelected(selected);
|
|
643
743
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (!el.hasAttribute('data-selectable')) {
|
|
650
|
-
el.setAttribute('data-selectable', '');
|
|
651
|
-
el.setAttribute('data-index', String(i));
|
|
652
|
-
}
|
|
653
|
-
this._applyOptionAttrs(el, i);
|
|
654
|
-
frag.appendChild(el);
|
|
655
|
-
}
|
|
656
|
-
else if (optionTemplate) {
|
|
657
|
-
// Fast path: render all via DocumentFragment in one call
|
|
658
|
-
renderTemplate(this._listRoot, this._items, optionTemplate);
|
|
659
|
-
// Apply ARIA attrs after template render
|
|
660
|
-
this._applyAriaToAll();
|
|
661
|
-
return; // rendering complete
|
|
744
|
+
else {
|
|
745
|
+
// Update lightweight option
|
|
746
|
+
if (selected) {
|
|
747
|
+
element.classList.add('selected');
|
|
748
|
+
element.setAttribute('aria-selected', 'true');
|
|
662
749
|
}
|
|
663
750
|
else {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
el.setAttribute('data-selectable', '');
|
|
667
|
-
el.setAttribute('data-index', String(i));
|
|
668
|
-
this._applyOptionAttrs(el, i);
|
|
669
|
-
frag.appendChild(el);
|
|
751
|
+
element.classList.remove('selected');
|
|
752
|
+
element.setAttribute('aria-selected', 'false');
|
|
670
753
|
}
|
|
671
754
|
}
|
|
672
|
-
this._listRoot.replaceChildren(frag);
|
|
673
755
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
756
|
+
/**
|
|
757
|
+
* Update focused state for an option
|
|
758
|
+
*
|
|
759
|
+
* @param index - The option index
|
|
760
|
+
* @param focused - Whether it has keyboard focus
|
|
761
|
+
*/
|
|
762
|
+
updateFocused(index, focused) {
|
|
763
|
+
const element = this._mountedElements.get(index);
|
|
764
|
+
if (!element)
|
|
765
|
+
return;
|
|
766
|
+
// Check if this is a custom component
|
|
767
|
+
const component = this._pool.getComponent(index);
|
|
768
|
+
if (component) {
|
|
769
|
+
if (component.updateFocused) {
|
|
770
|
+
component.updateFocused(focused);
|
|
771
|
+
}
|
|
772
|
+
// Also update the element's focused class for styling
|
|
773
|
+
element.classList.toggle('focused', focused);
|
|
679
774
|
}
|
|
680
775
|
else {
|
|
681
|
-
|
|
776
|
+
// Update lightweight option
|
|
777
|
+
element.classList.toggle('focused', focused);
|
|
682
778
|
}
|
|
683
779
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
780
|
+
/**
|
|
781
|
+
* Unmount and cleanup an option
|
|
782
|
+
*
|
|
783
|
+
* @param index - The option index
|
|
784
|
+
*/
|
|
785
|
+
unmount(index) {
|
|
786
|
+
// Release custom component if exists
|
|
787
|
+
this._pool.release(index);
|
|
788
|
+
// Remove from mounted elements
|
|
789
|
+
this._mountedElements.delete(index);
|
|
691
790
|
}
|
|
692
|
-
|
|
693
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Unmount all options
|
|
793
|
+
*/
|
|
794
|
+
unmountAll() {
|
|
795
|
+
this._pool.releaseAll();
|
|
796
|
+
this._mountedElements.clear();
|
|
694
797
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
798
|
+
/**
|
|
799
|
+
* Get pool statistics
|
|
800
|
+
*/
|
|
801
|
+
getStats() {
|
|
802
|
+
return this._pool.getStats();
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Render a lightweight option (traditional label/value)
|
|
806
|
+
*/
|
|
807
|
+
_renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId) {
|
|
808
|
+
const option = document.createElement('div');
|
|
809
|
+
option.className = 'option';
|
|
810
|
+
if (isSelected)
|
|
811
|
+
option.classList.add('selected');
|
|
812
|
+
if (isFocused)
|
|
813
|
+
option.classList.add('focused');
|
|
814
|
+
if (isDisabled)
|
|
815
|
+
option.classList.add('disabled');
|
|
816
|
+
option.id = `${uniqueId}-option-${index}`;
|
|
817
|
+
option.textContent = label;
|
|
818
|
+
option.dataset.value = String(value);
|
|
819
|
+
option.dataset.index = String(index);
|
|
820
|
+
option.dataset.mode = 'lightweight';
|
|
821
|
+
option.setAttribute('role', 'option');
|
|
822
|
+
option.setAttribute('aria-selected', String(isSelected));
|
|
823
|
+
if (isDisabled) {
|
|
824
|
+
option.setAttribute('aria-disabled', 'true');
|
|
706
825
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
826
|
+
// Click handler
|
|
827
|
+
if (!isDisabled) {
|
|
828
|
+
option.addEventListener('click', () => {
|
|
829
|
+
this._config.onSelect(index);
|
|
830
|
+
});
|
|
712
831
|
}
|
|
713
|
-
this.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
832
|
+
this._mountedElements.set(index, option);
|
|
833
|
+
console.log(`[OptionRenderer] Rendered lightweight option ${index}: ${label}`);
|
|
834
|
+
return option;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Render a custom component option
|
|
838
|
+
*/
|
|
839
|
+
_renderCustomComponent(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId, factory) {
|
|
840
|
+
// Create wrapper container for the custom component
|
|
841
|
+
const wrapper = document.createElement('div');
|
|
842
|
+
wrapper.className = 'option option-custom';
|
|
843
|
+
if (isSelected)
|
|
844
|
+
wrapper.classList.add('selected');
|
|
845
|
+
if (isFocused)
|
|
846
|
+
wrapper.classList.add('focused');
|
|
847
|
+
if (isDisabled)
|
|
848
|
+
wrapper.classList.add('disabled');
|
|
849
|
+
wrapper.id = `${uniqueId}-option-${index}`;
|
|
850
|
+
wrapper.dataset.value = String(value);
|
|
851
|
+
wrapper.dataset.index = String(index);
|
|
852
|
+
wrapper.dataset.mode = 'component';
|
|
853
|
+
wrapper.setAttribute('role', 'option');
|
|
854
|
+
wrapper.setAttribute('aria-selected', String(isSelected));
|
|
855
|
+
wrapper.setAttribute('aria-label', label); // Accessibility fallback
|
|
856
|
+
if (isDisabled) {
|
|
857
|
+
wrapper.setAttribute('aria-disabled', 'true');
|
|
858
|
+
}
|
|
859
|
+
// Create context for the custom component
|
|
860
|
+
const context = {
|
|
718
861
|
item,
|
|
719
862
|
index,
|
|
720
|
-
value
|
|
721
|
-
label
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
switch (e.key) {
|
|
729
|
-
case 'ArrowDown':
|
|
730
|
-
e.preventDefault();
|
|
731
|
-
this._moveActive(1);
|
|
732
|
-
break;
|
|
733
|
-
case 'ArrowUp':
|
|
734
|
-
e.preventDefault();
|
|
735
|
-
this._moveActive(-1);
|
|
736
|
-
break;
|
|
737
|
-
case 'Home':
|
|
738
|
-
e.preventDefault();
|
|
739
|
-
this._setActive(0);
|
|
740
|
-
break;
|
|
741
|
-
case 'End':
|
|
742
|
-
e.preventDefault();
|
|
743
|
-
this._setActive(this._items.length - 1);
|
|
744
|
-
break;
|
|
745
|
-
case 'PageDown':
|
|
746
|
-
e.preventDefault();
|
|
747
|
-
this._moveActive(10);
|
|
748
|
-
break;
|
|
749
|
-
case 'PageUp':
|
|
750
|
-
e.preventDefault();
|
|
751
|
-
this._moveActive(-10);
|
|
752
|
-
break;
|
|
753
|
-
case 'Enter':
|
|
754
|
-
case ' ':
|
|
755
|
-
e.preventDefault();
|
|
756
|
-
if (this._activeIndex >= 0) {
|
|
757
|
-
const item = this._items[this._activeIndex];
|
|
758
|
-
this._onSelect(item, this._activeIndex);
|
|
863
|
+
value,
|
|
864
|
+
label,
|
|
865
|
+
isSelected,
|
|
866
|
+
isFocused,
|
|
867
|
+
isDisabled,
|
|
868
|
+
onSelect: (idx) => {
|
|
869
|
+
if (!isDisabled) {
|
|
870
|
+
this._config.onSelect(idx);
|
|
759
871
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
break;
|
|
765
|
-
default:
|
|
766
|
-
// Type-ahead buffer
|
|
767
|
-
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
768
|
-
this._onType(e.key);
|
|
872
|
+
},
|
|
873
|
+
onCustomEvent: (eventName, data) => {
|
|
874
|
+
if (this._config.onCustomEvent) {
|
|
875
|
+
this._config.onCustomEvent(index, eventName, data);
|
|
769
876
|
}
|
|
770
|
-
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
// Acquire component from pool and mount it
|
|
880
|
+
try {
|
|
881
|
+
const component = this._pool.acquire(factory, item, index, context, wrapper);
|
|
882
|
+
// Get the component's root element and attach click handler to wrapper
|
|
883
|
+
const componentElement = component.getElement();
|
|
884
|
+
if (!isDisabled) {
|
|
885
|
+
// Use event delegation on the wrapper
|
|
886
|
+
wrapper.addEventListener('click', (e) => {
|
|
887
|
+
// Only trigger if clicking within the component
|
|
888
|
+
if (wrapper.contains(e.target)) {
|
|
889
|
+
this._config.onSelect(index);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
console.log(`[OptionRenderer] Rendered custom component option ${index}: ${label}`);
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
console.error(`[OptionRenderer] Failed to render custom component at index ${index}:`, error);
|
|
897
|
+
// Fallback to lightweight rendering on error
|
|
898
|
+
wrapper.innerHTML = '';
|
|
899
|
+
wrapper.textContent = label;
|
|
900
|
+
wrapper.classList.add('component-error');
|
|
901
|
+
if (this._config.onError) {
|
|
902
|
+
this._config.onError(index, error);
|
|
903
|
+
}
|
|
771
904
|
}
|
|
905
|
+
this._mountedElements.set(index, wrapper);
|
|
906
|
+
return wrapper;
|
|
772
907
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
class NativeSelectElement extends HTMLElement {
|
|
911
|
+
static get observedAttributes() {
|
|
912
|
+
return ['placement', 'strategy', 'portal'];
|
|
776
913
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
this.
|
|
780
|
-
this.
|
|
781
|
-
|
|
914
|
+
constructor() {
|
|
915
|
+
super();
|
|
916
|
+
this._options = {};
|
|
917
|
+
this._items = [];
|
|
918
|
+
// Multi-select & interaction state
|
|
919
|
+
this._selectedSet = new Set(); // indices
|
|
920
|
+
this._selectedItems = new Map(); // index -> item
|
|
921
|
+
this._activeIndex = -1;
|
|
922
|
+
this._multi = false;
|
|
923
|
+
this._typeBuffer = '';
|
|
924
|
+
this._shadow = this.attachShadow({ mode: 'open' });
|
|
925
|
+
this._listRoot = document.createElement('div');
|
|
926
|
+
this._listRoot.setAttribute('role', 'listbox');
|
|
927
|
+
this._listRoot.setAttribute('tabindex', '0');
|
|
928
|
+
this._shadow.appendChild(this._listRoot);
|
|
929
|
+
// Live region for screen reader announcements
|
|
930
|
+
this._liveRegion = document.createElement('div');
|
|
931
|
+
this._liveRegion.setAttribute('role', 'status');
|
|
932
|
+
this._liveRegion.setAttribute('aria-live', 'polite');
|
|
933
|
+
this._liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;';
|
|
934
|
+
this._shadow.appendChild(this._liveRegion);
|
|
935
|
+
this._helpers = createRendererHelpers((item, index) => this._onSelect(item, index));
|
|
936
|
+
// Delegated click
|
|
937
|
+
this._listRoot.addEventListener('click', (e) => {
|
|
938
|
+
const el = e.target.closest('[data-selectable]');
|
|
939
|
+
if (!el)
|
|
940
|
+
return;
|
|
941
|
+
const idx = Number(el.dataset.index);
|
|
942
|
+
const item = this._items[idx];
|
|
943
|
+
this._onSelect(item, idx);
|
|
944
|
+
});
|
|
945
|
+
// Keyboard navigation
|
|
946
|
+
this._listRoot.addEventListener('keydown', (e) => this._onKeydown(e));
|
|
782
947
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
948
|
+
connectedCallback() {
|
|
949
|
+
// Initialize ARIA roles and open event
|
|
950
|
+
this._listRoot.setAttribute('role', 'listbox');
|
|
951
|
+
this._listRoot.setAttribute('aria-label', 'Options list');
|
|
952
|
+
if (this._multi)
|
|
953
|
+
this._listRoot.setAttribute('aria-multiselectable', 'true');
|
|
954
|
+
this._initializeOptionRenderer();
|
|
955
|
+
this._emit('open', {});
|
|
786
956
|
}
|
|
787
|
-
|
|
957
|
+
disconnectedCallback() {
|
|
958
|
+
this._emit('close', {});
|
|
959
|
+
// Cleanup unified renderer
|
|
960
|
+
if (this._unifiedRenderer) {
|
|
961
|
+
this._unifiedRenderer.unmountAll();
|
|
962
|
+
}
|
|
963
|
+
// Cleanup: remove listeners if any were added outside constructor
|
|
788
964
|
if (this._typeTimeout)
|
|
789
965
|
window.clearTimeout(this._typeTimeout);
|
|
790
|
-
this._typeBuffer += char.toLowerCase();
|
|
791
|
-
this._typeTimeout = window.setTimeout(() => {
|
|
792
|
-
this._typeBuffer = '';
|
|
793
|
-
}, 400);
|
|
794
|
-
// Find first matching item
|
|
795
|
-
const match = this._items.findIndex((item) => String(item).toLowerCase().startsWith(this._typeBuffer));
|
|
796
|
-
if (match >= 0) {
|
|
797
|
-
this._setActive(match);
|
|
798
|
-
}
|
|
799
966
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
enabled: true,
|
|
833
|
-
multiSelectTarget: 'first',
|
|
834
|
-
behavior: 'smooth',
|
|
835
|
-
block: 'nearest',
|
|
836
|
-
},
|
|
837
|
-
loadMore: {
|
|
838
|
-
enabled: false,
|
|
839
|
-
itemsPerLoad: 3,
|
|
840
|
-
threshold: 100,
|
|
841
|
-
showLoader: true,
|
|
842
|
-
},
|
|
843
|
-
busyBucket: {
|
|
844
|
-
enabled: true,
|
|
845
|
-
showSpinner: true,
|
|
846
|
-
message: 'Loading...',
|
|
847
|
-
minDisplayTime: 200,
|
|
848
|
-
},
|
|
849
|
-
styles: {
|
|
850
|
-
classNames: {},
|
|
851
|
-
},
|
|
852
|
-
serverSide: {
|
|
853
|
-
enabled: false,
|
|
854
|
-
getValueFromItem: (item) => item?.value ?? item,
|
|
855
|
-
getLabelFromItem: (item) => item?.label ?? String(item),
|
|
856
|
-
},
|
|
857
|
-
infiniteScroll: {
|
|
858
|
-
enabled: false,
|
|
859
|
-
pageSize: 20,
|
|
860
|
-
initialPage: 1,
|
|
861
|
-
cachePages: true,
|
|
862
|
-
maxCachedPages: 10,
|
|
863
|
-
preloadAdjacent: true,
|
|
864
|
-
scrollRestoration: 'auto',
|
|
865
|
-
},
|
|
866
|
-
expandable: {
|
|
867
|
-
enabled: false,
|
|
868
|
-
collapsedHeight: '300px',
|
|
869
|
-
expandedHeight: '500px',
|
|
870
|
-
expandLabel: 'Show more',
|
|
871
|
-
collapseLabel: 'Show less',
|
|
872
|
-
},
|
|
873
|
-
callbacks: {},
|
|
874
|
-
enabled: true,
|
|
875
|
-
searchable: false,
|
|
876
|
-
placeholder: 'Select an option...',
|
|
877
|
-
virtualize: true,
|
|
878
|
-
estimatedItemHeight: 48,
|
|
879
|
-
};
|
|
880
|
-
/**
|
|
881
|
-
* Global configuration instance
|
|
882
|
-
*/
|
|
883
|
-
class SelectConfigManager {
|
|
884
|
-
constructor() {
|
|
885
|
-
this.config = this.deepClone(defaultConfig);
|
|
967
|
+
_initializeOptionRenderer() {
|
|
968
|
+
const getValue = (item) => item?.value ?? item;
|
|
969
|
+
const getLabel = (item) => item?.label ?? String(item);
|
|
970
|
+
const getDisabled = (item) => item?.disabled ?? false;
|
|
971
|
+
const rendererConfig = {
|
|
972
|
+
enableRecycling: true,
|
|
973
|
+
maxPoolSize: 100,
|
|
974
|
+
getValue,
|
|
975
|
+
getLabel,
|
|
976
|
+
getDisabled,
|
|
977
|
+
onSelect: (index) => {
|
|
978
|
+
const item = this._items[index];
|
|
979
|
+
this._onSelect(item, index);
|
|
980
|
+
},
|
|
981
|
+
onCustomEvent: (index, eventName, data) => {
|
|
982
|
+
// Emit custom events from option components
|
|
983
|
+
this.dispatchEvent(new CustomEvent('option:custom-event', {
|
|
984
|
+
detail: { index, eventName, data },
|
|
985
|
+
bubbles: true,
|
|
986
|
+
composed: true
|
|
987
|
+
}));
|
|
988
|
+
},
|
|
989
|
+
onError: (index, error) => {
|
|
990
|
+
console.error(`[NativeSelect] Error in option ${index}:`, error);
|
|
991
|
+
this.dispatchEvent(new CustomEvent('option:mount-error', {
|
|
992
|
+
detail: { index, error },
|
|
993
|
+
bubbles: true,
|
|
994
|
+
composed: true
|
|
995
|
+
}));
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
this._unifiedRenderer = new OptionRenderer(rendererConfig);
|
|
886
999
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1000
|
+
attributeChangedCallback(name, _oldValue, newValue) {
|
|
1001
|
+
switch (name) {
|
|
1002
|
+
case 'placement':
|
|
1003
|
+
this._options.placement = (newValue ?? undefined);
|
|
1004
|
+
break;
|
|
1005
|
+
case 'strategy':
|
|
1006
|
+
this._options.strategy = (newValue ?? undefined);
|
|
1007
|
+
break;
|
|
1008
|
+
case 'portal':
|
|
1009
|
+
this._options.portal = newValue === 'true' ? true : newValue === 'false' ? false : undefined;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
892
1012
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
this.
|
|
1013
|
+
set items(items) {
|
|
1014
|
+
this._items = items ?? [];
|
|
1015
|
+
// initialize virtualizer with estimated height (default 48) and buffer
|
|
1016
|
+
this._virtualizer = new Virtualizer(this._listRoot, this._items.length, (i) => this._items[i], { estimatedItemHeight: 48, buffer: 5 });
|
|
1017
|
+
this.render();
|
|
898
1018
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
*/
|
|
902
|
-
resetConfig() {
|
|
903
|
-
this.config = this.deepClone(defaultConfig);
|
|
1019
|
+
get items() {
|
|
1020
|
+
return this._items;
|
|
904
1021
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1022
|
+
set multi(value) {
|
|
1023
|
+
this._multi = value;
|
|
1024
|
+
if (value) {
|
|
1025
|
+
this._listRoot.setAttribute('aria-multiselectable', 'true');
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
this._listRoot.removeAttribute('aria-multiselectable');
|
|
1029
|
+
}
|
|
911
1030
|
}
|
|
912
|
-
|
|
913
|
-
return
|
|
1031
|
+
get multi() {
|
|
1032
|
+
return this._multi;
|
|
914
1033
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
for (const key in source) {
|
|
918
|
-
if (source.hasOwnProperty(key)) {
|
|
919
|
-
const sourceValue = source[key];
|
|
920
|
-
const targetValue = result[key];
|
|
921
|
-
if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
|
|
922
|
-
result[key] = this.deepMerge(targetValue && typeof targetValue === 'object' ? targetValue : {}, sourceValue);
|
|
923
|
-
}
|
|
924
|
-
else {
|
|
925
|
-
result[key] = sourceValue;
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
return result;
|
|
1034
|
+
get selectedIndices() {
|
|
1035
|
+
return Array.from(this._selectedSet);
|
|
930
1036
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* Helper function to reset select configuration
|
|
944
|
-
*/
|
|
945
|
-
function resetSelectConfig() {
|
|
946
|
-
selectConfig.resetConfig();
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
/**
|
|
950
|
-
* Custom Option Component Pool
|
|
951
|
-
*
|
|
952
|
-
* Manages lifecycle and recycling of custom option components for optimal performance.
|
|
953
|
-
* Uses object pooling pattern to minimize allocation/deallocation overhead.
|
|
954
|
-
*/
|
|
955
|
-
/**
|
|
956
|
-
* Manages a pool of reusable custom option component instances
|
|
957
|
-
*/
|
|
958
|
-
class CustomOptionPool {
|
|
959
|
-
constructor(maxPoolSize = 50) {
|
|
960
|
-
this._pool = new Map();
|
|
961
|
-
this._activeComponents = new Map();
|
|
962
|
-
this._maxPoolSize = maxPoolSize;
|
|
1037
|
+
get selectedItems() {
|
|
1038
|
+
return Array.from(this._selectedItems.values());
|
|
1039
|
+
}
|
|
1040
|
+
set optionTemplate(template) {
|
|
1041
|
+
this._options.optionTemplate = template;
|
|
1042
|
+
this.render();
|
|
1043
|
+
}
|
|
1044
|
+
set optionRenderer(renderer) {
|
|
1045
|
+
this._options.optionRenderer = renderer;
|
|
1046
|
+
this.render();
|
|
963
1047
|
}
|
|
964
1048
|
/**
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
* @param context - Context for mounting
|
|
971
|
-
* @param container - DOM container for mounting
|
|
972
|
-
* @returns Component instance
|
|
1049
|
+
* Public API: setItems() method for compatibility with EnhancedSelect
|
|
1050
|
+
* Accepts an array of items which can be:
|
|
1051
|
+
* - Simple primitives (string, number)
|
|
1052
|
+
* - Objects with {label, value} structure
|
|
1053
|
+
* - Objects with {label, value, optionComponent} for custom rendering (v1.2.0+)
|
|
973
1054
|
*/
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1055
|
+
setItems(items) {
|
|
1056
|
+
this.items = items ?? [];
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Public API: setValue() method to programmatically select an item
|
|
1060
|
+
* For single select: clears selection and selects the item matching the value
|
|
1061
|
+
* For multi-select: adds to selection if not already selected
|
|
1062
|
+
*/
|
|
1063
|
+
setValue(value) {
|
|
1064
|
+
if (value === null || value === undefined || value === '') {
|
|
1065
|
+
// Clear selection
|
|
1066
|
+
this._selectedSet.clear();
|
|
1067
|
+
this._selectedItems.clear();
|
|
1068
|
+
this._activeIndex = -1;
|
|
1069
|
+
this.render();
|
|
1070
|
+
return;
|
|
985
1071
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
console.log(`[CustomOptionPool] Created new component for index ${index}`);
|
|
991
|
-
// Add to pool if under limit
|
|
992
|
-
const pool = this._pool.get(factoryKey) || [];
|
|
993
|
-
if (pool.length < this._maxPoolSize) {
|
|
994
|
-
pool.push({
|
|
995
|
-
instance: component,
|
|
996
|
-
inUse: true,
|
|
997
|
-
lastUsedIndex: index
|
|
998
|
-
});
|
|
999
|
-
this._pool.set(factoryKey, pool);
|
|
1000
|
-
}
|
|
1072
|
+
// Find item by value
|
|
1073
|
+
const index = this._items.findIndex(item => {
|
|
1074
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
1075
|
+
return item.value === value;
|
|
1001
1076
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1077
|
+
return item === value;
|
|
1078
|
+
});
|
|
1079
|
+
if (index >= 0) {
|
|
1080
|
+
const item = this._items[index];
|
|
1081
|
+
if (!this._multi) {
|
|
1082
|
+
// Single select: clear and set
|
|
1083
|
+
this._selectedSet.clear();
|
|
1084
|
+
this._selectedItems.clear();
|
|
1005
1085
|
}
|
|
1086
|
+
this._selectedSet.add(index);
|
|
1087
|
+
this._selectedItems.set(index, item);
|
|
1088
|
+
this._activeIndex = index;
|
|
1089
|
+
this.render();
|
|
1006
1090
|
}
|
|
1007
|
-
// Mount the component
|
|
1008
|
-
try {
|
|
1009
|
-
component.mountOption(container, context);
|
|
1010
|
-
this._activeComponents.set(index, component);
|
|
1011
|
-
}
|
|
1012
|
-
catch (error) {
|
|
1013
|
-
console.error(`[CustomOptionPool] Failed to mount component at index ${index}:`, error);
|
|
1014
|
-
throw error;
|
|
1015
|
-
}
|
|
1016
|
-
return component;
|
|
1017
1091
|
}
|
|
1018
1092
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
* @param index - The index of the component to release
|
|
1093
|
+
* Public API: getValue() method to get currently selected value(s)
|
|
1094
|
+
* Returns single value for single-select, array for multi-select
|
|
1022
1095
|
*/
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1096
|
+
getValue() {
|
|
1097
|
+
const values = Array.from(this._selectedItems.values()).map(item => {
|
|
1098
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
1099
|
+
return item.value;
|
|
1100
|
+
}
|
|
1101
|
+
return item;
|
|
1102
|
+
});
|
|
1103
|
+
return this._multi ? values : (values[0] ?? null);
|
|
1104
|
+
}
|
|
1105
|
+
render() {
|
|
1106
|
+
const { optionTemplate, optionRenderer } = this._options;
|
|
1107
|
+
const viewportHeight = this.getBoundingClientRect().height || 300;
|
|
1108
|
+
const scrollTop = this.scrollTop || 0;
|
|
1109
|
+
// Update aria-activedescendant
|
|
1110
|
+
if (this._activeIndex >= 0) {
|
|
1111
|
+
this._listRoot.setAttribute('aria-activedescendant', `option-${this._activeIndex}`);
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
this._listRoot.removeAttribute('aria-activedescendant');
|
|
1115
|
+
}
|
|
1116
|
+
// Check if any items have custom components
|
|
1117
|
+
const hasCustomComponents = this._items.some(item => typeof item === 'object' && item !== null && 'optionComponent' in item);
|
|
1118
|
+
// Use unified renderer if we have custom components
|
|
1119
|
+
if (hasCustomComponents && this._unifiedRenderer) {
|
|
1120
|
+
this._listRoot.replaceChildren(); // Clear existing content
|
|
1121
|
+
const frag = document.createDocumentFragment();
|
|
1122
|
+
for (let i = 0; i < this._items.length; i++) {
|
|
1123
|
+
const item = this._items[i];
|
|
1124
|
+
const isSelected = this._selectedSet.has(i);
|
|
1125
|
+
const isFocused = this._activeIndex === i;
|
|
1126
|
+
const optionElement = this._unifiedRenderer.render(item, i, isSelected, isFocused, `native-${this.getAttribute('id') || 'default'}`);
|
|
1127
|
+
frag.appendChild(optionElement);
|
|
1128
|
+
}
|
|
1129
|
+
this._listRoot.appendChild(frag);
|
|
1026
1130
|
return;
|
|
1027
|
-
try {
|
|
1028
|
-
component.unmountOption();
|
|
1029
1131
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1132
|
+
// Fall back to original rendering logic for lightweight options
|
|
1133
|
+
if (this._virtualizer) {
|
|
1134
|
+
const { startIndex, endIndex } = this._virtualizer.computeWindow(scrollTop, viewportHeight);
|
|
1135
|
+
this._virtualizer.render(startIndex, endIndex, (node, item, i) => {
|
|
1136
|
+
this._applyOptionAttrs(node, i);
|
|
1137
|
+
if (optionRenderer) {
|
|
1138
|
+
const el = optionRenderer(item, i, this._helpers);
|
|
1139
|
+
// replace node contents
|
|
1140
|
+
node.replaceChildren(el);
|
|
1141
|
+
}
|
|
1142
|
+
else if (optionTemplate) {
|
|
1143
|
+
const wrapper = document.createElement('div');
|
|
1144
|
+
wrapper.innerHTML = optionTemplate(item, i);
|
|
1145
|
+
const el = wrapper.firstElementChild;
|
|
1146
|
+
node.replaceChildren(el ?? document.createTextNode(String(item)));
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
// Handle {label, value} objects or primitives
|
|
1150
|
+
const displayText = (typeof item === 'object' && item !== null && 'label' in item)
|
|
1151
|
+
? String(item.label)
|
|
1152
|
+
: String(item);
|
|
1153
|
+
node.textContent = displayText;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const frag = document.createDocumentFragment();
|
|
1159
|
+
for (let i = 0; i < this._items.length; i++) {
|
|
1160
|
+
const item = this._items[i];
|
|
1161
|
+
if (optionRenderer) {
|
|
1162
|
+
const el = optionRenderer(item, i, this._helpers);
|
|
1163
|
+
if (!el.hasAttribute('data-selectable')) {
|
|
1164
|
+
el.setAttribute('data-selectable', '');
|
|
1165
|
+
el.setAttribute('data-index', String(i));
|
|
1166
|
+
}
|
|
1167
|
+
this._applyOptionAttrs(el, i);
|
|
1168
|
+
frag.appendChild(el);
|
|
1169
|
+
}
|
|
1170
|
+
else if (optionTemplate) {
|
|
1171
|
+
// Fast path: render all via DocumentFragment in one call
|
|
1172
|
+
renderTemplate(this._listRoot, this._items, optionTemplate);
|
|
1173
|
+
// Apply ARIA attrs after template render
|
|
1174
|
+
this._applyAriaToAll();
|
|
1175
|
+
return; // rendering complete
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
const el = document.createElement('div');
|
|
1179
|
+
// Handle {label, value} objects or primitives
|
|
1180
|
+
const displayText = (typeof item === 'object' && item !== null && 'label' in item)
|
|
1181
|
+
? String(item.label)
|
|
1182
|
+
: String(item);
|
|
1183
|
+
el.textContent = displayText;
|
|
1184
|
+
el.setAttribute('data-selectable', '');
|
|
1185
|
+
el.setAttribute('data-index', String(i));
|
|
1186
|
+
this._applyOptionAttrs(el, i);
|
|
1187
|
+
frag.appendChild(el);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this._listRoot.replaceChildren(frag);
|
|
1191
|
+
}
|
|
1192
|
+
_applyOptionAttrs(node, index) {
|
|
1193
|
+
node.setAttribute('role', 'option');
|
|
1194
|
+
node.id = `option-${index}`;
|
|
1195
|
+
if (this._selectedSet.has(index)) {
|
|
1196
|
+
node.setAttribute('aria-selected', 'true');
|
|
1032
1197
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
for (const pool of this._pool.values()) {
|
|
1036
|
-
const pooled = pool.find(p => p.instance === component);
|
|
1037
|
-
if (pooled) {
|
|
1038
|
-
pooled.inUse = false;
|
|
1039
|
-
console.log(`[CustomOptionPool] Released component from index ${index}`);
|
|
1040
|
-
break;
|
|
1041
|
-
}
|
|
1198
|
+
else {
|
|
1199
|
+
node.setAttribute('aria-selected', 'false');
|
|
1042
1200
|
}
|
|
1043
1201
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1202
|
+
_applyAriaToAll() {
|
|
1203
|
+
const children = Array.from(this._listRoot.children);
|
|
1204
|
+
for (const child of children) {
|
|
1205
|
+
const idx = Number(child.dataset.index);
|
|
1206
|
+
if (Number.isFinite(idx))
|
|
1207
|
+
this._applyOptionAttrs(child, idx);
|
|
1208
|
+
}
|
|
1051
1209
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1210
|
+
_emit(name, detail) {
|
|
1211
|
+
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
|
1212
|
+
}
|
|
1213
|
+
// Multi-select and interaction methods
|
|
1214
|
+
_onSelect(item, index) {
|
|
1215
|
+
if (this._multi) {
|
|
1216
|
+
if (this._selectedSet.has(index)) {
|
|
1217
|
+
this._selectedSet.delete(index);
|
|
1218
|
+
this._selectedItems.delete(index);
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
this._selectedSet.add(index);
|
|
1222
|
+
this._selectedItems.set(index, item);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
this._selectedSet.clear();
|
|
1227
|
+
this._selectedItems.clear();
|
|
1228
|
+
this._selectedSet.add(index);
|
|
1229
|
+
this._selectedItems.set(index, item);
|
|
1062
1230
|
}
|
|
1231
|
+
this._activeIndex = index;
|
|
1232
|
+
this.render();
|
|
1233
|
+
// Emit with all required fields
|
|
1234
|
+
const selected = this._selectedSet.has(index);
|
|
1235
|
+
const value = item?.value ?? item;
|
|
1236
|
+
const label = item?.label ?? String(item);
|
|
1237
|
+
this._emit('select', {
|
|
1238
|
+
item,
|
|
1239
|
+
index,
|
|
1240
|
+
value,
|
|
1241
|
+
label,
|
|
1242
|
+
selected,
|
|
1243
|
+
multi: this._multi
|
|
1244
|
+
});
|
|
1245
|
+
// Emit 'change' event for better React compatibility
|
|
1246
|
+
this._emit('change', {
|
|
1247
|
+
selectedItems: Array.from(this._selectedItems.values()),
|
|
1248
|
+
selectedValues: Array.from(this._selectedItems.values()).map(i => i?.value ?? i),
|
|
1249
|
+
selectedIndices: Array.from(this._selectedSet)
|
|
1250
|
+
});
|
|
1251
|
+
this._announce(`Selected ${label}`);
|
|
1063
1252
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1253
|
+
_onKeydown(e) {
|
|
1254
|
+
switch (e.key) {
|
|
1255
|
+
case 'ArrowDown':
|
|
1256
|
+
e.preventDefault();
|
|
1257
|
+
this._moveActive(1);
|
|
1258
|
+
break;
|
|
1259
|
+
case 'ArrowUp':
|
|
1260
|
+
e.preventDefault();
|
|
1261
|
+
this._moveActive(-1);
|
|
1262
|
+
break;
|
|
1263
|
+
case 'Home':
|
|
1264
|
+
e.preventDefault();
|
|
1265
|
+
this._setActive(0);
|
|
1266
|
+
break;
|
|
1267
|
+
case 'End':
|
|
1268
|
+
e.preventDefault();
|
|
1269
|
+
this._setActive(this._items.length - 1);
|
|
1270
|
+
break;
|
|
1271
|
+
case 'PageDown':
|
|
1272
|
+
e.preventDefault();
|
|
1273
|
+
this._moveActive(10);
|
|
1274
|
+
break;
|
|
1275
|
+
case 'PageUp':
|
|
1276
|
+
e.preventDefault();
|
|
1277
|
+
this._moveActive(-10);
|
|
1278
|
+
break;
|
|
1279
|
+
case 'Enter':
|
|
1280
|
+
case ' ':
|
|
1281
|
+
e.preventDefault();
|
|
1282
|
+
if (this._activeIndex >= 0) {
|
|
1283
|
+
const item = this._items[this._activeIndex];
|
|
1284
|
+
this._onSelect(item, this._activeIndex);
|
|
1285
|
+
}
|
|
1286
|
+
break;
|
|
1287
|
+
case 'Escape':
|
|
1288
|
+
e.preventDefault();
|
|
1289
|
+
this._emit('close', {});
|
|
1290
|
+
break;
|
|
1291
|
+
default:
|
|
1292
|
+
// Type-ahead buffer
|
|
1293
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
1294
|
+
this._onType(e.key);
|
|
1295
|
+
}
|
|
1296
|
+
break;
|
|
1074
1297
|
}
|
|
1075
1298
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
* @param index - The option index
|
|
1080
|
-
* @returns The component instance or undefined
|
|
1081
|
-
*/
|
|
1082
|
-
getComponent(index) {
|
|
1083
|
-
return this._activeComponents.get(index);
|
|
1299
|
+
_moveActive(delta) {
|
|
1300
|
+
const next = Math.max(0, Math.min(this._items.length - 1, this._activeIndex + delta));
|
|
1301
|
+
this._setActive(next);
|
|
1084
1302
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
this.
|
|
1090
|
-
this._pool.clear();
|
|
1091
|
-
console.log('[CustomOptionPool] Pool cleared');
|
|
1303
|
+
_setActive(index) {
|
|
1304
|
+
this._activeIndex = index;
|
|
1305
|
+
this.render();
|
|
1306
|
+
this._scrollToActive();
|
|
1307
|
+
this._announce(`Navigated to ${String(this._items[index])}`);
|
|
1092
1308
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1309
|
+
_scrollToActive() {
|
|
1310
|
+
const el = this._shadow.getElementById(`option-${this._activeIndex}`);
|
|
1311
|
+
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1312
|
+
}
|
|
1313
|
+
_onType(char) {
|
|
1314
|
+
if (this._typeTimeout)
|
|
1315
|
+
window.clearTimeout(this._typeTimeout);
|
|
1316
|
+
this._typeBuffer += char.toLowerCase();
|
|
1317
|
+
this._typeTimeout = window.setTimeout(() => {
|
|
1318
|
+
this._typeBuffer = '';
|
|
1319
|
+
}, 400);
|
|
1320
|
+
// Find first matching item
|
|
1321
|
+
const match = this._items.findIndex((item) => String(item).toLowerCase().startsWith(this._typeBuffer));
|
|
1322
|
+
if (match >= 0) {
|
|
1323
|
+
this._setActive(match);
|
|
1102
1324
|
}
|
|
1103
|
-
return {
|
|
1104
|
-
totalPooled,
|
|
1105
|
-
activeComponents: this._activeComponents.size,
|
|
1106
|
-
availableComponents
|
|
1107
|
-
};
|
|
1108
1325
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1326
|
+
_announce(msg) {
|
|
1327
|
+
if (this._liveRegion) {
|
|
1328
|
+
this._liveRegion.textContent = msg;
|
|
1329
|
+
setTimeout(() => {
|
|
1330
|
+
if (this._liveRegion)
|
|
1331
|
+
this._liveRegion.textContent = '';
|
|
1332
|
+
}, 1000);
|
|
1333
|
+
}
|
|
1117
1334
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
_getFactoryKey(factory) {
|
|
1122
|
-
// Use function name or create a symbol
|
|
1123
|
-
return factory.name || `factory_${factory.toString().slice(0, 50)}`;
|
|
1335
|
+
// Focus management
|
|
1336
|
+
focus() {
|
|
1337
|
+
this._listRoot.focus();
|
|
1124
1338
|
}
|
|
1125
1339
|
}
|
|
1340
|
+
customElements.define('smilodon-select', NativeSelectElement);
|
|
1126
1341
|
|
|
1127
1342
|
/**
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
|
|
1131
|
-
|
|
1343
|
+
* Global Configuration System for Select Components
|
|
1344
|
+
* Allows users to define default behaviors that can be overridden at component level
|
|
1345
|
+
*/
|
|
1346
|
+
/**
|
|
1347
|
+
* Default global configuration
|
|
1132
1348
|
*/
|
|
1349
|
+
const defaultConfig = {
|
|
1350
|
+
selection: {
|
|
1351
|
+
mode: 'single',
|
|
1352
|
+
allowDeselect: false,
|
|
1353
|
+
maxSelections: 0,
|
|
1354
|
+
showRemoveButton: true,
|
|
1355
|
+
closeOnSelect: true,
|
|
1356
|
+
},
|
|
1357
|
+
scrollToSelected: {
|
|
1358
|
+
enabled: true,
|
|
1359
|
+
multiSelectTarget: 'first',
|
|
1360
|
+
behavior: 'smooth',
|
|
1361
|
+
block: 'nearest',
|
|
1362
|
+
},
|
|
1363
|
+
loadMore: {
|
|
1364
|
+
enabled: false,
|
|
1365
|
+
itemsPerLoad: 3,
|
|
1366
|
+
threshold: 100,
|
|
1367
|
+
showLoader: true,
|
|
1368
|
+
},
|
|
1369
|
+
busyBucket: {
|
|
1370
|
+
enabled: true,
|
|
1371
|
+
showSpinner: true,
|
|
1372
|
+
message: 'Loading...',
|
|
1373
|
+
minDisplayTime: 200,
|
|
1374
|
+
},
|
|
1375
|
+
styles: {
|
|
1376
|
+
classNames: {},
|
|
1377
|
+
},
|
|
1378
|
+
serverSide: {
|
|
1379
|
+
enabled: false,
|
|
1380
|
+
getValueFromItem: (item) => item?.value ?? item,
|
|
1381
|
+
getLabelFromItem: (item) => item?.label ?? String(item),
|
|
1382
|
+
},
|
|
1383
|
+
infiniteScroll: {
|
|
1384
|
+
enabled: false,
|
|
1385
|
+
pageSize: 20,
|
|
1386
|
+
initialPage: 1,
|
|
1387
|
+
cachePages: true,
|
|
1388
|
+
maxCachedPages: 10,
|
|
1389
|
+
preloadAdjacent: true,
|
|
1390
|
+
scrollRestoration: 'auto',
|
|
1391
|
+
},
|
|
1392
|
+
expandable: {
|
|
1393
|
+
enabled: false,
|
|
1394
|
+
collapsedHeight: '300px',
|
|
1395
|
+
expandedHeight: '500px',
|
|
1396
|
+
expandLabel: 'Show more',
|
|
1397
|
+
collapseLabel: 'Show less',
|
|
1398
|
+
},
|
|
1399
|
+
callbacks: {},
|
|
1400
|
+
enabled: true,
|
|
1401
|
+
searchable: false,
|
|
1402
|
+
placeholder: 'Select an option...',
|
|
1403
|
+
virtualize: true,
|
|
1404
|
+
estimatedItemHeight: 48,
|
|
1405
|
+
};
|
|
1133
1406
|
/**
|
|
1134
|
-
*
|
|
1407
|
+
* Global configuration instance
|
|
1135
1408
|
*/
|
|
1136
|
-
class
|
|
1137
|
-
constructor(
|
|
1138
|
-
this.
|
|
1139
|
-
this._config = config;
|
|
1140
|
-
this._pool = new CustomOptionPool(config.maxPoolSize);
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* Render an option (lightweight or custom component)
|
|
1144
|
-
*
|
|
1145
|
-
* @param item - The data item
|
|
1146
|
-
* @param index - The option index
|
|
1147
|
-
* @param isSelected - Whether the option is selected
|
|
1148
|
-
* @param isFocused - Whether the option has keyboard focus
|
|
1149
|
-
* @param uniqueId - Unique ID for the select instance
|
|
1150
|
-
* @returns The rendered DOM element
|
|
1151
|
-
*/
|
|
1152
|
-
render(item, index, isSelected, isFocused, uniqueId) {
|
|
1153
|
-
const extendedItem = item;
|
|
1154
|
-
const value = this._config.getValue(item);
|
|
1155
|
-
const label = this._config.getLabel(item);
|
|
1156
|
-
const isDisabled = this._config.getDisabled ? this._config.getDisabled(item) : false;
|
|
1157
|
-
// Determine if this is a custom component or lightweight option
|
|
1158
|
-
const hasCustomComponent = extendedItem.optionComponent && typeof extendedItem.optionComponent === 'function';
|
|
1159
|
-
if (hasCustomComponent) {
|
|
1160
|
-
return this._renderCustomComponent(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId, extendedItem.optionComponent);
|
|
1161
|
-
}
|
|
1162
|
-
else {
|
|
1163
|
-
return this._renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
/**
|
|
1167
|
-
* Update selection state for an option
|
|
1168
|
-
*
|
|
1169
|
-
* @param index - The option index
|
|
1170
|
-
* @param selected - Whether it's selected
|
|
1171
|
-
*/
|
|
1172
|
-
updateSelection(index, selected) {
|
|
1173
|
-
const element = this._mountedElements.get(index);
|
|
1174
|
-
if (!element)
|
|
1175
|
-
return;
|
|
1176
|
-
// Check if this is a custom component
|
|
1177
|
-
const component = this._pool.getComponent(index);
|
|
1178
|
-
if (component) {
|
|
1179
|
-
component.updateSelected(selected);
|
|
1180
|
-
}
|
|
1181
|
-
else {
|
|
1182
|
-
// Update lightweight option
|
|
1183
|
-
if (selected) {
|
|
1184
|
-
element.classList.add('selected');
|
|
1185
|
-
element.setAttribute('aria-selected', 'true');
|
|
1186
|
-
}
|
|
1187
|
-
else {
|
|
1188
|
-
element.classList.remove('selected');
|
|
1189
|
-
element.setAttribute('aria-selected', 'false');
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1409
|
+
class SelectConfigManager {
|
|
1410
|
+
constructor() {
|
|
1411
|
+
this.config = this.deepClone(defaultConfig);
|
|
1192
1412
|
}
|
|
1193
1413
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
* @param index - The option index
|
|
1197
|
-
* @param focused - Whether it has keyboard focus
|
|
1414
|
+
* Get current global configuration
|
|
1198
1415
|
*/
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
if (!element)
|
|
1202
|
-
return;
|
|
1203
|
-
// Check if this is a custom component
|
|
1204
|
-
const component = this._pool.getComponent(index);
|
|
1205
|
-
if (component) {
|
|
1206
|
-
if (component.updateFocused) {
|
|
1207
|
-
component.updateFocused(focused);
|
|
1208
|
-
}
|
|
1209
|
-
// Also update the element's focused class for styling
|
|
1210
|
-
element.classList.toggle('focused', focused);
|
|
1211
|
-
}
|
|
1212
|
-
else {
|
|
1213
|
-
// Update lightweight option
|
|
1214
|
-
element.classList.toggle('focused', focused);
|
|
1215
|
-
}
|
|
1416
|
+
getConfig() {
|
|
1417
|
+
return this.config;
|
|
1216
1418
|
}
|
|
1217
1419
|
/**
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
* @param index - The option index
|
|
1420
|
+
* Update global configuration (deep merge)
|
|
1221
1421
|
*/
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
this._pool.release(index);
|
|
1225
|
-
// Remove from mounted elements
|
|
1226
|
-
this._mountedElements.delete(index);
|
|
1422
|
+
updateConfig(updates) {
|
|
1423
|
+
this.config = this.deepMerge(this.config, updates);
|
|
1227
1424
|
}
|
|
1228
1425
|
/**
|
|
1229
|
-
*
|
|
1426
|
+
* Reset to default configuration
|
|
1230
1427
|
*/
|
|
1231
|
-
|
|
1232
|
-
this.
|
|
1233
|
-
this._mountedElements.clear();
|
|
1428
|
+
resetConfig() {
|
|
1429
|
+
this.config = this.deepClone(defaultConfig);
|
|
1234
1430
|
}
|
|
1235
1431
|
/**
|
|
1236
|
-
*
|
|
1432
|
+
* Merge component-level config with global config
|
|
1433
|
+
* Component-level config takes precedence
|
|
1237
1434
|
*/
|
|
1238
|
-
|
|
1239
|
-
return this.
|
|
1435
|
+
mergeWithComponentConfig(componentConfig) {
|
|
1436
|
+
return this.deepMerge(this.deepClone(this.config), componentConfig);
|
|
1240
1437
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
*/
|
|
1244
|
-
_renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId) {
|
|
1245
|
-
const option = document.createElement('div');
|
|
1246
|
-
option.className = 'option';
|
|
1247
|
-
if (isSelected)
|
|
1248
|
-
option.classList.add('selected');
|
|
1249
|
-
if (isFocused)
|
|
1250
|
-
option.classList.add('focused');
|
|
1251
|
-
if (isDisabled)
|
|
1252
|
-
option.classList.add('disabled');
|
|
1253
|
-
option.id = `${uniqueId}-option-${index}`;
|
|
1254
|
-
option.textContent = label;
|
|
1255
|
-
option.dataset.value = String(value);
|
|
1256
|
-
option.dataset.index = String(index);
|
|
1257
|
-
option.dataset.mode = 'lightweight';
|
|
1258
|
-
option.setAttribute('role', 'option');
|
|
1259
|
-
option.setAttribute('aria-selected', String(isSelected));
|
|
1260
|
-
if (isDisabled) {
|
|
1261
|
-
option.setAttribute('aria-disabled', 'true');
|
|
1262
|
-
}
|
|
1263
|
-
// Click handler
|
|
1264
|
-
if (!isDisabled) {
|
|
1265
|
-
option.addEventListener('click', () => {
|
|
1266
|
-
this._config.onSelect(index);
|
|
1267
|
-
});
|
|
1268
|
-
}
|
|
1269
|
-
this._mountedElements.set(index, option);
|
|
1270
|
-
console.log(`[OptionRenderer] Rendered lightweight option ${index}: ${label}`);
|
|
1271
|
-
return option;
|
|
1438
|
+
deepClone(obj) {
|
|
1439
|
+
return JSON.parse(JSON.stringify(obj));
|
|
1272
1440
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
wrapper.classList.add('selected');
|
|
1282
|
-
if (isFocused)
|
|
1283
|
-
wrapper.classList.add('focused');
|
|
1284
|
-
if (isDisabled)
|
|
1285
|
-
wrapper.classList.add('disabled');
|
|
1286
|
-
wrapper.id = `${uniqueId}-option-${index}`;
|
|
1287
|
-
wrapper.dataset.value = String(value);
|
|
1288
|
-
wrapper.dataset.index = String(index);
|
|
1289
|
-
wrapper.dataset.mode = 'component';
|
|
1290
|
-
wrapper.setAttribute('role', 'option');
|
|
1291
|
-
wrapper.setAttribute('aria-selected', String(isSelected));
|
|
1292
|
-
wrapper.setAttribute('aria-label', label); // Accessibility fallback
|
|
1293
|
-
if (isDisabled) {
|
|
1294
|
-
wrapper.setAttribute('aria-disabled', 'true');
|
|
1295
|
-
}
|
|
1296
|
-
// Create context for the custom component
|
|
1297
|
-
const context = {
|
|
1298
|
-
item,
|
|
1299
|
-
index,
|
|
1300
|
-
value,
|
|
1301
|
-
label,
|
|
1302
|
-
isSelected,
|
|
1303
|
-
isFocused,
|
|
1304
|
-
isDisabled,
|
|
1305
|
-
onSelect: (idx) => {
|
|
1306
|
-
if (!isDisabled) {
|
|
1307
|
-
this._config.onSelect(idx);
|
|
1441
|
+
deepMerge(target, source) {
|
|
1442
|
+
const result = { ...target };
|
|
1443
|
+
for (const key in source) {
|
|
1444
|
+
if (source.hasOwnProperty(key)) {
|
|
1445
|
+
const sourceValue = source[key];
|
|
1446
|
+
const targetValue = result[key];
|
|
1447
|
+
if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
|
|
1448
|
+
result[key] = this.deepMerge(targetValue && typeof targetValue === 'object' ? targetValue : {}, sourceValue);
|
|
1308
1449
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
if (this._config.onCustomEvent) {
|
|
1312
|
-
this._config.onCustomEvent(index, eventName, data);
|
|
1450
|
+
else {
|
|
1451
|
+
result[key] = sourceValue;
|
|
1313
1452
|
}
|
|
1314
1453
|
}
|
|
1315
|
-
};
|
|
1316
|
-
// Acquire component from pool and mount it
|
|
1317
|
-
try {
|
|
1318
|
-
const component = this._pool.acquire(factory, item, index, context, wrapper);
|
|
1319
|
-
// Get the component's root element and attach click handler to wrapper
|
|
1320
|
-
const componentElement = component.getElement();
|
|
1321
|
-
if (!isDisabled) {
|
|
1322
|
-
// Use event delegation on the wrapper
|
|
1323
|
-
wrapper.addEventListener('click', (e) => {
|
|
1324
|
-
// Only trigger if clicking within the component
|
|
1325
|
-
if (wrapper.contains(e.target)) {
|
|
1326
|
-
this._config.onSelect(index);
|
|
1327
|
-
}
|
|
1328
|
-
});
|
|
1329
|
-
}
|
|
1330
|
-
console.log(`[OptionRenderer] Rendered custom component option ${index}: ${label}`);
|
|
1331
|
-
}
|
|
1332
|
-
catch (error) {
|
|
1333
|
-
console.error(`[OptionRenderer] Failed to render custom component at index ${index}:`, error);
|
|
1334
|
-
// Fallback to lightweight rendering on error
|
|
1335
|
-
wrapper.innerHTML = '';
|
|
1336
|
-
wrapper.textContent = label;
|
|
1337
|
-
wrapper.classList.add('component-error');
|
|
1338
|
-
if (this._config.onError) {
|
|
1339
|
-
this._config.onError(index, error);
|
|
1340
|
-
}
|
|
1341
1454
|
}
|
|
1342
|
-
|
|
1343
|
-
return wrapper;
|
|
1455
|
+
return result;
|
|
1344
1456
|
}
|
|
1345
1457
|
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Singleton instance
|
|
1460
|
+
*/
|
|
1461
|
+
const selectConfig = new SelectConfigManager();
|
|
1462
|
+
/**
|
|
1463
|
+
* Helper function to configure select globally
|
|
1464
|
+
*/
|
|
1465
|
+
function configureSelect(config) {
|
|
1466
|
+
selectConfig.updateConfig(config);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Helper function to reset select configuration
|
|
1470
|
+
*/
|
|
1471
|
+
function resetSelectConfig() {
|
|
1472
|
+
selectConfig.resetConfig();
|
|
1473
|
+
}
|
|
1346
1474
|
|
|
1347
1475
|
/**
|
|
1348
1476
|
* Enhanced Select Component
|