import type { Vue } from 'vue-facing-decorator'; import { globalState } from '../app/global-state'; import PowerduckState from '../app/powerduck-state'; import TemporalUtils from './utils/temporal-utils'; type ScrollContext = Element | null | undefined; export default class ScrollUtils { private static readonly _errorFieldSelector = '.form-group.has-danger, .input-group.has-danger'; static scrollToFirstPossibleError(context: ScrollContext) { setTimeout(() => { // Widen the scope used for tab/fieldset detection so that a validator scoped // to a child container (e.g. OpeningHoursEditor) still finds the ModalSectionWrapper // tabs/fieldsets that live higher up in the tree. const wideScope = ScrollUtils._resolveWideScope(context); const errors = Array.from(wideScope.querySelectorAll(ScrollUtils._errorFieldSelector)); if (errors.length === 0) { return; } // Collect which tab buttons own errors, then paint them red and maybe switch tabs. // Keep the id set around — Vue re-renders the tab-button template string on // setActivePageIndex(), which wipes externally-added classes, so we need to // re-apply them once the re-render is done (inside doScroll below). const erroringButtonIds = ScrollUtils._collectErrorTabButtonIds(errors); ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds); const switchedTab = ScrollUtils._switchToFirstErroringTab(wideScope); // Highlight erroring fieldsets (list view) and expand collapsed ones const expandedAny = ScrollUtils._expandFieldsetsWithErrors(wideScope, errors); // Prevent multiple scrolls in quick succession const lastScroll = (PowerduckState as any)._lastValidationScroll || 0; const now = TemporalUtils.dateNowMs(); if (now - lastScroll < 800) { return; } (PowerduckState as any)._lastValidationScroll = now; const doScroll = () => { // Re-paint tab highlights after Vue re-rendered the tab buttons from the // setActivePageIndex triggered by _switchToFirstErroringTab. if (switchedTab) { ScrollUtils._applyTabErrorClasses(wideScope, erroringButtonIds); } const target = ScrollUtils._findTopmostError(wideScope); if (target != null) { const offset = ScrollUtils._getErrorScrollOffset(target); ScrollUtils.scrollIntoViewWithOffset( target, offset, 'smooth', ); } }; // Decide when to actually scroll: // - If we expanded a collapsed fieldset, the content above the target keeps growing // over ~800ms (CSS max-height transition). Smooth-scroll locks its destination at // init time, so scrolling before the transition finishes undershoots. Wait for // transitionend on the first expanded .fieldset-slots with a safety fallback. // - Else if we switched tabs, two animation frames are enough for Vue's re-render. // - Else scroll immediately. if (expandedAny) { ScrollUtils._waitForFieldsetExpand(wideScope, doScroll); } else if (switchedTab && typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { requestAnimationFrame(doScroll); }); } else { doScroll(); } }, 10); } private static _waitForFieldsetExpand(scope: Element, cb: () => void): void { const expandedSlots = Array.from(scope.querySelectorAll('.fieldset-control.fieldset-contains-error > .fieldset-slots')).filter(el => !el.classList.contains('fieldset-slots-collapsed')); const slotEl = expandedSlots[0]; if (slotEl == null) { cb(); return; } let fired = false; // `done` is recursive with `onTransitionEnd` (each calls the other), so // we forward-declare via `let` to break the temporal-dead-zone cycle. let done: () => void; const onTransitionEnd = (e: TransitionEvent) => { // Only react to the height animation, not opacity/transform on descendants if (e.target !== slotEl) { return; } if (e.propertyName !== 'max-height' && e.propertyName !== 'height') { return; } done(); }; done = () => { if (fired) { return; } fired = true; slotEl.removeEventListener('transitionend', onTransitionEnd); cb(); }; slotEl.addEventListener('transitionend', onTransitionEnd); // Safety fallback: CSS transition is 0.8s, allow a little slack setTimeout(done, 900); } private static _resolveWideScope(context: ScrollContext): Element { // Guard: Element.closest only exists on Element; Vue 3 can hand us comment/anchor // nodes (e.g. the Teleport placeholder) when the component root is a . if (context instanceof Element) { const modal = context.closest('.modal.show') as HTMLElement | null; if (modal != null) { return modal; } // ctxEl is clearly page-level — don't fall back to some other modal that // happens to be open, that would be the wrong form entirely. return document.body; } // No usable element context (degenerate/programmatic call): fall back to any // currently-open modal, else the document body. const openModals = document.querySelectorAll('.modal.show'); const lastOpen = openModals[openModals.length - 1]; return lastOpen ?? document.body; } private static _collectErrorTabButtonIds(errors: HTMLElement[]): Set { const buttonIds = new Set(); errors.forEach((el) => { const buttonId = el.closest('.inv-tab-wrap')?.getAttribute('data-button-id'); if (buttonId) { buttonIds.add(buttonId); } }); return buttonIds; } private static _applyTabErrorClasses(scope: Element, buttonIds: Set): void { // Reset previously highlighted tabs within the scope so stale reds clear scope.querySelectorAll('.inv-tab-button.modalsection-has-error').forEach((el) => { el.classList.remove('modalsection-has-error'); }); if (buttonIds.size === 0) { return; } buttonIds.forEach((id) => { // id is a runtime-provided attribute value, so use attribute-selector rather // than `#${id}` to avoid CSS-selector escape pitfalls on ids containing // special characters. scope.querySelectorAll(`[id="${CSS.escape(id)}"]`).forEach((el) => { el.classList.add('modalsection-has-error'); }); }); } private static _switchToFirstErroringTab(scope: Element): boolean { const activeButton = scope.querySelector('.inv-tab-button.active'); if (activeButton == null || activeButton.classList.contains('modalsection-has-error')) { return false; } const firstErrButton = scope.querySelector('.inv-tab-button.modalsection-has-error'); if (firstErrButton == null) { return false; } firstErrButton.click(); return true; } private static _expandFieldsetsWithErrors(scope: Element, errors: HTMLElement[]): boolean { // Collect every fieldset that contains any error, innermost and all ancestors const fieldsets = new Set(); errors.forEach((el) => { let parent = el.parentElement; while (parent != null) { if (parent.classList.contains('fieldset-control')) { fieldsets.add(parent); } parent = parent.parentElement; } }); // Reset previously auto-marked fieldsets within the scope scope.querySelectorAll('.fieldset-control.fieldset-contains-error').forEach((el) => { el.classList.remove('fieldset-contains-error'); }); if (fieldsets.size === 0) { return false; } let anyExpanded = false; fieldsets.forEach((fs) => { fs.classList.add('fieldset-contains-error'); const slots = Array.from(fs.children).find(c => c.classList.contains('fieldset-slots')); if (slots != null && slots.classList.contains('fieldset-slots-collapsed')) { // Click the legend to flip Vue-reactive isCollapsed → expand const legend = Array.from(fs.children).find(c => c.classList.contains('fieldset-legend')) as HTMLElement | undefined; if (legend != null) { legend.click(); anyExpanded = true; } } }); return anyExpanded; } private static _findTopmostError(scope: Element): HTMLElement | null { const errors = Array.from(scope.querySelectorAll(ScrollUtils._errorFieldSelector)); if (errors.length === 0) { return null; } let topmost: HTMLElement | null = null; let topY = Number.POSITIVE_INFINITY; errors.forEach((el) => { const rect = el.getBoundingClientRect(); // Skip elements that are not laid out at all (e.g. inside display:none tab panes) if (rect.width === 0 && rect.height === 0) { return; } if (rect.top < topY) { topY = rect.top; topmost = el; } }); return topmost ?? errors[0]; } private static _getErrorScrollOffset(elem: HTMLElement): number { // Breathing room above the scrolled-to field let offset = 50; const modal = elem.closest('.modal') as HTMLElement | null; if (modal) { // Inside a modal: its own header sits above the scrolling body const modalHeader = modal.querySelector('.modal-header') as HTMLElement | null; if (modalHeader) { offset += modalHeader.getBoundingClientRect().height; } return offset; } // Page-level scroll: account for a fixed navbar / header covering the top. // Only count elements that are actually stuck to the viewport — a plain
// inside normal flow scrolls with the page and must not inflate the offset. const stuckHeight = (selector: string): number => { const el = document.querySelector(selector) as HTMLElement | null; if (!el) { return 0; } const pos = getComputedStyle(el).position; if (pos !== 'fixed' && pos !== 'sticky') { return 0; } const h = el.getBoundingClientRect().height; return isNaN(h) ? 0 : h; }; const navHeight = stuckHeight('nav.navbar.fixed-top') || stuckHeight('.topnavbar-wrap') || stuckHeight('header'); return offset + navHeight; } static scrollIntoViewWithOffset( elem, offset = 0, behavior: ScrollBehavior = 'smooth', ) { if (!elem) { return; } // Find the nearest scrollable parent let scrollParent = elem.parentElement; while (scrollParent) { const overflowY = getComputedStyle(scrollParent).overflowY; if (overflowY === 'auto' || overflowY === 'scroll') { break; } scrollParent = scrollParent.parentElement; } // Fallback to window scroll if no scrollable parent if (!scrollParent || scrollParent.nodeName == 'BODY') { const elemTop = elem.getBoundingClientRect().top + globalState.pageYOffset; globalState.scrollTo({ top: elemTop - offset, behavior, }); } else { const parentRect = scrollParent.getBoundingClientRect(); const elemRect = elem.getBoundingClientRect(); const top = elemRect.top - parentRect.top + scrollParent.scrollTop; scrollParent.scrollTo({ top: top - offset, behavior, }); } } /** * Scrolls to element * @param elem */ static scrollToElem( elem: typeof Vue | Element | typeof Vue[] | Element[], mobileOffset?: boolean | number, _mobileOffsetSmoothing?: boolean, animated?: boolean, instant?: boolean, ): void { if (elem == null || !globalState.scrollTo) { return; } const target = ScrollUtils._unwrapToElement(elem); if (target == null) { return; } let offset = 0; let otherHeaderHeight: number = (document.querySelector('nav.navbar.fixed-top')?.offsetHeight) ?? 0; if (otherHeaderHeight == null || otherHeaderHeight === 0) { otherHeaderHeight = document.querySelector('header')?.offsetHeight ?? 0; } if ((globalState.innerWidth < 768 && mobileOffset != false) || otherHeaderHeight > 0) { if ((mobileOffset as number) > 1) { offset = mobileOffset as number; } else { offset = document.querySelector('.topnavbar-wrap')?.offsetHeight ?? 0; if (offset === 0 || offset == null || isNaN(offset)) { offset = otherHeaderHeight; } } } // jQuery's .offset().top is document-relative — getBoundingClientRect is // viewport-relative, so add the page scroll to convert. let itemTop = target.getBoundingClientRect().top + globalState.scrollY; const modalParent = target.closest('.modal'); const scrollContext: HTMLElement | null = modalParent; if (modalParent != null) { itemTop = modalParent.scrollTop + itemTop - 95; } if (isNaN(offset)) { offset = 0; } this.scrollToPos( itemTop - offset, scrollContext, animated, instant, ); } /** * Scrolls to position * @param position scroll-top in pixels * @param context optional scroll container (e.g. an open modal); defaults to window */ static scrollToPos( position: number, context?: HTMLElement | null, animated?: boolean, instant?: boolean, ): void { if (!globalState.scrollTo) { return; } const scroller: HTMLElement | Window = context ?? globalState; const opts: ScrollToOptions = { top: position, behavior: (animated != false ? 'smooth' : 'instant') as ScrollBehavior, }; if (instant != true) { setTimeout(() => { scroller.scrollTo(opts); }); } else { scroller.scrollTo(opts); } } /** * Vue 3 components don't have a JS-side `$el` array form; the helper accepts * one component / element or an array of either, and returns the first * underlying DOM element. */ private static _unwrapToElement(elem: typeof Vue | Element | typeof Vue[] | Element[]): HTMLElement | null { const first = Array.isArray(elem) ? elem[0] : elem; if (first == null) { return null; } const maybeVue = first as any; const candidate = (maybeVue.$el ?? maybeVue) as Element | null; return candidate instanceof HTMLElement ? candidate : null; } }