@smilodon/core 1.2.1 → 1.2.3

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