selective-ui 1.0.2 → 1.0.4

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/LICENSE +21 -21
  2. package/README.md +7 -2
  3. package/dist/selective-ui.css +567 -567
  4. package/dist/selective-ui.css.map +1 -1
  5. package/dist/selective-ui.esm.js +6186 -6047
  6. package/dist/selective-ui.esm.js.map +1 -1
  7. package/dist/selective-ui.esm.min.js +1 -1
  8. package/dist/selective-ui.esm.min.js.br +0 -0
  9. package/dist/selective-ui.min.js +2 -2
  10. package/dist/selective-ui.min.js.br +0 -0
  11. package/dist/selective-ui.umd.js +6186 -6047
  12. package/dist/selective-ui.umd.js.map +1 -1
  13. package/package.json +68 -68
  14. package/src/css/components/accessorybox.css +63 -63
  15. package/src/css/components/directive.css +19 -19
  16. package/src/css/components/empty-state.css +25 -25
  17. package/src/css/components/loading-state.css +25 -25
  18. package/src/css/components/optgroup.css +61 -61
  19. package/src/css/components/option-handle.css +33 -33
  20. package/src/css/components/option.css +129 -129
  21. package/src/css/components/placeholder.css +14 -14
  22. package/src/css/components/popup.css +38 -38
  23. package/src/css/components/searchbox.css +28 -28
  24. package/src/css/components/selectbox.css +53 -53
  25. package/src/css/index.css +74 -74
  26. package/src/js/adapter/mixed-adapter.js +434 -434
  27. package/src/js/components/accessorybox.js +124 -124
  28. package/src/js/components/directive.js +37 -37
  29. package/src/js/components/empty-state.js +67 -67
  30. package/src/js/components/loading-state.js +59 -59
  31. package/src/js/components/option-handle.js +113 -113
  32. package/src/js/components/placeholder.js +56 -56
  33. package/src/js/components/popup.js +470 -470
  34. package/src/js/components/searchbox.js +167 -167
  35. package/src/js/components/selectbox.js +749 -692
  36. package/src/js/core/base/adapter.js +162 -162
  37. package/src/js/core/base/model.js +59 -59
  38. package/src/js/core/base/recyclerview.js +82 -82
  39. package/src/js/core/base/view.js +62 -62
  40. package/src/js/core/model-manager.js +286 -286
  41. package/src/js/core/search-controller.js +603 -521
  42. package/src/js/index.js +136 -136
  43. package/src/js/models/group-model.js +142 -142
  44. package/src/js/models/option-model.js +236 -236
  45. package/src/js/services/dataset-observer.js +73 -73
  46. package/src/js/services/ea-observer.js +87 -87
  47. package/src/js/services/effector.js +403 -403
  48. package/src/js/services/refresher.js +39 -39
  49. package/src/js/services/resize-observer.js +151 -151
  50. package/src/js/services/select-observer.js +60 -60
  51. package/src/js/types/adapter.type.js +32 -32
  52. package/src/js/types/effector.type.js +23 -23
  53. package/src/js/types/ievents.type.js +10 -10
  54. package/src/js/types/libs.type.js +27 -27
  55. package/src/js/types/model.type.js +11 -11
  56. package/src/js/types/recyclerview.type.js +11 -11
  57. package/src/js/types/resize-observer.type.js +18 -18
  58. package/src/js/types/view.group.type.js +12 -12
  59. package/src/js/types/view.option.type.js +14 -14
  60. package/src/js/types/view.type.js +10 -10
  61. package/src/js/utils/guard.js +46 -46
  62. package/src/js/utils/ievents.js +83 -83
  63. package/src/js/utils/istorage.js +60 -60
  64. package/src/js/utils/libs.js +618 -618
  65. package/src/js/utils/selective.js +385 -385
  66. package/src/js/views/group-view.js +102 -102
  67. package/src/js/views/option-view.js +152 -152
