@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/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
- node.textContent = String(item);
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
- el.textContent = String(item);
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: item?.value ?? item,
723
- label: item?.label ?? String(item),
789
+ value,
790
+ label,
724
791
  selected,
725
792
  multi: this._multi
726
793
  });
727
- this._announce(`Selected ${String(item)}`);
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
- const option = document.createElement('div');
2421
- option.className = 'option';
2422
- option.id = `${this._uniqueId}-option-${index}`;
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
- option.addEventListener('click', () => {
2439
- this._selectOption(index);
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
- this._optionsContainer.appendChild(option);
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;