@smilodon/core 1.3.6 → 1.3.10

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
@@ -1439,7 +1439,7 @@ class SelectConfigManager {
1439
1439
  deepMerge(target, source) {
1440
1440
  const result = { ...target };
1441
1441
  for (const key in source) {
1442
- if (source.hasOwnProperty(key)) {
1442
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
1443
1443
  const sourceValue = source[key];
1444
1444
  const targetValue = result[key];
1445
1445
  if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
@@ -1471,182 +1471,20 @@ function resetSelectConfig() {
1471
1471
  }
1472
1472
 
1473
1473
  /**
1474
- * Enhanced Select Component
1475
- * Implements all advanced features: infinite scroll, load more, busy state,
1476
- * server-side selection, and full customization
1474
+ * Independent Option Component
1475
+ * High cohesion, low coupling - handles its own selection state and events
1477
1476
  */
1478
- class EnhancedSelect extends HTMLElement {
1479
- constructor() {
1477
+ class SelectOption extends HTMLElement {
1478
+ constructor(config) {
1480
1479
  super();
1481
- this._pageCache = {};
1482
- this._typeBuffer = '';
1483
- this._hasError = false;
1484
- this._errorMessage = '';
1485
- this._boundArrowClick = null;
1480
+ this._config = config;
1486
1481
  this._shadow = this.attachShadow({ mode: 'open' });
1487
- this._uniqueId = `enhanced-select-${Math.random().toString(36).substr(2, 9)}`;
1488
- // Merge global config with component-level config
1489
- this._config = selectConfig.getConfig();
1490
- // Initialize state
1491
- this._state = {
1492
- isOpen: false,
1493
- isBusy: false,
1494
- isSearching: false,
1495
- currentPage: this._config.infiniteScroll.initialPage || 1,
1496
- totalPages: 1,
1497
- selectedIndices: new Set(),
1498
- selectedItems: new Map(),
1499
- activeIndex: -1,
1500
- searchQuery: '',
1501
- loadedItems: [],
1502
- groupedItems: [],
1503
- preserveScrollPosition: false,
1504
- lastScrollPosition: 0,
1505
- lastNotifiedQuery: null,
1506
- lastNotifiedResultCount: 0,
1507
- isExpanded: false,
1508
- };
1509
- // Create DOM structure
1510
- this._container = this._createContainer();
1511
- this._inputContainer = this._createInputContainer();
1512
- this._input = this._createInput();
1513
- this._arrowContainer = this._createArrowContainer();
1514
- this._dropdown = this._createDropdown();
1515
- this._optionsContainer = this._createOptionsContainer();
1516
- this._liveRegion = this._createLiveRegion();
1517
- // Initialize styles BEFORE assembling DOM (order matters in shadow DOM)
1482
+ this._container = document.createElement('div');
1483
+ this._container.className = 'option-container';
1518
1484
  this._initializeStyles();
1519
- this._assembleDOM();
1485
+ this._render();
1520
1486
  this._attachEventListeners();
1521
- this._initializeObservers();
1522
- }
1523
- connectedCallback() {
1524
- // WORKAROUND: Force display style on host element for Angular compatibility
1525
- // Angular's rendering seems to not apply :host styles correctly in some cases
1526
- // Must be done in connectedCallback when element is attached to DOM
1527
- this.style.display = 'block';
1528
- this.style.width = '100%';
1529
- // Load initial data if server-side is enabled
1530
- if (this._config.serverSide.enabled && this._config.serverSide.initialSelectedValues) {
1531
- this._loadInitialSelectedItems();
1532
- }
1533
- // Emit open event if configured to start open
1534
- if (this._config.callbacks.onOpen) {
1535
- this._config.callbacks.onOpen();
1536
- }
1537
- }
1538
- disconnectedCallback() {
1539
- // Cleanup observers
1540
- this._resizeObserver?.disconnect();
1541
- this._intersectionObserver?.disconnect();
1542
- if (this._busyTimeout)
1543
- clearTimeout(this._busyTimeout);
1544
- if (this._typeTimeout)
1545
- clearTimeout(this._typeTimeout);
1546
- if (this._searchTimeout)
1547
- clearTimeout(this._searchTimeout);
1548
- // Cleanup arrow click listener
1549
- if (this._boundArrowClick && this._arrowContainer) {
1550
- this._arrowContainer.removeEventListener('click', this._boundArrowClick);
1551
- }
1552
- }
1553
- _createContainer() {
1554
- const container = document.createElement('div');
1555
- container.className = 'select-container';
1556
- if (this._config.styles.classNames?.container) {
1557
- container.className += ' ' + this._config.styles.classNames.container;
1558
- }
1559
- if (this._config.styles.container) {
1560
- Object.assign(container.style, this._config.styles.container);
1561
- }
1562
- return container;
1563
- }
1564
- _createInputContainer() {
1565
- const container = document.createElement('div');
1566
- container.className = 'input-container';
1567
- return container;
1568
- }
1569
- _createInput() {
1570
- const input = document.createElement('input');
1571
- input.type = 'text';
1572
- input.className = 'select-input';
1573
- input.placeholder = this._config.placeholder || 'Select an option...';
1574
- input.disabled = !this._config.enabled;
1575
- input.readOnly = !this._config.searchable;
1576
- // Update readonly when input is focused if searchable
1577
- input.addEventListener('focus', () => {
1578
- if (this._config.searchable) {
1579
- input.readOnly = false;
1580
- }
1581
- });
1582
- if (this._config.styles.classNames?.input) {
1583
- input.className += ' ' + this._config.styles.classNames.input;
1584
- }
1585
- if (this._config.styles.input) {
1586
- Object.assign(input.style, this._config.styles.input);
1587
- }
1588
- input.setAttribute('role', 'combobox');
1589
- input.setAttribute('aria-expanded', 'false');
1590
- input.setAttribute('aria-haspopup', 'listbox');
1591
- input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
1592
- return input;
1593
- }
1594
- _createDropdown() {
1595
- const dropdown = document.createElement('div');
1596
- dropdown.className = 'select-dropdown';
1597
- dropdown.style.display = 'none';
1598
- if (this._config.styles.classNames?.dropdown) {
1599
- dropdown.className += ' ' + this._config.styles.classNames.dropdown;
1600
- }
1601
- if (this._config.styles.dropdown) {
1602
- Object.assign(dropdown.style, this._config.styles.dropdown);
1603
- }
1604
- dropdown.setAttribute('role', 'listbox');
1605
- if (this._config.selection.mode === 'multi') {
1606
- dropdown.setAttribute('aria-multiselectable', 'true');
1607
- }
1608
- return dropdown;
1609
- }
1610
- _createOptionsContainer() {
1611
- const container = document.createElement('div');
1612
- container.className = 'options-container';
1613
- return container;
1614
- }
1615
- _createLiveRegion() {
1616
- const liveRegion = document.createElement('div');
1617
- liveRegion.setAttribute('role', 'status');
1618
- liveRegion.setAttribute('aria-live', 'polite');
1619
- liveRegion.setAttribute('aria-atomic', 'true');
1620
- liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;';
1621
- return liveRegion;
1622
- }
1623
- _createArrowContainer() {
1624
- const container = document.createElement('div');
1625
- container.className = 'dropdown-arrow-container';
1626
- container.innerHTML = `
1627
- <svg class="dropdown-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1628
- <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1629
- </svg>
1630
- `;
1631
- return container;
1632
- }
1633
- _assembleDOM() {
1634
- this._inputContainer.appendChild(this._input);
1635
- if (this._arrowContainer) {
1636
- this._inputContainer.appendChild(this._arrowContainer);
1637
- }
1638
- this._container.appendChild(this._inputContainer);
1639
- this._dropdown.appendChild(this._optionsContainer);
1640
- this._container.appendChild(this._dropdown);
1641
1487
  this._shadow.appendChild(this._container);
1642
- if (this._liveRegion) {
1643
- this._shadow.appendChild(this._liveRegion);
1644
- }
1645
- // Set ARIA relationships
1646
- const listboxId = `${this._uniqueId}-listbox`;
1647
- this._dropdown.id = listboxId;
1648
- this._input.setAttribute('aria-controls', listboxId);
1649
- this._input.setAttribute('aria-owns', listboxId);
1650
1488
  }
1651
1489
  _initializeStyles() {
1652
1490
  const style = document.createElement('style');
@@ -1654,1613 +1492,2075 @@ class EnhancedSelect extends HTMLElement {
1654
1492
  :host {
1655
1493
  display: block;
1656
1494
  position: relative;
1657
- width: 100%;
1658
- }
1659
-
1660
- .select-container {
1661
- position: relative;
1662
- width: 100%;
1663
- }
1664
-
1665
- .input-container {
1666
- position: relative;
1667
- width: 100%;
1668
- display: flex;
1669
- align-items: center;
1670
- flex-wrap: wrap;
1671
- gap: var(--select-input-gap, 6px);
1672
- padding: var(--select-input-padding, 6px 52px 6px 8px);
1673
- min-height: var(--select-input-min-height, 44px);
1674
- background: var(--select-input-bg, white);
1675
- border: var(--select-input-border, 1px solid #d1d5db);
1676
- border-radius: var(--select-input-border-radius, 6px);
1677
- box-sizing: border-box;
1678
- transition: all 0.2s ease;
1679
- }
1680
-
1681
- .input-container:focus-within {
1682
- border-color: var(--select-input-focus-border, #667eea);
1683
- box-shadow: var(--select-input-focus-shadow, 0 0 0 3px rgba(102, 126, 234, 0.1));
1684
- }
1685
-
1686
- /* Gradient separator before arrow */
1687
- .input-container::after {
1688
- content: '';
1689
- position: absolute;
1690
- top: 50%;
1691
- right: var(--select-separator-position, 40px);
1692
- transform: translateY(-50%);
1693
- width: var(--select-separator-width, 1px);
1694
- height: var(--select-separator-height, 60%);
1695
- background: var(--select-separator-gradient, linear-gradient(
1696
- to bottom,
1697
- transparent 0%,
1698
- rgba(0, 0, 0, 0.1) 20%,
1699
- rgba(0, 0, 0, 0.1) 80%,
1700
- transparent 100%
1701
- ));
1702
- pointer-events: none;
1703
- z-index: 1;
1704
1495
  }
1705
1496
 
1706
- .dropdown-arrow-container {
1707
- position: absolute;
1708
- top: 0;
1709
- right: 0;
1710
- bottom: 0;
1711
- width: var(--select-arrow-width, 40px);
1497
+ .option-container {
1712
1498
  display: flex;
1713
1499
  align-items: center;
1714
- justify-content: center;
1500
+ justify-content: space-between;
1501
+ padding: 8px 12px;
1715
1502
  cursor: pointer;
1503
+ user-select: none;
1716
1504
  transition: background-color 0.2s ease;
1717
- border-radius: var(--select-arrow-border-radius, 0 4px 4px 0);
1718
- z-index: 2;
1719
1505
  }
1720
1506
 
1721
- .dropdown-arrow-container:hover {
1722
- background-color: var(--select-arrow-hover-bg, rgba(102, 126, 234, 0.08));
1507
+ .option-container:hover {
1508
+ background-color: var(--select-option-hover-bg, #f0f0f0);
1723
1509
  }
1724
1510
 
1725
- .dropdown-arrow {
1726
- width: var(--select-arrow-size, 16px);
1727
- height: var(--select-arrow-size, 16px);
1728
- color: var(--select-arrow-color, #667eea);
1729
- transition: transform 0.2s ease, color 0.2s ease;
1730
- transform: translateY(0);
1511
+ .option-container.selected {
1512
+ background-color: var(--select-option-selected-bg, #e3f2fd);
1513
+ color: var(--select-option-selected-color, #1976d2);
1731
1514
  }
1732
1515
 
1733
- .dropdown-arrow-container:hover .dropdown-arrow {
1734
- color: var(--select-arrow-hover-color, #667eea);
1516
+ .option-container.active {
1517
+ outline: 2px solid var(--select-option-active-outline, #1976d2);
1518
+ outline-offset: -2px;
1735
1519
  }
1736
1520
 
1737
- .dropdown-arrow.open {
1738
- transform: rotate(180deg);
1739
- }
1740
-
1741
- .select-input {
1742
- flex: 1;
1743
- min-width: var(--select-input-min-width, 120px);
1744
- padding: var(--select-input-field-padding, 4px);
1745
- border: none;
1746
- font-size: var(--select-input-font-size, 14px);
1747
- line-height: var(--select-input-line-height, 1.5);
1748
- color: var(--select-input-color, #1f2937);
1749
- background: transparent;
1750
- box-sizing: border-box;
1751
- outline: none;
1752
- font-family: var(--select-font-family, inherit);
1753
- }
1754
-
1755
- .select-input::placeholder {
1756
- color: var(--select-input-placeholder-color, #9ca3af);
1757
- }
1758
-
1759
- .selection-badge {
1760
- display: inline-flex;
1761
- align-items: center;
1762
- gap: var(--select-badge-gap, 4px);
1763
- padding: var(--select-badge-padding, 4px 8px);
1764
- margin: var(--select-badge-margin, 2px);
1765
- background: var(--select-badge-bg, #667eea);
1766
- color: var(--select-badge-color, white);
1767
- border-radius: var(--select-badge-border-radius, 4px);
1768
- font-size: var(--select-badge-font-size, 13px);
1769
- line-height: 1;
1770
- }
1771
-
1772
- .badge-remove {
1773
- display: inline-flex;
1774
- align-items: center;
1775
- justify-content: center;
1776
- width: var(--select-badge-remove-size, 16px);
1777
- height: var(--select-badge-remove-size, 16px);
1778
- padding: 0;
1779
- margin-left: 4px;
1780
- background: var(--select-badge-remove-bg, rgba(255, 255, 255, 0.3));
1781
- border: none;
1782
- border-radius: 50%;
1783
- color: var(--select-badge-remove-color, white);
1784
- font-size: var(--select-badge-remove-font-size, 16px);
1785
- line-height: 1;
1786
- cursor: pointer;
1787
- transition: background 0.2s;
1788
- }
1789
-
1790
- .badge-remove:hover {
1791
- background: var(--select-badge-remove-hover-bg, rgba(255, 255, 255, 0.5));
1792
- }
1793
-
1794
- .select-input:disabled {
1795
- background-color: var(--select-disabled-bg, #f5f5f5);
1521
+ .option-container.disabled {
1522
+ opacity: 0.5;
1796
1523
  cursor: not-allowed;
1524
+ pointer-events: none;
1797
1525
  }
1798
1526
 
1799
- .select-dropdown {
1800
- position: absolute;
1801
- scroll-behavior: smooth;
1802
- top: 100%;
1803
- left: 0;
1804
- right: 0;
1805
- margin-top: var(--select-dropdown-margin-top, 4px);
1806
- max-height: var(--select-dropdown-max-height, 300px);
1527
+ .option-content {
1528
+ flex: 1;
1807
1529
  overflow: hidden;
1808
- background: var(--select-dropdown-bg, white);
1809
- border: var(--select-dropdown-border, 1px solid #ccc);
1810
- border-radius: var(--select-dropdown-border-radius, 4px);
1811
- box-shadow: var(--select-dropdown-shadow, 0 4px 6px rgba(0,0,0,0.1));
1812
- z-index: var(--select-dropdown-z-index, 1000);
1813
- }
1814
-
1815
- .options-container {
1816
- position: relative;
1817
- max-height: var(--select-options-max-height, 300px);
1818
- overflow: auto;
1819
- transition: opacity 0.2s ease-in-out;
1820
- background: var(--select-options-bg, white);
1821
- }
1822
-
1823
- .option {
1824
- padding: var(--select-option-padding, 8px 12px);
1825
- cursor: pointer;
1826
- color: var(--select-option-color, #1f2937);
1827
- background: var(--select-option-bg, white);
1828
- transition: var(--select-option-transition, background-color 0.15s ease);
1829
- user-select: none;
1830
- font-size: var(--select-option-font-size, 14px);
1831
- line-height: var(--select-option-line-height, 1.5);
1832
- border-bottom: var(--select-option-border-bottom, none);
1833
- }
1834
-
1835
- .option:hover {
1836
- background-color: var(--select-option-hover-bg, #f3f4f6);
1837
- color: var(--select-option-hover-color, #1f2937);
1838
- }
1839
-
1840
- .option.selected {
1841
- background-color: var(--select-option-selected-bg, #e0e7ff);
1842
- color: var(--select-option-selected-color, #4338ca);
1843
- font-weight: var(--select-option-selected-weight, 500);
1844
- }
1845
-
1846
- .option.active {
1847
- background-color: var(--select-option-active-bg, #f3f4f6);
1848
- color: var(--select-option-active-color, #1f2937);
1849
- }
1850
-
1851
- .load-more-container {
1852
- padding: var(--select-load-more-padding, 12px);
1853
- text-align: center;
1854
- border-top: var(--select-divider-border, 1px solid #e0e0e0);
1855
- background: var(--select-load-more-bg, white);
1530
+ text-overflow: ellipsis;
1531
+ white-space: nowrap;
1856
1532
  }
1857
1533
 
1858
- .load-more-button {
1859
- padding: var(--select-button-padding, 8px 16px);
1860
- border: var(--select-button-border, 1px solid #1976d2);
1861
- background: var(--select-button-bg, white);
1862
- color: var(--select-button-color, #1976d2);
1863
- border-radius: var(--select-button-border-radius, 4px);
1534
+ .remove-button {
1535
+ margin-left: 8px;
1536
+ padding: 2px 6px;
1537
+ border: none;
1538
+ background-color: var(--select-remove-btn-bg, transparent);
1539
+ color: var(--select-remove-btn-color, #666);
1864
1540
  cursor: pointer;
1865
- font-size: var(--select-button-font-size, 14px);
1866
- font-family: var(--select-font-family, inherit);
1541
+ border-radius: 3px;
1542
+ font-size: 16px;
1543
+ line-height: 1;
1867
1544
  transition: all 0.2s ease;
1868
1545
  }
1869
1546
 
1870
- .load-more-button:hover {
1871
- background: var(--select-button-hover-bg, #1976d2);
1872
- color: var(--select-button-hover-color, white);
1873
- }
1874
-
1875
- .load-more-button:disabled {
1876
- opacity: var(--select-button-disabled-opacity, 0.5);
1877
- cursor: not-allowed;
1878
- }
1879
-
1880
- .busy-bucket {
1881
- padding: var(--select-busy-padding, 16px);
1882
- text-align: center;
1883
- color: var(--select-busy-color, #666);
1884
- background: var(--select-busy-bg, white);
1885
- font-size: var(--select-busy-font-size, 14px);
1886
- }
1887
-
1888
- .spinner {
1889
- display: inline-block;
1890
- width: var(--select-spinner-size, 20px);
1891
- height: var(--select-spinner-size, 20px);
1892
- border: var(--select-spinner-border, 2px solid #ccc);
1893
- border-top-color: var(--select-spinner-active-color, #1976d2);
1894
- border-radius: 50%;
1895
- animation: spin 0.6s linear infinite;
1896
- }
1897
-
1898
- @keyframes spin {
1899
- to { transform: rotate(360deg); }
1900
- }
1901
-
1902
- .empty-state {
1903
- padding: var(--select-empty-padding, 24px);
1904
- text-align: center;
1905
- color: var(--select-empty-color, #999);
1906
- font-size: var(--select-empty-font-size, 14px);
1907
- background: var(--select-empty-bg, white);
1908
- }
1909
-
1910
- .searching-state {
1911
- padding: var(--select-searching-padding, 24px);
1912
- text-align: center;
1913
- color: var(--select-searching-color, #667eea);
1914
- font-size: var(--select-searching-font-size, 14px);
1915
- font-style: italic;
1916
- background: var(--select-searching-bg, white);
1917
- animation: pulse 1.5s ease-in-out infinite;
1918
- }
1919
-
1920
- @keyframes pulse {
1921
- 0%, 100% { opacity: 1; }
1922
- 50% { opacity: 0.5; }
1923
- }
1924
-
1925
- /* Error states */
1926
- .select-input[aria-invalid="true"] {
1927
- border-color: var(--select-error-border, #dc2626);
1547
+ .remove-button:hover {
1548
+ background-color: var(--select-remove-btn-hover-bg, #ffebee);
1549
+ color: var(--select-remove-btn-hover-color, #c62828);
1928
1550
  }
1929
1551
 
1930
- .select-input[aria-invalid="true"]:focus {
1931
- border-color: var(--select-error-border, #dc2626);
1932
- box-shadow: 0 0 0 2px var(--select-error-shadow, rgba(220, 38, 38, 0.1));
1933
- outline-color: var(--select-error-border, #dc2626);
1552
+ .remove-button:focus {
1553
+ outline: 2px solid var(--select-remove-btn-focus-outline, #1976d2);
1554
+ outline-offset: 2px;
1934
1555
  }
1935
-
1936
- /* Accessibility: Reduced motion */
1937
- @media (prefers-reduced-motion: reduce) {
1938
- * {
1939
- animation-duration: 0.01ms !important;
1940
- animation-iteration-count: 1 !important;
1941
- transition-duration: 0.01ms !important;
1556
+ `;
1557
+ this._shadow.appendChild(style);
1558
+ }
1559
+ _render() {
1560
+ const { item, index, selected, disabled, active, render, showRemoveButton } = this._config;
1561
+ // Clear container
1562
+ this._container.innerHTML = '';
1563
+ // Apply state classes
1564
+ this._container.classList.toggle('selected', selected);
1565
+ this._container.classList.toggle('disabled', disabled || false);
1566
+ this._container.classList.toggle('active', active || false);
1567
+ // Custom class name
1568
+ if (this._config.className) {
1569
+ this._container.className += ' ' + this._config.className;
1942
1570
  }
1943
- }
1944
-
1945
- /* Dark mode - Opt-in via class or data attribute */
1946
- :host(.dark-mode),
1947
- :host([data-theme="dark"]) {
1948
- .input-container {
1949
- background: var(--select-dark-bg, #1f2937);
1950
- border-color: var(--select-dark-border, #4b5563);
1571
+ // Apply custom styles
1572
+ if (this._config.style) {
1573
+ Object.assign(this._container.style, this._config.style);
1951
1574
  }
1952
-
1953
- .select-input {
1954
- color: var(--select-dark-text, #f9fafb);
1575
+ // Render content
1576
+ const contentDiv = document.createElement('div');
1577
+ contentDiv.className = 'option-content';
1578
+ if (render) {
1579
+ const rendered = render(item, index);
1580
+ if (typeof rendered === 'string') {
1581
+ contentDiv.innerHTML = rendered;
1582
+ }
1583
+ else {
1584
+ contentDiv.appendChild(rendered);
1585
+ }
1955
1586
  }
1956
-
1957
- .select-input::placeholder {
1958
- color: var(--select-dark-placeholder, #6b7280);
1587
+ else {
1588
+ const label = this._getLabel();
1589
+ contentDiv.textContent = label;
1959
1590
  }
1960
-
1961
- .select-dropdown {
1962
- background: var(--select-dark-dropdown-bg, #1f2937);
1963
- border-color: var(--select-dark-dropdown-border, #4b5563);
1964
- }
1965
-
1966
- .options-container {
1967
- background: var(--select-dark-options-bg, #1f2937);
1968
- }
1969
-
1970
- .option {
1971
- color: var(--select-dark-option-color, #f9fafb);
1972
- background: var(--select-dark-option-bg, #1f2937);
1973
- }
1974
-
1975
- .option:hover {
1976
- background-color: var(--select-dark-option-hover-bg, #374151);
1977
- color: var(--select-dark-option-hover-color, #f9fafb);
1978
- }
1979
-
1980
- .option.selected {
1981
- background-color: var(--select-dark-option-selected-bg, #3730a3);
1982
- color: var(--select-dark-option-selected-text, #e0e7ff);
1983
- }
1984
-
1985
- .option.active {
1986
- background-color: var(--select-dark-option-active-bg, #374151);
1987
- color: var(--select-dark-option-active-color, #f9fafb);
1988
- }
1989
-
1990
- .busy-bucket,
1991
- .empty-state {
1992
- color: var(--select-dark-busy-color, #9ca3af);
1993
- }
1994
-
1995
- .input-container::after {
1996
- background: linear-gradient(
1997
- to bottom,
1998
- transparent 0%,
1999
- rgba(255, 255, 255, 0.1) 20%,
2000
- rgba(255, 255, 255, 0.1) 80%,
2001
- transparent 100%
2002
- );
2003
- }
2004
- }
2005
-
2006
- /* Accessibility: High contrast mode */
2007
- @media (prefers-contrast: high) {
2008
- .select-input:focus {
2009
- outline-width: 3px;
2010
- outline-color: Highlight;
2011
- }
2012
-
2013
- .select-input {
2014
- border-width: 2px;
2015
- }
2016
- }
2017
-
2018
- /* Touch targets (WCAG 2.5.5) */
2019
- .load-more-button,
2020
- select-option {
2021
- min-height: 44px;
2022
- }
2023
- `;
2024
- // Insert as first child to ensure styles are processed first
2025
- if (this._shadow.firstChild) {
2026
- this._shadow.insertBefore(style, this._shadow.firstChild);
2027
- }
2028
- else {
2029
- this._shadow.appendChild(style);
1591
+ this._container.appendChild(contentDiv);
1592
+ // Add remove button if needed
1593
+ if (showRemoveButton && selected) {
1594
+ this._removeButton = document.createElement('button');
1595
+ this._removeButton.className = 'remove-button';
1596
+ this._removeButton.innerHTML = '×';
1597
+ this._removeButton.setAttribute('aria-label', 'Remove option');
1598
+ this._removeButton.setAttribute('type', 'button');
1599
+ this._container.appendChild(this._removeButton);
2030
1600
  }
1601
+ // Set ARIA attributes
1602
+ this.setAttribute('role', 'option');
1603
+ this.setAttribute('aria-selected', String(selected));
1604
+ if (disabled)
1605
+ this.setAttribute('aria-disabled', 'true');
1606
+ this.id = this._config.id || `select-option-${index}`;
2031
1607
  }
2032
1608
  _attachEventListeners() {
2033
- // Arrow click handler
2034
- if (this._arrowContainer) {
2035
- this._boundArrowClick = (e) => {
2036
- e.stopPropagation();
2037
- e.preventDefault();
2038
- const wasOpen = this._state.isOpen;
2039
- this._state.isOpen = !this._state.isOpen;
2040
- this._updateDropdownVisibility();
2041
- this._updateArrowRotation();
2042
- if (this._state.isOpen && this._config.callbacks.onOpen) {
2043
- this._config.callbacks.onOpen();
2044
- }
2045
- else if (!this._state.isOpen && this._config.callbacks.onClose) {
2046
- this._config.callbacks.onClose();
2047
- }
2048
- // Scroll to selected when opening
2049
- if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
2050
- setTimeout(() => this._scrollToSelected(), 50);
2051
- }
2052
- };
2053
- this._arrowContainer.addEventListener('click', this._boundArrowClick);
2054
- }
2055
- // Input container click - prevent event from reaching document listener
1609
+ // Click handler for selection
2056
1610
  this._container.addEventListener('click', (e) => {
2057
- e.stopPropagation();
2058
- });
2059
- // Input focus/blur
2060
- this._input.addEventListener('focus', () => this._handleOpen());
2061
- this._input.addEventListener('blur', (e) => {
2062
- // Delay to allow option click
2063
- setTimeout(() => {
2064
- if (!this._dropdown.contains(document.activeElement)) {
2065
- this._handleClose();
2066
- }
2067
- }, 200);
2068
- });
2069
- // Input search
2070
- this._input.addEventListener('input', (e) => {
2071
- if (!this._config.searchable)
1611
+ // Don't trigger selection if clicking remove button
1612
+ if (e.target === this._removeButton) {
2072
1613
  return;
2073
- const query = e.target.value;
2074
- this._handleSearch(query);
1614
+ }
1615
+ if (!this._config.disabled) {
1616
+ this._handleSelect();
1617
+ }
2075
1618
  });
2076
- // Keyboard navigation
2077
- this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
2078
- // Click outside to close
2079
- document.addEventListener('click', (e) => {
2080
- const target = e.target;
2081
- // Check if click is outside shadow root
2082
- if (!this._shadow.contains(target) && !this._container.contains(target)) {
2083
- this._handleClose();
1619
+ // Remove button handler
1620
+ if (this._removeButton) {
1621
+ this._removeButton.addEventListener('click', (e) => {
1622
+ e.stopPropagation();
1623
+ this._handleRemove();
1624
+ });
1625
+ }
1626
+ // Keyboard handler
1627
+ this.addEventListener('keydown', (e) => {
1628
+ if (this._config.disabled)
1629
+ return;
1630
+ if (e.key === 'Enter' || e.key === ' ') {
1631
+ e.preventDefault();
1632
+ this._handleSelect();
1633
+ }
1634
+ else if (e.key === 'Delete' || e.key === 'Backspace') {
1635
+ if (this._config.selected && this._config.showRemoveButton) {
1636
+ e.preventDefault();
1637
+ this._handleRemove();
1638
+ }
2084
1639
  }
2085
1640
  });
2086
1641
  }
2087
- _initializeObservers() {
2088
- // Disconnect existing observer if any
2089
- if (this._intersectionObserver) {
2090
- this._intersectionObserver.disconnect();
2091
- this._intersectionObserver = undefined;
2092
- }
2093
- // Intersection observer for infinite scroll
2094
- if (this._config.infiniteScroll.enabled) {
2095
- this._intersectionObserver = new IntersectionObserver((entries) => {
2096
- entries.forEach((entry) => {
2097
- if (entry.isIntersecting) {
2098
- if (!this._state.isBusy) {
2099
- this._loadMoreItems();
2100
- }
2101
- }
2102
- });
2103
- }, { threshold: 0.1 });
2104
- }
1642
+ _handleSelect() {
1643
+ const detail = {
1644
+ item: this._config.item,
1645
+ index: this._config.index,
1646
+ value: this._getValue(),
1647
+ label: this._getLabel(),
1648
+ selected: !this._config.selected,
1649
+ };
1650
+ this.dispatchEvent(new CustomEvent('optionSelect', {
1651
+ detail,
1652
+ bubbles: true,
1653
+ composed: true,
1654
+ }));
2105
1655
  }
2106
- async _loadInitialSelectedItems() {
2107
- if (!this._config.serverSide.fetchSelectedItems || !this._config.serverSide.initialSelectedValues) {
2108
- return;
2109
- }
2110
- this._setBusy(true);
2111
- try {
2112
- const items = await this._config.serverSide.fetchSelectedItems(this._config.serverSide.initialSelectedValues);
2113
- // Add to state
2114
- items.forEach((item, index) => {
2115
- this._state.selectedItems.set(index, item);
2116
- this._state.selectedIndices.add(index);
2117
- });
2118
- this._updateInputDisplay();
2119
- }
2120
- catch (error) {
2121
- this._handleError(error);
2122
- }
2123
- finally {
2124
- this._setBusy(false);
2125
- }
1656
+ _handleRemove() {
1657
+ const detail = {
1658
+ item: this._config.item,
1659
+ index: this._config.index,
1660
+ value: this._getValue(),
1661
+ label: this._getLabel(),
1662
+ selected: false,
1663
+ };
1664
+ this.dispatchEvent(new CustomEvent('optionRemove', {
1665
+ detail,
1666
+ bubbles: true,
1667
+ composed: true,
1668
+ }));
2126
1669
  }
2127
- _handleOpen() {
2128
- if (!this._config.enabled || this._state.isOpen)
2129
- return;
2130
- this._state.isOpen = true;
2131
- this._dropdown.style.display = 'block';
2132
- this._input.setAttribute('aria-expanded', 'true');
2133
- this._updateArrowRotation();
2134
- // Clear search query when opening to show all options
2135
- // This ensures we can scroll to selected item
2136
- if (this._config.searchable) {
2137
- this._state.searchQuery = '';
2138
- // Don't clear input value if it represents selection
2139
- // But if we want to search, we might want to clear it?
2140
- // Standard behavior: input keeps value (label), but dropdown shows all options
2141
- // until user types.
2142
- // However, our filtering logic uses _state.searchQuery.
2143
- // So clearing it here resets the filter.
1670
+ _getValue() {
1671
+ if (this._config.getValue) {
1672
+ return this._config.getValue(this._config.item);
2144
1673
  }
2145
- // Render options when opening
2146
- this._renderOptions();
2147
- this._emit('open', {});
2148
- this._config.callbacks.onOpen?.();
2149
- // Scroll to selected if configured
2150
- if (this._config.scrollToSelected.enabled) {
2151
- // Use requestAnimationFrame for better timing after render
2152
- requestAnimationFrame(() => {
2153
- // Double RAF to ensure layout is complete
2154
- requestAnimationFrame(() => {
2155
- this._scrollToSelected();
2156
- });
2157
- });
1674
+ return this._config.item?.value ?? this._config.item;
1675
+ }
1676
+ _getLabel() {
1677
+ if (this._config.getLabel) {
1678
+ return this._config.getLabel(this._config.item);
2158
1679
  }
1680
+ return this._config.item?.label ?? String(this._config.item);
2159
1681
  }
2160
- _handleClose() {
2161
- if (!this._state.isOpen)
2162
- return;
2163
- this._state.isOpen = false;
2164
- this._dropdown.style.display = 'none';
2165
- this._input.setAttribute('aria-expanded', 'false');
2166
- this._updateArrowRotation();
2167
- this._emit('close', {});
2168
- this._config.callbacks.onClose?.();
1682
+ /**
1683
+ * Update option configuration and re-render
1684
+ */
1685
+ updateConfig(updates) {
1686
+ this._config = { ...this._config, ...updates };
1687
+ this._render();
1688
+ this._attachEventListeners();
2169
1689
  }
2170
- _updateDropdownVisibility() {
2171
- if (this._state.isOpen) {
2172
- this._dropdown.style.display = 'block';
2173
- this._input.setAttribute('aria-expanded', 'true');
1690
+ /**
1691
+ * Get current configuration
1692
+ */
1693
+ getConfig() {
1694
+ return this._config;
1695
+ }
1696
+ /**
1697
+ * Get option value
1698
+ */
1699
+ getValue() {
1700
+ return this._getValue();
1701
+ }
1702
+ /**
1703
+ * Get option label
1704
+ */
1705
+ getLabel() {
1706
+ return this._getLabel();
1707
+ }
1708
+ /**
1709
+ * Set selected state
1710
+ */
1711
+ setSelected(selected) {
1712
+ this._config.selected = selected;
1713
+ this._render();
1714
+ }
1715
+ /**
1716
+ * Set active state
1717
+ */
1718
+ setActive(active) {
1719
+ this._config.active = active;
1720
+ this._render();
1721
+ }
1722
+ /**
1723
+ * Set disabled state
1724
+ */
1725
+ setDisabled(disabled) {
1726
+ this._config.disabled = disabled;
1727
+ this._render();
1728
+ }
1729
+ }
1730
+ // Register custom element
1731
+ if (!customElements.get('select-option')) {
1732
+ customElements.define('select-option', SelectOption);
1733
+ }
1734
+
1735
+ /**
1736
+ * Enhanced Select Component
1737
+ * Implements all advanced features: infinite scroll, load more, busy state,
1738
+ * server-side selection, and full customization
1739
+ */
1740
+ class EnhancedSelect extends HTMLElement {
1741
+ constructor() {
1742
+ super();
1743
+ this._pageCache = {};
1744
+ this._typeBuffer = '';
1745
+ this._hasError = false;
1746
+ this._errorMessage = '';
1747
+ this._boundArrowClick = null;
1748
+ this._pendingFirstRenderMark = false;
1749
+ this._pendingSearchRenderMark = false;
1750
+ this._rangeAnchorIndex = null;
1751
+ this._shadow = this.attachShadow({ mode: 'open' });
1752
+ this._uniqueId = `enhanced-select-${Math.random().toString(36).substr(2, 9)}`;
1753
+ this._rendererHelpers = this._buildRendererHelpers();
1754
+ // Merge global config with component-level config
1755
+ this._config = selectConfig.getConfig();
1756
+ // Initialize state
1757
+ this._state = {
1758
+ isOpen: false,
1759
+ isBusy: false,
1760
+ isSearching: false,
1761
+ currentPage: this._config.infiniteScroll.initialPage || 1,
1762
+ totalPages: 1,
1763
+ selectedIndices: new Set(),
1764
+ selectedItems: new Map(),
1765
+ activeIndex: -1,
1766
+ searchQuery: '',
1767
+ loadedItems: [],
1768
+ groupedItems: [],
1769
+ preserveScrollPosition: false,
1770
+ lastScrollPosition: 0,
1771
+ lastNotifiedQuery: null,
1772
+ lastNotifiedResultCount: 0,
1773
+ isExpanded: false,
1774
+ };
1775
+ // Create DOM structure
1776
+ this._container = this._createContainer();
1777
+ this._inputContainer = this._createInputContainer();
1778
+ this._input = this._createInput();
1779
+ this._arrowContainer = this._createArrowContainer();
1780
+ this._dropdown = this._createDropdown();
1781
+ this._optionsContainer = this._createOptionsContainer();
1782
+ this._liveRegion = this._createLiveRegion();
1783
+ // Initialize styles BEFORE assembling DOM (order matters in shadow DOM)
1784
+ this._initializeStyles();
1785
+ this._assembleDOM();
1786
+ this._attachEventListeners();
1787
+ this._initializeObservers();
1788
+ }
1789
+ connectedCallback() {
1790
+ // WORKAROUND: Force display style on host element for Angular compatibility
1791
+ // Angular's rendering seems to not apply :host styles correctly in some cases
1792
+ // Must be done in connectedCallback when element is attached to DOM
1793
+ this.style.display = 'block';
1794
+ this.style.width = '100%';
1795
+ // Load initial data if server-side is enabled
1796
+ if (this._config.serverSide.enabled && this._config.serverSide.initialSelectedValues) {
1797
+ this._loadInitialSelectedItems();
2174
1798
  }
2175
- else {
2176
- this._dropdown.style.display = 'none';
2177
- this._input.setAttribute('aria-expanded', 'false');
1799
+ // Emit open event if configured to start open
1800
+ if (this._config.callbacks.onOpen) {
1801
+ this._config.callbacks.onOpen();
2178
1802
  }
2179
1803
  }
2180
- _updateArrowRotation() {
2181
- if (this._arrowContainer) {
2182
- const arrow = this._arrowContainer.querySelector('.dropdown-arrow');
2183
- if (arrow) {
2184
- if (this._state.isOpen) {
2185
- arrow.classList.add('open');
2186
- }
2187
- else {
2188
- arrow.classList.remove('open');
2189
- }
1804
+ disconnectedCallback() {
1805
+ // Cleanup observers
1806
+ this._resizeObserver?.disconnect();
1807
+ this._intersectionObserver?.disconnect();
1808
+ if (this._busyTimeout)
1809
+ clearTimeout(this._busyTimeout);
1810
+ if (this._typeTimeout)
1811
+ clearTimeout(this._typeTimeout);
1812
+ if (this._searchTimeout)
1813
+ clearTimeout(this._searchTimeout);
1814
+ // Cleanup arrow click listener
1815
+ if (this._boundArrowClick && this._arrowContainer) {
1816
+ this._arrowContainer.removeEventListener('click', this._boundArrowClick);
1817
+ }
1818
+ }
1819
+ _createContainer() {
1820
+ const container = document.createElement('div');
1821
+ container.className = 'select-container';
1822
+ if (this._config.styles.classNames?.container) {
1823
+ container.className += ' ' + this._config.styles.classNames.container;
1824
+ }
1825
+ if (this._config.styles.container) {
1826
+ Object.assign(container.style, this._config.styles.container);
1827
+ }
1828
+ return container;
1829
+ }
1830
+ _createInputContainer() {
1831
+ const container = document.createElement('div');
1832
+ container.className = 'input-container';
1833
+ return container;
1834
+ }
1835
+ _createInput() {
1836
+ const input = document.createElement('input');
1837
+ input.type = 'text';
1838
+ input.className = 'select-input';
1839
+ input.id = `${this._uniqueId}-input`;
1840
+ input.placeholder = this._config.placeholder || 'Select an option...';
1841
+ input.disabled = !this._config.enabled;
1842
+ input.readOnly = !this._config.searchable;
1843
+ // Update readonly when input is focused if searchable
1844
+ input.addEventListener('focus', () => {
1845
+ if (this._config.searchable) {
1846
+ input.readOnly = false;
2190
1847
  }
1848
+ });
1849
+ if (this._config.styles.classNames?.input) {
1850
+ input.className += ' ' + this._config.styles.classNames.input;
1851
+ }
1852
+ if (this._config.styles.input) {
1853
+ Object.assign(input.style, this._config.styles.input);
2191
1854
  }
1855
+ input.setAttribute('role', 'combobox');
1856
+ input.setAttribute('aria-expanded', 'false');
1857
+ input.setAttribute('aria-haspopup', 'listbox');
1858
+ input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
1859
+ return input;
2192
1860
  }
2193
- _handleSearch(query) {
2194
- this._state.searchQuery = query;
2195
- // Clear previous search timeout
2196
- if (this._searchTimeout) {
2197
- clearTimeout(this._searchTimeout);
1861
+ _createDropdown() {
1862
+ const dropdown = document.createElement('div');
1863
+ dropdown.className = 'select-dropdown';
1864
+ dropdown.style.display = 'none';
1865
+ if (this._config.styles.classNames?.dropdown) {
1866
+ dropdown.className += ' ' + this._config.styles.classNames.dropdown;
2198
1867
  }
2199
- // Search immediately - no debouncing for better responsiveness
2200
- // Users expect instant feedback as they type
2201
- this._state.isSearching = false;
2202
- // Ensure dropdown is open when searching
2203
- if (!this._state.isOpen) {
2204
- this._handleOpen();
1868
+ if (this._config.styles.dropdown) {
1869
+ Object.assign(dropdown.style, this._config.styles.dropdown);
2205
1870
  }
2206
- else {
2207
- // Filter and render options immediately
2208
- this._renderOptions();
1871
+ dropdown.setAttribute('role', 'listbox');
1872
+ dropdown.setAttribute('aria-labelledby', `${this._uniqueId}-input`);
1873
+ if (this._config.selection.mode === 'multi') {
1874
+ dropdown.setAttribute('aria-multiselectable', 'true');
2209
1875
  }
2210
- // Get filtered items based on search query - searches ENTIRE phrase
2211
- const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2212
- // FIX: Do not trim query to allow searching for phrases with spaces
2213
- const searchQuery = query.toLowerCase();
2214
- const filteredItems = searchQuery
2215
- ? this._state.loadedItems.filter((item) => {
2216
- try {
2217
- const label = String(getLabel(item)).toLowerCase();
2218
- // Match the entire search phrase
2219
- return label.includes(searchQuery);
2220
- }
2221
- catch (e) {
2222
- return false;
2223
- }
2224
- })
2225
- : this._state.loadedItems;
2226
- const count = filteredItems.length;
2227
- // Announce search results for accessibility
2228
- if (searchQuery) {
2229
- this._announce(`${count} result${count !== 1 ? 's' : ''} found for "${query}"`);
1876
+ return dropdown;
1877
+ }
1878
+ _createOptionsContainer() {
1879
+ const container = document.createElement('div');
1880
+ container.className = 'options-container';
1881
+ return container;
1882
+ }
1883
+ _createLiveRegion() {
1884
+ const liveRegion = document.createElement('div');
1885
+ liveRegion.setAttribute('role', 'status');
1886
+ liveRegion.setAttribute('aria-live', 'polite');
1887
+ liveRegion.setAttribute('aria-atomic', 'true');
1888
+ liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;';
1889
+ return liveRegion;
1890
+ }
1891
+ _createArrowContainer() {
1892
+ const container = document.createElement('div');
1893
+ container.className = 'dropdown-arrow-container';
1894
+ container.innerHTML = `
1895
+ <svg class="dropdown-arrow" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1896
+ <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1897
+ </svg>
1898
+ `;
1899
+ return container;
1900
+ }
1901
+ _assembleDOM() {
1902
+ this._inputContainer.appendChild(this._input);
1903
+ if (this._arrowContainer) {
1904
+ this._inputContainer.appendChild(this._arrowContainer);
1905
+ }
1906
+ this._container.appendChild(this._inputContainer);
1907
+ this._dropdown.appendChild(this._optionsContainer);
1908
+ this._container.appendChild(this._dropdown);
1909
+ this._shadow.appendChild(this._container);
1910
+ if (this._liveRegion) {
1911
+ this._shadow.appendChild(this._liveRegion);
1912
+ }
1913
+ // Set ARIA relationships
1914
+ const listboxId = `${this._uniqueId}-listbox`;
1915
+ this._dropdown.id = listboxId;
1916
+ this._input.setAttribute('aria-controls', listboxId);
1917
+ this._input.setAttribute('aria-owns', listboxId);
1918
+ }
1919
+ _initializeStyles() {
1920
+ const style = document.createElement('style');
1921
+ style.textContent = `
1922
+ :host {
1923
+ display: block;
1924
+ position: relative;
1925
+ width: 100%;
1926
+ }
1927
+
1928
+ .select-container {
1929
+ position: relative;
1930
+ width: 100%;
1931
+ }
1932
+
1933
+ .input-container {
1934
+ position: relative;
1935
+ width: 100%;
1936
+ display: flex;
1937
+ align-items: center;
1938
+ flex-wrap: wrap;
1939
+ gap: var(--select-input-gap, 6px);
1940
+ padding: var(--select-input-padding, 6px 52px 6px 8px);
1941
+ min-height: var(--select-input-min-height, 44px);
1942
+ max-height: var(--select-input-max-height, 160px);
1943
+ overflow-y: var(--select-input-overflow-y, auto);
1944
+ align-content: flex-start;
1945
+ background: var(--select-input-bg, white);
1946
+ border: var(--select-input-border, 1px solid #d1d5db);
1947
+ border-radius: var(--select-input-border-radius, 6px);
1948
+ box-sizing: border-box;
1949
+ transition: all 0.2s ease;
1950
+ }
1951
+
1952
+ .input-container:focus-within {
1953
+ border-color: var(--select-input-focus-border, #667eea);
1954
+ box-shadow: var(--select-input-focus-shadow, 0 0 0 3px rgba(102, 126, 234, 0.1));
1955
+ }
1956
+
1957
+ /* Gradient separator before arrow */
1958
+ .input-container::after {
1959
+ content: '';
1960
+ position: absolute;
1961
+ top: 50%;
1962
+ right: var(--select-separator-position, 40px);
1963
+ transform: translateY(-50%);
1964
+ width: var(--select-separator-width, 1px);
1965
+ height: var(--select-separator-height, 60%);
1966
+ background: var(--select-separator-bg, var(--select-separator-gradient, linear-gradient(
1967
+ to bottom,
1968
+ transparent 0%,
1969
+ rgba(0, 0, 0, 0.1) 20%,
1970
+ rgba(0, 0, 0, 0.1) 80%,
1971
+ transparent 100%
1972
+ )));
1973
+ pointer-events: none;
1974
+ z-index: 1;
1975
+ }
1976
+
1977
+ .dropdown-arrow-container {
1978
+ position: absolute;
1979
+ top: 0;
1980
+ right: 0;
1981
+ bottom: 0;
1982
+ width: var(--select-arrow-width, 40px);
1983
+ display: flex;
1984
+ align-items: center;
1985
+ justify-content: center;
1986
+ cursor: pointer;
1987
+ transition: background-color 0.2s ease;
1988
+ border-radius: var(--select-arrow-border-radius, 0 4px 4px 0);
1989
+ z-index: 2;
1990
+ }
1991
+
1992
+ .dropdown-arrow-container:hover {
1993
+ background-color: var(--select-arrow-hover-bg, rgba(102, 126, 234, 0.08));
1994
+ }
1995
+
1996
+ .dropdown-arrow {
1997
+ width: var(--select-arrow-size, 16px);
1998
+ height: var(--select-arrow-size, 16px);
1999
+ color: var(--select-arrow-color, #667eea);
2000
+ transition: transform 0.2s ease, color 0.2s ease;
2001
+ transform: translateY(0);
2002
+ }
2003
+
2004
+ .dropdown-arrow path {
2005
+ stroke-width: var(--select-arrow-stroke-width, 2);
2006
+ }
2007
+
2008
+ .dropdown-arrow-container:hover .dropdown-arrow {
2009
+ color: var(--select-arrow-hover-color, #667eea);
2010
+ }
2011
+
2012
+ .dropdown-arrow.open {
2013
+ transform: rotate(180deg);
2014
+ }
2015
+
2016
+ .select-input {
2017
+ flex: 1;
2018
+ min-width: var(--select-input-min-width, 120px);
2019
+ padding: var(--select-input-field-padding, 4px);
2020
+ border: none;
2021
+ font-size: var(--select-input-font-size, 14px);
2022
+ line-height: var(--select-input-line-height, 1.5);
2023
+ color: var(--select-input-color, #1f2937);
2024
+ background: transparent;
2025
+ box-sizing: border-box;
2026
+ outline: none;
2027
+ font-family: var(--select-font-family, inherit);
2028
+ }
2029
+
2030
+ .select-input::placeholder {
2031
+ color: var(--select-input-placeholder-color, #9ca3af);
2032
+ }
2033
+
2034
+ .selection-badge {
2035
+ display: inline-flex;
2036
+ align-items: center;
2037
+ gap: var(--select-badge-gap, 4px);
2038
+ padding: var(--select-badge-padding, 4px 8px);
2039
+ margin: var(--select-badge-margin, 2px);
2040
+ background: var(--select-badge-bg, #667eea);
2041
+ color: var(--select-badge-color, white);
2042
+ border-radius: var(--select-badge-border-radius, 4px);
2043
+ font-size: var(--select-badge-font-size, 13px);
2044
+ line-height: 1;
2045
+ max-width: var(--select-badge-max-width, 100%);
2046
+ white-space: nowrap;
2047
+ overflow: hidden;
2048
+ text-overflow: ellipsis;
2049
+ }
2050
+
2051
+ .badge-remove {
2052
+ display: inline-flex;
2053
+ align-items: center;
2054
+ justify-content: center;
2055
+ width: var(--select-badge-remove-size, 16px);
2056
+ height: var(--select-badge-remove-size, 16px);
2057
+ padding: 0;
2058
+ margin-left: 4px;
2059
+ background: var(--select-badge-remove-bg, rgba(255, 255, 255, 0.3));
2060
+ border: none;
2061
+ border-radius: 50%;
2062
+ color: var(--select-badge-remove-color, white);
2063
+ font-size: var(--select-badge-remove-font-size, 16px);
2064
+ line-height: 1;
2065
+ cursor: pointer;
2066
+ transition: background 0.2s;
2067
+ }
2068
+
2069
+ .badge-remove:hover {
2070
+ background: var(--select-badge-remove-hover-bg, rgba(255, 255, 255, 0.5));
2071
+ }
2072
+
2073
+ .badge-remove:focus-visible {
2074
+ outline: 2px solid var(--select-badge-remove-focus-outline, rgba(255, 255, 255, 0.8));
2075
+ outline-offset: 2px;
2076
+ }
2077
+
2078
+ .select-input:disabled {
2079
+ background-color: var(--select-disabled-bg, #f5f5f5);
2080
+ cursor: not-allowed;
2081
+ }
2082
+
2083
+ .select-dropdown {
2084
+ position: absolute;
2085
+ scroll-behavior: smooth;
2086
+ top: 100%;
2087
+ left: 0;
2088
+ right: 0;
2089
+ margin-top: var(--select-dropdown-margin-top, 4px);
2090
+ max-height: var(--select-dropdown-max-height, 300px);
2091
+ overflow: hidden;
2092
+ background: var(--select-dropdown-bg, white);
2093
+ border: var(--select-dropdown-border, 1px solid #ccc);
2094
+ border-radius: var(--select-dropdown-border-radius, 4px);
2095
+ box-shadow: var(--select-dropdown-shadow, 0 4px 6px rgba(0,0,0,0.1));
2096
+ z-index: var(--select-dropdown-z-index, 1000);
2097
+ }
2098
+
2099
+ .options-container {
2100
+ position: relative;
2101
+ max-height: var(--select-options-max-height, 300px);
2102
+ overflow: auto;
2103
+ transition: opacity 0.2s ease-in-out;
2104
+ background: var(--select-options-bg, white);
2105
+ }
2106
+
2107
+ .option {
2108
+ padding: var(--select-option-padding, 8px 12px);
2109
+ cursor: pointer;
2110
+ color: var(--select-option-color, #1f2937);
2111
+ background: var(--select-option-bg, white);
2112
+ transition: var(--select-option-transition, background-color 0.15s ease);
2113
+ user-select: none;
2114
+ font-size: var(--select-option-font-size, 14px);
2115
+ line-height: var(--select-option-line-height, 1.5);
2116
+ border: var(--select-option-border, none);
2117
+ border-bottom: var(--select-option-border-bottom, none);
2118
+ }
2119
+
2120
+ .option:hover {
2121
+ background-color: var(--select-option-hover-bg, #f3f4f6);
2122
+ color: var(--select-option-hover-color, #1f2937);
2123
+ }
2124
+
2125
+ .option.selected {
2126
+ background-color: var(--select-option-selected-bg, #e0e7ff);
2127
+ color: var(--select-option-selected-color, #4338ca);
2128
+ font-weight: var(--select-option-selected-weight, 500);
2129
+ }
2130
+
2131
+ .option.active {
2132
+ background-color: var(--select-option-active-bg, #f3f4f6);
2133
+ color: var(--select-option-active-color, #1f2937);
2134
+ outline: var(--select-option-active-outline, 2px solid rgba(99, 102, 241, 0.45));
2135
+ outline-offset: -2px;
2136
+ }
2137
+
2138
+ .option:active {
2139
+ background-color: var(--select-option-pressed-bg, #e5e7eb);
2140
+ }
2141
+
2142
+ .load-more-container {
2143
+ padding: var(--select-load-more-padding, 12px);
2144
+ text-align: center;
2145
+ border-top: var(--select-divider-border, 1px solid #e0e0e0);
2146
+ background: var(--select-load-more-bg, white);
2147
+ }
2148
+
2149
+ .load-more-button {
2150
+ padding: var(--select-button-padding, 8px 16px);
2151
+ border: var(--select-button-border, 1px solid #1976d2);
2152
+ background: var(--select-button-bg, white);
2153
+ color: var(--select-button-color, #1976d2);
2154
+ border-radius: var(--select-button-border-radius, 4px);
2155
+ cursor: pointer;
2156
+ font-size: var(--select-button-font-size, 14px);
2157
+ font-family: var(--select-font-family, inherit);
2158
+ transition: all 0.2s ease;
2159
+ }
2160
+
2161
+ .load-more-button:hover {
2162
+ background: var(--select-button-hover-bg, #1976d2);
2163
+ color: var(--select-button-hover-color, white);
2164
+ }
2165
+
2166
+ .load-more-button:disabled {
2167
+ opacity: var(--select-button-disabled-opacity, 0.5);
2168
+ cursor: not-allowed;
2169
+ }
2170
+
2171
+ .busy-bucket {
2172
+ padding: var(--select-busy-padding, 16px);
2173
+ text-align: center;
2174
+ color: var(--select-busy-color, #666);
2175
+ background: var(--select-busy-bg, white);
2176
+ font-size: var(--select-busy-font-size, 14px);
2177
+ }
2178
+
2179
+ .spinner {
2180
+ display: inline-block;
2181
+ width: var(--select-spinner-size, 20px);
2182
+ height: var(--select-spinner-size, 20px);
2183
+ border: var(--select-spinner-border, 2px solid #ccc);
2184
+ border-top-color: var(--select-spinner-active-color, #1976d2);
2185
+ border-radius: 50%;
2186
+ animation: spin 0.6s linear infinite;
2187
+ }
2188
+
2189
+ @keyframes spin {
2190
+ to { transform: rotate(360deg); }
2191
+ }
2192
+
2193
+ .empty-state {
2194
+ padding: var(--select-empty-padding, 24px);
2195
+ text-align: center;
2196
+ color: var(--select-empty-color, #6b7280);
2197
+ font-size: var(--select-empty-font-size, 14px);
2198
+ background: var(--select-empty-bg, white);
2199
+ display: flex;
2200
+ flex-direction: column;
2201
+ align-items: center;
2202
+ justify-content: center;
2203
+ gap: 6px;
2204
+ min-height: var(--select-empty-min-height, 72px);
2205
+ }
2206
+
2207
+ .searching-state {
2208
+ padding: var(--select-searching-padding, 24px);
2209
+ text-align: center;
2210
+ color: var(--select-searching-color, #667eea);
2211
+ font-size: var(--select-searching-font-size, 14px);
2212
+ font-style: italic;
2213
+ background: var(--select-searching-bg, white);
2214
+ animation: pulse 1.5s ease-in-out infinite;
2215
+ display: flex;
2216
+ flex-direction: column;
2217
+ align-items: center;
2218
+ justify-content: center;
2219
+ gap: 6px;
2220
+ min-height: var(--select-searching-min-height, 72px);
2221
+ }
2222
+
2223
+ @keyframes pulse {
2224
+ 0%, 100% { opacity: 1; }
2225
+ 50% { opacity: 0.5; }
2226
+ }
2227
+
2228
+ /* Error states */
2229
+ .select-input[aria-invalid="true"] {
2230
+ border-color: var(--select-error-border, #dc2626);
2231
+ }
2232
+
2233
+ .select-input[aria-invalid="true"]:focus {
2234
+ border-color: var(--select-error-border, #dc2626);
2235
+ box-shadow: 0 0 0 2px var(--select-error-shadow, rgba(220, 38, 38, 0.1));
2236
+ outline-color: var(--select-error-border, #dc2626);
2237
+ }
2238
+
2239
+ /* Accessibility: Reduced motion */
2240
+ @media (prefers-reduced-motion: reduce) {
2241
+ * {
2242
+ animation-duration: 0.01ms !important;
2243
+ animation-iteration-count: 1 !important;
2244
+ transition-duration: 0.01ms !important;
2245
+ }
2246
+ }
2247
+
2248
+ /* Dark mode - Opt-in via class or data attribute */
2249
+ :host(.dark-mode),
2250
+ :host([data-theme="dark"]) {
2251
+ .input-container {
2252
+ background: var(--select-dark-bg, #1f2937);
2253
+ border-color: var(--select-dark-border, #4b5563);
2254
+ }
2255
+
2256
+ .select-input {
2257
+ color: var(--select-dark-text, #f9fafb);
2258
+ }
2259
+
2260
+ .select-input::placeholder {
2261
+ color: var(--select-dark-placeholder, #6b7280);
2262
+ }
2263
+
2264
+ .select-dropdown {
2265
+ background: var(--select-dark-dropdown-bg, #1f2937);
2266
+ border-color: var(--select-dark-dropdown-border, #4b5563);
2267
+ }
2268
+
2269
+ .options-container {
2270
+ background: var(--select-dark-options-bg, #1f2937);
2271
+ }
2272
+
2273
+ .option {
2274
+ color: var(--select-dark-option-color, #f9fafb);
2275
+ background: var(--select-dark-option-bg, #1f2937);
2276
+ }
2277
+
2278
+ .option:hover {
2279
+ background-color: var(--select-dark-option-hover-bg, #374151);
2280
+ color: var(--select-dark-option-hover-color, #f9fafb);
2281
+ }
2282
+
2283
+ .option.selected {
2284
+ background-color: var(--select-dark-option-selected-bg, #3730a3);
2285
+ color: var(--select-dark-option-selected-text, #e0e7ff);
2286
+ }
2287
+
2288
+ .option.active {
2289
+ background-color: var(--select-dark-option-active-bg, #374151);
2290
+ color: var(--select-dark-option-active-color, #f9fafb);
2291
+ outline: var(--select-dark-option-active-outline, 2px solid rgba(129, 140, 248, 0.55));
2292
+ }
2293
+
2294
+ .selection-badge {
2295
+ background: var(--select-dark-badge-bg, #4f46e5);
2296
+ color: var(--select-dark-badge-color, #eef2ff);
2297
+ }
2298
+
2299
+ .badge-remove {
2300
+ background: var(--select-dark-badge-remove-bg, rgba(255, 255, 255, 0.15));
2301
+ color: var(--select-dark-badge-remove-color, #e5e7eb);
2302
+ }
2303
+
2304
+ .badge-remove:hover {
2305
+ background: var(--select-dark-badge-remove-hover-bg, rgba(255, 255, 255, 0.3));
2306
+ }
2307
+
2308
+ .busy-bucket,
2309
+ .empty-state {
2310
+ color: var(--select-dark-busy-color, #9ca3af);
2311
+ background: var(--select-dark-empty-bg, #111827);
2312
+ }
2313
+
2314
+ .searching-state {
2315
+ background: var(--select-dark-searching-bg, #111827);
2316
+ }
2317
+
2318
+ .input-container::after {
2319
+ background: linear-gradient(
2320
+ to bottom,
2321
+ transparent 0%,
2322
+ rgba(255, 255, 255, 0.1) 20%,
2323
+ rgba(255, 255, 255, 0.1) 80%,
2324
+ transparent 100%
2325
+ );
2326
+ }
2327
+ }
2328
+
2329
+ /* Accessibility: High contrast mode */
2330
+ @media (prefers-contrast: high) {
2331
+ .select-input:focus {
2332
+ outline-width: 3px;
2333
+ outline-color: Highlight;
2334
+ }
2335
+
2336
+ .select-input {
2337
+ border-width: 2px;
2338
+ }
2339
+ }
2340
+
2341
+ /* Touch targets (WCAG 2.5.5) */
2342
+ .load-more-button,
2343
+ select-option {
2344
+ min-height: 44px;
2345
+ }
2346
+ `;
2347
+ // Insert as first child to ensure styles are processed first
2348
+ if (this._shadow.firstChild) {
2349
+ this._shadow.insertBefore(style, this._shadow.firstChild);
2230
2350
  }
2231
- // Only notify if query or result count changed to prevent infinite loops
2232
- if (query !== this._state.lastNotifiedQuery || count !== this._state.lastNotifiedResultCount) {
2233
- this._state.lastNotifiedQuery = query;
2234
- this._state.lastNotifiedResultCount = count;
2235
- // Use setTimeout to avoid synchronous state updates during render
2236
- setTimeout(() => {
2237
- this._emit('search', { query, results: filteredItems, count });
2238
- this._config.callbacks.onSearch?.(query);
2239
- }, 0);
2351
+ else {
2352
+ this._shadow.appendChild(style);
2240
2353
  }
2241
2354
  }
2242
- _handleKeydown(e) {
2243
- switch (e.key) {
2244
- case 'ArrowDown':
2245
- e.preventDefault();
2246
- if (!this._state.isOpen) {
2247
- this._handleOpen();
2248
- }
2249
- else {
2250
- this._moveActive(1);
2251
- }
2252
- break;
2253
- case 'ArrowUp':
2254
- e.preventDefault();
2255
- if (!this._state.isOpen) {
2256
- this._handleOpen();
2257
- }
2258
- else {
2259
- this._moveActive(-1);
2260
- }
2261
- break;
2262
- case 'Home':
2263
- e.preventDefault();
2264
- if (this._state.isOpen) {
2265
- this._setActive(0);
2266
- }
2267
- break;
2268
- case 'End':
2269
- e.preventDefault();
2270
- if (this._state.isOpen) {
2271
- const options = Array.from(this._optionsContainer.children);
2272
- this._setActive(options.length - 1);
2273
- }
2274
- break;
2275
- case 'PageDown':
2276
- e.preventDefault();
2277
- if (this._state.isOpen) {
2278
- this._moveActive(10);
2279
- }
2280
- break;
2281
- case 'PageUp':
2282
- e.preventDefault();
2283
- if (this._state.isOpen) {
2284
- this._moveActive(-10);
2285
- }
2286
- break;
2287
- case 'Enter':
2355
+ _attachEventListeners() {
2356
+ // Arrow click handler
2357
+ if (this._arrowContainer) {
2358
+ this._boundArrowClick = (e) => {
2359
+ e.stopPropagation();
2288
2360
  e.preventDefault();
2289
- if (this._state.activeIndex >= 0) {
2290
- this._selectOption(this._state.activeIndex);
2361
+ const wasOpen = this._state.isOpen;
2362
+ this._state.isOpen = !this._state.isOpen;
2363
+ this._updateDropdownVisibility();
2364
+ this._updateArrowRotation();
2365
+ if (this._state.isOpen && this._config.callbacks.onOpen) {
2366
+ this._config.callbacks.onOpen();
2291
2367
  }
2292
- break;
2293
- case 'Escape':
2294
- e.preventDefault();
2295
- this._handleClose();
2296
- break;
2297
- case 'a':
2298
- case 'A':
2299
- if ((e.ctrlKey || e.metaKey) && this._config.selection.mode === 'multi') {
2300
- e.preventDefault();
2301
- this._selectAll();
2368
+ else if (!this._state.isOpen && this._config.callbacks.onClose) {
2369
+ this._config.callbacks.onClose();
2302
2370
  }
2303
- break;
2304
- default:
2305
- // Type-ahead search
2306
- if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
2307
- this._handleTypeAhead(e.key);
2371
+ // Scroll to selected when opening
2372
+ if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
2373
+ setTimeout(() => this._scrollToSelected(), 50);
2308
2374
  }
2309
- break;
2375
+ };
2376
+ this._arrowContainer.addEventListener('click', this._boundArrowClick);
2310
2377
  }
2311
- }
2312
- _moveActive(delta) {
2313
- const options = Array.from(this._optionsContainer.children);
2314
- const next = Math.max(0, Math.min(options.length - 1, this._state.activeIndex + delta));
2315
- this._setActive(next);
2316
- }
2317
- _setActive(index) {
2318
- const options = Array.from(this._optionsContainer.children);
2319
- // Clear previous active state
2320
- if (this._state.activeIndex >= 0 && options[this._state.activeIndex]) {
2321
- const prevOption = options[this._state.activeIndex];
2322
- // Check if it's a custom SelectOption or a lightweight DOM element
2323
- if ('setActive' in prevOption && typeof prevOption.setActive === 'function') {
2324
- prevOption.setActive(false);
2378
+ // Input container click - prevent event from reaching document listener
2379
+ this._container.addEventListener('click', (e) => {
2380
+ e.stopPropagation();
2381
+ });
2382
+ // Input focus/blur
2383
+ this._input.addEventListener('focus', () => this._handleOpen());
2384
+ this._input.addEventListener('blur', (e) => {
2385
+ const related = e.relatedTarget;
2386
+ if (related && (this._shadow.contains(related) || this._container.contains(related))) {
2387
+ return;
2325
2388
  }
2326
- else {
2327
- // Lightweight option - remove active class
2328
- prevOption.classList.remove('smilodon-option--active');
2329
- prevOption.setAttribute('aria-selected', 'false');
2389
+ // Delay to allow option click/focus transitions
2390
+ setTimeout(() => {
2391
+ const active = document.activeElement;
2392
+ if (active && (this._shadow.contains(active) || this._container.contains(active))) {
2393
+ return;
2394
+ }
2395
+ this._handleClose();
2396
+ }, 0);
2397
+ });
2398
+ // Input search
2399
+ this._input.addEventListener('input', (e) => {
2400
+ if (!this._config.searchable)
2401
+ return;
2402
+ const query = e.target.value;
2403
+ this._handleSearch(query);
2404
+ });
2405
+ // Keyboard navigation
2406
+ this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
2407
+ // Click outside to close
2408
+ document.addEventListener('pointerdown', (e) => {
2409
+ const path = (e.composedPath && e.composedPath()) || [];
2410
+ const clickedInside = path.includes(this) || path.includes(this._container) || this._shadow.contains(e.target);
2411
+ if (!clickedInside) {
2412
+ this._handleClose();
2330
2413
  }
2414
+ });
2415
+ }
2416
+ _initializeObservers() {
2417
+ // Disconnect existing observer if any
2418
+ if (this._intersectionObserver) {
2419
+ this._intersectionObserver.disconnect();
2420
+ this._intersectionObserver = undefined;
2331
2421
  }
2332
- this._state.activeIndex = index;
2333
- // Set new active state
2334
- if (options[index]) {
2335
- const option = options[index];
2336
- // Check if it's a custom SelectOption or a lightweight DOM element
2337
- if ('setActive' in option && typeof option.setActive === 'function') {
2338
- option.setActive(true);
2339
- }
2340
- else {
2341
- // Lightweight option - add active class
2342
- option.classList.add('smilodon-option--active');
2343
- option.setAttribute('aria-selected', 'true');
2344
- }
2345
- option.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2346
- // Announce position for screen readers
2347
- const total = options.length;
2348
- this._announce(`Item ${index + 1} of ${total}`);
2349
- // Update aria-activedescendant
2350
- const optionId = `${this._uniqueId}-option-${index}`;
2351
- this._input.setAttribute('aria-activedescendant', optionId);
2422
+ // Intersection observer for infinite scroll
2423
+ if (this._config.infiniteScroll.enabled) {
2424
+ this._intersectionObserver = new IntersectionObserver((entries) => {
2425
+ entries.forEach((entry) => {
2426
+ if (entry.isIntersecting) {
2427
+ if (!this._state.isBusy) {
2428
+ this._loadMoreItems();
2429
+ }
2430
+ }
2431
+ });
2432
+ }, { threshold: 0.1 });
2352
2433
  }
2353
2434
  }
2354
- _handleTypeAhead(char) {
2355
- if (this._typeTimeout)
2356
- clearTimeout(this._typeTimeout);
2357
- this._typeBuffer += char.toLowerCase();
2358
- this._typeTimeout = window.setTimeout(() => {
2359
- this._typeBuffer = '';
2360
- }, 500);
2361
- // Find first matching option
2362
- const getValue = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2363
- const matchIndex = this._state.loadedItems.findIndex((item) => getValue(item).toLowerCase().startsWith(this._typeBuffer));
2364
- if (matchIndex >= 0) {
2365
- this._setActive(matchIndex);
2435
+ async _loadInitialSelectedItems() {
2436
+ if (!this._config.serverSide.fetchSelectedItems || !this._config.serverSide.initialSelectedValues) {
2437
+ return;
2438
+ }
2439
+ this._setBusy(true);
2440
+ try {
2441
+ const items = await this._config.serverSide.fetchSelectedItems(this._config.serverSide.initialSelectedValues);
2442
+ // Add to state
2443
+ items.forEach((item, index) => {
2444
+ this._state.selectedItems.set(index, item);
2445
+ this._state.selectedIndices.add(index);
2446
+ });
2447
+ this._updateInputDisplay();
2448
+ }
2449
+ catch (error) {
2450
+ this._handleError(error);
2451
+ }
2452
+ finally {
2453
+ this._setBusy(false);
2366
2454
  }
2367
2455
  }
2368
- _selectAll() {
2369
- if (this._config.selection.mode !== 'multi')
2456
+ _handleOpen() {
2457
+ if (!this._config.enabled || this._state.isOpen)
2370
2458
  return;
2371
- const options = Array.from(this._optionsContainer.children);
2372
- const maxSelections = this._config.selection.maxSelections || 0;
2373
- options.forEach((option, index) => {
2374
- if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
2375
- return;
2376
- }
2377
- if (!this._state.selectedIndices.has(index)) {
2378
- // Check if it's a custom SelectOption or a lightweight DOM element
2379
- if ('getConfig' in option && typeof option.getConfig === 'function') {
2380
- const config = option.getConfig();
2381
- this._state.selectedIndices.add(index);
2382
- this._state.selectedItems.set(index, config.item);
2383
- option.setSelected(true);
2384
- }
2385
- else {
2386
- // Lightweight option - get item from data attribute or state
2387
- const item = this._state.loadedItems[index];
2388
- if (item) {
2389
- this._state.selectedIndices.add(index);
2390
- this._state.selectedItems.set(index, item);
2391
- option.classList.add('smilodon-option--selected');
2392
- option.setAttribute('aria-selected', 'true');
2393
- }
2394
- }
2395
- }
2396
- });
2397
- this._updateInputDisplay();
2398
- this._emitChange();
2399
- this._announce(`Selected all ${options.length} items`);
2400
- }
2401
- _announce(message) {
2402
- if (this._liveRegion) {
2403
- this._liveRegion.textContent = message;
2404
- setTimeout(() => {
2405
- if (this._liveRegion)
2406
- this._liveRegion.textContent = '';
2407
- }, 1000);
2459
+ this._markOpenStart();
2460
+ this._state.isOpen = true;
2461
+ this._dropdown.style.display = 'block';
2462
+ this._input.setAttribute('aria-expanded', 'true');
2463
+ this._updateArrowRotation();
2464
+ // Clear search query when opening to show all options
2465
+ // This ensures we can scroll to selected item
2466
+ if (this._config.searchable) {
2467
+ this._state.searchQuery = '';
2468
+ // Don't clear input value if it represents selection
2469
+ // But if we want to search, we might want to clear it?
2470
+ // Standard behavior: input keeps value (label), but dropdown shows all options
2471
+ // until user types.
2472
+ // However, our filtering logic uses _state.searchQuery.
2473
+ // So clearing it here resets the filter.
2474
+ }
2475
+ // Render options when opening
2476
+ this._renderOptions();
2477
+ this._setInitialActiveOption();
2478
+ this._emit('open', {});
2479
+ this._config.callbacks.onOpen?.();
2480
+ this._announce('Options expanded');
2481
+ // Scroll to selected if configured
2482
+ if (this._config.scrollToSelected.enabled) {
2483
+ // Use requestAnimationFrame for better timing after render
2484
+ requestAnimationFrame(() => {
2485
+ // Double RAF to ensure layout is complete
2486
+ requestAnimationFrame(() => {
2487
+ this._scrollToSelected();
2488
+ });
2489
+ });
2408
2490
  }
2409
2491
  }
2410
- _selectOption(index) {
2411
- // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
2412
- // Instead, use the index to update state directly
2413
- const item = this._state.loadedItems[index];
2414
- if (!item)
2492
+ _handleClose() {
2493
+ if (!this._state.isOpen)
2415
2494
  return;
2416
- const isCurrentlySelected = this._state.selectedIndices.has(index);
2417
- if (this._config.selection.mode === 'single') {
2418
- // Single select: clear previous and select new
2419
- const wasSelected = this._state.selectedIndices.has(index);
2420
- this._state.selectedIndices.clear();
2421
- this._state.selectedItems.clear();
2422
- if (!wasSelected) {
2423
- // Select this option
2424
- this._state.selectedIndices.add(index);
2425
- this._state.selectedItems.set(index, item);
2426
- }
2427
- // Re-render to update all option styles
2428
- this._renderOptions();
2429
- if (this._config.selection.closeOnSelect) {
2430
- this._handleClose();
2431
- }
2495
+ this._state.isOpen = false;
2496
+ this._dropdown.style.display = 'none';
2497
+ this._input.setAttribute('aria-expanded', 'false');
2498
+ this._input.removeAttribute('aria-activedescendant');
2499
+ this._updateArrowRotation();
2500
+ this._emit('close', {});
2501
+ this._config.callbacks.onClose?.();
2502
+ this._announce('Options collapsed');
2503
+ }
2504
+ _updateDropdownVisibility() {
2505
+ if (this._state.isOpen) {
2506
+ this._dropdown.style.display = 'block';
2507
+ this._input.setAttribute('aria-expanded', 'true');
2432
2508
  }
2433
2509
  else {
2434
- // Multi select with toggle
2435
- const maxSelections = this._config.selection.maxSelections || 0;
2436
- if (isCurrentlySelected) {
2437
- // Deselect (toggle off)
2438
- this._state.selectedIndices.delete(index);
2439
- this._state.selectedItems.delete(index);
2440
- }
2441
- else {
2442
- // Select (toggle on)
2443
- if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
2444
- this._announce(`Maximum ${maxSelections} selections allowed`);
2445
- return; // Max selections reached
2510
+ this._dropdown.style.display = 'none';
2511
+ this._input.setAttribute('aria-expanded', 'false');
2512
+ }
2513
+ }
2514
+ _updateArrowRotation() {
2515
+ if (this._arrowContainer) {
2516
+ const arrow = this._arrowContainer.querySelector('.dropdown-arrow');
2517
+ if (arrow) {
2518
+ if (this._state.isOpen) {
2519
+ arrow.classList.add('open');
2520
+ }
2521
+ else {
2522
+ arrow.classList.remove('open');
2446
2523
  }
2447
- this._state.selectedIndices.add(index);
2448
- this._state.selectedItems.set(index, item);
2449
2524
  }
2450
- // Re-render to update styles (safer than trying to find the element in filtered list)
2451
- this._renderOptions();
2452
2525
  }
2453
- this._updateInputDisplay();
2454
- this._emitChange();
2455
- // Call user callback
2456
- const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2457
- const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2458
- this._config.callbacks.onSelect?.({
2459
- item: item,
2460
- index,
2461
- value: getValue(item),
2462
- label: getLabel(item),
2463
- selected: this._state.selectedIndices.has(index),
2464
- });
2465
2526
  }
2466
- _handleOptionRemove(index) {
2467
- const option = this._optionsContainer.children[index];
2468
- if (!option)
2527
+ _isPerfEnabled() {
2528
+ return typeof globalThis !== 'undefined'
2529
+ && globalThis.__SMILODON_DEV__ === true
2530
+ && typeof performance !== 'undefined'
2531
+ && typeof performance.mark === 'function'
2532
+ && typeof performance.measure === 'function';
2533
+ }
2534
+ _perfMark(name) {
2535
+ if (!this._isPerfEnabled())
2469
2536
  return;
2470
- this._state.selectedIndices.delete(index);
2471
- this._state.selectedItems.delete(index);
2472
- option.setSelected(false);
2473
- this._updateInputDisplay();
2474
- this._emitChange();
2475
- const config = option.getConfig();
2476
- this._emit('remove', { item: config.item, index });
2537
+ performance.mark(name);
2477
2538
  }
2478
- _updateInputDisplay() {
2479
- const selectedItems = Array.from(this._state.selectedItems.values());
2480
- const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2481
- if (selectedItems.length === 0) {
2482
- this._input.value = '';
2483
- this._input.placeholder = this._config.placeholder || 'Select an option...';
2484
- // Clear any badges
2485
- const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
2486
- existingBadges.forEach(badge => badge.remove());
2539
+ _perfMeasure(name, start, end) {
2540
+ if (!this._isPerfEnabled())
2541
+ return;
2542
+ performance.measure(name, start, end);
2543
+ }
2544
+ _markOpenStart() {
2545
+ if (!this._isPerfEnabled())
2546
+ return;
2547
+ this._pendingFirstRenderMark = true;
2548
+ this._perfMark('smilodon-dropdown-open-start');
2549
+ }
2550
+ _finalizePerfMarks() {
2551
+ if (!this._isPerfEnabled()) {
2552
+ this._pendingFirstRenderMark = false;
2553
+ this._pendingSearchRenderMark = false;
2554
+ return;
2487
2555
  }
2488
- else if (this._config.selection.mode === 'single') {
2489
- this._input.value = getLabel(selectedItems[0]);
2556
+ if (this._pendingFirstRenderMark) {
2557
+ this._pendingFirstRenderMark = false;
2558
+ this._perfMark('smilodon-first-render-complete');
2559
+ this._perfMeasure('smilodon-dropdown-to-first-render', 'smilodon-dropdown-open-start', 'smilodon-first-render-complete');
2490
2560
  }
2491
- else {
2492
- // Multi-select: show badges instead of text in input
2493
- this._input.value = '';
2494
- this._input.placeholder = '';
2495
- // Clear existing badges
2496
- const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
2497
- existingBadges.forEach(badge => badge.remove());
2498
- // Create badges for each selected item
2499
- const selectedEntries = Array.from(this._state.selectedItems.entries());
2500
- selectedEntries.forEach(([index, item]) => {
2501
- const badge = document.createElement('span');
2502
- badge.className = 'selection-badge';
2503
- badge.textContent = getLabel(item);
2504
- // Add remove button to badge
2505
- const removeBtn = document.createElement('button');
2506
- removeBtn.className = 'badge-remove';
2507
- removeBtn.innerHTML = '×';
2508
- removeBtn.setAttribute('aria-label', `Remove ${getLabel(item)}`);
2509
- removeBtn.addEventListener('click', (e) => {
2510
- e.stopPropagation();
2511
- this._state.selectedIndices.delete(index);
2512
- this._state.selectedItems.delete(index);
2513
- this._updateInputDisplay();
2514
- this._renderOptions();
2515
- this._emitChange();
2516
- });
2517
- badge.appendChild(removeBtn);
2518
- this._inputContainer.insertBefore(badge, this._input);
2519
- });
2561
+ if (this._pendingSearchRenderMark) {
2562
+ this._pendingSearchRenderMark = false;
2563
+ this._perfMark('smilodon-search-render-complete');
2564
+ this._perfMeasure('smilodon-search-to-render', 'smilodon-search-input-last', 'smilodon-search-render-complete');
2520
2565
  }
2521
2566
  }
2522
- _renderOptionsWithAnimation() {
2523
- // Add fade-out animation
2524
- this._optionsContainer.style.opacity = '0';
2525
- this._optionsContainer.style.transition = 'opacity 0.15s ease-out';
2526
- setTimeout(() => {
2567
+ _handleSearch(query) {
2568
+ this._state.searchQuery = query;
2569
+ if (query.length > 0) {
2570
+ this._perfMark('smilodon-search-input-last');
2571
+ this._pendingSearchRenderMark = true;
2572
+ }
2573
+ else {
2574
+ this._pendingSearchRenderMark = false;
2575
+ }
2576
+ // Clear previous search timeout
2577
+ if (this._searchTimeout) {
2578
+ clearTimeout(this._searchTimeout);
2579
+ }
2580
+ // Search immediately - no debouncing for better responsiveness
2581
+ // Users expect instant feedback as they type
2582
+ this._state.isSearching = false;
2583
+ // Ensure dropdown is open when searching
2584
+ if (!this._state.isOpen) {
2585
+ this._handleOpen();
2586
+ }
2587
+ else {
2588
+ // Filter and render options immediately
2527
2589
  this._renderOptions();
2528
- // Fade back in
2529
- this._optionsContainer.style.opacity = '1';
2530
- this._optionsContainer.style.transition = 'opacity 0.2s ease-in';
2531
- }, 150);
2590
+ }
2591
+ // Get filtered items based on search query - searches ENTIRE phrase
2592
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2593
+ // FIX: Do not trim query to allow searching for phrases with spaces
2594
+ const searchQuery = query.toLowerCase();
2595
+ const filteredItems = searchQuery
2596
+ ? this._state.loadedItems.filter((item) => {
2597
+ try {
2598
+ const label = String(getLabel(item)).toLowerCase();
2599
+ // Match the entire search phrase
2600
+ return label.includes(searchQuery);
2601
+ }
2602
+ catch (e) {
2603
+ return false;
2604
+ }
2605
+ })
2606
+ : this._state.loadedItems;
2607
+ const count = filteredItems.length;
2608
+ // Announce search results for accessibility
2609
+ if (searchQuery) {
2610
+ this._announce(`${count} result${count !== 1 ? 's' : ''} found for "${query}"`);
2611
+ }
2612
+ // Only notify if query or result count changed to prevent infinite loops
2613
+ if (query !== this._state.lastNotifiedQuery || count !== this._state.lastNotifiedResultCount) {
2614
+ this._state.lastNotifiedQuery = query;
2615
+ this._state.lastNotifiedResultCount = count;
2616
+ // Use setTimeout to avoid synchronous state updates during render
2617
+ setTimeout(() => {
2618
+ this._emit('search', { query, results: filteredItems, count });
2619
+ this._config.callbacks.onSearch?.(query);
2620
+ }, 0);
2621
+ }
2532
2622
  }
2533
- _scrollToSelected() {
2534
- if (this._state.selectedIndices.size === 0)
2535
- return;
2536
- const target = this._config.scrollToSelected.multiSelectTarget;
2537
- const indices = Array.from(this._state.selectedIndices).sort((a, b) => a - b);
2538
- // For multi-select, find the closest selected item to the current scroll position
2539
- let targetIndex;
2540
- if (this._config.selection.mode === 'multi' && indices.length > 1) {
2541
- // Calculate which selected item is closest to the center of the viewport
2542
- const dropdownRect = this._dropdown.getBoundingClientRect();
2543
- const viewportCenter = this._dropdown.scrollTop + (dropdownRect.height / 2);
2544
- // Find the selected item closest to viewport center
2545
- let closestIndex = indices[0];
2546
- let closestDistance = Infinity;
2547
- for (const index of indices) {
2548
- const optionId = `${this._uniqueId}-option-${index}`;
2549
- const option = this._optionsContainer.querySelector(`[id="${optionId}"]`);
2550
- if (option) {
2551
- const optionTop = option.offsetTop;
2552
- const distance = Math.abs(optionTop - viewportCenter);
2553
- if (distance < closestDistance) {
2554
- closestDistance = distance;
2555
- closestIndex = index;
2623
+ _handleKeydown(e) {
2624
+ switch (e.key) {
2625
+ case 'ArrowDown':
2626
+ e.preventDefault();
2627
+ if (!this._state.isOpen) {
2628
+ this._handleOpen();
2629
+ }
2630
+ else {
2631
+ this._moveActive(1, { shiftKey: e.shiftKey, toggleKey: e.ctrlKey || e.metaKey });
2632
+ }
2633
+ break;
2634
+ case 'ArrowUp':
2635
+ e.preventDefault();
2636
+ if (!this._state.isOpen) {
2637
+ this._handleOpen();
2638
+ }
2639
+ else {
2640
+ this._moveActive(-1, { shiftKey: e.shiftKey, toggleKey: e.ctrlKey || e.metaKey });
2641
+ }
2642
+ break;
2643
+ case 'Home':
2644
+ e.preventDefault();
2645
+ if (this._state.isOpen) {
2646
+ this._setActive(0);
2647
+ if (this._config.selection.mode === 'multi' && e.shiftKey) {
2648
+ this._selectRange(this._rangeAnchorIndex ?? 0, 0, { clear: !(e.ctrlKey || e.metaKey) });
2649
+ }
2650
+ }
2651
+ break;
2652
+ case 'End':
2653
+ e.preventDefault();
2654
+ if (this._state.isOpen) {
2655
+ const options = Array.from(this._optionsContainer.children);
2656
+ const lastIndex = Math.max(0, options.length - 1);
2657
+ this._setActive(lastIndex);
2658
+ if (this._config.selection.mode === 'multi' && e.shiftKey) {
2659
+ this._selectRange(this._rangeAnchorIndex ?? lastIndex, lastIndex, { clear: !(e.ctrlKey || e.metaKey) });
2556
2660
  }
2557
2661
  }
2558
- }
2559
- targetIndex = closestIndex;
2560
- }
2561
- else {
2562
- // For single select or only one selected item, use the configured target
2563
- targetIndex = target === 'first' ? indices[0] : indices[indices.length - 1];
2564
- }
2565
- // Find and scroll to the target option
2566
- const optionId = `${this._uniqueId}-option-${targetIndex}`;
2567
- const option = this._optionsContainer.querySelector(`[id="${optionId}"]`);
2568
- if (option) {
2569
- // Use smooth scrolling with center alignment for better UX
2570
- option.scrollIntoView({
2571
- block: this._config.scrollToSelected.block || 'center',
2572
- behavior: 'smooth',
2573
- });
2574
- // Also set it as active for keyboard navigation
2575
- this._setActive(targetIndex);
2662
+ break;
2663
+ case 'PageDown':
2664
+ e.preventDefault();
2665
+ if (this._state.isOpen) {
2666
+ this._moveActive(10, { shiftKey: e.shiftKey, toggleKey: e.ctrlKey || e.metaKey });
2667
+ }
2668
+ break;
2669
+ case 'PageUp':
2670
+ e.preventDefault();
2671
+ if (this._state.isOpen) {
2672
+ this._moveActive(-10, { shiftKey: e.shiftKey, toggleKey: e.ctrlKey || e.metaKey });
2673
+ }
2674
+ break;
2675
+ case 'Enter':
2676
+ e.preventDefault();
2677
+ if (this._state.activeIndex >= 0) {
2678
+ this._selectOption(this._state.activeIndex, { shiftKey: e.shiftKey, toggleKey: e.ctrlKey || e.metaKey });
2679
+ }
2680
+ break;
2681
+ case 'Escape':
2682
+ e.preventDefault();
2683
+ this._handleClose();
2684
+ break;
2685
+ case 'Tab':
2686
+ if (this._state.isOpen) {
2687
+ this._handleClose();
2688
+ }
2689
+ break;
2690
+ case 'a':
2691
+ case 'A':
2692
+ if ((e.ctrlKey || e.metaKey) && this._config.selection.mode === 'multi') {
2693
+ e.preventDefault();
2694
+ this._selectAll();
2695
+ }
2696
+ break;
2697
+ default:
2698
+ // Type-ahead search
2699
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
2700
+ this._handleTypeAhead(e.key);
2701
+ }
2702
+ break;
2576
2703
  }
2577
2704
  }
2578
- async _loadMoreItems() {
2579
- if (this._state.isBusy)
2580
- return;
2581
- this._setBusy(true);
2582
- // Save scroll position before loading
2583
- if (this._dropdown) {
2584
- this._state.lastScrollPosition = this._dropdown.scrollTop;
2585
- this._state.preserveScrollPosition = true;
2586
- // Update dropdown to show loading indicator but keep the
2587
- // same scrollTop so the visible items don't move.
2588
- this._renderOptions();
2589
- this._dropdown.scrollTop = this._state.lastScrollPosition;
2590
- }
2591
- try {
2592
- // Emit event for parent to handle
2593
- this._state.currentPage++;
2594
- this._emit('loadMore', { page: this._state.currentPage, items: [] });
2595
- this._config.callbacks.onLoadMore?.(this._state.currentPage);
2596
- // NOTE: We do NOT set isBusy = false here.
2597
- // The parent component MUST call setItems() or similar to clear the busy state.
2598
- // This prevents the sentinel from reappearing before new items are loaded.
2599
- }
2600
- catch (error) {
2601
- this._handleError(error);
2602
- this._setBusy(false); // Only clear on error
2705
+ _moveActive(delta, opts) {
2706
+ const options = Array.from(this._optionsContainer.children);
2707
+ const next = Math.max(0, Math.min(options.length - 1, this._state.activeIndex + delta));
2708
+ this._setActive(next);
2709
+ if (this._config.selection.mode === 'multi' && opts?.shiftKey) {
2710
+ const anchor = this._rangeAnchorIndex ?? this._state.activeIndex;
2711
+ const anchorIndex = anchor >= 0 ? anchor : next;
2712
+ if (this._rangeAnchorIndex === null) {
2713
+ this._rangeAnchorIndex = anchorIndex;
2714
+ }
2715
+ this._selectRange(anchorIndex, next, { clear: !opts?.toggleKey });
2603
2716
  }
2604
2717
  }
2605
- _setBusy(busy) {
2606
- this._state.isBusy = busy;
2607
- // Trigger re-render to show/hide busy indicator
2608
- // We use _renderOptions to handle the UI update
2609
- this._renderOptions();
2610
- }
2611
- _showBusyBucket() {
2612
- // Deprecated: Logic moved to _renderOptions
2613
- }
2614
- _hideBusyBucket() {
2615
- // Deprecated: Logic moved to _renderOptions
2616
- }
2617
- _handleError(error) {
2618
- this._emit('error', { message: error.message, cause: error });
2619
- this._config.callbacks.onError?.(error);
2620
- }
2621
- _emit(name, detail) {
2622
- this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
2623
- }
2624
- _emitChange() {
2625
- const selectedItems = Array.from(this._state.selectedItems.values());
2626
- const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2627
- const selectedValues = selectedItems.map(getValue);
2628
- const selectedIndices = Array.from(this._state.selectedIndices);
2629
- this._emit('change', { selectedItems, selectedValues, selectedIndices });
2630
- this._config.callbacks.onChange?.(selectedItems, selectedValues);
2631
- }
2632
- // Public API
2633
- /**
2634
- * Set items to display in the select
2635
- */
2636
- setItems(items) {
2637
- const previousLength = this._state.loadedItems.length;
2638
- this._state.loadedItems = items;
2639
- // If grouped items exist, flatten them to items
2640
- if (this._state.groupedItems.length > 0) {
2641
- this._state.loadedItems = this._state.groupedItems.flatMap(group => group.options);
2642
- }
2643
- const newLength = this._state.loadedItems.length;
2644
- // When infinite scroll is active (preserveScrollPosition = true),
2645
- // we need to maintain scroll position during the update
2646
- if (this._state.preserveScrollPosition && this._dropdown) {
2647
- const targetScrollTop = this._state.lastScrollPosition;
2648
- // Only clear loading if we actually got more items
2649
- if (newLength > previousLength) {
2650
- this._state.isBusy = false;
2718
+ _setActive(index) {
2719
+ const options = Array.from(this._optionsContainer.children);
2720
+ // Clear previous active state
2721
+ if (this._state.activeIndex >= 0 && options[this._state.activeIndex]) {
2722
+ const prevOption = options[this._state.activeIndex];
2723
+ // Check if it's a custom SelectOption or a lightweight DOM element
2724
+ if ('setActive' in prevOption && typeof prevOption.setActive === 'function') {
2725
+ prevOption.setActive(false);
2651
2726
  }
2652
- this._renderOptions();
2653
- // Restore the exact scrollTop we had before loading
2654
- // so the previously visible items stay in place and
2655
- // new ones simply appear below.
2656
- this._dropdown.scrollTop = targetScrollTop;
2657
- // Ensure it sticks after layout
2658
- requestAnimationFrame(() => {
2659
- if (this._dropdown) {
2660
- this._dropdown.scrollTop = targetScrollTop;
2661
- }
2662
- });
2663
- // Only clear preserveScrollPosition if we got new items
2664
- if (newLength > previousLength) {
2665
- this._state.preserveScrollPosition = false;
2727
+ else {
2728
+ // Lightweight option - remove active class
2729
+ prevOption.classList.remove('smilodon-option--active');
2730
+ prevOption.setAttribute('aria-selected', 'false');
2666
2731
  }
2667
2732
  }
2668
- else {
2669
- // Normal update - just render normally
2670
- this._state.isBusy = false;
2671
- this._renderOptions();
2733
+ this._state.activeIndex = index;
2734
+ // Set new active state
2735
+ if (options[index]) {
2736
+ const option = options[index];
2737
+ // Check if it's a custom SelectOption or a lightweight DOM element
2738
+ if ('setActive' in option && typeof option.setActive === 'function') {
2739
+ option.setActive(true);
2740
+ }
2741
+ else {
2742
+ // Lightweight option - add active class
2743
+ option.classList.add('smilodon-option--active');
2744
+ option.setAttribute('aria-selected', 'true');
2745
+ }
2746
+ option.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2747
+ // Announce position for screen readers
2748
+ const total = options.length;
2749
+ this._announce(`Item ${index + 1} of ${total}`);
2750
+ // Update aria-activedescendant using the actual option id when available
2751
+ const optionId = option.id || `${this._uniqueId}-option-${index}`;
2752
+ this._input.setAttribute('aria-activedescendant', optionId);
2672
2753
  }
2673
2754
  }
2674
- /**
2675
- * Set grouped items
2676
- */
2677
- setGroupedItems(groupedItems) {
2678
- this._state.groupedItems = groupedItems;
2679
- this._state.loadedItems = groupedItems.flatMap(group => group.options);
2680
- this._renderOptions();
2681
- }
2682
- /**
2683
- * Get currently selected items
2684
- */
2685
- getSelectedItems() {
2686
- return Array.from(this._state.selectedItems.values());
2687
- }
2688
- /**
2689
- * Get all loaded items
2690
- */
2691
- get loadedItems() {
2692
- return this._state.loadedItems;
2755
+ _getOptionElementByIndex(index) {
2756
+ return this._optionsContainer.querySelector(`[data-index="${index}"]`);
2693
2757
  }
2694
- /**
2695
- * Get currently selected values
2696
- */
2697
- getSelectedValues() {
2698
- const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2699
- return this.getSelectedItems().map(getValue);
2758
+ _buildRendererHelpers() {
2759
+ return {
2760
+ onSelect: (_item, index) => this._selectOption(index),
2761
+ getIndex: (node) => {
2762
+ const el = node?.closest?.('[data-selectable]');
2763
+ if (!el)
2764
+ return null;
2765
+ const idx = Number(el.dataset.index);
2766
+ return Number.isFinite(idx) ? idx : null;
2767
+ },
2768
+ keyboardFocus: (index) => {
2769
+ this._setActive(index);
2770
+ const el = this._getOptionElementByIndex(index);
2771
+ el?.focus?.();
2772
+ },
2773
+ };
2700
2774
  }
2701
- /**
2702
- * Set selected items by value
2703
- */
2704
- async setSelectedValues(values) {
2705
- if (this._config.serverSide.enabled && this._config.serverSide.fetchSelectedItems) {
2706
- await this._loadSelectedItemsByValues(values);
2775
+ _handleTypeAhead(char) {
2776
+ if (this._typeTimeout)
2777
+ clearTimeout(this._typeTimeout);
2778
+ this._typeBuffer += char.toLowerCase();
2779
+ this._typeTimeout = window.setTimeout(() => {
2780
+ this._typeBuffer = '';
2781
+ }, 500);
2782
+ // Find first matching option
2783
+ const getValue = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2784
+ const matchIndex = this._state.loadedItems.findIndex((item) => getValue(item).toLowerCase().startsWith(this._typeBuffer));
2785
+ if (matchIndex >= 0) {
2786
+ this._setActive(matchIndex);
2707
2787
  }
2708
- else {
2709
- // Select from loaded items
2710
- const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2711
- this._state.selectedIndices.clear();
2712
- this._state.selectedItems.clear();
2713
- this._state.loadedItems.forEach((item, index) => {
2714
- if (values.includes(getValue(item))) {
2788
+ }
2789
+ _selectAll() {
2790
+ if (this._config.selection.mode !== 'multi')
2791
+ return;
2792
+ const options = Array.from(this._optionsContainer.children);
2793
+ const maxSelections = this._config.selection.maxSelections || 0;
2794
+ options.forEach((option, index) => {
2795
+ if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
2796
+ return;
2797
+ }
2798
+ if (!this._state.selectedIndices.has(index)) {
2799
+ // Check if it's a custom SelectOption or a lightweight DOM element
2800
+ if ('getConfig' in option && typeof option.getConfig === 'function') {
2801
+ const config = option.getConfig();
2715
2802
  this._state.selectedIndices.add(index);
2716
- this._state.selectedItems.set(index, item);
2803
+ this._state.selectedItems.set(index, config.item);
2804
+ option.setSelected(true);
2717
2805
  }
2718
- });
2719
- this._renderOptions();
2720
- this._updateInputDisplay();
2721
- this._emitChange();
2722
- }
2806
+ else {
2807
+ // Lightweight option - get item from data attribute or state
2808
+ const item = this._state.loadedItems[index];
2809
+ if (item) {
2810
+ this._state.selectedIndices.add(index);
2811
+ this._state.selectedItems.set(index, item);
2812
+ option.classList.add('smilodon-option--selected');
2813
+ option.setAttribute('aria-selected', 'true');
2814
+ }
2815
+ }
2816
+ }
2817
+ });
2818
+ this._updateInputDisplay();
2819
+ this._emitChange();
2820
+ this._announce(`Selected all ${options.length} items`);
2723
2821
  }
2724
- /**
2725
- * Load and select items by their values (for infinite scroll scenario)
2726
- */
2727
- async _loadSelectedItemsByValues(values) {
2728
- if (!this._config.serverSide.fetchSelectedItems)
2822
+ _selectRange(start, end, opts) {
2823
+ if (this._config.selection.mode !== 'multi')
2729
2824
  return;
2730
- this._setBusy(true);
2731
- try {
2732
- const items = await this._config.serverSide.fetchSelectedItems(values);
2825
+ const maxSelections = this._config.selection.maxSelections || 0;
2826
+ const [min, max] = start < end ? [start, end] : [end, start];
2827
+ if (opts?.clear) {
2733
2828
  this._state.selectedIndices.clear();
2734
2829
  this._state.selectedItems.clear();
2735
- items.forEach((item, index) => {
2736
- this._state.selectedIndices.add(index);
2737
- this._state.selectedItems.set(index, item);
2738
- });
2739
- this._renderOptions();
2740
- this._updateInputDisplay();
2741
- this._emitChange();
2742
- // Scroll to selected if configured
2743
- if (this._config.scrollToSelected.enabled) {
2744
- this._scrollToSelected();
2745
- }
2746
- }
2747
- catch (error) {
2748
- this._handleError(error);
2749
2830
  }
2750
- finally {
2751
- this._setBusy(false);
2831
+ for (let i = min; i <= max; i += 1) {
2832
+ if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections)
2833
+ break;
2834
+ const item = this._state.loadedItems[i];
2835
+ if (!item)
2836
+ continue;
2837
+ this._state.selectedIndices.add(i);
2838
+ this._state.selectedItems.set(i, item);
2752
2839
  }
2753
- }
2754
- /**
2755
- * Clear all selections
2756
- */
2757
- clear() {
2758
- this._state.selectedIndices.clear();
2759
- this._state.selectedItems.clear();
2760
2840
  this._renderOptions();
2761
2841
  this._updateInputDisplay();
2762
2842
  this._emitChange();
2843
+ this._announce(`${this._state.selectedIndices.size} items selected`);
2763
2844
  }
2764
- /**
2765
- * Open dropdown
2766
- */
2767
- open() {
2768
- this._handleOpen();
2769
- }
2770
- /**
2771
- * Close dropdown
2772
- */
2773
- close() {
2774
- this._handleClose();
2775
- }
2776
- /**
2777
- * Update component configuration
2778
- */
2779
- updateConfig(config) {
2780
- this._config = selectConfig.mergeWithComponentConfig(config);
2781
- // Update input state based on new config
2782
- if (this._input) {
2783
- this._input.readOnly = !this._config.searchable;
2784
- this._input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
2785
- }
2786
- // Re-initialize observers in case infinite scroll was enabled/disabled
2787
- this._initializeObservers();
2788
- this._renderOptions();
2789
- }
2790
- /**
2791
- * Set error state
2792
- */
2793
- setError(message) {
2794
- this._hasError = true;
2795
- this._errorMessage = message;
2796
- this._input.setAttribute('aria-invalid', 'true');
2797
- this._announce(`Error: ${message}`);
2798
- }
2799
- /**
2800
- * Clear error state
2801
- */
2802
- clearError() {
2803
- this._hasError = false;
2804
- this._errorMessage = '';
2805
- this._input.removeAttribute('aria-invalid');
2806
- }
2807
- /**
2808
- * Set required state
2809
- */
2810
- setRequired(required) {
2811
- if (required) {
2812
- this._input.setAttribute('aria-required', 'true');
2813
- this._input.setAttribute('required', '');
2814
- }
2815
- else {
2816
- this._input.removeAttribute('aria-required');
2817
- this._input.removeAttribute('required');
2845
+ _setInitialActiveOption() {
2846
+ const options = Array.from(this._optionsContainer.children);
2847
+ if (options.length === 0)
2848
+ return;
2849
+ const selected = Array.from(this._state.selectedIndices).sort((a, b) => a - b);
2850
+ if (this._config.selection.mode === 'multi' && selected.length === 0) {
2851
+ this._state.activeIndex = -1;
2852
+ this._input.removeAttribute('aria-activedescendant');
2853
+ return;
2818
2854
  }
2855
+ const target = selected.length > 0 ? selected[0] : 0;
2856
+ this._setActive(Math.min(target, options.length - 1));
2819
2857
  }
2820
- /**
2821
- * Validate selection (for required fields)
2822
- */
2823
- validate() {
2824
- const isRequired = this._input.hasAttribute('required');
2825
- if (isRequired && this._state.selectedIndices.size === 0) {
2826
- this.setError('Selection is required');
2827
- return false;
2858
+ _announce(message) {
2859
+ if (this._liveRegion) {
2860
+ this._liveRegion.textContent = message;
2861
+ setTimeout(() => {
2862
+ if (this._liveRegion)
2863
+ this._liveRegion.textContent = '';
2864
+ }, 1000);
2828
2865
  }
2829
- this.clearError();
2830
- return true;
2831
2866
  }
2832
- /**
2833
- * Render options based on current state
2834
- */
2835
- _renderOptions() {
2836
- // Cleanup observer
2837
- if (this._loadMoreTrigger && this._intersectionObserver) {
2838
- this._intersectionObserver.unobserve(this._loadMoreTrigger);
2839
- }
2840
- // Clear options container
2841
- this._optionsContainer.innerHTML = '';
2842
- // Ensure dropdown only contains options container (cleanup legacy direct children)
2843
- // We need to preserve optionsContainer, so we can't just clear dropdown.innerHTML
2844
- // But we can check if there are other children and remove them
2845
- Array.from(this._dropdown.children).forEach(child => {
2846
- if (child !== this._optionsContainer) {
2847
- this._dropdown.removeChild(child);
2848
- }
2849
- });
2850
- // Ensure dropdown is visible if we are rendering options
2851
- if (this._state.isOpen && this._dropdown.style.display === 'none') {
2852
- this._dropdown.style.display = 'block';
2853
- }
2854
- // Show searching state (exclusive state)
2855
- if (this._state.isSearching) {
2856
- const searching = document.createElement('div');
2857
- searching.className = 'searching-state';
2858
- searching.textContent = 'Searching...';
2859
- this._optionsContainer.appendChild(searching);
2867
+ _selectOption(index, opts) {
2868
+ // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
2869
+ // Instead, use the index to update state directly
2870
+ const item = this._state.loadedItems[index];
2871
+ if (!item)
2860
2872
  return;
2861
- }
2862
- const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2863
- const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2864
- // Filter items by search query
2865
- const query = this._state.searchQuery.toLowerCase();
2866
- // Handle Grouped Items Rendering (when no search query)
2867
- if (this._state.groupedItems.length > 0 && !query) {
2868
- this._state.groupedItems.forEach(group => {
2869
- const header = document.createElement('div');
2870
- header.className = 'group-header';
2871
- header.textContent = group.label;
2872
- Object.assign(header.style, {
2873
- padding: '8px 12px',
2874
- fontWeight: '600',
2875
- color: '#6b7280',
2876
- backgroundColor: '#f3f4f6',
2877
- fontSize: '12px',
2878
- textTransform: 'uppercase',
2879
- letterSpacing: '0.05em',
2880
- position: 'sticky',
2881
- top: '0',
2882
- zIndex: '1',
2883
- borderBottom: '1px solid #e5e7eb'
2884
- });
2885
- this._optionsContainer.appendChild(header);
2886
- group.options.forEach(item => {
2887
- // Find original index for correct ID generation and selection
2888
- const index = this._state.loadedItems.indexOf(item);
2889
- if (index !== -1) {
2890
- this._renderSingleOption(item, index, getValue, getLabel);
2891
- }
2892
- });
2893
- });
2873
+ const isCurrentlySelected = this._state.selectedIndices.has(index);
2874
+ if (this._config.selection.mode === 'single') {
2875
+ // Single select: clear previous and select new
2876
+ const wasSelected = this._state.selectedIndices.has(index);
2877
+ this._state.selectedIndices.clear();
2878
+ this._state.selectedItems.clear();
2879
+ if (!wasSelected) {
2880
+ // Select this option
2881
+ this._state.selectedIndices.add(index);
2882
+ this._state.selectedItems.set(index, item);
2883
+ }
2884
+ // Re-render to update all option styles
2885
+ this._renderOptions();
2886
+ if (this._config.selection.closeOnSelect) {
2887
+ this._handleClose();
2888
+ }
2894
2889
  }
2895
2890
  else {
2896
- // Normal rendering (flat list or filtered)
2897
- let hasRenderedItems = false;
2898
- this._state.loadedItems.forEach((item, index) => {
2899
- // Apply filter if query exists
2900
- if (query) {
2901
- try {
2902
- const label = String(getLabel(item)).toLowerCase();
2903
- if (!label.includes(query))
2904
- return;
2905
- }
2906
- catch (e) {
2907
- return;
2908
- }
2909
- }
2910
- hasRenderedItems = true;
2911
- this._renderSingleOption(item, index, getValue, getLabel);
2912
- });
2913
- if (!hasRenderedItems && !this._state.isBusy) {
2914
- const empty = document.createElement('div');
2915
- empty.className = 'empty-state';
2916
- if (query) {
2917
- empty.textContent = `No results found for "${this._state.searchQuery}"`;
2918
- }
2919
- else {
2920
- empty.textContent = 'No options available';
2921
- }
2922
- this._optionsContainer.appendChild(empty);
2891
+ const toggleKey = Boolean(opts?.toggleKey);
2892
+ const shiftKey = Boolean(opts?.shiftKey);
2893
+ if (shiftKey) {
2894
+ const anchor = this._rangeAnchorIndex ?? index;
2895
+ this._selectRange(anchor, index, { clear: !toggleKey });
2896
+ this._rangeAnchorIndex = anchor;
2897
+ return;
2923
2898
  }
2924
- }
2925
- // Append Busy Indicator if busy
2926
- if (this._state.isBusy && this._config.busyBucket.enabled) {
2927
- const busyBucket = document.createElement('div');
2928
- busyBucket.className = 'busy-bucket';
2929
- if (this._config.busyBucket.showSpinner) {
2930
- const spinner = document.createElement('div');
2931
- spinner.className = 'spinner';
2932
- busyBucket.appendChild(spinner);
2899
+ // Multi select with toggle
2900
+ const maxSelections = this._config.selection.maxSelections || 0;
2901
+ if (isCurrentlySelected) {
2902
+ // Deselect (toggle off)
2903
+ this._state.selectedIndices.delete(index);
2904
+ this._state.selectedItems.delete(index);
2933
2905
  }
2934
- if (this._config.busyBucket.message) {
2935
- const message = document.createElement('div');
2936
- message.textContent = this._config.busyBucket.message;
2937
- busyBucket.appendChild(message);
2906
+ else {
2907
+ // Select (toggle on)
2908
+ if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
2909
+ this._announce(`Maximum ${maxSelections} selections allowed`);
2910
+ return; // Max selections reached
2911
+ }
2912
+ this._state.selectedIndices.add(index);
2913
+ this._state.selectedItems.set(index, item);
2938
2914
  }
2939
- this._optionsContainer.appendChild(busyBucket);
2940
- }
2941
- // Append Load More Trigger (Button or Sentinel) if enabled and not busy
2942
- else if ((this._config.loadMore.enabled || this._config.infiniteScroll.enabled) && this._state.loadedItems.length > 0) {
2943
- this._addLoadMoreTrigger();
2915
+ // Re-render to update styles (safer than trying to find the element in filtered list)
2916
+ this._renderOptions();
2944
2917
  }
2945
- }
2946
- _renderSingleOption(item, index, getValue, getLabel) {
2947
- const option = document.createElement('div');
2948
- option.className = 'option';
2949
- option.id = `${this._uniqueId}-option-${index}`;
2950
- const value = getValue(item);
2918
+ this._rangeAnchorIndex = index;
2919
+ this._updateInputDisplay();
2920
+ this._emitChange();
2921
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2922
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2951
2923
  const label = getLabel(item);
2952
- option.textContent = label;
2953
- option.dataset.value = String(value);
2954
- option.dataset.index = String(index); // Also useful for debugging/selectors
2955
- // Check if selected using selectedItems map
2956
- const isSelected = this._state.selectedIndices.has(index);
2957
- if (isSelected) {
2958
- option.classList.add('selected');
2959
- option.setAttribute('aria-selected', 'true');
2924
+ if (this._config.selection.mode === 'single') {
2925
+ this._announce(`Selected ${label}`);
2960
2926
  }
2961
2927
  else {
2962
- option.setAttribute('aria-selected', 'false');
2928
+ const selectedCount = this._state.selectedIndices.size;
2929
+ const action = isCurrentlySelected ? 'Deselected' : 'Selected';
2930
+ this._announce(`${action} ${label}. ${selectedCount} selected`);
2963
2931
  }
2964
- option.addEventListener('click', () => {
2965
- this._selectOption(index);
2932
+ this._config.callbacks.onSelect?.({
2933
+ item: item,
2934
+ index,
2935
+ value: getValue(item),
2936
+ label: getLabel(item),
2937
+ selected: this._state.selectedIndices.has(index),
2966
2938
  });
2967
- this._optionsContainer.appendChild(option);
2968
2939
  }
2969
- _addLoadMoreTrigger() {
2970
- const container = document.createElement('div');
2971
- container.className = 'load-more-container';
2972
- if (this._config.infiniteScroll.enabled) {
2973
- // Infinite Scroll: Render an invisible sentinel
2974
- // It must have some height to be intersected
2975
- const sentinel = document.createElement('div');
2976
- sentinel.className = 'infinite-scroll-sentinel';
2977
- sentinel.style.height = '10px';
2978
- sentinel.style.width = '100%';
2979
- sentinel.style.opacity = '0'; // Invisible
2980
- this._loadMoreTrigger = sentinel;
2981
- container.appendChild(sentinel);
2940
+ _handleOptionRemove(index) {
2941
+ const option = this._getOptionElementByIndex(index);
2942
+ if (!option)
2943
+ return;
2944
+ this._state.selectedIndices.delete(index);
2945
+ this._state.selectedItems.delete(index);
2946
+ option.setSelected(false);
2947
+ this._updateInputDisplay();
2948
+ this._emitChange();
2949
+ const config = option.getConfig();
2950
+ this._emit('remove', { item: config.item, index });
2951
+ }
2952
+ _updateInputDisplay() {
2953
+ const selectedItems = Array.from(this._state.selectedItems.values());
2954
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2955
+ if (selectedItems.length === 0) {
2956
+ this._input.value = '';
2957
+ this._input.placeholder = this._config.placeholder || 'Select an option...';
2958
+ // Clear any badges
2959
+ const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
2960
+ existingBadges.forEach(badge => badge.remove());
2982
2961
  }
2983
- else {
2984
- // Manual Load More: Render a button
2985
- const button = document.createElement('button');
2986
- button.className = 'load-more-button';
2987
- button.textContent = `Load ${this._config.loadMore.itemsPerLoad} more`;
2988
- button.addEventListener('click', () => this._loadMoreItems());
2989
- this._loadMoreTrigger = button;
2990
- container.appendChild(button);
2962
+ else if (this._config.selection.mode === 'single') {
2963
+ this._input.value = getLabel(selectedItems[0]);
2991
2964
  }
2992
- this._optionsContainer.appendChild(container);
2993
- // Setup intersection observer for auto-load
2994
- if (this._intersectionObserver && this._loadMoreTrigger) {
2995
- this._intersectionObserver.observe(this._loadMoreTrigger);
2965
+ else {
2966
+ // Multi-select: show badges instead of text in input
2967
+ this._input.value = '';
2968
+ this._input.placeholder = '';
2969
+ // Clear existing badges
2970
+ const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
2971
+ existingBadges.forEach(badge => badge.remove());
2972
+ // Create badges for each selected item
2973
+ const selectedEntries = Array.from(this._state.selectedItems.entries());
2974
+ selectedEntries.forEach(([index, item]) => {
2975
+ const badge = document.createElement('span');
2976
+ badge.className = 'selection-badge';
2977
+ badge.textContent = getLabel(item);
2978
+ // Add remove button to badge
2979
+ const removeBtn = document.createElement('button');
2980
+ removeBtn.className = 'badge-remove';
2981
+ removeBtn.innerHTML = '×';
2982
+ removeBtn.setAttribute('aria-label', `Remove ${getLabel(item)}`);
2983
+ removeBtn.addEventListener('click', (e) => {
2984
+ e.stopPropagation();
2985
+ this._state.selectedIndices.delete(index);
2986
+ this._state.selectedItems.delete(index);
2987
+ this._updateInputDisplay();
2988
+ this._renderOptions();
2989
+ this._emitChange();
2990
+ });
2991
+ badge.appendChild(removeBtn);
2992
+ this._inputContainer.insertBefore(badge, this._input);
2993
+ });
2996
2994
  }
2997
2995
  }
2998
- }
2999
- // Register custom element
3000
- if (!customElements.get('enhanced-select')) {
3001
- customElements.define('enhanced-select', EnhancedSelect);
3002
- }
3003
-
3004
- /**
3005
- * Independent Option Component
3006
- * High cohesion, low coupling - handles its own selection state and events
3007
- */
3008
- class SelectOption extends HTMLElement {
3009
- constructor(config) {
3010
- super();
3011
- this._config = config;
3012
- this._shadow = this.attachShadow({ mode: 'open' });
3013
- this._container = document.createElement('div');
3014
- this._container.className = 'option-container';
3015
- this._initializeStyles();
3016
- this._render();
3017
- this._attachEventListeners();
3018
- this._shadow.appendChild(this._container);
2996
+ _renderOptionsWithAnimation() {
2997
+ // Add fade-out animation
2998
+ this._optionsContainer.style.opacity = '0';
2999
+ this._optionsContainer.style.transition = 'opacity 0.15s ease-out';
3000
+ setTimeout(() => {
3001
+ this._renderOptions();
3002
+ // Fade back in
3003
+ this._optionsContainer.style.opacity = '1';
3004
+ this._optionsContainer.style.transition = 'opacity 0.2s ease-in';
3005
+ }, 150);
3019
3006
  }
3020
- _initializeStyles() {
3021
- const style = document.createElement('style');
3022
- style.textContent = `
3023
- :host {
3024
- display: block;
3025
- position: relative;
3026
- }
3027
-
3028
- .option-container {
3029
- display: flex;
3030
- align-items: center;
3031
- justify-content: space-between;
3032
- padding: 8px 12px;
3033
- cursor: pointer;
3034
- user-select: none;
3035
- transition: background-color 0.2s ease;
3036
- }
3037
-
3038
- .option-container:hover {
3039
- background-color: var(--select-option-hover-bg, #f0f0f0);
3040
- }
3041
-
3042
- .option-container.selected {
3043
- background-color: var(--select-option-selected-bg, #e3f2fd);
3044
- color: var(--select-option-selected-color, #1976d2);
3045
- }
3046
-
3047
- .option-container.active {
3048
- outline: 2px solid var(--select-option-active-outline, #1976d2);
3049
- outline-offset: -2px;
3050
- }
3051
-
3052
- .option-container.disabled {
3053
- opacity: 0.5;
3054
- cursor: not-allowed;
3055
- pointer-events: none;
3056
- }
3057
-
3058
- .option-content {
3059
- flex: 1;
3060
- overflow: hidden;
3061
- text-overflow: ellipsis;
3062
- white-space: nowrap;
3063
- }
3064
-
3065
- .remove-button {
3066
- margin-left: 8px;
3067
- padding: 2px 6px;
3068
- border: none;
3069
- background-color: var(--select-remove-btn-bg, transparent);
3070
- color: var(--select-remove-btn-color, #666);
3071
- cursor: pointer;
3072
- border-radius: 3px;
3073
- font-size: 16px;
3074
- line-height: 1;
3075
- transition: all 0.2s ease;
3076
- }
3077
-
3078
- .remove-button:hover {
3079
- background-color: var(--select-remove-btn-hover-bg, #ffebee);
3080
- color: var(--select-remove-btn-hover-color, #c62828);
3081
- }
3082
-
3083
- .remove-button:focus {
3084
- outline: 2px solid var(--select-remove-btn-focus-outline, #1976d2);
3085
- outline-offset: 2px;
3086
- }
3087
- `;
3088
- this._shadow.appendChild(style);
3007
+ _scrollToSelected() {
3008
+ if (this._state.selectedIndices.size === 0)
3009
+ return;
3010
+ const target = this._config.scrollToSelected.multiSelectTarget;
3011
+ const indices = Array.from(this._state.selectedIndices).sort((a, b) => a - b);
3012
+ // For multi-select, find the closest selected item to the current scroll position
3013
+ let targetIndex;
3014
+ if (this._config.selection.mode === 'multi' && indices.length > 1) {
3015
+ // Calculate which selected item is closest to the center of the viewport
3016
+ const dropdownRect = this._dropdown.getBoundingClientRect();
3017
+ const viewportCenter = this._dropdown.scrollTop + (dropdownRect.height / 2);
3018
+ // Find the selected item closest to viewport center
3019
+ let closestIndex = indices[0];
3020
+ let closestDistance = Infinity;
3021
+ for (const index of indices) {
3022
+ const option = this._getOptionElementByIndex(index);
3023
+ if (option) {
3024
+ const optionTop = option.offsetTop;
3025
+ const distance = Math.abs(optionTop - viewportCenter);
3026
+ if (distance < closestDistance) {
3027
+ closestDistance = distance;
3028
+ closestIndex = index;
3029
+ }
3030
+ }
3031
+ }
3032
+ targetIndex = closestIndex;
3033
+ }
3034
+ else {
3035
+ // For single select or only one selected item, use the configured target
3036
+ targetIndex = target === 'first' ? indices[0] : indices[indices.length - 1];
3037
+ }
3038
+ // Find and scroll to the target option
3039
+ const option = this._getOptionElementByIndex(targetIndex);
3040
+ if (option) {
3041
+ // Use smooth scrolling with center alignment for better UX
3042
+ option.scrollIntoView({
3043
+ block: this._config.scrollToSelected.block || 'center',
3044
+ behavior: 'smooth',
3045
+ });
3046
+ // Also set it as active for keyboard navigation
3047
+ this._setActive(targetIndex);
3048
+ }
3089
3049
  }
3090
- _render() {
3091
- const { item, index, selected, disabled, active, render, showRemoveButton } = this._config;
3092
- // Clear container
3093
- this._container.innerHTML = '';
3094
- // Apply state classes
3095
- this._container.classList.toggle('selected', selected);
3096
- this._container.classList.toggle('disabled', disabled || false);
3097
- this._container.classList.toggle('active', active || false);
3098
- // Custom class name
3099
- if (this._config.className) {
3100
- this._container.className += ' ' + this._config.className;
3050
+ async _loadMoreItems() {
3051
+ if (this._state.isBusy)
3052
+ return;
3053
+ this._setBusy(true);
3054
+ // Save scroll position before loading
3055
+ if (this._dropdown) {
3056
+ this._state.lastScrollPosition = this._dropdown.scrollTop;
3057
+ this._state.preserveScrollPosition = true;
3058
+ // Update dropdown to show loading indicator but keep the
3059
+ // same scrollTop so the visible items don't move.
3060
+ this._renderOptions();
3061
+ this._dropdown.scrollTop = this._state.lastScrollPosition;
3101
3062
  }
3102
- // Apply custom styles
3103
- if (this._config.style) {
3104
- Object.assign(this._container.style, this._config.style);
3063
+ try {
3064
+ // Emit event for parent to handle
3065
+ this._state.currentPage++;
3066
+ this._emit('loadMore', { page: this._state.currentPage, items: [] });
3067
+ this._config.callbacks.onLoadMore?.(this._state.currentPage);
3068
+ // NOTE: We do NOT set isBusy = false here.
3069
+ // The parent component MUST call setItems() or similar to clear the busy state.
3070
+ // This prevents the sentinel from reappearing before new items are loaded.
3105
3071
  }
3106
- // Render content
3107
- const contentDiv = document.createElement('div');
3108
- contentDiv.className = 'option-content';
3109
- if (render) {
3110
- const rendered = render(item, index);
3111
- if (typeof rendered === 'string') {
3112
- contentDiv.innerHTML = rendered;
3113
- }
3114
- else {
3115
- contentDiv.appendChild(rendered);
3116
- }
3072
+ catch (error) {
3073
+ this._handleError(error);
3074
+ this._setBusy(false); // Only clear on error
3117
3075
  }
3118
- else {
3119
- const label = this._getLabel();
3120
- contentDiv.textContent = label;
3076
+ }
3077
+ _setBusy(busy) {
3078
+ this._state.isBusy = busy;
3079
+ if (busy) {
3080
+ this._dropdown.setAttribute('aria-busy', 'true');
3121
3081
  }
3122
- this._container.appendChild(contentDiv);
3123
- // Add remove button if needed
3124
- if (showRemoveButton && selected) {
3125
- this._removeButton = document.createElement('button');
3126
- this._removeButton.className = 'remove-button';
3127
- this._removeButton.innerHTML = '×';
3128
- this._removeButton.setAttribute('aria-label', 'Remove option');
3129
- this._removeButton.setAttribute('type', 'button');
3130
- this._container.appendChild(this._removeButton);
3082
+ else {
3083
+ this._dropdown.removeAttribute('aria-busy');
3131
3084
  }
3132
- // Set ARIA attributes
3133
- this.setAttribute('role', 'option');
3134
- this.setAttribute('aria-selected', String(selected));
3135
- if (disabled)
3136
- this.setAttribute('aria-disabled', 'true');
3137
- this.id = `select-option-${index}`;
3085
+ // Trigger re-render to show/hide busy indicator
3086
+ // We use _renderOptions to handle the UI update
3087
+ this._renderOptions();
3138
3088
  }
3139
- _attachEventListeners() {
3140
- // Click handler for selection
3141
- this._container.addEventListener('click', (e) => {
3142
- // Don't trigger selection if clicking remove button
3143
- if (e.target === this._removeButton) {
3144
- return;
3145
- }
3146
- if (!this._config.disabled) {
3147
- this._handleSelect();
3148
- }
3149
- });
3150
- // Remove button handler
3151
- if (this._removeButton) {
3152
- this._removeButton.addEventListener('click', (e) => {
3153
- e.stopPropagation();
3154
- this._handleRemove();
3155
- });
3089
+ _handleError(error) {
3090
+ this._emit('error', { message: error.message, cause: error });
3091
+ this._config.callbacks.onError?.(error);
3092
+ }
3093
+ _emit(name, detail) {
3094
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
3095
+ }
3096
+ _emitChange() {
3097
+ const selectedItems = Array.from(this._state.selectedItems.values());
3098
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3099
+ const selectedValues = selectedItems.map(getValue);
3100
+ const selectedIndices = Array.from(this._state.selectedIndices);
3101
+ this._emit('change', { selectedItems, selectedValues, selectedIndices });
3102
+ this._config.callbacks.onChange?.(selectedItems, selectedValues);
3103
+ }
3104
+ // Public API
3105
+ get optionRenderer() {
3106
+ return this._optionRenderer;
3107
+ }
3108
+ set optionRenderer(renderer) {
3109
+ this._optionRenderer = renderer;
3110
+ this._renderOptions();
3111
+ }
3112
+ /**
3113
+ * Set items to display in the select
3114
+ */
3115
+ setItems(items) {
3116
+ const previousLength = this._state.loadedItems.length;
3117
+ this._state.loadedItems = items;
3118
+ // If grouped items exist, flatten them to items
3119
+ if (this._state.groupedItems.length > 0) {
3120
+ this._state.loadedItems = this._state.groupedItems.flatMap(group => group.options);
3156
3121
  }
3157
- // Keyboard handler
3158
- this.addEventListener('keydown', (e) => {
3159
- if (this._config.disabled)
3160
- return;
3161
- if (e.key === 'Enter' || e.key === ' ') {
3162
- e.preventDefault();
3163
- this._handleSelect();
3122
+ const newLength = this._state.loadedItems.length;
3123
+ // When infinite scroll is active (preserveScrollPosition = true),
3124
+ // we need to maintain scroll position during the update
3125
+ if (this._state.preserveScrollPosition && this._dropdown) {
3126
+ const targetScrollTop = this._state.lastScrollPosition;
3127
+ // Only clear loading if we actually got more items
3128
+ if (newLength > previousLength) {
3129
+ this._state.isBusy = false;
3164
3130
  }
3165
- else if (e.key === 'Delete' || e.key === 'Backspace') {
3166
- if (this._config.selected && this._config.showRemoveButton) {
3167
- e.preventDefault();
3168
- this._handleRemove();
3131
+ this._renderOptions();
3132
+ // Restore the exact scrollTop we had before loading
3133
+ // so the previously visible items stay in place and
3134
+ // new ones simply appear below.
3135
+ this._dropdown.scrollTop = targetScrollTop;
3136
+ // Ensure it sticks after layout
3137
+ requestAnimationFrame(() => {
3138
+ if (this._dropdown) {
3139
+ this._dropdown.scrollTop = targetScrollTop;
3169
3140
  }
3141
+ });
3142
+ // Only clear preserveScrollPosition if we got new items
3143
+ if (newLength > previousLength) {
3144
+ this._state.preserveScrollPosition = false;
3170
3145
  }
3171
- });
3146
+ }
3147
+ else {
3148
+ // Normal update - just render normally
3149
+ this._state.isBusy = false;
3150
+ this._renderOptions();
3151
+ }
3172
3152
  }
3173
- _handleSelect() {
3174
- const detail = {
3175
- item: this._config.item,
3176
- index: this._config.index,
3177
- value: this._getValue(),
3178
- label: this._getLabel(),
3179
- selected: !this._config.selected,
3180
- };
3181
- this.dispatchEvent(new CustomEvent('optionSelect', {
3182
- detail,
3183
- bubbles: true,
3184
- composed: true,
3185
- }));
3153
+ /**
3154
+ * Set grouped items
3155
+ */
3156
+ setGroupedItems(groupedItems) {
3157
+ this._state.groupedItems = groupedItems;
3158
+ this._state.loadedItems = groupedItems.flatMap(group => group.options);
3159
+ this._renderOptions();
3186
3160
  }
3187
- _handleRemove() {
3188
- const detail = {
3189
- item: this._config.item,
3190
- index: this._config.index,
3191
- value: this._getValue(),
3192
- label: this._getLabel(),
3193
- selected: false,
3194
- };
3195
- this.dispatchEvent(new CustomEvent('optionRemove', {
3196
- detail,
3197
- bubbles: true,
3198
- composed: true,
3199
- }));
3161
+ /**
3162
+ * Get currently selected items
3163
+ */
3164
+ getSelectedItems() {
3165
+ return Array.from(this._state.selectedItems.values());
3200
3166
  }
3201
- _getValue() {
3202
- if (this._config.getValue) {
3203
- return this._config.getValue(this._config.item);
3167
+ /**
3168
+ * Get all loaded items
3169
+ */
3170
+ get loadedItems() {
3171
+ return this._state.loadedItems;
3172
+ }
3173
+ /**
3174
+ * Get currently selected values
3175
+ */
3176
+ getSelectedValues() {
3177
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3178
+ return this.getSelectedItems().map(getValue);
3179
+ }
3180
+ /**
3181
+ * Set selected items by value
3182
+ */
3183
+ async setSelectedValues(values) {
3184
+ if (this._config.serverSide.enabled && this._config.serverSide.fetchSelectedItems) {
3185
+ await this._loadSelectedItemsByValues(values);
3186
+ }
3187
+ else {
3188
+ // Select from loaded items
3189
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3190
+ this._state.selectedIndices.clear();
3191
+ this._state.selectedItems.clear();
3192
+ this._state.loadedItems.forEach((item, index) => {
3193
+ if (values.includes(getValue(item))) {
3194
+ this._state.selectedIndices.add(index);
3195
+ this._state.selectedItems.set(index, item);
3196
+ }
3197
+ });
3198
+ this._renderOptions();
3199
+ this._updateInputDisplay();
3200
+ this._emitChange();
3204
3201
  }
3205
- return this._config.item?.value ?? this._config.item;
3206
3202
  }
3207
- _getLabel() {
3208
- if (this._config.getLabel) {
3209
- return this._config.getLabel(this._config.item);
3203
+ /**
3204
+ * Load and select items by their values (for infinite scroll scenario)
3205
+ */
3206
+ async _loadSelectedItemsByValues(values) {
3207
+ if (!this._config.serverSide.fetchSelectedItems)
3208
+ return;
3209
+ this._setBusy(true);
3210
+ try {
3211
+ const items = await this._config.serverSide.fetchSelectedItems(values);
3212
+ this._state.selectedIndices.clear();
3213
+ this._state.selectedItems.clear();
3214
+ items.forEach((item, index) => {
3215
+ this._state.selectedIndices.add(index);
3216
+ this._state.selectedItems.set(index, item);
3217
+ });
3218
+ this._renderOptions();
3219
+ this._updateInputDisplay();
3220
+ this._emitChange();
3221
+ // Scroll to selected if configured
3222
+ if (this._config.scrollToSelected.enabled) {
3223
+ this._scrollToSelected();
3224
+ }
3210
3225
  }
3211
- return this._config.item?.label ?? String(this._config.item);
3226
+ catch (error) {
3227
+ this._handleError(error);
3228
+ }
3229
+ finally {
3230
+ this._setBusy(false);
3231
+ }
3232
+ }
3233
+ /**
3234
+ * Clear all selections
3235
+ */
3236
+ clear() {
3237
+ this._state.selectedIndices.clear();
3238
+ this._state.selectedItems.clear();
3239
+ this._renderOptions();
3240
+ this._updateInputDisplay();
3241
+ this._emitChange();
3242
+ }
3243
+ /**
3244
+ * Open dropdown
3245
+ */
3246
+ open() {
3247
+ this._handleOpen();
3212
3248
  }
3213
3249
  /**
3214
- * Update option configuration and re-render
3250
+ * Close dropdown
3215
3251
  */
3216
- updateConfig(updates) {
3217
- this._config = { ...this._config, ...updates };
3218
- this._render();
3219
- this._attachEventListeners();
3252
+ close() {
3253
+ this._handleClose();
3220
3254
  }
3221
3255
  /**
3222
- * Get current configuration
3256
+ * Update component configuration
3223
3257
  */
3224
- getConfig() {
3225
- return this._config;
3258
+ updateConfig(config) {
3259
+ this._config = selectConfig.mergeWithComponentConfig(config);
3260
+ // Update input state based on new config
3261
+ if (this._input) {
3262
+ this._input.readOnly = !this._config.searchable;
3263
+ this._input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
3264
+ if (this._state.selectedIndices.size === 0) {
3265
+ this._input.placeholder = this._config.placeholder || 'Select an option...';
3266
+ }
3267
+ }
3268
+ if (this._dropdown) {
3269
+ if (this._config.selection.mode === 'multi') {
3270
+ this._dropdown.setAttribute('aria-multiselectable', 'true');
3271
+ }
3272
+ else {
3273
+ this._dropdown.removeAttribute('aria-multiselectable');
3274
+ }
3275
+ }
3276
+ // Re-initialize observers in case infinite scroll was enabled/disabled
3277
+ this._initializeObservers();
3278
+ this._renderOptions();
3226
3279
  }
3227
3280
  /**
3228
- * Get option value
3281
+ * Set error state
3229
3282
  */
3230
- getValue() {
3231
- return this._getValue();
3283
+ setError(message) {
3284
+ this._hasError = true;
3285
+ this._errorMessage = message;
3286
+ this._input.setAttribute('aria-invalid', 'true');
3287
+ this._announce(`Error: ${message}`);
3232
3288
  }
3233
3289
  /**
3234
- * Get option label
3290
+ * Clear error state
3235
3291
  */
3236
- getLabel() {
3237
- return this._getLabel();
3292
+ clearError() {
3293
+ this._hasError = false;
3294
+ this._errorMessage = '';
3295
+ this._input.removeAttribute('aria-invalid');
3238
3296
  }
3239
3297
  /**
3240
- * Set selected state
3298
+ * Set required state
3241
3299
  */
3242
- setSelected(selected) {
3243
- this._config.selected = selected;
3244
- this._render();
3300
+ setRequired(required) {
3301
+ if (required) {
3302
+ this._input.setAttribute('aria-required', 'true');
3303
+ this._input.setAttribute('required', '');
3304
+ }
3305
+ else {
3306
+ this._input.removeAttribute('aria-required');
3307
+ this._input.removeAttribute('required');
3308
+ }
3245
3309
  }
3246
3310
  /**
3247
- * Set active state
3311
+ * Validate selection (for required fields)
3248
3312
  */
3249
- setActive(active) {
3250
- this._config.active = active;
3251
- this._render();
3313
+ validate() {
3314
+ const isRequired = this._input.hasAttribute('required');
3315
+ if (isRequired && this._state.selectedIndices.size === 0) {
3316
+ this.setError('Selection is required');
3317
+ return false;
3318
+ }
3319
+ this.clearError();
3320
+ return true;
3252
3321
  }
3253
3322
  /**
3254
- * Set disabled state
3323
+ * Render options based on current state
3255
3324
  */
3256
- setDisabled(disabled) {
3257
- this._config.disabled = disabled;
3258
- this._render();
3325
+ _renderOptions() {
3326
+ // Cleanup observer
3327
+ if (this._loadMoreTrigger && this._intersectionObserver) {
3328
+ this._intersectionObserver.unobserve(this._loadMoreTrigger);
3329
+ }
3330
+ // Clear options container
3331
+ this._optionsContainer.innerHTML = '';
3332
+ // Ensure dropdown only contains options container (cleanup legacy direct children)
3333
+ // We need to preserve optionsContainer, so we can't just clear dropdown.innerHTML
3334
+ // But we can check if there are other children and remove them
3335
+ Array.from(this._dropdown.children).forEach(child => {
3336
+ if (child !== this._optionsContainer) {
3337
+ this._dropdown.removeChild(child);
3338
+ }
3339
+ });
3340
+ // Ensure dropdown is visible if we are rendering options
3341
+ if (this._state.isOpen && this._dropdown.style.display === 'none') {
3342
+ this._dropdown.style.display = 'block';
3343
+ }
3344
+ // Show searching state (exclusive state)
3345
+ if (this._state.isSearching) {
3346
+ const searching = document.createElement('div');
3347
+ searching.className = 'searching-state';
3348
+ searching.textContent = 'Searching...';
3349
+ this._optionsContainer.appendChild(searching);
3350
+ return;
3351
+ }
3352
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3353
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
3354
+ // Filter items by search query
3355
+ const query = this._state.searchQuery.toLowerCase();
3356
+ // Handle Grouped Items Rendering (when no search query)
3357
+ if (this._state.groupedItems.length > 0 && !query) {
3358
+ this._state.groupedItems.forEach(group => {
3359
+ const header = document.createElement('div');
3360
+ header.className = 'group-header';
3361
+ header.textContent = group.label;
3362
+ Object.assign(header.style, {
3363
+ padding: '8px 12px',
3364
+ fontWeight: '600',
3365
+ color: '#6b7280',
3366
+ backgroundColor: '#f3f4f6',
3367
+ fontSize: '12px',
3368
+ textTransform: 'uppercase',
3369
+ letterSpacing: '0.05em',
3370
+ position: 'sticky',
3371
+ top: '0',
3372
+ zIndex: '1',
3373
+ borderBottom: '1px solid #e5e7eb'
3374
+ });
3375
+ this._optionsContainer.appendChild(header);
3376
+ group.options.forEach(item => {
3377
+ // Find original index for correct ID generation and selection
3378
+ const index = this._state.loadedItems.indexOf(item);
3379
+ if (index !== -1) {
3380
+ this._renderSingleOption(item, index, getValue, getLabel);
3381
+ }
3382
+ });
3383
+ });
3384
+ }
3385
+ else {
3386
+ // Normal rendering (flat list or filtered)
3387
+ let hasRenderedItems = false;
3388
+ this._state.loadedItems.forEach((item, index) => {
3389
+ // Apply filter if query exists
3390
+ if (query) {
3391
+ try {
3392
+ const label = String(getLabel(item)).toLowerCase();
3393
+ if (!label.includes(query))
3394
+ return;
3395
+ }
3396
+ catch (e) {
3397
+ return;
3398
+ }
3399
+ }
3400
+ hasRenderedItems = true;
3401
+ this._renderSingleOption(item, index, getValue, getLabel);
3402
+ });
3403
+ if (!hasRenderedItems && !this._state.isBusy) {
3404
+ const empty = document.createElement('div');
3405
+ empty.className = 'empty-state';
3406
+ if (query) {
3407
+ empty.textContent = `No results found for "${this._state.searchQuery}"`;
3408
+ }
3409
+ else {
3410
+ empty.textContent = 'No options available';
3411
+ }
3412
+ this._optionsContainer.appendChild(empty);
3413
+ }
3414
+ }
3415
+ // Append Busy Indicator if busy
3416
+ if (this._state.isBusy && this._config.busyBucket.enabled) {
3417
+ const busyBucket = document.createElement('div');
3418
+ busyBucket.className = 'busy-bucket';
3419
+ if (this._config.busyBucket.showSpinner) {
3420
+ const spinner = document.createElement('div');
3421
+ spinner.className = 'spinner';
3422
+ busyBucket.appendChild(spinner);
3423
+ }
3424
+ if (this._config.busyBucket.message) {
3425
+ const message = document.createElement('div');
3426
+ message.textContent = this._config.busyBucket.message;
3427
+ busyBucket.appendChild(message);
3428
+ }
3429
+ this._optionsContainer.appendChild(busyBucket);
3430
+ }
3431
+ // Append Load More Trigger (Button or Sentinel) if enabled and not busy
3432
+ else if ((this._config.loadMore.enabled || this._config.infiniteScroll.enabled) && this._state.loadedItems.length > 0) {
3433
+ this._addLoadMoreTrigger();
3434
+ }
3435
+ this._finalizePerfMarks();
3436
+ }
3437
+ _renderSingleOption(item, index, getValue, getLabel) {
3438
+ const isSelected = this._state.selectedIndices.has(index);
3439
+ const isDisabled = Boolean(item?.disabled);
3440
+ const optionId = `${this._uniqueId}-option-${index}`;
3441
+ if (this._optionRenderer) {
3442
+ const rendered = this._optionRenderer(item, index, this._rendererHelpers);
3443
+ const optionElement = this._normalizeCustomOptionElement(rendered, {
3444
+ index,
3445
+ value: getValue(item),
3446
+ label: getLabel(item),
3447
+ selected: isSelected,
3448
+ active: this._state.activeIndex === index,
3449
+ disabled: isDisabled,
3450
+ id: optionId,
3451
+ });
3452
+ this._optionsContainer.appendChild(optionElement);
3453
+ return;
3454
+ }
3455
+ const option = new SelectOption({
3456
+ item,
3457
+ index,
3458
+ id: optionId,
3459
+ selected: isSelected,
3460
+ disabled: isDisabled,
3461
+ active: this._state.activeIndex === index,
3462
+ getValue,
3463
+ getLabel,
3464
+ showRemoveButton: this._config.selection.mode === 'multi' && this._config.selection.showRemoveButton,
3465
+ });
3466
+ option.dataset.index = String(index);
3467
+ option.dataset.value = String(getValue(item));
3468
+ option.id = option.id || optionId;
3469
+ option.addEventListener('click', (e) => {
3470
+ const mouseEvent = e;
3471
+ this._selectOption(index, {
3472
+ shiftKey: mouseEvent.shiftKey,
3473
+ toggleKey: mouseEvent.ctrlKey || mouseEvent.metaKey,
3474
+ });
3475
+ });
3476
+ option.addEventListener('optionRemove', (event) => {
3477
+ const detail = event.detail;
3478
+ const targetIndex = detail?.index ?? index;
3479
+ this._handleOptionRemove(targetIndex);
3480
+ });
3481
+ this._optionsContainer.appendChild(option);
3482
+ }
3483
+ _normalizeCustomOptionElement(element, meta) {
3484
+ const optionEl = element instanceof HTMLElement ? element : document.createElement('div');
3485
+ optionEl.classList.add('smilodon-option');
3486
+ optionEl.classList.toggle('smilodon-option--selected', meta.selected);
3487
+ optionEl.classList.toggle('smilodon-option--active', meta.active);
3488
+ optionEl.classList.toggle('smilodon-option--disabled', meta.disabled);
3489
+ if (!optionEl.hasAttribute('data-selectable')) {
3490
+ optionEl.setAttribute('data-selectable', '');
3491
+ }
3492
+ optionEl.dataset.index = String(meta.index);
3493
+ optionEl.dataset.value = String(meta.value);
3494
+ optionEl.id = optionEl.id || meta.id;
3495
+ if (!optionEl.getAttribute('role')) {
3496
+ optionEl.setAttribute('role', 'option');
3497
+ }
3498
+ if (!optionEl.getAttribute('aria-label')) {
3499
+ optionEl.setAttribute('aria-label', meta.label);
3500
+ }
3501
+ optionEl.setAttribute('aria-selected', String(meta.selected));
3502
+ if (meta.disabled) {
3503
+ optionEl.setAttribute('aria-disabled', 'true');
3504
+ }
3505
+ else {
3506
+ optionEl.removeAttribute('aria-disabled');
3507
+ }
3508
+ if (!optionEl.hasAttribute('tabindex')) {
3509
+ optionEl.tabIndex = -1;
3510
+ }
3511
+ if (!meta.disabled) {
3512
+ optionEl.addEventListener('click', (e) => {
3513
+ const mouseEvent = e;
3514
+ this._selectOption(meta.index, {
3515
+ shiftKey: mouseEvent.shiftKey,
3516
+ toggleKey: mouseEvent.ctrlKey || mouseEvent.metaKey,
3517
+ });
3518
+ });
3519
+ optionEl.addEventListener('keydown', (e) => {
3520
+ if (e.key === 'Enter' || e.key === ' ') {
3521
+ e.preventDefault();
3522
+ this._selectOption(meta.index, {
3523
+ shiftKey: e.shiftKey,
3524
+ toggleKey: e.ctrlKey || e.metaKey,
3525
+ });
3526
+ }
3527
+ });
3528
+ }
3529
+ return optionEl;
3530
+ }
3531
+ _addLoadMoreTrigger() {
3532
+ const container = document.createElement('div');
3533
+ container.className = 'load-more-container';
3534
+ if (this._config.infiniteScroll.enabled) {
3535
+ // Infinite Scroll: Render an invisible sentinel
3536
+ // It must have some height to be intersected
3537
+ const sentinel = document.createElement('div');
3538
+ sentinel.className = 'infinite-scroll-sentinel';
3539
+ sentinel.style.height = '10px';
3540
+ sentinel.style.width = '100%';
3541
+ sentinel.style.opacity = '0'; // Invisible
3542
+ this._loadMoreTrigger = sentinel;
3543
+ container.appendChild(sentinel);
3544
+ }
3545
+ else {
3546
+ // Manual Load More: Render a button
3547
+ const button = document.createElement('button');
3548
+ button.className = 'load-more-button';
3549
+ button.textContent = `Load ${this._config.loadMore.itemsPerLoad} more`;
3550
+ button.addEventListener('click', () => this._loadMoreItems());
3551
+ this._loadMoreTrigger = button;
3552
+ container.appendChild(button);
3553
+ }
3554
+ this._optionsContainer.appendChild(container);
3555
+ // Setup intersection observer for auto-load
3556
+ if (this._intersectionObserver && this._loadMoreTrigger) {
3557
+ this._intersectionObserver.observe(this._loadMoreTrigger);
3558
+ }
3259
3559
  }
3260
3560
  }
3261
3561
  // Register custom element
3262
- if (!customElements.get('select-option')) {
3263
- customElements.define('select-option', SelectOption);
3562
+ if (!customElements.get('enhanced-select')) {
3563
+ customElements.define('enhanced-select', EnhancedSelect);
3264
3564
  }
3265
3565
 
3266
3566
  /**