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