inviton-powerduck 0.0.364 → 0.0.366

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 (28) hide show
  1. package/common/scroll-utils.ts +433 -433
  2. package/common/utils/currency-utils.ts +52 -52
  3. package/common/utils/deep-clone.ts +98 -98
  4. package/common/utils/object-post-processor.ts +43 -43
  5. package/components/app/vue-plugin-toplevelpage.ts +8 -8
  6. package/components/context-menu/context-menu-binder.ts +27 -27
  7. package/components/context-menu/css/context-menu.css +72 -72
  8. package/components/context-menu/native-context-menu.ts +155 -155
  9. package/components/datatable/css/datatable.css +29 -0
  10. package/components/datatable/datatable.tsx +9 -1
  11. package/components/dropdown/MIGRATION.md +318 -318
  12. package/components/dropdown/image-dropdown.tsx +128 -128
  13. package/components/image-crop/css/image-cropping-modal.css +19 -16
  14. package/components/input/color-picker.tsx +453 -453
  15. package/components/input/css/minicolors.css +142 -142
  16. package/components/input/css/password-input.css +77 -77
  17. package/components/input/password-input.tsx +334 -334
  18. package/components/loading-indicator/index.tsx +1 -0
  19. package/components/loading-indicator/loading-indicator.css +87 -0
  20. package/components/modal/css/modal.css +60 -60
  21. package/components/modal/modal-utils.ts +320 -320
  22. package/components/modal/modal.tsx +4 -0
  23. package/components/modal-wrap/modal-section-wrapper.tsx +5 -5
  24. package/components/spreadsheet/virtualization-plugin.ts +239 -239
  25. package/components/stars/css/star-rating.css +113 -113
  26. package/components/stars/stars.tsx +373 -373
  27. package/components/ui/vendor/notify.ts +595 -595
  28. package/package.json +3 -3
