layer.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963
  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. const ensureXjsCss = () => {
  55. try {
  56. if (typeof document === 'undefined') return;
  57. if (!document.head) return;
  58. const existingHref = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
  59. .map((l) => (l.getAttribute('href') || '').trim())
  60. .find((h) => /(^|\/)xjs\.css(\?|#|$)/.test(h));
  61. if (existingHref) return;
  62. const id = 'xjs-css';
  63. if (document.getElementById(id)) return;
  64. const scripts = Array.from(document.getElementsByTagName('script'));
  65. const scriptSrc = scripts
  66. .map((s) => s && s.src)
  67. .find((src) => /(^|\/)(xjs|layer)\.js(\?|#|$)/.test(String(src || '')));
  68. let href = 'xjs.css';
  69. if (scriptSrc) {
  70. href = String(scriptSrc)
  71. .replace(/(^|\/)xjs\.js(\?|#|$)/, '$1xjs.css$2')
  72. .replace(/(^|\/)layer\.js(\?|#|$)/, '$1xjs.css$2');
  73. }
  74. const link = document.createElement('link');
  75. link.id = id;
  76. link.rel = 'stylesheet';
  77. link.href = href;
  78. document.head.appendChild(link);
  79. } catch {
  80. // ignore
  81. }
  82. };
  83. class Layer {
  84. constructor() {
  85. ensureXjsCss();
  86. this.params = {};
  87. this.dom = {};
  88. this.promise = null;
  89. this.resolve = null;
  90. this.reject = null;
  91. this._onKeydown = null;
  92. this._mounted = null; // { kind, originalEl, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay }
  93. this._isClosing = false;
  94. // Step/flow support
  95. this._flowSteps = null; // Array<options>
  96. this._flowIndex = 0;
  97. this._flowValues = [];
  98. this._flowBase = null; // base options merged into each step
  99. }
  100. // Constructor helper when called as function: const popup = Layer({...})
  101. static get isProxy() { return true; }
  102. // Static entry point
  103. static fire(options) {
  104. const instance = new Layer();
  105. return instance._fire(options);
  106. }
  107. // Chainable entry point (builder-style)
  108. // Example:
  109. // Layer.$({ title: 'Hi' }).fire().then(...)
  110. // Layer.$().config({ title: 'Hi' }).fire()
  111. static $(options) {
  112. const instance = new Layer();
  113. if (options !== undefined) instance.config(options);
  114. return instance;
  115. }
  116. // Chainable config helper (does not render until `.fire()` is called)
  117. config(options = {}) {
  118. // Support the same shorthand as Layer.fire(title, text, icon)
  119. options = normalizeOptions(options, arguments[1], arguments[2]);
  120. this.params = { ...(this.params || {}), ...options };
  121. return this;
  122. }
  123. // Add a single step (chainable)
  124. // Usage:
  125. // Layer.$().step({ title:'A', dom:'#step1' }).step({ title:'B', dom:'#step2' }).fire()
  126. step(options = {}) {
  127. if (!this._flowSteps) this._flowSteps = [];
  128. this._flowSteps.push(normalizeOptions(options, arguments[1], arguments[2]));
  129. return this;
  130. }
  131. // Add multiple steps at once (chainable)
  132. steps(steps = []) {
  133. if (!Array.isArray(steps)) return this;
  134. if (!this._flowSteps) this._flowSteps = [];
  135. steps.forEach((s) => this.step(s));
  136. return this;
  137. }
  138. // Convenience static helper: Layer.flow([steps], baseOptions?)
  139. static flow(steps = [], baseOptions = {}) {
  140. return Layer.$(baseOptions).steps(steps).fire();
  141. }
  142. // Instance entry point (chainable)
  143. fire(options) {
  144. // Flow mode: if configured via .step()/.steps(), ignore per-call options and use steps
  145. if (this._flowSteps && this._flowSteps.length) {
  146. if (options !== undefined && options !== null) {
  147. // allow providing base options at fire-time
  148. this.params = { ...(this.params || {}), ...normalizeOptions(options, arguments[1], arguments[2]) };
  149. }
  150. return this._fireFlow();
  151. }
  152. const merged = (options === undefined) ? (this.params || {}) : options;
  153. return this._fire(merged);
  154. }
  155. _fire(options = {}) {
  156. options = normalizeOptions(options, arguments[1], arguments[2]);
  157. this.params = {
  158. title: '',
  159. text: '',
  160. icon: null,
  161. iconSize: null, // e.g. '6em' / '72px'
  162. confirmButtonText: 'OK',
  163. cancelButtonText: 'Cancel',
  164. showCancelButton: false,
  165. confirmButtonColor: '#3085d6',
  166. cancelButtonColor: '#aaa',
  167. closeOnClickOutside: true,
  168. closeOnEsc: true,
  169. iconAnimation: true,
  170. popupAnimation: true,
  171. // Content:
  172. // - text: plain text
  173. // - html: innerHTML
  174. // - dom: selector / Element / <template> (preferred)
  175. // - content: backward compat for selector/Element OR advanced { dom, mode, clone }
  176. dom: null,
  177. domMode: 'move', // 'move' (default) | 'clone'
  178. // Hook for confirming (also used in flow steps)
  179. // Return false to prevent close / next.
  180. // Return a value to be attached as `value`.
  181. // May return Promise.
  182. preConfirm: null,
  183. ...options
  184. };
  185. this.promise = new Promise((resolve, reject) => {
  186. this.resolve = resolve;
  187. this.reject = reject;
  188. });
  189. this._render();
  190. return this.promise;
  191. }
  192. _fireFlow() {
  193. this._flowIndex = 0;
  194. this._flowValues = [];
  195. this._flowBase = { ...(this.params || {}) };
  196. this.promise = new Promise((resolve, reject) => {
  197. this.resolve = resolve;
  198. this.reject = reject;
  199. });
  200. const first = this._getFlowStepOptions(0);
  201. this.params = first;
  202. this._render({ flow: true });
  203. return this.promise;
  204. }
  205. static _getXjs() {
  206. // Prefer xjs, fallback to animal (compat)
  207. const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
  208. if (!g) return null;
  209. const x = g.xjs || g.animal;
  210. return (typeof x === 'function') ? x : null;
  211. }
  212. _render(meta = null) {
  213. // Remove existing if any (but first, try to restore any mounted DOM from the previous instance)
  214. const existing = document.querySelector(`.${PREFIX}overlay`);
  215. if (existing) {
  216. try {
  217. const prev = existing._layerInstance;
  218. if (prev && typeof prev._forceDestroy === 'function') prev._forceDestroy('replace');
  219. } catch {}
  220. try { existing.remove(); } catch {}
  221. }
  222. // Create Overlay
  223. this.dom.overlay = el('div', `${PREFIX}overlay`);
  224. this.dom.overlay._layerInstance = this;
  225. // Create Popup
  226. this.dom.popup = el('div', `${PREFIX}popup`);
  227. this.dom.overlay.appendChild(this.dom.popup);
  228. // Icon
  229. if (this.params.icon) {
  230. this.dom.icon = this._createIcon(this.params.icon);
  231. this.dom.popup.appendChild(this.dom.icon);
  232. }
  233. // Title
  234. if (this.params.title) {
  235. this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
  236. this.dom.popup.appendChild(this.dom.title);
  237. }
  238. // Content (Text / HTML / Element)
  239. if (this.params.text || this.params.html || this.params.content || this.params.dom) {
  240. this.dom.content = el('div', `${PREFIX}content`);
  241. // DOM content (preferred: dom, backward: content)
  242. const domSpec = this._normalizeDomSpec(this.params);
  243. if (domSpec) {
  244. this._mountDomContent(domSpec);
  245. } else if (this.params.html) {
  246. this.dom.content.innerHTML = this.params.html;
  247. } else {
  248. this.dom.content.textContent = this.params.text;
  249. }
  250. this.dom.popup.appendChild(this.dom.content);
  251. }
  252. // Actions
  253. this.dom.actions = el('div', `${PREFIX}actions`);
  254. // Cancel Button
  255. if (this.params.showCancelButton) {
  256. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  257. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  258. this.dom.cancelBtn.onclick = () => this._handleCancel();
  259. this.dom.actions.appendChild(this.dom.cancelBtn);
  260. }
  261. // Confirm Button
  262. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  263. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  264. this.dom.confirmBtn.onclick = () => this._handleConfirm();
  265. this.dom.actions.appendChild(this.dom.confirmBtn);
  266. this.dom.popup.appendChild(this.dom.actions);
  267. // Event Listeners
  268. if (this.params.closeOnClickOutside) {
  269. this.dom.overlay.addEventListener('click', (e) => {
  270. if (e.target === this.dom.overlay) {
  271. this._close(null); // Dismiss
  272. }
  273. });
  274. }
  275. document.body.appendChild(this.dom.overlay);
  276. // Animation
  277. requestAnimationFrame(() => {
  278. this.dom.overlay.classList.add('show');
  279. this._didOpen();
  280. });
  281. }
  282. _createIcon(type) {
  283. const icon = el('div', `${PREFIX}icon ${type}`);
  284. const applyIconSize = (mode) => {
  285. if (!(this.params && this.params.iconSize)) return;
  286. try {
  287. const s = String(this.params.iconSize).trim();
  288. if (!s) return;
  289. // For SweetAlert2-style success icon, scale via font-size so all `em`-based parts remain proportional.
  290. if (mode === 'font') {
  291. const m = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem)$/i);
  292. if (m) {
  293. const n = parseFloat(m[1]);
  294. const unit = m[2];
  295. if (Number.isFinite(n) && n > 0) {
  296. icon.style.fontSize = (n / 5) + unit; // icon is 5em wide/tall
  297. return;
  298. }
  299. }
  300. }
  301. // Fallback: directly size the box (works great for SVG icons)
  302. icon.style.width = s;
  303. icon.style.height = s;
  304. } catch {}
  305. };
  306. const appendRingParts = () => {
  307. // Use the same "success-like" ring parts for every built-in icon
  308. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  309. icon.appendChild(el('div', `${PREFIX}success-ring`));
  310. icon.appendChild(el('div', `${PREFIX}success-fix`));
  311. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  312. };
  313. const createInnerMarkSvg = () => {
  314. const svg = svgEl('svg');
  315. svg.setAttribute('viewBox', '0 0 80 80');
  316. svg.setAttribute('aria-hidden', 'true');
  317. svg.setAttribute('focusable', 'false');
  318. return svg;
  319. };
  320. const addMarkPath = (svg, d, extraClass) => {
  321. const p = svgEl('path');
  322. p.setAttribute('d', d);
  323. p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
  324. p.setAttribute('stroke', 'currentColor');
  325. svg.appendChild(p);
  326. return p;
  327. };
  328. const addDot = (svg, cx, cy) => {
  329. const dot = svgEl('circle');
  330. dot.setAttribute('class', `${PREFIX}svg-dot`);
  331. dot.setAttribute('cx', String(cx));
  332. dot.setAttribute('cy', String(cy));
  333. dot.setAttribute('r', '3.2');
  334. dot.setAttribute('fill', 'currentColor');
  335. svg.appendChild(dot);
  336. return dot;
  337. };
  338. const appendBuiltInInnerMark = (svg) => {
  339. if (type === 'error') {
  340. addMarkPath(svg, 'M28 28 L52 52', `${PREFIX}svg-error-left`);
  341. addMarkPath(svg, 'M52 28 L28 52', `${PREFIX}svg-error-right`);
  342. } else if (type === 'warning') {
  343. addMarkPath(svg, 'M40 20 L40 46', `${PREFIX}svg-warning-line`);
  344. addDot(svg, 40, 58);
  345. } else if (type === 'info') {
  346. addMarkPath(svg, 'M40 34 L40 56', `${PREFIX}svg-info-line`);
  347. addDot(svg, 40, 25);
  348. } else if (type === 'question') {
  349. 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`);
  350. addDot(svg, 42, 61);
  351. }
  352. };
  353. if (type === 'success') {
  354. // SweetAlert2-compatible DOM structure (circle + tick) for exact style parity
  355. // <div class="...success-circular-line-left"></div>
  356. // <div class="...success-mark"><span class="...success-line-tip"></span><span class="...success-line-long"></span></div>
  357. // <div class="...success-ring"></div>
  358. // <div class="...success-fix"></div>
  359. // <div class="...success-circular-line-right"></div>
  360. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  361. const mark = el('div', `${PREFIX}success-mark`);
  362. mark.appendChild(el('span', `${PREFIX}success-line-tip`));
  363. mark.appendChild(el('span', `${PREFIX}success-line-long`));
  364. icon.appendChild(mark);
  365. icon.appendChild(el('div', `${PREFIX}success-ring`));
  366. icon.appendChild(el('div', `${PREFIX}success-fix`));
  367. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  368. applyIconSize('font');
  369. return icon;
  370. }
  371. if (type === 'error' || type === 'warning' || type === 'info' || type === 'question') {
  372. // Use the same "success-like" ring parts for every icon
  373. appendRingParts();
  374. applyIconSize('font');
  375. // SVG only draws the inner symbol (no SVG ring)
  376. const svg = createInnerMarkSvg();
  377. appendBuiltInInnerMark(svg);
  378. icon.appendChild(svg);
  379. return icon;
  380. }
  381. // Default to SVG icons for other/custom types
  382. applyIconSize('box');
  383. const svg = createInnerMarkSvg();
  384. const ring = svgEl('circle');
  385. ring.setAttribute('class', `${PREFIX}svg-ring ${PREFIX}svg-draw`);
  386. ring.setAttribute('cx', '40');
  387. ring.setAttribute('cy', '40');
  388. ring.setAttribute('r', '34');
  389. ring.setAttribute('stroke', 'currentColor');
  390. svg.appendChild(ring);
  391. // For custom types, we draw ring only by default (no inner mark).
  392. icon.appendChild(svg);
  393. return icon;
  394. }
  395. _adjustRingBackgroundColor() {
  396. try {
  397. const icon = this.dom && this.dom.icon;
  398. const popup = this.dom && this.dom.popup;
  399. if (!icon || !popup) return;
  400. const bg = getComputedStyle(popup).backgroundColor;
  401. const parts = icon.querySelectorAll(RING_BG_PARTS_SELECTOR);
  402. parts.forEach((el) => {
  403. try { el.style.backgroundColor = bg; } catch {}
  404. });
  405. } catch {}
  406. }
  407. _didOpen() {
  408. // Keyboard close (ESC)
  409. if (this.params.closeOnEsc) {
  410. this._onKeydown = (e) => {
  411. if (!e) return;
  412. if (e.key === 'Escape') this._close(null, 'esc');
  413. };
  414. document.addEventListener('keydown', this._onKeydown);
  415. }
  416. // Keep the "success-like" ring perfectly blended with popup bg
  417. this._adjustRingBackgroundColor();
  418. // Popup animation (optional)
  419. if (this.params.popupAnimation) {
  420. const X = Layer._getXjs();
  421. if (X && this.dom.popup) {
  422. // Override the CSS scale transition with a spring-ish entrance.
  423. try {
  424. this.dom.popup.style.transition = 'none';
  425. this.dom.popup.style.transform = 'scale(0.92)';
  426. X(this.dom.popup).animate({
  427. scale: [0.92, 1],
  428. y: [-6, 0],
  429. duration: 520,
  430. easing: { stiffness: 260, damping: 16 }
  431. });
  432. } catch {}
  433. }
  434. }
  435. // Icon SVG draw animation
  436. if (this.params.iconAnimation) {
  437. this._animateIcon();
  438. }
  439. // User hook (SweetAlert-ish naming)
  440. try {
  441. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  442. } catch {}
  443. }
  444. _animateIcon() {
  445. const icon = this.dom.icon;
  446. if (!icon) return;
  447. const type = (this.params && this.params.icon) || '';
  448. // Ring animation (same as success) for all built-in icons
  449. if (RING_TYPES.has(type)) this._adjustRingBackgroundColor();
  450. try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
  451. requestAnimationFrame(() => {
  452. try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
  453. });
  454. // Success tick is CSS-driven; others still draw their SVG mark after the ring starts.
  455. if (type === 'success') return;
  456. const X = Layer._getXjs();
  457. if (!X) return;
  458. const svg = icon.querySelector('svg');
  459. if (!svg) return;
  460. const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
  461. const dot = svg.querySelector(`.${PREFIX}svg-dot`);
  462. // If this is a built-in icon, keep the SweetAlert-like order:
  463. // ring sweep first (~0.51s), then draw the inner mark.
  464. const baseDelay = RING_TYPES.has(type) ? 520 : 0;
  465. if (type === 'error') {
  466. // Draw order: left-top -> right-bottom, then right-top -> left-bottom
  467. // NOTE: A tiny delay (like 70ms) looks simultaneous; make it strictly sequential.
  468. const a = svg.querySelector(`.${PREFIX}svg-error-left`) || marks[0]; // M28 28 L52 52
  469. const b = svg.querySelector(`.${PREFIX}svg-error-right`) || marks[1]; // M52 28 L28 52
  470. const dur = 320;
  471. const gap = 60;
  472. try { if (a) X(a).draw({ duration: dur, easing: 'ease-out', delay: baseDelay }); } catch {}
  473. try { if (b) X(b).draw({ duration: dur, easing: 'ease-out', delay: baseDelay + dur + gap }); } catch {}
  474. } else {
  475. // warning / info / question (single stroke) or custom SVG symbols
  476. marks.forEach((m, i) => {
  477. try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
  478. });
  479. }
  480. if (dot) {
  481. try {
  482. dot.style.opacity = '0';
  483. // Keep dot pop after the ring begins
  484. const d = baseDelay + 140;
  485. if (type === 'info') {
  486. X(dot).animate({ opacity: [0, 1], y: [-8, 0], scale: [0.2, 1], duration: 420, delay: d, easing: { stiffness: 300, damping: 14 } });
  487. } else {
  488. X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
  489. }
  490. } catch {}
  491. }
  492. }
  493. _forceDestroy(reason = 'replace') {
  494. // Restore mounted DOM (if any) and cleanup listeners; also resolve the promise so it doesn't hang.
  495. try { this._unmountDomContent(); } catch {}
  496. if (this._onKeydown) {
  497. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  498. this._onKeydown = null;
  499. }
  500. try {
  501. if (this.resolve) {
  502. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason });
  503. }
  504. } catch {}
  505. }
  506. _close(isConfirmed, reason) {
  507. if (this._isClosing) return;
  508. this._isClosing = true;
  509. // Restore mounted DOM (moved into popup) before removing overlay
  510. try { this._unmountDomContent(); } catch {}
  511. this.dom.overlay.classList.remove('show');
  512. setTimeout(() => {
  513. if (this.dom.overlay && this.dom.overlay.parentNode) {
  514. this.dom.overlay.parentNode.removeChild(this.dom.overlay);
  515. }
  516. }, 300);
  517. if (this._onKeydown) {
  518. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  519. this._onKeydown = null;
  520. }
  521. try {
  522. if (typeof this.params.willClose === 'function') this.params.willClose(this.dom.popup);
  523. } catch {}
  524. try {
  525. if (typeof this.params.didClose === 'function') this.params.didClose();
  526. } catch {}
  527. const value = (this._flowSteps && this._flowSteps.length) ? (this._flowValues || []) : undefined;
  528. if (isConfirmed === true) {
  529. this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false, value });
  530. } else if (isConfirmed === false) {
  531. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'cancel', value });
  532. } else {
  533. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'backdrop', value });
  534. }
  535. }
  536. _normalizeDomSpec(params) {
  537. // Preferred: params.dom (selector/Element/template)
  538. // Backward: params.content (selector/Element) OR advanced object { dom, mode, clone }
  539. const p = params || {};
  540. let dom = p.dom;
  541. let mode = p.domMode || 'move';
  542. if (dom == null && p.content != null) {
  543. if (typeof p.content === 'object' && !(p.content instanceof Element)) {
  544. if (p.content && (p.content.dom != null || p.content.selector != null)) {
  545. dom = (p.content.dom != null) ? p.content.dom : p.content.selector;
  546. if (p.content.mode) mode = p.content.mode;
  547. if (p.content.clone === true) mode = 'clone';
  548. }
  549. } else {
  550. dom = p.content;
  551. }
  552. }
  553. if (dom == null) return null;
  554. return { dom, mode };
  555. }
  556. _mountDomContent(domSpec) {
  557. try {
  558. if (!this.dom.content) return;
  559. this._unmountDomContent(); // ensure only one mount at a time
  560. const forceVisible = (el) => {
  561. if (!el) return { prevHidden: false, prevInlineDisplay: '' };
  562. const prevHidden = !!el.hidden;
  563. const prevInlineDisplay = (el.style && typeof el.style.display === 'string') ? el.style.display : '';
  564. try { el.hidden = false; } catch {}
  565. try {
  566. const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(el) : null;
  567. const display = cs ? String(cs.display || '') : '';
  568. // If element is hidden via CSS (e.g. .hidden-dom{display:none}),
  569. // add an inline override so it becomes visible inside the popup.
  570. if (display === 'none') el.style.display = 'block';
  571. else if (el.style && el.style.display === 'none') el.style.display = 'block';
  572. } catch {}
  573. return { prevHidden, prevInlineDisplay };
  574. };
  575. let node = domSpec.dom;
  576. if (typeof node === 'string') node = document.querySelector(node);
  577. if (!node) return;
  578. // <template> support: always clone template content
  579. if (typeof HTMLTemplateElement !== 'undefined' && node instanceof HTMLTemplateElement) {
  580. const frag = node.content.cloneNode(true);
  581. this.dom.content.appendChild(frag);
  582. this._mounted = { kind: 'template' };
  583. return;
  584. }
  585. if (!(node instanceof Element)) return;
  586. if (domSpec.mode === 'clone') {
  587. const clone = node.cloneNode(true);
  588. // If original is hidden (via attr or CSS), the clone may inherit; force show it in popup.
  589. try { clone.hidden = false; } catch {}
  590. try {
  591. const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(node) : null;
  592. const display = cs ? String(cs.display || '') : '';
  593. if (display === 'none') clone.style.display = 'block';
  594. } catch {}
  595. this.dom.content.appendChild(clone);
  596. this._mounted = { kind: 'clone' };
  597. return;
  598. }
  599. // Default: move into popup but restore on close
  600. const placeholder = document.createComment('layer-dom-placeholder');
  601. const parent = node.parentNode;
  602. const nextSibling = node.nextSibling;
  603. if (!parent) {
  604. // Detached node: moving would lose it when overlay is removed; clone instead.
  605. const clone = node.cloneNode(true);
  606. try { clone.hidden = false; } catch {}
  607. try { if (clone.style && clone.style.display === 'none') clone.style.display = ''; } catch {}
  608. this.dom.content.appendChild(clone);
  609. this._mounted = { kind: 'clone' };
  610. return;
  611. }
  612. try { parent.insertBefore(placeholder, nextSibling); } catch {}
  613. const { prevHidden, prevInlineDisplay } = forceVisible(node);
  614. this.dom.content.appendChild(node);
  615. this._mounted = { kind: 'move', originalEl: node, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay };
  616. } catch {
  617. // ignore
  618. }
  619. }
  620. _unmountDomContent() {
  621. const m = this._mounted;
  622. if (!m) return;
  623. this._mounted = null;
  624. if (m.kind !== 'move') return;
  625. const node = m.originalEl;
  626. if (!node) return;
  627. // Restore hidden/display
  628. try { node.hidden = !!m.prevHidden; } catch {}
  629. try {
  630. if (node.style && typeof m.prevInlineDisplay === 'string') node.style.display = m.prevInlineDisplay;
  631. } catch {}
  632. // Move back to original position
  633. try {
  634. const ph = m.placeholder;
  635. if (ph && ph.parentNode) {
  636. ph.parentNode.insertBefore(node, ph);
  637. ph.parentNode.removeChild(ph);
  638. return;
  639. }
  640. } catch {}
  641. // Fallback: append to original parent
  642. try {
  643. if (m.parent) m.parent.appendChild(node);
  644. } catch {}
  645. }
  646. _setButtonsDisabled(disabled) {
  647. try {
  648. if (this.dom && this.dom.confirmBtn) this.dom.confirmBtn.disabled = !!disabled;
  649. if (this.dom && this.dom.cancelBtn) this.dom.cancelBtn.disabled = !!disabled;
  650. } catch {}
  651. }
  652. async _handleConfirm() {
  653. // Flow next / finalize
  654. if (this._flowSteps && this._flowSteps.length) {
  655. return this._flowNext();
  656. }
  657. // Single popup: support async preConfirm
  658. const pre = this.params && this.params.preConfirm;
  659. if (typeof pre === 'function') {
  660. try {
  661. this._setButtonsDisabled(true);
  662. const r = pre(this.dom && this.dom.popup);
  663. const v = (r && typeof r.then === 'function') ? await r : r;
  664. if (v === false) {
  665. this._setButtonsDisabled(false);
  666. return;
  667. }
  668. // store value in non-flow mode as single value
  669. this._flowValues = [v];
  670. } catch (e) {
  671. console.error(e);
  672. this._setButtonsDisabled(false);
  673. return;
  674. }
  675. }
  676. this._close(true, 'confirm');
  677. }
  678. _handleCancel() {
  679. if (this._flowSteps && this._flowSteps.length) {
  680. // default: if not first step, cancel acts as "back"
  681. if (this._flowIndex > 0) {
  682. this._flowPrev();
  683. return;
  684. }
  685. }
  686. this._close(false, 'cancel');
  687. }
  688. _getFlowStepOptions(index) {
  689. const step = (this._flowSteps && this._flowSteps[index]) ? this._flowSteps[index] : {};
  690. const base = this._flowBase || {};
  691. const merged = { ...base, ...step };
  692. // Default button texts for flow
  693. const isLast = index >= (this._flowSteps.length - 1);
  694. if (!('confirmButtonText' in step)) merged.confirmButtonText = isLast ? (base.confirmButtonText || 'OK') : 'Next';
  695. // Show cancel as Back after first step (unless step explicitly overrides)
  696. if (index > 0) {
  697. if (!('showCancelButton' in step)) merged.showCancelButton = true;
  698. if (!('cancelButtonText' in step)) merged.cancelButtonText = 'Back';
  699. } else {
  700. // First step default keeps base settings
  701. if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
  702. }
  703. return merged;
  704. }
  705. async _flowNext() {
  706. const idx = this._flowIndex;
  707. const total = this._flowSteps.length;
  708. const isLast = idx >= (total - 1);
  709. // preConfirm hook for current step
  710. const pre = this.params && this.params.preConfirm;
  711. if (typeof pre === 'function') {
  712. try {
  713. this._setButtonsDisabled(true);
  714. const r = pre(this.dom && this.dom.popup, idx);
  715. const v = (r && typeof r.then === 'function') ? await r : r;
  716. if (v === false) {
  717. this._setButtonsDisabled(false);
  718. return;
  719. }
  720. this._flowValues[idx] = v;
  721. } catch (e) {
  722. console.error(e);
  723. this._setButtonsDisabled(false);
  724. return;
  725. }
  726. }
  727. if (isLast) {
  728. this._close(true, 'confirm');
  729. return;
  730. }
  731. this._setButtonsDisabled(false);
  732. await this._flowGo(idx + 1, 'next');
  733. }
  734. async _flowPrev() {
  735. const idx = this._flowIndex;
  736. if (idx <= 0) return;
  737. await this._flowGo(idx - 1, 'prev');
  738. }
  739. async _flowGo(index, direction) {
  740. const next = this._getFlowStepOptions(index);
  741. this._flowIndex = index;
  742. await this._transitionTo(next, direction);
  743. }
  744. async _transitionTo(nextOptions, direction) {
  745. // Animate popup swap (keep overlay)
  746. const popup = this.dom && this.dom.popup;
  747. if (!popup) {
  748. this.params = nextOptions;
  749. this._rerenderInside();
  750. return;
  751. }
  752. // First, unmount moved DOM from current step so it can be remounted later
  753. try { this._unmountDomContent(); } catch {}
  754. const outKeyframes = [
  755. { opacity: 1, transform: 'translateY(0px)' },
  756. { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '8px' : '-8px'})` }
  757. ];
  758. const inKeyframes = [
  759. { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '-8px' : '8px'})` },
  760. { opacity: 1, transform: 'translateY(0px)' }
  761. ];
  762. try {
  763. const a = popup.animate(outKeyframes, { duration: 140, easing: 'ease-out', fill: 'forwards' });
  764. await (a && a.finished ? a.finished.catch(() => {}) : Promise.resolve());
  765. } catch {}
  766. // Apply new options
  767. this.params = nextOptions;
  768. this._rerenderInside();
  769. try {
  770. const b = popup.animate(inKeyframes, { duration: 200, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' });
  771. await (b && b.finished ? b.finished.catch(() => {}) : Promise.resolve());
  772. } catch {}
  773. // Re-adjust icon ring bg in case popup background differs
  774. try { this._adjustRingBackgroundColor(); } catch {}
  775. }
  776. _rerenderInside() {
  777. // Update popup content without recreating overlay (used in flow transitions)
  778. const popup = this.dom && this.dom.popup;
  779. if (!popup) return;
  780. // Clear popup (but keep reference)
  781. while (popup.firstChild) popup.removeChild(popup.firstChild);
  782. // Icon
  783. this.dom.icon = null;
  784. if (this.params.icon) {
  785. this.dom.icon = this._createIcon(this.params.icon);
  786. popup.appendChild(this.dom.icon);
  787. }
  788. // Title
  789. this.dom.title = null;
  790. if (this.params.title) {
  791. this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
  792. popup.appendChild(this.dom.title);
  793. }
  794. // Content
  795. this.dom.content = null;
  796. if (this.params.text || this.params.html || this.params.content || this.params.dom) {
  797. this.dom.content = el('div', `${PREFIX}content`);
  798. const domSpec = this._normalizeDomSpec(this.params);
  799. if (domSpec) this._mountDomContent(domSpec);
  800. else if (this.params.html) this.dom.content.innerHTML = this.params.html;
  801. else this.dom.content.textContent = this.params.text;
  802. popup.appendChild(this.dom.content);
  803. }
  804. // Actions / buttons
  805. this.dom.actions = el('div', `${PREFIX}actions`);
  806. this.dom.cancelBtn = null;
  807. if (this.params.showCancelButton) {
  808. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  809. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  810. this.dom.cancelBtn.onclick = () => this._handleCancel();
  811. this.dom.actions.appendChild(this.dom.cancelBtn);
  812. }
  813. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  814. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  815. this.dom.confirmBtn.onclick = () => this._handleConfirm();
  816. this.dom.actions.appendChild(this.dom.confirmBtn);
  817. popup.appendChild(this.dom.actions);
  818. // Re-run open hooks for each step
  819. try {
  820. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  821. } catch {}
  822. }
  823. }
  824. return Layer;
  825. })));