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.
- package/dist/selective-ui.esm.js +436 -103
- package/dist/selective-ui.esm.js.map +1 -1
- package/dist/selective-ui.esm.min.js +1 -1
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.js +2 -2
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +436 -103
- package/dist/selective-ui.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/js/adapter/mixed-adapter.js +6 -4
- package/src/js/core/model-manager.js +82 -7
- package/src/js/index.js +1 -1
- package/src/js/services/refresher.js +8 -0
- package/src/js/services/select-observer.js +72 -15
- package/src/js/views/option-view.js +267 -76
package/dist/selective-ui.umd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Selective UI v1.0.
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
|
1403
|
-
*
|
|
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.
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
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
|
-
|
|
1638
|
+
#applyPartialChange(prop, newValue, oldValue) {
|
|
1489
1639
|
const v = this.view;
|
|
1490
1640
|
if (!v || !v.view) return;
|
|
1491
1641
|
|
|
1492
|
-
const root
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
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 (
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2501
|
+
const key = `${dataVset.value}::${dataVset.text}`;
|
|
2502
|
+
const existingOption = /** @type {OptionModel} */ (oldOptionMap.get(key));
|
|
2244
2503
|
|
|
2245
2504
|
if (existingOption) {
|
|
2246
|
-
existingOption.
|
|
2247
|
-
existingOption.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(
|
|
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
|
|
2632
|
-
|
|
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
|
-
*
|
|
4761
|
-
*
|
|
4762
|
-
*
|
|
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
|
|
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
|
|
4771
|
-
},
|
|
5054
|
+
this.#handleChange();
|
|
5055
|
+
}, this.#DEBOUNCE_DELAY);
|
|
4772
5056
|
});
|
|
4773
5057
|
|
|
4774
|
-
this.#select = select;
|
|
4775
|
-
|
|
4776
5058
|
select.addEventListener("options:changed", () => {
|
|
4777
|
-
this
|
|
5059
|
+
clearTimeout(this.#debounceTimer);
|
|
5060
|
+
this.#debounceTimer = setTimeout(() => {
|
|
5061
|
+
this.#handleChange();
|
|
5062
|
+
}, this.#DEBOUNCE_DELAY);
|
|
4778
5063
|
});
|
|
4779
5064
|
}
|
|
4780
5065
|
|
|
4781
5066
|
/**
|
|
4782
|
-
*
|
|
4783
|
-
*
|
|
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
|
|
4797
|
-
* Override
|
|
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
|
|
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.
|
|
6468
|
+
const version = "1.0.5";
|
|
6136
6469
|
const name = "SelectiveUI";
|
|
6137
6470
|
|
|
6138
6471
|
const alreadyLoaded = checkDuplicate(name);
|