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