@smilodon/core 1.2.1 → 1.2.2

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