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,60 @@
1
+ import { Libs } from "../utils/libs";
2
+
3
+ export class LoadingState {
4
+
5
+ /** @type {HTMLDivElement} */
6
+ node = null;
7
+
8
+ options = null;
9
+
10
+ /**
11
+ * Represents a loading state component that displays a loading message during data fetch or processing.
12
+ * Provides methods to show/hide the state and check its visibility.
13
+ */
14
+ constructor(options = null) {
15
+ options && this.init(options);
16
+ }
17
+
18
+ /**
19
+ * Initializes the loading state element with ARIA attributes for accessibility and stores configuration options.
20
+ *
21
+ * @param {object} options - Configuration object containing text for the loading message.
22
+ */
23
+ init(options) {
24
+ this.options = options;
25
+ this.node = /** @type {HTMLDivElement} */(Libs.nodeCreator({
26
+ node: "div",
27
+ classList: ["selective-ui-loading-state", "hide"],
28
+ textContent: options.textLoading,
29
+ role: "status",
30
+ ariaLive: "polite"
31
+ }));
32
+ }
33
+
34
+ /**
35
+ * Displays the loading state message and adjusts its size based on whether items are present.
36
+ *
37
+ * @param {boolean} hasItems - If true, applies a "small" style for compact display.
38
+ */
39
+ show(hasItems) {
40
+ this.node.textContent = this.options.textLoading;
41
+ this.node.classList.toggle("small", hasItems);
42
+ this.node.classList.remove("hide");
43
+ }
44
+
45
+ /**
46
+ * Hides the loading state element by adding the "hide" class.
47
+ */
48
+ hide() {
49
+ this.node.classList.add("hide");
50
+ }
51
+
52
+ /**
53
+ * Indicates whether the loading state is currently visible.
54
+ *
55
+ * @returns {boolean} - True if visible, false otherwise.
56
+ */
57
+ get isVisible() {
58
+ return !this.node.classList.contains("hide");
59
+ }
60
+ }
@@ -0,0 +1,114 @@
1
+ import { iEvents } from "../utils/ievents.js";
2
+ import {Libs} from "../utils/libs.js";
3
+
4
+ /**
5
+ * @class
6
+ */
7
+ export class OptionHandle {
8
+ nodeMounted;
9
+ /**
10
+ * @type {HTMLDivElement}
11
+ */
12
+ node;
13
+
14
+ options = null;
15
+
16
+ /** @type {Function[]} */
17
+ #ActionOnSelectAll = [];
18
+
19
+ /** @type {Function[]} */
20
+ #ActionOnDeSelectAll = [];
21
+
22
+ /**
23
+ * Represents an option handle component that provides "Select All" and "Deselect All" actions
24
+ * for multiple-selection lists. Includes methods to show/hide the handle, refresh its visibility,
25
+ * and register callbacks for select/deselect events.
26
+ */
27
+ constructor(options = null) {
28
+ if (options) {
29
+ this.init(options);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Initializes the option handle UI with "Select All" and "Deselect All" buttons,
35
+ * wiring their click events to trigger registered callbacks.
36
+ *
37
+ * @param {object} options - Configuration object containing text labels and feature flags.
38
+ */
39
+ init(options) {
40
+ this.nodeMounted = Libs.mountNode({
41
+ OptionHandle: {
42
+ tag: {node: "div", classList: ["selective-ui-option-handle", "hide"]},
43
+ child: {
44
+ SelectAll: {
45
+ tag: {node: "a", classList: "selective-ui-option-handle-item", textContent: options.textSelectAll, onclick: () => {
46
+ iEvents.callFunctions(this.#ActionOnSelectAll);
47
+ }}
48
+ },
49
+ DeSelectAll: {
50
+ tag: {node: "a", classList: "selective-ui-option-handle-item", textContent: options.textDeselectAll, onclick: () => {
51
+ iEvents.callFunctions(this.#ActionOnDeSelectAll);
52
+ }}
53
+ }
54
+ }
55
+ }
56
+ });
57
+ this.node = this.nodeMounted.view;
58
+ this.options = options;
59
+ }
60
+
61
+ /**
62
+ * Determines whether the option handle should be available based on configuration.
63
+ *
64
+ * @returns {boolean} - True if multiple selection and select-all features are enabled.
65
+ */
66
+ available() {
67
+ return Libs.string2Boolean(this.options.multiple) && Libs.string2Boolean(this.options.selectall);
68
+ }
69
+
70
+ /**
71
+ * Refreshes the visibility of the option handle based on availability.
72
+ * Shows the handle if available; hides it otherwise.
73
+ */
74
+ refresh() {
75
+ if (this.available()) {
76
+ this.show();
77
+ }
78
+ else {
79
+ this.hide();
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Makes the option handle visible by removing the "hide" class.
85
+ */
86
+ show() {
87
+ this.node.classList.remove("hide");
88
+ }
89
+
90
+ /**
91
+ * Hides the option handle by adding the "hide" class.
92
+ */
93
+ hide() {
94
+ this.node.classList.add("hide");
95
+ }
96
+
97
+ /**
98
+ * Registers a callback to be executed when "Select All" is clicked.
99
+ *
100
+ * @param {Function|null} action - The function to call on select-all action.
101
+ */
102
+ OnSelectAll(action = null) {
103
+ this.#ActionOnSelectAll.push(action);
104
+ }
105
+
106
+ /**
107
+ * Registers a callback to be executed when "Deselect All" is clicked.
108
+ *
109
+ * @param {Function|null} action - The function to call on deselect-all action.
110
+ */
111
+ OnDeSelectAll(action = null) {
112
+ this.#ActionOnDeSelectAll.push(action);
113
+ }
114
+ }
@@ -0,0 +1,57 @@
1
+ import {Libs} from "../utils/libs.js";
2
+
3
+ /**
4
+ * @class
5
+ */
6
+ export class PlaceHolder {
7
+ /**
8
+ * Represents a placeholder component for the Select UI, allowing dynamic updates to placeholder text.
9
+ * Supports HTML content based on configuration and provides methods to get or set the placeholder value.
10
+ */
11
+ constructor(options) {
12
+ options && this.init(options);
13
+ }
14
+
15
+ /**
16
+ * @type {Element}
17
+ */
18
+ node = null;
19
+
20
+ #options = null;
21
+
22
+ /**
23
+ * Initializes the placeholder element with provided options and renders its initial content.
24
+ *
25
+ * @param {object} options - Configuration object containing placeholder text and HTML allowance.
26
+ */
27
+ init(options) {
28
+ this.node = Libs.nodeCreator({
29
+ node: "div", classList: "selective-ui-placeholder", innerHTML: options.placeholder
30
+ });
31
+ this.#options = options;
32
+ }
33
+
34
+ /**
35
+ * Retrieves the current placeholder text from the configuration.
36
+ *
37
+ * @returns {string} - The current placeholder text.
38
+ */
39
+ get() {
40
+ return this.#options.placeholder
41
+ }
42
+
43
+ /**
44
+ * Updates the placeholder text and optionally saves it to the configuration.
45
+ * Applies HTML sanitization based on the allowHtml setting.
46
+ *
47
+ * @param {string} value - The new placeholder text.
48
+ * @param {boolean} [isSave=true] - Whether to persist the new value in the configuration.
49
+ */
50
+ set(value, isSave = true) {
51
+ if (isSave) {
52
+ this.#options.placeholder = value;
53
+ }
54
+ value = Libs.tagTranslate(value);
55
+ this.node.innerHTML = this.#options.allowHtml ? value : Libs.stripHtml(value);
56
+ }
57
+ }
@@ -0,0 +1,471 @@
1
+ import { Libs } from "../utils/libs.js";
2
+ import { OptionHandle } from "./option-handle.js";
3
+ import { ResizeObserverService } from "../services/resize-observer.js"
4
+ import { ModelManager } from "../core/model-manager.js";
5
+ import { OptionModel } from "../models/option-model.js";
6
+ import { EmptyState } from "./empty-state.js";
7
+ import { LoadingState } from "./loading-state.js";
8
+ import { MixedAdapter } from "../adapter/mixed-adapter.js";
9
+
10
+ /**
11
+ * @class
12
+ */
13
+ export class Popup {
14
+ /** @type {ModelManager<OptionModel, MixedAdapter>} */
15
+ #modelManager;
16
+
17
+ /**
18
+ * Represents a popup component that manages rendering and interaction for a dropdown panel.
19
+ * Stores a reference to the ModelManager for handling option models and adapter logic.
20
+ *
21
+ * @param {HTMLSelectElement|null} [select=null] - The source select element to bind.
22
+ * @param {object|null} [options=null] - Configuration options for the popup.
23
+ * @param {ModelManager|null} [modelManager=null] - The model manager instance for data handling.
24
+ */
25
+ constructor(select = null, options = null, modelManager = null) {
26
+ this.#modelManager = modelManager;
27
+ if (select && options) {
28
+ this.init(select, options);
29
+ }
30
+ }
31
+
32
+ options = null;
33
+ isCreated = false;
34
+
35
+ /**
36
+ * @type {MixedAdapter}
37
+ */
38
+ optionAdapter = null;
39
+
40
+ /**
41
+ * @type {HTMLDivElement}
42
+ */
43
+ node = null;
44
+
45
+ /**
46
+ * @type {EffectorInterface}
47
+ */
48
+ #effSvc = null;
49
+
50
+ /**
51
+ * @type {ResizeObserverService}
52
+ */
53
+ #resizeObser;
54
+
55
+ #parent = null;
56
+
57
+ /**
58
+ * @type {OptionHandle}
59
+ */
60
+ optionHandle = null;
61
+
62
+ /**
63
+ * @type {EmptyState}
64
+ */
65
+ emptyState = null;
66
+
67
+ /**
68
+ * @type {LoadingState}
69
+ */
70
+ loadingState = null;
71
+
72
+ /**
73
+ * @type {RecyclerViewContract<MixedAdapter>}
74
+ */
75
+ recyclerView = null;
76
+
77
+ /**
78
+ * @type {HTMLDivElement}
79
+ */
80
+ #optionsContainer = null;
81
+
82
+ #scrollListener = null;
83
+
84
+ /**
85
+ * @type {NodeJS.Timeout}
86
+ */
87
+ #hideLoadHandle = null;
88
+
89
+ /**
90
+ * Initializes the popup UI: creates DOM structure, wires OptionHandle, LoadingState, and EmptyState,
91
+ * binds ModelManager resources (adapter/recyclerView), and sets up empty-state logic.
92
+ *
93
+ * @param {HTMLSelectElement} select - The source select element to bind.
94
+ * @param {object} options - Configuration for panel, IDs, multiple mode, and texts.
95
+ */
96
+ init(select, options) {
97
+ this.optionHandle = new OptionHandle(options);
98
+ this.emptyState = new EmptyState(options);
99
+ this.loadingState = new LoadingState(options);
100
+
101
+ const nodeMounted = Libs.mountNode({
102
+ PopupContainer: {
103
+ tag: {node: "div", classList: "selective-ui-popup", style: {maxHeight: options.panelHeight}},
104
+ child: {
105
+ OptionHandle: {
106
+ tag: this.optionHandle.node
107
+ },
108
+ OptionsContainer: {
109
+ tag: {id: options.SEID_LIST, node: "div", classList: "selective-ui-options-container", role: "listbox"}
110
+ },
111
+ LoadingState: {
112
+ tag: this.loadingState.node
113
+ },
114
+ EmptyState: {
115
+ tag: this.emptyState.node
116
+ }
117
+ }
118
+ }
119
+ });
120
+
121
+ this.node = nodeMounted.view;
122
+ this.#optionsContainer = nodeMounted.tags.OptionsContainer;
123
+ this.#parent = Libs.getBinderMap(select);
124
+ this.options = options;
125
+
126
+ this.#modelManager.load(this.#optionsContainer, {isMultiple: options.multiple});
127
+ const MMResources = this.#modelManager.getResources();
128
+ this.optionAdapter = MMResources.adapter;
129
+ this.recyclerView = MMResources.recyclerView;
130
+
131
+ this.optionHandle.OnSelectAll(() => {
132
+ MMResources.adapter.checkAll(true);
133
+ });
134
+ this.optionHandle.OnDeSelectAll(() => {
135
+ MMResources.adapter.checkAll(false);
136
+ });
137
+
138
+ this.#setupEmptyStateLogic();
139
+ }
140
+
141
+ /**
142
+ * Shows the loading state and temporarily skips model events.
143
+ * Adjusts size based on current visibility stats and triggers a resize.
144
+ */
145
+ async showLoading() {
146
+ clearTimeout(this.#hideLoadHandle);
147
+
148
+ this.#modelManager.skipEvent(true);
149
+ if (Libs.string2Boolean(this.options.loadingfield) === false) return;
150
+ this.loadingState.show(this.optionAdapter.getVisibilityStats().hasVisible);
151
+ // this.#optionsContainer.classList.add("hide");
152
+ this.optionHandle.hide();
153
+ this.triggerResize();
154
+ }
155
+
156
+ /**
157
+ * Hides the loading state after a short delay, restores event handling,
158
+ * updates empty state based on adapter visibility stats, and triggers a resize.
159
+ */
160
+ async hideLoading() {
161
+ clearTimeout(this.#hideLoadHandle);
162
+
163
+ this.#hideLoadHandle = setTimeout(() => {
164
+ this.#modelManager.skipEvent(false);
165
+ this.loadingState.hide();
166
+
167
+ const stats = this.optionAdapter.getVisibilityStats();
168
+ this.#updateEmptyState(stats);
169
+
170
+ this.triggerResize();
171
+ }, 200);
172
+ }
173
+
174
+ /**
175
+ * Subscribes to adapter visibility and item changes to keep the empty state in sync.
176
+ * Triggers resize when items change to reflect layout updates.
177
+ */
178
+ #setupEmptyStateLogic() {
179
+ this.optionAdapter.onVisibilityChanged((stats) => {
180
+ this.#updateEmptyState(stats);
181
+ });
182
+
183
+ this.optionAdapter.onPropChanged("items", () => {
184
+ const stats = this.optionAdapter.getVisibilityStats();
185
+ this.#updateEmptyState(stats);
186
+
187
+ this.triggerResize();
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Updates the empty state and option container visibility based on aggregated stats.
193
+ * Shows "no data" when empty, "not found" when no visible items, otherwise shows options and handle.
194
+ *
195
+ * @param {{visibleCount:number,totalCount:number,hasVisible:boolean,isEmpty:boolean}|undefined} stats - Visibility stats; computed if omitted.
196
+ */
197
+ #updateEmptyState(stats = null) {
198
+ if (!stats) {
199
+ stats = this.optionAdapter.getVisibilityStats();
200
+ }
201
+
202
+ if (stats?.isEmpty) {
203
+ this.emptyState.show("nodata");
204
+ this.#optionsContainer.classList.add("hide");
205
+ this.optionHandle.hide();
206
+ } else if (stats && !stats.hasVisible) {
207
+ this.emptyState.show("notfound");
208
+ this.#optionsContainer.classList.add("hide");
209
+ this.optionHandle.hide();
210
+ } else {
211
+ this.emptyState.hide();
212
+ this.#optionsContainer.classList.remove("hide");
213
+ this.optionHandle.refresh();
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Registers a callback for adapter property pre-change notifications.
219
+ *
220
+ * @param {string} propName - The adapter property to observe.
221
+ * @param {Function} callback - Invoked before the property changes.
222
+ */
223
+ onAdapterPropChanging(propName, callback) {
224
+ this.optionAdapter.onPropChanging(propName, callback);
225
+ }
226
+
227
+ /**
228
+ * Registers a callback for adapter property post-change notifications.
229
+ *
230
+ * @param {string} propName - The adapter property to observe.
231
+ * @param {Function} callback - Invoked after the property changes.
232
+ */
233
+ onAdapterPropChanged(propName, callback) {
234
+ this.optionAdapter.onPropChanged(propName, callback);
235
+ }
236
+
237
+ /**
238
+ * Injects an effector service used to perform side effects (e.g., animations or external actions).
239
+ *
240
+ * @param {EffectorInterface} effectorSvc - The effector service instance.
241
+ */
242
+ setupEffector(effectorSvc) {
243
+ this.#effSvc = effectorSvc;
244
+ }
245
+
246
+ /**
247
+ * Opens the popup: creates and attaches DOM if needed, initializes observers and effector,
248
+ * computes position and dimensions, and runs expand animation. Invokes callback on completion.
249
+ *
250
+ * @param {Function|null} [callback=null] - Function to call after the popup finishes expanding.
251
+ */
252
+ open(callback = null) {
253
+ if (!this.isCreated) {
254
+ document.body.appendChild(this.node);
255
+ this.isCreated = true;
256
+
257
+ this.#resizeObser = new ResizeObserverService();
258
+ this.#effSvc.setElement(this.node);
259
+
260
+ this.node.addEventListener("mousedown", (e) => {
261
+ e.stopPropagation();
262
+ e.preventDefault();
263
+ });
264
+ }
265
+
266
+ this.optionHandle.refresh();
267
+ this.#updateEmptyState();
268
+
269
+ const location = this.#getParentLocation();
270
+ const {position, top, maxHeight, realHeight} = this.#calculatePosition(location);
271
+
272
+ this.#effSvc.expand({
273
+ duration: this.options.animationtime,
274
+ display: "flex",
275
+ width: location.width,
276
+ left: location.left,
277
+ top: top,
278
+ maxHeight: maxHeight,
279
+ realHeight: realHeight,
280
+ position: position,
281
+ onComplete: () => {
282
+ this.#resizeObser.onChanged = (newLocation) => this.#handleResize(newLocation);
283
+ this.#resizeObser.connect(this.#parent.container.tags.ViewPanel);
284
+ callback && callback();
285
+ }
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Closes the popup: disconnects the resize observer and runs collapse animation.
291
+ * Safely no-ops if the popup has not been created.
292
+ *
293
+ * @param {Function|null} [callback=null] - Function to call after the popup finishes collapsing.
294
+ */
295
+ close(callback = null) {
296
+ if (!this.isCreated) {
297
+ return;
298
+ }
299
+
300
+ this.#resizeObser.disconnect();
301
+
302
+ this.#effSvc.collapse({
303
+ duration: this.options.animationtime,
304
+ onComplete: callback
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Programmatically triggers a resize recalculation if the popup is created,
310
+ * causing the layout to update based on the current parent dimensions.
311
+ */
312
+ triggerResize() {
313
+ if (this.isCreated) {
314
+ this.#resizeObser.trigger();
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Enables infinite scroll by listening to container scroll events and loading more data
320
+ * when nearing the bottom, respecting pagination state (enabled/loading/hasMore).
321
+ *
322
+ * @param {object} searchController - Controller providing pagination state and loadMore().
323
+ * @param {object} options - Additional configuration for the infinite scroll behavior.
324
+ */
325
+ setupInfiniteScroll(searchController, options) {
326
+ this.#scrollListener = async () => {
327
+ const state = searchController.getPaginationState();
328
+
329
+ if (!state.isPaginationEnabled) {
330
+ return;
331
+ }
332
+
333
+ const container = this.node;
334
+ const scrollTop = container.scrollTop;
335
+ const scrollHeight = container.scrollHeight;
336
+ const clientHeight = container.clientHeight;
337
+
338
+ if (scrollHeight - scrollTop - clientHeight < 100) {
339
+ if (!state.isLoading && state.hasMore) {
340
+ const result = await searchController.loadMore();
341
+
342
+ if (!result.success && result.message) {
343
+ console.log("Load more:", result.message);
344
+ }
345
+ }
346
+ }
347
+ };
348
+
349
+ this.node.addEventListener("scroll", this.#scrollListener);
350
+ }
351
+
352
+ /**
353
+ * Computes the parent panel's location and box metrics, including size, position,
354
+ * padding, and border, accounting for iOS visual viewport offsets.
355
+ *
356
+ * @returns {{
357
+ * width:number, height:number, top:number, left:number,
358
+ * padding:{top:number,right:number,bottom:number,left:number},
359
+ * border:{top:number,right:number,bottom:number,left:number}
360
+ * }} - The parent panel metrics in viewport coordinates.
361
+ */
362
+ #getParentLocation() {
363
+ const viewPanel = this.#parent.container.tags.ViewPanel;
364
+ const rect = viewPanel.getBoundingClientRect();
365
+ const style = window.getComputedStyle(viewPanel);
366
+
367
+ const vv = window.visualViewport;
368
+ const is_ios = Libs.IsIOS();
369
+
370
+ const viewportOffsetY = vv && is_ios ? vv.offsetTop : 0;
371
+ const viewportOffsetX = vv && is_ios ? vv.offsetLeft : 0;
372
+
373
+ return {
374
+ width: rect.width,
375
+ height: rect.height,
376
+ top: rect.top - viewportOffsetY,
377
+ left: rect.left - viewportOffsetX,
378
+ padding: {
379
+ top: parseFloat(style.paddingTop),
380
+ right: parseFloat(style.paddingRight),
381
+ bottom: parseFloat(style.paddingBottom),
382
+ left: parseFloat(style.paddingLeft),
383
+ },
384
+ border: {
385
+ top: parseFloat(style.borderTopWidth),
386
+ right: parseFloat(style.borderRightWidth),
387
+ bottom: parseFloat(style.borderBottomWidth),
388
+ left: parseFloat(style.borderLeftWidth),
389
+ }
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Determines popup placement (top/bottom) and height constraints based on available viewport space,
395
+ * content size, and configured min/max heights; returns final position, top, and heights.
396
+ *
397
+ * @param {{width:number,height:number,top:number,left:number}} location - Parent panel metrics.
398
+ * @returns {{position:string, top:number, maxHeight:number, realHeight:number, contentHeight:number}}
399
+ * - Calculated layout values for the popup.
400
+ */
401
+ #calculatePosition(location) {
402
+ const vv = window.visualViewport;
403
+ const is_ios = Libs.IsIOS();
404
+ const viewportHeight = vv?.height ?? window.innerHeight;
405
+ const viewportOffsetY = vv && is_ios ? vv.offsetTop : 0;
406
+
407
+ const gap = 3;
408
+ const safeMargin = 15;
409
+
410
+ const dimensions = this.#effSvc.getHiddenDimensions("flex");
411
+ const contentHeight = dimensions.scrollHeight;
412
+
413
+ const configMaxHeight = parseFloat(this.options.panelHeight) || 220;
414
+ const configMinHeight = parseFloat(this.options.panelMinHeight) || 100;
415
+
416
+ const spaceBelow = viewportHeight - (location.top + location.height) - gap;
417
+ const spaceAbove = location.top - gap;
418
+
419
+ let position = "bottom";
420
+ let maxHeight = configMaxHeight;
421
+ let realHeight = Math.min(contentHeight, maxHeight);
422
+ const heightOri = spaceBelow - safeMargin;
423
+
424
+ if (realHeight >= configMinHeight ? heightOri >= configMinHeight : heightOri >= realHeight) {
425
+ position = "bottom";
426
+ maxHeight = Math.min(spaceBelow - safeMargin, configMaxHeight);
427
+ }
428
+ else if (spaceAbove >= Math.max(realHeight, configMinHeight)) {
429
+ position = "top";
430
+ maxHeight = Math.min(spaceAbove - safeMargin, configMaxHeight);
431
+ }
432
+ else {
433
+ if (spaceBelow >= spaceAbove) {
434
+ position = "bottom";
435
+ maxHeight = Math.max(spaceBelow - safeMargin, configMinHeight);
436
+ } else {
437
+ position = "top";
438
+ maxHeight = Math.max(spaceAbove - safeMargin, configMinHeight);
439
+ }
440
+ }
441
+
442
+ realHeight = Math.min(contentHeight, maxHeight);
443
+
444
+ const top = position === "bottom"
445
+ ? (location.top + location.height + gap + viewportOffsetY)
446
+ : (location.top - realHeight - gap + viewportOffsetY);
447
+
448
+ return {position, top, maxHeight, realHeight, contentHeight};
449
+ }
450
+
451
+ /**
452
+ * Handles parent resize events by recalculating placement and dimensions,
453
+ * then animates the popup to the new size and position.
454
+ *
455
+ * @param {{width:number,height:number,top:number,left:number}} location - Updated parent metrics.
456
+ */
457
+ #handleResize(location) {
458
+ const {position, top, maxHeight, realHeight} = this.#calculatePosition(location);
459
+
460
+ this.#effSvc.resize({
461
+ duration: this.options.animationtime,
462
+ width: location.width,
463
+ left: location.left,
464
+ top: top,
465
+ maxHeight: maxHeight,
466
+ realHeight: realHeight,
467
+ position: position,
468
+ animate: true
469
+ });
470
+ }
471
+ }