selective-ui 1.0.4 → 1.0.5

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.
@@ -1,4 +1,4 @@
1
- /*! Selective UI v1.0.4 | MIT License */
1
+ /*! Selective UI v1.0.5 | MIT License */
2
2
  (function (global, factory) {
3
3
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
4
4
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
@@ -795,6 +795,14 @@
795
795
 
796
796
  let width = `${select.offsetWidth}px`,
797
797
  height = `${select.offsetHeight}px`;
798
+
799
+ const getCStyle = getComputedStyle(select);
800
+ if (width == "0px" && getCStyle.width != "auto") {
801
+ width = getCStyle.width;
802
+ }
803
+ if (height == "0px" && getCStyle.height != "auto") {
804
+ height = getCStyle.height;
805
+ }
798
806
 
799
807
  if (cfgWidth > 0) {
800
808
  width = options.width;
@@ -1394,58 +1402,206 @@
1394
1402
  /** @type {OptionViewResult} */
1395
1403
  view;
1396
1404
 
1397
- isMultiple = false;
1398
- hasImage = false;
1399
- optionConfig = null;
1405
+ #config = null;
1406
+ #configProxy = null;
1407
+ #isRendered = false;
1408
+
1409
+ /**
1410
+ * Initializes the OptionView with a parent container and sets up the reactive config proxy.
1411
+ * The proxy enables partial DOM updates when config properties change after initial render.
1412
+ *
1413
+ * @param {HTMLElement} parent - The parent element into which this view will be mounted.
1414
+ */
1415
+ constructor(parent) {
1416
+ super(parent);
1417
+ this.#setupConfigProxy();
1418
+ }
1419
+
1420
+ /**
1421
+ * Creates the internal configuration object and wraps it with a Proxy.
1422
+ * The proxy intercepts property assignments and, if the view is rendered,
1423
+ * applies only the necessary DOM changes for the updated property.
1424
+ * No DOM mutations occur before the first render.
1425
+ */
1426
+ #setupConfigProxy() {
1427
+ const self = this;
1428
+
1429
+ this.#config = {
1430
+ isMultiple: false,
1431
+ hasImage: false,
1432
+ imagePosition: 'right',
1433
+ imageWidth: '60px',
1434
+ imageHeight: '60px',
1435
+ imageBorderRadius: '4px',
1436
+ labelValign: 'center',
1437
+ labelHalign: 'left'
1438
+ };
1439
+
1440
+ this.#configProxy = new Proxy(this.#config, {
1441
+ set(target, prop, value) {
1442
+ const oldValue = target[prop];
1443
+
1444
+ if (oldValue !== value) {
1445
+ target[prop] = value;
1446
+
1447
+ if (self.#isRendered) {
1448
+ self.#applyPartialChange(prop, value, oldValue);
1449
+ }
1450
+ }
1451
+ return true;
1452
+ }
1453
+ });
1454
+ }
1455
+
1456
+ /**
1457
+ * Indicates whether the option supports multiple selection (checkbox) instead of single (radio).
1458
+ *
1459
+ * @returns {boolean} True if multiple selection is enabled; otherwise false.
1460
+ */
1461
+ get isMultiple() {
1462
+ return this.#config.isMultiple;
1463
+ }
1464
+
1465
+
1466
+ /**
1467
+ * Enables or disables multiple selection mode.
1468
+ * When rendered, toggles the root CSS class and switches the input type between 'checkbox' and 'radio'.
1469
+ *
1470
+ * @param {boolean} value - True to enable multiple selection; false for single selection.
1471
+ */
1472
+ set isMultiple(value) {
1473
+ this.#configProxy.isMultiple = !!value;
1474
+ }
1475
+
1476
+ /**
1477
+ * Indicates whether the option includes an image block alongside the label.
1478
+ *
1479
+ * @returns {boolean} True if an image is displayed; otherwise false.
1480
+ */
1481
+ get hasImage() {
1482
+ return this.#config.hasImage;
1483
+ }
1484
+
1485
+
1486
+ /**
1487
+ * Shows or hides the image block for the option.
1488
+ * When rendered, toggles related CSS classes and creates/removes the image element accordingly.
1489
+ *
1490
+ * @param {boolean} value - True to show the image; false to hide it.
1491
+ */
1492
+ set hasImage(value) {
1493
+ this.#configProxy.hasImage = !!value;
1494
+ }
1495
+
1496
+ /**
1497
+ * Provides reactive access to the entire option configuration via a Proxy.
1498
+ * Mutating properties on this object will trigger partial DOM updates when rendered.
1499
+ *
1500
+ * @returns {object} The proxied configuration object.
1501
+ */
1502
+ get optionConfig() {
1503
+ return this.#configProxy;
1504
+ }
1505
+
1506
+
1507
+ /**
1508
+ * Applies a set of configuration changes in batch.
1509
+ * Only properties that differ from the current config are updated.
1510
+ * When rendered, each changed property triggers a targeted DOM update via the proxy.
1511
+ *
1512
+ * @param {object} config - Partial configuration object.
1513
+ * @param {string} [config.imageWidth] - CSS width of the image (e.g., '60px').
1514
+ * @param {string} [config.imageHeight] - CSS height of the image (e.g., '60px').
1515
+ * @param {string} [config.imageBorderRadius] - CSS border-radius for the image (e.g., '4px').
1516
+ * @param {'top'|'right'|'bottom'|'left'} [config.imagePosition] - Position of the image relative to the label.
1517
+ * @param {'top'|'center'|'bottom'} [config.labelValign] - Vertical alignment of the label.
1518
+ * @param {'left'|'center'|'right'} [config.labelHalign] - Horizontal alignment of the label.
1519
+ */
1520
+ set optionConfig(config) {
1521
+ if (!config) return;
1522
+
1523
+ const changes = {};
1524
+ let hasChanges = false;
1525
+
1526
+ if (config.imageWidth !== undefined && config.imageWidth !== this.#config.imageWidth) {
1527
+ changes.imageWidth = config.imageWidth;
1528
+ hasChanges = true;
1529
+ }
1530
+ if (config.imageHeight !== undefined && config.imageHeight !== this.#config.imageHeight) {
1531
+ changes.imageHeight = config.imageHeight;
1532
+ hasChanges = true;
1533
+ }
1534
+ if (config.imageBorderRadius !== undefined && config.imageBorderRadius !== this.#config.imageBorderRadius) {
1535
+ changes.imageBorderRadius = config.imageBorderRadius;
1536
+ hasChanges = true;
1537
+ }
1538
+ if (config.imagePosition !== undefined && config.imagePosition !== this.#config.imagePosition) {
1539
+ changes.imagePosition = config.imagePosition;
1540
+ hasChanges = true;
1541
+ }
1542
+ if (config.labelValign !== undefined && config.labelValign !== this.#config.labelValign) {
1543
+ changes.labelValign = config.labelValign;
1544
+ hasChanges = true;
1545
+ }
1546
+ if (config.labelHalign !== undefined && config.labelHalign !== this.#config.labelHalign) {
1547
+ changes.labelHalign = config.labelHalign;
1548
+ hasChanges = true;
1549
+ }
1550
+
1551
+ if (hasChanges) {
1552
+ Object.assign(this.#configProxy, changes);
1553
+ }
1554
+ }
1400
1555
 
1401
1556
  /**
1402
- * Renders the option view DOM structure (input, optional image, label),
1403
- * sets ARIA attributes/IDs, mounts into parent, and applies initial config.
1557
+ * Renders the option view into the parent element.
1558
+ * Builds the DOM structure (input, optional image, label) based on current config,
1559
+ * assigns classes and ARIA attributes, mounts via Libs.mountView, and marks as rendered
1560
+ * to allow future incremental updates through the config proxy.
1404
1561
  */
1405
1562
  render() {
1406
1563
  const viewClass = ["selective-ui-option-view"];
1407
1564
  const opt_id = Libs.randomString(7);
1408
1565
  const inputID = `option_${opt_id}`;
1409
1566
 
1410
- if (this.isMultiple) {
1567
+ if (this.#config.isMultiple) {
1411
1568
  viewClass.push("multiple");
1412
1569
  }
1413
-
1414
- if (this.hasImage) {
1570
+ if (this.#config.hasImage) {
1415
1571
  viewClass.push("has-image");
1416
- viewClass.push(`image-${this.optionConfig?.imagePosition}`);
1572
+ viewClass.push(`image-${this.#config.imagePosition}`);
1417
1573
  }
1418
1574
 
1419
1575
  const childStructure = {
1420
1576
  OptionInput: {
1421
1577
  tag: {
1422
1578
  node: "input",
1423
- type: this.isMultiple ? "checkbox" : "radio",
1579
+ type: this.#config.isMultiple ? "checkbox" : "radio",
1424
1580
  classList: "allow-choice",
1425
1581
  id: inputID
1426
1582
  }
1427
1583
  },
1428
- ...(this.hasImage && {
1584
+ ...(this.#config.hasImage && {
1429
1585
  OptionImage: {
1430
- tag: {
1431
- node: "img",
1432
- classList: "option-image",
1433
- style: {
1434
- width: this.optionConfig?.imageWidth || "60px",
1435
- height: this.optionConfig?.imageHeight || "60px",
1436
- borderRadius: this.optionConfig?.imageBorderRadius || "4px"
1586
+ tag: {
1587
+ node: "img",
1588
+ classList: "option-image",
1589
+ style: {
1590
+ width: this.#config.imageWidth,
1591
+ height: this.#config.imageHeight,
1592
+ borderRadius: this.#config.imageBorderRadius
1593
+ }
1437
1594
  }
1438
1595
  }
1439
- }
1440
1596
  }),
1441
1597
  OptionLabel: {
1442
1598
  tag: {
1443
- node: "label",
1444
- htmlFor: inputID,
1445
- classList: [
1446
- `align-vertical-${this.optionConfig?.labelValign}`,
1447
- `align-horizontal-${this.optionConfig?.labelHalign}`
1448
- ]
1599
+ node: "label",
1600
+ htmlFor: inputID,
1601
+ classList: [
1602
+ `align-vertical-${this.#config.labelValign}`,
1603
+ `align-horizontal-${this.#config.labelHalign}`
1604
+ ]
1449
1605
  },
1450
1606
  child: {
1451
1607
  LabelContent: { tag: { node: "div" } }
@@ -1468,72 +1624,115 @@
1468
1624
  });
1469
1625
 
1470
1626
  this.parent.appendChild(this.view.view);
1471
-
1472
- this.applyConfigToDOM();
1473
- }
1474
-
1475
- /**
1476
- * Refreshes the option view by reapplying configuration (classes, alignments, image styles).
1477
- */
1478
- update() {
1479
- this.applyConfigToDOM();
1627
+ this.#isRendered = true;
1480
1628
  }
1481
1629
 
1482
1630
  /**
1483
- * Applies current configuration to the DOM in a minimal, fast way:
1484
- * - Set root/label classes in a single assignment (less DOM churn),
1485
- * - Ensure input type matches selection mode,
1486
- * - Create/remove image element only when needed, update its styles.
1631
+ * Applies a targeted DOM update for a single configuration property change.
1632
+ * Safely updates classes, attributes, styles, and child elements without re-rendering the whole view.
1633
+ *
1634
+ * @param {string | symbol} prop - The name of the changed configuration property.
1635
+ * @param {any} newValue - The new value assigned to the property.
1636
+ * @param {any} oldValue - The previous value of the property.
1487
1637
  */
1488
- applyConfigToDOM() {
1638
+ #applyPartialChange(prop, newValue, oldValue) {
1489
1639
  const v = this.view;
1490
1640
  if (!v || !v.view) return;
1491
1641
 
1492
- const root = v.view;
1642
+ const root = v.view;
1493
1643
  const input = v.tags?.OptionInput;
1494
1644
  const label = v.tags?.OptionLabel;
1495
- const isMultiple = !!this.isMultiple;
1496
- const hasImage = !!this.hasImage;
1497
- const imagePos = this.optionConfig?.imagePosition || 'right';
1498
- const imageWidth = this.optionConfig?.imageWidth || '60px';
1499
- const imageHeight = this.optionConfig?.imageHeight || '60px';
1500
- const imageRadius = this.optionConfig?.imageBorderRadius || '4px';
1501
- const vAlign = this.optionConfig?.labelValign || 'center';
1502
- const hAlign = this.optionConfig?.labelHalign || 'left';
1503
1645
 
1504
- const rootClasses = ['selective-ui-option-view'];
1505
- if (isMultiple) rootClasses.push('multiple');
1506
- if (hasImage) {
1507
- rootClasses.push('has-image', `image-${imagePos}`);
1508
- }
1509
- root.className = rootClasses.join(' ');
1646
+ switch(prop) {
1647
+ case 'isMultiple':
1648
+ root.classList.toggle('multiple', newValue);
1649
+
1650
+ if (input && input.type !== (newValue ? 'checkbox' : 'radio')) {
1651
+ input.type = newValue ? 'checkbox' : 'radio';
1652
+ }
1653
+ break;
1510
1654
 
1511
- if (input) {
1512
- const desiredType = isMultiple ? 'checkbox' : 'radio';
1513
- if (input.type !== desiredType) input.type = desiredType;
1514
- }
1655
+ case 'hasImage':
1656
+ root.classList.toggle('has-image', newValue);
1657
+
1658
+ if (newValue) {
1659
+ root.classList.add(`image-${this.#config.imagePosition}`);
1660
+ this.#createImage();
1661
+ } else {
1662
+ root.className = root.className.replace(/image-(top|right|bottom|left)/g, '').trim();
1663
+ const image = v.tags?.OptionImage;
1664
+ if (image) {
1665
+ image.remove();
1666
+ v.tags.OptionImage = null;
1667
+ }
1668
+ }
1669
+ break;
1515
1670
 
1516
- if (label) {
1517
- label.className = `align-vertical-${vAlign} align-horizontal-${hAlign}`;
1671
+ case 'imagePosition':
1672
+ if (this.#config.hasImage) {
1673
+ root.className = root.className.replace(/image-(top|right|bottom|left)/g, '').trim();
1674
+ root.classList.add(`image-${newValue}`);
1675
+ }
1676
+ break;
1677
+
1678
+ case 'imageWidth':
1679
+ case 'imageHeight':
1680
+ case 'imageBorderRadius':
1681
+ const image = v.tags?.OptionImage;
1682
+ if (image) {
1683
+ const styleProp = {
1684
+ 'imageWidth': 'width',
1685
+ 'imageHeight': 'height',
1686
+ 'imageBorderRadius': 'borderRadius'
1687
+ }[prop];
1688
+
1689
+ if (image.style[styleProp] !== newValue) {
1690
+ image.style[styleProp] = newValue;
1691
+ }
1692
+ }
1693
+ break;
1694
+
1695
+ case 'labelValign':
1696
+ case 'labelHalign':
1697
+ if (label) {
1698
+ const newClass = `align-vertical-${this.#config.labelValign} align-horizontal-${this.#config.labelHalign}`;
1699
+ if (label.className !== newClass) {
1700
+ label.className = newClass;
1701
+ }
1702
+ }
1703
+ break;
1518
1704
  }
1705
+ }
1706
+
1707
+ /**
1708
+ * Creates the <img> element for the option on demand and inserts it into the DOM.
1709
+ * Skips creation if the view or root is missing, or if an image already exists.
1710
+ * The image receives configured styles (width, height, borderRadius) and is placed
1711
+ * before the label if present; otherwise appended to the root. Updates `v.tags.OptionImage`.
1712
+ */
1713
+ #createImage() {
1714
+ const v = this.view;
1715
+ if (!v || !v.view) return;
1519
1716
 
1520
1717
  let image = v.tags?.OptionImage;
1521
- if (hasImage) {
1522
- if (!image) {
1523
- image = document.createElement('img');
1524
- image.className = 'option-image';
1525
- if (label && label.parentElement) root.insertBefore(image, label);
1526
- else root.appendChild(image);
1527
- v.tags.OptionImage = image;
1528
- }
1529
- const style = image.style;
1530
- style.width = imageWidth;
1531
- style.height = imageHeight;
1532
- style.borderRadius = imageRadius;
1533
- } else if (image) {
1534
- image.remove();
1535
- v.tags.OptionImage = null;
1718
+ if (image) return;
1719
+
1720
+ const root = v.view;
1721
+ const label = v.tags?.OptionLabel;
1722
+
1723
+ image = document.createElement('img');
1724
+ image.className = 'option-image';
1725
+ image.style.width = this.#config.imageWidth;
1726
+ image.style.height = this.#config.imageHeight;
1727
+ image.style.borderRadius = this.#config.imageBorderRadius;
1728
+
1729
+ if (label && label.parentElement) {
1730
+ root.insertBefore(image, label);
1731
+ } else {
1732
+ root.appendChild(image);
1536
1733
  }
1734
+
1735
+ v.tags.OptionImage = image;
1537
1736
  }
1538
1737
  }
1539
1738
 
@@ -2089,6 +2288,8 @@
2089
2288
  /** @type {RecyclerViewContract<TAdapter>} */
2090
2289
  #privRecyclerViewHandle;
2091
2290
 
2291
+ #lastFingerprint = null;
2292
+
2092
2293
  options = null;
2093
2294
 
2094
2295
  /**
@@ -2118,6 +2319,48 @@
2118
2319
  this.#privRecyclerView = recyclerView;
2119
2320
  }
2120
2321
 
2322
+ /**
2323
+ * Checks whether the provided model data differs from the last recorded fingerprint.
2324
+ * Computes a new fingerprint and compares it to the previous one; if different,
2325
+ * updates the stored fingerprint and returns true, otherwise returns false.
2326
+ *
2327
+ * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data (options/optgroups).
2328
+ * @returns {boolean} True if there are real changes; false otherwise.
2329
+ */
2330
+ hasRealChanges(modelData) {
2331
+ const newFingerprint = this.#createFingerprint(modelData);
2332
+ const hasChanges = newFingerprint !== this.#lastFingerprint;
2333
+
2334
+ if (hasChanges) {
2335
+ this.#lastFingerprint = newFingerprint;
2336
+ }
2337
+
2338
+ return hasChanges;
2339
+ }
2340
+
2341
+ /**
2342
+ * Produces a stable string fingerprint for the given model data.
2343
+ * For <optgroup>, includes the label and a pipe-joined hash of its child options
2344
+ * (value:text:selected). For plain <option>, includes its value, text, and selected state.
2345
+ * The entire list is joined by '||' to form the final fingerprint.
2346
+ *
2347
+ * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data to fingerprint.
2348
+ * @returns {string} A deterministic fingerprint representing the structure and selection state.
2349
+ */
2350
+ #createFingerprint(modelData) {
2351
+ return modelData.map(item => {
2352
+ if (item.tagName === "OPTGROUP") {
2353
+ const optionsHash = Array.from(item.children)
2354
+ .map((/** @type {HTMLOptionElement} */ opt) => `${opt.value}:${opt.text}:${opt.selected}`)
2355
+ .join('|');
2356
+ return `G:${item.label}:${optionsHash}`;
2357
+ } else {
2358
+ const oItem = /** @type {HTMLOptionElement} */ (item);
2359
+ return `O:${oItem.value}:${oItem.text}:${oItem.selected}`;
2360
+ }
2361
+ }).join('||');
2362
+ }
2363
+
2121
2364
  /**
2122
2365
  * Builds model instances (GroupModel/OptionModel) from raw <optgroup>/<option> elements.
2123
2366
  * Preserves grouping relationships and returns the structured list.
@@ -2158,6 +2401,8 @@
2158
2401
  * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - New source elements to rebuild models from.
2159
2402
  */
2160
2403
  replace(modelData) {
2404
+ this.#lastFingerprint = null;
2405
+
2161
2406
  this.createModelResources(modelData);
2162
2407
 
2163
2408
  if (this.#privAdapterHandle) {
@@ -2202,6 +2447,10 @@
2202
2447
  * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - Fresh DOM elements reflecting the latest state.
2203
2448
  */
2204
2449
  update(modelData) {
2450
+ if (!this.hasRealChanges(modelData)) {
2451
+ return;
2452
+ }
2453
+
2205
2454
  const oldModels = this.#privModelList;
2206
2455
  const newModels = [];
2207
2456
 
@@ -2212,20 +2461,29 @@
2212
2461
  if (model instanceof GroupModel) {
2213
2462
  oldGroupMap.set(model.label, model);
2214
2463
  } else if (model instanceof OptionModel) {
2215
- oldOptionMap.set(model.value, model);
2464
+ const key = `${model.value}::${model.textContent}`;
2465
+ oldOptionMap.set(key, model);
2216
2466
  }
2217
2467
  });
2218
2468
 
2219
2469
  let currentGroup = null;
2220
2470
  let position = 0;
2471
+ const changesToApply = [];
2221
2472
 
2222
2473
  modelData.forEach((data, index) => {
2223
2474
  if (data.tagName === "OPTGROUP") {
2224
2475
  let dataVset = /** @type {HTMLOptGroupElement} */ (data);
2225
- const existingGroup = oldGroupMap.get(dataVset.label);
2476
+ const existingGroup = /** @type {GroupModel} */ (oldGroupMap.get(dataVset.label));
2226
2477
 
2227
2478
  if (existingGroup) {
2228
- existingGroup.update(dataVset);
2479
+ const hasLabelChange = existingGroup.label !== dataVset.label;
2480
+
2481
+ if (hasLabelChange) {
2482
+ changesToApply.push(() => {
2483
+ existingGroup.update(dataVset);
2484
+ });
2485
+ }
2486
+
2229
2487
  existingGroup.position = position;
2230
2488
  existingGroup.items = [];
2231
2489
  currentGroup = existingGroup;
@@ -2240,11 +2498,21 @@
2240
2498
  }
2241
2499
  else if (data.tagName === "OPTION") {
2242
2500
  let dataVset = /** @type {HTMLOptionElement} */ (data);
2243
- const existingOption = oldOptionMap.get(dataVset.value);
2501
+ const key = `${dataVset.value}::${dataVset.text}`;
2502
+ const existingOption = /** @type {OptionModel} */ (oldOptionMap.get(key));
2244
2503
 
2245
2504
  if (existingOption) {
2246
- existingOption.update(dataVset);
2247
- existingOption.position = position;
2505
+ const hasSelectedChange = existingOption.selected !== dataVset.selected;
2506
+ const hasPositionChange = existingOption.position !== position;
2507
+
2508
+ if (hasSelectedChange || hasPositionChange) {
2509
+ changesToApply.push(() => {
2510
+ existingOption.update(dataVset);
2511
+ existingOption.position = position;
2512
+ });
2513
+ } else {
2514
+ existingOption.position = position;
2515
+ }
2248
2516
 
2249
2517
  if (dataVset["__parentGroup"] && currentGroup) {
2250
2518
  currentGroup.addItem(existingOption);
@@ -2254,7 +2522,7 @@
2254
2522
  newModels.push(existingOption);
2255
2523
  }
2256
2524
 
2257
- oldOptionMap.delete(dataVset.value);
2525
+ oldOptionMap.delete(key);
2258
2526
  } else {
2259
2527
  const newOption = new OptionModel(this.options, dataVset);
2260
2528
  newOption.position = position;
@@ -2270,6 +2538,12 @@
2270
2538
  }
2271
2539
  });
2272
2540
 
2541
+ if (changesToApply.length > 0) {
2542
+ requestAnimationFrame(() => {
2543
+ changesToApply.forEach(change => change());
2544
+ });
2545
+ }
2546
+
2273
2547
  oldGroupMap.forEach(removedGroup => {
2274
2548
  if (removedGroup.view) {
2275
2549
  removedGroup.view.getView()?.remove();
@@ -2619,8 +2893,6 @@
2619
2893
 
2620
2894
  if (!optionModel.isInit) {
2621
2895
  super.onViewHolder(optionModel, optionViewer, position);
2622
- } else {
2623
- optionViewer.update();
2624
2896
  }
2625
2897
 
2626
2898
  optionModel.view = optionViewer;
@@ -2628,8 +2900,12 @@
2628
2900
  if (optionModel.hasImage) {
2629
2901
  const imageTag = optionViewer.getTag("OptionImage");
2630
2902
  if (imageTag) {
2631
- imageTag.src = optionModel.imageSrc;
2632
- imageTag.alt = optionModel.text;
2903
+ if (imageTag.src != optionModel.imageSrc) {
2904
+ imageTag.src = optionModel.imageSrc;
2905
+ }
2906
+ if (imageTag.alt != optionModel.text) {
2907
+ imageTag.alt = optionModel.text;
2908
+ }
2633
2909
  }
2634
2910
  }
2635
2911
 
@@ -4756,31 +5032,85 @@
4756
5032
 
4757
5033
  #debounceTimer = null;
4758
5034
 
5035
+ #lastSnapshot = null;
5036
+
5037
+ #DEBOUNCE_DELAY = 50;
5038
+
5039
+
4759
5040
  /**
4760
- * Observes a <select> element for option list and attribute changes, with debouncing.
4761
- * Detects modifications to children (options added/removed) and relevant attributes
4762
- * ("selected", "value", "disabled"). Emits updates via the overridable onChanged() hook.
5041
+ * Initializes the SelectObserver for a given <select> element.
5042
+ * Captures the initial snapshot, sets up a MutationObserver, and listens for custom "options:changed" events.
5043
+ * Changes are debounced to prevent excessive calls.
4763
5044
  *
4764
- * @param {HTMLSelectElement} select - The <select> element to monitor.
5045
+ * @param {HTMLSelectElement} select - The <select> element to observe.
4765
5046
  */
4766
5047
  constructor(select) {
5048
+ this.#select = select;
5049
+ this.#lastSnapshot = this.#createSnapshot();
5050
+
4767
5051
  this.#observer = new MutationObserver(() => {
4768
5052
  clearTimeout(this.#debounceTimer);
4769
5053
  this.#debounceTimer = setTimeout(() => {
4770
- this.onChanged(select);
4771
- }, 50);
5054
+ this.#handleChange();
5055
+ }, this.#DEBOUNCE_DELAY);
4772
5056
  });
4773
5057
 
4774
- this.#select = select;
4775
-
4776
5058
  select.addEventListener("options:changed", () => {
4777
- this.onChanged(select);
5059
+ clearTimeout(this.#debounceTimer);
5060
+ this.#debounceTimer = setTimeout(() => {
5061
+ this.#handleChange();
5062
+ }, this.#DEBOUNCE_DELAY);
4778
5063
  });
4779
5064
  }
4780
5065
 
4781
5066
  /**
4782
- * Starts observing the select element for child list mutations and attribute changes.
4783
- * Uses a MutationObserver with a debounce to batch rapid updates.
5067
+ * Creates a snapshot of the current state of the <select> element's options.
5068
+ * The snapshot includes option count, values, texts, and selected states for comparison.
5069
+ *
5070
+ * @returns {{length:number, values:string, texts:string, selected:string}} A snapshot of the options state.
5071
+ */
5072
+ #createSnapshot() {
5073
+ const options = Array.from(this.#select.options);
5074
+ return {
5075
+ length: options.length,
5076
+ values: options.map(opt => opt.value).join(','),
5077
+ texts: options.map(opt => opt.text).join(','),
5078
+ selected: options.map(opt => opt.selected).join(',')
5079
+ };
5080
+ }
5081
+
5082
+ /**
5083
+ * Determines if there has been a real change in the <select> element's options or attributes.
5084
+ * Compares the new snapshot with the previous one and updates the stored snapshot if different.
5085
+ *
5086
+ * @returns {boolean} True if a real change occurred, otherwise false.
5087
+ */
5088
+ #hasRealChange() {
5089
+ const newSnapshot = this.#createSnapshot();
5090
+ const changed = JSON.stringify(newSnapshot) !== JSON.stringify(this.#lastSnapshot);
5091
+
5092
+ if (changed) {
5093
+ this.#lastSnapshot = newSnapshot;
5094
+ }
5095
+
5096
+ return changed;
5097
+ }
5098
+
5099
+ /**
5100
+ * Handles detected changes after debouncing.
5101
+ * If a real change is found, invokes the onChanged() hook with the current <select> element.
5102
+ */
5103
+ #handleChange() {
5104
+ if (!this.#hasRealChange()) {
5105
+ return;
5106
+ }
5107
+
5108
+ this.onChanged(this.#select);
5109
+ }
5110
+
5111
+ /**
5112
+ * Starts observing the <select> element for child list mutations and attribute changes.
5113
+ * Uses MutationObserver with a debounce mechanism to batch rapid updates.
4784
5114
  */
4785
5115
  connect() {
4786
5116
  this.#observer.observe(this.#select, {
@@ -4792,16 +5122,19 @@
4792
5122
  });
4793
5123
  }
4794
5124
 
5125
+
4795
5126
  /**
4796
- * Hook invoked when the select's options or attributes change.
4797
- * Override to handle updates; receives the current HTMLCollection of options.
5127
+ * Hook called when the <select> element's options or attributes change.
5128
+ * Override this method to implement custom update handling logic.
4798
5129
  *
4799
- * @param {HTMLSelectElement} options - The Select element.
5130
+ * @param {HTMLSelectElement} options - The current <select> element.
4800
5131
  */
4801
5132
  onChanged(options) { }
4802
5133
 
5134
+
4803
5135
  /**
4804
- * Stops observing the select element and clears any pending debounce timers.
5136
+ * Stops observing the <select> element and clears any pending debounce timers.
5137
+ * Ensures no further change handling occurs after disconnecting.
4805
5138
  */
4806
5139
  disconnect() {
4807
5140
  clearTimeout(this.#debounceTimer);
@@ -6132,7 +6465,7 @@
6132
6465
  console.log(`[${name}] v${version} loaded successfully`);
6133
6466
  }
6134
6467
 
6135
- const version = "1.0.4";
6468
+ const version = "1.0.5";
6136
6469
  const name = "SelectiveUI";
6137
6470
 
6138
6471
  const alreadyLoaded = checkDuplicate(name);