selective-ui 1.0.3 → 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.1 | 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
+ }
1394
1413
 
1395
1414
  /**
1396
- * Renders the option view DOM structure (input, optional image, label),
1397
- * sets ARIA attributes/IDs, mounts into parent, and applies initial config.
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
+ }
1549
+
1550
+ /**
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
 
@@ -4184,6 +4460,88 @@ class SearchController {
4184
4460
  return !(!this.#ajaxConfig);
4185
4461
  }
4186
4462
 
4463
+ /**
4464
+ * Load specific options by their values from server
4465
+ * @param {string|string[]} values - Values to load
4466
+ * @returns {Promise<{success: boolean, items: Array, message?: string}>}
4467
+ */
4468
+ async loadByValues(values) {
4469
+ if (!this.#ajaxConfig) {
4470
+ return { success: false, items: [], message: "Ajax not configured" };
4471
+ }
4472
+
4473
+ const valuesArray = Array.isArray(values) ? values : [values];
4474
+ if (valuesArray.length === 0) {
4475
+ return { success: true, items: [] };
4476
+ }
4477
+
4478
+ try {
4479
+ const cfg = this.#ajaxConfig;
4480
+
4481
+ let payload;
4482
+ if (typeof cfg.dataByValues === "function") {
4483
+ payload = cfg.dataByValues(valuesArray);
4484
+ } else {
4485
+ payload = {
4486
+ values: valuesArray.join(","),
4487
+ load_by_values: "1",
4488
+ ...(typeof cfg.data === "function" ? cfg.data("", 0) : (cfg.data || {}))
4489
+ };
4490
+ }
4491
+
4492
+ let response;
4493
+ if (cfg.method === "POST") {
4494
+ const formData = new URLSearchParams();
4495
+ Object.keys(payload).forEach(key => {
4496
+ formData.append(key, payload[key]);
4497
+ });
4498
+
4499
+ response = await fetch(cfg.url, {
4500
+ method: "POST",
4501
+ body: formData,
4502
+ headers: { "Content-Type": "application/x-www-form-urlencoded" }
4503
+ });
4504
+ } else {
4505
+ const params = new URLSearchParams(payload).toString();
4506
+ response = await fetch(`${cfg.url}?${params}`);
4507
+ }
4508
+
4509
+ if (!response.ok) {
4510
+ throw new Error(`HTTP error! status: ${response.status}`);
4511
+ }
4512
+
4513
+ const data = await response.json();
4514
+ const result = this.#parseResponse(data);
4515
+
4516
+ return {
4517
+ success: true,
4518
+ items: result.items
4519
+ };
4520
+ } catch (error) {
4521
+ console.error("Load by values error:", error);
4522
+ return {
4523
+ success: false,
4524
+ message: error.message,
4525
+ items: []
4526
+ };
4527
+ }
4528
+ }
4529
+
4530
+ /**
4531
+ * Check if values exist in current options
4532
+ * @param {string[]} values - Values to check
4533
+ * @returns {{existing: string[], missing: string[]}}
4534
+ */
4535
+ checkMissingValues(values) {
4536
+ const allOptions = Array.from(this.#select.options);
4537
+ const existingValues = allOptions.map(opt => opt.value);
4538
+
4539
+ const existing = values.filter(v => existingValues.includes(v));
4540
+ const missing = values.filter(v => !existingValues.includes(v));
4541
+
4542
+ return { existing, missing };
4543
+ }
4544
+
4187
4545
  /**
4188
4546
  * Configures AJAX settings used for remote searching and pagination.
4189
4547
  *
@@ -4668,31 +5026,85 @@ class SelectObserver {
4668
5026
 
4669
5027
  #debounceTimer = null;
4670
5028
 
5029
+ #lastSnapshot = null;
5030
+
5031
+ #DEBOUNCE_DELAY = 50;
5032
+
5033
+
4671
5034
  /**
4672
- * Observes a <select> element for option list and attribute changes, with debouncing.
4673
- * Detects modifications to children (options added/removed) and relevant attributes
4674
- * ("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.
4675
5038
  *
4676
- * @param {HTMLSelectElement} select - The <select> element to monitor.
5039
+ * @param {HTMLSelectElement} select - The <select> element to observe.
4677
5040
  */
4678
5041
  constructor(select) {
5042
+ this.#select = select;
5043
+ this.#lastSnapshot = this.#createSnapshot();
5044
+
4679
5045
  this.#observer = new MutationObserver(() => {
4680
5046
  clearTimeout(this.#debounceTimer);
4681
5047
  this.#debounceTimer = setTimeout(() => {
4682
- this.onChanged(select);
4683
- }, 50);
5048
+ this.#handleChange();
5049
+ }, this.#DEBOUNCE_DELAY);
4684
5050
  });
