robert 15 órája
szülő
commit
72319d4e54
6 módosított fájl, 437 hozzáadás és 28 törlés
  1. 7 0
      Guide.md
  2. 8 14
      animal.js
  3. 2 0
      doc/index.html
  4. 7 0
      doc/layer/custom_list.html
  5. 347 0
      doc/layer/test_custom_animation_container_steps.html
  6. 66 14
      layer.js

+ 7 - 0
Guide.md

@@ -322,6 +322,13 @@ Layer.run({ title: 'Preview', dom: '#my_form', domMode: 'clone' });
 - 快捷:`Layer.flow([step1, step2], base)`
 - 容器式:`{ step: '#container', stepItem: '.item' }`
 - Steps 按钮图标:`nextIcon` / `prevIcon`
+- 容器式步骤 DOM 属性(挂在每个 stepItem 上):
+  - `data-step-title`:步骤标题
+  - `data-step-background`:背景(支持 `svg(...)` / `url(...)` / `css(...)`)
+  - `data-step-icon`:图标(支持 `svg:<name>`)
+  - `data-step-width` / `data-step-height`:弹窗尺寸
+  - `data-step-iwidth` / `data-step-iheight`:图标容器尺寸
+  - `data-step-theme`:`auto | light | dark`
 
 示例(链式 steps):
 

+ 8 - 14
animal.js

@@ -81,13 +81,12 @@
             };
           }
           
-          // Prefer builder API if present, fallback to static fire.
+          // Prefer builder API if present, fallback to static run.
           if (LayerCtor.$ && isFunc(LayerCtor.$)) {
             const inst = LayerCtor.$(opts);
-            return (inst.run ? inst.run() : inst.fire());
+            return inst.run ? inst.run() : undefined;
           }
           if (LayerCtor.run && isFunc(LayerCtor.run)) return LayerCtor.run(opts);
-          if (LayerCtor.fire && isFunc(LayerCtor.fire)) return LayerCtor.fire(opts);
         };
         
         if (_layerHandlerByEl) _layerHandlerByEl.set(el, handler);
@@ -111,7 +110,7 @@
 
     // jQuery-style click handler
     // - click(fn): bind handler with `this` as element
-    // - click(layerInstance): bind Layer instance to click (auto fire)
+    // - click(layerInstance): bind Layer instance to click (auto run)
     // - click(options): open Layer with options on click
     click(handler) {
       const LayerCtor =
@@ -123,12 +122,12 @@
         if (!isEl(el)) return;
         let cb = handler;
 
-        if (handler && (typeof handler.run === 'function' || typeof handler.fire === 'function')) {
+        if (handler && (typeof handler.run === 'function')) {
           // Layer instance: defer auto-fire and trigger on click
           try { handler._deferAuto = true; } catch {}
           cb = (e) => {
             try { handler._triggerEl = el; handler._triggerEvent = e; } catch {}
-            try { return handler.run ? handler.run() : handler.fire(); } catch {}
+            try { return handler.run ? handler.run() : undefined; } catch {}
           };
         } else if (!isFunc(handler)) {
           // Options object: open Layer on click
@@ -147,10 +146,9 @@
             }
             if (LayerCtor.$ && isFunc(LayerCtor.$)) {
               const inst = LayerCtor.$(finalOpts);
-              return (inst.run ? inst.run() : inst.fire());
+              return inst.run ? inst.run() : undefined;
             }
             if (LayerCtor.run && isFunc(LayerCtor.run)) return LayerCtor.run(finalOpts);
-            if (LayerCtor.fire && isFunc(LayerCtor.fire)) return LayerCtor.fire(finalOpts);
           };
         } else {
           cb = (e) => handler.call(el, e);
@@ -1439,12 +1437,10 @@
   // Allows $.run({ title: 'Hi' })
   animal.run = (options) => {
     const L = animal.Layer;
-    if (L) return (L.run ? L.run(options) : (L.fire ? L.fire(options) : new L(options).run()));
+    if (L) return (L.run ? L.run(options) : new L(options).run());
     console.warn('Layer module not loaded.');
     return Promise.reject('Layer module not loaded');
   };
-  // Backward-compatible alias
-  animal.fire = (options) => animal.run(options);
   // $.layer(): returns a Layer instance with auto-fire for simple popups
   animal.layer = (options) => {
     const L = animal.Layer;
@@ -1453,7 +1449,7 @@
       return Promise.reject('Layer module not loaded');
     }
     const inst = (L.$ && isFunc(L.$)) ? L.$(options) : (new L()).config(options);
-    const rawRun = inst.run ? inst.run.bind(inst) : (inst.fire ? inst.fire.bind(inst) : null);
+    const rawRun = inst.run ? inst.run.bind(inst) : null;
     if (rawRun) {
       inst.run = (opts) => {
         const p = rawRun(opts);
@@ -1485,8 +1481,6 @@
       if (!inst._autoPromise && rawRun) inst.run();
       return inst._autoPromise ? inst._autoPromise.finally(...args) : Promise.resolve().finally(...args);
     };
-    // Backward-compatible alias
-    if (!inst.fire && inst.run) inst.fire = inst.run.bind(inst);
     return inst;
   };
 

+ 2 - 0
doc/index.html

@@ -370,6 +370,7 @@
             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 === 'layer/test_custom_animation_container_steps.html') return 'layer/custom_list.html';
             if (u.startsWith('layer/')) return 'layer/list.html';
             if (u.startsWith('examples/')) return 'examples/list.html';
             return null;
