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