layer.js 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468
  1. (function (global, factory) {
  2. const LayerClass = factory();
  3. // Allow usage as `Layer({...})` or `new Layer()`
  4. // But Layer is a class. We can wrap it in a proxy or factory function.
  5. function LayerFactory(options) {
  6. if (options && typeof options === 'object') {
  7. return LayerClass.$(options);
  8. }
  9. return new LayerClass();
  10. }
  11. // Copy static methods (including non-enumerable class statics like `fire` / `$`)
  12. // Class static methods are non-enumerable by default, so Object.assign() would miss them.
  13. const copyStatic = (to, from) => {
  14. try {
  15. Object.getOwnPropertyNames(from).forEach((k) => {
  16. if (k === 'prototype' || k === 'name' || k === 'length') return;
  17. const desc = Object.getOwnPropertyDescriptor(from, k);
  18. if (!desc) return;
  19. Object.defineProperty(to, k, desc);
  20. });
  21. } catch (e) {
  22. // Best-effort fallback
  23. try { Object.assign(to, from); } catch {}
  24. }
  25. };
  26. copyStatic(LayerFactory, LayerClass);
  27. // Also copy prototype for instanceof checks if needed (though tricky with factory)
  28. LayerFactory.prototype = LayerClass.prototype;
  29. typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = LayerFactory :
  30. typeof define === 'function' && define.amd ? define(() => LayerFactory) :
  31. (global.Layer = LayerFactory);
  32. }(this, (function () {
  33. 'use strict';
  34. const PREFIX = 'layer-';
  35. const RING_TYPES = new Set(['success', 'error', 'warning', 'info', 'question']);
  36. const RING_BG_PARTS_SELECTOR = `.${PREFIX}success-circular-line-left, .${PREFIX}success-circular-line-right, .${PREFIX}success-fix`;
  37. const normalizeOptions = (options, text, icon) => {
  38. if (typeof options !== 'string') return options || {};
  39. const o = { title: options };
  40. if (text) o.text = text;
  41. if (icon) o.icon = icon;
  42. return o;
  43. };
  44. const el = (tag, className, text) => {
  45. const node = document.createElement(tag);
  46. if (className) node.className = className;
  47. if (text !== undefined) node.textContent = text;
  48. return node;
  49. };
  50. const svgEl = (tag) => document.createElementNS('http://www.w3.org/2000/svg', tag);
  51. // Ensure library CSS is loaded (xjs.css)
  52. // - CSS is centralized in xjs.css (no runtime <style> injection).
  53. // - We still auto-load it for convenience/compat, since consumers may forget the <link>.
  54. let _xjsCssReady = null; // Promise<void>
  55. const waitForStylesheet = (link) => new Promise((resolve) => {
  56. if (!link) return resolve();
  57. if (link.sheet) return resolve();
  58. let done = false;
  59. const finish = () => {
  60. if (done) return;
  61. done = true;
  62. resolve();
  63. };
  64. try { link.addEventListener('load', finish, { once: true }); } catch {}
  65. try { link.addEventListener('error', finish, { once: true }); } catch {}
  66. // Fallback timeout: avoid blocking forever if the load event is missed.
  67. setTimeout(finish, 200);
  68. });
  69. const ensureXjsCss = () => {
  70. try {
  71. if (_xjsCssReady) return _xjsCssReady;
  72. if (typeof document === 'undefined') return Promise.resolve();
  73. if (!document.head) return Promise.resolve();
  74. const existingLink = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
  75. .find((l) => /(^|\/)xjs\.css(\?|#|$)/.test((l.getAttribute('href') || '').trim()));
  76. if (existingLink) return (_xjsCssReady = waitForStylesheet(existingLink));
  77. const id = 'xjs-css';
  78. const existing = document.getElementById(id);
  79. if (existing) return (_xjsCssReady = waitForStylesheet(existing));
  80. const scripts = Array.from(document.getElementsByTagName('script'));
  81. const scriptSrc = scripts
  82. .map((s) => s && s.src)
  83. .find((src) => /(^|\/)(xjs|layer)\.js(\?|#|$)/.test(String(src || '')));
  84. let href = 'xjs.css';
  85. if (scriptSrc) {
  86. href = String(scriptSrc)
  87. .replace(/(^|\/)xjs\.js(\?|#|$)/, '$1xjs.css$2')
  88. .replace(/(^|\/)layer\.js(\?|#|$)/, '$1xjs.css$2');
  89. }
  90. const link = document.createElement('link');
  91. link.id = id;
  92. link.rel = 'stylesheet';
  93. link.href = href;
  94. _xjsCssReady = waitForStylesheet(link);
  95. document.head.appendChild(link);
  96. return _xjsCssReady;
  97. } catch {
  98. // ignore
  99. return Promise.resolve();
  100. }
  101. };
  102. class Layer {
  103. constructor() {
  104. this._cssReady = ensureXjsCss();
  105. this.params = {};
  106. this.dom = {};
  107. this.promise = null;
  108. this.resolve = null;
  109. this.reject = null;
  110. this._onKeydown = null;
  111. this._mounted = null; // { kind, originalEl, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay }
  112. this._isClosing = false;
  113. this._isReplace = false;
  114. // Step/flow support
  115. this._flowSteps = null; // Array<options>
  116. this._flowIndex = 0;
  117. this._flowValues = [];
  118. this._flowBase = null; // base options merged into each step
  119. this._flowResolved = null; // resolved merged steps
  120. this._flowMountedList = []; // mounted DOM records for move-mode steps
  121. }
  122. // Constructor helper when called as function: const popup = Layer({...})
  123. static get isProxy() { return true; }
  124. // Static entry point
  125. static run(options) {
  126. const instance = new Layer();
  127. return instance._fire(options);
  128. }
  129. // Backward-compatible alias
  130. static fire(options) { return Layer.run(options); }
  131. // Chainable entry point (builder-style)
  132. // Example:
  133. // Layer.$({ title: 'Hi' }).run().then(...)
  134. // Layer.$().config({ title: 'Hi' }).run()
  135. static $(options) {
  136. const instance = new Layer();
  137. if (options !== undefined) instance.config(options);
  138. return instance;
  139. }
  140. // Chainable config helper (does not render until `.run()` is called)
  141. config(options = {}) {
  142. // Support the same shorthand as Layer.fire(title, text, icon)
  143. options = normalizeOptions(options, arguments[1], arguments[2]);
  144. this.params = { ...(this.params || {}), ...options };
  145. return this;
  146. }
  147. // Add a single step (chainable)
  148. // Usage:
  149. // Layer.$().step({ title:'A', dom:'#step1' }).step({ title:'B', dom:'#step2' }).run()
  150. step(options = {}) {
  151. if (!this._flowSteps) this._flowSteps = [];
  152. this._flowSteps.push(normalizeOptions(options, arguments[1], arguments[2]));
  153. return this;
  154. }
  155. // Add multiple steps at once (chainable)
  156. steps(steps = []) {
  157. if (!Array.isArray(steps)) return this;
  158. if (!this._flowSteps) this._flowSteps = [];
  159. steps.forEach((s) => this.step(s));
  160. return this;
  161. }
  162. // Convenience static helper: Layer.flow([steps], baseOptions?)
  163. static flow(steps = [], baseOptions = {}) {
  164. return Layer.$(baseOptions).steps(steps).run();
  165. }
  166. // Instance entry point (chainable)
  167. run(options) {
  168. // Flow mode: if configured via .step()/.steps(), ignore per-call options and use steps
  169. if (this._flowSteps && this._flowSteps.length) {
  170. if (options !== undefined && options !== null) {
  171. // allow providing base options at fire-time
  172. this.params = { ...(this.params || {}), ...normalizeOptions(options, arguments[1], arguments[2]) };
  173. }
  174. return this._fireFlow();
  175. }
  176. const merged = (options === undefined) ? (this.params || {}) : options;
  177. return this._fire(merged);
  178. }
  179. // Backward-compatible alias
  180. fire(options) { return this.run(options); }
  181. _fire(options = {}) {
  182. options = normalizeOptions(options, arguments[1], arguments[2]);
  183. this.params = {
  184. title: '',
  185. text: '',
  186. icon: null,
  187. iconSize: null, // e.g. '6em' / '72px'
  188. confirmButtonText: 'OK',
  189. cancelButtonText: 'Cancel',
  190. showCancelButton: false,
  191. confirmButtonColor: '#3085d6',
  192. cancelButtonColor: '#aaa',
  193. closeOnClickOutside: true,
  194. closeOnEsc: true,
  195. iconAnimation: true,
  196. popupAnimation: true,
  197. // Content:
  198. // - text: plain text
  199. // - html: innerHTML
  200. // - dom: selector / Element / <template> (preferred)
  201. // - content: backward compat for selector/Element OR advanced { dom, mode, clone }
  202. dom: null,
  203. domMode: 'move', // 'move' (default) | 'clone'
  204. // Hook for confirming (also used in flow steps)
  205. // Return false to prevent close / next.
  206. // Return a value to be attached as `value`.
  207. // May return Promise.
  208. preConfirm: null,
  209. ...options
  210. };
  211. this.promise = new Promise((resolve, reject) => {
  212. this.resolve = resolve;
  213. this.reject = reject;
  214. });
  215. this._render();
  216. return this.promise;
  217. }
  218. _fireFlow() {
  219. this._flowIndex = 0;
  220. this._flowValues = [];
  221. // In flow mode:
  222. // - keep iconAnimation off by default (avoids jitter)
  223. // - BUT allow popupAnimation by default so the first step has the same entrance feel
  224. const base = { ...(this.params || {}) };
  225. if (!('popupAnimation' in base)) base.popupAnimation = true;
  226. if (!('iconAnimation' in base)) base.iconAnimation = false;
  227. this._flowBase = base;
  228. this.promise = new Promise((resolve, reject) => {
  229. this.resolve = resolve;
  230. this.reject = reject;
  231. });
  232. this._flowResolved = (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
  233. const first = this._flowResolved[0] || this._getFlowStepOptions(0);
  234. this.params = first;
  235. this._render({ flow: true });
  236. return this.promise;
  237. }
  238. static _getXjs() {
  239. // Prefer $ (main), fallback to xjs/animal (compat)
  240. const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
  241. if (!g) return null;
  242. const x = g.$ || g.xjs || g.animal;
  243. return (typeof x === 'function') ? x : null;
  244. }
  245. _render(meta = null) {
  246. // Remove existing if any (but first, try to restore any mounted DOM from the previous instance)
  247. const existing = document.querySelector(`.${PREFIX}overlay`);
  248. const wantReplace = !!(this.params && this.params.replace);
  249. this._isReplace = false;
  250. if (existing && wantReplace) {
  251. try {
  252. const prev = existing._layerInstance;
  253. if (prev && typeof prev._forceDestroy === 'function') prev._forceDestroy('replace');
  254. } catch {}
  255. try {
  256. if (existing._layerCloseTimer) {
  257. clearTimeout(existing._layerCloseTimer);
  258. existing._layerCloseTimer = null;
  259. }
  260. } catch {}
  261. try {
  262. if (existing._layerOnClick) {
  263. existing.removeEventListener('click', existing._layerOnClick);
  264. existing._layerOnClick = null;
  265. }
  266. } catch {}
  267. try {
  268. while (existing.firstChild) existing.removeChild(existing.firstChild);
  269. } catch {}
  270. try {
  271. existing.style.visibility = '';
  272. existing.classList.add('show');
  273. // Reset any inline fade state from the previous close
  274. existing.style.transition = 'none';
  275. existing.style.opacity = '1';
  276. requestAnimationFrame(() => {
  277. try { existing.style.transition = ''; } catch {}
  278. });
  279. } catch {}
  280. this.dom.overlay = existing;
  281. this.dom.overlay._layerInstance = this;
  282. this._isReplace = true;
  283. } else {
  284. if (existing) {
  285. try {
  286. const prev = existing._layerInstance;
  287. if (prev && typeof prev._forceDestroy === 'function') prev._forceDestroy('replace');
  288. } catch {}
  289. try { existing.remove(); } catch {}
  290. }
  291. // Create Overlay
  292. this.dom.overlay = el('div', `${PREFIX}overlay`);
  293. this.dom.overlay._layerInstance = this;
  294. }
  295. // Create Popup
  296. this.dom.popup = el('div', `${PREFIX}popup`);
  297. this.dom.overlay.appendChild(this.dom.popup);
  298. // Flow mode: pre-mount all steps and switch by hide/show (DOM continuity, less jitter)
  299. if (meta && meta.flow) {
  300. this._renderFlowUI();
  301. return;
  302. }
  303. // Icon
  304. if (this.params.icon) {
  305. this.dom.icon = this._createIcon(this.params.icon);
  306. this.dom.popup.appendChild(this.dom.icon);
  307. }
  308. // Title
  309. if (this.params.title) {
  310. this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
  311. this.dom.popup.appendChild(this.dom.title);
  312. }
  313. // Content (Text / HTML / Element)
  314. if (this.params.text || this.params.html || this.params.content || this.params.dom) {
  315. this.dom.content = el('div', `${PREFIX}content`);
  316. // DOM content (preferred: dom, backward: content)
  317. const domSpec = this._normalizeDomSpec(this.params);
  318. if (domSpec) {
  319. this._mountDomContent(domSpec);
  320. } else if (this.params.html) {
  321. this.dom.content.innerHTML = this.params.html;
  322. } else {
  323. this.dom.content.textContent = this.params.text;
  324. }
  325. this.dom.popup.appendChild(this.dom.content);
  326. }
  327. // Actions
  328. this.dom.actions = el('div', `${PREFIX}actions`);
  329. // Cancel Button
  330. if (this.params.showCancelButton) {
  331. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  332. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  333. this.dom.cancelBtn.onclick = () => this._handleCancel();
  334. this.dom.actions.appendChild(this.dom.cancelBtn);
  335. }
  336. // Confirm Button
  337. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  338. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  339. this.dom.confirmBtn.onclick = () => this._handleConfirm();
  340. this.dom.actions.appendChild(this.dom.confirmBtn);
  341. this.dom.popup.appendChild(this.dom.actions);
  342. // Event Listeners
  343. if (this.params.closeOnClickOutside) {
  344. const onClick = (e) => {
  345. if (e.target === this.dom.overlay) {
  346. this._close(null); // Dismiss
  347. }
  348. };
  349. try {
  350. if (this.dom.overlay._layerOnClick) {
  351. this.dom.overlay.removeEventListener('click', this.dom.overlay._layerOnClick);
  352. }
  353. } catch {}
  354. this.dom.overlay._layerOnClick = onClick;
  355. this.dom.overlay.addEventListener('click', onClick);
  356. } else {
  357. try {
  358. if (this.dom.overlay._layerOnClick) {
  359. this.dom.overlay.removeEventListener('click', this.dom.overlay._layerOnClick);
  360. this.dom.overlay._layerOnClick = null;
  361. }
  362. } catch {}
  363. }
  364. // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
  365. // then show on a clean frame so opacity transitions are smooth.
  366. const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
  367. ready.then(() => {
  368. try { this.dom.overlay.style.visibility = ''; } catch {}
  369. if (!this.dom.overlay.parentNode) {
  370. document.body.appendChild(this.dom.overlay);
  371. }
  372. // Double-rAF gives the browser a chance to apply styles before animating.
  373. requestAnimationFrame(() => {
  374. requestAnimationFrame(() => {
  375. this.dom.overlay.classList.add('show');
  376. this._didOpen();
  377. });
  378. });
  379. });
  380. }
  381. _renderFlowUI() {
  382. // Icon/title/content/actions are stable; steps are pre-mounted and toggled.
  383. const popup = this.dom.popup;
  384. if (!popup) return;
  385. // Clear popup
  386. while (popup.firstChild) popup.removeChild(popup.firstChild);
  387. // Icon (in flow: suppressed by default unless question or summary)
  388. this.dom.icon = null;
  389. if (this.params.icon) {
  390. this.dom.icon = this._createIcon(this.params.icon);
  391. popup.appendChild(this.dom.icon);
  392. }
  393. // Title (always present for flow)
  394. this.dom.title = el('h2', `${PREFIX}title`, this.params.title || '');
  395. popup.appendChild(this.dom.title);
  396. // Content container
  397. this.dom.content = el('div', `${PREFIX}content`);
  398. // Stack container: keep panes absolute so we can cross-fade without layout thrash
  399. this.dom.stepStack = el('div', `${PREFIX}step-stack`);
  400. this.dom.stepStack.style.position = 'relative';
  401. this.dom.stepStack.style.width = '100%';
  402. this.dom.stepPanes = [];
  403. this._flowMountedList = [];
  404. const steps = this._flowResolved || (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
  405. steps.forEach((opt, i) => {
  406. const pane = el('div', `${PREFIX}step-pane`);
  407. pane.style.width = '100%';
  408. pane.style.boxSizing = 'border-box';
  409. pane.style.display = (i === this._flowIndex) ? '' : 'none';
  410. // Fill pane: dom/html/text
  411. const domSpec = this._normalizeDomSpec(opt);
  412. if (domSpec) {
  413. this._mountDomContentInto(pane, domSpec, { collectFlow: true });
  414. } else if (opt.html) {
  415. pane.innerHTML = opt.html;
  416. } else if (opt.text) {
  417. pane.textContent = opt.text;
  418. }
  419. this.dom.stepStack.appendChild(pane);
  420. this.dom.stepPanes.push(pane);
  421. });
  422. this.dom.content.appendChild(this.dom.stepStack);
  423. popup.appendChild(this.dom.content);
  424. // Actions
  425. this.dom.actions = el('div', `${PREFIX}actions`);
  426. popup.appendChild(this.dom.actions);
  427. this._updateFlowActions();
  428. // Event Listeners
  429. if (this.params.closeOnClickOutside) {
  430. const onClick = (e) => {
  431. if (e.target === this.dom.overlay) {
  432. this._close(null, 'backdrop');
  433. }
  434. };
  435. try {
  436. if (this.dom.overlay._layerOnClick) {
  437. this.dom.overlay.removeEventListener('click', this.dom.overlay._layerOnClick);
  438. }
  439. } catch {}
  440. this.dom.overlay._layerOnClick = onClick;
  441. this.dom.overlay.addEventListener('click', onClick);
  442. } else {
  443. try {
  444. if (this.dom.overlay._layerOnClick) {
  445. this.dom.overlay.removeEventListener('click', this.dom.overlay._layerOnClick);
  446. this.dom.overlay._layerOnClick = null;
  447. }
  448. } catch {}
  449. }
  450. // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
  451. // then show on a clean frame so opacity transitions are smooth.
  452. const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
  453. ready.then(() => {
  454. try { this.dom.overlay.style.visibility = ''; } catch {}
  455. if (!this.dom.overlay.parentNode) {
  456. document.body.appendChild(this.dom.overlay);
  457. }
  458. requestAnimationFrame(() => {
  459. requestAnimationFrame(() => {
  460. this.dom.overlay.classList.add('show');
  461. this._didOpen();
  462. });
  463. });
  464. });
  465. }
  466. _updateFlowActions() {
  467. const actions = this.dom && this.dom.actions;
  468. if (!actions) return;
  469. while (actions.firstChild) actions.removeChild(actions.firstChild);
  470. this.dom.cancelBtn = null;
  471. if (this.params.showCancelButton) {
  472. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  473. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  474. this.dom.cancelBtn.onclick = () => this._handleCancel();
  475. actions.appendChild(this.dom.cancelBtn);
  476. }
  477. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  478. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  479. this.dom.confirmBtn.onclick = () => this._handleConfirm();
  480. actions.appendChild(this.dom.confirmBtn);
  481. }
  482. _createIcon(type) {
  483. const icon = el('div', `${PREFIX}icon ${type}`);
  484. const applyIconSize = (mode) => {
  485. if (!(this.params && this.params.iconSize)) return;
  486. try {
  487. const s = String(this.params.iconSize).trim();
  488. if (!s) return;
  489. // For SweetAlert2-style success icon, scale via font-size so all `em`-based parts remain proportional.
  490. if (mode === 'font') {
  491. const m = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem)$/i);
  492. if (m) {
  493. const n = parseFloat(m[1]);
  494. const unit = m[2];
  495. if (Number.isFinite(n) && n > 0) {
  496. icon.style.fontSize = (n / 5) + unit; // icon is 5em wide/tall
  497. return;
  498. }
  499. }
  500. }
  501. // Fallback: directly size the box (works great for SVG icons)
  502. icon.style.width = s;
  503. icon.style.height = s;
  504. } catch {}
  505. };
  506. const appendRingParts = () => {
  507. // Use the same "success-like" ring parts for every built-in icon
  508. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  509. icon.appendChild(el('div', `${PREFIX}success-ring`));
  510. icon.appendChild(el('div', `${PREFIX}success-fix`));
  511. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  512. };
  513. const createInnerMarkSvg = () => {
  514. const svg = svgEl('svg');
  515. svg.setAttribute('viewBox', '0 0 80 80');
  516. svg.setAttribute('aria-hidden', 'true');
  517. svg.setAttribute('focusable', 'false');
  518. return svg;
  519. };
  520. const addMarkPath = (svg, d, extraClass) => {
  521. const p = svgEl('path');
  522. p.setAttribute('d', d);
  523. p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
  524. p.setAttribute('stroke', 'currentColor');
  525. svg.appendChild(p);
  526. return p;
  527. };
  528. const addDot = (svg, cx, cy) => {
  529. const dot = svgEl('circle');
  530. dot.setAttribute('class', `${PREFIX}svg-dot`);
  531. dot.setAttribute('cx', String(cx));
  532. dot.setAttribute('cy', String(cy));
  533. dot.setAttribute('r', '3.2');
  534. dot.setAttribute('fill', 'currentColor');
  535. svg.appendChild(dot);
  536. return dot;
  537. };
  538. const appendBuiltInInnerMark = (svg) => {
  539. if (type === 'error') {
  540. addMarkPath(svg, 'M28 28 L52 52', `${PREFIX}svg-error-left`);
  541. addMarkPath(svg, 'M52 28 L28 52', `${PREFIX}svg-error-right`);
  542. } else if (type === 'warning') {
  543. addMarkPath(svg, 'M40 20 L40 46', `${PREFIX}svg-warning-line`);
  544. addDot(svg, 40, 58);
  545. } else if (type === 'info') {
  546. addMarkPath(svg, 'M40 34 L40 56', `${PREFIX}svg-info-line`);
  547. addDot(svg, 40, 25);
  548. } else if (type === 'question') {
  549. addMarkPath(svg, 'M30 30 C30 23 35 19 42 19 C49 19 54 23 54 30 C54 36 50 39 46 41 C43 42 42 44 42 48 L42 52', `${PREFIX}svg-question`);
  550. addDot(svg, 42, 61);
  551. }
  552. };
  553. if (type === 'success') {
  554. // SweetAlert2-compatible DOM structure (circle + tick) for exact style parity
  555. // <div class="...success-circular-line-left"></div>
  556. // <div class="...success-mark"><span class="...success-line-tip"></span><span class="...success-line-long"></span></div>
  557. // <div class="...success-ring"></div>
  558. // <div class="...success-fix"></div>
  559. // <div class="...success-circular-line-right"></div>
  560. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  561. const mark = el('div', `${PREFIX}success-mark`);
  562. mark.appendChild(el('span', `${PREFIX}success-line-tip`));
  563. mark.appendChild(el('span', `${PREFIX}success-line-long`));
  564. icon.appendChild(mark);
  565. icon.appendChild(el('div', `${PREFIX}success-ring`));
  566. icon.appendChild(el('div', `${PREFIX}success-fix`));
  567. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  568. applyIconSize('font');
  569. return icon;
  570. }
  571. if (type === 'error' || type === 'warning' || type === 'info' || type === 'question') {
  572. // Use the same "success-like" ring parts for every icon
  573. appendRingParts();
  574. applyIconSize('font');
  575. // SVG only draws the inner symbol (no SVG ring)
  576. const svg = createInnerMarkSvg();
  577. appendBuiltInInnerMark(svg);
  578. icon.appendChild(svg);
  579. return icon;
  580. }
  581. // Default to SVG icons for other/custom types
  582. applyIconSize('box');
  583. const svg = createInnerMarkSvg();
  584. const ring = svgEl('circle');
  585. ring.setAttribute('class', `${PREFIX}svg-ring ${PREFIX}svg-draw`);
  586. ring.setAttribute('cx', '40');
  587. ring.setAttribute('cy', '40');
  588. ring.setAttribute('r', '34');
  589. ring.setAttribute('stroke', 'currentColor');
  590. svg.appendChild(ring);
  591. // For custom types, we draw ring only by default (no inner mark).
  592. icon.appendChild(svg);
  593. return icon;
  594. }
  595. _adjustRingBackgroundColor() {
  596. try {
  597. const icon = this.dom && this.dom.icon;
  598. const popup = this.dom && this.dom.popup;
  599. if (!icon || !popup) return;
  600. const bg = getComputedStyle(popup).backgroundColor;
  601. const parts = icon.querySelectorAll(RING_BG_PARTS_SELECTOR);
  602. parts.forEach((el) => {
  603. try { el.style.backgroundColor = bg; } catch {}
  604. });
  605. } catch {}
  606. }
  607. _didOpen() {
  608. // Keyboard close (ESC)
  609. if (this.params.closeOnEsc) {
  610. this._onKeydown = (e) => {
  611. if (!e) return;
  612. if (e.key === 'Escape') this._close(null, 'esc');
  613. };
  614. document.addEventListener('keydown', this._onKeydown);
  615. }
  616. // Keep the "success-like" ring perfectly blended with popup bg
  617. this._adjustRingBackgroundColor();
  618. // Popup animation (optional)
  619. if (this.params.popupAnimation) {
  620. if (this.dom.popup) {
  621. // Use WAAPI directly to guarantee the slide+fade effect is visible,
  622. // independent from xjs.animate implementation details.
  623. try {
  624. const popup = this.dom.popup;
  625. try { popup.classList.add(`${PREFIX}popup-anim-slide`); } catch {}
  626. const rect = popup.getBoundingClientRect();
  627. // Default entrance: from above center by "more than half" of popup height.
  628. // Clamp to viewport so very tall popups don't start far off-screen.
  629. const vh = (typeof window !== 'undefined' && window && window.innerHeight) ? window.innerHeight : rect.height;
  630. const baseH = Math.min(rect.height, vh);
  631. const y0 = -Math.round(Math.max(18, baseH * 0.75));
  632. // Disable CSS transform transition so it won't fight with WAAPI.
  633. popup.style.transition = 'none';
  634. popup.style.willChange = 'transform, opacity';
  635. // If WAAPI is available, prefer it (most consistent).
  636. if (popup.animate) {
  637. const anim = popup.animate(
  638. [
  639. { transform: `translateY(${y0}px) scale(0.92)`, opacity: 0 },
  640. { transform: 'translateY(0px) scale(1)', opacity: 1 }
  641. ],
  642. {
  643. duration: 520,
  644. easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
  645. fill: 'forwards'
  646. }
  647. );
  648. anim.finished
  649. .catch(() => {})
  650. .finally(() => {
  651. try { popup.style.willChange = ''; } catch {}
  652. });
  653. } else {
  654. // Fallback: try xjs.animate if WAAPI isn't available.
  655. const X = Layer._getXjs();
  656. if (X) {
  657. popup.style.opacity = '0';
  658. popup.style.transform = `translateY(${y0}px) scale(0.92)`;
  659. X(popup).animate({
  660. y: [y0, 0],
  661. scale: [0.92, 1],
  662. opacity: [0, 1],
  663. duration: 520,
  664. easing: 'ease-out'
  665. });
  666. }
  667. setTimeout(() => {
  668. try { popup.style.willChange = ''; } catch {}
  669. }, 560);
  670. }
  671. } catch {}
  672. }
  673. }
  674. // Replace mode: if popupAnimation is off, do a soft fade+scale to avoid a hard cut.
  675. if (!this.params.popupAnimation && this._isReplace && this.dom.popup) {
  676. try {
  677. const popup = this.dom.popup;
  678. popup.style.transition = 'none';
  679. popup.style.opacity = '0';
  680. popup.style.transform = 'scale(0.98)';
  681. requestAnimationFrame(() => {
  682. popup.style.transition = 'opacity 0.22s ease, transform 0.22s ease';
  683. popup.style.opacity = '1';
  684. popup.style.transform = 'scale(1)';
  685. setTimeout(() => {
  686. try { popup.style.transition = ''; } catch {}
  687. try { popup.style.opacity = ''; } catch {}
  688. try { popup.style.transform = ''; } catch {}
  689. }, 260);
  690. });
  691. } catch {}
  692. }
  693. // Icon SVG draw animation
  694. if (this.params.iconAnimation) {
  695. this._animateIcon();
  696. }
  697. // User hook (SweetAlert-ish naming)
  698. try {
  699. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  700. } catch {}
  701. }
  702. _animateIcon() {
  703. const icon = this.dom.icon;
  704. if (!icon) return;
  705. const type = (this.params && this.params.icon) || '';
  706. // Ring animation (same as success) for all built-in icons
  707. if (RING_TYPES.has(type)) this._adjustRingBackgroundColor();
  708. try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
  709. requestAnimationFrame(() => {
  710. try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
  711. });
  712. // Success tick is CSS-driven; others still draw their SVG mark after the ring starts.
  713. if (type === 'success') return;
  714. const X = Layer._getXjs();
  715. if (!X) return;
  716. const svg = icon.querySelector('svg');
  717. if (!svg) return;
  718. const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
  719. const dot = svg.querySelector(`.${PREFIX}svg-dot`);
  720. // If this is a built-in icon, keep the SweetAlert-like order:
  721. // ring sweep first (~0.51s), then draw the inner mark.
  722. const baseDelay = RING_TYPES.has(type) ? 520 : 0;
  723. if (type === 'error') {
  724. // Draw order: left-top -> right-bottom, then right-top -> left-bottom
  725. // NOTE: A tiny delay (like 70ms) looks simultaneous; make it strictly sequential.
  726. const a = svg.querySelector(`.${PREFIX}svg-error-left`) || marks[0]; // M28 28 L52 52
  727. const b = svg.querySelector(`.${PREFIX}svg-error-right`) || marks[1]; // M52 28 L28 52
  728. const dur = 320;
  729. const gap = 60;
  730. try { if (a) X(a).draw({ duration: dur, easing: 'ease-out', delay: baseDelay }); } catch {}
  731. try { if (b) X(b).draw({ duration: dur, easing: 'ease-out', delay: baseDelay + dur + gap }); } catch {}
  732. } else {
  733. // warning / info / question (single stroke) or custom SVG symbols
  734. marks.forEach((m, i) => {
  735. try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
  736. });
  737. }
  738. if (dot) {
  739. try {
  740. dot.style.opacity = '0';
  741. // Keep dot pop after the ring begins
  742. const d = baseDelay + 140;
  743. if (type === 'info') {
  744. X(dot).animate({ opacity: [0, 1], y: [-8, 0], scale: [0.2, 1], duration: 420, delay: d, easing: { stiffness: 300, damping: 14 } });
  745. } else {
  746. X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
  747. }
  748. } catch {}
  749. }
  750. }
  751. _forceDestroy(reason = 'replace') {
  752. // Restore mounted DOM (if any) and cleanup listeners; also resolve the promise so it doesn't hang.
  753. try {
  754. if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
  755. else this._unmountDomContent();
  756. } catch {}
  757. if (this._onKeydown) {
  758. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  759. this._onKeydown = null;
  760. }
  761. try {
  762. if (this.resolve) {
  763. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason });
  764. }
  765. } catch {}
  766. }
  767. _close(isConfirmed, reason) {
  768. if (this._isClosing) return;
  769. this._isClosing = true;
  770. const shouldDelayUnmount = !!(
  771. (this._mounted && this._mounted.kind === 'move') ||
  772. (this._flowSteps && this._flowSteps.length && this._flowMountedList && this._flowMountedList.length)
  773. ) || !this.params.popupAnimation;
  774. const doUnmount = () => {
  775. try {
  776. if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
  777. else this._unmountDomContent();
  778. } catch {}
  779. };
  780. if (!shouldDelayUnmount) {
  781. // Restore mounted DOM (moved into popup) before removing overlay
  782. doUnmount();
  783. }
  784. let customClose = false;
  785. // Soft close for non-popupAnimation cases (avoid abrupt cut)
  786. if (!this.params.popupAnimation) {
  787. try {
  788. const popup = this.dom.popup;
  789. if (popup) {
  790. popup.style.transition = 'opacity 0.22s ease';
  791. popup.style.opacity = '0';
  792. popup.style.transform = '';
  793. }
  794. if (this.dom.overlay) {
  795. const overlay = this.dom.overlay;
  796. // Use inline opacity to avoid class-based jumps
  797. overlay.style.transition = 'opacity 0.22s ease';
  798. overlay.style.opacity = '1';
  799. requestAnimationFrame(() => {
  800. try {
  801. if (overlay._layerInstance !== this) return;
  802. overlay.style.opacity = '0';
  803. } catch {}
  804. });
  805. }
  806. customClose = true;
  807. } catch {}
  808. }
  809. if (!customClose) {
  810. this.dom.overlay.classList.remove('show');
  811. }
  812. try {
  813. if (this.dom.overlay) {
  814. const overlay = this.dom.overlay;
  815. const delay = customClose ? 240 : 300;
  816. overlay._layerCloseTimer = setTimeout(() => {
  817. if (shouldDelayUnmount) {
  818. doUnmount();
  819. }
  820. if (overlay._layerInstance !== this) return;
  821. if (overlay.parentNode) {
  822. overlay.parentNode.removeChild(overlay);
  823. }
  824. }, delay);
  825. }
  826. } catch {}
  827. if (this._onKeydown) {
  828. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  829. this._onKeydown = null;
  830. }
  831. try {
  832. if (typeof this.params.willClose === 'function') this.params.willClose(this.dom.popup);
  833. } catch {}
  834. try {
  835. if (typeof this.params.didClose === 'function') this.params.didClose();
  836. } catch {}
  837. const value = (this._flowSteps && this._flowSteps.length) ? (this._flowValues || []) : undefined;
  838. if (isConfirmed === true) {
  839. this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false, value });
  840. } else if (isConfirmed === false) {
  841. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'cancel', value });
  842. } else {
  843. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'backdrop', value });
  844. }
  845. }
  846. _normalizeDomSpec(params) {
  847. // Preferred: params.dom (selector/Element/template)
  848. // Backward: params.content (selector/Element) OR advanced object { dom, mode, clone }
  849. const p = params || {};
  850. let dom = p.dom;
  851. let mode = p.domMode || 'move';
  852. if (dom == null && p.content != null) {
  853. if (typeof p.content === 'object' && !(p.content instanceof Element)) {
  854. if (p.content && (p.content.dom != null || p.content.selector != null)) {
  855. dom = (p.content.dom != null) ? p.content.dom : p.content.selector;
  856. if (p.content.mode) mode = p.content.mode;
  857. if (p.content.clone === true) mode = 'clone';
  858. }
  859. } else {
  860. dom = p.content;
  861. }
  862. }
  863. if (dom == null) return null;
  864. return { dom, mode };
  865. }
  866. _mountDomContentInto(target, domSpec, opts = null) {
  867. // Like _mountDomContent, but mounts into the provided container.
  868. const collectFlow = !!(opts && opts.collectFlow);
  869. const originalContent = this.dom.content;
  870. // Temporarily redirect this.dom.content for reuse of internal logic.
  871. try { this.dom.content = target; } catch {}
  872. try {
  873. const before = this._mounted;
  874. this._mountDomContent(domSpec);
  875. const rec = this._mounted;
  876. // If we mounted in move-mode, _mounted holds record; detach it from single-mode tracking.
  877. if (collectFlow && rec && rec.kind === 'move') {
  878. this._flowMountedList.push(rec);
  879. this._mounted = before; // restore previous single record (usually null)
  880. }
  881. } finally {
  882. try { this.dom.content = originalContent; } catch {}
  883. }
  884. }
  885. _unmountFlowMounted() {
  886. // Restore all moved DOM nodes for flow steps
  887. const list = Array.isArray(this._flowMountedList) ? this._flowMountedList : [];
  888. this._flowMountedList = [];
  889. list.forEach((m) => {
  890. try {
  891. if (!m || m.kind !== 'move') return;
  892. // Reuse single unmount logic by swapping _mounted
  893. const prev = this._mounted;
  894. this._mounted = m;
  895. this._unmountDomContent();
  896. this._mounted = prev;
  897. } catch {}
  898. });
  899. }
  900. _mountDomContent(domSpec) {
  901. try {
  902. if (!this.dom.content) return;
  903. this._unmountDomContent(); // ensure only one mount at a time
  904. const forceVisible = (el) => {
  905. if (!el) return { prevHidden: false, prevInlineDisplay: '' };
  906. const prevHidden = !!el.hidden;
  907. const prevInlineDisplay = (el.style && typeof el.style.display === 'string') ? el.style.display : '';
  908. try { el.hidden = false; } catch {}
  909. try {
  910. const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(el) : null;
  911. const display = cs ? String(cs.display || '') : '';
  912. // If element is hidden via CSS (e.g. .hidden-dom{display:none}),
  913. // add an inline override so it becomes visible inside the popup.
  914. if (display === 'none') el.style.display = 'block';
  915. else if (el.style && el.style.display === 'none') el.style.display = 'block';
  916. } catch {}
  917. return { prevHidden, prevInlineDisplay };
  918. };
  919. let node = domSpec.dom;
  920. if (typeof node === 'string') node = document.querySelector(node);
  921. if (!node) return;
  922. // <template> support: always clone template content
  923. if (typeof HTMLTemplateElement !== 'undefined' && node instanceof HTMLTemplateElement) {
  924. const frag = node.content.cloneNode(true);
  925. this.dom.content.appendChild(frag);
  926. this._mounted = { kind: 'template' };
  927. return;
  928. }
  929. if (!(node instanceof Element)) return;
  930. if (domSpec.mode === 'clone') {
  931. const clone = node.cloneNode(true);
  932. // If original is hidden (via attr or CSS), the clone may inherit; force show it in popup.
  933. try { clone.hidden = false; } catch {}
  934. try {
  935. const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(node) : null;
  936. const display = cs ? String(cs.display || '') : '';
  937. if (display === 'none') clone.style.display = 'block';
  938. } catch {}
  939. this.dom.content.appendChild(clone);
  940. this._mounted = { kind: 'clone' };
  941. return;
  942. }
  943. // Default: move into popup but restore on close
  944. const placeholder = document.createComment('layer-dom-placeholder');
  945. const parent = node.parentNode;
  946. const nextSibling = node.nextSibling;
  947. if (!parent) {
  948. // Detached node: moving would lose it when overlay is removed; clone instead.
  949. const clone = node.cloneNode(true);
  950. try { clone.hidden = false; } catch {}
  951. try { if (clone.style && clone.style.display === 'none') clone.style.display = ''; } catch {}
  952. this.dom.content.appendChild(clone);
  953. this._mounted = { kind: 'clone' };
  954. return;
  955. }
  956. try { parent.insertBefore(placeholder, nextSibling); } catch {}
  957. const { prevHidden, prevInlineDisplay } = forceVisible(node);
  958. this.dom.content.appendChild(node);
  959. this._mounted = { kind: 'move', originalEl: node, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay };
  960. } catch {
  961. // ignore
  962. }
  963. }
  964. _unmountDomContent() {
  965. const m = this._mounted;
  966. if (!m) return;
  967. this._mounted = null;
  968. if (m.kind !== 'move') return;
  969. const node = m.originalEl;
  970. if (!node) return;
  971. // Restore hidden/display
  972. try { node.hidden = !!m.prevHidden; } catch {}
  973. try {
  974. if (node.style && typeof m.prevInlineDisplay === 'string') node.style.display = m.prevInlineDisplay;
  975. } catch {}
  976. // Move back to original position
  977. try {
  978. const ph = m.placeholder;
  979. if (ph && ph.parentNode) {
  980. ph.parentNode.insertBefore(node, ph);
  981. ph.parentNode.removeChild(ph);
  982. return;
  983. }
  984. } catch {}
  985. // Fallback: append to original parent
  986. try {
  987. if (m.parent) m.parent.appendChild(node);
  988. } catch {}
  989. }
  990. _setButtonsDisabled(disabled) {
  991. try {
  992. if (this.dom && this.dom.confirmBtn) this.dom.confirmBtn.disabled = !!disabled;
  993. if (this.dom && this.dom.cancelBtn) this.dom.cancelBtn.disabled = !!disabled;
  994. } catch {}
  995. }
  996. async _handleConfirm() {
  997. // Flow next / finalize
  998. if (this._flowSteps && this._flowSteps.length) {
  999. return this._flowNext();
  1000. }
  1001. // Single popup: support async preConfirm
  1002. const pre = this.params && this.params.preConfirm;
  1003. if (typeof pre === 'function') {
  1004. try {
  1005. this._setButtonsDisabled(true);
  1006. const r = pre(this.dom && this.dom.popup);
  1007. const v = (r && typeof r.then === 'function') ? await r : r;
  1008. if (v === false) {
  1009. this._setButtonsDisabled(false);
  1010. return;
  1011. }
  1012. // store value in non-flow mode as single value
  1013. this._flowValues = [v];
  1014. } catch (e) {
  1015. console.error(e);
  1016. this._setButtonsDisabled(false);
  1017. return;
  1018. }
  1019. }
  1020. this._close(true, 'confirm');
  1021. }
  1022. _handleCancel() {
  1023. if (this._flowSteps && this._flowSteps.length) {
  1024. // default: if not first step, cancel acts as "back"
  1025. if (this._flowIndex > 0) {
  1026. this._flowPrev();
  1027. return;
  1028. }
  1029. }
  1030. this._close(false, 'cancel');
  1031. }
  1032. _getFlowStepOptions(index) {
  1033. const step = (this._flowSteps && this._flowSteps[index]) ? this._flowSteps[index] : {};
  1034. const base = this._flowBase || {};
  1035. const merged = { ...base, ...step };
  1036. // Default button texts for flow
  1037. const isLast = index >= (this._flowSteps.length - 1);
  1038. if (!('confirmButtonText' in step)) merged.confirmButtonText = isLast ? (base.confirmButtonText || 'OK') : 'Next';
  1039. // Show cancel as Back after first step (unless step explicitly overrides)
  1040. if (index > 0) {
  1041. if (!('showCancelButton' in step)) merged.showCancelButton = true;
  1042. if (!('cancelButtonText' in step)) merged.cancelButtonText = 'Back';
  1043. } else {
  1044. // First step default keeps base settings
  1045. if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
  1046. }
  1047. // Icon/animation policy for flow:
  1048. // - During steps: no icon/animations by default (avoids distraction + layout jitter)
  1049. // - Allow icon only if step explicitly uses `icon:'question'`, or step is marked as summary.
  1050. const isSummary = !!(step && (step.summary === true || step.isSummary === true));
  1051. const explicitIcon = ('icon' in step) ? step.icon : undefined;
  1052. const baseIcon = ('icon' in base) ? base.icon : undefined;
  1053. const chosenIcon = (explicitIcon !== undefined) ? explicitIcon : baseIcon;
  1054. if (!isSummary && !isLast) {
  1055. merged.icon = (chosenIcon === 'question') ? 'question' : null;
  1056. merged.iconAnimation = false;
  1057. } else if (!isSummary && isLast) {
  1058. // last step: still suppress unless summary or question
  1059. merged.icon = (chosenIcon === 'question') ? 'question' : null;
  1060. merged.iconAnimation = false;
  1061. } else {
  1062. // summary step: allow icon; animation follows explicit config (default true only if provided elsewhere)
  1063. if (!('icon' in step) && chosenIcon === undefined) merged.icon = null;
  1064. }
  1065. return merged;
  1066. }
  1067. async _flowNext() {
  1068. const idx = this._flowIndex;
  1069. const total = this._flowSteps.length;
  1070. const isLast = idx >= (total - 1);
  1071. // preConfirm hook for current step
  1072. const pre = this.params && this.params.preConfirm;
  1073. if (typeof pre === 'function') {
  1074. try {
  1075. this._setButtonsDisabled(true);
  1076. const r = pre(this.dom && this.dom.popup, idx);
  1077. const v = (r && typeof r.then === 'function') ? await r : r;
  1078. if (v === false) {
  1079. this._setButtonsDisabled(false);
  1080. return;
  1081. }
  1082. this._flowValues[idx] = v;
  1083. } catch (e) {
  1084. console.error(e);
  1085. this._setButtonsDisabled(false);
  1086. return;
  1087. }
  1088. }
  1089. if (isLast) {
  1090. this._close(true, 'confirm');
  1091. return;
  1092. }
  1093. this._setButtonsDisabled(false);
  1094. await this._flowGo(idx + 1, 'next');
  1095. }
  1096. async _flowPrev() {
  1097. const idx = this._flowIndex;
  1098. if (idx <= 0) return;
  1099. await this._flowGo(idx - 1, 'prev');
  1100. }
  1101. async _flowGo(index, direction) {
  1102. const next = (this._flowResolved && this._flowResolved[index]) ? this._flowResolved[index] : this._getFlowStepOptions(index);
  1103. await this._transitionToFlow(index, next, direction);
  1104. }
  1105. async _transitionToFlow(nextIndex, nextOptions, direction) {
  1106. const popup = this.dom && this.dom.popup;
  1107. const content = this.dom && this.dom.content;
  1108. const panes = this.dom && this.dom.stepPanes;
  1109. if (!popup || !content || !panes || !panes.length) {
  1110. this._flowIndex = nextIndex;
  1111. this.params = nextOptions;
  1112. this._render({ flow: true });
  1113. return;
  1114. }
  1115. const fromIndex = this._flowIndex;
  1116. const fromPane = panes[fromIndex];
  1117. const toPane = panes[nextIndex];
  1118. if (!fromPane || !toPane) {
  1119. this._flowIndex = nextIndex;
  1120. this.params = nextOptions;
  1121. this._render({ flow: true });
  1122. return;
  1123. }
  1124. // Measure current content height
  1125. const oldH = content.getBoundingClientRect().height;
  1126. // Prepare target pane for measurement without affecting layout
  1127. const prevDisplay = toPane.style.display;
  1128. const prevPos = toPane.style.position;
  1129. const prevVis = toPane.style.visibility;
  1130. const prevPointer = toPane.style.pointerEvents;
  1131. toPane.style.display = '';
  1132. toPane.style.position = 'absolute';
  1133. toPane.style.visibility = 'hidden';
  1134. toPane.style.pointerEvents = 'none';
  1135. toPane.style.left = '0';
  1136. toPane.style.right = '0';
  1137. const newH = toPane.getBoundingClientRect().height;
  1138. // Restore pane styles (keep hidden until animation starts)
  1139. toPane.style.position = prevPos;
  1140. toPane.style.visibility = prevVis;
  1141. toPane.style.pointerEvents = prevPointer;
  1142. toPane.style.display = prevDisplay; // usually 'none'
  1143. // Apply new options (title/buttons/icon policy) before showing the pane
  1144. this._flowIndex = nextIndex;
  1145. this.params = nextOptions;
  1146. // Update icon/title/buttons without recreating DOM
  1147. try {
  1148. if (this.dom.title) this.dom.title.textContent = this.params.title || '';
  1149. } catch {}
  1150. // Icon updates (only if needed)
  1151. try {
  1152. const wantsIcon = !!this.params.icon;
  1153. if (!wantsIcon && this.dom.icon) {
  1154. this.dom.icon.remove();
  1155. this.dom.icon = null;
  1156. } else if (wantsIcon) {
  1157. const curType = this.dom.icon ? (Array.from(this.dom.icon.classList).find(c => c !== `${PREFIX}icon`) || '') : '';
  1158. if (!this.dom.icon || !this.dom.icon.classList.contains(String(this.params.icon))) {
  1159. if (this.dom.icon) this.dom.icon.remove();
  1160. this.dom.icon = this._createIcon(this.params.icon);
  1161. // icon should be on top (before title)
  1162. popup.insertBefore(this.dom.icon, this.dom.title || popup.firstChild);
  1163. }
  1164. }
  1165. } catch {}
  1166. this._updateFlowActions();
  1167. // Switch panes with directional slide (next: left, prev: right)
  1168. const isNext = direction !== 'prev';
  1169. const enterFrom = isNext ? 100 : -100;
  1170. const exitTo = isNext ? -100 : 100;
  1171. // Prepare panes for animation
  1172. toPane.style.display = '';
  1173. toPane.style.position = 'absolute';
  1174. toPane.style.left = '0';
  1175. toPane.style.right = '0';
  1176. toPane.style.top = '0';
  1177. toPane.style.transform = `translateX(${enterFrom}%)`;
  1178. toPane.style.opacity = '0';
  1179. toPane.style.pointerEvents = 'none';
  1180. fromPane.style.position = 'absolute';
  1181. fromPane.style.left = '0';
  1182. fromPane.style.right = '0';
  1183. fromPane.style.top = '0';
  1184. fromPane.style.transform = 'translateX(0%)';
  1185. fromPane.style.opacity = '1';
  1186. fromPane.style.pointerEvents = 'none';
  1187. // Lock content height during transition
  1188. content.style.height = oldH + 'px';
  1189. content.style.overflow = 'hidden';
  1190. const slideDuration = 320;
  1191. let heightAnim = null;
  1192. let fromAnim = null;
  1193. let toAnim = null;
  1194. try {
  1195. heightAnim = content.animate(
  1196. [{ height: oldH + 'px' }, { height: newH + 'px' }],
  1197. { duration: 220, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
  1198. );
  1199. } catch {}
  1200. try {
  1201. if (fromPane.animate && toPane.animate) {
  1202. fromAnim = fromPane.animate(
  1203. [
  1204. { transform: 'translateX(0%)', opacity: 1 },
  1205. { transform: `translateX(${exitTo}%)`, opacity: 0.1 }
  1206. ],
  1207. { duration: slideDuration, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
  1208. );
  1209. toAnim = toPane.animate(
  1210. [
  1211. { transform: `translateX(${enterFrom}%)`, opacity: 0.2 },
  1212. { transform: 'translateX(0%)', opacity: 1 }
  1213. ],
  1214. { duration: slideDuration, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
  1215. );
  1216. }
  1217. } catch {}
  1218. try {
  1219. await Promise.all([
  1220. heightAnim && heightAnim.finished ? heightAnim.finished.catch(() => {}) : Promise.resolve(),
  1221. fromAnim && fromAnim.finished ? fromAnim.finished.catch(() => {}) : Promise.resolve(),
  1222. toAnim && toAnim.finished ? toAnim.finished.catch(() => {}) : Promise.resolve()
  1223. ]);
  1224. } catch {}
  1225. // Cleanup styles and hide old pane
  1226. fromPane.style.display = 'none';
  1227. fromPane.style.position = '';
  1228. fromPane.style.left = '';
  1229. fromPane.style.right = '';
  1230. fromPane.style.top = '';
  1231. fromPane.style.transform = '';
  1232. fromPane.style.opacity = '';
  1233. fromPane.style.pointerEvents = '';
  1234. toPane.style.position = 'relative';
  1235. toPane.style.left = '';
  1236. toPane.style.right = '';
  1237. toPane.style.top = '';
  1238. toPane.style.transform = '';
  1239. toPane.style.opacity = '';
  1240. toPane.style.pointerEvents = '';
  1241. content.style.height = '';
  1242. content.style.overflow = '';
  1243. // Re-adjust ring background if icon exists (rare in flow)
  1244. try { this._adjustRingBackgroundColor(); } catch {}
  1245. }
  1246. _rerenderInside() {
  1247. // Update popup content without recreating overlay (used in flow transitions)
  1248. const popup = this.dom && this.dom.popup;
  1249. if (!popup) return;
  1250. // Clear popup (but keep reference)
  1251. while (popup.firstChild) popup.removeChild(popup.firstChild);
  1252. // Icon
  1253. this.dom.icon = null;
  1254. if (this.params.icon) {
  1255. this.dom.icon = this._createIcon(this.params.icon);
  1256. popup.appendChild(this.dom.icon);
  1257. }
  1258. // Title
  1259. this.dom.title = null;
  1260. if (this.params.title) {
  1261. this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
  1262. popup.appendChild(this.dom.title);
  1263. }
  1264. // Content
  1265. this.dom.content = null;
  1266. if (this.params.text || this.params.html || this.params.content || this.params.dom) {
  1267. this.dom.content = el('div', `${PREFIX}content`);
  1268. const domSpec = this._normalizeDomSpec(this.params);
  1269. if (domSpec) this._mountDomContent(domSpec);
  1270. else if (this.params.html) this.dom.content.innerHTML = this.params.html;
  1271. else this.dom.content.textContent = this.params.text;
  1272. popup.appendChild(this.dom.content);
  1273. }
  1274. // Actions / buttons
  1275. this.dom.actions = el('div', `${PREFIX}actions`);
  1276. this.dom.cancelBtn = null;
  1277. if (this.params.showCancelButton) {
  1278. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  1279. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  1280. this.dom.cancelBtn.onclick = () => this._handleCancel();
  1281. this.dom.actions.appendChild(this.dom.cancelBtn);
  1282. }
  1283. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  1284. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  1285. this.dom.confirmBtn.onclick = () => this._handleConfirm();
  1286. this.dom.actions.appendChild(this.dom.confirmBtn);
  1287. popup.appendChild(this.dom.actions);
  1288. // Re-run open hooks for each step
  1289. try {
  1290. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  1291. } catch {}
  1292. }
  1293. }
  1294. return Layer;
  1295. })));