@smilodon/core 1.3.6 → 1.3.10

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