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