4685
5051
 
4686
- this.#select = select;
4687
-
4688
5052
  select.addEventListener("options:changed", () => {
4689
- this.onChanged(select);
5053
+ clearTimeout(this.#debounceTimer);
5054
+ this.#debounceTimer = setTimeout(() => {
5055
+ this.#handleChange();
5056
+ }, this.#DEBOUNCE_DELAY);
4690
5057
  });
4691
5058
  }
4692
5059
 
4693
5060
  /**
4694
- * Starts observing the select element for child list mutations and attribute changes.
4695
- * 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.
4696
5108
  */
4697
5109
  connect() {
4698
5110
  this.#observer.observe(this.#select, {
@@ -4704,16 +5116,19 @@ class SelectObserver {
4704
5116
  });
4705
5117
  }
4706
5118
 
5119
+
4707
5120
  /**
4708
- * Hook invoked when the select's options or attributes change.
4709
- * 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.
4710
5123
  *
4711
- * @param {HTMLSelectElement} options - The Select element.
5124
+ * @param {HTMLSelectElement} options - The current <select> element.
4712
5125
  */
4713
5126
  onChanged(options) { }
4714
5127
 
5128
+
4715
5129
  /**
4716
- * 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.
4717
5132
  */
4718
5133
  disconnect() {
4719
5134
  clearTimeout(this.#debounceTimer);
@@ -4916,13 +5331,7 @@ class SelectBox {
4916
5331
  tag: {
4917
5332
  node: "div",
4918
5333
  classList: "selective-ui-view",
4919
- tabIndex: 0,
4920
- role: "combobox",
4921
- ariaExpanded: "false",
4922
- ariaLabelledby: options.SEID_HOLDER,
4923
- ariaControls: options.SEID_LIST,
4924
- ariaHaspopup: "true",
4925
- ariaMultiselectable: options.multiple ? "true" : "false",
5334
+ tabIndex: 0,
4926
5335
  onkeydown: (e) => {
4927
5336
  if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
4928
5337
  e.preventDefault();
@@ -5242,10 +5651,21 @@ class SelectBox {
5242
5651
  },
5243
5652
  setValue(evtToken = null, value, trigger = true, force = false) {
5244
5653
  !Array.isArray(value) && (value = [value]);
5654
+
5655
+ value = value.filter(v => v !== "" && v != null);
5656
+
5657
+ if (value.length === 0) {
5658
+ superThis.getModelOption().forEach(modelOption => {
5659
+ modelOption["selectedNonTrigger"] = false;
5660
+ });
5661
+ this.change(false, trigger);
5662
+ return;
5663
+ }
5245
5664
 
5246
5665
  if (bindedOptions.multiple && bindedOptions.maxSelected > 0) {
5247
5666
  if (value.length > bindedOptions.maxSelected) {
5248
- return
5667
+ console.warn(`Cannot select more than ${bindedOptions.maxSelected} items`);
5668
+ return;
5249
5669
  }
5250
5670
  }
5251
5671
 
@@ -5253,12 +5673,58 @@ class SelectBox {
5253
5673
  return;
5254
5674
  }
5255
5675
 
5676
+ if (container.searchController?.isAjax()) {
5677
+ const { existing, missing } = container.searchController.checkMissingValues(value);
5678
+
5679
+ if (missing.length > 0) {
5680
+ console.log(`Loading ${missing.length} missing values from server...`);
5681
+
5682
+ (async () => {
5683
+ if (bindedOptions.loadingfield) {
5684
+ container.popup?.showLoading();
5685
+ }
5686
+
5687
+ try {
5688
+ const result = await container.searchController.loadByValues(missing);
5689
+
5690
+ if (result.success && result.items.length > 0) {
5691
+ result.items.forEach(item => {
5692
+ if (missing.includes(item.value)) {
5693
+ item.selected = true;
5694
+ }
5695
+ });
5696
+
5697
+ container.searchController['#applyAjaxResult'](
5698
+ result.items,
5699
+ true,
5700
+ true
5701
+ );
5702
+
5703
+ setTimeout(() => {
5704
+ superThis.getModelOption().forEach(modelOption => {
5705
+ modelOption["selectedNonTrigger"] = value.some(v => v == modelOption["value"]);
5706
+ });
5707
+ this.change(false, false);
5708
+ }, 100);
5709
+ } else if (missing.length > 0) {
5710
+ console.warn(`Could not load ${missing.length} values:`, missing);
5711
+ }
5712
+ } catch (error) {
5713
+ console.error("Error loading missing values:", error);
5714
+ } finally {
5715
+ if (bindedOptions.loadingfield) {
5716
+ container.popup?.hideLoading();
5717
+ }
5718
+ }
5719
+ })();
5720
+ }
5721
+ }
5722
+
5256
5723
  if (trigger) {
5257
5724
  const beforeChangeToken = iEvents.callEvent([this], ...bindedOptions.on.beforeChange);
5258
5725
  if (beforeChangeToken.isCancel) {
5259
5726
  return;
5260
5727
  }
5261
-
5262
5728
  superThis.oldValue = this.value;
5263
5729
  }
5264
5730
 
@@ -5266,7 +5732,7 @@ class SelectBox {
5266
5732
  modelOption["selectedNonTrigger"] = value.some(v => v == modelOption["value"]);
5267
5733
  });
5268
5734
 
5269
- if (!bindedOptions.multiple){
5735
+ if (!bindedOptions.multiple && value.length > 0) {
5270
5736
  container.targetElement.value = value[0];
5271
5737
  }
5272
5738
 
@@ -5318,8 +5784,14 @@ class SelectBox {
5318
5784
 
5319
5785
  container.popup.open();
5320
5786
  container.searchbox.show();
5321
-
5322
- container.tags.ViewPanel.setAttribute("aria-expanded", "true");
5787
+ const ViewPanel = /** @type {HTMLElement} */ (container.tags.ViewPanel);
5788
+ ViewPanel.setAttribute("aria-expanded", "true");
5789
+ ViewPanel.setAttribute("aria-controls", bindedOptions.SEID_LIST);
5790
+ ViewPanel.setAttribute("aria-haspopup", "listbox");
5791
+ ViewPanel.setAttribute("aria-labelledby", bindedOptions.SEID_HOLDER);
5792
+ if (bindedOptions.multiple) {
5793
+ ViewPanel.setAttribute("aria-multiselectable", "true");
5794
+ }
5323
5795
 
5324
5796
  iEvents.callEvent([this], ...bindedOptions.on.show);
5325
5797
 
@@ -5987,7 +6459,7 @@ function markLoaded(name, version, api) {
5987
6459
  console.log(`[${name}] v${version} loaded successfully`);
5988
6460
  }
5989
6461
 
5990
- const version = "1.0.3";
6462
+ const version = "1.0.5";
5991
6463
  const name = "SelectiveUI";
5992
6464
 
5993
6465
  const alreadyLoaded = checkDuplicate(name);