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