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