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