@@ -548,6 +549,7 @@
             { 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: '容器定义混合类型动画', url: 'layer/test_custom_animation_container_steps.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

@@ -78,6 +78,13 @@
         <div class="card-footer">背景动画</div>
       </div>
 
+      <div class="card" data-content="layer/test_custom_animation_container_steps.html" onclick="selectItem('test_custom_animation_container_steps.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">step: "#container"</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>

+ 347 - 0
doc/layer/test_custom_animation_container_steps.html

@@ -0,0 +1,347 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer 容器定义混合类型动画</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;
+    }
+    .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; }
+    .hidden-dom { display: none; }
+    .step-card {
+      display: grid;
+      gap: 10px;
+      text-align: left;
+    }
+    .step-title {
+      font-weight: 650;
+      color: #1d1d1d;
+      font-size: 14px;
+    }
+    .step-desc {
+      color: #4d4d4d;
+      font-size: 13px;
+      line-height: 1.5;
+    }
+    .field label {
+      display: block;
+      font-size: 12px;
+      color: #666;
+      margin-bottom: 6px;
+    }
+    .field input, .field select {
+      width: 100%;
+      box-sizing: border-box;
+      padding: 10px 12px;
+      border-radius: 10px;
+      border: 1px solid rgba(0,0,0,0.12);
+      outline: none;
+      background: #fff;
+      color: #111;
+      font-size: 14px;
+    }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">CUSTOM ANIMATION</div>
+    </div>
+
+    <h1>容器定义混合类型动画</h1>
+    <p class="description">
+      使用 <code class="inline">step: '#container'</code> + <code class="inline">stepItem: '.item'</code>,
+      通过 DOM 属性定义每一步的 <code class="inline">background / icon / width / height / iwidth / iheight / theme</code>,
+      并展示 SVG 动画背景与 SVG 图标混合使用的效果。
+    </p>
+
+    <div class="box-container">
+      <div class="demo-visual">
+        <div class="controls" style="margin-bottom: 14px;">
+          <span>点击按钮触发容器式 Steps</span>
+        </div>
+        <div class="row">
+          <button class="btn" type="button" onclick="openContainerSteps()">Open container steps</button>
+        </div>
+        <div class="hint">
+          Tip: 每个 stepItem 通过 <code class="inline">data-step-*</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">function openContainerSteps() {
+  if (typeof window.$ !== 'function') return;
+
+  Layer.run({
+    step: '#mix_steps',
+    stepItem: '.mix-step',
+    title: 'Container steps (mixed animations)',
+    showCancelButton: true
+  }).then((res) =&gt; {
+    if (res.isConfirmed) {
+      Layer.run({
+        title: 'Collected data',
+        icon: 'success',
+        popupAnimation: false,
+        replace: true,
+        html: '&lt;pre&gt;' + JSON.stringify(res.data, null, 2) + '&lt;/pre&gt;'
+      });
+    }
+  });
+}</pre>
+
+      <pre id="html-code" class="html-view">&lt;button class="btn" type="button" onclick="openContainerSteps()"&gt;Open container steps&lt;/button&gt;
+
+&lt;div id="mix_steps" class="hidden-dom"&gt;
+  &lt;div class="mix-step"
+       data-step-title="Step 1: Banner icon"
+       data-step-icon="svg:banner"
+       data-step-iwidth="10em"
+       data-step-iheight="6em"
+       data-step-width="520"
+       data-step-height="260"
+       data-step-theme="light"&gt;
+    &lt;div class="step-card"&gt;
+      &lt;div class="step-title"&gt;Welcome&lt;/div&gt;
+      &lt;div class="step-desc"&gt;This step uses a Lottie SVG icon with custom icon box size.&lt;/div&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Your name&lt;/label&gt;
+        &lt;input name="name" placeholder="Input will be collected"&gt;
+      &lt;/div&gt;
+    &lt;/div&gt;
+  &lt;/div&gt;
+
+  &lt;div class="mix-step"
+       data-step-title="Step 2: SVG background"
+       data-step-background="svg(BallSorting.json)"
+       data-step-width="560"
+       data-step-height="280"
+       data-step-theme="light"&gt;
+    &lt;div class="step-card"&gt;
+      &lt;div class="step-title"&gt;Pick a plan&lt;/div&gt;
+      &lt;div class="step-desc"&gt;This step uses a Lottie background animation.&lt;/div&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Plan&lt;/label&gt;
+        &lt;select name="plan"&gt;
+          &lt;option value="free"&gt;Free&lt;/option&gt;
+          &lt;option value="pro"&gt;Pro&lt;/option&gt;
+          &lt;option value="team"&gt;Team&lt;/option&gt;
+        &lt;/select&gt;
+      &lt;/div&gt;
+    &lt;/div&gt;
+  &lt;/div&gt;
+
+  &lt;div class="mix-step"
+       data-step-title="Step 3: Mixed icon + css background"
+       data-step-icon="svg:BallSorting"
+       data-step-iwidth="8em"
+       data-step-iheight="8em"
+       data-step-background="css(background: linear-gradient(135deg, rgba(32, 32, 48, 0.9), rgba(10, 10, 12, 0.92));)"
+       data-step-width="520"
+       data-step-height="260"
+       data-step-theme="light"&gt;
+    &lt;div class="step-card"&gt;
+      &lt;div class="step-title"&gt;Confirm&lt;/div&gt;
+      &lt;div class="step-desc"&gt;Background and icon are both customized here.&lt;/div&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Note&lt;/label&gt;
+        &lt;input name="note" placeholder="Optional note"&gt;
+      &lt;/div&gt;
+    &lt;/div&gt;
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">.hidden-dom { display: none; }
+.step-card { display: grid; gap: 10px; text-align: left; }
+.step-title { font-weight: 650; color: #1d1d1d; font-size: 14px; }
+.step-desc { color: #4d4d4d; font-size: 13px; line-height: 1.5; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>容器式 steps 会根据 <code class="inline">data-step-*</code> 动态设置每一步的背景与图标,
+        并通过内置表单收集机制把 <code class="inline">input/select</code> 汇总到 <code class="inline">res.data</code>。
+      </div>
+    </div>
+
+    <!-- Hidden DOM container -->
+    <div id="mix_steps" class="hidden-dom">
+      <div class="mix-step"
+           data-step-title="Step 1: Banner icon"
+           data-step-icon="svg:banner"
+           data-step-iwidth="10em"
+           data-step-iheight="6em"
+           data-step-width="520"
+           data-step-height="260"
+           data-step-theme="light">
+        <div class="step-card">
+          <div class="step-title">Welcome</div>
+          <div class="step-desc">This step uses a Lottie SVG icon with custom icon box size.</div>
+          <div class="field">
+            <label>Your name</label>
+            <input name="name" placeholder="Input will be collected">
+          </div>
+        </div>
+      </div>
+
+      <div class="mix-step"
+           data-step-title="Step 2: SVG background"
+           data-step-background="svg(BallSorting.json)"
+           data-step-width="560"
+           data-step-height="280"
+           data-step-theme="light">
+        <div class="step-card">
+          <div class="step-title">Pick a plan</div>
+          <div class="step-desc">This step uses a Lottie background animation.</div>
+          <div class="field">
+            <label>Plan</label>
+            <select name="plan">
+              <option value="free">Free</option>
+              <option value="pro">Pro</option>
+              <option value="team">Team</option>
+            </select>
+          </div>
+        </div>
+      </div>
+
+      <div class="mix-step"
+           data-step-title="Step 3: Mixed icon + css background"
+           data-step-icon="svg:BallSorting"
+           data-step-iwidth="8em"
+           data-step-iheight="8em"
+           data-step-background="css(background: linear-gradient(135deg, rgba(32, 32, 48, 0.9), rgba(10, 10, 12, 0.92));)"
+           data-step-width="520"
+           data-step-height="260"
+           data-step-theme="light">
+        <div class="step-card">
+          <div class="step-title">Confirm</div>
+          <div class="step-desc">Background and icon are both customized here.</div>
+          <div class="field">
+            <label>Note</label>
+            <input name="note" placeholder="Optional note">
+          </div>
+        </div>
+      </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_container_steps.html';
+
+    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 openContainerSteps() {
+      if (typeof window.$ !== 'function') return;
+      Layer.run({
+        step: '#mix_steps',
+        stepItem: '.mix-step',
+        title: 'Container steps (mixed animations)',
+        showCancelButton: true
+      }).then((res) => {
+        if (res.isConfirmed) {
+          Layer.run({
+            title: 'Collected data',
+            icon: 'success',
+            popupAnimation: false,
+            replace: true,
+            html: '<pre>' + JSON.stringify(res.data, null, 2) + '</pre>'
+          });
+        }
+      });
+    }
+
+    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>

+ 66 - 14
layer.js

@@ -264,11 +264,8 @@
     // Static entry point
     static run(options) {
       const instance = new Layer();
-      return instance._fire(options);
+      return instance.run(options);
     }
-    // Backward-compatible alias
-    static fire(options) { return Layer.run(options); }
-    
     // Chainable entry point (builder-style)
     // Example:
     //   Layer.$({ title: 'Hi' }).run().then(...)
@@ -281,7 +278,7 @@
     
     // Chainable config helper (does not render until `.run()` is called)
     config(options = {}) {
-      // Support the same shorthand as Layer.fire(title, text, icon)
+      // Support the same shorthand as Layer.run(title, text, icon)
       options = normalizeOptions(options, arguments[1], arguments[2]);
       this.params = { ...(this.params || {}), ...options };
       return this;
@@ -334,8 +331,6 @@
 
       return this._fire(merged);
     }
-    // Backward-compatible alias
-    fire(options) { return this.run(options); }
 
     _fire(options = {}) {
       options = normalizeOptions(options, arguments[1], arguments[2]);
@@ -423,6 +418,27 @@
       return out;
     }
 
+    _parseStepSizeAttr(raw) {
+      if (raw === undefined || raw === null) return null;
+      const s = String(raw).trim();
+      if (!s) return null;
+      if (/^-?\d+(\.\d+)?$/.test(s)) return parseFloat(s);
+      return s;
+    }
+
+    _readStepAttr(item, container, names) {
+      const list = Array.isArray(names) ? names : [names];
+      const readFrom = (el) => {
+        if (!el || !el.getAttribute) return '';
+        for (let i = 0; i < list.length; i++) {
+          const v = el.getAttribute(list[i]);
+          if (v != null && String(v).trim() !== '') return String(v).trim();
+        }
+        return '';
+      };
+      return readFrom(item) || readFrom(container) || '';
+    }
+
     _normalizeStepContainer(options) {
       const opts = options || {};
       const raw = opts.step;
@@ -442,18 +458,38 @@
     _initFlowFromStepContainer(options) {
       const spec = this._normalizeStepContainer(options);
       if (!spec) return false;
-      const { items } = spec;
+      const { items, container } = spec;
       if (!items || !items.length) {
         try { console.warn('Layer step container has no items.'); } catch {}
         return false;
       }
       this._flowSteps = items.map((item) => {
-        const step = { dom: item };
+        const step = { dom: item, __fromContainer: true };
         try {
           const title =
             (item.getAttribute('data-step-title') || item.getAttribute('data-layer-title') || item.getAttribute('title') || '').trim();
           if (title) step.title = title;
         } catch {}
+        try {
+          const background = this._readStepAttr(item, container, ['data-step-background', 'data-layer-background', 'data-background']);
+          if (background) step.background = background;
+          const icon = this._readStepAttr(item, container, ['data-step-icon', 'data-layer-icon', 'data-icon']);
+          if (icon) step.icon = icon;
+          const theme = this._readStepAttr(item, container, ['data-step-theme', 'data-layer-theme', 'data-theme']);
+          if (theme) step.theme = theme;
+          const width = this._readStepAttr(item, container, ['data-step-width', 'data-layer-width', 'data-width']);
+          const height = this._readStepAttr(item, container, ['data-step-height', 'data-layer-height', 'data-height']);
+          const iwidth = this._readStepAttr(item, container, ['data-step-iwidth', 'data-layer-iwidth', 'data-iwidth']);
+          const iheight = this._readStepAttr(item, container, ['data-step-iheight', 'data-layer-iheight', 'data-iheight']);
+          const w = this._parseStepSizeAttr(width);
+          const h = this._parseStepSizeAttr(height);
+          const iw = this._parseStepSizeAttr(iwidth);
+          const ih = this._parseStepSizeAttr(iheight);
+          if (w != null) step.width = w;
+          if (h != null) step.height = h;
+          if (iw != null) step.iwidth = iw;
+          if (ih != null) step.iheight = ih;
+        } catch {}
         return step;
       });
       this._flowAutoForm = { enabled: true };
@@ -1797,19 +1833,35 @@
       const explicitIcon = ('icon' in step) ? step.icon : undefined;
       const baseIcon = ('icon' in base) ? base.icon : undefined;
       const chosenIcon = (explicitIcon !== undefined) ? explicitIcon : baseIcon;
+      const isContainerStep = !!(step && step.__fromContainer);
+      const allowContainerIcon = isContainerStep && explicitIcon !== undefined;
 
       if (!isSummary && !isLast) {
-        merged.icon = (chosenIcon === 'question') ? 'question' : null;
-        merged.iconAnimation = false;
+        if (allowContainerIcon) {
+          merged.icon = chosenIcon;
+        } else {
+          merged.icon = (chosenIcon === 'question') ? 'question' : null;
+          merged.iconAnimation = false;
+        }
       } else if (!isSummary && isLast) {
-        // last step: still suppress unless summary or question
-        merged.icon = (chosenIcon === 'question') ? 'question' : null;
-        merged.iconAnimation = false;
+        // last step: still suppress unless summary/question/container icon
+        if (allowContainerIcon) {
+          merged.icon = chosenIcon;
+        } else {
+          merged.icon = (chosenIcon === 'question') ? 'question' : null;
+          merged.iconAnimation = false;
+        }
       } else {
         // summary step: allow icon; animation follows explicit config (default true only if provided elsewhere)
         if (!('icon' in step) && chosenIcon === undefined) merged.icon = null;
       }
 
+      if (allowContainerIcon) {
+        if (!('iconAnimation' in step) && !('iconAnimation' in base)) {
+          merged.iconAnimation = true;
+        }
+      }
+
       return merged;
     }