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