Ver código fonte

弹出层宽度高度

robert 5 horas atrás
pai
commit
eee857885c
6 arquivos alterados com 802 adições e 5 exclusões
  1. 67 0
      Guide.md
  2. 2 0
      doc/index.html
  3. 7 0
      doc/layer/custom_list.html
  4. 407 0
      doc/layer/test_custom_animation_background.html
  5. 290 1
      layer.js
  6. 29 4
      xjs.css

+ 67 - 0
Guide.md

@@ -383,3 +383,70 @@ Layer.flow([
 ], { icon: 'info' });
 ```
 
+### 11.3 背景动画 / 背景图案(Layer popup 背景)
+
+Layer 现支持为 `layer-popup` 设置整体背景(可与 `icon` 同时使用):
+
+- **Lottie SVG**:`background: 'svg(BallSorting.json)'`
+- **图片**:`background: 'url("/images/xxx.jpg")'`
+- **CSS**:`background: 'css(filter: saturate(1.1); background: linear-gradient(...))'`
+
+说明:
+
+- 背景会铺满弹窗区域(100% 宽高)
+- 不设置 `icon` 时不会显示 icon 行
+- 每个 step 或单独的 layer 都可设置 `background`
+- Steps 模式下:**背景不一致时**随内容左右切换,**背景一致时**仅切换内容
+
+示例(单层):
+
+```js
+$.layer({
+  title: 'Background demo',
+  text: 'Popup background',
+  background: 'svg(BallSorting.json)',
+  icon: 'svg:BallSorting'
+});
+```
+
+示例(Steps):
+
+```js
+$.layer({
+  title: 'Step 1',
+  text: 'Background A',
+  background: 'svg(BallSorting.json)',
+  showCancelButton: true
+})
+.step({
+  title: 'Step 2',
+  text: 'Background B',
+  background: 'url("/images/hero.jpg")'
+})
+.run();
+```
+
+### 11.4 弹窗尺寸(宽/高)
+
+Layer 支持直接指定弹窗宽高:
+
+- `width`:宽度(例如 `320` / `'320px'` / `'24em'`)
+- `height`:高度(例如 `240` / `'240px'` / `'40vh'`)
+
+说明:
+
+- 不设置时遵循 `xjs.css` 的默认自适应(`fit-content` + `min-width`)
+- 设置任一尺寸后,**该尺寸优先**,超出区域会自动出现对应滚动条(可能双向)
+ - 传入数字时默认按 `px` 处理;字符串可用任意 CSS 单位(`px`/`em`/`rem`/`vw`/`vh`/`%` 等)
+
+示例:
+
+```js
+$.layer({
+  title: 'Fixed size',
+  text: 'Popup with fixed size',
+  width: 360,
+  height: 220
+});
+```
+

+ 2 - 0
doc/index.html

@@ -369,6 +369,7 @@
             if (u === 'layer/test_custom_animation.html') return 'layer/custom_list.html';
             if (u === 'layer/test_custom_animation_banner.html') return 'layer/custom_list.html';
             if (u === 'layer/test_custom_animation_ballsorting.html') return 'layer/custom_list.html';
+            if (u === 'layer/test_custom_animation_background.html') return 'layer/custom_list.html';
             if (u.startsWith('layer/')) return 'layer/list.html';
             if (u.startsWith('examples/')) return 'examples/list.html';
             return null;
@@ -546,6 +547,7 @@
             { group: 'Layer', title: '自定义动画', url: 'layer/test_custom_animation.html' },
             { group: 'Layer', title: '自定义动画(Banner)', url: 'layer/test_custom_animation_banner.html' },
             { group: 'Layer', title: '自定义动画(BallSorting)', url: 'layer/test_custom_animation_ballsorting.html' },
+            { group: 'Layer', title: '背景动画', url: 'layer/test_custom_animation_background.html' },
             { group: 'Layer', title: 'SVG Icon animation', url: 'layer/test_icons_svg_animation.html' },
             { group: 'Layer', title: 'Confirm flow', url: 'layer/test_confirm_flow.html' },
             { group: 'Layer', title: 'Selection.layer + dataset', url: 'layer/test_selection_layer_dataset.html' },

+ 7 - 0
doc/layer/custom_list.html

@@ -71,6 +71,13 @@
         <div class="card-footer">Banner 动画示例</div>
       </div>
 
+      <div class="card" data-content="layer/test_custom_animation_background.html" onclick="selectItem('test_custom_animation_background.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">background: "svg(BallSorting.json)"</div>
+        </div>
+        <div class="card-footer">背景动画</div>
+      </div>
+
       <div class="card" data-content="layer/test_custom_animation_ballsorting.html" onclick="selectItem('test_custom_animation_ballsorting.html', this)">
         <div class="card-preview">
           <div class="preview-pill">icon: "svg:BallSorting"</div>

+ 407 - 0
doc/layer/test_custom_animation_background.html

@@ -0,0 +1,407 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer 背景动画 - Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <link rel="stylesheet" href="../../xjs.css">
+  <style>
+    /* Keep Layer above the doc UI */
+    .layer-overlay { z-index: 99999; }
+  </style>
+  <style>
+    .controls {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px 12px;
+      align-items: center;
+      font-size: 13px;
+      color: #b7b7b7;
+    }
+    .controls label {
+      display: inline-flex;
+      gap: 8px;
+      align-items: center;
+      cursor: pointer;
+      user-select: none;
+    }
+    .controls input { accent-color: var(--highlight-color); }
+    .row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .btn {
+      appearance: none;
+      border: 1px solid rgba(255,255,255,0.12);
+      background: rgba(255,255,255,0.04);
+      color: #fff;
+      border-radius: 999px;
+      padding: 10px 14px;
+      font-weight: 650;
+      cursor: pointer;
+      transition: border-color 0.15s ease, background 0.15s ease;
+    }
+    .btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+    .demo-visual { padding: 22px 24px; }
+    .hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+    .split { margin-top: 10px; width: 100%; height: 1px; background: rgba(255,255,255,0.08); }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">BACKGROUND ANIMATION</div>
+    </div>
+
+    <h1>背景动画 / 背景图案</h1>
+    <p class="description">
+      使用 <code class="inline">background</code> 设置 <code class="inline">layer-popup</code> 的整体背景,
+      支持三种形式:<code class="inline">svg(...)</code> / <code class="inline">url(...)</code> / <code class="inline">css(...)</code>。
+      在 Steps 模式下,当背景不同会随内容左右切换;相同背景则保持不动,仅切换内容。
+    </p>
+
+    <div class="box-container">
+      <div class="demo-visual">
+        <div class="controls" style="margin-bottom: 14px;">
+          <label><input id="optIcon" type="checkbox" checked> showIcon</label>
+          <label><input id="optPopup" type="checkbox" checked> popupAnimation</label>
+          <label><input id="optEsc" type="checkbox" checked> closeOnEsc</label>
+        </div>
+        <div class="row">
+          <button class="btn" type="button" onclick="openSingle('svg')">SVG 背景</button>
+          <button class="btn" type="button" onclick="openSingle('image')">图片背景</button>
+          <button class="btn" type="button" onclick="openSingle('css')">CSS 背景</button>
+        </div>
+        <div class="row" style="margin-top: 12px;">
+          <button class="btn" type="button" onclick="openSingle('svg', 'compact')">SVG + 紧凑尺寸</button>
+          <button class="btn" type="button" onclick="openSingle('image', 'wide')">图片 + 宽屏尺寸</button>
+          <button class="btn" type="button" onclick="openSingle('css', 'tall')">CSS + 高屏尺寸</button>
+        </div>
+        <div class="split"></div>
+        <div class="row" style="margin-top: 12px;">
+          <button class="btn" type="button" onclick="openSteps('same')">Steps(相同背景)</button>
+          <button class="btn" type="button" onclick="openSteps('diff')">Steps(不同背景)</button>
+        </div>
+        <div class="hint">
+          Tip: Lottie 背景从 <code class="inline">/svg</code> 目录读取 JSON,示例为 <code class="inline">svg(BallSorting.json)</code>。
+        </div>
+      </div>
+
+      <div class="box-header">
+        <div class="box-title">Controls</div>
+        <div class="tabs">
+          <div class="tab active" onclick="switchTab('js')">JavaScript</div>
+          <div class="tab" onclick="switchTab('html')">HTML</div>
+          <div class="tab" onclick="switchTab('css')">CSS</div>
+        </div>
+      </div>
+
+      <pre id="js-code" class="code-view active">const BACKGROUNDS = {
+  svg: 'svg(BallSorting.json)',
+  image: 'url("https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&amp;fit=crop&amp;w=900&amp;q=60")',
+  css: 'css(background: linear-gradient(135deg, rgba(255,255,255,0.15), rgba(0,0,0,0.25)); filter: saturate(1.2))'
+};
+
+const SIZE_PRESETS = {
+  normal: null,
+  compact: { width: 340, height: 220 },
+  wide: { width: '60vw', height: 260 },
+  tall: { width: 360, height: '60vh' }
+};
+
+function openSingle(type, sizeKey) {
+  const showIcon = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+  const size = SIZE_PRESETS[sizeKey] || null;
+
+  $.layer({
+    title: 'Background: ' + type.toUpperCase(),
+    text: 'Layer popup background demo.',
+    background: BACKGROUNDS[type],
+    width: size ? size.width : null,
+    height: size ? size.height : null,
+    icon: showIcon ? 'svg:BallSorting' : null,
+    popupAnimation,
+    closeOnEsc
+  });
+}
+
+function openSteps(mode) {
+  const showIcon = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+  const same = BACKGROUNDS.svg;
+  const a = BACKGROUNDS.svg;
+  const b = BACKGROUNDS.image;
+
+  $.layer({
+    title: 'Step 1',
+    text: 'Background should follow when different.',
+    background: mode === 'same' ? same : a,
+    icon: showIcon ? 'info' : null,
+    popupAnimation,
+    closeOnEsc,
+    showCancelButton: true
+  })
+  .step({
+    title: 'Step 2',
+    text: 'Swipe left/right to see background transition.',
+    background: mode === 'same' ? same : b
+  })
+  .step({
+    title: 'Step 3',
+    text: 'Back to the first background.',
+    background: mode === 'same' ? same : a
+  })
+  .run();
+}</pre>
+
+      <pre id="html-code" class="html-view">&lt;div class="demo-visual"&gt;
+  &lt;div class="controls" style="margin-bottom: 14px;"&gt;
+    &lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; showIcon&lt;/label&gt;
+    &lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+    &lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+  &lt;/div&gt;
+  &lt;div class="row"&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('svg')"&gt;SVG 背景&lt;/button&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('image')"&gt;图片背景&lt;/button&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('css')"&gt;CSS 背景&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="row" style="margin-top: 12px;"&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('svg', 'compact')"&gt;SVG + 紧凑尺寸&lt;/button&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('image', 'wide')"&gt;图片 + 宽屏尺寸&lt;/button&gt;
+    &lt;button class="btn" type="button" onclick="openSingle('css', 'tall')"&gt;CSS + 高屏尺寸&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="split"&gt;&lt;/div&gt;
+  &lt;div class="row" style="margin-top: 12px;"&gt;
+    &lt;button class="btn" type="button" onclick="openSteps('same')"&gt;Steps(相同背景)&lt;/button&gt;
+    &lt;button class="btn" type="button" onclick="openSteps('diff')"&gt;Steps(不同背景)&lt;/button&gt;
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">/* Keep Layer above the doc UI */
+.layer-overlay { z-index: 99999; }
+
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 12px;
+  align-items: center;
+  font-size: 13px;
+  color: #b7b7b7;
+}
+.controls label {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  cursor: pointer;
+  user-select: none;
+}
+.controls input { accent-color: var(--highlight-color); }
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+}
+.btn {
+  appearance: none;
+  border: 1px solid rgba(255,255,255,0.12);
+  background: rgba(255,255,255,0.04);
+  color: #fff;
+  border-radius: 999px;
+  padding: 10px 14px;
+  font-weight: 650;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+.demo-visual { padding: 22px 24px; }
+.hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+.split { margin-top: 10px; width: 100%; height: 1px; background: rgba(255,255,255,0.08); }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>本页验证 Layer 的 <code class="inline">background</code> 支持
+        Lottie SVG / 图片 / CSS 三种形式,并在 Steps 中按方向切换背景。
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" id="prevLink" onclick="goPrev(); return false;">
+        <span><span class="nav-label">Previous</span><br><span class="nav-title" id="prevTitle">—</span></span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div class="nav-center" id="navCenter">Layer</div>
+      <a href="#" id="nextLink" onclick="goNext(); return false;">
+        <span><span class="nav-label">Next</span><br><span class="nav-title" id="nextTitle">—</span></span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <script src="../highlight_css.js"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
+  <script>
+    (function () {
+      var b = Date.now();
+      document.write('<script src="../../animal.js?_=' + b + '"><\/script>');
+      document.write('<script src="../../layer.js?_=' + b + '"><\/script>');
+      document.write('<script>try{if(window.animal&&!window.xjs)window.xjs=window.animal;}catch(e){}<\/script>');
+    })();
+  </script>
+  <script>
+    const CURRENT = 'layer/test_custom_animation_background.html';
+    const BACKGROUNDS = {
+      svg: 'svg(BallSorting.json)',
+      image: 'url("https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=60")',
+      css: 'css(background: linear-gradient(135deg, rgba(255,255,255,0.15), rgba(0,0,0,0.25)); filter: saturate(1.2))'
+    };
+    const SIZE_PRESETS = {
+      normal: null,
+      compact: { width: 340, height: 220 },
+      wide: { width: '60vw', height: 260 },
+      tall: { width: 360, height: '60vh' }
+    };
+
+    function switchTab(tab) {
+      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+      document.querySelectorAll('.code-view, .html-view, .css-view').forEach(v => v.classList.remove('active'));
+      if (tab === 'js') {
+        document.querySelector('.tabs .tab:nth-child(1)')?.classList.add('active');
+        document.getElementById('js-code')?.classList.add('active');
+      } else if (tab === 'html') {
+        document.querySelector('.tabs .tab:nth-child(2)')?.classList.add('active');
+        document.getElementById('html-code')?.classList.add('active');
+      } else {
+        document.querySelector('.tabs .tab:nth-child(3)')?.classList.add('active');
+        document.getElementById('css-code')?.classList.add('active');
+      }
+    }
+
+    function setJsCode(code) {
+      const jsPre = document.getElementById('js-code');
+      if (!jsPre) return;
+      jsPre.textContent = code;
+      try { delete jsPre.dataset.highlighted; } catch {}
+      try { jsPre.dataset.highlighted = '0'; } catch {}
+      if (typeof window.__highlightCssViews === 'function') {
+        window.__highlightCssViews();
+      }
+    }
+
+    function openSingle(type, sizeKey) {
+      if (typeof window.$ !== 'function') return;
+      const showIcon = !!document.getElementById('optIcon')?.checked;
+      const popupAnimation = !!document.getElementById('optPopup')?.checked;
+      const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+      const size = SIZE_PRESETS[sizeKey] || null;
+
+      const code = `$.layer({
+  title: 'Background: ${type.toUpperCase()}',
+  text: 'Layer popup background demo.',
+  background: '${BACKGROUNDS[type]}',
+  width: ${size ? JSON.stringify(size.width) : 'null'},
+  height: ${size ? JSON.stringify(size.height) : 'null'},
+  icon: ${showIcon ? "'svg:BallSorting'" : 'null'},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc}
+});`;
+      setJsCode(code);
+
+      $.layer({
+        title: 'Background: ' + type.toUpperCase(),
+        text: 'Layer popup background demo.',
+        background: BACKGROUNDS[type],
+        width: size ? size.width : null,
+        height: size ? size.height : null,
+        icon: showIcon ? 'svg:BallSorting' : null,
+        popupAnimation,
+        closeOnEsc
+      });
+    }
+
+    function openSteps(mode) {
+      if (typeof window.$ !== 'function') return;
+      const showIcon = !!document.getElementById('optIcon')?.checked;
+      const popupAnimation = !!document.getElementById('optPopup')?.checked;
+      const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+      const same = BACKGROUNDS.svg;
+      const a = BACKGROUNDS.svg;
+      const b = BACKGROUNDS.image;
+
+      const code = `$.layer({
+  title: 'Step 1',
+  text: 'Background should follow when different.',
+  background: ${mode === 'same' ? 'same' : 'a'},
+  icon: ${showIcon ? "'info'" : 'null'},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc},
+  showCancelButton: true
+})
+.step({
+  title: 'Step 2',
+  text: 'Swipe left/right to see background transition.',
+  background: ${mode === 'same' ? 'same' : 'b'}
+})
+.step({
+  title: 'Step 3',
+  text: 'Back to the first background.',
+  background: ${mode === 'same' ? 'same' : 'a'}
+})
+.run();`;
+      setJsCode(code.replace(/\b(same|a|b)\b/g, (m) => {
+        if (m === 'same') return `'${same}'`;
+        if (m === 'a') return `'${a}'`;
+        return `'${b}'`;
+      }));
+
+      $.layer({
+        title: 'Step 1',
+        text: 'Background should follow when different.',
+        background: mode === 'same' ? same : a,
+        icon: showIcon ? 'info' : null,
+        popupAnimation,
+        closeOnEsc,
+        showCancelButton: true
+      })
+      .step({
+        title: 'Step 2',
+        text: 'Swipe left/right to see background transition.',
+        background: mode === 'same' ? same : b
+      })
+      .step({
+        title: 'Step 3',
+        text: 'Back to the first background.',
+        background: mode === 'same' ? same : a
+      })
+      .run();
+    }
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    function syncNavLabels() {
+      const api = window.parent?.docGetPrevNext;
+      if (typeof api !== 'function') return;
+      const { prev, next, current } = api(CURRENT) || {};
+      const prevLink = document.getElementById('prevLink');
+      const nextLink = document.getElementById('nextLink');
+      if (prev) document.getElementById('prevTitle').textContent = prev.title || prev.url;
+      else { prevLink.style.visibility = 'hidden'; }
+      if (next) document.getElementById('nextTitle').textContent = next.title || next.url;
+      else { nextLink.style.visibility = 'hidden'; }
+      document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
+    }
+
+    try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
+    syncNavLabels();
+  </script>
+</body>
+</html>

+ 290 - 1
layer.js

@@ -39,6 +39,8 @@
   const RING_TYPES = new Set(['success', 'error', 'warning', 'info', 'question']);
   const RING_BG_PARTS_SELECTOR = `.${PREFIX}success-circular-line-left, .${PREFIX}success-circular-line-right, .${PREFIX}success-fix`;
   const LOTTIE_PREFIX = 'svg:';
+  const BG_SVG_PREFIX = 'svg(';
+  const BG_CSS_PREFIX = 'css(';
 
   const normalizeOptions = (options, text, icon) => {
     if (typeof options !== 'string') return options || {};
@@ -128,6 +130,13 @@
     if (name === 'loadding') name = 'loading';
     return name;
   };
+  const normalizeLottieName = (raw) => {
+    let name = String(raw || '').trim();
+    if (!name) return '';
+    if (name.startsWith(LOTTIE_PREFIX)) name = getLottieIconName(name);
+    if (name === 'loadding') name = 'loading';
+    return name;
+  };
   const getLayerScriptBase = () => {
     try {
       if (typeof document === 'undefined') return '';
@@ -222,6 +231,7 @@
       this._flowResolved = null; // resolved merged steps
       this._flowMountedList = []; // mounted DOM records for move-mode steps
       this._flowAutoForm = null; // { enabled: true } when using step container shorthand
+      this._backgroundSpec = null;
     }
 
     // Constructor helper when called as function: const popup = Layer({...})
@@ -319,6 +329,9 @@
         closeOnEsc: true,
         iconAnimation: true,
         popupAnimation: true,
+        background: null,
+        width: null,
+        height: null,
         nextIcon: null, // flow-only: icon for Next button
         prevIcon: null, // flow-only: icon for Back button
         // Content:
@@ -475,6 +488,10 @@
       // Create Popup
       this.dom.popup = el('div', `${PREFIX}popup`);
       this.dom.overlay.appendChild(this.dom.popup);
+      this._applyPopupSize(this.params);
+
+      // Background (full popup)
+      this._applyBackgroundForOptions(this.params, { force: true });
 
       // Flow mode: pre-mount all steps and switch by hide/show (DOM continuity, less jitter)
       if (meta && meta.flow) {
@@ -580,6 +597,11 @@
       // Clear popup
       while (popup.firstChild) popup.removeChild(popup.firstChild);
 
+      this._applyPopupSize(this.params);
+
+      // Background (full popup)
+      this._applyBackgroundForOptions(this.params, { force: true });
+
       // Icon (in flow: suppressed by default unless question or summary)
       this.dom.icon = null;
       if (this.params.icon) {
@@ -837,6 +859,266 @@
       return icon;
     }
 
+    _normalizeSizeValue(v) {
+      if (v === undefined || v === null) return null;
+      if (typeof v === 'number' && Number.isFinite(v)) return v + 'px';
+      if (typeof v === 'string') {
+        const s = v.trim();
+        return s ? s : null;
+      }
+      return null;
+    }
+
+    _applyPopupSize(options) {
+      const popup = this.dom && this.dom.popup;
+      if (!popup) return;
+      const w = this._normalizeSizeValue(options && options.width);
+      const h = this._normalizeSizeValue(options && options.height);
+      if (!w && !h) {
+        popup.style.width = '';
+        popup.style.height = '';
+        popup.style.overflow = '';
+        return;
+      }
+      if (w) popup.style.width = w;
+      else popup.style.width = '';
+      if (h) popup.style.height = h;
+      else popup.style.height = '';
+      popup.style.overflow = 'auto';
+    }
+
+    _normalizeBackgroundSpec(raw) {
+      if (!raw || typeof raw !== 'string') return null;
+      const s = raw.trim();
+      if (!s) return null;
+      if (s.startsWith(BG_SVG_PREFIX) && s.endsWith(')')) {
+        const name = normalizeLottieName(s.slice(BG_SVG_PREFIX.length, -1));
+        if (!name) return null;
+        return { type: 'svg', name, key: `svg:${name}` };
+      }
+      if (s.startsWith(LOTTIE_PREFIX)) {
+        const name = normalizeLottieName(s);
+        if (!name) return null;
+        return { type: 'svg', name, key: `svg:${name}` };
+      }
+      if (/^url\(/i.test(s)) {
+        return { type: 'image', value: s, key: s };
+      }
+      if (s.startsWith(BG_CSS_PREFIX) && s.endsWith(')')) {
+        const css = s.slice(BG_CSS_PREFIX.length, -1).trim();
+        if (!css) return null;
+        return { type: 'css', css, key: `css:${css}` };
+      }
+      // Fallback: treat as raw css string
+      return { type: 'css', css: s, key: `css:${s}` };
+    }
+
+    _isSameBackgroundSpec(a, b) {
+      if (!a && !b) return true;
+      if (!a || !b) return false;
+      return a.key === b.key;
+    }
+
+    _insertBackgroundEl(bgEl) {
+      const popup = this.dom && this.dom.popup;
+      if (!popup || !bgEl) return;
+      if (popup.firstChild) popup.insertBefore(bgEl, popup.firstChild);
+      else popup.appendChild(bgEl);
+    }
+
+    _createBackgroundEl(spec) {
+      const bg = el('div', `${PREFIX}bg`);
+      bg.dataset.bgKey = spec.key;
+      if (spec.type === 'image') {
+        bg.style.backgroundImage = spec.value;
+      } else if (spec.type === 'css') {
+        try { bg.style.cssText += ';' + spec.css; } catch {}
+      } else if (spec.type === 'svg') {
+        const container = el('div', `${PREFIX}lottie`);
+        bg.appendChild(container);
+        this._initLottieBackground(bg, spec.name);
+      }
+      return bg;
+    }
+
+    _applyBackgroundForOptions(options, { force = false } = {}) {
+      const popup = this.dom && this.dom.popup;
+      if (!popup) return;
+      const spec = this._normalizeBackgroundSpec(options && options.background);
+      if (!spec) {
+        if (this.dom.background) {
+          this._destroyLottieBackground(this.dom.background);
+          try { this.dom.background.remove(); } catch {}
+        }
+        this.dom.background = null;
+        this._backgroundSpec = null;
+        popup.style.background = '';
+        return;
+      }
+      if (!force && this._isSameBackgroundSpec(this._backgroundSpec, spec)) return;
+      if (this.dom.background) {
+        this._destroyLottieBackground(this.dom.background);
+        try { this.dom.background.remove(); } catch {}
+      }
+      const bgEl = this._createBackgroundEl(spec);
+      this.dom.background = bgEl;
+      this._backgroundSpec = spec;
+      popup.style.background = 'transparent';
+      this._insertBackgroundEl(bgEl);
+    }
+
+    async _transitionBackground(nextSpec, direction) {
+      const popup = this.dom && this.dom.popup;
+      if (!popup) return;
+      if (this._isSameBackgroundSpec(this._backgroundSpec, nextSpec)) return;
+
+      const fromEl = this.dom.background;
+      let toEl = null;
+      if (nextSpec) {
+        toEl = this._createBackgroundEl(nextSpec);
+        this._insertBackgroundEl(toEl);
+        popup.style.background = 'transparent';
+      } else {
+        popup.style.background = '';
+      }
+
+      const isNext = direction !== 'prev';
+      const enterFrom = isNext ? 100 : -100;
+      const exitTo = isNext ? -100 : 100;
+      const duration = 320;
+
+      if (toEl) {
+        toEl.style.transform = `translateX(${enterFrom}%)`;
+        toEl.style.opacity = '0.2';
+      }
+      if (fromEl) {
+        fromEl.style.transform = 'translateX(0%)';
+        fromEl.style.opacity = '1';
+      }
+
+      let fromAnim = null;
+      let toAnim = null;
+      try {
+        if (fromEl && fromEl.animate) {
+          fromAnim = fromEl.animate(
+            [
+              { transform: 'translateX(0%)', opacity: 1 },
+              { transform: `translateX(${exitTo}%)`, opacity: 0.1 }
+            ],
+            { duration, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
+          );
+        }
+      } catch {}
+      try {
+        if (toEl && toEl.animate) {
+          toAnim = toEl.animate(
+            [
+              { transform: `translateX(${enterFrom}%)`, opacity: 0.2 },
+              { transform: 'translateX(0%)', opacity: 1 }
+            ],
+            { duration, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
+          );
+        }
+      } catch {}
+
+      try {
+        await Promise.all([
+          fromAnim && fromAnim.finished ? fromAnim.finished.catch(() => {}) : Promise.resolve(),
+          toAnim && toAnim.finished ? toAnim.finished.catch(() => {}) : Promise.resolve()
+        ]);
+      } catch {}
+
+      if (fromEl) {
+        this._destroyLottieBackground(fromEl);
+        try { fromEl.remove(); } catch {}
+      }
+      if (toEl) {
+        toEl.style.transform = '';
+        toEl.style.opacity = '';
+      }
+      this.dom.background = toEl;
+      this._backgroundSpec = nextSpec || null;
+    }
+
+    _destroyLottieBackground(bgEl) {
+      if (!bgEl) return;
+      const inst = bgEl._lottieInstance;
+      if (inst && typeof inst.destroy === 'function') {
+        try { inst.destroy(); } catch {}
+      }
+      try { bgEl._lottieInstance = null; } catch {}
+    }
+
+    _initLottieBackground(bgEl, name) {
+      if (!bgEl) return;
+      const container = bgEl.querySelector(`.${PREFIX}lottie`);
+      if (!container) return;
+      const cleanName = normalizeLottieName(name);
+      if (!cleanName) return;
+
+      const url = resolveLottieJsonUrl(cleanName);
+      if (!url) return;
+
+      const showError = (msg) => {
+        try {
+          container.textContent = String(msg || '');
+          container.classList.add(`${PREFIX}lottie-error`);
+        } catch {}
+      };
+
+      const loadData = () => new Promise((resolve, reject) => {
+        try {
+          if (typeof fetch === 'function') {
+            fetch(url).then((res) => {
+              if (!res || !res.ok) throw new Error('fetch failed');
+              return res.json();
+            }).then(resolve).catch(reject);
+            return;
+          }
+          const xhr = new XMLHttpRequest();
+          xhr.open('GET', url, true);
+          xhr.responseType = 'json';
+          xhr.onload = () => {
+            if (xhr.status >= 200 && xhr.status < 300) {
+              const data = xhr.response || (xhr.responseText ? JSON.parse(xhr.responseText) : null);
+              resolve(data);
+            } else {
+              reject(new Error('xhr failed'));
+            }
+          };
+          xhr.onerror = () => reject(new Error('xhr failed'));
+          xhr.send();
+        } catch (e) {
+          reject(e);
+        }
+      });
+
+      const boot = async () => {
+        const lottie = await ensureLottieLib();
+        if (!lottie) {
+          showError('Lottie lib not available');
+          return;
+        }
+        try {
+          const data = await loadData();
+          if (!data) throw new Error('empty json');
+          try { container.textContent = ''; } catch {}
+          const anim = lottie.loadAnimation({
+            container,
+            renderer: 'svg',
+            loop: true,
+            autoplay: true,
+            animationData: data
+          });
+          bgEl._lottieInstance = anim;
+        } catch (e) {
+          showError('Lottie load failed');
+        }
+      };
+
+      boot();
+    }
+
     _adjustRingBackgroundColor() {
       try {
         const icon = this.dom && this.dom.icon;
@@ -1014,6 +1296,7 @@
 
     _forceDestroy(reason = 'replace') {
       try { this._destroyLottieIcon(); } catch {}
+      try { this._destroyLottieBackground(this.dom && this.dom.background); } catch {}
       // Restore mounted DOM (if any) and cleanup listeners; also resolve the promise so it doesn't hang.
       try {
         if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
@@ -1034,6 +1317,7 @@
       if (this._isClosing) return;
       this._isClosing = true;
       try { this._destroyLottieIcon(); } catch {}
+      try { this._destroyLottieBackground(this.dom && this.dom.background); } catch {}
 
       const shouldDelayUnmount = !!(
         (this._mounted && this._mounted.kind === 'move') ||
@@ -1579,6 +1863,10 @@
         if (this.dom.title) this.dom.title.textContent = this.params.title || '';
       } catch {}
 
+      const nextBgSpec = this._normalizeBackgroundSpec(this.params && this.params.background);
+      const bgTransition = this._transitionBackground(nextBgSpec, direction);
+      this._applyPopupSize(this.params);
+
       // Icon updates (only if needed)
       try {
         const wantsIcon = !!this.params.icon;
@@ -1662,7 +1950,8 @@
         await Promise.all([
           heightAnim && heightAnim.finished ? heightAnim.finished.catch(() => {}) : Promise.resolve(),
           fromAnim && fromAnim.finished ? fromAnim.finished.catch(() => {}) : Promise.resolve(),
-          toAnim && toAnim.finished ? toAnim.finished.catch(() => {}) : Promise.resolve()
+          toAnim && toAnim.finished ? toAnim.finished.catch(() => {}) : Promise.resolve(),
+          bgTransition && typeof bgTransition.then === 'function' ? bgTransition.catch(() => {}) : Promise.resolve()
         ]);
       } catch {}
 

+ 29 - 4
xjs.css

@@ -28,14 +28,16 @@
   background: #fff;
   border-radius: 8px;
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-  width: 32em;
-  max-width: 90%;
+  width: fit-content;
+  min-width: var(--layer-min-width, 30em);
+  max-width: 90vw;
   padding: 1.5em;
   display: flex;
   flex-direction: column;
   align-items: center;
   position: relative;
   z-index: 1;
+  overflow: hidden;
   transform: scale(0.92);
   transition: transform 0.26s ease;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -43,7 +45,29 @@
   --layer-confirm-hover: #2b77c0;
   --layer-cancel-bg: #444;
   --layer-cancel-hover: #333;
-  background-image: url("https://i0.wp.com/picjumbo.com/wp-content/uploads/crazy-iridescent-smoke-abstract-background-free-image.jpeg?w=600&quality=80");
+}
+.layer-popup > :not(.layer-bg) {
+  position: relative;
+  z-index: 1;
+}
+.layer-bg {
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 100% 100%;
+  pointer-events: none;
+}
+.layer-bg .layer-lottie {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+.layer-bg .layer-lottie svg {
+  width: 100%;
+  height: 100%;
+  display: block;
 }
 .layer-overlay.show .layer-popup {
   transform: scale(1);
@@ -61,7 +85,8 @@
   margin-bottom: 1.5em;
   text-align: center;
   line-height: 1.5;
-  width: 100%;
+  width: auto;
+  max-width: 100%;
 }
 .layer-actions {
   display: flex;