selective-ui 1.0.2
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/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/selective-ui.css +569 -0
- package/dist/selective-ui.css.map +1 -0
- package/dist/selective-ui.esm.js +6101 -0
- package/dist/selective-ui.esm.js.map +1 -0
- package/dist/selective-ui.esm.min.js +1 -0
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.css +1 -0
- package/dist/selective-ui.min.css.br +0 -0
- package/dist/selective-ui.min.js +2 -0
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +6115 -0
- package/dist/selective-ui.umd.js.map +1 -0
- package/package.json +68 -0
- package/src/css/components/accessorybox.css +64 -0
- package/src/css/components/directive.css +20 -0
- package/src/css/components/empty-state.css +26 -0
- package/src/css/components/loading-state.css +26 -0
- package/src/css/components/optgroup.css +62 -0
- package/src/css/components/option-handle.css +34 -0
- package/src/css/components/option.css +130 -0
- package/src/css/components/placeholder.css +15 -0
- package/src/css/components/popup.css +39 -0
- package/src/css/components/searchbox.css +29 -0
- package/src/css/components/selectbox.css +54 -0
- package/src/css/index.css +75 -0
- package/src/js/adapter/mixed-adapter.js +435 -0
- package/src/js/components/accessorybox.js +125 -0
- package/src/js/components/directive.js +38 -0
- package/src/js/components/empty-state.js +68 -0
- package/src/js/components/loading-state.js +60 -0
- package/src/js/components/option-handle.js +114 -0
- package/src/js/components/placeholder.js +57 -0
- package/src/js/components/popup.js +471 -0
- package/src/js/components/searchbox.js +168 -0
- package/src/js/components/selectbox.js +693 -0
- package/src/js/core/base/adapter.js +163 -0
- package/src/js/core/base/model.js +59 -0
- package/src/js/core/base/recyclerview.js +83 -0
- package/src/js/core/base/view.js +62 -0
- package/src/js/core/model-manager.js +286 -0
- package/src/js/core/search-controller.js +522 -0
- package/src/js/index.js +137 -0
- package/src/js/models/group-model.js +143 -0
- package/src/js/models/option-model.js +237 -0
- package/src/js/services/dataset-observer.js +73 -0
- package/src/js/services/ea-observer.js +88 -0
- package/src/js/services/effector.js +404 -0
- package/src/js/services/refresher.js +40 -0
- package/src/js/services/resize-observer.js +152 -0
- package/src/js/services/select-observer.js +61 -0
- package/src/js/types/adapter.type.js +33 -0
- package/src/js/types/effector.type.js +24 -0
- package/src/js/types/ievents.type.js +11 -0
- package/src/js/types/libs.type.js +28 -0
- package/src/js/types/model.type.js +11 -0
- package/src/js/types/recyclerview.type.js +12 -0
- package/src/js/types/resize-observer.type.js +19 -0
- package/src/js/types/view.group.type.js +13 -0
- package/src/js/types/view.option.type.js +15 -0
- package/src/js/types/view.type.js +11 -0
- package/src/js/utils/guard.js +47 -0
- package/src/js/utils/ievents.js +83 -0
- package/src/js/utils/istorage.js +61 -0
- package/src/js/utils/libs.js +619 -0
- package/src/js/utils/selective.js +386 -0
- package/src/js/views/group-view.js +103 -0
- package/src/js/views/option-view.js +153 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Model } from "../core/base/model";
|
|
2
|
+
import { iEvents } from "../utils/ievents";
|
|
3
|
+
import { Libs } from "../utils/libs";
|
|
4
|
+
import { GroupView } from "../views/group-view";
|
|
5
|
+
import { OptionModel } from "./option-model";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @extends {Model<HTMLOptGroupElement, GroupViewTags, GroupView>}
|
|
9
|
+
*/
|
|
10
|
+
export class GroupModel extends Model {
|
|
11
|
+
label = "";
|
|
12
|
+
/** @type {OptionModel[]} */
|
|
13
|
+
items = [];
|
|
14
|
+
collapsed = false;
|
|
15
|
+
#privOnCollapsedChanged = [];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initializes a group model with options and an optional <optgroup> target.
|
|
19
|
+
* Reads the label and collapsed state from the target element's attributes/dataset.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} options - Configuration for the model.
|
|
22
|
+
* @param {HTMLOptGroupElement} [targetElement] - The source <optgroup> element.
|
|
23
|
+
*/
|
|
24
|
+
constructor(options, targetElement) {
|
|
25
|
+
super(options, targetElement);
|
|
26
|
+
if (targetElement) {
|
|
27
|
+
this.label = targetElement.label;
|
|
28
|
+
this.collapsed = Libs.string2Boolean(targetElement.dataset?.collapsed);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns the array of values from all option items within the group.
|
|
34
|
+
*
|
|
35
|
+
* @type {String[]}
|
|
36
|
+
*/
|
|
37
|
+
get value() {
|
|
38
|
+
return this.items.map(item => item.value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns the list of option items currently selected within the group.
|
|
43
|
+
*
|
|
44
|
+
* @type {OptionModel[]}
|
|
45
|
+
*/
|
|
46
|
+
get selectedItems() {
|
|
47
|
+
return this.items.filter(item => item.selected);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns the list of option items currently visible within the group.
|
|
52
|
+
*
|
|
53
|
+
* @type {OptionModel[]}
|
|
54
|
+
*/
|
|
55
|
+
get visibleItems() {
|
|
56
|
+
return this.items.filter(item => item.visible);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Indicates whether the group has at least one visible option item.
|
|
61
|
+
*
|
|
62
|
+
* @type {boolean}
|
|
63
|
+
*/
|
|
64
|
+
get hasVisibleItems() {
|
|
65
|
+
return this.visibleItems.length > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Updates the group's label from the new target element and propagates the change to the view.
|
|
70
|
+
*
|
|
71
|
+
* @param {HTMLOptGroupElement} targetElement - The updated <optgroup> element.
|
|
72
|
+
*/
|
|
73
|
+
update(targetElement) {
|
|
74
|
+
this.label = targetElement.label;
|
|
75
|
+
if (this.view) {
|
|
76
|
+
this.view.updateLabel(this.label);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hook invoked when the target element reference changes.
|
|
82
|
+
* Updates the view's label and collapsed state to keep UI in sync.
|
|
83
|
+
*/
|
|
84
|
+
onTargetChanged() {
|
|
85
|
+
if (this.view) {
|
|
86
|
+
this.view.updateLabel(this.label);
|
|
87
|
+
this.view.setCollapsed(this.collapsed);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Registers a callback to be invoked when the group's collapsed state changes.
|
|
93
|
+
*
|
|
94
|
+
* @param {(evtToken: any, model: GroupModel, collapsed: boolean) => void} callback - Listener for collapse changes.
|
|
95
|
+
*/
|
|
96
|
+
onCollapsedChanged(callback) {
|
|
97
|
+
this.#privOnCollapsedChanged.push(callback);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Toggles the group's collapsed state, updates the view, and notifies registered listeners.
|
|
102
|
+
*/
|
|
103
|
+
toggleCollapse() {
|
|
104
|
+
this.collapsed = !this.collapsed;
|
|
105
|
+
if (this.view) {
|
|
106
|
+
this.view.setCollapsed(this.collapsed);
|
|
107
|
+
}
|
|
108
|
+
iEvents.callEvent([this, this.collapsed], ...this.#privOnCollapsedChanged);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Adds an option item to this group and sets its back-reference to the group.
|
|
113
|
+
*
|
|
114
|
+
* @param {OptionModel} optionModel - The option to add.
|
|
115
|
+
*/
|
|
116
|
+
addItem(optionModel) {
|
|
117
|
+
this.items.push(optionModel);
|
|
118
|
+
optionModel.group = this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Removes an option item from this group and clears its group reference.
|
|
123
|
+
*
|
|
124
|
+
* @param {OptionModel} optionModel - The option to remove.
|
|
125
|
+
*/
|
|
126
|
+
removeItem(optionModel) {
|
|
127
|
+
const index = this.items.indexOf(optionModel);
|
|
128
|
+
if (index > -1) {
|
|
129
|
+
this.items.splice(index, 1);
|
|
130
|
+
optionModel.group = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Updates the group's visibility in the view, typically based on children visibility.
|
|
136
|
+
* No-ops if the view is not initialized.
|
|
137
|
+
*/
|
|
138
|
+
updateVisibility() {
|
|
139
|
+
if (this.view) {
|
|
140
|
+
this.view.updateVisibility();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Model } from "../core/base/model.js";
|
|
2
|
+
import { iEvents } from "../utils/ievents.js";
|
|
3
|
+
import { Libs } from "../utils/libs.js";
|
|
4
|
+
import { OptionView } from "../views/option-view.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @extends {Model<HTMLOptionElement, OptionViewTags, OptionView>}
|
|
8
|
+
*/
|
|
9
|
+
export class OptionModel extends Model {
|
|
10
|
+
#privOnSelected = [];
|
|
11
|
+
#privOnInternalSelected = [];
|
|
12
|
+
#privOnVisibilityChanged = [];
|
|
13
|
+
#visible = true;
|
|
14
|
+
#highlighted = false;
|
|
15
|
+
|
|
16
|
+
/** @type {import('./group-model').GroupModel} */
|
|
17
|
+
group = null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns the image source from dataset (imgsrc or image), or an empty string if absent.
|
|
21
|
+
*
|
|
22
|
+
* @type {string}
|
|
23
|
+
*/
|
|
24
|
+
get imageSrc() {
|
|
25
|
+
return this.dataset?.imgsrc || this.dataset?.image || "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Indicates whether this option has an associated image source.
|
|
30
|
+
*
|
|
31
|
+
* @type {boolean}
|
|
32
|
+
*/
|
|
33
|
+
get hasImage() {
|
|
34
|
+
return !!this.imageSrc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gets the option's current value from the underlying <option> element.
|
|
39
|
+
*
|
|
40
|
+
* @type {string}
|
|
41
|
+
*/
|
|
42
|
+
get value() {
|
|
43
|
+
return this.targetElement.value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Gets whether the option is currently selected (proxied to the <option> element).
|
|
48
|
+
*
|
|
49
|
+
* @type {boolean}
|
|
50
|
+
*/
|
|
51
|
+
get selected() {
|
|
52
|
+
return this.targetElement.selected;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sets the selected state and triggers external selection listeners.
|
|
57
|
+
* Uses selectedNonTrigger internally to update DOM/ARIA without firing external side effects first.
|
|
58
|
+
*
|
|
59
|
+
* @type {boolean}
|
|
60
|
+
*/
|
|
61
|
+
set selected(value) {
|
|
62
|
+
this.selectedNonTrigger = value;
|
|
63
|
+
|
|
64
|
+
iEvents.callEvent([this, value], ...this.#privOnSelected);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gets whether the option is currently visible in the UI.
|
|
69
|
+
*
|
|
70
|
+
* @type {boolean}
|
|
71
|
+
*/
|
|
72
|
+
get visible() {
|
|
73
|
+
return this.#visible;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sets the visibility state; toggles "hide" class on the view and notifies visibility listeners.
|
|
78
|
+
*
|
|
79
|
+
* @type {boolean}
|
|
80
|
+
*/
|
|
81
|
+
set visible(value) {
|
|
82
|
+
if (this.#visible === value) return;
|
|
83
|
+
this.#visible = value;
|
|
84
|
+
|
|
85
|
+
const view = this.view?.getView();
|
|
86
|
+
if (view) {
|
|
87
|
+
view.classList.toggle("hide", !value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
iEvents.callEvent([this, value], ...this.#privOnVisibilityChanged);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Gets the selected state without triggering external listeners (alias of selected).
|
|
95
|
+
*
|
|
96
|
+
* @type {boolean}
|
|
97
|
+
*/
|
|
98
|
+
get selectedNonTrigger() {
|
|
99
|
+
return this.selected;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sets the selected state and updates input checked, CSS classes, ARIA attributes,
|
|
104
|
+
* and the underlying <option> 'selected' attribute. Notifies internal selection listeners.
|
|
105
|
+
*
|
|
106
|
+
* @type {boolean}
|
|
107
|
+
*/
|
|
108
|
+
set selectedNonTrigger(value) {
|
|
109
|
+
const tag = this.view?.getTag("OptionInput");
|
|
110
|
+
const view = this.view?.getView();
|
|
111
|
+
|
|
112
|
+
if (tag) {
|
|
113
|
+
tag.checked = value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (view) {
|
|
117
|
+
view.classList.toggle("checked", !!value);
|
|
118
|
+
view.setAttribute("aria-selected", value ? "true" : "false");
|
|
119
|
+
this.targetElement.toggleAttribute("selected", !!value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.targetElement.selected = value;
|
|
123
|
+
iEvents.callEvent([this, value], ...this.#privOnInternalSelected);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns the display text for the option, applying tag translation and optional HTML allowance.
|
|
128
|
+
* If allowHtml=false, returns stripped/sanitized text.
|
|
129
|
+
*
|
|
130
|
+
* @type {string}
|
|
131
|
+
*/
|
|
132
|
+
get text() {
|
|
133
|
+
const text = Libs.tagTranslate(this.dataset?.mask ?? this.targetElement.text);
|
|
134
|
+
return this.options.allowHtml ? text : Libs.stripHtml(text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns a plain-text version of the display text (trimmed),
|
|
139
|
+
* stripping HTML if allowHtml is true, otherwise the raw text.
|
|
140
|
+
*
|
|
141
|
+
* @type {string}
|
|
142
|
+
*/
|
|
143
|
+
get textContent() {
|
|
144
|
+
return this.options.allowHtml ? Libs.stripHtml(this.text).trim() : this.text.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Returns the dataset object of the underlying <option> element, or an empty object.
|
|
149
|
+
*
|
|
150
|
+
* @type {DOMStringMap|Record<string, string>}
|
|
151
|
+
*/
|
|
152
|
+
get dataset() {
|
|
153
|
+
return this.targetElement.dataset ?? {};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Gets whether the option is currently highlighted (e.g., via keyboard navigation).
|
|
158
|
+
*
|
|
159
|
+
* @type {boolean}
|
|
160
|
+
*/
|
|
161
|
+
get highlighted() {
|
|
162
|
+
return this.#highlighted;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Sets the highlighted state and toggles the "highlight" CSS class on the view.
|
|
168
|
+
* Always syncs the DOM class even if the state is unchanged.
|
|
169
|
+
*
|
|
170
|
+
* @type {boolean}
|
|
171
|
+
*/
|
|
172
|
+
set highlighted(value) {
|
|
173
|
+
const val = !!value;
|
|
174
|
+
const view = this.view?.getView?.();
|
|
175
|
+
|
|
176
|
+
if (this.#highlighted !== val) {
|
|
177
|
+
this.#highlighted = val;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (view) {
|
|
181
|
+
view.classList.toggle('highlight', val);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Registers a listener invoked when external selection changes (via setter `selected`).
|
|
188
|
+
*
|
|
189
|
+
* @param {(evtToken: any, el: OptionModel, selected: boolean) => void} callback - Selection listener.
|
|
190
|
+
*/
|
|
191
|
+
onSelected(callback) {
|
|
192
|
+
this.#privOnSelected.push(callback);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Registers a listener invoked when internal selection changes (via setter `selectedNonTrigger`).
|
|
197
|
+
*
|
|
198
|
+
* @param {(evtToken: any, el: OptionModel, selected: boolean) => void} callback - Internal selection listener.
|
|
199
|
+
*/
|
|
200
|
+
onInternalSelected(callback) {
|
|
201
|
+
this.#privOnInternalSelected.push(callback);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Registers a listener invoked when visibility changes (via setter `visible`).
|
|
206
|
+
*
|
|
207
|
+
* @param {(evtToken: any, model: OptionModel, visible: boolean) => void} callback - Visibility listener.
|
|
208
|
+
*/
|
|
209
|
+
onVisibilityChanged(callback) {
|
|
210
|
+
this.#privOnVisibilityChanged.push(callback);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Hook called when the target <option> element changes.
|
|
215
|
+
* Updates label content (HTML or text), image src/alt if present,
|
|
216
|
+
* and synchronizes initial selected state to the view.
|
|
217
|
+
*/
|
|
218
|
+
onTargetChanged() {
|
|
219
|
+
const labelContent = this.view.getTag("LabelContent");
|
|
220
|
+
if (labelContent) {
|
|
221
|
+
if (this.options.allowHtml) {
|
|
222
|
+
labelContent.innerHTML = this.text;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
labelContent.textContent = this.textContent;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const imageTag = this.view.getTag("OptionImage");
|
|
230
|
+
if (imageTag && this.hasImage) {
|
|
231
|
+
imageTag.src = this.imageSrc;
|
|
232
|
+
imageTag.alt = this.text;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.selectedNonTrigger = this.targetElement.selected;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export class DatasetObserver {
|
|
2
|
+
/** @type {MutationObserver} */
|
|
3
|
+
#observer;
|
|
4
|
+
|
|
5
|
+
/** @type {HTMLElement} */
|
|
6
|
+
#element;
|
|
7
|
+
|
|
8
|
+
#debounceTimer = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Observes data-* attribute changes on a target element and debounces notifications.
|
|
12
|
+
* Sets up a MutationObserver to detect dataset mutations and a fallback custom event listener.
|
|
13
|
+
*
|
|
14
|
+
* @param {HTMLElement} element - The element whose dataset (data-* attributes) will be observed.
|
|
15
|
+
*/
|
|
16
|
+
constructor(element) {
|
|
17
|
+
this.#element = element;
|
|
18
|
+
|
|
19
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
20
|
+
let datasetChanged = false;
|
|
21
|
+
|
|
22
|
+
for (const mutation of mutations) {
|
|
23
|
+
if (
|
|
24
|
+
mutation.type === "attributes" &&
|
|
25
|
+
mutation.attributeName?.startsWith("data-")
|
|
26
|
+
) {
|
|
27
|
+
datasetChanged = true;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!datasetChanged) return;
|
|
33
|
+
|
|
34
|
+
clearTimeout(this.#debounceTimer);
|
|
35
|
+
this.#debounceTimer = setTimeout(() => {
|
|
36
|
+
this.onChanged({ ...this.#element.dataset });
|
|
37
|
+
}, 50);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
element.addEventListener("dataset:changed", () => {
|
|
41
|
+
this.onChanged({ ...this.#element.dataset });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Starts observing the element for attribute changes, including old values.
|
|
47
|
+
* Uses MutationObserver to track updates to data-* attributes.
|
|
48
|
+
*/
|
|
49
|
+
connect() {
|
|
50
|
+
this.#observer.observe(this.#element, {
|
|
51
|
+
attributes: true,
|
|
52
|
+
attributeOldValue: true
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callback invoked when the element's dataset changes (debounced).
|
|
58
|
+
* Override in subclasses to handle dataset updates.
|
|
59
|
+
*
|
|
60
|
+
* @param {DOMStringMap} dataset - A shallow copy of the element's current dataset.
|
|
61
|
+
*/
|
|
62
|
+
onChanged(dataset) {
|
|
63
|
+
// override
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Stops observing the element and clears any pending debounce timers.
|
|
68
|
+
*/
|
|
69
|
+
disconnect() {
|
|
70
|
+
clearTimeout(this.#debounceTimer);
|
|
71
|
+
this.#observer.disconnect();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @class
|
|
3
|
+
*/
|
|
4
|
+
export class ElementAdditionObserver {
|
|
5
|
+
#isActive = false;
|
|
6
|
+
/** @type {MutationObserver} */
|
|
7
|
+
#observer = null;
|
|
8
|
+
|
|
9
|
+
/** @type {Function[]} */
|
|
10
|
+
#actions = [];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registers a callback to be invoked whenever a matching element is detected being added to the DOM.
|
|
14
|
+
*
|
|
15
|
+
* @param {(el: HTMLSelectElement) => void} action - Function executed with the newly added element.
|
|
16
|
+
*/
|
|
17
|
+
onDetect(action) {
|
|
18
|
+
this.#actions.push(action);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clears all previously registered detection callbacks.
|
|
23
|
+
*/
|
|
24
|
+
clearDetect() {
|
|
25
|
+
this.#actions = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Starts observing the document for additions of elements matching the given tag.
|
|
30
|
+
* Detects both direct additions and nested matches within added subtrees.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} tag - The tag name to watch for (e.g., "select", "div").
|
|
33
|
+
*/
|
|
34
|
+
start(tag) {
|
|
35
|
+
if (this.#isActive) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.#isActive = true;
|
|
39
|
+
const upperTag = tag.toUpperCase();
|
|
40
|
+
const lowerTag = tag.toLowerCase();
|
|
41
|
+
this.#observer = new MutationObserver((mutations) => {
|
|
42
|
+
mutations.forEach((mutation) => {
|
|
43
|
+
mutation.addedNodes.forEach((node) => {
|
|
44
|
+
const subnode = /** @type {HTMLElement} */ (node);
|
|
45
|
+
if (subnode.nodeType === 1) {
|
|
46
|
+
if (subnode.tagName === upperTag) {
|
|
47
|
+
this.#handle(subnode);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const selects = subnode.querySelectorAll(lowerTag);
|
|
51
|
+
selects.forEach((select) => {
|
|
52
|
+
this.#handle(select);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.#observer.observe(document.body, {
|
|
60
|
+
childList: true,
|
|
61
|
+
subtree: true
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stops observing for element additions and releases internal resources.
|
|
67
|
+
* No-ops if the observer is not active.
|
|
68
|
+
*/
|
|
69
|
+
stop() {
|
|
70
|
+
if (!this.#isActive) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.#isActive = false;
|
|
74
|
+
this.#observer.disconnect();
|
|
75
|
+
this.#observer = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Internal handler that invokes all registered detection callbacks for the provided element.
|
|
80
|
+
*
|
|
81
|
+
* @param {Element} element - The element that was detected as added to the DOM.
|
|
82
|
+
*/
|
|
83
|
+
#handle(element) {
|
|
84
|
+
this.#actions.forEach(action => {
|
|
85
|
+
action(element);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|