@smilodon/core 1.1.9 → 1.2.1
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 +14 -5
- package/dist/index.cjs +540 -940
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +539 -940
- 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 +540 -940
- 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 +2 -0
- package/dist/types/src/components/native-select.d.ts +19 -0
- package/dist/types/src/index.d.ts +3 -1
- package/dist/types/src/types/custom-option.d.ts +135 -0
- package/dist/types/src/utils/custom-option-pool.d.ts +78 -0
- package/dist/types/src/utils/option-renderer.d.ts +76 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -611,6 +611,63 @@ class NativeSelectElement extends HTMLElement {
|
|
|
611
611
|
this._options.optionRenderer = renderer;
|
|
612
612
|
this.render();
|
|
613
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Public API: setItems() method for compatibility with EnhancedSelect
|
|
616
|
+
* Accepts an array of items which can be:
|
|
617
|
+
* - Simple primitives (string, number)
|
|
618
|
+
* - Objects with {label, value} structure
|
|
619
|
+
* - Objects with {label, value, optionComponent} for custom rendering (v1.2.0+)
|
|
620
|
+
*/
|
|
621
|
+
setItems(items) {
|
|
622
|
+
this.items = items ?? [];
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Public API: setValue() method to programmatically select an item
|
|
626
|
+
* For single select: clears selection and selects the item matching the value
|
|
627
|
+
* For multi-select: adds to selection if not already selected
|
|
628
|
+
*/
|
|
629
|
+
setValue(value) {
|
|
630
|
+
if (value === null || value === undefined || value === '') {
|
|
631
|
+
// Clear selection
|
|
632
|
+
this._selectedSet.clear();
|
|
633
|
+
this._selectedItems.clear();
|
|
634
|
+
this._activeIndex = -1;
|
|
635
|
+
this.render();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
// Find item by value
|
|
639
|
+
const index = this._items.findIndex(item => {
|
|
640
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
641
|
+
return item.value === value;
|
|
642
|
+
}
|
|
643
|
+
return item === value;
|
|
644
|
+
});
|
|
645
|
+
if (index >= 0) {
|
|
646
|
+
const item = this._items[index];
|
|
647
|
+
if (!this._multi) {
|
|
648
|
+
// Single select: clear and set
|
|
649
|
+
this._selectedSet.clear();
|
|
650
|
+
this._selectedItems.clear();
|
|
651
|
+
}
|
|
652
|
+
this._selectedSet.add(index);
|
|
653
|
+
this._selectedItems.set(index, item);
|
|
654
|
+
this._activeIndex = index;
|
|
655
|
+
this.render();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Public API: getValue() method to get currently selected value(s)
|
|
660
|
+
* Returns single value for single-select, array for multi-select
|
|
661
|
+
*/
|
|
662
|
+
getValue() {
|
|
663
|
+
const values = Array.from(this._selectedItems.values()).map(item => {
|
|
664
|
+
if (typeof item === 'object' && item !== null && 'value' in item) {
|
|
665
|
+
return item.value;
|
|
666
|
+
}
|
|
667
|
+
return item;
|
|
668
|
+
});
|
|
669
|
+
return this._multi ? values : (values[0] ?? null);
|
|
670
|
+
}
|
|
614
671
|
render() {
|
|
615
672
|
const { optionTemplate, optionRenderer } = this._options;
|
|
616
673
|
const viewportHeight = this.getBoundingClientRect().height || 300;
|
|
@@ -638,7 +695,11 @@ class NativeSelectElement extends HTMLElement {
|
|
|
638
695
|
node.replaceChildren(el ?? document.createTextNode(String(item)));
|
|
639
696
|
}
|
|
640
697
|
else {
|
|
641
|
-
|
|
698
|
+
// Handle {label, value} objects or primitives
|
|
699
|
+
const displayText = (typeof item === 'object' && item !== null && 'label' in item)
|
|
700
|
+
? String(item.label)
|
|
701
|
+
: String(item);
|
|
702
|
+
node.textContent = displayText;
|
|
642
703
|
}
|
|
643
704
|
});
|
|
644
705
|
return;
|
|
@@ -664,7 +725,11 @@ class NativeSelectElement extends HTMLElement {
|
|
|
664
725
|
}
|
|
665
726
|
else {
|
|
666
727
|
const el = document.createElement('div');
|
|
667
|
-
|
|
728
|
+
// Handle {label, value} objects or primitives
|
|
729
|
+
const displayText = (typeof item === 'object' && item !== null && 'label' in item)
|
|
730
|
+
? String(item.label)
|
|
731
|
+
: String(item);
|
|
732
|
+
el.textContent = displayText;
|
|
668
733
|
el.setAttribute('data-selectable', '');
|
|
669
734
|
el.setAttribute('data-index', String(i));
|
|
670
735
|
this._applyOptionAttrs(el, i);
|
|
@@ -716,15 +781,23 @@ class NativeSelectElement extends HTMLElement {
|
|
|
716
781
|
this.render();
|
|
717
782
|
// Emit with all required fields
|
|
718
783
|
const selected = this._selectedSet.has(index);
|
|
784
|
+
const value = item?.value ?? item;
|
|
785
|
+
const label = item?.label ?? String(item);
|
|
719
786
|
this._emit('select', {
|
|
720
787
|
item,
|
|
721
788
|
index,
|
|
722
|
-
value
|
|
723
|
-
label
|
|
789
|
+
value,
|
|
790
|
+
label,
|
|
724
791
|
selected,
|
|
725
792
|
multi: this._multi
|
|
726
793
|
});
|
|
727
|
-
|
|
794
|
+
// Emit 'change' event for better React compatibility
|
|
795
|
+
this._emit('change', {
|
|
796
|
+
selectedItems: Array.from(this._selectedItems.values()),
|
|
797
|
+
selectedValues: Array.from(this._selectedItems.values()).map(i => i?.value ?? i),
|
|
798
|
+
selectedIndices: Array.from(this._selectedSet)
|
|
799
|
+
});
|
|
800
|
+
this._announce(`Selected ${label}`);
|
|
728
801
|
}
|
|
729
802
|
_onKeydown(e) {
|
|
730
803
|
switch (e.key) {
|
|
@@ -948,6 +1021,404 @@ function resetSelectConfig() {
|
|
|
948
1021
|
selectConfig.resetConfig();
|
|
949
1022
|
}
|
|
950
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Custom Option Component Pool
|
|
1026
|
+
*
|
|
1027
|
+
* Manages lifecycle and recycling of custom option components for optimal performance.
|
|
1028
|
+
* Uses object pooling pattern to minimize allocation/deallocation overhead.
|
|
1029
|
+
*/
|
|
1030
|
+
/**
|
|
1031
|
+
* Manages a pool of reusable custom option component instances
|
|
1032
|
+
*/
|
|
1033
|
+
class CustomOptionPool {
|
|
1034
|
+
constructor(maxPoolSize = 50) {
|
|
1035
|
+
this._pool = new Map();
|
|
1036
|
+
this._activeComponents = new Map();
|
|
1037
|
+
this._maxPoolSize = maxPoolSize;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Get or create a component instance for the given index
|
|
1041
|
+
*
|
|
1042
|
+
* @param factory - Factory function to create new instances
|
|
1043
|
+
* @param item - The data item
|
|
1044
|
+
* @param index - The option index
|
|
1045
|
+
* @param context - Context for mounting
|
|
1046
|
+
* @param container - DOM container for mounting
|
|
1047
|
+
* @returns Component instance
|
|
1048
|
+
*/
|
|
1049
|
+
acquire(factory, item, index, context, container) {
|
|
1050
|
+
const factoryKey = this._getFactoryKey(factory);
|
|
1051
|
+
// Try to find an available component in the pool
|
|
1052
|
+
const pooled = this._findAvailableComponent(factoryKey);
|
|
1053
|
+
let component;
|
|
1054
|
+
if (pooled) {
|
|
1055
|
+
// Reuse pooled component
|
|
1056
|
+
component = pooled.instance;
|
|
1057
|
+
pooled.inUse = true;
|
|
1058
|
+
pooled.lastUsedIndex = index;
|
|
1059
|
+
console.log(`[CustomOptionPool] Reusing component for index ${index}`);
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
// Create new component
|
|
1063
|
+
try {
|
|
1064
|
+
component = factory(item, index);
|
|
1065
|
+
console.log(`[CustomOptionPool] Created new component for index ${index}`);
|
|
1066
|
+
// Add to pool if under limit
|
|
1067
|
+
const pool = this._pool.get(factoryKey) || [];
|
|
1068
|
+
if (pool.length < this._maxPoolSize) {
|
|
1069
|
+
pool.push({
|
|
1070
|
+
instance: component,
|
|
1071
|
+
inUse: true,
|
|
1072
|
+
lastUsedIndex: index
|
|
1073
|
+
});
|
|
1074
|
+
this._pool.set(factoryKey, pool);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
console.error(`[CustomOptionPool] Failed to create component:`, error);
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// Mount the component
|
|
1083
|
+
try {
|
|
1084
|
+
component.mountOption(container, context);
|
|
1085
|
+
this._activeComponents.set(index, component);
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
console.error(`[CustomOptionPool] Failed to mount component at index ${index}:`, error);
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
return component;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Release a component back to the pool
|
|
1095
|
+
*
|
|
1096
|
+
* @param index - The index of the component to release
|
|
1097
|
+
*/
|
|
1098
|
+
release(index) {
|
|
1099
|
+
const component = this._activeComponents.get(index);
|
|
1100
|
+
if (!component)
|
|
1101
|
+
return;
|
|
1102
|
+
try {
|
|
1103
|
+
component.unmountOption();
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
console.error(`[CustomOptionPool] Failed to unmount component at index ${index}:`, error);
|
|
1107
|
+
}
|
|
1108
|
+
this._activeComponents.delete(index);
|
|
1109
|
+
// Mark as available in pool
|
|
1110
|
+
for (const pool of this._pool.values()) {
|
|
1111
|
+
const pooled = pool.find(p => p.instance === component);
|
|
1112
|
+
if (pooled) {
|
|
1113
|
+
pooled.inUse = false;
|
|
1114
|
+
console.log(`[CustomOptionPool] Released component from index ${index}`);
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Release all active components
|
|
1121
|
+
*/
|
|
1122
|
+
releaseAll() {
|
|
1123
|
+
console.log(`[CustomOptionPool] Releasing ${this._activeComponents.size} active components`);
|
|
1124
|
+
const indices = Array.from(this._activeComponents.keys());
|
|
1125
|
+
indices.forEach(index => this.release(index));
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Update selection state for a component
|
|
1129
|
+
*
|
|
1130
|
+
* @param index - The index of the component
|
|
1131
|
+
* @param selected - Whether it's selected
|
|
1132
|
+
*/
|
|
1133
|
+
updateSelection(index, selected) {
|
|
1134
|
+
const component = this._activeComponents.get(index);
|
|
1135
|
+
if (component) {
|
|
1136
|
+
component.updateSelected(selected);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Update focused state for a component
|
|
1141
|
+
*
|
|
1142
|
+
* @param index - The index of the component
|
|
1143
|
+
* @param focused - Whether it has keyboard focus
|
|
1144
|
+
*/
|
|
1145
|
+
updateFocused(index, focused) {
|
|
1146
|
+
const component = this._activeComponents.get(index);
|
|
1147
|
+
if (component && component.updateFocused) {
|
|
1148
|
+
component.updateFocused(focused);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Get active component at index
|
|
1153
|
+
*
|
|
1154
|
+
* @param index - The option index
|
|
1155
|
+
* @returns The component instance or undefined
|
|
1156
|
+
*/
|
|
1157
|
+
getComponent(index) {
|
|
1158
|
+
return this._activeComponents.get(index);
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Clear the entire pool
|
|
1162
|
+
*/
|
|
1163
|
+
clear() {
|
|
1164
|
+
this.releaseAll();
|
|
1165
|
+
this._pool.clear();
|
|
1166
|
+
console.log('[CustomOptionPool] Pool cleared');
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Get pool statistics for debugging
|
|
1170
|
+
*/
|
|
1171
|
+
getStats() {
|
|
1172
|
+
let totalPooled = 0;
|
|
1173
|
+
let availableComponents = 0;
|
|
1174
|
+
for (const pool of this._pool.values()) {
|
|
1175
|
+
totalPooled += pool.length;
|
|
1176
|
+
availableComponents += pool.filter(p => !p.inUse).length;
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
totalPooled,
|
|
1180
|
+
activeComponents: this._activeComponents.size,
|
|
1181
|
+
availableComponents
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Find an available component in the pool
|
|
1186
|
+
*/
|
|
1187
|
+
_findAvailableComponent(factoryKey) {
|
|
1188
|
+
const pool = this._pool.get(factoryKey);
|
|
1189
|
+
if (!pool)
|
|
1190
|
+
return undefined;
|
|
1191
|
+
return pool.find(p => !p.inUse);
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Generate a unique key for a factory function
|
|
1195
|
+
*/
|
|
1196
|
+
_getFactoryKey(factory) {
|
|
1197
|
+
// Use function name or create a symbol
|
|
1198
|
+
return factory.name || `factory_${factory.toString().slice(0, 50)}`;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Option Renderer
|
|
1204
|
+
*
|
|
1205
|
+
* Unified renderer that handles both lightweight (label/value) and
|
|
1206
|
+
* custom component rendering with consistent performance characteristics.
|
|
1207
|
+
*/
|
|
1208
|
+
/**
|
|
1209
|
+
* Manages rendering of both lightweight and custom component options
|
|
1210
|
+
*/
|
|
1211
|
+
class OptionRenderer {
|
|
1212
|
+
constructor(config) {
|
|
1213
|
+
this._mountedElements = new Map();
|
|
1214
|
+
this._config = config;
|
|
1215
|
+
this._pool = new CustomOptionPool(config.maxPoolSize);
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Render an option (lightweight or custom component)
|
|
1219
|
+
*
|
|
1220
|
+
* @param item - The data item
|
|
1221
|
+
* @param index - The option index
|
|
1222
|
+
* @param isSelected - Whether the option is selected
|
|
1223
|
+
* @param isFocused - Whether the option has keyboard focus
|
|
1224
|
+
* @param uniqueId - Unique ID for the select instance
|
|
1225
|
+
* @returns The rendered DOM element
|
|
1226
|
+
*/
|
|
1227
|
+
render(item, index, isSelected, isFocused, uniqueId) {
|
|
1228
|
+
const extendedItem = item;
|
|
1229
|
+
const value = this._config.getValue(item);
|
|
1230
|
+
const label = this._config.getLabel(item);
|
|
1231
|
+
const isDisabled = this._config.getDisabled ? this._config.getDisabled(item) : false;
|
|
1232
|
+
// Determine if this is a custom component or lightweight option
|
|
1233
|
+
const hasCustomComponent = extendedItem.optionComponent && typeof extendedItem.optionComponent === 'function';
|
|
1234
|
+
if (hasCustomComponent) {
|
|
1235
|
+
return this._renderCustomComponent(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId, extendedItem.optionComponent);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
return this._renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Update selection state for an option
|
|
1243
|
+
*
|
|
1244
|
+
* @param index - The option index
|
|
1245
|
+
* @param selected - Whether it's selected
|
|
1246
|
+
*/
|
|
1247
|
+
updateSelection(index, selected) {
|
|
1248
|
+
const element = this._mountedElements.get(index);
|
|
1249
|
+
if (!element)
|
|
1250
|
+
return;
|
|
1251
|
+
// Check if this is a custom component
|
|
1252
|
+
const component = this._pool.getComponent(index);
|
|
1253
|
+
if (component) {
|
|
1254
|
+
component.updateSelected(selected);
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
// Update lightweight option
|
|
1258
|
+
if (selected) {
|
|
1259
|
+
element.classList.add('selected');
|
|
1260
|
+
element.setAttribute('aria-selected', 'true');
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
element.classList.remove('selected');
|
|
1264
|
+
element.setAttribute('aria-selected', 'false');
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Update focused state for an option
|
|
1270
|
+
*
|
|
1271
|
+
* @param index - The option index
|
|
1272
|
+
* @param focused - Whether it has keyboard focus
|
|
1273
|
+
*/
|
|
1274
|
+
updateFocused(index, focused) {
|
|
1275
|
+
const element = this._mountedElements.get(index);
|
|
1276
|
+
if (!element)
|
|
1277
|
+
return;
|
|
1278
|
+
// Check if this is a custom component
|
|
1279
|
+
const component = this._pool.getComponent(index);
|
|
1280
|
+
if (component) {
|
|
1281
|
+
if (component.updateFocused) {
|
|
1282
|
+
component.updateFocused(focused);
|
|
1283
|
+
}
|
|
1284
|
+
// Also update the element's focused class for styling
|
|
1285
|
+
element.classList.toggle('focused', focused);
|
|
1286
|
+
}
|
|
1287
|
+
else {
|
|
1288
|
+
// Update lightweight option
|
|
1289
|
+
element.classList.toggle('focused', focused);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Unmount and cleanup an option
|
|
1294
|
+
*
|
|
1295
|
+
* @param index - The option index
|
|
1296
|
+
*/
|
|
1297
|
+
unmount(index) {
|
|
1298
|
+
// Release custom component if exists
|
|
1299
|
+
this._pool.release(index);
|
|
1300
|
+
// Remove from mounted elements
|
|
1301
|
+
this._mountedElements.delete(index);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Unmount all options
|
|
1305
|
+
*/
|
|
1306
|
+
unmountAll() {
|
|
1307
|
+
this._pool.releaseAll();
|
|
1308
|
+
this._mountedElements.clear();
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Get pool statistics
|
|
1312
|
+
*/
|
|
1313
|
+
getStats() {
|
|
1314
|
+
return this._pool.getStats();
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Render a lightweight option (traditional label/value)
|
|
1318
|
+
*/
|
|
1319
|
+
_renderLightweightOption(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId) {
|
|
1320
|
+
const option = document.createElement('div');
|
|
1321
|
+
option.className = 'option';
|
|
1322
|
+
if (isSelected)
|
|
1323
|
+
option.classList.add('selected');
|
|
1324
|
+
if (isFocused)
|
|
1325
|
+
option.classList.add('focused');
|
|
1326
|
+
if (isDisabled)
|
|
1327
|
+
option.classList.add('disabled');
|
|
1328
|
+
option.id = `${uniqueId}-option-${index}`;
|
|
1329
|
+
option.textContent = label;
|
|
1330
|
+
option.dataset.value = String(value);
|
|
1331
|
+
option.dataset.index = String(index);
|
|
1332
|
+
option.dataset.mode = 'lightweight';
|
|
1333
|
+
option.setAttribute('role', 'option');
|
|
1334
|
+
option.setAttribute('aria-selected', String(isSelected));
|
|
1335
|
+
if (isDisabled) {
|
|
1336
|
+
option.setAttribute('aria-disabled', 'true');
|
|
1337
|
+
}
|
|
1338
|
+
// Click handler
|
|
1339
|
+
if (!isDisabled) {
|
|
1340
|
+
option.addEventListener('click', () => {
|
|
1341
|
+
this._config.onSelect(index);
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
this._mountedElements.set(index, option);
|
|
1345
|
+
console.log(`[OptionRenderer] Rendered lightweight option ${index}: ${label}`);
|
|
1346
|
+
return option;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Render a custom component option
|
|
1350
|
+
*/
|
|
1351
|
+
_renderCustomComponent(item, index, value, label, isSelected, isFocused, isDisabled, uniqueId, factory) {
|
|
1352
|
+
// Create wrapper container for the custom component
|
|
1353
|
+
const wrapper = document.createElement('div');
|
|
1354
|
+
wrapper.className = 'option option-custom';
|
|
1355
|
+
if (isSelected)
|
|
1356
|
+
wrapper.classList.add('selected');
|
|
1357
|
+
if (isFocused)
|
|
1358
|
+
wrapper.classList.add('focused');
|
|
1359
|
+
if (isDisabled)
|
|
1360
|
+
wrapper.classList.add('disabled');
|
|
1361
|
+
wrapper.id = `${uniqueId}-option-${index}`;
|
|
1362
|
+
wrapper.dataset.value = String(value);
|
|
1363
|
+
wrapper.dataset.index = String(index);
|
|
1364
|
+
wrapper.dataset.mode = 'component';
|
|
1365
|
+
wrapper.setAttribute('role', 'option');
|
|
1366
|
+
wrapper.setAttribute('aria-selected', String(isSelected));
|
|
1367
|
+
wrapper.setAttribute('aria-label', label); // Accessibility fallback
|
|
1368
|
+
if (isDisabled) {
|
|
1369
|
+
wrapper.setAttribute('aria-disabled', 'true');
|
|
1370
|
+
}
|
|
1371
|
+
// Create context for the custom component
|
|
1372
|
+
const context = {
|
|
1373
|
+
item,
|
|
1374
|
+
index,
|
|
1375
|
+
value,
|
|
1376
|
+
label,
|
|
1377
|
+
isSelected,
|
|
1378
|
+
isFocused,
|
|
1379
|
+
isDisabled,
|
|
1380
|
+
onSelect: (idx) => {
|
|
1381
|
+
if (!isDisabled) {
|
|
1382
|
+
this._config.onSelect(idx);
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
onCustomEvent: (eventName, data) => {
|
|
1386
|
+
if (this._config.onCustomEvent) {
|
|
1387
|
+
this._config.onCustomEvent(index, eventName, data);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
// Acquire component from pool and mount it
|
|
1392
|
+
try {
|
|
1393
|
+
const component = this._pool.acquire(factory, item, index, context, wrapper);
|
|
1394
|
+
// Get the component's root element and attach click handler to wrapper
|
|
1395
|
+
const componentElement = component.getElement();
|
|
1396
|
+
if (!isDisabled) {
|
|
1397
|
+
// Use event delegation on the wrapper
|
|
1398
|
+
wrapper.addEventListener('click', (e) => {
|
|
1399
|
+
// Only trigger if clicking within the component
|
|
1400
|
+
if (wrapper.contains(e.target)) {
|
|
1401
|
+
this._config.onSelect(index);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
console.log(`[OptionRenderer] Rendered custom component option ${index}: ${label}`);
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
console.error(`[OptionRenderer] Failed to render custom component at index ${index}:`, error);
|
|
1409
|
+
// Fallback to lightweight rendering on error
|
|
1410
|
+
wrapper.innerHTML = '';
|
|
1411
|
+
wrapper.textContent = label;
|
|
1412
|
+
wrapper.classList.add('component-error');
|
|
1413
|
+
if (this._config.onError) {
|
|
1414
|
+
this._config.onError(index, error);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
this._mountedElements.set(index, wrapper);
|
|
1418
|
+
return wrapper;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
951
1422
|
/**
|
|
952
1423
|
* Enhanced Select Component
|
|
953
1424
|
* Implements all advanced features: infinite scroll, load more, busy state,
|
|
@@ -1006,6 +1477,9 @@ class EnhancedSelect extends HTMLElement {
|
|
|
1006
1477
|
// Initialize styles BEFORE assembling DOM (order matters in shadow DOM)
|
|
1007
1478
|
this._initializeStyles();
|
|
1008
1479
|
console.log('[EnhancedSelect] Styles initialized');
|
|
1480
|
+
// Initialize option renderer
|
|
1481
|
+
this._initializeOptionRenderer();
|
|
1482
|
+
console.log('[EnhancedSelect] Option renderer initialized');
|
|
1009
1483
|
this._assembleDOM();
|
|
1010
1484
|
console.log('[EnhancedSelect] DOM assembled');
|
|
1011
1485
|
this._attachEventListeners();
|
|
@@ -1042,6 +1516,11 @@ class EnhancedSelect extends HTMLElement {
|
|
|
1042
1516
|
clearTimeout(this._typeTimeout);
|
|
1043
1517
|
if (this._searchTimeout)
|
|
1044
1518
|
clearTimeout(this._searchTimeout);
|
|
1519
|
+
// Cleanup option renderer
|
|
1520
|
+
if (this._optionRenderer) {
|
|
1521
|
+
this._optionRenderer.unmountAll();
|
|
1522
|
+
console.log('[EnhancedSelect] Option renderer cleaned up');
|
|
1523
|
+
}
|
|
1045
1524
|
// Cleanup arrow click listener
|
|
1046
1525
|
if (this._boundArrowClick && this._arrowContainer) {
|
|
1047
1526
|
this._arrowContainer.removeEventListener('click', this._boundArrowClick);
|
|
@@ -1581,6 +2060,40 @@ class EnhancedSelect extends HTMLElement {
|
|
|
1581
2060
|
}, { threshold: 0.1 });
|
|
1582
2061
|
}
|
|
1583
2062
|
}
|
|
2063
|
+
_initializeOptionRenderer() {
|
|
2064
|
+
const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
|
|
2065
|
+
const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
|
|
2066
|
+
const getDisabled = (item) => item?.disabled ?? false;
|
|
2067
|
+
const rendererConfig = {
|
|
2068
|
+
enableRecycling: true,
|
|
2069
|
+
maxPoolSize: 100,
|
|
2070
|
+
getValue,
|
|
2071
|
+
getLabel,
|
|
2072
|
+
getDisabled,
|
|
2073
|
+
onSelect: (index) => {
|
|
2074
|
+
this._selectOption(index);
|
|
2075
|
+
},
|
|
2076
|
+
onCustomEvent: (index, eventName, data) => {
|
|
2077
|
+
console.log(`[EnhancedSelect] Custom event from option ${index}: ${eventName}`, data);
|
|
2078
|
+
// Emit as a generic event since these aren't in the standard event map
|
|
2079
|
+
this.dispatchEvent(new CustomEvent('option:custom-event', {
|
|
2080
|
+
detail: { index, eventName, data },
|
|
2081
|
+
bubbles: true,
|
|
2082
|
+
composed: true
|
|
2083
|
+
}));
|
|
2084
|
+
},
|
|
2085
|
+
onError: (index, error) => {
|
|
2086
|
+
console.error(`[EnhancedSelect] Error in option ${index}:`, error);
|
|
2087
|
+
this.dispatchEvent(new CustomEvent('option:mount-error', {
|
|
2088
|
+
detail: { index, error },
|
|
2089
|
+
bubbles: true,
|
|
2090
|
+
composed: true
|
|
2091
|
+
}));
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
this._optionRenderer = new OptionRenderer(rendererConfig);
|
|
2095
|
+
console.log('[EnhancedSelect] Option renderer initialized with config:', rendererConfig);
|
|
2096
|
+
}
|
|
1584
2097
|
async _loadInitialSelectedItems() {
|
|
1585
2098
|
if (!this._config.serverSide.fetchSelectedItems || !this._config.serverSide.initialSelectedValues) {
|
|
1586
2099
|
return;
|
|
@@ -2301,6 +2814,11 @@ class EnhancedSelect extends HTMLElement {
|
|
|
2301
2814
|
if (this._loadMoreTrigger && this._intersectionObserver) {
|
|
2302
2815
|
this._intersectionObserver.unobserve(this._loadMoreTrigger);
|
|
2303
2816
|
}
|
|
2817
|
+
// Cleanup all rendered options (including custom components)
|
|
2818
|
+
if (this._optionRenderer) {
|
|
2819
|
+
this._optionRenderer.unmountAll();
|
|
2820
|
+
console.log('[EnhancedSelect] Unmounted all option components');
|
|
2821
|
+
}
|
|
2304
2822
|
// Clear options container
|
|
2305
2823
|
console.log('[EnhancedSelect] Clearing options container, previous children:', this._optionsContainer.children.length);
|
|
2306
2824
|
this._optionsContainer.innerHTML = '';
|
|
@@ -2417,28 +2935,23 @@ class EnhancedSelect extends HTMLElement {
|
|
|
2417
2935
|
console.log('[EnhancedSelect] _renderOptions complete, optionsContainer children:', this._optionsContainer.children.length);
|
|
2418
2936
|
}
|
|
2419
2937
|
_renderSingleOption(item, index, getValue, getLabel) {
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
const value = getValue(item);
|
|
2424
|
-
const label = getLabel(item);
|
|
2425
|
-
console.log('[EnhancedSelect] Rendering option', index, ':', { value, label });
|
|
2426
|
-
option.textContent = label;
|
|
2427
|
-
option.dataset.value = String(value);
|
|
2428
|
-
option.dataset.index = String(index); // Also useful for debugging/selectors
|
|
2429
|
-
// Check if selected using selectedItems map
|
|
2430
|
-
const isSelected = this._state.selectedIndices.has(index);
|
|
2431
|
-
if (isSelected) {
|
|
2432
|
-
option.classList.add('selected');
|
|
2433
|
-
option.setAttribute('aria-selected', 'true');
|
|
2434
|
-
}
|
|
2435
|
-
else {
|
|
2436
|
-
option.setAttribute('aria-selected', 'false');
|
|
2938
|
+
if (!this._optionRenderer) {
|
|
2939
|
+
console.error('[EnhancedSelect] Option renderer not initialized');
|
|
2940
|
+
return;
|
|
2437
2941
|
}
|
|
2438
|
-
|
|
2439
|
-
|
|
2942
|
+
// Check if selected
|
|
2943
|
+
const isSelected = this._state.selectedIndices.has(index);
|
|
2944
|
+
const isFocused = this._state.activeIndex === index;
|
|
2945
|
+
console.log('[EnhancedSelect] Rendering option', index, ':', {
|
|
2946
|
+
value: getValue(item),
|
|
2947
|
+
label: getLabel(item),
|
|
2948
|
+
isSelected,
|
|
2949
|
+
isFocused,
|
|
2950
|
+
hasCustomComponent: !!item.optionComponent
|
|
2440
2951
|
});
|
|
2441
|
-
|
|
2952
|
+
// Use the OptionRenderer to render both lightweight and custom component options
|
|
2953
|
+
const optionElement = this._optionRenderer.render(item, index, isSelected, isFocused, this._uniqueId);
|
|
2954
|
+
this._optionsContainer.appendChild(optionElement);
|
|
2442
2955
|
console.log('[EnhancedSelect] Option', index, 'appended to optionsContainer');
|
|
2443
2956
|
}
|
|
2444
2957
|
_addLoadMoreTrigger() {
|
|
@@ -2476,920 +2989,6 @@ if (!customElements.get('enhanced-select')) {
|
|
|
2476
2989
|
customElements.define('enhanced-select', EnhancedSelect);
|
|
2477
2990
|
}
|
|
2478
2991
|
|
|
2479
|
-
/**
|
|
2480
|
-
* Angular-Optimized Enhanced Select Component
|
|
2481
|
-
*
|
|
2482
|
-
* This is a specialized variant that uses light DOM instead of shadow DOM
|
|
2483
|
-
* to ensure perfect compatibility with Angular's rendering pipeline and
|
|
2484
|
-
* view encapsulation system.
|
|
2485
|
-
*
|
|
2486
|
-
* Key differences from standard EnhancedSelect:
|
|
2487
|
-
* - Uses light DOM with scoped CSS classes
|
|
2488
|
-
* - Integrates seamlessly with Angular's change detection
|
|
2489
|
-
* - Maintains all core features (virtualization, accessibility, performance)
|
|
2490
|
-
* - Uses unique class prefixes to avoid style conflicts
|
|
2491
|
-
*
|
|
2492
|
-
* @performance Optimized for Angular's zone.js and rendering cycle
|
|
2493
|
-
* @accessibility Full WCAG 2.1 AAA compliance maintained
|
|
2494
|
-
*/
|
|
2495
|
-
/**
|
|
2496
|
-
* Angular-Enhanced Select Web Component
|
|
2497
|
-
* Uses light DOM for Angular compatibility
|
|
2498
|
-
*/
|
|
2499
|
-
class AngularEnhancedSelect extends HTMLElement {
|
|
2500
|
-
constructor() {
|
|
2501
|
-
super();
|
|
2502
|
-
this._pageCache = {};
|
|
2503
|
-
this._typeBuffer = '';
|
|
2504
|
-
this._hasError = false;
|
|
2505
|
-
this._errorMessage = '';
|
|
2506
|
-
this._boundArrowClick = null;
|
|
2507
|
-
this._isReady = false;
|
|
2508
|
-
// Unique class prefix to avoid conflicts
|
|
2509
|
-
this.PREFIX = 'smilodon-ang-';
|
|
2510
|
-
console.log('[AngularEnhancedSelect] Constructor called');
|
|
2511
|
-
this._uniqueId = `angular-select-${Math.random().toString(36).substr(2, 9)}`;
|
|
2512
|
-
// Merge global config
|
|
2513
|
-
this._config = selectConfig.getConfig();
|
|
2514
|
-
// Initialize state
|
|
2515
|
-
this._state = {
|
|
2516
|
-
isOpen: false,
|
|
2517
|
-
isBusy: false,
|
|
2518
|
-
isSearching: false,
|
|
2519
|
-
currentPage: this._config.infiniteScroll.initialPage || 1,
|
|
2520
|
-
totalPages: 1,
|
|
2521
|
-
selectedIndices: new Set(),
|
|
2522
|
-
selectedItems: new Map(),
|
|
2523
|
-
activeIndex: -1,
|
|
2524
|
-
searchQuery: '',
|
|
2525
|
-
loadedItems: [],
|
|
2526
|
-
groupedItems: [],
|
|
2527
|
-
preserveScrollPosition: false,
|
|
2528
|
-
lastScrollPosition: 0,
|
|
2529
|
-
lastNotifiedQuery: null,
|
|
2530
|
-
lastNotifiedResultCount: 0,
|
|
2531
|
-
isExpanded: false,
|
|
2532
|
-
};
|
|
2533
|
-
// Create DOM structure in light DOM
|
|
2534
|
-
this._initializeStyles();
|
|
2535
|
-
this._container = this._createContainer();
|
|
2536
|
-
this._inputContainer = this._createInputContainer();
|
|
2537
|
-
this._input = this._createInput();
|
|
2538
|
-
this._arrowContainer = this._createArrowContainer();
|
|
2539
|
-
this._dropdown = this._createDropdown();
|
|
2540
|
-
this._optionsContainer = this._createOptionsContainer();
|
|
2541
|
-
this._liveRegion = this._createLiveRegion();
|
|
2542
|
-
this._assembleDOM();
|
|
2543
|
-
this._attachEventListeners();
|
|
2544
|
-
this._initializeObservers();
|
|
2545
|
-
}
|
|
2546
|
-
connectedCallback() {
|
|
2547
|
-
console.log('[AngularEnhancedSelect] connectedCallback called');
|
|
2548
|
-
// Ensure host has proper layout
|
|
2549
|
-
if (!this.style.display) {
|
|
2550
|
-
this.style.display = 'block';
|
|
2551
|
-
}
|
|
2552
|
-
if (!this.style.position) {
|
|
2553
|
-
this.style.position = 'relative';
|
|
2554
|
-
}
|
|
2555
|
-
// Load initial data if server-side is enabled
|
|
2556
|
-
if (this._config.serverSide.enabled && this._config.serverSide.initialSelectedValues) {
|
|
2557
|
-
this._loadInitialSelectedItems();
|
|
2558
|
-
}
|
|
2559
|
-
// Mark element as ready
|
|
2560
|
-
this._isReady = true;
|
|
2561
|
-
console.log('[AngularEnhancedSelect] Element is now ready');
|
|
2562
|
-
}
|
|
2563
|
-
disconnectedCallback() {
|
|
2564
|
-
// Cleanup observers
|
|
2565
|
-
this._resizeObserver?.disconnect();
|
|
2566
|
-
this._intersectionObserver?.disconnect();
|
|
2567
|
-
if (this._busyTimeout)
|
|
2568
|
-
clearTimeout(this._busyTimeout);
|
|
2569
|
-
if (this._typeTimeout)
|
|
2570
|
-
clearTimeout(this._typeTimeout);
|
|
2571
|
-
if (this._searchTimeout)
|
|
2572
|
-
clearTimeout(this._searchTimeout);
|
|
2573
|
-
// Cleanup arrow click listener
|
|
2574
|
-
if (this._boundArrowClick && this._arrowContainer) {
|
|
2575
|
-
this._arrowContainer.removeEventListener('click', this._boundArrowClick);
|
|
2576
|
-
}
|
|
2577
|
-
// Remove style element
|
|
2578
|
-
if (this._styleElement && this._styleElement.parentNode) {
|
|
2579
|
-
this._styleElement.parentNode.removeChild(this._styleElement);
|
|
2580
|
-
}
|
|
2581
|
-
}
|
|
2582
|
-
_initializeStyles() {
|
|
2583
|
-
// Check if styles already exist for this component type
|
|
2584
|
-
const existingStyle = document.head.querySelector('style[data-component="angular-enhanced-select-shared"]');
|
|
2585
|
-
if (existingStyle) {
|
|
2586
|
-
// Styles already injected, reuse the existing one
|
|
2587
|
-
this._styleElement = existingStyle;
|
|
2588
|
-
return;
|
|
2589
|
-
}
|
|
2590
|
-
// Create scoped styles for this component type (shared across all instances)
|
|
2591
|
-
this._styleElement = document.createElement('style');
|
|
2592
|
-
this._styleElement.setAttribute('data-component', 'angular-enhanced-select-shared');
|
|
2593
|
-
const p = this.PREFIX; // shorthand
|
|
2594
|
-
this._styleElement.textContent = `
|
|
2595
|
-
/* Host styles - applied to <angular-enhanced-select> */
|
|
2596
|
-
angular-enhanced-select {
|
|
2597
|
-
display: block;
|
|
2598
|
-
position: relative;
|
|
2599
|
-
width: 100%;
|
|
2600
|
-
min-height: 44px;
|
|
2601
|
-
box-sizing: border-box;
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
|
-
/* Container */
|
|
2605
|
-
.${p}container {
|
|
2606
|
-
position: relative;
|
|
2607
|
-
width: 100%;
|
|
2608
|
-
min-height: 44px;
|
|
2609
|
-
box-sizing: border-box;
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
/* Input Container */
|
|
2613
|
-
.${p}input-container {
|
|
2614
|
-
position: relative;
|
|
2615
|
-
width: 100%;
|
|
2616
|
-
display: flex;
|
|
2617
|
-
align-items: center;
|
|
2618
|
-
flex-wrap: wrap;
|
|
2619
|
-
gap: 6px;
|
|
2620
|
-
padding: 6px 52px 6px 8px;
|
|
2621
|
-
min-height: 44px;
|
|
2622
|
-
background: white;
|
|
2623
|
-
border: 1px solid #d1d5db;
|
|
2624
|
-
border-radius: 6px;
|
|
2625
|
-
box-sizing: border-box;
|
|
2626
|
-
transition: all 0.2s ease;
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
.${p}input-container:focus-within {
|
|
2630
|
-
border-color: #667eea;
|
|
2631
|
-
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
/* Gradient separator before arrow */
|
|
2635
|
-
.${p}input-container::after {
|
|
2636
|
-
content: '';
|
|
2637
|
-
position: absolute;
|
|
2638
|
-
top: 50%;
|
|
2639
|
-
right: 40px;
|
|
2640
|
-
transform: translateY(-50%);
|
|
2641
|
-
width: 1px;
|
|
2642
|
-
height: 60%;
|
|
2643
|
-
background: linear-gradient(
|
|
2644
|
-
to bottom,
|
|
2645
|
-
transparent 0%,
|
|
2646
|
-
rgba(0, 0, 0, 0.1) 20%,
|
|
2647
|
-
rgba(0, 0, 0, 0.1) 80%,
|
|
2648
|
-
transparent 100%
|
|
2649
|
-
);
|
|
2650
|
-
pointer-events: none;
|
|
2651
|
-
z-index: 1;
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
/* Dropdown Arrow Container */
|
|
2655
|
-
.${p}arrow-container {
|
|
2656
|
-
position: absolute;
|
|
2657
|
-
top: 0;
|
|
2658
|
-
right: 0;
|
|
2659
|
-
bottom: 0;
|
|
2660
|
-
width: 40px;
|
|
2661
|
-
display: flex;
|
|
2662
|
-
align-items: center;
|
|
2663
|
-
justify-content: center;
|
|
2664
|
-
cursor: pointer;
|
|
2665
|
-
transition: background-color 0.2s ease;
|
|
2666
|
-
border-radius: 0 4px 4px 0;
|
|
2667
|
-
z-index: 2;
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
.${p}arrow-container:hover {
|
|
2671
|
-
background-color: rgba(102, 126, 234, 0.08);
|
|
2672
|
-
}
|
|
2673
|
-
|
|
2674
|
-
.${p}arrow {
|
|
2675
|
-
width: 16px;
|
|
2676
|
-
height: 16px;
|
|
2677
|
-
color: #667eea;
|
|
2678
|
-
transition: transform 0.2s ease, color 0.2s ease;
|
|
2679
|
-
transform: translateY(0);
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
.${p}arrow-container:hover .${p}arrow {
|
|
2683
|
-
color: #667eea;
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
.${p}arrow.${p}open {
|
|
2687
|
-
transform: rotate(180deg);
|
|
2688
|
-
}
|
|
2689
|
-
|
|
2690
|
-
/* Input */
|
|
2691
|
-
.${p}input {
|
|
2692
|
-
flex: 1;
|
|
2693
|
-
min-width: 120px;
|
|
2694
|
-
padding: 4px;
|
|
2695
|
-
border: none;
|
|
2696
|
-
font-size: 14px;
|
|
2697
|
-
line-height: 1.5;
|
|
2698
|
-
color: #1f2937;
|
|
2699
|
-
background: transparent;
|
|
2700
|
-
box-sizing: border-box;
|
|
2701
|
-
outline: none;
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
.${p}input::placeholder {
|
|
2705
|
-
color: #9ca3af;
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
/* Selection Badges */
|
|
2709
|
-
.${p}badge {
|
|
2710
|
-
display: inline-flex;
|
|
2711
|
-
align-items: center;
|
|
2712
|
-
gap: 4px;
|
|
2713
|
-
padding: 4px 8px;
|
|
2714
|
-
margin: 2px;
|
|
2715
|
-
background: #667eea;
|
|
2716
|
-
color: white;
|
|
2717
|
-
border-radius: 4px;
|
|
2718
|
-
font-size: 13px;
|
|
2719
|
-
line-height: 1;
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
|
-
.${p}badge-remove {
|
|
2723
|
-
display: inline-flex;
|
|
2724
|
-
align-items: center;
|
|
2725
|
-
justify-content: center;
|
|
2726
|
-
width: 16px;
|
|
2727
|
-
height: 16px;
|
|
2728
|
-
padding: 0;
|
|
2729
|
-
margin-left: 4px;
|
|
2730
|
-
background: rgba(255, 255, 255, 0.3);
|
|
2731
|
-
border: none;
|
|
2732
|
-
border-radius: 50%;
|
|
2733
|
-
color: white;
|
|
2734
|
-
font-size: 16px;
|
|
2735
|
-
line-height: 1;
|
|
2736
|
-
cursor: pointer;
|
|
2737
|
-
transition: background 0.2s;
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
.${p}badge-remove:hover {
|
|
2741
|
-
background: rgba(255, 255, 255, 0.5);
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
/* Dropdown */
|
|
2745
|
-
.${p}dropdown {
|
|
2746
|
-
position: absolute;
|
|
2747
|
-
scroll-behavior: smooth;
|
|
2748
|
-
top: 100%;
|
|
2749
|
-
left: 0;
|
|
2750
|
-
right: 0;
|
|
2751
|
-
margin-top: 4px;
|
|
2752
|
-
max-height: 300px;
|
|
2753
|
-
overflow: hidden;
|
|
2754
|
-
background: white;
|
|
2755
|
-
border: 1px solid #ccc;
|
|
2756
|
-
border-radius: 4px;
|
|
2757
|
-
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
2758
|
-
z-index: 1000;
|
|
2759
|
-
box-sizing: border-box;
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
.${p}dropdown[style*="display: none"] {
|
|
2763
|
-
display: none !important;
|
|
2764
|
-
}
|
|
2765
|
-
|
|
2766
|
-
/* Options Container */
|
|
2767
|
-
.${p}options-container {
|
|
2768
|
-
position: relative;
|
|
2769
|
-
max-height: 300px;
|
|
2770
|
-
overflow: auto;
|
|
2771
|
-
transition: opacity 0.2s ease-in-out;
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
/* Option */
|
|
2775
|
-
.${p}option {
|
|
2776
|
-
padding: 8px 12px;
|
|
2777
|
-
cursor: pointer;
|
|
2778
|
-
color: inherit;
|
|
2779
|
-
transition: background-color 0.15s ease;
|
|
2780
|
-
user-select: none;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
.${p}option:hover {
|
|
2784
|
-
background-color: #f3f4f6;
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
.${p}option.${p}selected {
|
|
2788
|
-
background-color: #e0e7ff;
|
|
2789
|
-
color: #4338ca;
|
|
2790
|
-
font-weight: 500;
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
.${p}option.${p}active {
|
|
2794
|
-
background-color: #f3f4f6;
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
/* Load More */
|
|
2798
|
-
.${p}load-more-container {
|
|
2799
|
-
padding: 12px;
|
|
2800
|
-
text-align: center;
|
|
2801
|
-
border-top: 1px solid #e0e0e0;
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
.${p}load-more-button {
|
|
2805
|
-
padding: 8px 16px;
|
|
2806
|
-
border: 1px solid #1976d2;
|
|
2807
|
-
background: white;
|
|
2808
|
-
color: #1976d2;
|
|
2809
|
-
border-radius: 4px;
|
|
2810
|
-
cursor: pointer;
|
|
2811
|
-
font-size: 14px;
|
|
2812
|
-
transition: all 0.2s ease;
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
.${p}load-more-button:hover {
|
|
2816
|
-
background: #1976d2;
|
|
2817
|
-
color: white;
|
|
2818
|
-
}
|
|
2819
|
-
|
|
2820
|
-
.${p}load-more-button:disabled {
|
|
2821
|
-
opacity: 0.5;
|
|
2822
|
-
cursor: not-allowed;
|
|
2823
|
-
}
|
|
2824
|
-
|
|
2825
|
-
/* Busy State */
|
|
2826
|
-
.${p}busy-bucket {
|
|
2827
|
-
padding: 16px;
|
|
2828
|
-
text-align: center;
|
|
2829
|
-
color: #666;
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
.${p}spinner {
|
|
2833
|
-
display: inline-block;
|
|
2834
|
-
width: 20px;
|
|
2835
|
-
height: 20px;
|
|
2836
|
-
border: 2px solid #ccc;
|
|
2837
|
-
border-top-color: #1976d2;
|
|
2838
|
-
border-radius: 50%;
|
|
2839
|
-
animation: ${p}spin 0.6s linear infinite;
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
@keyframes ${p}spin {
|
|
2843
|
-
to { transform: rotate(360deg); }
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
/* Empty State */
|
|
2847
|
-
.${p}empty-state {
|
|
2848
|
-
padding: 24px;
|
|
2849
|
-
text-align: center;
|
|
2850
|
-
color: #999;
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
/* Searching State */
|
|
2854
|
-
.${p}searching-state {
|
|
2855
|
-
padding: 24px;
|
|
2856
|
-
text-align: center;
|
|
2857
|
-
color: #667eea;
|
|
2858
|
-
font-style: italic;
|
|
2859
|
-
animation: ${p}pulse 1.5s ease-in-out infinite;
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
@keyframes ${p}pulse {
|
|
2863
|
-
0%, 100% { opacity: 1; }
|
|
2864
|
-
50% { opacity: 0.5; }
|
|
2865
|
-
}
|
|
2866
|
-
|
|
2867
|
-
/* Error states */
|
|
2868
|
-
.${p}input[aria-invalid="true"] {
|
|
2869
|
-
border-color: #dc2626;
|
|
2870
|
-
}
|
|
2871
|
-
|
|
2872
|
-
.${p}input[aria-invalid="true"]:focus {
|
|
2873
|
-
border-color: #dc2626;
|
|
2874
|
-
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.1);
|
|
2875
|
-
outline-color: #dc2626;
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
/* Live Region (Screen reader only) */
|
|
2879
|
-
.${p}live-region {
|
|
2880
|
-
position: absolute;
|
|
2881
|
-
left: -10000px;
|
|
2882
|
-
width: 1px;
|
|
2883
|
-
height: 1px;
|
|
2884
|
-
overflow: hidden;
|
|
2885
|
-
clip: rect(0, 0, 0, 0);
|
|
2886
|
-
white-space: nowrap;
|
|
2887
|
-
border-width: 0;
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
/* Accessibility: Reduced motion */
|
|
2891
|
-
@media (prefers-reduced-motion: reduce) {
|
|
2892
|
-
.${p}arrow,
|
|
2893
|
-
.${p}badge-remove,
|
|
2894
|
-
.${p}option,
|
|
2895
|
-
.${p}dropdown {
|
|
2896
|
-
animation-duration: 0.01ms !important;
|
|
2897
|
-
animation-iteration-count: 1 !important;
|
|
2898
|
-
transition-duration: 0.01ms !important;
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
/* Touch targets (WCAG 2.5.5) */
|
|
2903
|
-
.${p}load-more-button,
|
|
2904
|
-
.${p}option {
|
|
2905
|
-
min-height: 44px;
|
|
2906
|
-
}
|
|
2907
|
-
`;
|
|
2908
|
-
// Safely append to document head (check if document is ready and not already appended)
|
|
2909
|
-
if (document.head && !this._styleElement.parentNode) {
|
|
2910
|
-
try {
|
|
2911
|
-
document.head.appendChild(this._styleElement);
|
|
2912
|
-
}
|
|
2913
|
-
catch (e) {
|
|
2914
|
-
console.warn('[AngularEnhancedSelect] Could not inject styles:', e);
|
|
2915
|
-
// Fallback: inject after a delay
|
|
2916
|
-
setTimeout(() => {
|
|
2917
|
-
try {
|
|
2918
|
-
if (this._styleElement && !this._styleElement.parentNode) {
|
|
2919
|
-
document.head.appendChild(this._styleElement);
|
|
2920
|
-
}
|
|
2921
|
-
}
|
|
2922
|
-
catch (err) {
|
|
2923
|
-
console.error('[AngularEnhancedSelect] Style injection failed:', err);
|
|
2924
|
-
}
|
|
2925
|
-
}, 0);
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2929
|
-
_createContainer() {
|
|
2930
|
-
const container = document.createElement('div');
|
|
2931
|
-
container.className = `${this.PREFIX}container`;
|
|
2932
|
-
return container;
|
|
2933
|
-
}
|
|
2934
|
-
_createInputContainer() {
|
|
2935
|
-
const container = document.createElement('div');
|
|
2936
|
-
container.className = `${this.PREFIX}input-container`;
|
|
2937
|
-
return container;
|
|
2938
|
-
}
|
|
2939
|
-
_createInput() {
|
|
2940
|
-
const input = document.createElement('input');
|
|
2941
|
-
input.type = 'text';
|
|
2942
|
-
input.className = `${this.PREFIX}input`;
|
|
2943
|
-
input.placeholder = this.getAttribute('placeholder') || 'Select an option...';
|
|
2944
|
-
input.setAttribute('readonly', '');
|
|
2945
|
-
input.setAttribute('role', 'combobox');
|
|
2946
|
-
input.setAttribute('aria-expanded', 'false');
|
|
2947
|
-
input.setAttribute('aria-haspopup', 'listbox');
|
|
2948
|
-
input.setAttribute('aria-autocomplete', 'none');
|
|
2949
|
-
input.setAttribute('aria-controls', `${this._uniqueId}-listbox`);
|
|
2950
|
-
input.setAttribute('aria-owns', `${this._uniqueId}-listbox`);
|
|
2951
|
-
input.tabIndex = 0;
|
|
2952
|
-
return input;
|
|
2953
|
-
}
|
|
2954
|
-
_createArrowContainer() {
|
|
2955
|
-
const container = document.createElement('div');
|
|
2956
|
-
container.className = `${this.PREFIX}arrow-container`;
|
|
2957
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2958
|
-
svg.setAttribute('class', `${this.PREFIX}arrow`);
|
|
2959
|
-
svg.setAttribute('width', '16');
|
|
2960
|
-
svg.setAttribute('height', '16');
|
|
2961
|
-
svg.setAttribute('viewBox', '0 0 16 16');
|
|
2962
|
-
svg.setAttribute('fill', 'none');
|
|
2963
|
-
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
2964
|
-
path.setAttribute('d', 'M4 6l4 4 4-4');
|
|
2965
|
-
path.setAttribute('stroke', 'currentColor');
|
|
2966
|
-
path.setAttribute('stroke-width', '2');
|
|
2967
|
-
path.setAttribute('stroke-linecap', 'round');
|
|
2968
|
-
path.setAttribute('stroke-linejoin', 'round');
|
|
2969
|
-
svg.appendChild(path);
|
|
2970
|
-
container.appendChild(svg);
|
|
2971
|
-
return container;
|
|
2972
|
-
}
|
|
2973
|
-
_createDropdown() {
|
|
2974
|
-
const dropdown = document.createElement('div');
|
|
2975
|
-
dropdown.id = `${this._uniqueId}-listbox`;
|
|
2976
|
-
dropdown.className = `${this.PREFIX}dropdown`;
|
|
2977
|
-
dropdown.style.display = 'none';
|
|
2978
|
-
dropdown.setAttribute('role', 'listbox');
|
|
2979
|
-
return dropdown;
|
|
2980
|
-
}
|
|
2981
|
-
_createOptionsContainer() {
|
|
2982
|
-
const container = document.createElement('div');
|
|
2983
|
-
container.className = `${this.PREFIX}options-container`;
|
|
2984
|
-
return container;
|
|
2985
|
-
}
|
|
2986
|
-
_createLiveRegion() {
|
|
2987
|
-
const region = document.createElement('div');
|
|
2988
|
-
region.className = `${this.PREFIX}live-region`;
|
|
2989
|
-
region.setAttribute('role', 'status');
|
|
2990
|
-
region.setAttribute('aria-live', 'polite');
|
|
2991
|
-
region.setAttribute('aria-atomic', 'true');
|
|
2992
|
-
return region;
|
|
2993
|
-
}
|
|
2994
|
-
_assembleDOM() {
|
|
2995
|
-
// Assemble in light DOM
|
|
2996
|
-
this._inputContainer.appendChild(this._input);
|
|
2997
|
-
this._inputContainer.appendChild(this._arrowContainer);
|
|
2998
|
-
this._container.appendChild(this._inputContainer);
|
|
2999
|
-
this._dropdown.appendChild(this._optionsContainer);
|
|
3000
|
-
this._container.appendChild(this._dropdown);
|
|
3001
|
-
this._container.appendChild(this._liveRegion);
|
|
3002
|
-
this.appendChild(this._container);
|
|
3003
|
-
}
|
|
3004
|
-
_attachEventListeners() {
|
|
3005
|
-
// Arrow click handler
|
|
3006
|
-
if (this._arrowContainer) {
|
|
3007
|
-
this._boundArrowClick = (e) => {
|
|
3008
|
-
e.stopPropagation();
|
|
3009
|
-
e.preventDefault();
|
|
3010
|
-
const wasOpen = this._state.isOpen;
|
|
3011
|
-
this._state.isOpen = !this._state.isOpen;
|
|
3012
|
-
this._updateDropdownVisibility();
|
|
3013
|
-
this._updateArrowRotation();
|
|
3014
|
-
if (this._state.isOpen && this._config.callbacks.onOpen) {
|
|
3015
|
-
this._config.callbacks.onOpen();
|
|
3016
|
-
}
|
|
3017
|
-
else if (!this._state.isOpen && this._config.callbacks.onClose) {
|
|
3018
|
-
this._config.callbacks.onClose();
|
|
3019
|
-
}
|
|
3020
|
-
if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
|
|
3021
|
-
setTimeout(() => this._scrollToSelected(), 50);
|
|
3022
|
-
}
|
|
3023
|
-
};
|
|
3024
|
-
this._arrowContainer.addEventListener('click', this._boundArrowClick);
|
|
3025
|
-
}
|
|
3026
|
-
// Input focus handler
|
|
3027
|
-
this._input.addEventListener('focus', () => {
|
|
3028
|
-
if (!this._state.isOpen) {
|
|
3029
|
-
this._state.isOpen = true;
|
|
3030
|
-
this._updateDropdownVisibility();
|
|
3031
|
-
this._updateArrowRotation();
|
|
3032
|
-
if (this._config.callbacks.onOpen) {
|
|
3033
|
-
this._config.callbacks.onOpen();
|
|
3034
|
-
}
|
|
3035
|
-
}
|
|
3036
|
-
});
|
|
3037
|
-
// Input keyboard handler
|
|
3038
|
-
this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
|
|
3039
|
-
// Click outside to close
|
|
3040
|
-
document.addEventListener('click', (e) => {
|
|
3041
|
-
if (!this.contains(e.target) && this._state.isOpen) {
|
|
3042
|
-
this._state.isOpen = false;
|
|
3043
|
-
this._updateDropdownVisibility();
|
|
3044
|
-
this._updateArrowRotation();
|
|
3045
|
-
if (this._config.callbacks.onClose) {
|
|
3046
|
-
this._config.callbacks.onClose();
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
});
|
|
3050
|
-
// Search handler
|
|
3051
|
-
if (this.hasAttribute('searchable')) {
|
|
3052
|
-
this._input.removeAttribute('readonly');
|
|
3053
|
-
this._input.addEventListener('input', (e) => {
|
|
3054
|
-
const query = e.target.value;
|
|
3055
|
-
this._handleSearch(query);
|
|
3056
|
-
});
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3059
|
-
_initializeObservers() {
|
|
3060
|
-
// Resize observer for dropdown positioning
|
|
3061
|
-
if (typeof ResizeObserver !== 'undefined') {
|
|
3062
|
-
this._resizeObserver = new ResizeObserver(() => {
|
|
3063
|
-
if (this._state.isOpen) {
|
|
3064
|
-
this._updateDropdownPosition();
|
|
3065
|
-
}
|
|
3066
|
-
});
|
|
3067
|
-
this._resizeObserver.observe(this);
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
_updateDropdownVisibility() {
|
|
3071
|
-
if (this._state.isOpen) {
|
|
3072
|
-
this._dropdown.style.display = 'block';
|
|
3073
|
-
this._input.setAttribute('aria-expanded', 'true');
|
|
3074
|
-
this._updateDropdownPosition();
|
|
3075
|
-
}
|
|
3076
|
-
else {
|
|
3077
|
-
this._dropdown.style.display = 'none';
|
|
3078
|
-
this._input.setAttribute('aria-expanded', 'false');
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
_updateDropdownPosition() {
|
|
3082
|
-
// Ensure dropdown is positioned correctly relative to input
|
|
3083
|
-
const rect = this._inputContainer.getBoundingClientRect();
|
|
3084
|
-
const viewportHeight = window.innerHeight;
|
|
3085
|
-
const spaceBelow = viewportHeight - rect.bottom;
|
|
3086
|
-
const spaceAbove = rect.top;
|
|
3087
|
-
// Auto placement
|
|
3088
|
-
if (spaceBelow < 300 && spaceAbove > spaceBelow) {
|
|
3089
|
-
// Open upward
|
|
3090
|
-
this._dropdown.style.top = 'auto';
|
|
3091
|
-
this._dropdown.style.bottom = '100%';
|
|
3092
|
-
this._dropdown.style.marginTop = '0';
|
|
3093
|
-
this._dropdown.style.marginBottom = '4px';
|
|
3094
|
-
}
|
|
3095
|
-
else {
|
|
3096
|
-
// Open downward (default)
|
|
3097
|
-
this._dropdown.style.top = '100%';
|
|
3098
|
-
this._dropdown.style.bottom = 'auto';
|
|
3099
|
-
this._dropdown.style.marginTop = '4px';
|
|
3100
|
-
this._dropdown.style.marginBottom = '0';
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
_updateArrowRotation() {
|
|
3104
|
-
const arrow = this._arrowContainer?.querySelector(`.${this.PREFIX}arrow`);
|
|
3105
|
-
if (arrow) {
|
|
3106
|
-
if (this._state.isOpen) {
|
|
3107
|
-
arrow.classList.add(`${this.PREFIX}open`);
|
|
3108
|
-
}
|
|
3109
|
-
else {
|
|
3110
|
-
arrow.classList.remove(`${this.PREFIX}open`);
|
|
3111
|
-
}
|
|
3112
|
-
}
|
|
3113
|
-
}
|
|
3114
|
-
_handleKeydown(e) {
|
|
3115
|
-
// Implement keyboard navigation
|
|
3116
|
-
switch (e.key) {
|
|
3117
|
-
case 'ArrowDown':
|
|
3118
|
-
e.preventDefault();
|
|
3119
|
-
if (!this._state.isOpen) {
|
|
3120
|
-
this._state.isOpen = true;
|
|
3121
|
-
this._updateDropdownVisibility();
|
|
3122
|
-
this._updateArrowRotation();
|
|
3123
|
-
}
|
|
3124
|
-
else {
|
|
3125
|
-
this._moveActive(1);
|
|
3126
|
-
}
|
|
3127
|
-
break;
|
|
3128
|
-
case 'ArrowUp':
|
|
3129
|
-
e.preventDefault();
|
|
3130
|
-
if (this._state.isOpen) {
|
|
3131
|
-
this._moveActive(-1);
|
|
3132
|
-
}
|
|
3133
|
-
break;
|
|
3134
|
-
case 'Enter':
|
|
3135
|
-
e.preventDefault();
|
|
3136
|
-
if (this._state.isOpen && this._state.activeIndex >= 0) {
|
|
3137
|
-
this._selectByIndex(this._state.activeIndex);
|
|
3138
|
-
}
|
|
3139
|
-
else {
|
|
3140
|
-
this._state.isOpen = true;
|
|
3141
|
-
this._updateDropdownVisibility();
|
|
3142
|
-
this._updateArrowRotation();
|
|
3143
|
-
}
|
|
3144
|
-
break;
|
|
3145
|
-
case 'Escape':
|
|
3146
|
-
e.preventDefault();
|
|
3147
|
-
if (this._state.isOpen) {
|
|
3148
|
-
this._state.isOpen = false;
|
|
3149
|
-
this._updateDropdownVisibility();
|
|
3150
|
-
this._updateArrowRotation();
|
|
3151
|
-
}
|
|
3152
|
-
break;
|
|
3153
|
-
case 'Tab':
|
|
3154
|
-
if (this._state.isOpen) {
|
|
3155
|
-
this._state.isOpen = false;
|
|
3156
|
-
this._updateDropdownVisibility();
|
|
3157
|
-
this._updateArrowRotation();
|
|
3158
|
-
}
|
|
3159
|
-
break;
|
|
3160
|
-
}
|
|
3161
|
-
}
|
|
3162
|
-
_moveActive(direction) {
|
|
3163
|
-
const options = this._optionsContainer.querySelectorAll(`.${this.PREFIX}option`);
|
|
3164
|
-
if (options.length === 0)
|
|
3165
|
-
return;
|
|
3166
|
-
let newIndex = this._state.activeIndex + direction;
|
|
3167
|
-
if (newIndex < 0)
|
|
3168
|
-
newIndex = 0;
|
|
3169
|
-
if (newIndex >= options.length)
|
|
3170
|
-
newIndex = options.length - 1;
|
|
3171
|
-
this._state.activeIndex = newIndex;
|
|
3172
|
-
// Update visual active state
|
|
3173
|
-
options.forEach((opt, idx) => {
|
|
3174
|
-
if (idx === newIndex) {
|
|
3175
|
-
opt.classList.add(`${this.PREFIX}active`);
|
|
3176
|
-
opt.scrollIntoView({ block: 'nearest' });
|
|
3177
|
-
}
|
|
3178
|
-
else {
|
|
3179
|
-
opt.classList.remove(`${this.PREFIX}active`);
|
|
3180
|
-
}
|
|
3181
|
-
});
|
|
3182
|
-
}
|
|
3183
|
-
_selectByIndex(index) {
|
|
3184
|
-
const item = this._state.loadedItems[index];
|
|
3185
|
-
if (!item)
|
|
3186
|
-
return;
|
|
3187
|
-
const isMultiple = this.hasAttribute('multiple');
|
|
3188
|
-
if (isMultiple) {
|
|
3189
|
-
// Toggle selection
|
|
3190
|
-
if (this._state.selectedIndices.has(index)) {
|
|
3191
|
-
this._state.selectedIndices.delete(index);
|
|
3192
|
-
this._state.selectedItems.delete(index);
|
|
3193
|
-
}
|
|
3194
|
-
else {
|
|
3195
|
-
this._state.selectedIndices.add(index);
|
|
3196
|
-
this._state.selectedItems.set(index, item);
|
|
3197
|
-
}
|
|
3198
|
-
}
|
|
3199
|
-
else {
|
|
3200
|
-
// Single selection
|
|
3201
|
-
this._state.selectedIndices.clear();
|
|
3202
|
-
this._state.selectedItems.clear();
|
|
3203
|
-
this._state.selectedIndices.add(index);
|
|
3204
|
-
this._state.selectedItems.set(index, item);
|
|
3205
|
-
// Close dropdown
|
|
3206
|
-
this._state.isOpen = false;
|
|
3207
|
-
this._updateDropdownVisibility();
|
|
3208
|
-
this._updateArrowRotation();
|
|
3209
|
-
}
|
|
3210
|
-
this._updateInputDisplay();
|
|
3211
|
-
this._renderOptions();
|
|
3212
|
-
this._emitChangeEvent();
|
|
3213
|
-
}
|
|
3214
|
-
_updateInputDisplay() {
|
|
3215
|
-
const selectedItems = Array.from(this._state.selectedItems.values());
|
|
3216
|
-
const isMultiple = this.hasAttribute('multiple');
|
|
3217
|
-
if (isMultiple) {
|
|
3218
|
-
// Clear input, show badges
|
|
3219
|
-
this._input.value = '';
|
|
3220
|
-
// Remove existing badges
|
|
3221
|
-
this._inputContainer.querySelectorAll(`.${this.PREFIX}badge`).forEach(badge => badge.remove());
|
|
3222
|
-
// Add new badges
|
|
3223
|
-
selectedItems.forEach((item, idx) => {
|
|
3224
|
-
const badge = document.createElement('span');
|
|
3225
|
-
badge.className = `${this.PREFIX}badge`;
|
|
3226
|
-
badge.textContent = item.label;
|
|
3227
|
-
const removeBtn = document.createElement('button');
|
|
3228
|
-
removeBtn.className = `${this.PREFIX}badge-remove`;
|
|
3229
|
-
removeBtn.textContent = '×';
|
|
3230
|
-
removeBtn.addEventListener('click', (e) => {
|
|
3231
|
-
e.stopPropagation();
|
|
3232
|
-
const itemIndex = Array.from(this._state.selectedItems.keys())[idx];
|
|
3233
|
-
this._state.selectedIndices.delete(itemIndex);
|
|
3234
|
-
this._state.selectedItems.delete(itemIndex);
|
|
3235
|
-
this._updateInputDisplay();
|
|
3236
|
-
this._renderOptions();
|
|
3237
|
-
this._emitChangeEvent();
|
|
3238
|
-
});
|
|
3239
|
-
badge.appendChild(removeBtn);
|
|
3240
|
-
this._inputContainer.insertBefore(badge, this._input);
|
|
3241
|
-
});
|
|
3242
|
-
}
|
|
3243
|
-
else {
|
|
3244
|
-
// Single selection - show label in input
|
|
3245
|
-
if (selectedItems.length > 0) {
|
|
3246
|
-
this._input.value = selectedItems[0].label;
|
|
3247
|
-
}
|
|
3248
|
-
else {
|
|
3249
|
-
this._input.value = '';
|
|
3250
|
-
}
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
_renderOptions() {
|
|
3254
|
-
// Clear existing options
|
|
3255
|
-
this._optionsContainer.innerHTML = '';
|
|
3256
|
-
const items = this._state.loadedItems;
|
|
3257
|
-
if (items.length === 0) {
|
|
3258
|
-
const emptyDiv = document.createElement('div');
|
|
3259
|
-
emptyDiv.className = `${this.PREFIX}empty-state`;
|
|
3260
|
-
emptyDiv.textContent = 'No options available';
|
|
3261
|
-
this._optionsContainer.appendChild(emptyDiv);
|
|
3262
|
-
return;
|
|
3263
|
-
}
|
|
3264
|
-
// Render options
|
|
3265
|
-
items.forEach((item, index) => {
|
|
3266
|
-
const optionDiv = document.createElement('div');
|
|
3267
|
-
optionDiv.className = `${this.PREFIX}option`;
|
|
3268
|
-
optionDiv.textContent = item.label;
|
|
3269
|
-
optionDiv.setAttribute('role', 'option');
|
|
3270
|
-
optionDiv.setAttribute('data-index', String(index));
|
|
3271
|
-
if (this._state.selectedIndices.has(index)) {
|
|
3272
|
-
optionDiv.classList.add(`${this.PREFIX}selected`);
|
|
3273
|
-
optionDiv.setAttribute('aria-selected', 'true');
|
|
3274
|
-
}
|
|
3275
|
-
if (item.disabled) {
|
|
3276
|
-
optionDiv.style.opacity = '0.5';
|
|
3277
|
-
optionDiv.style.cursor = 'not-allowed';
|
|
3278
|
-
}
|
|
3279
|
-
else {
|
|
3280
|
-
optionDiv.addEventListener('click', () => {
|
|
3281
|
-
this._selectByIndex(index);
|
|
3282
|
-
});
|
|
3283
|
-
}
|
|
3284
|
-
this._optionsContainer.appendChild(optionDiv);
|
|
3285
|
-
});
|
|
3286
|
-
}
|
|
3287
|
-
_handleSearch(query) {
|
|
3288
|
-
this._state.searchQuery = query;
|
|
3289
|
-
// Filter items based on search
|
|
3290
|
-
const allItems = this._state.loadedItems;
|
|
3291
|
-
const filtered = allItems.filter(item => item.label.toLowerCase().includes(query.toLowerCase()));
|
|
3292
|
-
// Temporarily replace loaded items with filtered
|
|
3293
|
-
this._state.loadedItems;
|
|
3294
|
-
this._state.loadedItems = filtered;
|
|
3295
|
-
this._renderOptions();
|
|
3296
|
-
// Emit search event
|
|
3297
|
-
this.dispatchEvent(new CustomEvent('search', {
|
|
3298
|
-
detail: { query },
|
|
3299
|
-
bubbles: true,
|
|
3300
|
-
composed: true,
|
|
3301
|
-
}));
|
|
3302
|
-
}
|
|
3303
|
-
_emitChangeEvent() {
|
|
3304
|
-
const selectedItems = Array.from(this._state.selectedItems.values());
|
|
3305
|
-
const selectedValues = selectedItems.map(item => item.value);
|
|
3306
|
-
const selectedIndices = Array.from(this._state.selectedIndices);
|
|
3307
|
-
this.dispatchEvent(new CustomEvent('change', {
|
|
3308
|
-
detail: { selectedItems, selectedValues, selectedIndices },
|
|
3309
|
-
bubbles: true,
|
|
3310
|
-
composed: true,
|
|
3311
|
-
}));
|
|
3312
|
-
}
|
|
3313
|
-
_scrollToSelected() {
|
|
3314
|
-
const firstSelected = this._optionsContainer.querySelector(`.${this.PREFIX}selected`);
|
|
3315
|
-
if (firstSelected) {
|
|
3316
|
-
firstSelected.scrollIntoView({ block: 'nearest' });
|
|
3317
|
-
}
|
|
3318
|
-
}
|
|
3319
|
-
_loadInitialSelectedItems() {
|
|
3320
|
-
// Placeholder for server-side data loading
|
|
3321
|
-
}
|
|
3322
|
-
_announce(message) {
|
|
3323
|
-
if (this._liveRegion) {
|
|
3324
|
-
this._liveRegion.textContent = message;
|
|
3325
|
-
setTimeout(() => {
|
|
3326
|
-
if (this._liveRegion)
|
|
3327
|
-
this._liveRegion.textContent = '';
|
|
3328
|
-
}, 1000);
|
|
3329
|
-
}
|
|
3330
|
-
}
|
|
3331
|
-
// Public API methods
|
|
3332
|
-
isReady() {
|
|
3333
|
-
return this._isReady;
|
|
3334
|
-
}
|
|
3335
|
-
setItems(items) {
|
|
3336
|
-
this._state.loadedItems = items;
|
|
3337
|
-
this._renderOptions();
|
|
3338
|
-
}
|
|
3339
|
-
setGroupedItems(groups) {
|
|
3340
|
-
this._state.groupedItems = groups;
|
|
3341
|
-
// Flatten for now
|
|
3342
|
-
const items = [];
|
|
3343
|
-
groups.forEach(group => {
|
|
3344
|
-
if (group.items) {
|
|
3345
|
-
items.push(...group.items);
|
|
3346
|
-
}
|
|
3347
|
-
});
|
|
3348
|
-
this.setItems(items);
|
|
3349
|
-
}
|
|
3350
|
-
setSelectedValues(values) {
|
|
3351
|
-
this._state.selectedIndices.clear();
|
|
3352
|
-
this._state.selectedItems.clear();
|
|
3353
|
-
values.forEach(value => {
|
|
3354
|
-
const index = this._state.loadedItems.findIndex(item => item.value === value);
|
|
3355
|
-
if (index >= 0) {
|
|
3356
|
-
this._state.selectedIndices.add(index);
|
|
3357
|
-
this._state.selectedItems.set(index, this._state.loadedItems[index]);
|
|
3358
|
-
}
|
|
3359
|
-
});
|
|
3360
|
-
this._updateInputDisplay();
|
|
3361
|
-
this._renderOptions();
|
|
3362
|
-
}
|
|
3363
|
-
getSelectedValues() {
|
|
3364
|
-
return Array.from(this._state.selectedItems.values()).map(item => item.value);
|
|
3365
|
-
}
|
|
3366
|
-
updateConfig(config) {
|
|
3367
|
-
this._config = { ...this._config, ...config };
|
|
3368
|
-
}
|
|
3369
|
-
setError(message) {
|
|
3370
|
-
this._hasError = true;
|
|
3371
|
-
this._errorMessage = message;
|
|
3372
|
-
this._input.setAttribute('aria-invalid', 'true');
|
|
3373
|
-
this._announce(`Error: ${message}`);
|
|
3374
|
-
}
|
|
3375
|
-
clearError() {
|
|
3376
|
-
this._hasError = false;
|
|
3377
|
-
this._errorMessage = '';
|
|
3378
|
-
this._input.removeAttribute('aria-invalid');
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
// Register the custom element
|
|
3382
|
-
console.log('[AngularEnhancedSelect] Attempting to register custom element...');
|
|
3383
|
-
console.log('[AngularEnhancedSelect] customElements available:', typeof customElements !== 'undefined');
|
|
3384
|
-
console.log('[AngularEnhancedSelect] Already registered:', customElements?.get('angular-enhanced-select'));
|
|
3385
|
-
if (typeof customElements !== 'undefined' && !customElements.get('angular-enhanced-select')) {
|
|
3386
|
-
customElements.define('angular-enhanced-select', AngularEnhancedSelect);
|
|
3387
|
-
console.log('[AngularEnhancedSelect] Successfully registered custom element');
|
|
3388
|
-
}
|
|
3389
|
-
else if (customElements?.get('angular-enhanced-select')) {
|
|
3390
|
-
console.log('[AngularEnhancedSelect] Custom element already registered');
|
|
3391
|
-
}
|
|
3392
|
-
|
|
3393
2992
|
/**
|
|
3394
2993
|
* Independent Option Component
|
|
3395
2994
|
* High cohesion, low coupling - handles its own selection state and events
|
|
@@ -4652,12 +4251,13 @@ function warnCSPViolation(feature, fallback) {
|
|
|
4652
4251
|
}
|
|
4653
4252
|
}
|
|
4654
4253
|
|
|
4655
|
-
exports.AngularEnhancedSelect = AngularEnhancedSelect;
|
|
4656
4254
|
exports.CSPFeatures = CSPFeatures;
|
|
4255
|
+
exports.CustomOptionPool = CustomOptionPool;
|
|
4657
4256
|
exports.DOMPool = DOMPool;
|
|
4658
4257
|
exports.EnhancedSelect = EnhancedSelect;
|
|
4659
4258
|
exports.FenwickTree = FenwickTree;
|
|
4660
4259
|
exports.NativeSelectElement = NativeSelectElement;
|
|
4260
|
+
exports.OptionRenderer = OptionRenderer;
|
|
4661
4261
|
exports.PerformanceTelemetry = PerformanceTelemetry;
|
|
4662
4262
|
exports.SelectOption = SelectOption;
|
|
4663
4263
|
exports.Virtualizer = Virtualizer;
|