@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.cjs CHANGED
@@ -511,913 +511,968 @@ class Virtualizer {
511
511
  }
512
512
  }
513
513
 
514
- class NativeSelectElement extends HTMLElement {
515
- static get observedAttributes() {
516
- return ['placement', 'strategy', 'portal'];
517
- }
518
- constructor() {
519
- super();
520
- this._options = {};
521
- this._items = [];
522
- // Multi-select & interaction state
523
- this._selectedSet = new Set(); // indices
524
- this._selectedItems = new Map(); // index -> item
525
- this._activeIndex = -1;
526
- this._multi = false;
527
- this._typeBuffer = '';
528
- this._shadow = this.attachShadow({ mode: 'open' });
529
- this._listRoot = document.createElement('div');
530
- this._listRoot.setAttribute('role', 'listbox');
531
- this._listRoot.setAttribute('tabindex', '0');
532
- this._shadow.appendChild(this._listRoot);
533
- // Live region for screen reader announcements
534
- this._liveRegion = document.createElement('div');
535
- this._liveRegion.setAttribute('role', 'status');
536
- this._liveRegion.setAttribute('aria-live', 'polite');
537
- this._liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;';
538
- this._shadow.appendChild(this._liveRegion);
539
- this._helpers = createRendererHelpers((item, index) => this._onSelect(item, index));
540
- // Delegated click
541
- this._listRoot.addEventListener('click', (e) => {
542
- const el = e.target.closest('[data-selectable]');
543
- if (!el)
544
- return;
545
- const idx = Number(el.dataset.index);
546
- const item = this._items[idx];
547
- this._onSelect(item, idx);
548
- });
549
- // Keyboard navigation
550
- this._listRoot.addEventListener('keydown', (e) => this._onKeydown(e));
551
- }
552
- connectedCallback() {
553
- // Initialize ARIA roles and open event
554
- this._listRoot.setAttribute('role', 'listbox');
555
- this._listRoot.setAttribute('aria-label', 'Options list');
556
- if (this._multi)
557
- this._listRoot.setAttribute('aria-multiselectable', 'true');
558
- this._emit('open', {});
514
+ /**
515
+ * Custom Option Component Pool
516
+ *
517
+ * Manages lifecycle and recycling of custom option components for optimal performance.
518
+ * Uses object pooling pattern to minimize allocation/deallocation overhead.
519
+ */
520
+ /**
521
+ * Manages a pool of reusable custom option component instances
522
+ */
523
+ class CustomOptionPool {
524
+ constructor(maxPoolSize = 50) {
525
+ this._pool = new Map();
526
+ this._activeComponents = new Map();
527
+ this._maxPoolSize = maxPoolSize;
559
528
  }
560
- disconnectedCallback() {
561
- this._emit('close', {});
562
- // Cleanup: remove listeners if any were added outside constructor
563
- if (this._typeTimeout)
564
- window.clearTimeout(this._typeTimeout);
529
+ /**
530
+ * Get or create a component instance for the given index
531
+ *
532
+ * @param factory - Factory function to create new instances
533
+ * @param item - The data item
534
+ * @param index - The option index
535
+ * @param context - Context for mounting
536
+ * @param container - DOM container for mounting
537
+ * @returns Component instance
538
+ */
539
+ acquire(factory, item, index, context, container) {
540
+ const factoryKey = this._getFactoryKey(factory);
541
+ // Try to find an available component in the pool
542
+ const pooled = this._findAvailableComponent(factoryKey);
543
+ let component;
544
+ if (pooled) {
545
+ // Reuse pooled component
546
+ component = pooled.instance;
547
+ pooled.inUse = true;
548
+ pooled.lastUsedIndex = index;
549
+ console.log(`[CustomOptionPool] Reusing component for index ${index}`);
550
+ }
551
+ else {
552
+ // Create new component
553
+ try {
554
+ component = factory(item, index);
555
+ console.log(`[CustomOptionPool] Created new component for index ${index}`);
556
+ // Add to pool if under limit
557
+ const pool = this._pool.get(factoryKey) || [];
558
+ if (pool.length < this._maxPoolSize) {
559
+ pool.push({
560
+ instance: component,
561
+ inUse: true,
562
+ lastUsedIndex: index
563
+ });
564
+ this._pool.set(factoryKey, pool);
565
+ }
566
+ }
567
+ catch (error) {
568
+ console.error(`[CustomOptionPool] Failed to create component:`, error);
569
+ throw error;
570
+ }
571
+ }
572
+ // Mount the component
573
+ try {
574
+ component.mountOption(container, context);
575
+ this._activeComponents.set(index, component);
576
+ }
577
+ catch (error) {
578
+ console.error(`[CustomOptionPool] Failed to mount component at index ${index}:`, error);
579
+ throw error;
580
+ }
581
+ return component;
565
582
  }
566
- attributeChangedCallback(name, _oldValue, newValue) {
567
- switch (name) {
568
- case 'placement':
569
- this._options.placement = (newValue ?? undefined);
570
- break;
571
- case 'strategy':
572
- this._options.strategy = (newValue ?? undefined);
573
- break;
574
- case 'portal':
575
- this._options.portal = newValue === 'true' ? true : newValue === 'false' ? false : undefined;
583
+ /**
584
+ * Release a component back to the pool
585
+ *
586
+ * @param index - The index of the component to release
587
+ */
588
+ release(index) {
589
+ const component = this._activeComponents.get(index);
590
+ if (!component)
591
+ return;
592
+ try {
593
+ component.unmountOption();
594
+ }
595
+ catch (error) {
596
+ console.error(`[CustomOptionPool] Failed to unmount component at index ${index}:`, error);
597
+ }
598
+ this._activeComponents.delete(index);
599
+ // Mark as available in pool
600
+ for (const pool of this._pool.values()) {
601
+ const pooled = pool.find(p => p.instance === component);
602
+ if (pooled) {
603
+ pooled.inUse = false;
604
+ console.log(`[CustomOptionPool] Released component from index ${index}`);
576
605
  break;
606
+ }
577
607
  }
578
608
  }
579
- set items(items) {
580
- this._items = items ?? [];
581
- // initialize virtualizer with estimated height (default 48) and buffer
582
- this._virtualizer = new Virtualizer(this._listRoot, this._items.length, (i) => this._items[i], { estimatedItemHeight: 48, buffer: 5 });
583
- this.render();
584
- }
585
- get items() {
586
- return this._items;
609
+ /**
610
+ * Release all active components
611
+ */
612
+ releaseAll() {
613
+ console.log(`[CustomOptionPool] Releasing ${this._activeComponents.size} active components`);
614
+ const indices = Array.from(this._activeComponents.keys());
615
+ indices.forEach(index => this.release(index));
587
616
  }
588
- set multi(value) {
589
- this._multi = value;
590
- if (value) {
591
- this._listRoot.setAttribute('aria-multiselectable', 'true');
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' && item !== null && 'optionComponent' in item);
1120
+ // Use unified renderer if we have custom components
1121
+ if (hasCustomComponents && this._unifiedRenderer) {
1122
+ this._listRoot.replaceChildren(); // Clear existing content
1123
+ const frag = document.createDocumentFragment();
1124
+ for (let i = 0; i < this._items.length; i++) {
1125
+ const item = this._items[i];
1126
+ const isSelected = this._selectedSet.has(i);
1127
+ const isFocused = this._activeIndex === i;
1128
+ const optionElement = this._unifiedRenderer.render(item, i, isSelected, isFocused, `native-${this.getAttribute('id') || 'default'}`);
1129
+ frag.appendChild(optionElement);
1080
1130
  }
1131
+ this._listRoot.appendChild(frag);
1132
+ return;
1081
1133
  }
1082
- // Mount the component
1083
- try {
1084
- component.mountOption(container, context);
1085
- this._activeComponents.set(index, component);
1134
+ // Fall back to original rendering logic for lightweight options
1135
+ if (this._virtualizer) {
1136
+ const { startIndex, endIndex } = this._virtualizer.computeWindow(scrollTop, viewportHeight);
1137
+ this._virtualizer.render(startIndex, endIndex, (node, item, i) => {
1138
+ this._applyOptionAttrs(node, i);
1139
+ if (optionRenderer) {
1140
+ const el = optionRenderer(item, i, this._helpers);
1141
+ // replace node contents
1142
+ node.replaceChildren(el);
1143
+ }
1144
+ else if (optionTemplate) {
1145
+ const wrapper = document.createElement('div');
1146
+ wrapper.innerHTML = optionTemplate(item, i);
1147
+ const el = wrapper.firstElementChild;
1148
+ node.replaceChildren(el ?? document.createTextNode(String(item)));
1149
+ }
1150
+ else {
1151
+ // Handle {label, value} objects or primitives
1152
+ const displayText = (typeof item === 'object' && item !== null && 'label' in item)
1153
+ ? String(item.label)
1154
+ : String(item);
1155
+ node.textContent = displayText;
1156
+ }
1157
+ });
1158
+ return;
1086
1159
  }
1087
- catch (error) {
1088
- console.error(`[CustomOptionPool] Failed to mount component at index ${index}:`, error);
1089
- throw error;
1160
+ const frag = document.createDocumentFragment();
1161
+ for (let i = 0; i < this._items.length; i++) {
1162
+ const item = this._items[i];
1163
+ if (optionRenderer) {
1164
+ const el = optionRenderer(item, i, this._helpers);
1165
+ if (!el.hasAttribute('data-selectable')) {
1166
+ el.setAttribute('data-selectable', '');
1167
+ el.setAttribute('data-index', String(i));
1168
+ }
1169
+ this._applyOptionAttrs(el, i);
1170
+ frag.appendChild(el);
1171
+ }
1172
+ else if (optionTemplate) {
1173
+ // Fast path: render all via DocumentFragment in one call
1174
+ renderTemplate(this._listRoot, this._items, optionTemplate);
1175
+ // Apply ARIA attrs after template render
1176
+ this._applyAriaToAll();
1177
+ return; // rendering complete
1178
+ }
1179
+ else {
1180
+ const el = document.createElement('div');
1181
+ // Handle {label, value} objects or primitives
1182
+ const displayText = (typeof item === 'object' && item !== null && 'label' in item)
1183
+ ? String(item.label)
1184
+ : String(item);
1185
+ el.textContent = displayText;
1186
+ el.setAttribute('data-selectable', '');
1187
+ el.setAttribute('data-index', String(i));
1188
+ this._applyOptionAttrs(el, i);
1189
+ frag.appendChild(el);
1190
+ }
1090
1191
  }
1091
- return component;
1192
+ this._listRoot.replaceChildren(frag);
1092
1193
  }
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();
1194
+ _applyOptionAttrs(node, index) {
1195
+ node.setAttribute('role', 'option');
1196
+ node.id = `option-${index}`;
1197
+ if (this._selectedSet.has(index)) {
1198
+ node.setAttribute('aria-selected', 'true');
1104
1199
  }
1105
- catch (error) {
1106
- console.error(`[CustomOptionPool] Failed to unmount component at index ${index}:`, error);
1200
+ else {
1201
+ node.setAttribute('aria-selected', 'false');
1107
1202
  }
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
- }
1203
+ }
1204
+ _applyAriaToAll() {
1205
+ const children = Array.from(this._listRoot.children);
1206
+ for (const child of children) {
1207
+ const idx = Number(child.dataset.index);
1208
+ if (Number.isFinite(idx))
1209
+ this._applyOptionAttrs(child, idx);
1117
1210
  }
1118
1211
  }
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));
1212
+ _emit(name, detail) {
1213
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
1126
1214
  }
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);
1215
+ // Multi-select and interaction methods
1216
+ _onSelect(item, index) {
1217
+ if (this._multi) {
1218
+ if (this._selectedSet.has(index)) {
1219
+ this._selectedSet.delete(index);
1220
+ this._selectedItems.delete(index);
1221
+ }
1222
+ else {
1223
+ this._selectedSet.add(index);
1224
+ this._selectedItems.set(index, item);
1225
+ }
1226
+ }
1227
+ else {
1228
+ this._selectedSet.clear();
1229
+ this._selectedItems.clear();
1230
+ this._selectedSet.add(index);
1231
+ this._selectedItems.set(index, item);
1137
1232
  }
1233
+ this._activeIndex = index;
1234
+ this.render();
1235
+ // Emit with all required fields
1236
+ const selected = this._selectedSet.has(index);
1237
+ const value = item?.value ?? item;
1238
+ const label = item?.label ?? String(item);
1239
+ this._emit('select', {
1240
+ item,
1241
+ index,
1242
+ value,
1243
+ label,
1244
+ selected,
1245
+ multi: this._multi
1246
+ });
1247
+ // Emit 'change' event for better React compatibility
1248
+ this._emit('change', {
1249
+ selectedItems: Array.from(this._selectedItems.values()),
1250
+ selectedValues: Array.from(this._selectedItems.values()).map(i => i?.value ?? i),
1251
+ selectedIndices: Array.from(this._selectedSet)
1252
+ });
1253
+ this._announce(`Selected ${label}`);
1138
1254
  }
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);
1255
+ _onKeydown(e) {
1256
+ switch (e.key) {
1257
+ case 'ArrowDown':
1258
+ e.preventDefault();
1259
+ this._moveActive(1);
1260
+ break;
1261
+ case 'ArrowUp':
1262
+ e.preventDefault();
1263
+ this._moveActive(-1);
1264
+ break;
1265
+ case 'Home':
1266
+ e.preventDefault();
1267
+ this._setActive(0);
1268
+ break;
1269
+ case 'End':
1270
+ e.preventDefault();
1271
+ this._setActive(this._items.length - 1);
1272
+ break;
1273
+ case 'PageDown':
1274
+ e.preventDefault();
1275
+ this._moveActive(10);
1276
+ break;
1277
+ case 'PageUp':
1278
+ e.preventDefault();
1279
+ this._moveActive(-10);
1280
+ break;
1281
+ case 'Enter':
1282
+ case ' ':
1283
+ e.preventDefault();
1284
+ if (this._activeIndex >= 0) {
1285
+ const item = this._items[this._activeIndex];
1286
+ this._onSelect(item, this._activeIndex);
1287
+ }
1288
+ break;
1289
+ case 'Escape':
1290
+ e.preventDefault();
1291
+ this._emit('close', {});
1292
+ break;
1293
+ default:
1294
+ // Type-ahead buffer
1295
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
1296
+ this._onType(e.key);
1297
+ }
1298
+ break;
1149
1299
  }
1150
1300
  }
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);
1301
+ _moveActive(delta) {
1302
+ const next = Math.max(0, Math.min(this._items.length - 1, this._activeIndex + delta));
1303
+ this._setActive(next);
1159
1304
  }
1160
- /**
1161
- * Clear the entire pool
1162
- */
1163
- clear() {
1164
- this.releaseAll();
1165
- this._pool.clear();
1166
- console.log('[CustomOptionPool] Pool cleared');
1305
+ _setActive(index) {
1306
+ this._activeIndex = index;
1307
+ this.render();
1308
+ this._scrollToActive();
1309
+ this._announce(`Navigated to ${String(this._items[index])}`);
1167
1310
  }
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;
1311
+ _scrollToActive() {
1312
+ const el = this._shadow.getElementById(`option-${this._activeIndex}`);
1313
+ el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1314
+ }
1315
+ _onType(char) {
1316
+ if (this._typeTimeout)
1317
+ window.clearTimeout(this._typeTimeout);
1318
+ this._typeBuffer += char.toLowerCase();
1319
+ this._typeTimeout = window.setTimeout(() => {
1320
+ this._typeBuffer = '';
1321
+ }, 400);
1322
+ // Find first matching item
1323
+ const match = this._items.findIndex((item) => String(item).toLowerCase().startsWith(this._typeBuffer));
1324
+ if (match >= 0) {
1325
+ this._setActive(match);
1177
1326
  }
1178
- return {
1179
- totalPooled,
1180
- activeComponents: this._activeComponents.size,
1181
- availableComponents
1182
- };
1183
1327
  }
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);
1328
+ _announce(msg) {
1329
+ if (this._liveRegion) {
1330
+ this._liveRegion.textContent = msg;
1331
+ setTimeout(() => {
1332
+ if (this._liveRegion)
1333
+ this._liveRegion.textContent = '';
1334
+ }, 1000);
1335
+ }
1192
1336
  }
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)}`;
1337
+ // Focus management
1338
+ focus() {
1339
+ this._listRoot.focus();
1199
1340
  }
1200
1341
  }
1342
+ customElements.define('smilodon-select', NativeSelectElement);
1201
1343
 
1202
1344
  /**
1203
- * Option Renderer
1204
- *
1205
- * Unified renderer that handles both lightweight (label/value) and
1206
- * custom component rendering with consistent performance characteristics.
1345
+ * Global Configuration System for Select Components
1346
+ * Allows users to define default behaviors that can be overridden at component level
1347
+ */
1348
+ /**
1349
+ * Default global configuration
1207
1350
  */
1351
+ const defaultConfig = {
1352
+ selection: {
1353
+ mode: 'single',
1354
+ allowDeselect: false,
1355
+ maxSelections: 0,
1356
+ showRemoveButton: true,
1357
+ closeOnSelect: true,
1358
+ },
1359
+ scrollToSelected: {
1360
+ enabled: true,
1361
+ multiSelectTarget: 'first',
1362
+ behavior: 'smooth',
1363
+ block: 'nearest',
1364
+ },
1365
+ loadMore: {
1366
+ enabled: false,
1367
+ itemsPerLoad: 3,
1368
+ threshold: 100,
1369
+ showLoader: true,
1370
+ },
1371
+ busyBucket: {
1372
+ enabled: true,
1373
+ showSpinner: true,
1374
+ message: 'Loading...',
1375
+ minDisplayTime: 200,
1376
+ },
1377
+ styles: {
1378
+ classNames: {},
1379
+ },
1380
+ serverSide: {
1381
+ enabled: false,
1382
+ getValueFromItem: (item) => item?.value ?? item,
1383
+ getLabelFromItem: (item) => item?.label ?? String(item),
1384
+ },
1385
+ infiniteScroll: {
1386
+ enabled: false,
1387
+ pageSize: 20,
1388
+ initialPage: 1,
1389
+ cachePages: true,
1390
+ maxCachedPages: 10,
1391
+ preloadAdjacent: true,
1392
+ scrollRestoration: 'auto',
1393
+ },
1394
+ expandable: {
1395
+ enabled: false,
1396
+ collapsedHeight: '300px',
1397
+ expandedHeight: '500px',
1398
+ expandLabel: 'Show more',
1399
+ collapseLabel: 'Show less',
1400
+ },
1401
+ callbacks: {},
1402
+ enabled: true,
1403
+ searchable: false,
1404
+ placeholder: 'Select an option...',
1405
+ virtualize: true,
1406
+ estimatedItemHeight: 48,
1407
+ };
1208
1408
  /**
1209
- * Manages rendering of both lightweight and custom component options
1409
+ * Global configuration instance
1210
1410
  */
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
- }
1411
+ class SelectConfigManager {
1412
+ constructor() {
1413
+ this.config = this.deepClone(defaultConfig);
1267
1414
  }
1268
1415
  /**
1269
- * Update focused state for an option
1270
- *
1271
- * @param index - The option index
1272
- * @param focused - Whether it has keyboard focus
1416
+ * Get current global configuration
1273
1417
  */
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
- }
1418
+ getConfig() {
1419
+ return this.config;
1291
1420
  }
1292
1421
  /**
1293
- * Unmount and cleanup an option
1294
- *
1295
- * @param index - The option index
1422
+ * Update global configuration (deep merge)
1296
1423
  */
1297
- unmount(index) {
1298
- // Release custom component if exists
1299
- this._pool.release(index);
1300
- // Remove from mounted elements
1301
- this._mountedElements.delete(index);
1424
+ updateConfig(updates) {
1425
+ this.config = this.deepMerge(this.config, updates);
1302
1426
  }
1303
1427
  /**
1304
- * Unmount all options
1428
+ * Reset to default configuration
1305
1429
  */
1306
- unmountAll() {
1307
- this._pool.releaseAll();
1308
- this._mountedElements.clear();
1430
+ resetConfig() {
1431
+ this.config = this.deepClone(defaultConfig);
1309
1432
  }
1310
1433
  /**
1311
- * Get pool statistics
1434
+ * Merge component-level config with global config
1435
+ * Component-level config takes precedence
1312
1436
  */
1313
- getStats() {
1314
- return this._pool.getStats();
1437
+ mergeWithComponentConfig(componentConfig) {
1438
+ return this.deepMerge(this.deepClone(this.config), componentConfig);
1315
1439
  }
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;
1440
+ deepClone(obj) {
1441
+ return JSON.parse(JSON.stringify(obj));
1347
1442
  }
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);
1443
+ deepMerge(target, source) {
1444
+ const result = { ...target };
1445
+ for (const key in source) {
1446
+ if (source.hasOwnProperty(key)) {
1447
+ const sourceValue = source[key];
1448
+ const targetValue = result[key];
1449
+ if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
1450
+ result[key] = this.deepMerge(targetValue && typeof targetValue === 'object' ? targetValue : {}, sourceValue);
1383
1451
  }
1384
- },
1385
- onCustomEvent: (eventName, data) => {
1386
- if (this._config.onCustomEvent) {
1387
- this._config.onCustomEvent(index, eventName, data);
1452
+ else {
1453
+ result[key] = sourceValue;
1388
1454
  }
1389
1455
  }
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
1456
  }
1417
- this._mountedElements.set(index, wrapper);
1418
- return wrapper;
1457
+ return result;
1419
1458
  }
1420
1459
  }
1460
+ /**
1461
+ * Singleton instance
1462
+ */
1463
+ const selectConfig = new SelectConfigManager();
1464
+ /**
1465
+ * Helper function to configure select globally
1466
+ */
1467
+ function configureSelect(config) {
1468
+ selectConfig.updateConfig(config);
1469
+ }
1470
+ /**
1471
+ * Helper function to reset select configuration
1472
+ */
1473
+ function resetSelectConfig() {
1474
+ selectConfig.resetConfig();
1475
+ }
1421
1476
 
1422
1477
  /**
1423
1478
  * Enhanced Select Component