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