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