import { Prop, toNative } from 'vue-facing-decorator'; import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import ScrollUtils from '../../common/scroll-utils'; import Tabs, { TabsRenderMode } from '../tabs/tabs'; import { ModalSectionMode, ModalSectionPillScrollMode } from './../../common/enums/modal'; import './css/modal-section-wrapper.css'; interface ModalSectionWrapperArgs { wrapInCard?: boolean; } @Component class ModalSectionWrapperComponent extends TsxComponent implements ModalSectionWrapperArgs { @Prop() wrapInCard!: boolean; // Scroll offset (per side-pill section index) remembered while the pill scroll // mode is `rememberPosition`, so returning to a section restores where we were. private _sectionScrollPositions: { [index: number]: number } = {}; // Index of the side-pill section currently displayed. Tracked so we can save // the scroll offset of the section we are leaving before it is swapped out. private _activeSectionIndex: number = 0; resetValidationErrors() { const root = this.$el as HTMLElement; root.querySelectorAll('.modalsection-has-error').forEach(el => el.classList.remove('modalsection-has-error')); root.querySelectorAll('.fieldset-contains-error').forEach(el => el.classList.remove('fieldset-contains-error')); } displayValidationErrors() { this.resetValidationErrors(); this.$nextTick(() => { this.$nextTick(() => { ScrollUtils.scrollToFirstPossibleError(this.$el as Element); }); }); } /** * Switch the inner side-pill Tabs to the first sub-section that contains a * validation error. Useful right after the consumer runs vuelidate's * `$validate()` so the user lands on the sub-tab with the highlighted field * instead of staying on whichever pill was active when they pressed Save. * * Strategy: query the DOM for the first invalid field across all currently * mounted tab panes (in `navPills` mode all panes render, so this finds the * truly-first invalid field anywhere in the form), walk up to its `.tab-pane`, * compute the pane index among its siblings, and call `setActivePageIndex(idx)` * on the internal Tabs instance. * * No-op in `fieldSet` mode (single flat page — nothing to navigate) and * returns false when no invalid field is in the DOM. */ navigateToFirstError(): boolean { if (PowerduckState.getModalSectionMode() == ModalSectionMode.fieldSet) { return false; } const root = this.$el as HTMLElement | null; if (root == null) { return false; } // Project-style validation indicators (Bootstrap + vuelidate output): // - `.has-danger` on the `.form-group` wrapper of an invalid field. // - `.invalid-feedback` on the message `
` that vuelidate renders. // - `.modalsection-has-error` applied to the section root by // `displayValidationErrors()` above (for consumers that opt in). // `.is-invalid` / `.has-error` / `.form-group-error` are kept as // fallbacks for other consumers using stock Bootstrap or jQuery // validation conventions. const firstInvalid = root.querySelector('.has-danger, .invalid-feedback, .modalsection-has-error, .is-invalid, .has-error, .form-group-error') as HTMLElement | null; if (firstInvalid == null) { return false; } const tabPane = firstInvalid.closest('.tab-pane.inv-tab-wrap'); if (tabPane == null) { return false; } const siblings = Array.from(tabPane.parentElement?.children ?? []).filter(c => c.classList.contains('tab-pane')); const idx = siblings.indexOf(tabPane); if (idx === -1) { return false; } const innerTabs = this.$refs.innerTabs as typeof Tabs.prototype | undefined; if (innerTabs?.setActivePageIndex == null) { return false; } innerTabs.setActivePageIndex(idx); return true; } /** * Reacts to a side-pill section change by scrolling the section content * according to `PowerduckState.getModalSectionPillScrollMode()`: * - `disabled` → leave the scroll position alone. * - `scrollToTop` → always jump back to the top. * - `rememberPosition` → save the offset of the section we are leaving and * restore the target section's saved offset, falling * back to the top when it has not been visited yet. * * The save has to happen synchronously (the section being left is still in the * DOM here, before Vue re-renders); the restore is deferred until the newly * selected section has rendered. */ private onSectionPillChange(newIndex: number): void { const mode = PowerduckState.getModalSectionPillScrollMode(); const previousIndex = this._activeSectionIndex; this._activeSectionIndex = newIndex; if (mode == ModalSectionPillScrollMode.disabled) { return; } if (mode == ModalSectionPillScrollMode.rememberPosition) { this._sectionScrollPositions[previousIndex] = this.readScrollTop(this.getScrollContainer()); } this.$nextTick(() => { const apply = () => this.applySectionScroll(newIndex, mode); if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(apply); } else { apply(); } }); } private applySectionScroll(index: number, mode: ModalSectionPillScrollMode): void { const remembered = this._sectionScrollPositions[index]; const target = (mode == ModalSectionPillScrollMode.rememberPosition && remembered != null) ? remembered : 0; ScrollUtils.scrollToPos( target, this.getScrollContainer(), false, true, ); } /** * Nearest scrollable ancestor of the section content. Resolves to the `.modal` * element on desktop and to `.modal-body` in the mobile bottom-sheet layout; * returns null when nothing scrolls (page-level usage → window). */ private getScrollContainer(): HTMLElement | null { let parent = (this.$el as HTMLElement | null)?.parentElement ?? null; while (parent != null) { const overflowY = getComputedStyle(parent).overflowY; if (overflowY === 'auto' || overflowY === 'scroll') { return parent; } parent = parent.parentElement; } return null; } private readScrollTop(container: HTMLElement | null): number { if (container != null) { return container.scrollTop; } return globalState.scrollY ?? globalState.pageYOffset ?? 0; } render(h) { if (PowerduckState.getModalSectionMode() == ModalSectionMode.fieldSet) { return
{this.$slots.default?.()}
; } else { return ( this.onSectionPillChange(index)} > {this.$slots.default?.()} ); } } } const ModalSectionWrapper = toNative(ModalSectionWrapperComponent); export default ModalSectionWrapper;