|
@@ -59,18 +59,34 @@
|
|
|
// Ensure library CSS is loaded (xjs.css)
|
|
// Ensure library CSS is loaded (xjs.css)
|
|
|
// - CSS is centralized in xjs.css (no runtime <style> injection).
|
|
// - CSS is centralized in xjs.css (no runtime <style> injection).
|
|
|
// - We still auto-load it for convenience/compat, since consumers may forget the <link>.
|
|
// - We still auto-load it for convenience/compat, since consumers may forget the <link>.
|
|
|
|
|
+ let _xjsCssReady = null; // Promise<void>
|
|
|
|
|
+ const waitForStylesheet = (link) => new Promise((resolve) => {
|
|
|
|
|
+ if (!link) return resolve();
|
|
|
|
|
+ if (link.sheet) return resolve();
|
|
|
|
|
+ let done = false;
|
|
|
|
|
+ const finish = () => {
|
|
|
|
|
+ if (done) return;
|
|
|
|
|
+ done = true;
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ };
|
|
|
|
|
+ try { link.addEventListener('load', finish, { once: true }); } catch {}
|
|
|
|
|
+ try { link.addEventListener('error', finish, { once: true }); } catch {}
|
|
|
|
|
+ // Fallback timeout: avoid blocking forever if the load event is missed.
|
|
|
|
|
+ setTimeout(finish, 200);
|
|
|
|
|
+ });
|
|
|
const ensureXjsCss = () => {
|
|
const ensureXjsCss = () => {
|
|
|
try {
|
|
try {
|
|
|
- if (typeof document === 'undefined') return;
|
|
|
|
|
- if (!document.head) return;
|
|
|
|
|
|
|
+ if (_xjsCssReady) return _xjsCssReady;
|
|
|
|
|
+ if (typeof document === 'undefined') return Promise.resolve();
|
|
|
|
|
+ if (!document.head) return Promise.resolve();
|
|
|
|
|
|
|
|
- const existingHref = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
|
|
|
|
- .map((l) => (l.getAttribute('href') || '').trim())
|
|
|
|
|
- .find((h) => /(^|\/)xjs\.css(\?|#|$)/.test(h));
|
|
|
|
|
- if (existingHref) return;
|
|
|
|
|
|
|
+ const existingLink = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
|
|
|
|
+ .find((l) => /(^|\/)xjs\.css(\?|#|$)/.test((l.getAttribute('href') || '').trim()));
|
|
|
|
|
+ if (existingLink) return (_xjsCssReady = waitForStylesheet(existingLink));
|
|
|
|
|
|
|
|
const id = 'xjs-css';
|
|
const id = 'xjs-css';
|
|
|
- if (document.getElementById(id)) return;
|
|
|
|
|
|
|
+ const existing = document.getElementById(id);
|
|
|
|
|
+ if (existing) return (_xjsCssReady = waitForStylesheet(existing));
|
|
|
|
|
|
|
|
const scripts = Array.from(document.getElementsByTagName('script'));
|
|
const scripts = Array.from(document.getElementsByTagName('script'));
|
|
|
const scriptSrc = scripts
|
|
const scriptSrc = scripts
|
|
@@ -88,15 +104,18 @@
|
|
|
link.id = id;
|
|
link.id = id;
|
|
|
link.rel = 'stylesheet';
|
|
link.rel = 'stylesheet';
|
|
|
link.href = href;
|
|
link.href = href;
|
|
|
|
|
+ _xjsCssReady = waitForStylesheet(link);
|
|
|
document.head.appendChild(link);
|
|
document.head.appendChild(link);
|
|
|
|
|
+ return _xjsCssReady;
|
|
|
} catch {
|
|
} catch {
|
|
|
// ignore
|
|
// ignore
|
|
|
|
|
+ return Promise.resolve();
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
class Layer {
|
|
class Layer {
|
|
|
constructor() {
|
|
constructor() {
|
|
|
- ensureXjsCss();
|
|
|
|
|
|
|
+ this._cssReady = ensureXjsCss();
|
|
|
this.params = {};
|
|
this.params = {};
|
|
|
this.dom = {};
|
|
this.dom = {};
|
|
|
this.promise = null;
|
|
this.promise = null;
|
|
@@ -111,6 +130,8 @@
|
|
|
this._flowIndex = 0;
|
|
this._flowIndex = 0;
|
|
|
this._flowValues = [];
|
|
this._flowValues = [];
|
|
|
this._flowBase = null; // base options merged into each step
|
|
this._flowBase = null; // base options merged into each step
|
|
|
|
|
+ this._flowResolved = null; // resolved merged steps
|
|
|
|
|
+ this._flowMountedList = []; // mounted DOM records for move-mode steps
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Constructor helper when called as function: const popup = Layer({...})
|
|
// Constructor helper when called as function: const popup = Layer({...})
|
|
@@ -221,14 +242,21 @@
|
|
|
_fireFlow() {
|
|
_fireFlow() {
|
|
|
this._flowIndex = 0;
|
|
this._flowIndex = 0;
|
|
|
this._flowValues = [];
|
|
this._flowValues = [];
|
|
|
- this._flowBase = { ...(this.params || {}) };
|
|
|
|
|
|
|
+ // In flow mode:
|
|
|
|
|
+ // - keep iconAnimation off by default (avoids jitter)
|
|
|
|
|
+ // - BUT allow popupAnimation by default so the first step has the same entrance feel
|
|
|
|
|
+ const base = { ...(this.params || {}) };
|
|
|
|
|
+ if (!('popupAnimation' in base)) base.popupAnimation = true;
|
|
|
|
|
+ if (!('iconAnimation' in base)) base.iconAnimation = false;
|
|
|
|
|
+ this._flowBase = base;
|
|
|
|
|
|
|
|
this.promise = new Promise((resolve, reject) => {
|
|
this.promise = new Promise((resolve, reject) => {
|
|
|
this.resolve = resolve;
|
|
this.resolve = resolve;
|
|
|
this.reject = reject;
|
|
this.reject = reject;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const first = this._getFlowStepOptions(0);
|
|
|
|
|
|
|
+ this._flowResolved = (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
|
|
|
|
|
+ const first = this._flowResolved[0] || this._getFlowStepOptions(0);
|
|
|
this.params = first;
|
|
this.params = first;
|
|
|
this._render({ flow: true });
|
|
this._render({ flow: true });
|
|
|
return this.promise;
|
|
return this.promise;
|
|
@@ -261,6 +289,12 @@
|
|
|
this.dom.popup = el('div', `${PREFIX}popup`);
|
|
this.dom.popup = el('div', `${PREFIX}popup`);
|
|
|
this.dom.overlay.appendChild(this.dom.popup);
|
|
this.dom.overlay.appendChild(this.dom.popup);
|
|
|
|
|
|
|
|
|
|
+ // Flow mode: pre-mount all steps and switch by hide/show (DOM continuity, less jitter)
|
|
|
|
|
+ if (meta && meta.flow) {
|
|
|
|
|
+ this._renderFlowUI();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Icon
|
|
// Icon
|
|
|
if (this.params.icon) {
|
|
if (this.params.icon) {
|
|
|
this.dom.icon = this._createIcon(this.params.icon);
|
|
this.dom.icon = this._createIcon(this.params.icon);
|
|
@@ -318,15 +352,126 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- document.body.appendChild(this.dom.overlay);
|
|
|
|
|
|
|
+ // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
|
|
|
|
|
+ // then show on a clean frame so opacity transitions are smooth.
|
|
|
|
|
+ const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
|
|
|
|
|
+ ready.then(() => {
|
|
|
|
|
+ try { this.dom.overlay.style.visibility = ''; } catch {}
|
|
|
|
|
+ if (!this.dom.overlay.parentNode) {
|
|
|
|
|
+ document.body.appendChild(this.dom.overlay);
|
|
|
|
|
+ }
|
|
|
|
|
+ // Double-rAF gives the browser a chance to apply styles before animating.
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ this.dom.overlay.classList.add('show');
|
|
|
|
|
+ this._didOpen();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _renderFlowUI() {
|
|
|
|
|
+ // Icon/title/content/actions are stable; steps are pre-mounted and toggled.
|
|
|
|
|
+ const popup = this.dom.popup;
|
|
|
|
|
+ if (!popup) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Clear popup
|
|
|
|
|
+ while (popup.firstChild) popup.removeChild(popup.firstChild);
|
|
|
|
|
|
|
|
- // Animation
|
|
|
|
|
- requestAnimationFrame(() => {
|
|
|
|
|
- this.dom.overlay.classList.add('show');
|
|
|
|
|
- this._didOpen();
|
|
|
|
|
|
|
+ // Icon (in flow: suppressed by default unless question or summary)
|
|
|
|
|
+ this.dom.icon = null;
|
|
|
|
|
+ if (this.params.icon) {
|
|
|
|
|
+ this.dom.icon = this._createIcon(this.params.icon);
|
|
|
|
|
+ popup.appendChild(this.dom.icon);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Title (always present for flow)
|
|
|
|
|
+ this.dom.title = el('h2', `${PREFIX}title`, this.params.title || '');
|
|
|
|
|
+ popup.appendChild(this.dom.title);
|
|
|
|
|
+
|
|
|
|
|
+ // Content container
|
|
|
|
|
+ this.dom.content = el('div', `${PREFIX}content`);
|
|
|
|
|
+ // Stack container: keep panes absolute so we can cross-fade without layout thrash
|
|
|
|
|
+ this.dom.stepStack = el('div', `${PREFIX}step-stack`);
|
|
|
|
|
+ this.dom.stepStack.style.position = 'relative';
|
|
|
|
|
+ this.dom.stepStack.style.width = '100%';
|
|
|
|
|
+ this.dom.stepPanes = [];
|
|
|
|
|
+ this._flowMountedList = [];
|
|
|
|
|
+
|
|
|
|
|
+ const steps = this._flowResolved || (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
|
|
|
|
|
+ steps.forEach((opt, i) => {
|
|
|
|
|
+ const pane = el('div', `${PREFIX}step-pane`);
|
|
|
|
|
+ pane.style.width = '100%';
|
|
|
|
|
+ pane.style.boxSizing = 'border-box';
|
|
|
|
|
+ pane.style.display = (i === this._flowIndex) ? '' : 'none';
|
|
|
|
|
+
|
|
|
|
|
+ // Fill pane: dom/html/text
|
|
|
|
|
+ const domSpec = this._normalizeDomSpec(opt);
|
|
|
|
|
+ if (domSpec) {
|
|
|
|
|
+ this._mountDomContentInto(pane, domSpec, { collectFlow: true });
|
|
|
|
|
+ } else if (opt.html) {
|
|
|
|
|
+ pane.innerHTML = opt.html;
|
|
|
|
|
+ } else if (opt.text) {
|
|
|
|
|
+ pane.textContent = opt.text;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.dom.stepStack.appendChild(pane);
|
|
|
|
|
+ this.dom.stepPanes.push(pane);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ this.dom.content.appendChild(this.dom.stepStack);
|
|
|
|
|
+ popup.appendChild(this.dom.content);
|
|
|
|
|
+
|
|
|
|
|
+ // Actions
|
|
|
|
|
+ this.dom.actions = el('div', `${PREFIX}actions`);
|
|
|
|
|
+ popup.appendChild(this.dom.actions);
|
|
|
|
|
+ this._updateFlowActions();
|
|
|
|
|
+
|
|
|
|
|
+ // Event Listeners
|
|
|
|
|
+ if (this.params.closeOnClickOutside) {
|
|
|
|
|
+ this.dom.overlay.addEventListener('click', (e) => {
|
|
|
|
|
+ if (e.target === this.dom.overlay) {
|
|
|
|
|
+ this._close(null, 'backdrop');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
|
|
|
|
|
+ // then show on a clean frame so opacity transitions are smooth.
|
|
|
|
|
+ const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
|
|
|
|
|
+ ready.then(() => {
|
|
|
|
|
+ try { this.dom.overlay.style.visibility = ''; } catch {}
|
|
|
|
|
+ if (!this.dom.overlay.parentNode) {
|
|
|
|
|
+ document.body.appendChild(this.dom.overlay);
|
|
|
|
|
+ }
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ this.dom.overlay.classList.add('show');
|
|
|
|
|
+ this._didOpen();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ _updateFlowActions() {
|
|
|
|
|
+ const actions = this.dom && this.dom.actions;
|
|
|
|
|
+ if (!actions) return;
|
|
|
|
|
+ while (actions.firstChild) actions.removeChild(actions.firstChild);
|
|
|
|
|
+
|
|
|
|
|
+ this.dom.cancelBtn = null;
|
|
|
|
|
+ if (this.params.showCancelButton) {
|
|
|
|
|
+ this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
|
|
|
|
|
+ this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
|
|
|
|
|
+ this.dom.cancelBtn.onclick = () => this._handleCancel();
|
|
|
|
|
+ actions.appendChild(this.dom.cancelBtn);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
|
|
|
|
|
+ this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
|
|
|
|
|
+ this.dom.confirmBtn.onclick = () => this._handleConfirm();
|
|
|
|
|
+ actions.appendChild(this.dom.confirmBtn);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
_createIcon(type) {
|
|
_createIcon(type) {
|
|
|
const icon = el('div', `${PREFIX}icon ${type}`);
|
|
const icon = el('div', `${PREFIX}icon ${type}`);
|
|
|
const applyIconSize = (mode) => {
|
|
const applyIconSize = (mode) => {
|
|
@@ -485,18 +630,60 @@
|
|
|
|
|
|
|
|
// Popup animation (optional)
|
|
// Popup animation (optional)
|
|
|
if (this.params.popupAnimation) {
|
|
if (this.params.popupAnimation) {
|
|
|
- const X = Layer._getXjs();
|
|
|
|
|
- if (X && this.dom.popup) {
|
|
|
|
|
- // Override the CSS scale transition with a spring-ish entrance.
|
|
|
|
|
|
|
+ if (this.dom.popup) {
|
|
|
|
|
+ // Use WAAPI directly to guarantee the slide+fade effect is visible,
|
|
|
|
|
+ // independent from xjs.animate implementation details.
|
|
|
try {
|
|
try {
|
|
|
- this.dom.popup.style.transition = 'none';
|
|
|
|
|
- this.dom.popup.style.transform = 'scale(0.92)';
|
|
|
|
|
- X(this.dom.popup).animate({
|
|
|
|
|
- scale: [0.92, 1],
|
|
|
|
|
- y: [-6, 0],
|
|
|
|
|
- duration: 520,
|
|
|
|
|
- easing: { stiffness: 260, damping: 16 }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const popup = this.dom.popup;
|
|
|
|
|
+ try { popup.classList.add(`${PREFIX}popup-anim-slide`); } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ const rect = popup.getBoundingClientRect();
|
|
|
|
|
+ // Default entrance: from above center by "more than half" of popup height.
|
|
|
|
|
+ // Clamp to viewport so very tall popups don't start far off-screen.
|
|
|
|
|
+ const vh = (typeof window !== 'undefined' && window && window.innerHeight) ? window.innerHeight : rect.height;
|
|
|
|
|
+ const baseH = Math.min(rect.height, vh);
|
|
|
|
|
+ const y0 = -Math.round(Math.max(18, baseH * 0.75));
|
|
|
|
|
+
|
|
|
|
|
+ // Disable CSS transform transition so it won't fight with WAAPI.
|
|
|
|
|
+ popup.style.transition = 'none';
|
|
|
|
|
+ popup.style.willChange = 'transform, opacity';
|
|
|
|
|
+
|
|
|
|
|
+ // If WAAPI is available, prefer it (most consistent).
|
|
|
|
|
+ if (popup.animate) {
|
|
|
|
|
+ const anim = popup.animate(
|
|
|
|
|
+ [
|
|
|
|
|
+ { transform: `translateY(${y0}px) scale(0.92)`, opacity: 0 },
|
|
|
|
|
+ { transform: 'translateY(0px) scale(1)', opacity: 1 }
|
|
|
|
|
+ ],
|
|
|
|
|
+ {
|
|
|
|
|
+ duration: 520,
|
|
|
|
|
+ easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
|
|
|
|
|
+ fill: 'forwards'
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ anim.finished
|
|
|
|
|
+ .catch(() => {})
|
|
|
|
|
+ .finally(() => {
|
|
|
|
|
+ try { popup.style.willChange = ''; } catch {}
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Fallback: try xjs.animate if WAAPI isn't available.
|
|
|
|
|
+ const X = Layer._getXjs();
|
|
|
|
|
+ if (X) {
|
|
|
|
|
+ popup.style.opacity = '0';
|
|
|
|
|
+ popup.style.transform = `translateY(${y0}px) scale(0.92)`;
|
|
|
|
|
+ X(popup).animate({
|
|
|
|
|
+ y: [y0, 0],
|
|
|
|
|
+ scale: [0.92, 1],
|
|
|
|
|
+ opacity: [0, 1],
|
|
|
|
|
+ duration: 520,
|
|
|
|
|
+ easing: 'ease-out'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ try { popup.style.willChange = ''; } catch {}
|
|
|
|
|
+ }, 560);
|
|
|
|
|
+ }
|
|
|
} catch {}
|
|
} catch {}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -589,7 +776,10 @@
|
|
|
this._isClosing = true;
|
|
this._isClosing = true;
|
|
|
|
|
|
|
|
// Restore mounted DOM (moved into popup) before removing overlay
|
|
// Restore mounted DOM (moved into popup) before removing overlay
|
|
|
- try { this._unmountDomContent(); } catch {}
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
|
|
|
|
|
+ else this._unmountDomContent();
|
|
|
|
|
+ } catch {}
|
|
|
|
|
|
|
|
this.dom.overlay.classList.remove('show');
|
|
this.dom.overlay.classList.remove('show');
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
@@ -643,6 +833,42 @@
|
|
|
return { dom, mode };
|
|
return { dom, mode };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ _mountDomContentInto(target, domSpec, opts = null) {
|
|
|
|
|
+ // Like _mountDomContent, but mounts into the provided container.
|
|
|
|
|
+ const collectFlow = !!(opts && opts.collectFlow);
|
|
|
|
|
+ const originalContent = this.dom.content;
|
|
|
|
|
+ // Temporarily redirect this.dom.content for reuse of internal logic.
|
|
|
|
|
+ try { this.dom.content = target; } catch {}
|
|
|
|
|
+ try {
|
|
|
|
|
+ const before = this._mounted;
|
|
|
|
|
+ this._mountDomContent(domSpec);
|
|
|
|
|
+ const rec = this._mounted;
|
|
|
|
|
+ // If we mounted in move-mode, _mounted holds record; detach it from single-mode tracking.
|
|
|
|
|
+ if (collectFlow && rec && rec.kind === 'move') {
|
|
|
|
|
+ this._flowMountedList.push(rec);
|
|
|
|
|
+ this._mounted = before; // restore previous single record (usually null)
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ try { this.dom.content = originalContent; } catch {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _unmountFlowMounted() {
|
|
|
|
|
+ // Restore all moved DOM nodes for flow steps
|
|
|
|
|
+ const list = Array.isArray(this._flowMountedList) ? this._flowMountedList : [];
|
|
|
|
|
+ this._flowMountedList = [];
|
|
|
|
|
+ list.forEach((m) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!m || m.kind !== 'move') return;
|
|
|
|
|
+ // Reuse single unmount logic by swapping _mounted
|
|
|
|
|
+ const prev = this._mounted;
|
|
|
|
|
+ this._mounted = m;
|
|
|
|
|
+ this._unmountDomContent();
|
|
|
|
|
+ this._mounted = prev;
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
_mountDomContent(domSpec) {
|
|
_mountDomContent(domSpec) {
|
|
|
try {
|
|
try {
|
|
|
if (!this.dom.content) return;
|
|
if (!this.dom.content) return;
|
|
@@ -813,6 +1039,26 @@
|
|
|
if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
|
|
if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Icon/animation policy for flow:
|
|
|
|
|
+ // - During steps: no icon/animations by default (avoids distraction + layout jitter)
|
|
|
|
|
+ // - Allow icon only if step explicitly uses `icon:'question'`, or step is marked as summary.
|
|
|
|
|
+ const isSummary = !!(step && (step.summary === true || step.isSummary === true));
|
|
|
|
|
+ const explicitIcon = ('icon' in step) ? step.icon : undefined;
|
|
|
|
|
+ const baseIcon = ('icon' in base) ? base.icon : undefined;
|
|
|
|
|
+ const chosenIcon = (explicitIcon !== undefined) ? explicitIcon : baseIcon;
|
|
|
|
|
+
|
|
|
|
|
+ if (!isSummary && !isLast) {
|
|
|
|
|
+ merged.icon = (chosenIcon === 'question') ? 'question' : null;
|
|
|
|
|
+ merged.iconAnimation = false;
|
|
|
|
|
+ } else if (!isSummary && isLast) {
|
|
|
|
|
+ // last step: still suppress unless summary or question
|
|
|
|
|
+ merged.icon = (chosenIcon === 'question') ? 'question' : null;
|
|
|
|
|
+ merged.iconAnimation = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // summary step: allow icon; animation follows explicit config (default true only if provided elsewhere)
|
|
|
|
|
+ if (!('icon' in step) && chosenIcon === undefined) merged.icon = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return merged;
|
|
return merged;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -856,47 +1102,139 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async _flowGo(index, direction) {
|
|
async _flowGo(index, direction) {
|
|
|
- const next = this._getFlowStepOptions(index);
|
|
|
|
|
- this._flowIndex = index;
|
|
|
|
|
- await this._transitionTo(next, direction);
|
|
|
|
|
|
|
+ const next = (this._flowResolved && this._flowResolved[index]) ? this._flowResolved[index] : this._getFlowStepOptions(index);
|
|
|
|
|
+ await this._transitionToFlow(index, next, direction);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async _transitionTo(nextOptions, direction) {
|
|
|
|
|
- // Animate popup swap (keep overlay)
|
|
|
|
|
|
|
+ async _transitionToFlow(nextIndex, nextOptions, direction) {
|
|
|
const popup = this.dom && this.dom.popup;
|
|
const popup = this.dom && this.dom.popup;
|
|
|
- if (!popup) {
|
|
|
|
|
|
|
+ const content = this.dom && this.dom.content;
|
|
|
|
|
+ const panes = this.dom && this.dom.stepPanes;
|
|
|
|
|
+ if (!popup || !content || !panes || !panes.length) {
|
|
|
|
|
+ this._flowIndex = nextIndex;
|
|
|
this.params = nextOptions;
|
|
this.params = nextOptions;
|
|
|
- this._rerenderInside();
|
|
|
|
|
|
|
+ this._render({ flow: true });
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // First, unmount moved DOM from current step so it can be remounted later
|
|
|
|
|
- try { this._unmountDomContent(); } catch {}
|
|
|
|
|
|
|
+ const fromIndex = this._flowIndex;
|
|
|
|
|
+ const fromPane = panes[fromIndex];
|
|
|
|
|
+ const toPane = panes[nextIndex];
|
|
|
|
|
+ if (!fromPane || !toPane) {
|
|
|
|
|
+ this._flowIndex = nextIndex;
|
|
|
|
|
+ this.params = nextOptions;
|
|
|
|
|
+ this._render({ flow: true });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const outKeyframes = [
|
|
|
|
|
- { opacity: 1, transform: 'translateY(0px)' },
|
|
|
|
|
- { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '8px' : '-8px'})` }
|
|
|
|
|
- ];
|
|
|
|
|
- const inKeyframes = [
|
|
|
|
|
- { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '-8px' : '8px'})` },
|
|
|
|
|
- { opacity: 1, transform: 'translateY(0px)' }
|
|
|
|
|
- ];
|
|
|
|
|
|
|
+ // Measure current content height
|
|
|
|
|
+ const oldH = content.getBoundingClientRect().height;
|
|
|
|
|
+
|
|
|
|
|
+ // Prepare target pane for measurement without affecting layout
|
|
|
|
|
+ const prevDisplay = toPane.style.display;
|
|
|
|
|
+ const prevPos = toPane.style.position;
|
|
|
|
|
+ const prevVis = toPane.style.visibility;
|
|
|
|
|
+ const prevPointer = toPane.style.pointerEvents;
|
|
|
|
|
+ toPane.style.display = '';
|
|
|
|
|
+ toPane.style.position = 'absolute';
|
|
|
|
|
+ toPane.style.visibility = 'hidden';
|
|
|
|
|
+ toPane.style.pointerEvents = 'none';
|
|
|
|
|
+ toPane.style.left = '0';
|
|
|
|
|
+ toPane.style.right = '0';
|
|
|
|
|
+
|
|
|
|
|
+ const newH = toPane.getBoundingClientRect().height;
|
|
|
|
|
+
|
|
|
|
|
+ // Restore pane styles (keep hidden until animation starts)
|
|
|
|
|
+ toPane.style.position = prevPos;
|
|
|
|
|
+ toPane.style.visibility = prevVis;
|
|
|
|
|
+ toPane.style.pointerEvents = prevPointer;
|
|
|
|
|
+ toPane.style.display = prevDisplay; // usually 'none'
|
|
|
|
|
+
|
|
|
|
|
+ // Apply new options (title/buttons/icon policy) before showing the pane
|
|
|
|
|
+ this._flowIndex = nextIndex;
|
|
|
|
|
+ this.params = nextOptions;
|
|
|
|
|
|
|
|
|
|
+ // Update icon/title/buttons without recreating DOM
|
|
|
try {
|
|
try {
|
|
|
- const a = popup.animate(outKeyframes, { duration: 140, easing: 'ease-out', fill: 'forwards' });
|
|
|
|
|
- await (a && a.finished ? a.finished.catch(() => {}) : Promise.resolve());
|
|
|
|
|
|
|
+ if (this.dom.title) this.dom.title.textContent = this.params.title || '';
|
|
|
} catch {}
|
|
} catch {}
|
|
|
|
|
|
|
|
- // Apply new options
|
|
|
|
|
- this.params = nextOptions;
|
|
|
|
|
- this._rerenderInside();
|
|
|
|
|
|
|
+ // Icon updates (only if needed)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const wantsIcon = !!this.params.icon;
|
|
|
|
|
+ if (!wantsIcon && this.dom.icon) {
|
|
|
|
|
+ this.dom.icon.remove();
|
|
|
|
|
+ this.dom.icon = null;
|
|
|
|
|
+ } else if (wantsIcon) {
|
|
|
|
|
+ const curType = this.dom.icon ? (Array.from(this.dom.icon.classList).find(c => c !== `${PREFIX}icon`) || '') : '';
|
|
|
|
|
+ if (!this.dom.icon || !this.dom.icon.classList.contains(String(this.params.icon))) {
|
|
|
|
|
+ if (this.dom.icon) this.dom.icon.remove();
|
|
|
|
|
+ this.dom.icon = this._createIcon(this.params.icon);
|
|
|
|
|
+ // icon should be on top (before title)
|
|
|
|
|
+ popup.insertBefore(this.dom.icon, this.dom.title || popup.firstChild);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ this._updateFlowActions();
|
|
|
|
|
+
|
|
|
|
|
+ // Switch panes with smooth opacity/translate; animate content height to reduce jitter
|
|
|
|
|
+ const dy = (direction === 'prev') ? -8 : 8;
|
|
|
|
|
+
|
|
|
|
|
+ // Show both panes during animation (stacked)
|
|
|
|
|
+ toPane.style.display = '';
|
|
|
|
|
+ toPane.style.position = 'relative';
|
|
|
|
|
+ toPane.style.opacity = '0';
|
|
|
|
|
+ toPane.style.transform = `translateY(${dy}px)`;
|
|
|
|
|
+ toPane.style.willChange = 'opacity, transform';
|
|
|
|
|
+ fromPane.style.willChange = 'opacity, transform';
|
|
|
|
|
|
|
|
|
|
+ // Lock content height during transition
|
|
|
|
|
+ content.style.height = oldH + 'px';
|
|
|
|
|
+ content.style.overflow = 'hidden';
|
|
|
|
|
+
|
|
|
|
|
+ // Animate height
|
|
|
|
|
+ let heightAnim = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ heightAnim = content.animate(
|
|
|
|
|
+ [{ height: oldH + 'px' }, { height: newH + 'px' }],
|
|
|
|
|
+ { duration: 220, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ // Animate panes
|
|
|
try {
|
|
try {
|
|
|
- const b = popup.animate(inKeyframes, { duration: 200, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' });
|
|
|
|
|
- await (b && b.finished ? b.finished.catch(() => {}) : Promise.resolve());
|
|
|
|
|
|
|
+ fromPane.animate(
|
|
|
|
|
+ [{ opacity: 1, transform: 'translateY(0px)' }, { opacity: 0, transform: `translateY(${-dy}px)` }],
|
|
|
|
|
+ { duration: 160, easing: 'ease-out', fill: 'forwards' }
|
|
|
|
|
+ );
|
|
|
} catch {}
|
|
} catch {}
|
|
|
|
|
|
|
|
- // Re-adjust icon ring bg in case popup background differs
|
|
|
|
|
|
|
+ let paneIn = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ paneIn = toPane.animate(
|
|
|
|
|
+ [{ opacity: 0, transform: `translateY(${dy}px)` }, { opacity: 1, transform: 'translateY(0px)' }],
|
|
|
|
|
+ { duration: 220, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await Promise.all([
|
|
|
|
|
+ heightAnim && heightAnim.finished ? heightAnim.finished.catch(() => {}) : Promise.resolve(),
|
|
|
|
|
+ paneIn && paneIn.finished ? paneIn.finished.catch(() => {}) : Promise.resolve()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ // Cleanup styles and hide old pane
|
|
|
|
|
+ fromPane.style.display = 'none';
|
|
|
|
|
+ fromPane.style.willChange = '';
|
|
|
|
|
+ toPane.style.willChange = '';
|
|
|
|
|
+ toPane.style.opacity = '';
|
|
|
|
|
+ toPane.style.transform = '';
|
|
|
|
|
+ content.style.height = '';
|
|
|
|
|
+ content.style.overflow = '';
|
|
|
|
|
+
|
|
|
|
|
+ // Re-adjust ring background if icon exists (rare in flow)
|
|
|
try { this._adjustRingBackgroundColor(); } catch {}
|
|
try { this._adjustRingBackgroundColor(); } catch {}
|
|
|
}
|
|
}
|
|
|
|
|
|