selective-ui 1.2.4 → 1.2.6

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 (67) hide show
  1. package/README.md +7 -0
  2. package/dist/selective-ui.css +64 -58
  3. package/dist/selective-ui.css.map +1 -1
  4. package/dist/selective-ui.esm.js +4396 -1344
  5. package/dist/selective-ui.esm.js.map +1 -1
  6. package/dist/selective-ui.esm.min.js +2 -2
  7. package/dist/selective-ui.esm.min.js.br +0 -0
  8. package/dist/selective-ui.min.css +1 -1
  9. package/dist/selective-ui.min.css.br +0 -0
  10. package/dist/selective-ui.min.js +2 -2
  11. package/dist/selective-ui.min.js.br +0 -0
  12. package/dist/selective-ui.umd.js +4401 -1345
  13. package/dist/selective-ui.umd.js.map +1 -1
  14. package/package.json +3 -3
  15. package/src/css/components/accessorybox.css +1 -1
  16. package/src/css/components/directive.css +2 -2
  17. package/src/css/components/option-handle.css +4 -4
  18. package/src/css/components/placeholder.css +1 -1
  19. package/src/css/components/popup/empty-state.css +3 -3
  20. package/src/css/components/popup/loading-state.css +3 -3
  21. package/src/css/components/popup/popup.css +5 -5
  22. package/src/css/components/searchbox.css +2 -2
  23. package/src/css/components/selectbox.css +7 -7
  24. package/src/css/views/group-view.css +8 -8
  25. package/src/css/views/option-view.css +22 -22
  26. package/src/ts/adapter/mixed-adapter.ts +248 -92
  27. package/src/ts/components/accessorybox.ts +170 -73
  28. package/src/ts/components/directive.ts +55 -26
  29. package/src/ts/components/option-handle.ts +127 -60
  30. package/src/ts/components/placeholder.ts +73 -35
  31. package/src/ts/components/popup/empty-state.ts +71 -35
  32. package/src/ts/components/popup/loading-state.ts +73 -33
  33. package/src/ts/components/popup/popup.ts +19 -39
  34. package/src/ts/components/searchbox.ts +189 -50
  35. package/src/ts/components/selectbox.ts +401 -40
  36. package/src/ts/core/base/adapter.ts +160 -79
  37. package/src/ts/core/base/fenwick.ts +147 -0
  38. package/src/ts/core/base/lifecycle.ts +118 -35
  39. package/src/ts/core/base/model.ts +94 -36
  40. package/src/ts/core/base/recyclerview.ts +0 -1
  41. package/src/ts/core/base/view.ts +54 -23
  42. package/src/ts/core/base/virtual-recyclerview.ts +365 -283
  43. package/src/ts/core/model-manager.ts +172 -92
  44. package/src/ts/core/search-controller.ts +166 -93
  45. package/src/ts/global.ts +26 -5
  46. package/src/ts/index.ts +22 -3
  47. package/src/ts/models/group-model.ts +138 -32
  48. package/src/ts/models/option-model.ts +197 -53
  49. package/src/ts/services/dataset-observer.ts +72 -10
  50. package/src/ts/services/ea-observer.ts +87 -10
  51. package/src/ts/services/effector.ts +181 -32
  52. package/src/ts/services/refresher.ts +32 -7
  53. package/src/ts/services/resize-observer.ts +136 -19
  54. package/src/ts/services/select-observer.ts +115 -50
  55. package/src/ts/types/core/base/view.type.ts +3 -3
  56. package/src/ts/types/core/base/virtual-recyclerview.type.ts +1 -1
  57. package/src/ts/types/plugins/plugin.type.ts +46 -0
  58. package/src/ts/types/utils/ievents.type.ts +6 -1
  59. package/src/ts/types/utils/istorage.type.ts +8 -4
  60. package/src/ts/types/utils/libs.type.ts +2 -2
  61. package/src/ts/types/utils/selective.type.ts +14 -1
  62. package/src/ts/utils/callback-scheduler.ts +115 -37
  63. package/src/ts/utils/ievents.ts +91 -29
  64. package/src/ts/utils/libs.ts +41 -65
  65. package/src/ts/utils/selective.ts +412 -79
  66. package/src/ts/views/group-view.ts +142 -31
  67. package/src/ts/views/option-view.ts +272 -60