@@ -1,433 +1,433 @@
1
- import type { Vue } from 'vue-facing-decorator';
2
- import { globalState } from '../app/global-state';
3
- import PowerduckState from '../app/powerduck-state';
4
- import TemporalUtils from './utils/temporal-utils';
5
-
6
- type ScrollContext = Element | null | undefined;
7
-
8
- export default class ScrollUtils {
9
- private static readonly _errorFieldSelector = '.form-group.has-danger, .input-group.has-danger';
10
-
11
- static scrollToFirstPossibleError(context: ScrollContext) {
12
- setTimeout(() => {
13
- // Widen the scope used for tab/fieldset detection so that a validator scoped
14
- // to a child container (e.g. OpeningHoursEditor) still finds the ModalSectionWrapper
15
- // tabs/fieldsets that live higher up in the tree.
16
- const wideScope = ScrollUtils._resolveWideScope(context);
17
- const errors = Array.from(wideScope.querySelectorAll<HTMLElement>(ScrollUtils._errorFieldSelector));
18
-
19
- if (errors.length === 0) {
20
- return;
21
- }
22
-
23
- // Collect which tab buttons own errors, then paint them red and maybe switch tabs.
24
- // Keep the id set around — Vue re-renders the tab-button template string on
25
- // setActivePageIndex(), which wipes externally-added classes, so we need to
26
- // re-apply them once the re-render is done (inside doScroll below).
27
- const erroringButtonIds = ScrollUtils._collectErrorTabButtonIds(errors);
28
- ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds);
29
- const switchedTab = ScrollUtils._switchToFirstErroringTab(wideScope);
30
-
31
- // Highlight erroring fieldsets (list view) and expand collapsed ones
32
- const expandedAny = ScrollUtils._expandFieldsetsWithErrors(wideScope, errors);
33
-
34
- // Prevent multiple scrolls in quick succession
35
- const lastScroll = (PowerduckState as any)._lastValidationScroll || 0;
36
- const now = TemporalUtils.dateNowMs();
37
- if (now - lastScroll < 800) {
38
- return;
39
- }
40
-
41
- (PowerduckState as any)._lastValidationScroll = now;
42
-
43
- const doScroll = () => {
44
- // Re-paint tab highlights after Vue re-rendered the tab buttons from the
45
- // setActivePageIndex triggered by _switchToFirstErroringTab.
46
- if (switchedTab) {
47
- ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds);
48
- }
49
-
50
- const target = ScrollUtils._findTopmostError(wideScope);
51
- if (target != null) {
52
- const offset = ScrollUtils._getErrorScrollOffset(target);
53
- ScrollUtils.scrollIntoViewWithOffset(
54
- target,
55
- offset,
56
- 'smooth',
57
- );
58
- }
59
- };
60
-
61
- // Decide when to actually scroll:
62
- // - If we expanded a collapsed fieldset, the content above the target keeps growing
63
- // over ~800ms (CSS max-height transition). Smooth-scroll locks its destination at
64
- // init time, so scrolling before the transition finishes undershoots. Wait for
65
- // transitionend on the first expanded .fieldset-slots with a safety fallback.
66
- // - Else if we switched tabs, two animation frames are enough for Vue's re-render.
67
- // - Else scroll immediately.
68
- if (expandedAny) {
69
- ScrollUtils._waitForFieldsetExpand(wideScope, doScroll);
70
- } else if (switchedTab && typeof requestAnimationFrame === 'function') {
71
- requestAnimationFrame(() => {
72
- requestAnimationFrame(doScroll);
73
- });
74
- } else {
75
- doScroll();
76
- }
77
- }, 10);
78
- }
79
-
80
- private static _waitForFieldsetExpand(scope: Element, cb: () => void): void {
81
- const expandedSlots = Array.from(scope.querySelectorAll<HTMLElement>('.fieldset-control.fieldset-contains-error > .fieldset-slots')).filter(el => !el.classList.contains('fieldset-slots-collapsed'));
82
- const slotEl = expandedSlots[0];
83
-
84
- if (slotEl == null) {
85
- cb();
86
- return;
87
- }
88
-
89
- let fired = false;
90
- // `done` is recursive with `onTransitionEnd` (each calls the other), so
91
- // we forward-declare via `let` to break the temporal-dead-zone cycle.
92
- let done: () => void;
93
- const onTransitionEnd = (e: TransitionEvent) => {
94
- // Only react to the height animation, not opacity/transform on descendants
95
- if (e.target !== slotEl) {
96
- return;
97
- }
98
-
99
- if (e.propertyName !== 'max-height' && e.propertyName !== 'height') {
100
- return;
101
- }
102
-
103
- done();
104
- };
105
- done = () => {
106
- if (fired) {
107
- return;
108
- }
109
-
110
- fired = true;
111
- slotEl.removeEventListener('transitionend', onTransitionEnd);
112
- cb();
113
- };
114
-
115
- slotEl.addEventListener('transitionend', onTransitionEnd);
116
- // Safety fallback: CSS transition is 0.8s, allow a little slack
117
- setTimeout(done, 900);
118
- }
119
-
120
- private static _resolveWideScope(context: ScrollContext): Element {
121
- // Guard: Element.closest only exists on Element; Vue 3 can hand us comment/anchor
122
- // nodes (e.g. the Teleport placeholder) when the component root is a <Teleport>.
123
- if (context instanceof Element) {
124
- const modal = context.closest('.modal.show') as HTMLElement | null;
125
- if (modal != null) {
126
- return modal;
127
- }
128
-
129
- // ctxEl is clearly page-level — don't fall back to some other modal that
130
- // happens to be open, that would be the wrong form entirely.
131
- return document.body;
132
- }
133
-
134
- // No usable element context (degenerate/programmatic call): fall back to any
135
- // currently-open modal, else the document body.
136
- const openModals = document.querySelectorAll<HTMLElement>('.modal.show');
137
- const lastOpen = openModals[openModals.length - 1];
138
- return lastOpen ?? document.body;
139
- }
140
-
141
- private static _collectErrorTabButtonIds(errors: HTMLElement[]): Set<string> {
142
- const buttonIds = new Set<string>();
143
- errors.forEach((el) => {
144
- const buttonId = el.closest('.inv-tab-wrap')?.getAttribute('data-button-id');
145
- if (buttonId) {
146
- buttonIds.add(buttonId);
147
- }
148
- });
149
- return buttonIds;
150
- }
151
-
152
- private static _applyTabErrorClasses(scope: Element, buttonIds: Set<string>): void {
153
- // Reset previously highlighted tabs within the scope so stale reds clear
154
- scope.querySelectorAll('.inv-tab-button.modalsection-has-error').forEach((el) => {
155
- el.classList.remove('modalsection-has-error');
156
- });
157
-
158
- if (buttonIds.size === 0) {
159
- return;
160
- }
161
-
162
- buttonIds.forEach((id) => {
163
- // id is a runtime-provided attribute value, so use attribute-selector rather
164
- // than `#${id}` to avoid CSS-selector escape pitfalls on ids containing
165
- // special characters.
166
- scope.querySelectorAll(`[id="${CSS.escape(id)}"]`).forEach((el) => {
167
- el.classList.add('modalsection-has-error');
168
- });
169
- });
170
- }
171
-
172
- private static _switchToFirstErroringTab(scope: Element): boolean {
173
- const activeButton = scope.querySelector('.inv-tab-button.active');
174
- if (activeButton == null || activeButton.classList.contains('modalsection-has-error')) {
175
- return false;
176
- }
177
-
178
- const firstErrButton = scope.querySelector<HTMLElement>('.inv-tab-button.modalsection-has-error');
179
- if (firstErrButton == null) {
180
- return false;
181
- }
182
-
183
- firstErrButton.click();
184
- return true;
185
- }
186
-
187
- private static _expandFieldsetsWithErrors(scope: Element, errors: HTMLElement[]): boolean {
188
- // Collect every fieldset that contains any error, innermost and all ancestors
189
- const fieldsets = new Set<HTMLElement>();
190
- errors.forEach((el) => {
191
- let parent = el.parentElement;
192
- while (parent != null) {
193
- if (parent.classList.contains('fieldset-control')) {
194
- fieldsets.add(parent);
195
- }
196
-
197
- parent = parent.parentElement;
198
- }
199
- });
200
-
201
- // Reset previously auto-marked fieldsets within the scope
202
- scope.querySelectorAll('.fieldset-control.fieldset-contains-error').forEach((el) => {
203
- el.classList.remove('fieldset-contains-error');
204
- });
205
-
206
- if (fieldsets.size === 0) {
207
- return false;
208
- }
209
-
210
- let anyExpanded = false;
211
- fieldsets.forEach((fs) => {
212
- fs.classList.add('fieldset-contains-error');
213
- const slots = Array.from(fs.children).find(c => c.classList.contains('fieldset-slots'));
214
- if (slots != null && slots.classList.contains('fieldset-slots-collapsed')) {
215
- // Click the legend to flip Vue-reactive isCollapsed → expand
216
- const legend = Array.from(fs.children).find(c => c.classList.contains('fieldset-legend')) as HTMLElement | undefined;
217
- if (legend != null) {
218
- legend.click();
219
- anyExpanded = true;
220
- }
221
- }
222
- });
223
-
224
- return anyExpanded;
225
- }
226
-
227
- private static _findTopmostError(scope: Element): HTMLElement | null {
228
- const errors = Array.from(scope.querySelectorAll<HTMLElement>(ScrollUtils._errorFieldSelector));
229
- if (errors.length === 0) {
230
- return null;
231
- }
232
-
233
- let topmost: HTMLElement | null = null;
234
- let topY = Number.POSITIVE_INFINITY;
235
- errors.forEach((el) => {
236
- const rect = el.getBoundingClientRect();
237
- // Skip elements that are not laid out at all (e.g. inside display:none tab panes)
238
- if (rect.width === 0 && rect.height === 0) {
239
- return;
240
- }
241
-
242
- if (rect.top < topY) {
243
- topY = rect.top;
244
- topmost = el;
245
- }
246
- });
247
-
248
- return topmost ?? errors[0];
249
- }
250
-
251
- private static _getErrorScrollOffset(elem: HTMLElement): number {
252
- // Breathing room above the scrolled-to field
253
- let offset = 50;
254
-
255
- const modal = elem.closest('.modal') as HTMLElement | null;
256
- if (modal) {
257
- // Inside a modal: its own header sits above the scrolling body
258
- const modalHeader = modal.querySelector('.modal-header') as HTMLElement | null;
259
- if (modalHeader) {
260
- offset += modalHeader.getBoundingClientRect().height;
261
- }
262
-
263
- return offset;
264
- }
265
-
266
- // Page-level scroll: account for a fixed navbar / header covering the top.
267
- // Only count elements that are actually stuck to the viewport — a plain <header>
268
- // inside normal flow scrolls with the page and must not inflate the offset.
269
- const stuckHeight = (selector: string): number => {
270
- const el = document.querySelector(selector) as HTMLElement | null;
271
- if (!el) {
272
- return 0;
273
- }
274
-
275
- const pos = getComputedStyle(el).position;
276
- if (pos !== 'fixed' && pos !== 'sticky') {
277
- return 0;
278
- }
279
-
280
- const h = el.getBoundingClientRect().height;
281
- return isNaN(h) ? 0 : h;
282
- };
283
-
284
- const navHeight = stuckHeight('nav.navbar.fixed-top')
285
- || stuckHeight('.topnavbar-wrap')
286
- || stuckHeight('header');
287
-
288
- return offset + navHeight;
289
- }
290
-
291
- static scrollIntoViewWithOffset(
292
- elem,
293
- offset = 0,
294
- behavior: ScrollBehavior = 'smooth',
295
- ) {
296
- if (!elem) {
297
- return;
298
- }
299
-
300
- // Find the nearest scrollable parent
301
- let scrollParent = elem.parentElement;
302
- while (scrollParent) {
303
- const overflowY = getComputedStyle(scrollParent).overflowY;
304
- if (overflowY === 'auto' || overflowY === 'scroll') {
305
- break;
306
- }
307
-
308
- scrollParent = scrollParent.parentElement;
309
- }
310
-
311
- // Fallback to window scroll if no scrollable parent
312
- if (!scrollParent || scrollParent.nodeName == 'BODY') {
313
- const elemTop = elem.getBoundingClientRect().top + globalState.pageYOffset;
314
- globalState.scrollTo({
315
- top: elemTop - offset,
316
- behavior,
317
- });
318
- } else {
319
- const parentRect = scrollParent.getBoundingClientRect();
320
- const elemRect = elem.getBoundingClientRect();
321
- const top = elemRect.top - parentRect.top + scrollParent.scrollTop;
322
-
323
- scrollParent.scrollTo({
324
- top: top - offset,
325
- behavior,
326
- });
327
- }
328
- }
329
-
330
- /**
331
- * Scrolls to element
332
- * @param elem
333
- */
334
- static scrollToElem(
335
- elem: typeof Vue | Element | typeof Vue[] | Element[],
336
- mobileOffset?: boolean | number,
337
- _mobileOffsetSmoothing?: boolean,
338
- animated?: boolean,
339
- instant?: boolean,
340
- ): void {
341
- if (elem == null || !globalState.scrollTo) {
342
- return;
343
- }
344
-
345
- const target = ScrollUtils._unwrapToElement(elem);
346
- if (target == null) {
347
- return;
348
- }
349
-
350
- let offset = 0;
351
- let otherHeaderHeight: number = (document.querySelector<HTMLElement>('nav.navbar.fixed-top')?.offsetHeight) ?? 0;
352
- if (otherHeaderHeight == null || otherHeaderHeight === 0) {
353
- otherHeaderHeight = document.querySelector<HTMLElement>('header')?.offsetHeight ?? 0;
354
- }
355
-
356
- if ((globalState.innerWidth < 768 && mobileOffset != false) || otherHeaderHeight > 0) {
357
- if ((mobileOffset as number) > 1) {
358
- offset = mobileOffset as number;
359
- } else {
360
- offset = document.querySelector<HTMLElement>('.topnavbar-wrap')?.offsetHeight ?? 0;
361
- if (offset === 0 || offset == null || isNaN(offset)) {
362
- offset = otherHeaderHeight;
363
- }
364
- }
365
- }
366
-
367
- // jQuery's .offset().top is document-relative — getBoundingClientRect is
368
- // viewport-relative, so add the page scroll to convert.
369
- let itemTop = target.getBoundingClientRect().top + globalState.scrollY;
370
- const modalParent = target.closest<HTMLElement>('.modal');
371
- const scrollContext: HTMLElement | null = modalParent;
372
- if (modalParent != null) {
373
- itemTop = modalParent.scrollTop + itemTop - 95;
374
- }
375
-
376
- if (isNaN(offset)) {
377
- offset = 0;
378
- }
379
-
380
- this.scrollToPos(
381
- itemTop - offset,
382
- scrollContext,
383
- animated,
384
- instant,
385
- );
386
- }
387
-
388
- /**
389
- * Scrolls to position
390
- * @param position scroll-top in pixels
391
- * @param context optional scroll container (e.g. an open modal); defaults to window
392
- */
393
- static scrollToPos(
394
- position: number,
395
- context?: HTMLElement | null,
396
- animated?: boolean,
397
- instant?: boolean,
398
- ): void {
399
- if (!globalState.scrollTo) {
400
- return;
401
- }
402
-
403
- const scroller: HTMLElement | Window = context ?? globalState;
404
- const opts: ScrollToOptions = {
405
- top: position,
406
- behavior: (animated != false ? 'smooth' : 'instant') as ScrollBehavior,
407
- };
408
-
409
- if (instant != true) {
410
- setTimeout(() => {
411
- scroller.scrollTo(opts);
412
- });
413
- } else {
414
- scroller.scrollTo(opts);
415
- }
416
- }
417
-
418
- /**
419
- * Vue 3 components don't have a JS-side `$el` array form; the helper accepts
420
- * one component / element or an array of either, and returns the first
421
- * underlying DOM element.
422
- */
423
- private static _unwrapToElement(elem: typeof Vue | Element | typeof Vue[] | Element[]): HTMLElement | null {
424
- const first = Array.isArray(elem) ? elem[0] : elem;
425
- if (first == null) {
426
- return null;
427
- }
428
-
429
- const maybeVue = first as any;
430
- const candidate = (maybeVue.$el ?? maybeVue) as Element | null;
431
- return candidate instanceof HTMLElement ? candidate : null;
432
- }
433
- }
1
+ import type { Vue } from 'vue-facing-decorator';
2
+ import { globalState } from '../app/global-state';
3
+ import PowerduckState from '../app/powerduck-state';
4
+ import TemporalUtils from './utils/temporal-utils';
5
+
6
+ type ScrollContext = Element | null | undefined;
7
+
8
+ export default class ScrollUtils {
9
+ private static readonly _errorFieldSelector = '.form-group.has-danger, .input-group.has-danger';
10
+
11
+ static scrollToFirstPossibleError(context: ScrollContext) {
12
+ setTimeout(() => {
13
+ // Widen the scope used for tab/fieldset detection so that a validator scoped
14
+ // to a child container (e.g. OpeningHoursEditor) still finds the ModalSectionWrapper
15
+ // tabs/fieldsets that live higher up in the tree.
16
+ const wideScope = ScrollUtils._resolveWideScope(context);
17
+ const errors = Array.from(wideScope.querySelectorAll<HTMLElement>(ScrollUtils._errorFieldSelector));
18
+
19
+ if (errors.length === 0) {
20
+ return;
21
+ }
22
+
23
+ // Collect which tab buttons own errors, then paint them red and maybe switch tabs.
24
+ // Keep the id set around — Vue re-renders the tab-button template string on
25
+ // setActivePageIndex(), which wipes externally-added classes, so we need to
26
+ // re-apply them once the re-render is done (inside doScroll below).
27
+ const erroringButtonIds = ScrollUtils._collectErrorTabButtonIds(errors);
28
+ ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds);
29
+ const switchedTab = ScrollUtils._switchToFirstErroringTab(wideScope);
30
+
31
+ // Highlight erroring fieldsets (list view) and expand collapsed ones
32
+ const expandedAny = ScrollUtils._expandFieldsetsWithErrors(wideScope, errors);
33
+
34
+ // Prevent multiple scrolls in quick succession
35
+ const lastScroll = (PowerduckState as any)._lastValidationScroll || 0;
36
+ const now = TemporalUtils.dateNowMs();
37
+ if (now - lastScroll < 800) {
38
+ return;
39
+ }
40
+
41
+ (PowerduckState as any)._lastValidationScroll = now;
42
+
43
+ const doScroll = () => {
44
+ // Re-paint tab highlights after Vue re-rendered the tab buttons from the
45
+ // setActivePageIndex triggered by _switchToFirstErroringTab.
46
+ if (switchedTab) {
47
+ ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds);
48
+ }
49
+
50
+ const target = ScrollUtils._findTopmostError(wideScope);
51
+ if (target != null) {
52
+ const offset = ScrollUtils._getErrorScrollOffset(target);
53
+ ScrollUtils.scrollIntoViewWithOffset(
54
+ target,
55
+ offset,
56
+ 'smooth',
57
+ );
58
+ }
59
+ };
60
+
61
+ // Decide when to actually scroll:
62
+ // - If we expanded a collapsed fieldset, the content above the target keeps growing
63
+ // over ~800ms (CSS max-height transition). Smooth-scroll locks its destination at
64
+ // init time, so scrolling before the transition finishes undershoots. Wait for
65
+ // transitionend on the first expanded .fieldset-slots with a safety fallback.
66
+ // - Else if we switched tabs, two animation frames are enough for Vue's re-render.
67
+ // - Else scroll immediately.
68
+ if (expandedAny) {
69
+ ScrollUtils._waitForFieldsetExpand(wideScope, doScroll);
70
+ } else if (switchedTab && typeof requestAnimationFrame === 'function') {
71
+ requestAnimationFrame(() => {
72
+ requestAnimationFrame(doScroll);
73
+ });
74
+ } else {
75
+ doScroll();
76
+ }
77
+ }, 10);
78
+ }
79
+
80
+ private static _waitForFieldsetExpand(scope: Element, cb: () => void): void {
81
+ const expandedSlots = Array.from(scope.querySelectorAll<HTMLElement>('.fieldset-control.fieldset-contains-error > .fieldset-slots')).filter(el => !el.classList.contains('fieldset-slots-collapsed'));
82
+ const slotEl = expandedSlots[0];
83
+
84
+ if (slotEl == null) {
85
+ cb();
86
+ return;
87
+ }
88
+
89
+ let fired = false;
90
+ // `done` is recursive with `onTransitionEnd` (each calls the other), so
91
+ // we forward-declare via `let` to break the temporal-dead-zone cycle.
92
+ let done: () => void;
93
+ const onTransitionEnd = (e: TransitionEvent) => {
94
+ // Only react to the height animation, not opacity/transform on descendants
95
+ if (e.target !== slotEl) {
96
+ return;
97
+ }
98
+
99
+ if (e.propertyName !== 'max-height' && e.propertyName !== 'height') {
100
+ return;
101
+ }
102
+
103
+ done();
104
+ };
105
+ done = () => {
106
+ if (fired) {
107
+ return;
108
+ }
109
+
110
+ fired = true;
111
+ slotEl.removeEventListener('transitionend', onTransitionEnd);
112
+ cb();
113
+ };
114
+
115
+ slotEl.addEventListener('transitionend', onTransitionEnd);
116
+ // Safety fallback: CSS transition is 0.8s, allow a little slack
117
+ setTimeout(done, 900);
118
+ }
119
+
120
+ private static _resolveWideScope(context: ScrollContext): Element {
121
+ // Guard: Element.closest only exists on Element; Vue 3 can hand us comment/anchor
122
+ // nodes (e.g. the Teleport placeholder) when the component root is a <Teleport>.
123
+ if (context instanceof Element) {
124
+ const modal = context.closest('.modal.show') as HTMLElement | null;
125
+ if (modal != null) {
126
+ return modal;
127
+ }
128
+
129
+ // ctxEl is clearly page-level — don't fall back to some other modal that
130
+ // happens to be open, that would be the wrong form entirely.
131
+ return document.body;
132
+ }
133
+
134
+ // No usable element context (degenerate/programmatic call): fall back to any
135
+ // currently-open modal, else the document body.
136
+ const openModals = document.querySelectorAll<HTMLElement>('.modal.show');
137
+ const lastOpen = openModals[openModals.length - 1];
138
+ return lastOpen ?? document.body;
139
+ }
140
+
141
+ private static _collectErrorTabButtonIds(errors: HTMLElement[]): Set<string> {
142
+ const buttonIds = new Set<string>();
143
+ errors.forEach((el) => {
144
+ const buttonId = el.closest('.inv-tab-wrap')?.getAttribute('data-button-id');
145
+ if (buttonId) {
146
+ buttonIds.add(buttonId);
147
+ }
148
+ });
149
+ return buttonIds;
150
+ }
151
+
152
+ private static _applyTabErrorClasses(scope: Element, buttonIds: Set<string>): void {
153
+ // Reset previously highlighted tabs within the scope so stale reds clear
154
+ scope.querySelectorAll('.inv-tab-button.modalsection-has-error').forEach((el) => {
155
+ el.classList.remove('modalsection-has-error');
156
+ });
157
+
158
+ if (buttonIds.size === 0) {
159
+ return;
160
+ }
161
+
162
+ buttonIds.forEach((id) => {
163
+ // id is a runtime-provided attribute value, so use attribute-selector rather
164
+ // than `#${id}` to avoid CSS-selector escape pitfalls on ids containing
165
+ // special characters.
166
+ scope.querySelectorAll(`[id="${CSS.escape(id)}"]`).forEach((el) => {
167
+ el.classList.add('modalsection-has-error');
168
+ });
169
+ });
170
+ }
171
+
172
+ private static _switchToFirstErroringTab(scope: Element): boolean {
173
+ const activeButton = scope.querySelector('.inv-tab-button.active');
174
+ if (activeButton == null || activeButton.classList.contains('modalsection-has-error')) {
175
+ return false;
176
+ }
177
+
178
+ const firstErrButton = scope.querySelector<HTMLElement>('.inv-tab-button.modalsection-has-error');
179
+ if (firstErrButton == null) {
180
+ return false;
181
+ }
182
+
183
+ firstErrButton.click();
184
+ return true;
185
+ }
186
+
187
+ private static _expandFieldsetsWithErrors(scope: Element, errors: HTMLElement[]): boolean {
188
+ // Collect every fieldset that contains any error, innermost and all ancestors
189
+ const fieldsets = new Set<HTMLElement>();
190
+ errors.forEach((el) => {
191
+ let parent = el.parentElement;
192
+ while (parent != null) {
193
+ if (parent.classList.contains('fieldset-control')) {
194
+ fieldsets.add(parent);
195
+ }
196
+
197
+ parent = parent.parentElement;
198
+ }
199
+ });
200
+
201
+ // Reset previously auto-marked fieldsets within the scope
202
+ scope.querySelectorAll('.fieldset-control.fieldset-contains-error').forEach((el) => {
203
+ el.classList.remove('fieldset-contains-error');
204
+ });
205
+
206
+ if (fieldsets.size === 0) {
207
+ return false;
208
+ }
209
+
210
+ let anyExpanded = false;
211
+ fieldsets.forEach((fs) => {
212
+ fs.classList.add('fieldset-contains-error');
213
+ const slots = Array.from(fs.children).find(c => c.classList.contains('fieldset-slots'));
214
+ if (slots != null && slots.classList.contains('fieldset-slots-collapsed')) {
215
+ // Click the legend to flip Vue-reactive isCollapsed → expand
216
+ const legend = Array.from(fs.children).find(c => c.classList.contains('fieldset-legend')) as HTMLElement | undefined;
217
+ if (legend != null) {
218
+ legend.click();
219
+ anyExpanded = true;
220
+ }
221
+ }
222
+ });
223
+
224
+ return anyExpanded;
225
+ }
226
+
227
+ private static _findTopmostError(scope: Element): HTMLElement | null {
228
+ const errors = Array.from(scope.querySelectorAll<HTMLElement>(ScrollUtils._errorFieldSelector));
229
+ if (errors.length === 0) {
230
+ return null;
231
+ }
232
+
233
+ let topmost: HTMLElement | null = null;
234
+ let topY = Number.POSITIVE_INFINITY;
235
+ errors.forEach((el) => {
236
+ const rect = el.getBoundingClientRect();
237
+ // Skip elements that are not laid out at all (e.g. inside display:none tab panes)
238
+ if (rect.width === 0 && rect.height === 0) {
239
+ return;
240
+ }
241
+
242
+ if (rect.top < topY) {
243
+ topY = rect.top;
244
+ topmost = el;
245
+ }
246
+ });
247
+
248
+ return topmost ?? errors[0];
249
+ }
250
+
251
+ private static _getErrorScrollOffset(elem: HTMLElement): number {
252
+ // Breathing room above the scrolled-to field
253
+ let offset = 50;
254
+
255
+ const modal = elem.closest('.modal') as HTMLElement | null;
256
+ if (modal) {
257
+ // Inside a modal: its own header sits above the scrolling body
258
+ const modalHeader = modal.querySelector('.modal-header') as HTMLElement | null;
259
+ if (modalHeader) {
260
+ offset += modalHeader.getBoundingClientRect().height;
261
+ }
262
+
263
+ return offset;
264
+ }
265
+
266
+ // Page-level scroll: account for a fixed navbar / header covering the top.
267
+ // Only count elements that are actually stuck to the viewport — a plain <header>
268
+ // inside normal flow scrolls with the page and must not inflate the offset.
269
+ const stuckHeight = (selector: string): number => {
270
+ const el = document.querySelector(selector) as HTMLElement | null;
271
+ if (!el) {
272
+ return 0;
273
+ }
274
+
275
+ const pos = getComputedStyle(el).position;
276
+ if (pos !== 'fixed' && pos !== 'sticky') {
277
+ return 0;
278
+ }
279
+
280
+ const h = el.getBoundingClientRect().height;
281
+ return isNaN(h) ? 0 : h;
282
+ };
283
+
284
+ const navHeight = stuckHeight('nav.navbar.fixed-top')
285
+ || stuckHeight('.topnavbar-wrap')
286
+ || stuckHeight('header');
287
+
288
+ return offset + navHeight;
289
+ }
290
+
291
+ static scrollIntoViewWithOffset(
292
+ elem,
293
+ offset = 0,
294
+ behavior: ScrollBehavior = 'smooth',
295
+ ) {
296
+ if (!elem) {
297
+ return;
298
+ }
299
+
300
+ // Find the nearest scrollable parent
301
+ let scrollParent = elem.parentElement;
302
+ while (scrollParent) {
303
+ const overflowY = getComputedStyle(scrollParent).overflowY;
304
+ if (overflowY === 'auto' || overflowY === 'scroll') {
305
+ break;
306
+ }
307
+
308
+ scrollParent = scrollParent.parentElement;
309
+ }
310
+
311
+ // Fallback to window scroll if no scrollable parent
312
+ if (!scrollParent || scrollParent.nodeName == 'BODY') {
313
+ const elemTop = elem.getBoundingClientRect().top + globalState.pageYOffset;
314
+ globalState.scrollTo({
315
+ top: elemTop - offset,
316
+ behavior,
317
+ });
318
+ } else {
319
+ const parentRect = scrollParent.getBoundingClientRect();
320
+ const elemRect = elem.getBoundingClientRect();
321
+ const top = elemRect.top - parentRect.top + scrollParent.scrollTop;
322
+
323
+ scrollParent.scrollTo({
324
+ top: top - offset,
325
+ behavior,
326
+ });
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Scrolls to element
332
+ * @param elem
333
+ */
334
+ static scrollToElem(
335
+ elem: typeof Vue | Element | typeof Vue[] | Element[],
336
+ mobileOffset?: boolean | number,
337
+ _mobileOffsetSmoothing?: boolean,
338
+ animated?: boolean,
339
+ instant?: boolean,
340
+ ): void {
341
+ if (elem == null || !globalState.scrollTo) {
342
+ return;
343
+ }
344
+
345
+ const target = ScrollUtils._unwrapToElement(elem);
346
+ if (target == null) {
347
+ return;
348
+ }
349
+
350
+ let offset = 0;
351
+ let otherHeaderHeight: number = (document.querySelector<HTMLElement>('nav.navbar.fixed-top')?.offsetHeight) ?? 0;
352
+ if (otherHeaderHeight == null || otherHeaderHeight === 0) {
353
+ otherHeaderHeight = document.querySelector<HTMLElement>('header')?.offsetHeight ?? 0;
354
+ }
355
+
356
+ if ((globalState.innerWidth < 768 && mobileOffset != false) || otherHeaderHeight > 0) {
357
+ if ((mobileOffset as number) > 1) {
358
+ offset = mobileOffset as number;
359
+ } else {
360
+ offset = document.querySelector<HTMLElement>('.topnavbar-wrap')?.offsetHeight ?? 0;
361
+ if (offset === 0 || offset == null || isNaN(offset)) {
362
+ offset = otherHeaderHeight;
363
+ }
364
+ }
365
+ }
366
+
367
+ // jQuery's .offset().top is document-relative — getBoundingClientRect is
368
+ // viewport-relative, so add the page scroll to convert.
369
+ let itemTop = target.getBoundingClientRect().top + globalState.scrollY;
370
+ const modalParent = target.closest<HTMLElement>('.modal');
371
+ const scrollContext: HTMLElement | null = modalParent;
372
+ if (modalParent != null) {
373
+ itemTop = modalParent.scrollTop + itemTop - 95;
374
+ }
375
+
376
+ if (isNaN(offset)) {
377
+ offset = 0;
378
+ }
379
+
380
+ this.scrollToPos(
381
+ itemTop - offset,
382
+ scrollContext,
383
+ animated,
384
+ instant,
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Scrolls to position
390
+ * @param position scroll-top in pixels
391
+ * @param context optional scroll container (e.g. an open modal); defaults to window
392
+ */
393
+ static scrollToPos(
394
+ position: number,
395
+ context?: HTMLElement | null,
396
+ animated?: boolean,
397
+ instant?: boolean,
398
+ ): void {
399
+ if (!globalState.scrollTo) {
400
+ return;
401
+ }
402
+
403
+ const scroller: HTMLElement | Window = context ?? globalState;
404
+ const opts: ScrollToOptions = {
405
+ top: position,
406
+ behavior: (animated != false ? 'smooth' : 'instant') as ScrollBehavior,
407
+ };
408
+
409
+ if (instant != true) {
410
+ setTimeout(() => {
411
+ scroller.scrollTo(opts);
412
+ });
413
+ } else {
414
+ scroller.scrollTo(opts);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Vue 3 components don't have a JS-side `$el` array form; the helper accepts
420
+ * one component / element or an array of either, and returns the first
421
+ * underlying DOM element.
422
+ */
423
+ private static _unwrapToElement(elem: typeof Vue | Element | typeof Vue[] | Element[]): HTMLElement | null {
424
+ const first = Array.isArray(elem) ? elem[0] : elem;
425
+ if (first == null) {
426
+ return null;
427
+ }
428
+
429
+ const maybeVue = first as any;
430
+ const candidate = (maybeVue.$el ?? maybeVue) as Element | null;
431
+ return candidate instanceof HTMLElement ? candidate : null;
432
+ }
433
+ }