@smilodon/core 1.2.0 → 1.2.2

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