@@ -10,33 +10,109 @@ import { SelectiveOptions } from "../types/utils/selective.type";
10
10
  import { LifecycleState } from "../types/core/base/lifecycle.type";
11
11
 
12
12
  /**
13
+ * Domain model for a native `<option>` element.
14
+ *
15
+ * This is the core selectable row model consumed by adapters/recyclers. It mirrors the backing
16
+ * `<option>` node while also carrying UI-only state used by the headless+DOM-driven layer
17
+ * (visibility, highlight, precomputed search key).
18
+ *
19
+ * ### Responsibility
20
+ * - Mirror and synchronize state with the backing `<option>` element:
21
+ * - `value`, `selected`, `dataset`, and display label (with optional tag translation / HTML policy).
22
+ * - Provide derived properties for rendering:
23
+ * - image resolution (`imageSrc` / `hasImage`),
24
+ * - rich/stripped label (`text` / `textContent`),
25
+ * - normalized search key (`textToFind`).
26
+ * - Maintain UI-only flags:
27
+ * - `visible` for filtering/search,
28
+ * - `highlighted` for keyboard navigation/hover.
29
+ * - Publish change notifications:
30
+ * - **External** selection (`selected`) vs **internal** selection sync (`selectedNonTrigger`),
31
+ * - visibility changes (`visible`).
32
+ *
33
+ * ### Lifecycle (Strict FSM)
34
+ * - Base {@link Model} calls `init()` during construction (`NEW → INITIALIZED`), and this subclass
35
+ * overrides {@link init} to precompute {@link textToFind} before delegating to `super.init()`.
36
+ * - {@link init} then transitions to `MOUNTED` via `mount()` for first render readiness.
37
+ * - {@link update} recomputes derived text/search fields and pushes state into the {@link OptionView}
38
+ * if attached, then emits lifecycle update.
39
+ * - {@link destroy} clears listeners/references and transitions to `DESTROYED` (idempotent).
40
+ *
41
+ * ### External vs internal selection semantics
42
+ * - `selected` is the **external** user-facing signal:
43
+ * updates state (via {@link selectedNonTrigger}) and then notifies {@link onSelected} listeners.
44
+ * - `selectedNonTrigger` is the **internal** sync signal:
45
+ * updates view/DOM/backing `<option>` and then notifies {@link onInternalSelected} listeners
46
+ * **without** implying user intent.
47
+ *
48
+ * ### DOM & a11y side effects (when a view is attached)
49
+ * - Toggles CSS classes: `"hide"`, `"highlight"`, `"checked"`.
50
+ * - Updates `aria-selected` on the option row root element.
51
+ * - Updates label content (either `innerHTML` or `textContent` depending on `allowHtml`).
52
+ * - Mirrors selection state to the backing `<option>` (property + attribute).
53
+ *
13
54
  * @extends {Model<HTMLOptionElement, OptionViewTags, OptionView, SelectiveOptions>}
55
+ * @see {@link GroupModel}
56
+ * @see {@link OptionView}
14
57
  */
