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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -0
  3. package/dist/selective-ui.css +569 -0
  4. package/dist/selective-ui.css.map +1 -0
  5. package/dist/selective-ui.esm.js +6101 -0
  6. package/dist/selective-ui.esm.js.map +1 -0
  7. package/dist/selective-ui.esm.min.js +1 -0
  8. package/dist/selective-ui.esm.min.js.br +0 -0
  9. package/dist/selective-ui.min.css +1 -0
  10. package/dist/selective-ui.min.css.br +0 -0
  11. package/dist/selective-ui.min.js +2 -0
  12. package/dist/selective-ui.min.js.br +0 -0
  13. package/dist/selective-ui.umd.js +6115 -0
  14. package/dist/selective-ui.umd.js.map +1 -0
  15. package/package.json +68 -0
  16. package/src/css/components/accessorybox.css +64 -0
  17. package/src/css/components/directive.css +20 -0
  18. package/src/css/components/empty-state.css +26 -0
  19. package/src/css/components/loading-state.css +26 -0
  20. package/src/css/components/optgroup.css +62 -0
  21. package/src/css/components/option-handle.css +34 -0
  22. package/src/css/components/option.css +130 -0
  23. package/src/css/components/placeholder.css +15 -0
  24. package/src/css/components/popup.css +39 -0
  25. package/src/css/components/searchbox.css +29 -0
  26. package/src/css/components/selectbox.css +54 -0
  27. package/src/css/index.css +75 -0
  28. package/src/js/adapter/mixed-adapter.js +435 -0
  29. package/src/js/components/accessorybox.js +125 -0
  30. package/src/js/components/directive.js +38 -0
  31. package/src/js/components/empty-state.js +68 -0
  32. package/src/js/components/loading-state.js +60 -0
  33. package/src/js/components/option-handle.js +114 -0
  34. package/src/js/components/placeholder.js +57 -0
  35. package/src/js/components/popup.js +471 -0
  36. package/src/js/components/searchbox.js +168 -0
  37. package/src/js/components/selectbox.js +693 -0
  38. package/src/js/core/base/adapter.js +163 -0
  39. package/src/js/core/base/model.js +59 -0
  40. package/src/js/core/base/recyclerview.js +83 -0
  41. package/src/js/core/base/view.js +62 -0
  42. package/src/js/core/model-manager.js +286 -0
  43. package/src/js/core/search-controller.js +522 -0
  44. package/src/js/index.js +137 -0
  45. package/src/js/models/group-model.js +143 -0
  46. package/src/js/models/option-model.js +237 -0
  47. package/src/js/services/dataset-observer.js +73 -0
  48. package/src/js/services/ea-observer.js +88 -0
  49. package/src/js/services/effector.js +404 -0
  50. package/src/js/services/refresher.js +40 -0
  51. package/src/js/services/resize-observer.js +152 -0
  52. package/src/js/services/select-observer.js +61 -0
  53. package/src/js/types/adapter.type.js +33 -0
  54. package/src/js/types/effector.type.js +24 -0
  55. package/src/js/types/ievents.type.js +11 -0
  56. package/src/js/types/libs.type.js +28 -0
  57. package/src/js/types/model.type.js +11 -0
  58. package/src/js/types/recyclerview.type.js +12 -0
  59. package/src/js/types/resize-observer.type.js +19 -0
  60. package/src/js/types/view.group.type.js +13 -0
  61. package/src/js/types/view.option.type.js +15 -0
  62. package/src/js/types/view.type.js +11 -0
  63. package/src/js/utils/guard.js +47 -0
  64. package/src/js/utils/ievents.js +83 -0
  65. package/src/js/utils/istorage.js +61 -0
  66. package/src/js/utils/libs.js +619 -0
  67. package/src/js/utils/selective.js +386 -0
  68. package/src/js/views/group-view.js +103 -0
  69. 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
+ */