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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @returns {EffectorInterface}
|
|
3
|
+
*/
|
|
4
|
+
export function Effector(query) {
|
|
5
|
+
return new class {
|
|
6
|
+
/**
|
|
7
|
+
* @type {HTMLElement}
|
|
8
|
+
*/
|
|
9
|
+
element;
|
|
10
|
+
#timeOut = null;
|
|
11
|
+
#resizeTimeout = null;
|
|
12
|
+
#isAnimating = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Provides an effector utility that controls animations and resizing for a target element.
|
|
16
|
+
* Supports setting the element by selector or node, canceling in-flight animations/timers,
|
|
17
|
+
* and exposes methods (expand, collapse, resize) via the returned object instance.
|
|
18
|
+
*
|
|
19
|
+
* @param {string|HTMLElement} [query] - A CSS selector or the target element to control.
|
|
20
|
+
*/
|
|
21
|
+
constructor(query = null) {
|
|
22
|
+
if (query) {
|
|
23
|
+
this.setElement(query);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets the target element to be controlled by the effector.
|
|
29
|
+
* Accepts either a CSS selector or a direct HTMLElement reference.
|
|
30
|
+
*
|
|
31
|
+
* @param {string|HTMLElement} query - The element or selector to bind.
|
|
32
|
+
*/
|
|
33
|
+
setElement(query) {
|
|
34
|
+
if (typeof query === "string") {
|
|
35
|
+
this.element = document.querySelector(query);
|
|
36
|
+
} else {
|
|
37
|
+
this.element = query;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cancels any pending timeouts or resize triggers and resets the animation state.
|
|
43
|
+
* Use this to stop ongoing expand/collapse/resize animations immediately.
|
|
44
|
+
*
|
|
45
|
+
* @returns {this} - The effector instance for chaining.
|
|
46
|
+
*/
|
|
47
|
+
cancel() {
|
|
48
|
+
if (this.#timeOut) {
|
|
49
|
+
clearTimeout(this.#timeOut);
|
|
50
|
+
this.#timeOut = null;
|
|
51
|
+
}
|
|
52
|
+
if (this.#resizeTimeout) {
|
|
53
|
+
clearTimeout(this.#resizeTimeout);
|
|
54
|
+
this.#resizeTimeout = null;
|
|
55
|
+
}
|
|
56
|
+
this.#isAnimating = false;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get hidden dimensions
|
|
62
|
+
* @param {string} display
|
|
63
|
+
* @returns {{width: number, height: number, scrollHeight: number}}
|
|
64
|
+
*/
|
|
65
|
+
getHiddenDimensions(display = "flex") {
|
|
66
|
+
const originalStyles = {
|
|
67
|
+
display: this.element.style.display,
|
|
68
|
+
visibility: this.element.style.visibility,
|
|
69
|
+
position: this.element.style.position,
|
|
70
|
+
height: this.element.style.height,
|
|
71
|
+
width: this.element.style.width,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
Object.assign(this.element.style, {
|
|
75
|
+
display: display,
|
|
76
|
+
visibility: "hidden",
|
|
77
|
+
position: "fixed",
|
|
78
|
+
height: "fit-content",
|
|
79
|
+
width: "fit-content"
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const getComputedStyle = window.getComputedStyle(this.element);
|
|
83
|
+
const borderTopWidth = parseFloat(getComputedStyle.borderTopWidth);
|
|
84
|
+
const borderBottomWidth = parseFloat(getComputedStyle.borderBottomWidth);
|
|
85
|
+
|
|
86
|
+
const scrollHeight = this.element.scrollHeight + borderTopWidth + borderBottomWidth;
|
|
87
|
+
|
|
88
|
+
const rect = this.element.getBoundingClientRect();
|
|
89
|
+
|
|
90
|
+
const dimensions = {
|
|
91
|
+
width: rect.width,
|
|
92
|
+
height: rect.height + borderTopWidth + borderBottomWidth,
|
|
93
|
+
scrollHeight: scrollHeight
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
Object.assign(this.element.style, originalStyles);
|
|
97
|
+
|
|
98
|
+
return dimensions;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Expand animation (open popup)
|
|
103
|
+
* @param {Object} config
|
|
104
|
+
* @param {number} config.duration - Animation duration in ms
|
|
105
|
+
* @param {string} config.display - Display type
|
|
106
|
+
* @param {number} config.width - Target width
|
|
107
|
+
* @param {number} config.left - Left position
|
|
108
|
+
* @param {number} config.top - Top position
|
|
109
|
+
* @param {number} config.maxHeight - Max height
|
|
110
|
+
* @param {number} config.realHeight - Real height
|
|
111
|
+
* @param {string} config.position - Position type (top/bottom)
|
|
112
|
+
* @param {Function} config.onComplete - Callback when complete
|
|
113
|
+
* @returns {this}
|
|
114
|
+
*/
|
|
115
|
+
expand(config) {
|
|
116
|
+
this.cancel();
|
|
117
|
+
this.#isAnimating = true;
|
|
118
|
+
|
|
119
|
+
const {
|
|
120
|
+
duration = 200,
|
|
121
|
+
display = "flex",
|
|
122
|
+
width,
|
|
123
|
+
left,
|
|
124
|
+
top,
|
|
125
|
+
maxHeight,
|
|
126
|
+
realHeight,
|
|
127
|
+
position = "bottom",
|
|
128
|
+
onComplete
|
|
129
|
+
} = config;
|
|
130
|
+
|
|
131
|
+
const initialTop = position === "bottom"
|
|
132
|
+
? top
|
|
133
|
+
: top + realHeight;
|
|
134
|
+
|
|
135
|
+
Object.assign(this.element.style, {
|
|
136
|
+
display: display,
|
|
137
|
+
width: `${width}px`,
|
|
138
|
+
left: `${left}px`,
|
|
139
|
+
top: `${initialTop}px`,
|
|
140
|
+
maxHeight: `${maxHeight}px`,
|
|
141
|
+
height: "0px",
|
|
142
|
+
opacity: "0",
|
|
143
|
+
overflow: "hidden",
|
|
144
|
+
transition: "none"
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.element.classList.toggle("position-top", position === "top");
|
|
148
|
+
this.element.classList.toggle("position-bottom", position === "bottom");
|
|
149
|
+
|
|
150
|
+
requestAnimationFrame(() => {
|
|
151
|
+
const isScrollable = realHeight >= maxHeight;
|
|
152
|
+
|
|
153
|
+
Object.assign(this.element.style, {
|
|
154
|
+
transition: `top ${duration}ms, height ${duration}ms, opacity ${duration}ms`,
|
|
155
|
+
top: `${top}px`,
|
|
156
|
+
height: `${realHeight}px`,
|
|
157
|
+
opacity: "1",
|
|
158
|
+
overflow: isScrollable ? "auto" : "hidden"
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.#timeOut = setTimeout(() => {
|
|
162
|
+
this.element.style.transition = "none";
|
|
163
|
+
this.#isAnimating = false;
|
|
164
|
+
onComplete && onComplete();
|
|
165
|
+
}, duration);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Collapse animation (close popup)
|
|
173
|
+
* @param {Object} config
|
|
174
|
+
* @param {number} config.duration - Animation duration in ms
|
|
175
|
+
* @param {Function} config.onComplete - Callback when complete
|
|
176
|
+
* @returns {this}
|
|
177
|
+
*/
|
|
178
|
+
collapse(config) {
|
|
179
|
+
this.cancel();
|
|
180
|
+
this.#isAnimating = true;
|
|
181
|
+
|
|
182
|
+
const {
|
|
183
|
+
duration = 200,
|
|
184
|
+
onComplete
|
|
185
|
+
} = config;
|
|
186
|
+
|
|
187
|
+
const currentHeight = this.element.offsetHeight;
|
|
188
|
+
const currentTop = this.element.offsetTop;
|
|
189
|
+
const position = this.element.classList.contains("position-top") ? "top" : "bottom";
|
|
190
|
+
const isScrollable = (this.element.scrollHeight - this.element.offsetHeight) > 0;
|
|
191
|
+
|
|
192
|
+
const finalTop = position === "top"
|
|
193
|
+
? currentTop + currentHeight
|
|
194
|
+
: currentTop;
|
|
195
|
+
|
|
196
|
+
requestAnimationFrame(() => {
|
|
197
|
+
Object.assign(this.element.style, {
|
|
198
|
+
transition: `height ${duration}ms, top ${duration}ms, opacity ${duration}ms`,
|
|
199
|
+
height: "0px",
|
|
200
|
+
top: `${finalTop}px`,
|
|
201
|
+
opacity: "0",
|
|
202
|
+
overflow: isScrollable ? "auto" : "hidden"
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.#timeOut = setTimeout(() => {
|
|
206
|
+
Object.assign(this.element.style, {
|
|
207
|
+
display: "none",
|
|
208
|
+
transition: "none"
|
|
209
|
+
});
|
|
210
|
+
this.#isAnimating = false;
|
|
211
|
+
onComplete && onComplete();
|
|
212
|
+
}, duration);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* show Swipe animation (close element)
|
|
220
|
+
* @param {Object} config
|
|
221
|
+
* @param {number} config.duration - Animation duration in ms
|
|
222
|
+
* @param {String} config.display - Display for element
|
|
223
|
+
* @param {Function} config.onComplete - Callback when complete
|
|
224
|
+
* @returns {this}
|
|
225
|
+
*/
|
|
226
|
+
showSwipeWidth(config) {
|
|
227
|
+
this.cancel();
|
|
228
|
+
this.#isAnimating = true;
|
|
229
|
+
|
|
230
|
+
const {
|
|
231
|
+
duration = 200,
|
|
232
|
+
display = "block",
|
|
233
|
+
onComplete
|
|
234
|
+
} = config;
|
|
235
|
+
|
|
236
|
+
Object.assign(this.element.style, {
|
|
237
|
+
transition: "none",
|
|
238
|
+
display: display,
|
|
239
|
+
width: "fit-content"
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const maxWidth = this.getHiddenDimensions(display).width;
|
|
243
|
+
|
|
244
|
+
Object.assign(this.element.style, {
|
|
245
|
+
width: "0px"
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
requestAnimationFrame(() => {
|
|
249
|
+
Object.assign(this.element.style, {
|
|
250
|
+
transition: `width ${duration}ms`,
|
|
251
|
+
width: `${maxWidth}px`,
|
|
252
|
+
overflow: "hidden"
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.#timeOut = setTimeout(() => {
|
|
257
|
+
Object.assign(this.element.style, {
|
|
258
|
+
width: null,
|
|
259
|
+
overflow: null,
|
|
260
|
+
transition: null
|
|
261
|
+
});
|
|
262
|
+
this.#isAnimating = false;
|
|
263
|
+
onComplete && onComplete();
|
|
264
|
+
}, duration);
|
|
265
|
+
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* hide Swipe animation (close element)
|
|
271
|
+
* @param {Object} config
|
|
272
|
+
* @param {number} config.duration - Animation duration in ms
|
|
273
|
+
* @param {Function} config.onComplete - Callback when complete
|
|
274
|
+
* @returns {this}
|
|
275
|
+
*/
|
|
276
|
+
hideSwipeWidth(config) {
|
|
277
|
+
this.cancel();
|
|
278
|
+
this.#isAnimating = true;
|
|
279
|
+
|
|
280
|
+
const {
|
|
281
|
+
duration = 200,
|
|
282
|
+
onComplete
|
|
283
|
+
} = config;
|
|
284
|
+
|
|
285
|
+
const maxWidth = this.getHiddenDimensions().width;
|
|
286
|
+
|
|
287
|
+
Object.assign(this.element.style, {
|
|
288
|
+
transition: "none",
|
|
289
|
+
width: `${maxWidth}px`
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
requestAnimationFrame(() => {
|
|
293
|
+
Object.assign(this.element.style, {
|
|
294
|
+
transition: `width ${duration}ms`,
|
|
295
|
+
width: `0px`,
|
|
296
|
+
overflow: "hidden"
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
this.#timeOut = setTimeout(() => {
|
|
301
|
+
Object.assign(this.element.style, {
|
|
302
|
+
width: null,
|
|
303
|
+
overflow: null,
|
|
304
|
+
transition: null,
|
|
305
|
+
display: null
|
|
306
|
+
});
|
|
307
|
+
this.#isAnimating = false;
|
|
308
|
+
onComplete && onComplete();
|
|
309
|
+
}, duration);
|
|
310
|
+
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resize animation (when content changes)
|
|
316
|
+
* @param {Object} config
|
|
317
|
+
* @param {number} config.duration - Animation duration in ms
|
|
318
|
+
* @param {number} config.width - Target width
|
|
319
|
+
* @param {number} config.left - Left position
|
|
320
|
+
* @param {number} config.top - Top position
|
|
321
|
+
* @param {number} config.maxHeight - Max height
|
|
322
|
+
* @param {number} config.realHeight - Real height
|
|
323
|
+
* @param {string} config.position - Position type (top/bottom)
|
|
324
|
+
* @param {boolean} config.animate - Whether to animate
|
|
325
|
+
* @param {Function} config.onComplete - Callback when complete
|
|
326
|
+
* @returns {this}
|
|
327
|
+
*/
|
|
328
|
+
resize(config) {
|
|
329
|
+
if (this.#resizeTimeout) {
|
|
330
|
+
clearTimeout(this.#resizeTimeout);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const {
|
|
334
|
+
duration = 200,
|
|
335
|
+
width,
|
|
336
|
+
left,
|
|
337
|
+
top,
|
|
338
|
+
maxHeight,
|
|
339
|
+
realHeight,
|
|
340
|
+
position = "bottom",
|
|
341
|
+
animate = true,
|
|
342
|
+
onComplete
|
|
343
|
+
} = config;
|
|
344
|
+
|
|
345
|
+
const currentPosition = this.element.classList.contains("position-top") ? "top" : "bottom";
|
|
346
|
+
const isPositionChanged = currentPosition !== position;
|
|
347
|
+
const isScrollable = this.element.scrollHeight > maxHeight;
|
|
348
|
+
|
|
349
|
+
this.element.classList.toggle("position-top", position === "top");
|
|
350
|
+
this.element.classList.toggle("position-bottom", position === "bottom");
|
|
351
|
+
|
|
352
|
+
if (isPositionChanged) {
|
|
353
|
+
this.element.style.transition = `top ${duration}ms ease-out, height ${duration}ms ease-out, max-height ${duration}ms ease-out;`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
requestAnimationFrame(() => {
|
|
357
|
+
const curTop = this.element.offsetTop;
|
|
358
|
+
const styles = {
|
|
359
|
+
width: `${width}px`,
|
|
360
|
+
left: `${left}px`,
|
|
361
|
+
top: `${top}px`,
|
|
362
|
+
maxHeight: `${maxHeight}px`,
|
|
363
|
+
height: `${realHeight}px`,
|
|
364
|
+
overflowY: isScrollable ? "auto" : "hidden"
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (animate && (isPositionChanged || Math.abs(this.element.offsetHeight - realHeight) > 5) ) {
|
|
368
|
+
styles.transition = `height ${duration}ms, top ${duration}ms`;
|
|
369
|
+
} else {
|
|
370
|
+
this.#resizeTimeout = setTimeout(() => {
|
|
371
|
+
this.element.style.transition = "none";
|
|
372
|
+
}, duration);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
Object.assign(this.element.style, styles);
|
|
376
|
+
|
|
377
|
+
if (animate && (isPositionChanged || Math.abs(this.element.offsetHeight - realHeight) > 1)) {
|
|
378
|
+
this.#resizeTimeout = setTimeout(() => {
|
|
379
|
+
this.element.style.transition = "none";
|
|
380
|
+
if (isPositionChanged) {
|
|
381
|
+
delete this.element.style.transition;
|
|
382
|
+
}
|
|
383
|
+
onComplete && onComplete();
|
|
384
|
+
}, duration);
|
|
385
|
+
} else {
|
|
386
|
+
if (isPositionChanged) {
|
|
387
|
+
delete this.element.style.transition;
|
|
388
|
+
}
|
|
389
|
+
onComplete && onComplete();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Check if currently animating
|
|
398
|
+
* @returns {boolean}
|
|
399
|
+
*/
|
|
400
|
+
get isAnimating() {
|
|
401
|
+
return this.#isAnimating;
|
|
402
|
+
}
|
|
403
|
+
}(query);
|
|
404
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {Libs} from "../utils/libs.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @class
|
|
5
|
+
*/
|
|
6
|
+
export class Refresher {
|
|
7
|
+
/**
|
|
8
|
+
* Provides a utility to resize the Select UI view panel based on the bound <select> element
|
|
9
|
+
* and configuration options. Applies explicit width/height if configured; otherwise uses the
|
|
10
|
+
* select's current offset size. Ensures minimum width/height constraints are respected.
|
|
11
|
+
*
|
|
12
|
+
* @param {HTMLSelectElement} select - The native select element used to derive dimensions.
|
|
13
|
+
* @param {HTMLElement} view - The view panel element whose styles will be updated.
|
|
14
|
+
*/
|
|
15
|
+
static resizeBox(select, view) {
|
|
16
|
+
const
|
|
17
|
+
bindedMap = Libs.getBinderMap(select),
|
|
18
|
+
options = bindedMap.options
|
|
19
|
+
;
|
|
20
|
+
|
|
21
|
+
const
|
|
22
|
+
minWidth = options.minWidth,
|
|
23
|
+
minHeight = options.minHeight,
|
|
24
|
+
cfgWidth = parseInt(options.width, 10),
|
|
25
|
+
cfgHeight = parseInt(options.height, 10)
|
|
26
|
+
;
|
|
27
|
+
|
|
28
|
+
let width = `${select.offsetWidth}px`,
|
|
29
|
+
height = `${select.offsetHeight}px`;
|
|
30
|
+
|
|
31
|
+
if (cfgWidth > 0) {
|
|
32
|
+
width = options.width;
|
|
33
|
+
}
|
|
34
|
+
if (cfgHeight > 0) {
|
|
35
|
+
height = options.height;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Libs.setStyle(view, {width, height, minWidth, minHeight});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @class
|
|
4
|
+
*/
|
|
5
|
+
export class ResizeObserverService {
|
|
6
|
+
isInit = false;
|
|
7
|
+
element = null;
|
|
8
|
+
/** @type {ResizeObserver} */
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#mutationObserver = null;
|
|
11
|
+
#boundUpdateChanged;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Initializes the service and binds the internal update handler to `this`.
|
|
15
|
+
* Sets the service to an initialized state.
|
|
16
|
+
*/
|
|
17
|
+
constructor() {
|
|
18
|
+
this.isInit = true
|
|
19
|
+
this.#boundUpdateChanged = this.#updateChanged.bind(this);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Callback invoked when the observed element's metrics change.
|
|
24
|
+
* Override to react to size/position/style updates.
|
|
25
|
+
*
|
|
26
|
+
* @param {ElementMetrics} metrics - Calculated box metrics (size, position, padding, border, margin).
|
|
27
|
+
*/
|
|
28
|
+
onChanged(metrics) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Computes the current metrics of the bound element (bounding rect + computed styles)
|
|
32
|
+
* and forwards them to `onChanged(metrics)`.
|
|
33
|
+
*
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
#updateChanged() {
|
|
37
|
+
// Guard: nếu element chưa sẵn sàng hoặc không đo được, trả về metrics mặc định
|
|
38
|
+
const el = this.element;
|
|
39
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') {
|
|
40
|
+
/** @type {ElementMetrics} */
|
|
41
|
+
const defaultMetrics = {
|
|
42
|
+
width: 0,
|
|
43
|
+
height: 0,
|
|
44
|
+
top: 0,
|
|
45
|
+
left: 0,
|
|
46
|
+
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
47
|
+
border: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
48
|
+
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
49
|
+
};
|
|
50
|
+
this.onChanged(defaultMetrics);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rect = el.getBoundingClientRect();
|
|
55
|
+
const style = (typeof window?.getComputedStyle === 'function')
|
|
56
|
+
? window.getComputedStyle(el)
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
/** @type {ElementMetrics} */
|
|
60
|
+
const metrics = {
|
|
61
|
+
width: rect?.width ?? 0,
|
|
62
|
+
height: rect?.height ?? 0,
|
|
63
|
+
top: rect?.top ?? 0,
|
|
64
|
+
left: rect?.left ?? 0,
|
|
65
|
+
|
|
66
|
+
padding: {
|
|
67
|
+
top: parseFloat(style?.paddingTop ?? '0'),
|
|
68
|
+
right: parseFloat(style?.paddingRight ?? '0'),
|
|
69
|
+
bottom: parseFloat(style?.paddingBottom ?? '0'),
|
|
70
|
+
left: parseFloat(style?.paddingLeft ?? '0'),
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
border: {
|
|
74
|
+
top: parseFloat(style?.borderTopWidth ?? '0'),
|
|
75
|
+
right: parseFloat(style?.borderRightWidth ?? '0'),
|
|
76
|
+
bottom: parseFloat(style?.borderBottomWidth ?? '0'),
|
|
77
|
+
left: parseFloat(style?.borderLeftWidth ?? '0'),
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
margin: {
|
|
81
|
+
top: parseFloat(style?.marginTop ?? '0'),
|
|
82
|
+
right: parseFloat(style?.marginRight ?? '0'),
|
|
83
|
+
bottom: parseFloat(style?.marginBottom ?? '0'),
|
|
84
|
+
left: parseFloat(style?.marginLeft ?? '0'),
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this.onChanged(metrics);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Manually triggers a metrics computation and notification via `onChanged`.
|
|
93
|
+
*/
|
|
94
|
+
trigger() {
|
|
95
|
+
this.#updateChanged();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Starts observing the provided element for resize and style/class mutations,
|
|
100
|
+
* and listens to window/visualViewport scroll/resize to detect layout changes.
|
|
101
|
+
*
|
|
102
|
+
* @param {Element} element - The element to observe; must be a valid DOM Element.
|
|
103
|
+
* @throws {Error} If `element` is not an instance of Element.
|
|
104
|
+
*/
|
|
105
|
+
connect(element) {
|
|
106
|
+
if (!(element instanceof Element)) {
|
|
107
|
+
throw new Error("Element không hợp lệ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.element = element;
|
|
111
|
+
|
|
112
|
+
this.#resizeObserver = new ResizeObserver(this.#boundUpdateChanged);
|
|
113
|
+
this.#resizeObserver.observe(element);
|
|
114
|
+
|
|
115
|
+
this.#mutationObserver = new MutationObserver(this.#boundUpdateChanged);
|
|
116
|
+
this.#mutationObserver.observe(element, {
|
|
117
|
+
attributes: true,
|
|
118
|
+
attributeFilter: ["style", "class"]
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
window.addEventListener("scroll", this.#boundUpdateChanged, true);
|
|
122
|
+
window.addEventListener("resize", this.#boundUpdateChanged);
|
|
123
|
+
|
|
124
|
+
if (window.visualViewport) {
|
|
125
|
+
window.visualViewport.addEventListener("resize", this.#boundUpdateChanged);
|
|
126
|
+
window.visualViewport.addEventListener("scroll", this.#boundUpdateChanged);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stops all observations and event listeners, resets the change handler,
|
|
132
|
+
* and releases internal observer resources.
|
|
133
|
+
*/
|
|
134
|
+
disconnect() {
|
|
135
|
+
// Optional chaining để an toàn với mocks trong môi trường test
|
|
136
|
+
this.#resizeObserver?.disconnect?.();
|
|
137
|
+
this.#mutationObserver?.disconnect?.();
|
|
138
|
+
|
|
139
|
+
this.onChanged = (metrics) => {};
|
|
140
|
+
window.removeEventListener("scroll", this.#boundUpdateChanged, true);
|
|
141
|
+
window.removeEventListener("resize", this.#boundUpdateChanged);
|
|
142
|
+
|
|
143
|
+
if (window.visualViewport) {
|
|
144
|
+
window.visualViewport.removeEventListener("resize", this.#boundUpdateChanged);
|
|
145
|
+
window.visualViewport.removeEventListener("scroll", this.#boundUpdateChanged);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.#resizeObserver = null;
|
|
149
|
+
this.#mutationObserver = null;
|
|
150
|
+
this.element = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class SelectObserver {
|
|
2
|
+
/** @type {MutationObserver} */
|
|
3
|
+
#observer;
|
|
4
|
+
|
|
5
|
+
/** @type {HTMLSelectElement} */
|
|
6
|
+
#select;
|
|
7
|
+
|
|
8
|
+
#debounceTimer = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Observes a <select> element for option list and attribute changes, with debouncing.
|
|
12
|
+
* Detects modifications to children (options added/removed) and relevant attributes
|
|
13
|
+
* ("selected", "value", "disabled"). Emits updates via the overridable onChanged() hook.
|
|
14
|
+
*
|
|
15
|
+
* @param {HTMLSelectElement} select - The <select> element to monitor.
|
|
16
|
+
*/
|
|
17
|
+
constructor(select) {
|
|
18
|
+
this.#observer = new MutationObserver(() => {
|
|
19
|
+
clearTimeout(this.#debounceTimer);
|
|
20
|
+
this.#debounceTimer = setTimeout(() => {
|
|
21
|
+
this.onChanged(select);
|
|
22
|
+
}, 50);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.#select = select;
|
|
26
|
+
|
|
27
|
+
select.addEventListener("options:changed", () => {
|
|
28
|
+
this.onChanged(select);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Starts observing the select element for child list mutations and attribute changes.
|
|
34
|
+
* Uses a MutationObserver with a debounce to batch rapid updates.
|
|
35
|
+
*/
|
|
36
|
+
connect() {
|
|
37
|
+
this.#observer.observe(this.#select, {
|
|
38
|
+
childList: true,
|
|
39
|
+
subtree: false,
|
|
40
|
+
|
|
41
|
+
attributes: true,
|
|
42
|
+
attributeFilter: ["selected", "value", "disabled"]
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook invoked when the select's options or attributes change.
|
|
48
|
+
* Override to handle updates; receives the current HTMLCollection of options.
|
|
49
|
+
*
|
|
50
|
+
* @param {HTMLSelectElement} options - The Select element.
|
|
51
|
+
*/
|
|
52
|
+
onChanged(options) { }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stops observing the select element and clears any pending debounce timers.
|
|
56
|
+
*/
|
|
57
|
+
disconnect() {
|
|
58
|
+
clearTimeout(this.#debounceTimer);
|
|
59
|
+
this.#observer.disconnect();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @template {ModelContract<any, any>} TItem
|
|
4
|
+
* @typedef {Object} AdapterContract
|
|
5
|
+
*
|
|
6
|
+
* @property {TItem[]} items - List of items managed by the adapter.
|
|
7
|
+
* @property {string} adapterKey - Unique key identifier for the adapter.
|
|
8
|
+
*
|
|
9
|
+
* @property {(items: TItem[]) => void} setItems - Replace or update the list of items.
|
|
10
|
+
* @property {(items: TItem[]) => void} syncFromSource - Synchronize items from an external source.
|
|
11
|
+
* @property {() => number} itemCount - Get the number of items.
|
|
12
|
+
*
|
|
13
|
+
* @property {(parent: HTMLElement, item: TItem) => any} viewHolder
|
|
14
|
+
* - Create a viewer for the given item inside the parent container.
|
|
15
|
+
*
|
|
16
|
+
* @property {(item: TItem, viewer: any, position: number) => void} onViewHolder
|
|
17
|
+
* - Bind an item to its viewer at the specified position (render if not initialized, otherwise update).
|
|
18
|
+
*
|
|
19
|
+
* @property {(propName: string, callback: Function) => void} onPropChanging
|
|
20
|
+
* - Register a pre-change callback for a property.
|
|
21
|
+
* @property {(propName: string, callback: Function) => void} onPropChanged
|
|
22
|
+
* - Register a post-change callback for a property.
|
|
23
|
+
* @property {(propName: string, ...params: any[]) => void} changeProp
|
|
24
|
+
* - Trigger the post-change pipeline for a property.
|
|
25
|
+
* @property {(propName: string, ...params: any[]) => void} changingProp
|
|
26
|
+
* - Trigger the pre-change pipeline for a property.
|
|
27
|
+
*
|
|
28
|
+
* @property {(parent: HTMLElement) => void} updateRecyclerView
|
|
29
|
+
* - Ensure all items have viewers and bind them into the recycler container.
|
|
30
|
+
*
|
|
31
|
+
* @property {(items: TItem[]) => void} updateData
|
|
32
|
+
* - Update adapter data (override in subclasses for custom behavior).
|
|
33
|
+
*/
|