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.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Selective UI v1.0.
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
|
1397
|
-
*
|
|
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.
|
|
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
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
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
|
-
|
|
1632
|
+
#applyPartialChange(prop, newValue, oldValue) {
|
|
1483
1633
|
const v = this.view;
|
|
1484
1634
|
if (!v || !v.view) return;
|
|
1485
1635
|
|
|
1486
|
-
const root
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
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 (
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2495
|
+
const key = `${dataVset.value}::${dataVset.text}`;
|
|
2496
|
+
const existingOption = /** @type {OptionModel} */ (oldOptionMap.get(key));
|
|
2238
2497
|
|
|
2239
2498
|
if (existingOption) {
|
|
2240
|
-
existingOption.
|
|
2241
|
-
existingOption.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(
|
|
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
|
|
2626
|
-
|
|
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
|
-
*
|
|
4755
|
-
*
|
|
4756
|
-
*
|
|
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
|
|
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
|
|
4765
|
-
},
|
|
5048
|
+
this.#handleChange();
|
|
5049
|
+
}, this.#DEBOUNCE_DELAY);
|
|
4766
5050
|
});
|
|
4767
5051
|
|
|
4768
|
-
this.#select = select;
|
|
4769
|
-
|
|
4770
5052
|
select.addEventListener("options:changed", () => {
|
|
4771
|
-
this
|
|
5053
|
+
clearTimeout(this.#debounceTimer);
|
|
5054
|
+
this.#debounceTimer = setTimeout(() => {
|
|
5055
|
+
this.#handleChange();
|
|
5056
|
+
}, this.#DEBOUNCE_DELAY);
|
|
4772
5057
|
});
|
|
4773
5058
|
}
|
|
4774
5059
|
|
|
4775
5060
|
/**
|
|
4776
|
-
*
|
|
4777
|
-
*
|
|
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
|
|
4791
|
-
* Override
|
|
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
|
|
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.
|
|
6462
|
+
const version = "1.0.5";
|
|
6130
6463
|
const name = "SelectiveUI";
|
|
6131
6464
|
|
|
6132
6465
|
const alreadyLoaded = checkDuplicate(name);
|