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