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