@@ -1,435 +1,435 @@
1
- import { Adapter } from "../core/base/adapter";
2
- import { GroupModel } from "../models/group-model";
3
- import { OptionModel } from "../models/option-model";
4
- import { GroupView } from "../views/group-view";
5
- import { OptionView } from "../views/option-view";
6
-
7
- /**
8
- * @extends {Adapter<GroupModel|OptionModel>}
9
- */
10
- export class MixedAdapter extends Adapter {
11
- isMultiple = false;
12
- #visibilityChangedCallbacks = [];
13
- #currentHighlightIndex = -1;
14
- /** @type {OptionModel} */
15
- #selectedItemSingle = null;
16
-
17
- /** @type {GroupModel[]} */
18
- groups = [];
19
-
20
- /** @type {OptionModel[]} */
21
- flatOptions = [];
22
-
23
- constructor(items = []) {
24
- super(items);
25
- this.#buildFlatStructure();
26
- }
27
-
28
- /**
29
- * Build flat list of all options for navigation
30
- */
31
- #buildFlatStructure() {
32
- this.flatOptions = [];
33
- this.groups = [];
34
-
35
- this.items.forEach(item => {
36
- if (item instanceof GroupModel) {
37
- this.groups.push(item);
38
- this.flatOptions.push(...item.items);
39
- } else if (item instanceof OptionModel) {
40
- this.flatOptions.push(item);
41
- }
42
- });
43
- }
44
-
45
- /**
46
- * Creates and returns the appropriate view instance for the given item type.
47
- *
48
- * @param {HTMLElement} parent - The parent container element where the view will be attached.
49
- * @param {GroupModel|OptionModel} item - The data model representing either a group or an option.
50
- * @returns {GroupView|OptionView} - A new view instance corresponding to the item type.
51
- */
52
- viewHolder(parent, item) {
53
- if (item instanceof GroupModel) {
54
- return new GroupView(parent);
55
- } else {
56
- return new OptionView(parent);
57
- }
58
- }
59
-
60
- /**
61
- * Binds a data model (GroupModel or OptionModel) to its corresponding view
62
- * and delegates rendering logic based on the type of item.
63
- *
64
- * @param {GroupModel|OptionModel} item - The data model representing either a group or an option.
65
- * @param {GroupView|OptionView} viewer - The view instance that displays the item in the UI.
66
- * @param {number} position - The position (index) of the item within its parent list.
67
- */
68
- onViewHolder(item, viewer, position) {
69
- item.position = position;
70
-
71
- if (item instanceof GroupModel) {
72
- this.#handleGroupView(item, /** @type {GroupView} */ (viewer), position);
73
- } else if (item instanceof OptionModel) {
74
- this.#handleOptionView(item, /** @type {OptionView} */ (viewer), position);
75
- }
76
-
77
- item.isInit = true;
78
- }
79
-
80
- /**
81
- * Handles binding and rendering logic for a group view, including header behavior,
82
- * collapse/expand state, and initialization of option items.
83
- *
84
- * @param {GroupModel} groupModel - The data model representing the group and its items.
85
- * @param {GroupView} groupView - The view instance that renders the group in the UI.
86
- * @param {number} position - The position (index) of the group within a list.
87
- */
88
- #handleGroupView(groupModel, groupView, position) {
89
- super.onViewHolder(groupModel, groupView, position);
90
- groupModel.view = groupView;
91
-
92
- const header = groupView.getTag("GroupHeader");
93
- header.textContent = groupModel.label;
94
-
95
- if (!groupModel.isInit) {
96
- header.style.cursor = "pointer";
97
- header.addEventListener("click", () => {
98
- groupModel.toggleCollapse();
99
- });
100
-
101
- groupModel.onCollapsedChanged((evtToken, model, collapsed) => {
102
- model.items.forEach(optItem => {
103
- const optView = optItem.view?.getView();
104
- if (optView) {
105
- optView.style.display = collapsed ? "none" : "";
106
- }
107
- });
108
- this.onCollapsedChange(model, collapsed);
109
- });
110
- }
111
-
112
- const itemsContainer = groupView.getItemsContainer();
113
- groupModel.items.forEach((optionModel, idx) => {
114
- let optionViewer = optionModel.view;
115
- if (!optionModel.isInit) {
116
- optionViewer = new OptionView(itemsContainer);
117
- }
118
- this.#handleOptionView(optionModel, optionViewer, idx);
119
- optionModel.isInit = true;
120
- });
121
-
122
- groupView.setCollapsed(groupModel.collapsed);
123
- groupView.updateVisibility();
124
- }
125
-
126
- /**
127
- * Handles binding and rendering logic for an option item, including image setup,
128
- * label rendering, event wiring, and selection state synchronization.
129
- *
130
- * @param {OptionModel} optionModel - The data model representing a single option.
131
- * @param {OptionView} optionViewer - The view instance that renders the option in the UI.
132
- * @param {number} position - The index of this option within its group's item list.
133
- */
134
- #handleOptionView(optionModel, optionViewer, position) {
135
- optionViewer.isMultiple = this.isMultiple;
136
- optionViewer.hasImage = optionModel.hasImage;
137
- optionViewer.optionConfig = {
138
- imageWidth: optionModel.options.imageWidth,
139
- imageHeight: optionModel.options.imageHeight,
140
- imageBorderRadius: optionModel.options.imageBorderRadius,
141
- imagePosition: optionModel.options.imagePosition,
142
- labelValign: optionModel.options.labelValign,
143
- labelHalign: optionModel.options.labelHalign
144
- };
145
-
146
- if (!optionModel.isInit) {
147
- super.onViewHolder(optionModel, optionViewer, position);
148
- } else {
149
- optionViewer.update();
150
- }
151
-
152
- optionModel.view = optionViewer;
153
-
154
- if (optionModel.hasImage) {
155
- const imageTag = optionViewer.getTag("OptionImage");
156
- if (imageTag) {
157
- imageTag.src = optionModel.imageSrc;
158
- imageTag.alt = optionModel.text;
159
- }
160
- }
161
-
162
- optionViewer.getTag("LabelContent").innerHTML = optionModel.text;
163
-
164
- if (!optionModel.isInit) {
165
- optionViewer.getTag("OptionView").addEventListener("click", (ev) => {
166
- ev.stopPropagation();
167
- ev.preventDefault();
168
-
169
- if (this.isSkipEvent) return;
170
-
171
- if (this.isMultiple) {
172
- this.changingProp("select");
173
- setTimeout(() => {
174
- optionModel.selected = !optionModel.selected;
175
- }, 5);
176
- } else if (optionModel.selected !== true) {
177
- this.changingProp("select");
178
- setTimeout(() => {
179
- if (this.#selectedItemSingle) {
180
- this.#selectedItemSingle.selected = false;
181
- }
182
- optionModel.selected = true;
183
- }, 5);
184
- }
185
- });
186
-
187
- optionViewer.getTag("OptionView").title = optionModel.textContent;
188
-
189
- optionViewer.getTag("OptionView").addEventListener("mouseenter", () => {
190
- if (this.isSkipEvent) return;
191
- this.setHighlight(this.flatOptions.indexOf(optionModel), false);
192
- });
193
-
194
- optionModel.onSelected((evtToken, el, selected) => {
195
- this.changeProp("selected");
196
- });
197
-
198
- optionModel.onInternalSelected((evtToken, el, selected) => {
199
- if (selected) {
200
- this.#selectedItemSingle = optionModel;
201
- }
202
- this.changeProp("selected_internal");
203
- });
204
-
205
- optionModel.onVisibilityChanged((evtToken, model, visible) => {
206
- if (model.group) {
207
- model.group.updateVisibility();
208
- }
209
- this.#notifyVisibilityChanged();
210
- });
211
- }
212
-
213
- if (optionModel.selected) {
214
- this.#selectedItemSingle = optionModel;
215
- optionModel.selectedNonTrigger = true;
216
- }
217
- }
218
-
219
- /**
220
- * Updates the list of items in the component and rebuilds its internal flat structure.
221
- *
222
- * @param {Array<GroupModel|OptionModel>} items - The new collection of items to be displayed.
223
- */
224
- setItems(items) {
225
- this.changingProp("items", items);
226
- this.items = items;
227
- this.#buildFlatStructure();
228
- this.changeProp("items", items);
229
- }
230
-
231
- /**
232
- * Synchronizes the component's items from an external source by delegating to setItems().
233
- *
234
- * @param {Array<GroupModel|OptionModel>} items - The new collection of items to sync.
235
- */
236
- syncFromSource(items) {
237
- this.setItems(items);
238
- }
239
-
240
- /**
241
- * Updates the component's data items and rebuilds the internal flat structure
242
- * without triggering change notifications.
243
- *
244
- * @param {Array<GroupModel|OptionModel>} items - The new collection of items to update.
245
- */
246
- updateData(items) {
247
- this.items = items;
248
- this.#buildFlatStructure();
249
- }
250
-
251
- /**
252
- * Returns all option items that are currently selected.
253
- *
254
- * @returns {OptionModel[]} - An array of selected option items from the flat list.
255
- */
256
- getSelectedItems() {
257
- return this.flatOptions.filter(item => item.selected);
258
- }
259
-
260
- /**
261
- * Returns the first selected option item, if any.
262
- *
263
- * @returns {OptionModel|undefined} - The first selected option or undefined if none are selected.
264
- */
265
- getSelectedItem() {
266
- return this.flatOptions.find(item => item.selected);
267
- }
268
-
269
- /**
270
- * Checks or unchecks all options when in multiple selection mode.
271
- *
272
- * @param {boolean} isChecked - If true, select all; if false, deselect all.
273
- */
274
- checkAll(isChecked) {
275
- if (this.isMultiple) {
276
- this.flatOptions.forEach(item => {
277
- item.selected = isChecked;
278
- });
279
- }
280
- }
281
-
282
- /**
283
- * Subscribes a callback to visibility changes across options.
284
- *
285
- * @param {(stats: {visibleCount:number,totalCount:number,hasVisible:boolean,isEmpty:boolean}) => void} callback
286
- * - Function to invoke when visibility stats change.
287
- */
288
- onVisibilityChanged(callback) {
289
- this.#visibilityChangedCallbacks.push(callback);
290
- }
291
-
292
- /**
293
- * Notifies all registered visibility-change callbacks with up-to-date statistics.
294
- * Computes visible and total counts, then emits aggregated state.
295
- */
296
- #notifyVisibilityChanged() {
297
- const visibleCount = this.flatOptions.filter(item => item.visible).length;
298
- const totalCount = this.flatOptions.length;
299
-
300
- this.#visibilityChangedCallbacks.forEach(callback => {
301
- callback({
302
- visibleCount,
303
- totalCount,
304
- hasVisible: visibleCount > 0,
305
- isEmpty: totalCount === 0
306
- });
307
- });
308
- }
309
-
310
- /**
311
- * Computes and returns current visibility statistics for options.
312
- *
313
- * @returns {{visibleCount:number,totalCount:number,hasVisible:boolean,isEmpty:boolean}}
314
- * - Aggregated visibility information.
315
- */
316
- getVisibilityStats() {
317
- const visibleCount = this.flatOptions.filter(item => item.visible).length;
318
- const totalCount = this.flatOptions.length;
319
-
320
- return {
321
- visibleCount,
322
- totalCount,
323
- hasVisible: visibleCount > 0,
324
- isEmpty: totalCount === 0
325
- };
326
- }
327
-
328
- /**
329
- * Resets the highlight to the first visible option (index 0).
330
- */
331
- resetHighlight() {
332
- this.setHighlight(0);
333
- }
334
-
335
- /**
336
- * Moves the highlight forward/backward among visible options and optionally scrolls into view.
337
- *
338
- * @param {number} direction - Increment (+1) or decrement (-1) of the current visible index.
339
- * @param {boolean} [isScrollToView=true] - Whether to scroll the highlighted item into view.
340
- */
341
- navigate(direction, isScrollToView = true) {
342
- const visibleOptions = this.flatOptions.filter(opt => opt.visible);
343
- if (visibleOptions.length === 0) return;
344
-
345
- let currentVisibleIndex = visibleOptions.findIndex(
346
- opt => opt === this.flatOptions[this.#currentHighlightIndex]
347
- );
348
-
349
- if (currentVisibleIndex === -1) currentVisibleIndex = -1;
350
-
351
- let nextVisibleIndex = currentVisibleIndex + direction;
352
-
353
- if (nextVisibleIndex >= visibleOptions.length) nextVisibleIndex = 0;
354
- if (nextVisibleIndex < 0) nextVisibleIndex = visibleOptions.length - 1;
355
-
356
- const nextOption = visibleOptions[nextVisibleIndex];
357
- const flatIndex = this.flatOptions.indexOf(nextOption);
358
-
359
- this.setHighlight(flatIndex, isScrollToView);
360
- }
361
-
362
- /**
363
- * Triggers a click on the currently highlighted and visible option to select it.
364
- * No-op if nothing is highlighted or the highlighted item is not visible.
365
- */
366
- selectHighlighted() {
367
- if (this.#currentHighlightIndex > -1 && this.flatOptions[this.#currentHighlightIndex]) {
368
- const item = this.flatOptions[this.#currentHighlightIndex];
369
- if (item.visible) {
370
- const viewEl = item.view?.getView();
371
- if (viewEl) viewEl.click();
372
- }
373
- }
374
- }
375
-
376
- /**
377
- * Highlights a target option by flat index or model instance, skipping invisible items,
378
- * and optionally scrolls the highlighted element into view.
379
- *
380
- * @param {number|OptionModel} target - Flat index or the specific OptionModel to highlight.
381
- * @param {boolean} [isScrollToView=true] - Whether to scroll the highlighted item into view.
382
- */
383
- setHighlight(target, isScrollToView = true) {
384
- let index = 0;
385
- if (typeof target === "number") {
386
- index = target;
387
- } else if (target instanceof OptionModel) {
388
- const fi = this.flatOptions.indexOf(target);
389
- index = fi > -1 ? fi : 0;
390
- } else {
391
- index = 0;
392
- }
393
-
394
- if (this.#currentHighlightIndex > -1 && this.flatOptions[this.#currentHighlightIndex]) {
395
- this.flatOptions[this.#currentHighlightIndex].highlighted = false;
396
- }
397
-
398
- for (let i = index; i < this.flatOptions.length; i++) {
399
- const item = this.flatOptions[i];
400
- if (item.visible) {
401
- item.highlighted = true;
402
- this.#currentHighlightIndex = i;
403
-
404
- if (isScrollToView) {
405
- const el = item.view?.getView();
406
- if (el) {
407
- el.scrollIntoView({ block: "center", behavior: "smooth" });
408
- }
409
- }
410
-
411
- this.onHighlightChange(i, item.view?.getView()?.id);
412
- return;
413
- }
414
- }
415
- }
416
-
417
- /**
418
- * Hook invoked whenever the highlight changes.
419
- * Override to handle UI side effects (e.g., ARIA announcement, focus sync).
420
- *
421
- * @param {number} index - The flat index of the newly highlighted item.
422
- * @param {string|undefined} id - The DOM id of the highlighted item's view, if available.
423
- */
424
- onHighlightChange(index, id) {}
425
-
426
-
427
- /**
428
- * Hook invoked when a group's collapsed state changes.
429
- * Override to handle side effects like analytics or layout adjustments.
430
- *
431
- * @param {GroupModel} model - The group whose collapsed state changed.
432
- * @param {boolean} collapsed - The new collapsed state.
433
- */
434
- onCollapsedChange(model, collapsed) {}
1
+ import { Adapter } from "../core/base/adapter";
2
+ import { GroupModel } from "../models/group-model";
3
+ import { OptionModel } from "../models/option-model";
4
+ import { GroupView } from "../views/group-view";
5
+ import { OptionView } from "../views/option-view";
6
+
7
+ /**
8
+ * @extends {Adapter<GroupModel|OptionModel>}
9
+ */
10
+ export class MixedAdapter extends Adapter {
11
+ isMultiple = false;
12
+ #visibilityChangedCallbacks = [];
13
+ #currentHighlightIndex = -1;
14
+ /** @type {OptionModel} */
15
+ #selectedItemSingle = null;
16
+
17
+ /** @type {GroupModel[]} */
18
+ groups = [];
19
+
20
+ /** @type {OptionModel[]} */
21
+ flatOptions = [];
22
+
23
+ constructor(items = []) {
24
+ super(items);
25
+ this.#buildFlatStructure();
26
+ }
27
+
28
+ /**
29
+ * Build flat list of all options for navigation
30
+ */
31
+ #buildFlatStructure() {
32
+ this.flatOptions = [];
33
+ this.groups = [];
34
+
35
+ this.items.forEach(item => {
36
+ if (item instanceof GroupModel) {
37
+ this.groups.push(item);
38
+ this.flatOptions.push(...item.items);
39
+ } else if (item instanceof OptionModel) {
40
+ this.flatOptions.push(item);
41
+ }
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Creates and returns the appropriate view instance for the given item type.
47
+ *
48
+ * @param {HTMLElement} parent - The parent container element where the view will be attached.
49
+ * @param {GroupModel|OptionModel} item - The data model representing either a group or an option.
50
+ * @returns {GroupView|OptionView} - A new view instance corresponding to the item type.
51
+ */
52
+ viewHolder(parent, item) {
53
+ if (item instanceof GroupModel) {
54
+ return new GroupView(parent);
55
+ } else {
56
+ return new OptionView(parent);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Binds a data model (GroupModel or OptionModel) to its corresponding view
62
+ * and delegates rendering logic based on the type of item.
63
+ *
64
+ * @param {GroupModel|OptionModel} item - The data model representing either a group or an option.
65
+ * @param {GroupView|OptionView} viewer - The view instance that displays the item in the UI.
66
+ * @param {number} position - The position (index) of the item within its parent list.
67
+ */
68
+ onViewHolder(item, viewer, position) {
69
+ item.position = position;
70
+
71
+ if (item instanceof GroupModel) {
72
+ this.#handleGroupView(item, /** @type {GroupView} */ (viewer), position);
73
+ } else if (item instanceof OptionModel) {
74
+ this.#handleOptionView(item, /** @type {OptionView} */ (viewer), position);
75
+ }
76
+
77
+ item.isInit = true;
78
+ }
79
+
80
+ /**
81
+ * Handles binding and rendering logic for a group view, including header behavior,
82
+ * collapse/expand state, and initialization of option items.
83
+ *
84
+ * @param {GroupModel} groupModel - The data model representing the group and its items.
85
+ * @param {GroupView} groupView - The view instance that renders the group in the UI.
86
+ * @param {number} position - The position (index) of the group within a list.
87
+ */
88
+ #handleGroupView(groupModel, groupView, position) {
89
+ super.onViewHolder(groupModel, groupView, position);
90
+ groupModel.view = groupView;
91
+
92
+ const header = groupView.getTag("GroupHeader");
93
+ header.textContent = groupModel.label;
94
+
95
+ if (!groupModel.isInit) {
96
+ header.style.cursor = "pointer";
97
+ header.addEventListener("click", () => {
98
+ groupModel.toggleCollapse();
99
+ });
100
+
101
+ groupModel.onCollapsedChanged((evtToken, model, collapsed) => {
102
+ model.items.forEach(optItem => {
103
+ const optView = optItem.view?.getView();
104
+ if (optView) {
105
+ optView.style.display = collapsed ? "none" : "";
106
+ }
107
+ });
108
+ this.onCollapsedChange(model, collapsed);
109
+ });
110
+ }
111
+
112
+ const itemsContainer = groupView.getItemsContainer();
113
+ groupModel.items.forEach((optionModel, idx) => {
114
+ let optionViewer = optionModel.view;
115
+ if (!optionModel.isInit) {
116
+ optionViewer = new OptionView(itemsContainer);
117
+ }
118
+ this.#handleOptionView(optionModel, optionViewer, idx);
119
+ optionModel.isInit = true;
120
+ });
121
+
122
+ groupView.setCollapsed(groupModel.collapsed);
123
+ groupView.updateVisibility();
124
+ }
125
+
126
+ /**
127
+ * Handles binding and rendering logic for an option item, including image setup,
128
+ * label rendering, event wiring, and selection state synchronization.
129
+ *
130
+ * @param {OptionModel} optionModel - The data model representing a single option.
131
+ * @param {OptionView} optionViewer - The view instance that renders the option in the UI.
132
+ * @param {number} position - The index of this option within its group's item list.
133
+ */
134
+ #handleOptionView(optionModel, optionViewer, position) {
135
+ optionViewer.isMultiple = this.isMultiple;
136
+ optionViewer.hasImage = optionModel.hasImage;
137
+ optionViewer.optionConfig = {
138
+ imageWidth: optionModel.options.imageWidth,
139
+ imageHeight: optionModel.options.imageHeight,
140
+ imageBorderRadius: optionModel.options.imageBorderRadius,
141
+ imagePosition: optionModel.options.imagePosition,
142
+ labelValign: optionModel.options.labelValign,
143
+ labelHalign: optionModel.options.labelHalign
144
+ };
145
+
146
+ if (!optionModel.isInit) {
147
+ super.onViewHolder(optionModel, optionViewer, position);
148
+ } else {
149
+ optionViewer.update();
150
+ }
151
+
152
+ optionModel.view = optionViewer;
153
+
154
+ if (optionModel.hasImage) {
155
+ const imageTag = optionViewer.getTag("OptionImage");
156
+ if (imageTag) {
157
+ imageTag.src = optionModel.imageSrc;
158
+ imageTag.alt = optionModel.text;
159
+ }
160
+ }
161
+
162
+ optionViewer.getTag("LabelContent").innerHTML = optionModel.text;
163
+
164
+ if (!optionModel.isInit) {
165
+ optionViewer.getTag("OptionView").addEventListener("click", (ev) => {
166
+ ev.stopPropagation();
167
+ ev.preventDefault();
168
+
169
+ if (this.isSkipEvent) return;
170
+
171
+ if (this.isMultiple) {
172
+ this.changingProp("select");
173
+ setTimeout(() => {
174
+ optionModel.selected = !optionModel.selected;
175
+ }, 5);
176
+ } else if (optionModel.selected !== true) {
177
+ this.changingProp("select");
178
+ setTimeout(() => {
179
+ if (this.#selectedItemSingle) {
180
+ this.#selectedItemSingle.selected = false;
181
+ }
182
+ optionModel.selected = true;
183
+ }, 5);
184
+ }
185
+ });
186
+
187
+ optionViewer.getTag("OptionView").title = optionModel.textContent;
188
+
189
+ optionViewer.getTag("OptionView").addEventListener("mouseenter", () => {
190
+ if (this.isSkipEvent) return;
191
+ this.setHighlight(this.flatOptions.indexOf(optionModel), false);
192
+ });
193
+
194
+ optionModel.onSelected((evtToken, el, selected) => {
195
+ this.changeProp("selected");
196
+ });
197
+
198
+ optionModel.onInternalSelected((evtToken, el, selected) => {
199
+ if (selected) {
200
+ this.#selectedItemSingle = optionModel;
201
+ }
202
+ this.changeProp("selected_internal");
203
+ });
204
+
205
+ optionModel.onVisibilityChanged((evtToken, model, visible) => {
206
+ if (model.group) {
207
+ model.group.updateVisibility();
208
+ }
209
+ this.#notifyVisibilityChanged();
210
+ });
211
+ }
212
+
213
+ if (optionModel.selected) {
214
+ this.#selectedItemSingle = optionModel;
215
+ optionModel.selectedNonTrigger = true;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Updates the list of items in the component and rebuilds its internal flat structure.
221
+ *
222
+ * @param {Array<GroupModel|OptionModel>} items - The new collection of items to be displayed.
223
+ */
224
+ setItems(items) {
225
+ this.changingProp("items", items);
226
+ this.items = items;
227
+ this.#buildFlatStructure();
228
+ this.changeProp("items", items);
229
+ }
230
+
231
+ /**
232
+ * Synchronizes the component's items from an external source by delegating to setItems().
233
+ *
234
+ * @param {Array<GroupModel|OptionModel>} items - The new collection of items to sync.
235
+ */
236
+ syncFromSource(items) {
237
+ this.setItems(items);
238
+ }
239
+
240
+ /**
241
+ * Updates the component's data items and rebuilds the internal flat structure
242
+ * without triggering change notifications.
243
+ *
244
+ * @param {Array<GroupModel|OptionModel>} items - The new collection of items to update.
245
+ */
246
+ updateData(items) {
247
+ this.items = items;
248
+ this.#buildFlatStructure();
249
+ }
250
+
251
+ /**
252
+ * Returns all option items that are currently selected.
253
+ *
254
+ * @returns {OptionModel[]} - An array of selected option items from the flat list.
255
+ */
256
+ getSelectedItems() {
257
+ return this.flatOptions.filter(item => item.selected);
258
+ }
259
+
260
+ /**
261
+ * Returns the first selected option item, if any.
262
+ *
263
+ * @returns {OptionModel|undefined} - The first selected option or undefined if none are selected.
264
+ */
265
+ getSelectedItem() {
266
+ return this.flatOptions.find(item => item.selected);
267
+ }
268
+
269
+ /**
270
+ * Checks or unchecks all options when in multiple selection mode.
271
+ *
272
+ * @param {boolean} isChecked - If true, select all; if false, deselect all.
273
+ */
274
+ checkAll(isChecked) {
275
+ if (this.isMultiple) {
276
+ this.flatOptions.forEach(item => {
277
+ item.selected = isChecked;
278
+ });
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Subscribes a callback to visibility changes across options.
284
+ *
285
+ * @param {(stats: {visibleCount:number,totalCount:number,hasVisible:boolean,isEmpty:boolean}) => void} callback
286
+ * - Function to invoke when visibility stats change.
287
+ */
288
+ onVisibilityChanged(callback) {
289
+ this.#visibilityChangedCallbacks.push(callback);
290
+ }
291
+
292
+ /**
293
+ * Notifies all registered visibility-change callbacks with up-to-date statistics.
294
+ * Computes visible and total counts, then emits aggregated state.
295
+ */
296
+ #notifyVisibilityChanged() {
297
+ const visibleCount = this.flatOptions.filter(item => item.visible).length;
298
+ const totalCount = this.flatOptions.length;
299
+
300
+ this.#visibilityChangedCallbacks.forEach(callback => {
301
+ callback({
302
+ visibleCount,
303
+ totalCount,
304
+ hasVisible: visibleCount > 0,
305
+ isEmpty: totalCount === 0
306
+ });
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Computes and returns current visibility statistics for options.
312
+ *
313
+ * @returns {{visibleCount:number,totalCount:number,hasVisible:boolean,isEmpty:boolean}}
314
+ * - Aggregated visibility information.
315
+ */
316
+ getVisibilityStats() {
317
+ const visibleCount = this.flatOptions.filter(item => item.visible).length;
318
+ const totalCount = this.flatOptions.length;
319
+
320
+ return {
321
+ visibleCount,
322
+ totalCount,
323
+ hasVisible: visibleCount > 0,
324
+ isEmpty: totalCount === 0
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Resets the highlight to the first visible option (index 0).
330
+ */
331
+ resetHighlight() {
332
+ this.setHighlight(0);
333
+ }
334
+
335
+ /**
336
+ * Moves the highlight forward/backward among visible options and optionally scrolls into view.
337
+ *
338
+ * @param {number} direction - Increment (+1) or decrement (-1) of the current visible index.
339
+ * @param {boolean} [isScrollToView=true] - Whether to scroll the highlighted item into view.
340
+ */
341
+ navigate(direction, isScrollToView = true) {
342
+ const visibleOptions = this.flatOptions.filter(opt => opt.visible);
343
+ if (visibleOptions.length === 0) return;
344
+
345
+ let currentVisibleIndex = visibleOptions.findIndex(
346
+ opt => opt === this.flatOptions[this.#currentHighlightIndex]
347
+ );
348
+
349
+ if (currentVisibleIndex === -1) currentVisibleIndex = -1;
350
+
351
+ let nextVisibleIndex = currentVisibleIndex + direction;
352
+
353
+ if (nextVisibleIndex >= visibleOptions.length) nextVisibleIndex = 0;
354
+ if (nextVisibleIndex < 0) nextVisibleIndex = visibleOptions.length - 1;
355
+
356
+ const nextOption = visibleOptions[nextVisibleIndex];
357
+ const flatIndex = this.flatOptions.indexOf(nextOption);
358
+
359
+ this.setHighlight(flatIndex, isScrollToView);
360
+ }
361
+
362
+ /**
363
+ * Triggers a click on the currently highlighted and visible option to select it.
364
+ * No-op if nothing is highlighted or the highlighted item is not visible.
365
+ */
366
+ selectHighlighted() {
367
+ if (this.#currentHighlightIndex > -1 && this.flatOptions[this.#currentHighlightIndex]) {
368
+ const item = this.flatOptions[this.#currentHighlightIndex];
369
+ if (item.visible) {
370
+ const viewEl = item.view?.getView();
371
+ if (viewEl) viewEl.click();
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Highlights a target option by flat index or model instance, skipping invisible items,
378
+ * and optionally scrolls the highlighted element into view.
379
+ *
380
+ * @param {number|OptionModel} target - Flat index or the specific OptionModel to highlight.
381
+ * @param {boolean} [isScrollToView=true] - Whether to scroll the highlighted item into view.
382
+ */
383
+ setHighlight(target, isScrollToView = true) {
384
+ let index = 0;
385
+ if (typeof target === "number") {
386
+ index = target;
387
+ } else if (target instanceof OptionModel) {
388
+ const fi = this.flatOptions.indexOf(target);
389
+ index = fi > -1 ? fi : 0;
390
+ } else {
391
+ index = 0;
392
+ }
393
+
394
+ if (this.#currentHighlightIndex > -1 && this.flatOptions[this.#currentHighlightIndex]) {
395
+ this.flatOptions[this.#currentHighlightIndex].highlighted = false;
396
+ }
397
+
398
+ for (let i = index; i < this.flatOptions.length; i++) {
399
+ const item = this.flatOptions[i];
400
+ if (item.visible) {
401
+ item.highlighted = true;
402
+ this.#currentHighlightIndex = i;
403
+
404
+ if (isScrollToView) {
405
+ const el = item.view?.getView();
406
+ if (el) {
407
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
408
+ }
409
+ }
410
+
411
+ this.onHighlightChange(i, item.view?.getView()?.id);
412
+ return;
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Hook invoked whenever the highlight changes.
419
+ * Override to handle UI side effects (e.g., ARIA announcement, focus sync).
420
+ *
421
+ * @param {number} index - The flat index of the newly highlighted item.
422
+ * @param {string|undefined} id - The DOM id of the highlighted item's view, if available.
423
+ */
424
+ onHighlightChange(index, id) {}
425
+
426
+
427
+ /**
428
+ * Hook invoked when a group's collapsed state changes.
429
+ * Override to handle side effects like analytics or layout adjustments.
430
+ *
431
+ * @param {GroupModel} model - The group whose collapsed state changed.
432
+ * @param {boolean} collapsed - The new collapsed state.
433
+ */
434
+ onCollapsedChange(model, collapsed) {}
435
435
  }