@smilodon/core 1.3.9 → 1.3.11

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