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
  /**
3
3
  * @class
4
4
  */
@@ -789,6 +789,14 @@ class Refresher {
789
789
 
790
790
  let width = `${select.offsetWidth}px`,
791
791
  height = `${select.offsetHeight}px`;
792
+
793
+ const getCStyle = getComputedStyle(select);
794
+ if (width == "0px" && getCStyle.width != "auto") {
795
+ width = getCStyle.width;
796
+ }
797
+ if (height == "0px" && getCStyle.height != "auto") {
798
+ height = getCStyle.height;
799
+ }
792
800
 
793
801
  if (cfgWidth > 0) {
794
802
  width = options.width;
@@ -1388,58 +1396,206 @@ class OptionView extends View {
1388
1396
  /** @type {OptionViewResult} */
1389
1397
  view;
1390
1398
 
1391
- isMultiple = false;
1392
- hasImage = false;
1393
- optionConfig = null;
1399
+ #config = null;
1400
+ #configProxy = null;
1401
+ #isRendered = false;
1402
+
1403
+ /**
1404
+ * Initializes the OptionView with a parent container and sets up the reactive config proxy.
1405
+ * The proxy enables partial DOM updates when config properties change after initial render.
1406
+ *
1407
+ * @param {HTMLElement} parent - The parent element into which this view will be mounted.
1408
+ */
1409
+ constructor(parent) {
1410
+ super(parent);
1411
+ this.#setupConfigProxy();
1412
+ }
1413
+
1414
+ /**
1415
+ * Creates the internal configuration object and wraps it with a Proxy.
1416
+ * The proxy intercepts property assignments and, if the view is rendered,
1417
+ * applies only the necessary DOM changes for the updated property.
1418
+ * No DOM mutations occur before the first render.
1419
+ */
1420
+ #setupConfigProxy() {
1421
+ const self = this;
1422
+
1423
+ this.#config = {
1424
+ isMultiple: false,
1425
+ hasImage: false,
1426
+ imagePosition: 'right',
1427
+ imageWidth: '60px',
1428
+ imageHeight: '60px',
1429
+ imageBorderRadius: '4px',
1430
+ labelValign: 'center',
1431
+ labelHalign: 'left'
1432
+ };
1433
+
1434
+ this.#configProxy = new Proxy(this.#config, {
1435
+ set(target, prop, value) {
1436
+ const oldValue = target[prop];
1437
+
1438
+ if (oldValue !== value) {
1439
+ target[prop] = value;
1440
+
1441
+ if (self.#isRendered) {
1442
+ self.#applyPartialChange(prop, value, oldValue);
1443
+ }
1444
+ }
1445
+ return true;
1446
+ }
1447
+ });
1448
+ }
1449
+
1450
+ /**
1451
+ * Indicates whether the option supports multiple selection (checkbox) instead of single (radio).
1452
+ *
1453
+ * @returns {boolean} True if multiple selection is enabled; otherwise false.
1454
+ */
1455
+ get isMultiple() {
1456
+ return this.#config.isMultiple;
1457
+ }
1458
+
1459
+
1460
+ /**
1461
+ * Enables or disables multiple selection mode.
1462
+ * When rendered, toggles the root CSS class and switches the input type between 'checkbox' and 'radio'.
1463
+ *
1464
+ * @param {boolean} value - True to enable multiple selection; false for single selection.
1465
+ */
1466
+ set isMultiple(value) {
1467
+ this.#configProxy.isMultiple = !!value;
1468
+ }
1469
+
1470
+ /**
1471
+ * Indicates whether the option includes an image block alongside the label.
1472
+ *
1473
+ * @returns {boolean} True if an image is displayed; otherwise false.
1474
+ */
1475
+ get hasImage() {
1476
+ return this.#config.hasImage;
1477
+ }
1478
+
1479
+
1480
+ /**
1481
+ * Shows or hides the image block for the option.
1482
+ * When rendered, toggles related CSS classes and creates/removes the image element accordingly.
1483
+ *
1484
+ * @param {boolean} value - True to show the image; false to hide it.
1485
+ */
1486
+ set hasImage(value) {
1487
+ this.#configProxy.hasImage = !!value;
1488
+ }
1489
+
1490
+ /**
1491
+ * Provides reactive access to the entire option configuration via a Proxy.
1492
+ * Mutating properties on this object will trigger partial DOM updates when rendered.
1493
+ *
1494
+ * @returns {object} The proxied configuration object.
1495
+ */
1496
+ get optionConfig() {
1497
+ return this.#configProxy;
1498
+ }
1499
+
1500
+
1501
+ /**
1502
+ * Applies a set of configuration changes in batch.
1503
+ * Only properties that differ from the current config are updated.
1504
+ * When rendered, each changed property triggers a targeted DOM update via the proxy.
1505
+ *
1506
+ * @param {object} config - Partial configuration object.
1507
+ * @param {string} [config.imageWidth] - CSS width of the image (e.g., '60px').
1508
+ * @param {string} [config.imageHeight] - CSS height of the image (e.g., '60px').
1509
+ * @param {string} [config.imageBorderRadius] - CSS border-radius for the image (e.g., '4px').
1510
+ * @param {'top'|'right'|'bottom'|'left'} [config.imagePosition] - Position of the image relative to the label.
1511
+ * @param {'top'|'center'|'bottom'} [config.labelValign] - Vertical alignment of the label.
1512
+ * @param {'left'|'center'|'right'} [config.labelHalign] - Horizontal alignment of the label.
1513
+ */
1514
+ set optionConfig(config) {
1515
+ if (!config) return;
1516
+
1517
+ const changes = {};
1518
+ let hasChanges = false;
1519
+
1520
+ if (config.imageWidth !== undefined && config.imageWidth !== this.#config.imageWidth) {
1521
+ changes.imageWidth = config.imageWidth;
1522
+ hasChanges = true;
1523
+ }
1524
+ if (config.imageHeight !== undefined && config.imageHeight !== this.#config.imageHeight) {
1525
+ changes.imageHeight = config.imageHeight;
1526
+ hasChanges = true;
1527
+ }
1528
+ if (config.imageBorderRadius !== undefined && config.imageBorderRadius !== this.#config.imageBorderRadius) {
1529
+ changes.imageBorderRadius = config.imageBorderRadius;
1530
+ hasChanges = true;
1531
+ }
1532
+ if (config.imagePosition !== undefined && config.imagePosition !== this.#config.imagePosition) {
1533
+ changes.imagePosition = config.imagePosition;
1534
+ hasChanges = true;
1535
+ }
1536
+ if (config.labelValign !== undefined && config.labelValign !== this.#config.labelValign) {
1537
+ changes.labelValign = config.labelValign;
1538
+ hasChanges = true;
1539
+ }
1540
+ if (config.labelHalign !== undefined && config.labelHalign !== this.#config.labelHalign) {
1541
+ changes.labelHalign = config.labelHalign;
1542
+ hasChanges = true;
1543
+ }
1544
+
1545
+ if (hasChanges) {
1546
+ Object.assign(this.#configProxy, changes);
1547
+ }
1548
+ }
1394
1549
 
1395
1550
  /**
1396
- * Renders the option view DOM structure (input, optional image, label),
1397
- * sets ARIA attributes/IDs, mounts into parent, and applies initial config.
1551
+ * Renders the option view into the parent element.
1552
+ * Builds the DOM structure (input, optional image, label) based on current config,
1553
+ * assigns classes and ARIA attributes, mounts via Libs.mountView, and marks as rendered
1554
+ * to allow future incremental updates through the config proxy.
1398
1555
  */
1399
1556
  render() {
1400
1557
  const viewClass = ["selective-ui-option-view"];
1401
1558
  const opt_id = Libs.randomString(7);
1402
1559
  const inputID = `option_${opt_id}`;
1403
1560
 
1404
- if (this.isMultiple) {
1561
+ if (this.#config.isMultiple) {
1405
1562
  viewClass.push("multiple");
1406
1563
  }
1407
-
1408
- if (this.hasImage) {
1564
+ if (this.#config.hasImage) {
1409
1565
  viewClass.push("has-image");
1410
- viewClass.push(`image-${this.optionConfig?.imagePosition}`);
1566
+ viewClass.push(`image-${this.#config.imagePosition}`);
1411
1567
  }
1412
1568
 
1413
1569
  const childStructure = {
1414
1570
  OptionInput: {
1415
1571
  tag: {
1416
1572
  node: "input",
1417
- type: this.isMultiple ? "checkbox" : "radio",
1573
+ type: this.#config.isMultiple ? "checkbox" : "radio",
1418
1574
  classList: "allow-choice",
1419
1575
  id: inputID
1420
1576
  }
1421
1577
  },
1422
- ...(this.hasImage && {
1578
+ ...(this.#config.hasImage && {
1423
1579
  OptionImage: {
1424
- tag: {
1425
- node: "img",
1426
- classList: "option-image",
1427
- style: {
1428
- width: this.optionConfig?.imageWidth || "60px",
1429
- height: this.optionConfig?.imageHeight || "60px",
1430
- borderRadius: this.optionConfig?.imageBorderRadius || "4px"
1580
+ tag: {
1581
+ node: "img",
1582
+ classList: "option-image",
1583
+ style: {
1584
+ width: this.#config.imageWidth,
1585
+ height: this.#config.imageHeight,
1586
+ borderRadius: this.#config.imageBorderRadius
1587
+ }
1431
1588
  }
1432
1589
  }
1433
- }
1434
1590
  }),
1435
1591
  OptionLabel: {
1436
1592
  tag: {
1437
- node: "label",
1438
- htmlFor: inputID,
1439
- classList: [
1440
- `align-vertical-${this.optionConfig?.labelValign}`,
1441
- `align-horizontal-${this.optionConfig?.labelHalign}`
1442
- ]
1593
+ node: "label",
1594
+ htmlFor: inputID,
1595
+ classList: [
1596
+ `align-vertical-${this.#config.labelValign}`,
1597
+ `align-horizontal-${this.#config.labelHalign}`
1598
+ ]
1443
1599
  },
1444
1600
  child: {
1445
1601
  LabelContent: { tag: { node: "div" } }
@@ -1462,72 +1618,115 @@ class OptionView extends View {
1462
1618
  });
1463
1619
 
1464
1620
  this.parent.appendChild(this.view.view);
1465
-
1466
- this.applyConfigToDOM();
1467
- }
1468
-
1469
- /**
1470
- * Refreshes the option view by reapplying configuration (classes, alignments, image styles).
1471
- */
1472
- update() {
1473
- this.applyConfigToDOM();
1621
+ this.#isRendered = true;
1474
1622
  }
1475
1623
 
1476
1624
  /**
1477
- * Applies current configuration to the DOM in a minimal, fast way:
1478
- * - Set root/label classes in a single assignment (less DOM churn),
1479
- * - Ensure input type matches selection mode,
1480
- * - Create/remove image element only when needed, update its styles.
1625
+ * Applies a targeted DOM update for a single configuration property change.
1626
+ * Safely updates classes, attributes, styles, and child elements without re-rendering the whole view.
1627
+ *
1628
+ * @param {string | symbol} prop - The name of the changed configuration property.
1629
+ * @param {any} newValue - The new value assigned to the property.
1630
+ * @param {any} oldValue - The previous value of the property.
1481
1631
  */
1482
- applyConfigToDOM() {
1632
+ #applyPartialChange(prop, newValue, oldValue) {
1483
1633
  const v = this.view;
1484
1634
  if (!v || !v.view) return;
1485
1635
 
1486
- const root = v.view;
1636
+ const root = v.view;
1487
1637
  const input = v.tags?.OptionInput;
1488
1638
  const label = v.tags?.OptionLabel;
1489
- const isMultiple = !!this.isMultiple;
1490
- const hasImage = !!this.hasImage;
1491
- const imagePos = this.optionConfig?.imagePosition || 'right';
1492
- const imageWidth = this.optionConfig?.imageWidth || '60px';
1493
- const imageHeight = this.optionConfig?.imageHeight || '60px';
1494
- const imageRadius = this.optionConfig?.imageBorderRadius || '4px';
1495
- const vAlign = this.optionConfig?.labelValign || 'center';
1496
- const hAlign = this.optionConfig?.labelHalign || 'left';
1497
1639
 
1498
- const rootClasses = ['selective-ui-option-view'];
1499
- if (isMultiple) rootClasses.push('multiple');
1500
- if (hasImage) {
1501
- rootClasses.push('has-image', `image-${imagePos}`);
1502
- }
1503
- root.className = rootClasses.join(' ');
1640
+ switch(prop) {
1641
+ case 'isMultiple':
1642
+ root.classList.toggle('multiple', newValue);
1643
+
1644
+ if (input && input.type !== (newValue ? 'checkbox' : 'radio')) {
1645
+ input.type = newValue ? 'checkbox' : 'radio';
1646
+ }
1647
+ break;
1504
1648
 
1505
- if (input) {
1506
- const desiredType = isMultiple ? 'checkbox' : 'radio';
1507
- if (input.type !== desiredType) input.type = desiredType;
1508
- }
1649
+ case 'hasImage':
1650
+ root.classList.toggle('has-image', newValue);
1651
+
1652
+ if (newValue) {
1653
+ root.classList.add(`image-${this.#config.imagePosition}`);
1654
+ this.#createImage();
1655
+ } else {
1656
+ root.className = root.className.replace(/image-(top|right|bottom|left)/g, '').trim();
1657
+ const image = v.tags?.OptionImage;
1658
+ if (image) {
1659
+ image.remove();
1660
+ v.tags.OptionImage = null;
1661
+ }
1662
+ }
1663
+ break;
1509
1664
 
1510
- if (label) {
1511
- label.className = `align-vertical-${vAlign} align-horizontal-${hAlign}`;
1665
+ case 'imagePosition':
1666
+ if (this.#config.hasImage) {
1667
+ root.className = root.className.replace(/image-(top|right|bottom|left)/g, '').trim();
1668
+ root.classList.add(`image-${newValue}`);
1669
+ }
1670
+ break;
1671
+
1672
+ case 'imageWidth':
1673
+ case 'imageHeight':
1674
+ case 'imageBorderRadius':
1675
+ const image = v.tags?.OptionImage;
1676
+ if (image) {
1677
+ const styleProp = {
1678
+ 'imageWidth': 'width',
1679
+ 'imageHeight': 'height',
1680
+ 'imageBorderRadius': 'borderRadius'
1681
+ }[prop];
1682
+
1683
+ if (image.style[styleProp] !== newValue) {
1684
+ image.style[styleProp] = newValue;
1685
+ }
1686
+ }
1687
+ break;
1688
+
1689
+ case 'labelValign':
1690
+ case 'labelHalign':
1691
+ if (label) {
1692
+ const newClass = `align-vertical-${this.#config.labelValign} align-horizontal-${this.#config.labelHalign}`;
1693
+ if (label.className !== newClass) {
1694
+ label.className = newClass;
1695
+ }
1696
+ }
1697
+ break;
1512
1698
  }
1699
+ }
1700
+
1701
+ /**
1702
+ * Creates the <img> element for the option on demand and inserts it into the DOM.
1703
+ * Skips creation if the view or root is missing, or if an image already exists.
1704
+ * The image receives configured styles (width, height, borderRadius) and is placed
1705
+ * before the label if present; otherwise appended to the root. Updates `v.tags.OptionImage`.
1706
+ */
1707
+ #createImage() {
1708
+ const v = this.view;
1709
+ if (!v || !v.view) return;
1513
1710
 
1514
1711
  let image = v.tags?.OptionImage;
1515
- if (hasImage) {
1516
- if (!image) {
1517
- image = document.createElement('img');
1518
- image.className = 'option-image';
1519
- if (label && label.parentElement) root.insertBefore(image, label);
1520
- else root.appendChild(image);
1521
- v.tags.OptionImage = image;
1522
- }
1523
- const style = image.style;
1524
- style.width = imageWidth;
1525
- style.height = imageHeight;
1526
- style.borderRadius = imageRadius;
1527
- } else if (image) {
1528
- image.remove();
1529
- v.tags.OptionImage = null;
1712
+ if (image) return;
1713
+
1714
+ const root = v.view;
1715
+ const label = v.tags?.OptionLabel;
1716
+
1717
+ image = document.createElement('img');
1718
+ image.className = 'option-image';
1719
+ image.style.width = this.#config.imageWidth;
1720
+ image.style.height = this.#config.imageHeight;
1721
+ image.style.borderRadius = this.#config.imageBorderRadius;
1722
+
1723
+ if (label && label.parentElement) {
1724
+ root.insertBefore(image, label);
1725
+ } else {
1726
+ root.appendChild(image);
1530
1727
  }
1728
+
1729
+ v.tags.OptionImage = image;
1531
1730
  }
1532
1731
  }
1533
1732
 
@@ -2083,6 +2282,8 @@ class ModelManager {
2083
2282
  /** @type {RecyclerViewContract<TAdapter>} */
2084
2283
  #privRecyclerViewHandle;
2085
2284
 
2285
+ #lastFingerprint = null;
2286
+
2086
2287
  options = null;
2087
2288
 
2088
2289
  /**
@@ -2112,6 +2313,48 @@ class ModelManager {
2112
2313
  this.#privRecyclerView = recyclerView;
2113
2314
  }
2114
2315
 
2316
+ /**
2317
+ * Checks whether the provided model data differs from the last recorded fingerprint.
2318
+ * Computes a new fingerprint and compares it to the previous one; if different,
2319
+ * updates the stored fingerprint and returns true, otherwise returns false.
2320
+ *
2321
+ * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data (options/optgroups).
2322
+ * @returns {boolean} True if there are real changes; false otherwise.
2323
+ */
2324
+ hasRealChanges(modelData) {
2325
+ const newFingerprint = this.#createFingerprint(modelData);
2326
+ const hasChanges = newFingerprint !== this.#lastFingerprint;
2327
+
2328
+ if (hasChanges) {
2329
+ this.#lastFingerprint = newFingerprint;
2330
+ }
2331
+
2332
+ return hasChanges;
2333
+ }
2334
+
2335
+ /**
2336
+ * Produces a stable string fingerprint for the given model data.
2337
+ * For <optgroup>, includes the label and a pipe-joined hash of its child options
2338
+ * (value:text:selected). For plain <option>, includes its value, text, and selected state.
2339
+ * The entire list is joined by '||' to form the final fingerprint.
2340
+ *
2341
+ * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data to fingerprint.
2342
+ * @returns {string} A deterministic fingerprint representing the structure and selection state.
2343
+ */
2344
+ #createFingerprint(modelData) {
2345
+ return modelData.map(item => {
2346
+ if (item.tagName === "OPTGROUP") {
2347
+ const optionsHash = Array.from(item.children)
2348
+ .map((/** @type {HTMLOptionElement} */ opt) => `${opt.value}:${opt.text}:${opt.selected}`)
2349
+ .join('|');
2350
+ return `G:${item.label}:${optionsHash}`;
2351
+ } else {
2352
+ const oItem = /** @type {HTMLOptionElement} */ (item);
2353
+ return `O:${oItem.value}:${oItem.text}:${oItem.selected}`;
2354
+ }
2355
+ }).join('||');
2356
+ }
2357
+
2115
2358
  /**
2116
2359
  * Builds model instances (GroupModel/OptionModel) from raw <optgroup>/<option> elements.
2117
2360
  * Preserves grouping relationships and returns the structured list.
@@ -2152,6 +2395,8 @@ class ModelManager {
2152
2395
  * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - New source elements to rebuild models from.
2153
2396
  */
2154
2397
  replace(modelData) {
2398
+ this.#lastFingerprint = null;
2399
+
2155
2400
  this.createModelResources(modelData);
2156
2401
 
2157
2402
  if (this.#privAdapterHandle) {
@@ -2196,6 +2441,10 @@ class ModelManager {
2196
2441
  * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - Fresh DOM elements reflecting the latest state.
2197
2442
  */
2198
2443
  update(modelData) {
2444
+ if (!this.hasRealChanges(modelData)) {
2445
+ return;
2446
+ }
2447
+
2199
2448
  const oldModels = this.#privModelList;
2200
2449
  const newModels = [];
2201
2450
 
@@ -2206,20 +2455,29 @@ class ModelManager {
2206
2455
  if (model instanceof GroupModel) {
2207
2456
  oldGroupMap.set(model.label, model);
2208
2457
  } else if (model instanceof OptionModel) {
2209
- oldOptionMap.set(model.value, model);
2458
+ const key = `${model.value}::${model.textContent}`;
2459
+ oldOptionMap.set(key, model);
2210
2460
  }
2211
2461
  });
2212
2462
 
2213
2463
  let currentGroup = null;
2214
2464
  let position = 0;
2465
+ const changesToApply = [];
2215
2466
 
2216
2467
  modelData.forEach((data, index) => {
2217
2468
  if (data.tagName === "OPTGROUP") {
2218
2469
  let dataVset = /** @type {HTMLOptGroupElement} */ (data);
2219
- const existingGroup = oldGroupMap.get(dataVset.label);
2470
+ const existingGroup = /** @type {GroupModel} */ (oldGroupMap.get(dataVset.label));
2220
2471
 
2221
2472
  if (existingGroup) {
2222
- existingGroup.update(dataVset);
2473
+ const hasLabelChange = existingGroup.label !== dataVset.label;
2474
+
2475
+ if (hasLabelChange) {
2476
+ changesToApply.push(() => {
2477
+ existingGroup.update(dataVset);
2478
+ });
2479
+ }
2480
+
2223
2481
  existingGroup.position = position;
2224
2482
  existingGroup.items = [];
2225
2483
  currentGroup = existingGroup;
@@ -2234,11 +2492,21 @@ class ModelManager {
2234
2492
  }
2235
2493
  else if (data.tagName === "OPTION") {
2236
2494
  let dataVset = /** @type {HTMLOptionElement} */ (data);
2237
- const existingOption = oldOptionMap.get(dataVset.value);
2495
+ const key = `${dataVset.value}::${dataVset.text}`;
2496
+ const existingOption = /** @type {OptionModel} */ (oldOptionMap.get(key));
2238
2497
 
2239
2498
  if (existingOption) {
2240
- existingOption.update(dataVset);
2241
- existingOption.position = position;
2499
+ const hasSelectedChange = existingOption.selected !== dataVset.selected;
2500
+ const hasPositionChange = existingOption.position !== position;
2501
+
2502
+ if (hasSelectedChange || hasPositionChange) {
2503
+ changesToApply.push(() => {
2504
+ existingOption.update(dataVset);
2505
+ existingOption.position = position;
2506
+ });
2507
+ } else {
2508
+ existingOption.position = position;
2509
+ }
2242
2510
 
2243
2511
  if (dataVset["__parentGroup"] && currentGroup) {
2244
2512
  currentGroup.addItem(existingOption);
@@ -2248,7 +2516,7 @@ class ModelManager {
2248
2516
  newModels.push(existingOption);
2249
2517
  }
2250
2518
 
2251
- oldOptionMap.delete(dataVset.value);
2519
+ oldOptionMap.delete(key);
2252
2520
  } else {
2253
2521
  const newOption = new OptionModel(this.options, dataVset);
2254
2522
  newOption.position = position;
@@ -2264,6 +2532,12 @@ class ModelManager {
2264
2532
  }
2265
2533
  });
2266
2534
 
2535
+ if (changesToApply.length > 0) {
2536
+ requestAnimationFrame(() => {
2537
+ changesToApply.forEach(change => change());
2538
+ });
2539
+ }
2540
+
2267
2541
  oldGroupMap.forEach(removedGroup => {
2268
2542
  if (removedGroup.view) {
2269
2543
  removedGroup.view.getView()?.remove();
@@ -2613,8 +2887,6 @@ class MixedAdapter extends Adapter {
2613
2887
 
2614
2888
  if (!optionModel.isInit) {
2615
2889
  super.onViewHolder(optionModel, optionViewer, position);
2616
- } else {
2617
- optionViewer.update();
2618
2890
  }
2619
2891
 
2620
2892
  optionModel.view = optionViewer;
@@ -2622,8 +2894,12 @@ class MixedAdapter extends Adapter {
2622
2894
  if (optionModel.hasImage) {
2623
2895
  const imageTag = optionViewer.getTag("OptionImage");
2624
2896
  if (imageTag) {
2625
- imageTag.src = optionModel.imageSrc;
2626
- imageTag.alt = optionModel.text;
2897
+ if (imageTag.src != optionModel.imageSrc) {
2898
+ imageTag.src = optionModel.imageSrc;
2899
+ }
2900
+ if (imageTag.alt != optionModel.text) {
2901
+ imageTag.alt = optionModel.text;
2902
+ }
2627
2903
  }
2628
2904
  }
2629
2905
 
@@ -4750,31 +5026,85 @@ class SelectObserver {
4750
5026
 
4751
5027
  #debounceTimer = null;
4752
5028
 
5029
+ #lastSnapshot = null;
5030
+
5031
+ #DEBOUNCE_DELAY = 50;
5032
+
5033
+
4753
5034
  /**
4754
- * Observes a <select> element for option list and attribute changes, with debouncing.
4755
- * Detects modifications to children (options added/removed) and relevant attributes
4756
- * ("selected", "value", "disabled"). Emits updates via the overridable onChanged() hook.
5035
+ * Initializes the SelectObserver for a given <select> element.
5036
+ * Captures the initial snapshot, sets up a MutationObserver, and listens for custom "options:changed" events.
5037
+ * Changes are debounced to prevent excessive calls.
4757
5038
  *
4758
- * @param {HTMLSelectElement} select - The <select> element to monitor.
5039
+ * @param {HTMLSelectElement} select - The <select> element to observe.
4759
5040
  */
4760
5041
  constructor(select) {
5042
+ this.#select = select;
5043
+ this.#lastSnapshot = this.#createSnapshot();
5044
+
4761
5045
  this.#observer = new MutationObserver(() => {
4762
5046
  clearTimeout(this.#debounceTimer);
4763
5047
  this.#debounceTimer = setTimeout(() => {
4764
- this.onChanged(select);
4765
- }, 50);
5048
+ this.#handleChange();
5049
+ }, this.#DEBOUNCE_DELAY);
4766
5050
  });
4767
5051
 
4768
- this.#select = select;
4769
-
4770
5052
  select.addEventListener("options:changed", () => {
4771
- this.onChanged(select);
5053
+ clearTimeout(this.#debounceTimer);
5054
+ this.#debounceTimer = setTimeout(() => {
5055
+ this.#handleChange();
5056
+ }, this.#DEBOUNCE_DELAY);
4772
5057
  });
4773
5058
  }
4774
5059
 
4775
5060
  /**
4776
- * Starts observing the select element for child list mutations and attribute changes.
4777
- * Uses a MutationObserver with a debounce to batch rapid updates.
5061
+ * Creates a snapshot of the current state of the <select> element's options.
5062
+ * The snapshot includes option count, values, texts, and selected states for comparison.
5063
+ *
5064
+ * @returns {{length:number, values:string, texts:string, selected:string}} A snapshot of the options state.
5065
+ */
5066
+ #createSnapshot() {
5067
+ const options = Array.from(this.#select.options);
5068
+ return {
5069
+ length: options.length,
5070
+ values: options.map(opt => opt.value).join(','),
5071
+ texts: options.map(opt => opt.text).join(','),
5072
+ selected: options.map(opt => opt.selected).join(',')
5073
+ };
5074
+ }
5075
+
5076
+ /**
5077
+ * Determines if there has been a real change in the <select> element's options or attributes.
5078
+ * Compares the new snapshot with the previous one and updates the stored snapshot if different.
5079
+ *
5080
+ * @returns {boolean} True if a real change occurred, otherwise false.
5081
+ */
5082
+ #hasRealChange() {
5083
+ const newSnapshot = this.#createSnapshot();
5084
+ const changed = JSON.stringify(newSnapshot) !== JSON.stringify(this.#lastSnapshot);
5085
+
5086
+ if (changed) {
5087
+ this.#lastSnapshot = newSnapshot;
5088
+ }
5089
+
5090
+ return changed;
5091
+ }
5092
+
5093
+ /**
5094
+ * Handles detected changes after debouncing.
5095
+ * If a real change is found, invokes the onChanged() hook with the current <select> element.
5096
+ */
5097
+ #handleChange() {
5098
+ if (!this.#hasRealChange()) {
5099
+ return;
5100
+ }
5101
+
5102
+ this.onChanged(this.#select);
5103
+ }
5104
+
5105
+ /**
5106
+ * Starts observing the <select> element for child list mutations and attribute changes.
5107
+ * Uses MutationObserver with a debounce mechanism to batch rapid updates.
4778
5108
  */
4779
5109
  connect() {
4780
5110
  this.#observer.observe(this.#select, {
@@ -4786,16 +5116,19 @@ class SelectObserver {
4786
5116
  });
4787
5117
  }
4788
5118
 
5119
+
4789
5120
  /**
4790
- * Hook invoked when the select's options or attributes change.
4791
- * Override to handle updates; receives the current HTMLCollection of options.
5121
+ * Hook called when the <select> element's options or attributes change.
5122
+ * Override this method to implement custom update handling logic.
4792
5123
  *
4793
- * @param {HTMLSelectElement} options - The Select element.
5124
+ * @param {HTMLSelectElement} options - The current <select> element.
4794
5125
  */
4795
5126
  onChanged(options) { }
4796
5127
 
5128
+
4797
5129
  /**
4798
- * Stops observing the select element and clears any pending debounce timers.
5130
+ * Stops observing the <select> element and clears any pending debounce timers.
5131
+ * Ensures no further change handling occurs after disconnecting.
4799
5132
  */
4800
5133
  disconnect() {
4801
5134
  clearTimeout(this.#debounceTimer);
@@ -6126,7 +6459,7 @@ function markLoaded(name, version, api) {
6126
6459
  console.log(`[${name}] v${version} loaded successfully`);
6127
6460
  }
6128
6461
 
6129
- const version = "1.0.4";
6462
+ const version = "1.0.5";
6130
6463
  const name = "SelectiveUI";
6131
6464
 
6132
6465
  const alreadyLoaded = checkDuplicate(name);