layer.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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. // Theme & Styles
  36. const css = `
  37. .${PREFIX}overlay {
  38. position: fixed;
  39. top: 0;
  40. left: 0;
  41. width: 100%;
  42. height: 100%;
  43. background: rgba(0, 0, 0, 0.4);
  44. display: flex;
  45. justify-content: center;
  46. align-items: center;
  47. z-index: 1000;
  48. opacity: 0;
  49. transition: opacity 0.3s;
  50. }
  51. .${PREFIX}overlay.show {
  52. opacity: 1;
  53. }
  54. .${PREFIX}popup {
  55. background: #fff;
  56. border-radius: 8px;
  57. box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  58. width: 32em;
  59. max-width: 90%;
  60. padding: 1.5em;
  61. display: flex;
  62. flex-direction: column;
  63. align-items: center;
  64. position: relative;
  65. z-index: 1;
  66. transform: scale(0.92);
  67. transition: transform 0.26s ease;
  68. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  69. }
  70. .${PREFIX}overlay.show .${PREFIX}popup {
  71. transform: scale(1);
  72. }
  73. .${PREFIX}title {
  74. font-size: 1.8em;
  75. font-weight: 600;
  76. color: #333;
  77. margin: 0 0 0.5em;
  78. text-align: center;
  79. }
  80. .${PREFIX}content {
  81. font-size: 1.125em;
  82. color: #545454;
  83. margin-bottom: 1.5em;
  84. text-align: center;
  85. line-height: 1.5;
  86. }
  87. .${PREFIX}actions {
  88. display: flex;
  89. justify-content: center;
  90. gap: 1em;
  91. width: 100%;
  92. }
  93. .${PREFIX}button {
  94. border: none;
  95. border-radius: 4px;
  96. padding: 0.6em 1.2em;
  97. font-size: 1em;
  98. cursor: pointer;
  99. transition: background-color 0.2s, box-shadow 0.2s;
  100. color: #fff;
  101. }
  102. .${PREFIX}confirm {
  103. background-color: #3085d6;
  104. }
  105. .${PREFIX}confirm:hover {
  106. background-color: #2b77c0;
  107. }
  108. .${PREFIX}cancel {
  109. background-color: #aaa;
  110. }
  111. .${PREFIX}cancel:hover {
  112. background-color: #999;
  113. }
  114. .${PREFIX}icon {
  115. width: 6em;
  116. height: 6em;
  117. margin: 1.5em auto 1.2em;
  118. position: relative;
  119. display: grid;
  120. place-items: center;
  121. }
  122. /* SVG Icon (SweetAlert-like, animated via xjs.draw + spring) */
  123. .${PREFIX}icon svg {
  124. width: 100%;
  125. height: 100%;
  126. display: block;
  127. overflow: visible;
  128. }
  129. .${PREFIX}svg-ring,
  130. .${PREFIX}svg-mark {
  131. fill: none;
  132. stroke-linecap: round;
  133. stroke-linejoin: round;
  134. }
  135. .${PREFIX}svg-ring {
  136. stroke-width: 4.5;
  137. opacity: 0.95;
  138. }
  139. .${PREFIX}svg-mark {
  140. stroke-width: 6;
  141. }
  142. .${PREFIX}svg-dot {
  143. transform-box: fill-box;
  144. transform-origin: center;
  145. }
  146. .${PREFIX}icon.success { color: #a5dc86; }
  147. .${PREFIX}icon.error { color: #f27474; }
  148. .${PREFIX}icon.warning { color: #f8bb86; }
  149. .${PREFIX}icon.info { color: #3fc3ee; }
  150. .${PREFIX}icon.question { color: #b18cff; }
  151. `;
  152. // Inject Styles
  153. const injectStyles = () => {
  154. if (document.getElementById(`${PREFIX}styles`)) return;
  155. const styleSheet = document.createElement('style');
  156. styleSheet.id = `${PREFIX}styles`;
  157. styleSheet.textContent = css;
  158. document.head.appendChild(styleSheet);
  159. };
  160. class Layer {
  161. constructor() {
  162. injectStyles();
  163. this.params = {};
  164. this.dom = {};
  165. this.promise = null;
  166. this.resolve = null;
  167. this.reject = null;
  168. this._onKeydown = null;
  169. }
  170. // Constructor helper when called as function: const popup = Layer({...})
  171. static get isProxy() { return true; }
  172. // Static entry point
  173. static fire(options) {
  174. const instance = new Layer();
  175. return instance._fire(options);
  176. }
  177. // Chainable entry point (builder-style)
  178. // Example:
  179. // Layer.$({ title: 'Hi' }).fire().then(...)
  180. // Layer.$().config({ title: 'Hi' }).fire()
  181. static $(options) {
  182. const instance = new Layer();
  183. if (options !== undefined) instance.config(options);
  184. return instance;
  185. }
  186. // Chainable config helper (does not render until `.fire()` is called)
  187. config(options = {}) {
  188. // Support the same shorthand as Layer.fire(title, text, icon)
  189. if (typeof options === 'string') {
  190. options = { title: options };
  191. if (arguments[1]) options.text = arguments[1];
  192. if (arguments[2]) options.icon = arguments[2];
  193. }
  194. this.params = { ...(this.params || {}), ...options };
  195. return this;
  196. }
  197. // Instance entry point (chainable)
  198. fire(options) {
  199. const merged = (options === undefined) ? (this.params || {}) : options;
  200. return this._fire(merged);
  201. }
  202. _fire(options = {}) {
  203. if (typeof options === 'string') {
  204. options = { title: options };
  205. if (arguments[1]) options.text = arguments[1];
  206. if (arguments[2]) options.icon = arguments[2];
  207. }
  208. this.params = {
  209. title: '',
  210. text: '',
  211. icon: null,
  212. iconSize: null, // e.g. '6em' / '72px'
  213. confirmButtonText: 'OK',
  214. cancelButtonText: 'Cancel',
  215. showCancelButton: false,
  216. confirmButtonColor: '#3085d6',
  217. cancelButtonColor: '#aaa',
  218. closeOnClickOutside: true,
  219. closeOnEsc: true,
  220. iconAnimation: true,
  221. popupAnimation: true,
  222. ...options
  223. };
  224. this.promise = new Promise((resolve, reject) => {
  225. this.resolve = resolve;
  226. this.reject = reject;
  227. });
  228. this._render();
  229. return this.promise;
  230. }
  231. static _getXjs() {
  232. // Prefer xjs, fallback to animal (compat)
  233. const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
  234. if (!g) return null;
  235. const x = g.xjs || g.animal;
  236. return (typeof x === 'function') ? x : null;
  237. }
  238. _render() {
  239. // Remove existing if any
  240. const existing = document.querySelector(`.${PREFIX}overlay`);
  241. if (existing) existing.remove();
  242. // Create Overlay
  243. this.dom.overlay = document.createElement('div');
  244. this.dom.overlay.className = `${PREFIX}overlay`;
  245. // Create Popup
  246. this.dom.popup = document.createElement('div');
  247. this.dom.popup.className = `${PREFIX}popup`;
  248. this.dom.overlay.appendChild(this.dom.popup);
  249. // Icon
  250. if (this.params.icon) {
  251. this.dom.icon = this._createIcon(this.params.icon);
  252. this.dom.popup.appendChild(this.dom.icon);
  253. }
  254. // Title
  255. if (this.params.title) {
  256. this.dom.title = document.createElement('h2');
  257. this.dom.title.className = `${PREFIX}title`;
  258. this.dom.title.textContent = this.params.title;
  259. this.dom.popup.appendChild(this.dom.title);
  260. }
  261. // Content (Text / HTML / Element)
  262. if (this.params.text || this.params.html || this.params.content) {
  263. this.dom.content = document.createElement('div');
  264. this.dom.content.className = `${PREFIX}content`;
  265. if (this.params.content) {
  266. // DOM Element or Selector
  267. let el = this.params.content;
  268. if (typeof el === 'string') el = document.querySelector(el);
  269. if (el instanceof Element) this.dom.content.appendChild(el);
  270. } else if (this.params.html) {
  271. this.dom.content.innerHTML = this.params.html;
  272. } else {
  273. this.dom.content.textContent = this.params.text;
  274. }
  275. this.dom.popup.appendChild(this.dom.content);
  276. }
  277. // Actions
  278. this.dom.actions = document.createElement('div');
  279. this.dom.actions.className = `${PREFIX}actions`;
  280. // Cancel Button
  281. if (this.params.showCancelButton) {
  282. this.dom.cancelBtn = document.createElement('button');
  283. this.dom.cancelBtn.className = `${PREFIX}button ${PREFIX}cancel`;
  284. this.dom.cancelBtn.textContent = this.params.cancelButtonText;
  285. this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
  286. this.dom.cancelBtn.onclick = () => this._close(false);
  287. this.dom.actions.appendChild(this.dom.cancelBtn);
  288. }
  289. // Confirm Button
  290. this.dom.confirmBtn = document.createElement('button');
  291. this.dom.confirmBtn.className = `${PREFIX}button ${PREFIX}confirm`;
  292. this.dom.confirmBtn.textContent = this.params.confirmButtonText;
  293. this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
  294. this.dom.confirmBtn.onclick = () => this._close(true);
  295. this.dom.actions.appendChild(this.dom.confirmBtn);
  296. this.dom.popup.appendChild(this.dom.actions);
  297. // Event Listeners
  298. if (this.params.closeOnClickOutside) {
  299. this.dom.overlay.addEventListener('click', (e) => {
  300. if (e.target === this.dom.overlay) {
  301. this._close(null); // Dismiss
  302. }
  303. });
  304. }
  305. document.body.appendChild(this.dom.overlay);
  306. // Animation
  307. requestAnimationFrame(() => {
  308. this.dom.overlay.classList.add('show');
  309. this._didOpen();
  310. });
  311. }
  312. _createIcon(type) {
  313. const icon = document.createElement('div');
  314. icon.className = `${PREFIX}icon ${type}`;
  315. // Allow per-popup icon sizing (overrides CSS default)
  316. if (this.params && this.params.iconSize) {
  317. try {
  318. const s = String(this.params.iconSize).trim();
  319. if (s) {
  320. icon.style.width = s;
  321. icon.style.height = s;
  322. }
  323. } catch {}
  324. }
  325. const svgNs = 'http://www.w3.org/2000/svg';
  326. const svg = document.createElementNS(svgNs, 'svg');
  327. svg.setAttribute('viewBox', '0 0 80 80');
  328. svg.setAttribute('aria-hidden', 'true');
  329. svg.setAttribute('focusable', 'false');
  330. const ring = document.createElementNS(svgNs, 'circle');
  331. ring.setAttribute('class', `${PREFIX}svg-ring ${PREFIX}svg-draw`);
  332. ring.setAttribute('cx', '40');
  333. ring.setAttribute('cy', '40');
  334. ring.setAttribute('r', '34');
  335. ring.setAttribute('stroke', 'currentColor');
  336. svg.appendChild(ring);
  337. const addPath = (d, extraClass) => {
  338. const p = document.createElementNS(svgNs, 'path');
  339. p.setAttribute('d', d);
  340. p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
  341. p.setAttribute('stroke', 'currentColor');
  342. svg.appendChild(p);
  343. return p;
  344. };
  345. if (type === 'success') {
  346. addPath('M23 41 L34.5 53 L58 29', `${PREFIX}svg-success`);
  347. } else if (type === 'error') {
  348. addPath('M28 28 L52 52', `${PREFIX}svg-error-left`);
  349. addPath('M52 28 L28 52', `${PREFIX}svg-error-right`);
  350. } else if (type === 'warning') {
  351. addPath('M40 20 L40 46', `${PREFIX}svg-warning-line`);
  352. const dot = document.createElementNS(svgNs, 'circle');
  353. dot.setAttribute('class', `${PREFIX}svg-dot`);
  354. dot.setAttribute('cx', '40');
  355. dot.setAttribute('cy', '58');
  356. dot.setAttribute('r', '3.2');
  357. dot.setAttribute('fill', 'currentColor');
  358. svg.appendChild(dot);
  359. } else if (type === 'info') {
  360. addPath('M40 34 L40 56', `${PREFIX}svg-info-line`);
  361. const dot = document.createElementNS(svgNs, 'circle');
  362. dot.setAttribute('class', `${PREFIX}svg-dot`);
  363. dot.setAttribute('cx', '40');
  364. dot.setAttribute('cy', '25');
  365. dot.setAttribute('r', '3.2');
  366. dot.setAttribute('fill', 'currentColor');
  367. svg.appendChild(dot);
  368. } else if (type === 'question') {
  369. // Question mark (single stroke + dot)
  370. addPath('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`);
  371. const dot = document.createElementNS(svgNs, 'circle');
  372. dot.setAttribute('class', `${PREFIX}svg-dot`);
  373. dot.setAttribute('cx', '42');
  374. dot.setAttribute('cy', '61');
  375. dot.setAttribute('r', '3.2');
  376. dot.setAttribute('fill', 'currentColor');
  377. svg.appendChild(dot);
  378. } else {
  379. // Fallback to info-like ring only
  380. }
  381. icon.appendChild(svg);
  382. return icon;
  383. }
  384. _didOpen() {
  385. // Keyboard close (ESC)
  386. if (this.params.closeOnEsc) {
  387. this._onKeydown = (e) => {
  388. if (!e) return;
  389. if (e.key === 'Escape') this._close(null);
  390. };
  391. document.addEventListener('keydown', this._onKeydown);
  392. }
  393. // Popup animation (optional)
  394. if (this.params.popupAnimation) {
  395. const X = Layer._getXjs();
  396. if (X && this.dom.popup) {
  397. // Override the CSS scale transition with a spring-ish entrance.
  398. try {
  399. this.dom.popup.style.transition = 'none';
  400. this.dom.popup.style.transform = 'scale(0.92)';
  401. X(this.dom.popup).animate({
  402. scale: [0.92, 1],
  403. y: [-6, 0],
  404. duration: 520,
  405. easing: { stiffness: 260, damping: 16 }
  406. });
  407. } catch {}
  408. }
  409. }
  410. // Icon SVG draw animation
  411. if (this.params.iconAnimation) {
  412. this._animateIcon();
  413. }
  414. // User hook (SweetAlert-ish naming)
  415. try {
  416. if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
  417. } catch {}
  418. }
  419. _animateIcon() {
  420. const icon = this.dom.icon;
  421. if (!icon) return;
  422. const X = Layer._getXjs();
  423. if (!X) return;
  424. const type = (this.params && this.params.icon) || '';
  425. try {
  426. // Entrance varies slightly by type (SweetAlert-like feel)
  427. if (type === 'error') {
  428. X(icon).animate({
  429. opacity: [0, 1],
  430. scale: [0.62, 1],
  431. rotate: [-10, 0],
  432. duration: 520,
  433. easing: { stiffness: 240, damping: 14 }
  434. });
  435. } else if (type === 'warning') {
  436. X(icon).animate({
  437. opacity: [0, 1],
  438. scale: [0.62, 1],
  439. y: [-10, 0],
  440. duration: 620,
  441. easing: { stiffness: 260, damping: 15 }
  442. });
  443. } else if (type === 'question') {
  444. X(icon).animate({
  445. opacity: [0, 1],
  446. scale: [0.62, 1],
  447. rotate: [8, 0],
  448. duration: 620,
  449. easing: { stiffness: 240, damping: 14 }
  450. });
  451. } else {
  452. X(icon).animate({
  453. opacity: [0, 1],
  454. scale: [0.66, 1],
  455. duration: 520,
  456. easing: { stiffness: 240, damping: 14 }
  457. });
  458. }
  459. } catch {}
  460. const svg = icon.querySelector('svg');
  461. if (!svg) return;
  462. const ring = svg.querySelector(`.${PREFIX}svg-ring`);
  463. const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
  464. const dot = svg.querySelector(`.${PREFIX}svg-dot`);
  465. // Draw ring first (timing varies by type)
  466. try {
  467. if (ring) {
  468. const ringDur = (type === 'success') ? 520 : (type === 'error') ? 420 : 560;
  469. const ringEase = (type === 'warning' || type === 'question') ? 'ease-in-out' : 'ease-out';
  470. X(ring).draw({ duration: ringDur, easing: ringEase, delay: 40 });
  471. }
  472. } catch {}
  473. if (type === 'error') {
  474. // Two lines + shake after draw
  475. const left = marks[0];
  476. const right = marks[1];
  477. try { if (left) X(left).draw({ duration: 320, easing: 'ease-out', delay: 170 }); } catch {}
  478. try { if (right) X(right).draw({ duration: 320, easing: 'ease-out', delay: 240 }); } catch {}
  479. try {
  480. X(icon).animate({
  481. rotate: [-10, 10],
  482. duration: 70,
  483. loop: 4,
  484. direction: 'alternate',
  485. easing: 'ease-in-out',
  486. delay: 360
  487. });
  488. } catch {}
  489. return;
  490. }
  491. // Single-mark types (success / warning / info / question)
  492. if (type === 'success') {
  493. const m = marks[0];
  494. try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
  495. try {
  496. if (ring) X(ring).animate({ strokeWidth: [4.5, 6], duration: 220, delay: 360, easing: { stiffness: 300, damping: 18 } });
  497. } catch {}
  498. } else if (type === 'warning') {
  499. const m = marks[0];
  500. try { if (m) X(m).draw({ duration: 380, easing: 'ease-in-out', delay: 190 }); } catch {}
  501. try {
  502. if (ring) X(ring).animate({ opacity: [1, 0.55], duration: 260, delay: 420, loop: 2, direction: 'alternate', easing: 'ease-in-out' });
  503. } catch {}
  504. } else if (type === 'info') {
  505. const m = marks[0];
  506. try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
  507. } else if (type === 'question') {
  508. const m = marks[0];
  509. try { if (m) X(m).draw({ duration: 520, easing: 'ease-in-out', delay: 170 }); } catch {}
  510. try {
  511. X(icon).animate({ rotate: [-6, 6], duration: 90, loop: 3, direction: 'alternate', easing: 'ease-in-out', delay: 420 });
  512. } catch {}
  513. } else {
  514. marks.forEach((m, i) => {
  515. try { X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 + i * 60 }); } catch {}
  516. });
  517. }
  518. if (dot) {
  519. try {
  520. dot.style.opacity = '0';
  521. if (type === 'warning') {
  522. X(dot).animate({
  523. opacity: [0, 1],
  524. scale: [0.2, 1],
  525. duration: 260,
  526. delay: 320,
  527. easing: { stiffness: 360, damping: 18 }
  528. });
  529. } else if (type === 'info') {
  530. X(dot).animate({
  531. opacity: [0, 1],
  532. y: [-8, 0],
  533. scale: [0.2, 1],
  534. duration: 420,
  535. delay: 260,
  536. easing: { stiffness: 300, damping: 14 }
  537. });
  538. } else if (type === 'question') {
  539. X(dot).animate({
  540. opacity: [0, 1],
  541. scale: [0.2, 1],
  542. duration: 360,
  543. delay: 360,
  544. easing: { stiffness: 320, damping: 16 }
  545. });
  546. } else {
  547. X(dot).animate({
  548. opacity: [0, 1],
  549. scale: [0.2, 1],
  550. duration: 320,
  551. delay: 280,
  552. easing: { stiffness: 320, damping: 18 }
  553. });
  554. }
  555. } catch {}
  556. }
  557. }
  558. _close(isConfirmed) {
  559. this.dom.overlay.classList.remove('show');
  560. setTimeout(() => {
  561. if (this.dom.overlay && this.dom.overlay.parentNode) {
  562. this.dom.overlay.parentNode.removeChild(this.dom.overlay);
  563. }
  564. }, 300);
  565. if (this._onKeydown) {
  566. try { document.removeEventListener('keydown', this._onKeydown); } catch {}
  567. this._onKeydown = null;
  568. }
  569. try {
  570. if (typeof this.params.willClose === 'function') this.params.willClose(this.dom.popup);
  571. } catch {}
  572. try {
  573. if (typeof this.params.didClose === 'function') this.params.didClose();
  574. } catch {}
  575. if (isConfirmed === true) {
  576. this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false });
  577. } else if (isConfirmed === false) {
  578. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'cancel' });
  579. } else {
  580. this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'backdrop' });
  581. }
  582. }
  583. }
  584. return Layer;
  585. })));