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.
- package/common/scroll-utils.ts +433 -433
- package/common/utils/currency-utils.ts +52 -52
- package/common/utils/deep-clone.ts +98 -98
- package/common/utils/object-post-processor.ts +43 -43
- package/components/app/vue-plugin-toplevelpage.ts +8 -8
- package/components/context-menu/context-menu-binder.ts +27 -27
- package/components/context-menu/css/context-menu.css +72 -72
- package/components/context-menu/native-context-menu.ts +155 -155
- package/components/datatable/css/datatable.css +29 -0
- package/components/datatable/datatable.tsx +9 -1
- package/components/dropdown/MIGRATION.md +318 -318
- package/components/dropdown/image-dropdown.tsx +128 -128
- package/components/image-crop/css/image-cropping-modal.css +19 -16
- package/components/input/color-picker.tsx +453 -453
- package/components/input/css/minicolors.css +142 -142
- package/components/input/css/password-input.css +77 -77
- package/components/input/password-input.tsx +334 -334
- package/components/loading-indicator/index.tsx +1 -0
- package/components/loading-indicator/loading-indicator.css +87 -0
- package/components/modal/css/modal.css +60 -60
- package/components/modal/modal-utils.ts +320 -320
- package/components/modal/modal.tsx +4 -0
- package/components/modal-wrap/modal-section-wrapper.tsx +5 -5
- package/components/spreadsheet/virtualization-plugin.ts +239 -239
- package/components/stars/css/star-rating.css +113 -113
- package/components/stars/stars.tsx +373 -373
- package/components/ui/vendor/notify.ts +595 -595
- package/package.json +3 -3
package/common/scroll-utils.ts
CHANGED
|
@@ -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
|
+
}
|