15
58
  export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, OptionView, SelectiveOptions> {
59
+ /**
60
+ * External selection subscribers (emitted by the {@link selected} setter).
61
+ * Use this for user-facing selection flows.
62
+ */
16
63
  private privOnSelected: Array<(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void> = [];
17
64
 
65
+ /**
66
+ * Internal selection subscribers (emitted by the {@link selectedNonTrigger} setter).
67
+ * Use this for silent synchronization flows.
68
+ */
18
69
  private privOnInternalSelected: Array<(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void> = [];
19
70
 
71
+ /**
72
+ * Visibility subscribers (emitted by the {@link visible} setter).
73
+ * Commonly used to recompute group visibility and update aggregated visibility stats.
74
+ */
20
75
  private privOnVisibilityChanged: Array<(evtToken: IEventCallback, model: OptionModel, visible: boolean) => void> = [];
21
76
 
77
+ /**
78
+ * Visibility flag used for filtering/search.
79
+ * When `false`, adapters/recyclers may treat this item as non-renderable.
80
+ */
22
81
  private _visible = true;
23
82
 
83
+ /** Highlight flag used for keyboard navigation / hover. */
24
84
  private _highlighted = false;
25
85
 
86
+ /**
87
+ * Parent group model (if this option belongs to a group).
88
+ * Assigned by grouping logic (e.g., GroupModel/MixedAdapter).
89
+ */
26
90
  public group: GroupModel | null = null;
27
91
 
28
92
  /**
29
- * Constructs a Model instance with configuration options and optional bindings to a target element and view.
30
- * Stores references for later updates and rendering.
93
+ * Creates an option model.
31
94
  *
32
- * @param {SelectiveOptions} options - Configuration options for the model.
33
- * @param {HTMLOptionElement|null} [targetElement=null] - The underlying element (e.g., <option> or group node).
34
- * @param {OptionView|null} [view=null] - The associated view responsible for rendering the model.
95
+ * @param {SelectiveOptions} options - Shared configuration for models/views.
96
+ * @param {HTMLOptionElement | null} [targetElement=null] - Backing `<option>` element.
97
+ * @param {OptionView | null} [view=null] - Optional view used to render this model.
35
98
  */
36
- public constructor(options: SelectiveOptions, targetElement: HTMLOptionElement | null = null, view: OptionView | null = null) {
99
+ public constructor(
100
+ options: SelectiveOptions,
101
+ targetElement: HTMLOptionElement | null = null,
102
+ view: OptionView | null = null
103
+ ) {
37
104
  super(options, targetElement, view);
38
105
  }
39
106
 
107
+ /**
108
+ * Initializes the model and precomputes the search key.
109
+ *
110
+ * - Computes {@link textToFind} from {@link textContent} (lowercased + normalized).
111
+ * - Delegates to `super.init()` and then transitions to `MOUNTED` via `mount()`.
112
+ *
113
+ * @returns {void}
114
+ * @override
115
+ */
40
116
  public override init(): void {
41
117
  this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
42
118
 
@@ -45,46 +121,50 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
45
121
  }
46
122
 
47
123
  /**
48
- * Returns the image source from dataset (imgsrc or image), or an empty string if absent.
124
+ * Image source resolved from dataset (`imgsrc` or `image`), or empty string if absent.
49
125
  *
50
- * @type {string}
126
+ * @returns {string}
51
127
  */
52
128
  public get imageSrc(): string {
53
129
  return this.dataset?.imgsrc || this.dataset?.image || "";
54
130
  }
55
131
 
56
132
  /**
57
- * Indicates whether this option has an associated image source.
133
+ * Whether this option has an image associated with it.
58
134
  *
59
- * @type {boolean}
135
+ * @returns {boolean}
60
136
  */
61
137
  public get hasImage(): boolean {
62
138
  return !!this.imageSrc;
63
139
  }
64
140
 
65
141
  /**
66
- * Gets the option's current value from the underlying <option> element.
142
+ * Current value of the backing `<option>`.
67
143
  *
68
- * @type {string}
144
+ * @returns {string}
69
145
  */
70
146
  public get value(): string {
71
147
  return this.targetElement?.value ?? "";
72
148
  }
73
149
 
74
150
  /**
75
- * Gets whether the option is currently selected (proxied to the <option> element).
151
+ * Whether the backing `<option>` is selected.
76
152
  *
77
- * @type {boolean}
153
+ * @returns {boolean}
78
154
  */
79
155
  public get selected(): boolean {
80
156
  return !!this.targetElement?.selected;
81
157
  }
82
158
 
83
159
  /**
84
- * Sets the selected state and triggers external selection listeners.
85
- * Uses selectedNonTrigger internally to update DOM/ARIA without firing external side effects first.
160
+ * Sets selected state and emits **external** selection listeners.
86
161
  *
87
- * @type {boolean}
162
+ * Flow:
163
+ * - Delegates to {@link selectedNonTrigger} to synchronize view/DOM/backing element.
164
+ * - Notifies {@link onSelected} subscribers via {@link iEvents.callEvent}.
165
+ *
166
+ * @param {boolean} value - New selection state.
167
+ * @returns {void}
88
168
  */
89
169
  public set selected(value: boolean) {
90
170
  this.selectedNonTrigger = value;
@@ -92,18 +172,25 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
92
172
  }
93
173
 
94
174
  /**
95
- * Gets whether the option is currently visible in the UI.
175
+ * Whether this option is visible (used for filtering/search).
96
176
  *
97
- * @type {boolean}
177
+ * @returns {boolean}
98
178
  */
99
179
  public get visible(): boolean {
100
180
  return this._visible;
101
181
  }
102
182
 
103
183
  /**
104
- * Sets the visibility state; toggles "hide" class on the view and notifies visibility listeners.
184
+ * Sets visibility and synchronizes the view (if attached), then emits visibility listeners.
185
+ *
186
+ * Side effects (when view attached):
187
+ * - Toggles `"hide"` CSS class on the view root element.
105
188
  *
106
- * @type {boolean}
189
+ * Idempotent:
190
+ * - No-op if the new value equals the current state.
191
+ *
192
+ * @param {boolean} value - New visibility state.
193
+ * @returns {void}
107
194
  */
108
195
  public set visible(value: boolean) {
109
196
  if (this._visible === value) return;
@@ -116,25 +203,35 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
116
203
  }
117
204
 
118
205
  /**
119
- * Gets the selected state without triggering external listeners (alias of selected).
206
+ * Reads selected state without emitting external selection listeners.
120
207
  *
121
- * @type {boolean}
208
+ * @returns {boolean}
122
209
  */
123
210
  public get selectedNonTrigger(): boolean {
124
211
  return this.selected;
125
212
  }
126
213
 
127
214
  /**
128
- * Sets the selected state and updates input checked, CSS classes, ARIA attributes,
129
- * and the underlying <option> 'selected' attribute. Notifies internal selection listeners.
215
+ * Sets selected state **silently** (internal sync), updates view/a11y/backing DOM, then emits internal listeners.
216
+ *
217
+ * Side effects (when view/backing element exist):
218
+ * - Updates the input checked state (`OptionInput`) if present.
219
+ * - Toggles `"checked"` class on the root element.
220
+ * - Sets `aria-selected`.
221
+ * - Mirrors to backing `<option>`:
222
+ * - toggles `selected` attribute,
223
+ * - sets `targetElement.selected`.
130
224
  *
131
- * @type {boolean}
225
+ * @param {boolean} value - New selection state.
226
+ * @returns {void}
132
227
  */
133
228
  public set selectedNonTrigger(value: boolean) {
134
229
  const input = this.view?.view?.tags?.OptionInput;
135
230
  const viewEl = this.view?.getView?.();
136
231
 
137
- if (input) (input as HTMLInputElement).checked = value;
232
+ if (input) {
233
+ input.checked = value;
234
+ }
138
235
 
139
236
  if (viewEl && this.targetElement) {
140
237
  viewEl.classList.toggle("checked", !!value);
@@ -142,16 +239,24 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
142
239
  this.targetElement.toggleAttribute("selected", !!value);
143
240
  }
144
241
 
145
- if (this.targetElement) this.targetElement.selected = value;
242
+ if (this.targetElement) {
243
+ this.targetElement.selected = value;
244
+ }
146
245
 
147
246
  iEvents.callEvent<[OptionModel, boolean]>([this, value], ...this.privOnInternalSelected);
148
247
  }
149
248
 
150
249
  /**
151
- * Returns the display text for the option, applying tag translation and optional HTML allowance.
152
- * If allowHtml=false, returns stripped/sanitized text.
250
+ * Display label for rendering (with tag translation and HTML policy).
251
+ *
252
+ * Source precedence:
253
+ * - `dataset.mask` if present, otherwise `targetElement.text`.
153
254
  *
154
- * @type {string}
255
+ * Policy:
256
+ * - When `options.allowHtml === true`, returns translated HTML.
257
+ * - When `options.allowHtml === false`, returns plain text (HTML stripped).
258
+ *
259
+ * @returns {string}
155
260
  */
156
261
  public get text(): string {
157
262
  const raw = this.dataset?.mask ?? this.targetElement?.text ?? "";
@@ -160,40 +265,49 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
160
265
  }
161
266
 
162
267
  /**
163
- * Returns a plain-text version of the display text (trimmed),
164
- * stripping HTML if allowHtml is true, otherwise the raw text.
268
+ * Plain-text version of the display label, trimmed.
269
+ *
270
+ * - If `allowHtml` is enabled, strips HTML from {@link text}.
271
+ * - Otherwise returns {@link text} directly (already plain).
165
272
  *
166
- * @type {string}
273
+ * @returns {string}
167
274
  */
168
275
  public get textContent(): string {
169
276
  return this.options.allowHtml ? Libs.stripHtml(this.text).trim() : this.text.trim();
170
277
  }
171
278
 
279
+ /**
280
+ * Normalized, lowercase search key used for filtering/search.
281
+ * Computed during {@link init} and recomputed in {@link update}.
282
+ */
172
283
  public textToFind: string;
173
284
 
174
285
  /**
175
- * Returns the dataset object of the underlying <option> element, or an empty object.
286
+ * Dataset object of the backing `<option>` element.
176
287
  *
177
- * @type {DOMStringMap|Record<string, string>}
288
+ * @returns {DOMStringMap}
178
289
  */
179
290
  public get dataset(): DOMStringMap {
180
- return this.targetElement?.dataset ?? ({} as DOMStringMap);
291
+ return this.targetElement?.dataset ?? {};
181
292
  }
182
293
 
183
294
  /**
184
- * Gets whether the option is currently highlighted (e.g., via keyboard navigation).
295
+ * Whether this option is currently highlighted (navigation/hover).
185
296
  *
186
- * @type {boolean}
297
+ * @returns {boolean}
187
298
  */
188
299
  public get highlighted(): boolean {
189
300
  return this._highlighted;
190
301
  }
191
302
 
192
303
  /**
193
- * Sets the highlighted state and toggles the "highlight" CSS class on the view.
194
- * Always syncs the DOM class even if the state is unchanged.
304
+ * Sets highlight state and synchronizes the view (if attached).
195
305
  *
196
- * @type {boolean}
306
+ * Side effects:
307
+ * - Toggles `"highlight"` CSS class on the view root element.
308
+ *
309
+ * @param {boolean} value - New highlight state.
310
+ * @returns {void}
197
311
  */
198
312
  public set highlighted(value: boolean) {
199
313
  const val = !!value;
@@ -204,36 +318,50 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
204
318
  }
205
319
 
206
320
  /**
207
- * Registers a listener invoked when external selection changes (via setter `selected`).
321
+ * Subscribes to **external** selection changes (emitted by {@link selected}).
208
322
  *
209
- * @param {(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void} callback - Selection listener.
323
+ * @param {(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void} callback - Listener callback.
324
+ * @returns {void}
210
325
  */
211
326
  public onSelected(callback: (evtToken: IEventCallback, el: OptionModel, selected: boolean) => void): void {
212
327
  this.privOnSelected.push(callback);
213
328
  }
214
329
 
215
330
  /**
216
- * Registers a listener invoked when internal selection changes (via setter `selectedNonTrigger`).
331
+ * Subscribes to **internal** selection changes (emitted by {@link selectedNonTrigger}).
217
332
  *
218
- * @param {(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void} callback - Internal selection listener.
333
+ * @param {(evtToken: IEventCallback, el: OptionModel, selected: boolean) => void} callback - Listener callback.
334
+ * @returns {void}
219
335
  */
220
336
  public onInternalSelected(callback: (evtToken: IEventCallback, el: OptionModel, selected: boolean) => void): void {
221
337
  this.privOnInternalSelected.push(callback);
222
338
  }
223
339
 
224
340
  /**
225
- * Registers a listener invoked when visibility changes (via setter `visible`).
341
+ * Subscribes to visibility changes (emitted by {@link visible}).
226
342
  *
227
- * @param {(evtToken: IEventCallback, model: OptionModel, visible: boolean) => void} callback - Visibility listener.
343
+ * @param {(evtToken: IEventCallback, model: OptionModel, visible: boolean) => void} callback - Listener callback.
344
+ * @returns {void}
228
345
  */
229
346
  public onVisibilityChanged(callback: (evtToken: IEventCallback, model: OptionModel, visible: boolean) => void): void {
230
347
  this.privOnVisibilityChanged.push(callback);
231
348
  }
232
349
 
233
350
  /**
234
- * Hook called when the target <option> element changes.
235
- * Updates label content (HTML or text), image src/alt if present,
236
- * and synchronizes initial selected state to the view.
351
+ * Synchronizes derived fields and the attached view from the current backing element/options.
352
+ *
353
+ * Syncs:
354
+ * - {@link textToFind} (normalized search key)
355
+ * - Label content:
356
+ * - `innerHTML` when `allowHtml` is enabled,
357
+ * - otherwise `textContent`
358
+ * - Image attributes (`src`/`alt`) when present
359
+ * - Selected state from `targetElement.selected` via {@link selectedNonTrigger}
360
+ *
361
+ * No-op for view updates when no view is attached; still emits lifecycle update via `super.update()`.
362
+ *
363
+ * @returns {void}
364
+ * @override
237
365
  */
238
366
  public override update(): void {
239
367
  this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
@@ -253,8 +381,12 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
253
381
 
254
382
  const imageTag = this.view.view.tags.OptionImage;
255
383
  if (imageTag && this.hasImage) {
256
- (imageTag as HTMLImageElement).src = this.imageSrc;
257
- (imageTag as HTMLImageElement).alt = this.text;
384
+ if (imageTag.src != this.imageSrc) {
385
+ imageTag.src = this.imageSrc;
386
+ }
387
+ if (imageTag.alt != this.text) {
388
+ imageTag.alt = this.text;
389
+ }
258
390
  }
259
391
 
260
392
  if (this.targetElement) this.selectedNonTrigger = this.targetElement.selected;
@@ -262,6 +394,18 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
262
394
  super.update();
263
395
  }
264
396
 
397
+ /**
398
+ * Destroys the model and releases listener references.
399
+ *
400
+ * Behavior:
401
+ * - Idempotent once lifecycle is {@link LifecycleState.DESTROYED}.
402
+ * - Clears external/internal selection listeners and visibility listeners.
403
+ * - Detaches from parent group and clears cached search key.
404
+ * - Completes teardown via `super.destroy()` (base {@link Model} also destroys the view if present).
405
+ *
406
+ * @returns {void}
407
+ * @override
408
+ */
265
409
  public override destroy(): void {
266
410
  if (this.is(LifecycleState.DESTROYED)) {
267
411
  return;
@@ -1,18 +1,59 @@
1
1
  /**
2
- * @class
2
+ * DatasetObserver
3
+ *
4
+ * Lightweight observer that watches `data-*` attribute mutations on a target element
5
+ * and emits a debounced snapshot of `element.dataset`.
6
+ *
7
+ * ### Responsibility
8
+ * - Detect changes to `data-*` attributes using a {@link MutationObserver}.
9
+ * - Debounce rapid attribute mutations into a single callback invocation.
10
+ * - Provide a secondary/manual notification path via a custom `"dataset:changed"` event.
11
+ *
12
+ * ### Event Model (External vs. Internal)
13
+ * - **External changes**: DOM attribute mutations (e.g., `el.dataset.disabled = "1"`) are detected
14
+ * by {@link MutationObserver} and delivered after the debounce window.
15
+ * - **Internal/manual signal**: dispatching `"dataset:changed"` on the element forces an immediate
16
+ * snapshot emission (not debounced here), useful when dataset-like state is updated through
17
+ * non-attribute paths or when consumers want an explicit refresh signal.
18
+ *
19
+ * ### Debounce Semantics
20
+ * - Multiple attribute changes within ~50ms are coalesced into a single {@link onChanged} call.
21
+ * - The callback receives a shallow copy of the current dataset (`{ ...element.dataset }`),
22
+ * ensuring callers do not hold a live reference.
23
+ *
24
+ * ### Usage
25
+ * - Create instance with a target element.
26
+ * - Call {@link connect} to start observing.
27
+ * - Implement/assign {@link onChanged} to react to updates.
28
+ * - Call {@link disconnect} during teardown to prevent leaks.
3
29
  */
4
30
  export class DatasetObserver {
31
+ /** Underlying MutationObserver instance used to detect `data-*` attribute mutations. */
5
32
  private observer: MutationObserver;
6
33
 
34
+ /** Target element whose dataset (`data-*` attributes) is observed. */
7
35
  private element: HTMLElement;
8
36
 
37
+ /**
38
+ * Debounce timer handle for coalescing rapid attribute mutations.
39
+ * Cleared/replaced whenever a new relevant mutation arrives within the debounce window.
40
+ */
9
41
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
10
42
 
11
43
  /**
12
- * Observes data-* attribute changes on a target element and debounces notifications.
13
- * Sets up a MutationObserver to detect dataset mutations and a fallback custom event listener.
44
+ * Creates a {@link DatasetObserver} for the given element.
45
+ *
46
+ * Side effects:
47
+ * - Instantiates a {@link MutationObserver} that filters for `attributes` mutations
48
+ * where `attributeName` starts with `"data-"`.
49
+ * - Registers a `"dataset:changed"` event listener on the element to allow manual
50
+ * emission of dataset snapshots.
14
51
  *
15
- * @param {HTMLElement} element - The element whose dataset (data-* attributes) will be observed.
52
+ * Notes:
53
+ * - Observation does not begin until {@link connect} is called.
54
+ * - The `"dataset:changed"` listener is always active after construction.
55
+ *
56
+ * @param element - The element whose `data-*` attributes will be observed.
16
57
  */
17
58
  public constructor(element: HTMLElement) {
18
59
  this.element = element;
@@ -41,8 +82,17 @@ export class DatasetObserver {
41
82
  }
42
83
 
43
84
  /**
44
- * Starts observing the element for attribute changes, including old values.
45
- * Uses MutationObserver to track updates to data-* attributes.
85
+ * Starts observing the target element for attribute changes.
86
+ *
87
+ * - Observes all attribute mutations and relies on the mutation callback to filter
88
+ * down to `data-*` changes.
89
+ * - `attributeOldValue` is enabled to allow future diagnostics; the current implementation
90
+ * does not consume old values directly.
91
+ *
92
+ * No-op behavior:
93
+ * - Calling `connect()` multiple times will register multiple observations on the same
94
+ * element in standard DOM APIs. Consumers should treat this as "call once" unless the
95
+ * implementation is extended to guard idempotency.
46
96
  */
47
97
  public connect(): void {
48
98
  this.observer.observe(this.element, {
@@ -52,10 +102,15 @@ export class DatasetObserver {
52
102
  }
53
103
 
54
104
  /**
55
- * Callback invoked when the element's dataset changes (debounced).
56
- * Override in subclasses to handle dataset updates.
105
+ * Hook invoked when the element's dataset changes.
106
+ *
107
+ * Consumers typically override this method (or assign to it) to react to changes such as:
108
+ * - disabled / readonly / visible flags
109
+ * - feature toggles exposed via `data-*` attributes
110
+ *
111
+ * The `dataset` argument is a shallow copy of the *current* dataset at the time of emission.
57
112
  *
58
- * @param {Record<string, string>} dataset - A shallow copy of the element's current dataset.
113
+ * @param dataset - Snapshot of `element.dataset` (string values).
59
114
  */
60
115
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
61
116
  public onChanged(dataset: Record<string, string>): void {
@@ -63,7 +118,14 @@ export class DatasetObserver {
63
118
  }
64
119
 
65
120
  /**
66
- * Stops observing the element and clears any pending debounce timers.
121
+ * Stops observing and clears pending debounce work.
122
+ *
123
+ * Side effects:
124
+ * - Cancels the pending debounce timer (if any).
125
+ * - Disconnects the underlying {@link MutationObserver}.
126
+ *
127
+ * Idempotency:
128
+ * - Safe to call multiple times; subsequent calls will be effectively no-ops after disconnect.
67
129
  */
68
130
  public disconnect(): void {
69
131
  if (this.debounceTimer) clearTimeout(this.debounceTimer);