layer.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  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. }
  93. // Constructor helper when called as function: const popup = Layer({...})
  94. static get isProxy() { return true; }
  95. // Static entry point
  96. static fire(options) {
  97. const instance = new Layer();
  98. return instance._fire(options);
  99. }
  100. // Chainable entry point (builder-style)
  101. // Example:
  102. // Layer.$({ title: 'Hi' }).fire().then(...)
  103. // Layer.$().config({ title: 'Hi' }).fire()
  104. static $(options) {
  105. const instance = new Layer();
  106. if (options !== undefined) instance.config(options);
  107. return instance;
  108. }
  109. // Chainable config helper (does not render until `.fire()` is called)
  110. config(options = {}) {
  111. // Support the same shorthand as Layer.fire(title, text, icon)
  112. options = normalizeOptions(options, arguments[1], arguments[2]);
  113. this.params = { ...(this.params || {}), ...options };
  114. return this;
  115. }
  116. // Instance entry point (chainable)
  117. fire(options) {
  118. const merged = (options === undefined) ? (this.params || {}) : options;
  119. return this._fire(merged);
  120. }
  121. _fire(options = {}) {
  122. options = normalizeOptions(options, arguments[1], arguments[2]);
  123. this.params = {
  124. title: '',
  125. text: '',
  126. icon: null,
  127. iconSize: null, // e.g. '6em' / '72px'
  128. confirmButtonText: 'OK',
  129. cancelButtonText: 'Cancel',
  130. showCancelButton: false,
  131. confirmButtonColor: '#3085d6',
  132. cancelButtonColor: '#aaa',
  133. closeOnClickOutside: true,
  134. closeOnEsc: true,
  135. iconAnimation: true,
  136. popupAnimation: true,
  137. ...options
  138. };
  139. this.promise = new Promise((resolve, reject) => {
  140. this.resolve = resolve;
  141. this.reject = reject;
  142. });
  143. this._render();
  144. return this.promise;
  145. }
  146. static _getXjs() {
  147. // Prefer xjs, fallback to animal (compat)
  148. const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
  149. if (!g) return null;
  150. const x = g.xjs || g.animal;
  151. return (typeof x === 'function') ? x : null;
  152. }
  153. _render() {
  154. // Remove existing if any
  155. const existing = document.querySelector(`.${PREFIX}overlay`);
  156. if (existing) existing.remove();
  157. // Create Overlay
  158. this.dom.overlay = el('div', `${PREFIX}overlay`);
  159. // Create Popup
  160. this.dom.popup = el('div', `${PREFIX}popup`);
  161. this.dom.overlay.appendChild(this.dom.popup);
  162. // Icon
  163. if (this.params.icon) {
  164. this.dom.icon = this._createIcon(this.params.icon);
  165. this.dom.popup.appendChild(this.dom.icon);
  166. }
  167. // Title
  168. if (this.params.title) {
  169. this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
  170. this.dom.popup.appendChild(this.dom.title);
  171. }
  172. // Content (Text / HTML / Element)
  173. if (this.params.text || this.params.html || this.params.content) {
  174. this.dom.content = el('div', `${PREFIX}content`);
  175. if (this.params.content) {
  176. // DOM Element or Selector
  177. let el = this.params.content;
  178. if (typeof el === 'string') el = document.querySelector(el);
  179. if (el instanceof Element) this.dom.content.appendChild(el);
  180. } else if (this.params.html) {
  181. this.dom.content.innerHTML = this.params.html;
  182. } else {
  183. this.dom.content.textContent = this.params.text;
  184. }
  185. this.dom.popup.appendChild(this.dom.content);
  186. }
  187. // Actions
  188. this.dom.actions = el('div', `${PREFIX}actions`);
  189. // Cancel Button
  190. if (this.params.showCancelButton) {
  191. this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
  192. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  193. this.dom.cancelBtn.onclick = () => this._close(false);
  194. this.dom.actions.appendChild(this.dom.cancelBtn);
  195. }
  196. // Confirm Button
  197. this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
  198. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  199. this.dom.confirmBtn.onclick = () => this._close(true);
  200. this.dom.actions.appendChild(this.dom.confirmBtn);
  201. this.dom.popup.appendChild(this.dom.actions);
  202. // Event Listeners
  203. if (this.params.closeOnClickOutside) {
  204. this.dom.overlay.addEventListener('click', (e) => {
  205. if (e.target === this.dom.overlay) {
  206. this._close(null); // Dismiss
  207. }
  208. });
  209. }
  210. document.body.appendChild(this.dom.overlay);
  211. // Animation
  212. requestAnimationFrame(() => {
  213. this.dom.overlay.classList.add('show');
  214. this._didOpen();
  215. });
  216. }
  217. _createIcon(type) {
  218. const icon = el('div', `${PREFIX}icon ${type}`);
  219. const applyIconSize = (mode) => {
  220. if (!(this.params && this.params.iconSize)) return;
  221. try {
  222. const s = String(this.params.iconSize).trim();
  223. if (!s) return;
  224. // For SweetAlert2-style success icon, scale via font-size so all `em`-based parts remain proportional.
  225. if (mode === 'font') {
  226. const m = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem)$/i);
  227. if (m) {
  228. const n = parseFloat(m[1]);
  229. const unit = m[2];
  230. if (Number.isFinite(n) && n > 0) {
  231. icon.style.fontSize = (n / 5) + unit; // icon is 5em wide/tall
  232. return;
  233. }
  234. }
  235. }
  236. // Fallback: directly size the box (works great for SVG icons)
  237. icon.style.width = s;
  238. icon.style.height = s;
  239. } catch {}
  240. };
  241. const appendRingParts = () => {
  242. // Use the same "success-like" ring parts for every built-in icon
  243. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  244. icon.appendChild(el('div', `${PREFIX}success-ring`));
  245. icon.appendChild(el('div', `${PREFIX}success-fix`));
  246. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  247. };
  248. const createInnerMarkSvg = () => {
  249. const svg = svgEl('svg');
  250. svg.setAttribute('viewBox', '0 0 80 80');
  251. svg.setAttribute('aria-hidden', 'true');
  252. svg.setAttribute('focusable', 'false');
  253. return svg;
  254. };
  255. const addMarkPath = (svg, d, extraClass) => {
  256. const p = svgEl('path');
  257. p.setAttribute('d', d);
  258. p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
  259. p.setAttribute('stroke', 'currentColor');
  260. svg.appendChild(p);
  261. return p;
  262. };
  263. const addDot = (svg, cx, cy) => {
  264. const dot = svgEl('circle');
  265. dot.setAttribute('class', `${PREFIX}svg-dot`);
  266. dot.setAttribute('cx', String(cx));
  267. dot.setAttribute('cy', String(cy));
  268. dot.setAttribute('r', '3.2');
  269. dot.setAttribute('fill', 'currentColor');
  270. svg.appendChild(dot);
  271. return dot;
  272. };
  273. const appendBuiltInInnerMark = (svg) => {
  274. if (type === 'error') {
  275. addMarkPath(svg, 'M28 28 L52 52', `${PREFIX}svg-error-left`);
  276. addMarkPath(svg, 'M52 28 L28 52', `${PREFIX}svg-error-right`);
  277. } else if (type === 'warning') {
  278. addMarkPath(svg, 'M40 20 L40 46', `${PREFIX}svg-warning-line`);
  279. addDot(svg, 40, 58);
  280. } else if (type === 'info') {
  281. addMarkPath(svg, 'M40 34 L40 56', `${PREFIX}svg-info-line`);
  282. addDot(svg, 40, 25);
  283. } else if (type === 'question') {
  284. 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`);
  285. addDot(svg, 42, 61);
  286. }
  287. };
  288. if (type === 'success') {
  289. // SweetAlert2-compatible DOM structure (circle + tick) for exact style parity
  290. // <div class="...success-circular-line-left"></div>
  291. // <div class="...success-mark"><span class="...success-line-tip"></span><span class="...success-line-long"></span></div>
  292. // <div class="...success-ring"></div>
  293. // <div class="...success-fix"></div>
  294. // <div class="...success-circular-line-right"></div>
  295. icon.appendChild(el('div', `${PREFIX}success-circular-line-left`));
  296. const mark = el('div', `${PREFIX}success-mark`);
  297. mark.appendChild(el('span', `${PREFIX}success-line-tip`));
  298. mark.appendChild(el('span', `${PREFIX}success-line-long`));
  299. icon.appendChild(mark);
  300. icon.appendChild(el('div', `${PREFIX}success-ring`));
  301. icon.appendChild(el('div', `${PREFIX}success-fix`));
  302. icon.appendChild(el('div', `${PREFIX}success-circular-line-right`));
  303. applyIconSize('font');
  304. return icon;
  305. }
  306. if (type === 'error' || type === 'warning' || type === 'info' || type === 'question') {
  307. // Use the same "success-like" ring parts for every icon
  308. appendRingParts();
  309. applyIconSize('font');
  310. // SVG only draws the inner symbol (no SVG ring)
  311. const svg = createInnerMarkSvg();
  312. appendBuiltInInnerMark(svg);
  313. icon.appendChild(svg);
  314. return icon;
  315. }
  316. // Default to SVG icons for other/custom types
  317. applyIconSize('box');
  318. const svg = createInnerMarkSvg();
  319. const ring = svgEl('circle');
  320. ring.setAttribute('class', `${PREFIX}svg-ring ${PREFIX}svg-draw`);
  321. ring.setAttribute('cx', '40');
  322. ring.setAttribute('cy', '40');
  323. ring.setAttribute('r', '34');
  324. ring.setAttribute('stroke', 'currentColor');
  325. svg.appendChild(ring);
  326. // For custom types, we draw ring only by default (no inner mark).
  327. icon.appendChild(svg);
  328. return icon;
  329. }
  330. _adjustRingBackgroundColor() {
  331. try {
  332. const icon = this.dom && this.dom.icon;
  333. const popup = this.dom && this.dom.popup;
  334. if (!icon || !popup) return;
  335. const bg = getComputedStyle(popup).backgroundColor;
  336. const parts = icon.querySelectorAll(RING_BG_PARTS_SELECTOR);
  337. parts.forEach((el) => {
  338. try { el.style.backgroundColor = bg; } catch {}
  339. });
  340. } catch {}
  341. }
  342. _didOpen() {
  343. // Keyboard close (ESC)
  344. if (this.params.closeOnEsc) {
  345. this._onKeydown = (e) => {
  346. if (!e) return;
  347. if (e.key === 'Escape') this._close(null);
  348. };
  349. document.addEventListener('keydown', this._onKeydown);
  350. }
  351. // Keep the "success-like" ring perfectly blended with popup bg
  352. this._adjustRingBackgroundColor();
  353. // Popup animation (optional)
  354. if (this.params.popupAnimation) {
  355. const X = Layer._getXjs();
  356. if (X && this.dom.popup) {
  357. // Override the CSS scale transition with a spring-ish entrance.
  358. try {
  359. this.dom.popup.style.transition = 'none';
  360. this.dom.popup.style.transform = 'scale(0.92)';
  361. X(this.dom.popup).animate({
  362. scale: [0.92, 1],
  363. y: [-6, 0],
  364. duration: 520,
  365. easing: { stiffness: 260, damping: 16 }
  366. });
  367. } catch {}
  368. }
  369. }
  370. // Icon SVG draw animation
  371. if (this.params.iconAnimation) {
  372. this._animateIcon();
  373. }
  374. // User hook (SweetAlert-ish naming)
  375. try {
  376. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  377. } catch {}
  378. }
  379. _animateIcon() {
  380. const icon = this.dom.icon;
  381. if (!icon) return;
  382. const type = (this.params && this.params.icon) || '';
  383. // Ring animation (same as success) for all built-in icons
  384. if (RING_TYPES.has(type)) this._adjustRingBackgroundColor();
  385. try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
  386. requestAnimationFrame(() => {
  387. try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
  388. });
  389. // Success tick is CSS-driven; others still draw their SVG mark after the ring starts.
  390. if (type === 'success') return;
  391. const X = Layer._getXjs();
  392. if (!X) return;
  393. const svg = icon.querySelector('svg');
  394. if (!svg) return;
  395. const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
  396. const dot = svg.querySelector(`.${PREFIX}svg-dot`);
  397. // If this is a built-in icon, keep the SweetAlert-like order:
  398. // ring sweep first (~0.51s), then draw the inner mark.
  399. const baseDelay = RING_TYPES.has(type) ? 520 : 0;
  400. if (type === 'error') {
  401. // Draw order: left-top -> right-bottom, then right-top -> left-bottom
  402. // NOTE: A tiny delay (like 70ms) looks simultaneous; make it strictly sequential.
  403. const a = svg.querySelector(`.${PREFIX}svg-error-left`) || marks[0]; // M28 28 L52 52
  404. const b = svg.querySelector(`.${PREFIX}svg-error-right`) || marks[1]; // M52 28 L28 52
  405. const dur = 320;
  406. const gap = 60;
  407. try { if (a) X(a).draw({ duration: dur, easing: 'ease-out', delay: baseDelay }); } catch {}
  408. try { if (b) X(b).draw({ duration: dur, easing: 'ease-out', delay: baseDelay + dur + gap }); } catch {}
  409. } else {
  410. // warning / info / question (single stroke) or custom SVG symbols
  411. marks.forEach((m, i) => {
  412. try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
  413. });
  414. }
  415. if (dot) {
  416. try {
  417. dot.style.opacity = '0';
  418. // Keep dot pop after the ring begins
  419. const d = baseDelay + 140;
  420. if (type === 'info') {
  421. X(dot).animate({ opacity: [0, 1], y: [-8, 0], scale: [0.2, 1], duration: 420, delay: d, easing: { stiffness: 300, damping: 14 } });
  422. } else {
  423. X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
  424. }
  425. } catch {}
  426. }
  427. }
  428. _close(isConfirmed) {
  429. this.dom.overlay.classList.remove('show');
  430. setTimeout(() => {
  431. if (this.dom.overlay && this.dom.overlay.parentNode) {
  432. this.dom.overlay.parentNode.removeChild(this.dom.overlay);
  433. }
  434. }, 300);
  435. if (this._onKeydown) {
  436. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  437. this._onKeydown = null;
  438. }
  439. try {
  440. if (typeof this.params.willClose === 'function') this.params.willClose(this.dom.popup);
  441. } catch {}
  442. try {
  443. if (typeof this.params.didClose === 'function') this.params.didClose();
  444. } catch {}
  445. if (isConfirmed === true) {
  446. this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false });
  447. } else if (isConfirmed === false) {
  448. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'cancel' });
  449. } else {
  450. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'backdrop' });
  451. }
  452. }
  453. }
  454. return Layer;
  455. })));