Przeglądaj źródła

简易的step layer

robert 4 godzin temu
rodzic
commit
3fe224f506
3 zmienionych plików z 313 dodań i 43 usunięć
  1. 81 35
      Guide.md
  2. 96 1
      doc/layer/test_dom_steps.html
  3. 136 7
      layer.js

+ 81 - 35
Guide.md

@@ -259,6 +259,25 @@ go run main.go build path/to/xjs.js
 
 ## 11. Layer 新增能力:DOM 内容 / Steps(分步弹窗)
 
+### 11.0 最新使用入口(推荐)
+
+- **主入口**:`$`(同时保留 `xjs/animal` 兼容别名)
+- **静态弹窗**:`Layer.run(options)` 或 `$.run(options)`
+- **链式弹窗**:`$.layer(options).step(...).run()`
+- **事件绑定**:`$('#btn').click(function () { /* this 为按钮 */ })`
+
+示例(点击触发):
+
+```js
+$('#openDialog').click(function () {
+  $.layer({
+    title: 'Hello',
+    text: 'Triggered by click',
+    showCancelButton: true
+  });
+});
+```
+
 ### 11.1 DOM 内容(显示指定 DOM,而不是一句话)
 
 Layer 现在支持把“页面里已有的 DOM(可默认隐藏)”挂载到弹窗内容区域展示:
@@ -275,11 +294,13 @@ Layer 现在支持把“页面里已有的 DOM(可默认隐藏)”挂载到
 // 页面中一个默认隐藏的 DOM
 // <div id="my_form" style="display:none">...</div>
 
-$.layer({
-  title: 'Edit profile',
-  dom: '#my_form',
-  showCancelButton: true,
-  confirmButtonText: 'Save'
+$('#openForm').click(function () {
+  $.layer({
+    title: 'Edit profile',
+    dom: '#my_form',
+    showCancelButton: true,
+    confirmButtonText: 'Save'
+  });
 });
 ```
 
@@ -292,39 +313,64 @@ Layer 支持“同一弹窗内”的步骤流:点击确认按钮不关闭,
 推荐写法(链式):
 
 ```js
-$.layer({
-  icon: 'info',
-  closeOnClickOutside: false,
-  closeOnEsc: false
-})
-.step({
-  title: 'Step 1',
-  dom: '#step1',
-  showCancelButton: true,
-  cancelButtonText: 'Cancel',
-  preConfirm(popup) {
-    // return false 阻止进入下一步(用于校验)
-    const v = popup.querySelector('input[name="name"]').value.trim();
-    if (!v) return false;
-    return { name: v };
-  }
-})
-.step({
-  title: 'Step 2',
-  dom: '#step2',
-  preConfirm(popup) {
-    return { plan: popup.querySelector('select[name="plan"]').value };
-  }
-})
-.run()
-.then((res) => {
-  if (res.isConfirmed) {
-    // res.value 是每一步 preConfirm 的返回值数组
-    console.log(res.value);
-  }
+$('#openWizard').click(function () {
+  $.layer({
+    icon: 'info',
+    closeOnClickOutside: false,
+    closeOnEsc: false
+  })
+  .step({
+    title: 'Step 1',
+    dom: '#step1',
+    showCancelButton: true,
+    cancelButtonText: 'Cancel',
+    preConfirm(popup) {
+      // return false 阻止进入下一步(用于校验)
+      const v = popup.querySelector('input[name="name"]').value.trim();
+      if (!v) return false;
+      return { name: v };
+    }
+  })
+  .step({
+    title: 'Step 2',
+    dom: '#step2',
+    preConfirm(popup) {
+      return { plan: popup.querySelector('select[name="plan"]').value };
+    }
+  })
+  .run()
+  .then((res) => {
+    if (res.isConfirmed) {
+      // res.value 是每一步 preConfirm 的返回值数组
+      console.log(res.value);
+    }
+  });
 });
 ```
 
+更简单的“容器式 steps”写法(适合表单收集):
+
+```js
+$('#step').click(function () {
+  $.layer({
+    step: '#stepContainer',      // 容器
+    stepItem: '.stepItem',       // 可选:没有则默认取容器的一级子元素
+    title: 'Create order',       // 其他 Layer 选项仍可用(作为 base)
+    showCancelButton: true
+  }).then((res) => {
+    if (res.isConfirmed) {
+      // res.data 为统一汇总的表单数据(来自 steps 内所有 input/select/textarea)
+      console.log(res.data);
+    }
+  });
+});
+```
+
+补充说明:
+
+- `stepItem` 不传时,会自动用 `#stepContainer` 的一级子元素作为每一步
+- 单步标题可由每个 step DOM 自身提供:`data-step-title` / `data-layer-title` / `title`
+
 也支持便捷写法:
 
 ```js

+ 96 - 1
doc/layer/test_dom_steps.html

@@ -87,6 +87,7 @@
         <div class="row">
           <button id="openDom" class="btn primary">Open DOM popup</button>
           <button id="openWizard" class="btn success">Open step wizard</button>
+          <button id="openStepContainer" class="btn">Open container steps</button>
         </div>
         <div class="hint">
           Tip: step wizard disables backdrop/ESC close to avoid accidental dismiss.
@@ -161,10 +162,31 @@ $('#openWizard').click(function () {
       });
     }
   });
+});
+
+// 3) Step container shorthand (auto steps + form data)
+$('#openStepContainer').click(function () {
+  $.layer({
+    step: '#step_container',
+    stepItem: '.stepItem',
+    title: 'Create task',
+    showCancelButton: true
+  }).then((res) =&gt; {
+    if (res.isConfirmed) {
+      $.layer({
+        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 primary" onclick="openDom()"&gt;Open DOM popup&lt;/button&gt;
 &lt;button class="btn success" onclick="openWizard()"&gt;Open step wizard&lt;/button&gt;
+&lt;button class="btn" onclick="openStepContainer()"&gt;Open container steps&lt;/button&gt;
 
 &lt;!-- Hidden DOM blocks (default display:none) --&gt;
 &lt;div id="demo_dom_block" class="hidden-dom"&gt;
@@ -201,6 +223,33 @@ $('#openWizard').click(function () {
     &lt;/div&gt;
     &lt;div class="hint"&gt;Confirm will finish the wizard.&lt;/div&gt;
   &lt;/div&gt;
+&lt;/div&gt;
+
+&lt;div id="step_container" class="hidden-dom"&gt;
+  &lt;div class="stepItem" data-step-title="Step 1: Basics"&gt;
+    &lt;div class="form"&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Project&lt;/label&gt;
+        &lt;input name="project" placeholder="Project name"&gt;
+      &lt;/div&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Owner&lt;/label&gt;
+        &lt;input name="owner" placeholder="Owner name"&gt;
+      &lt;/div&gt;
+    &lt;/div&gt;
+  &lt;/div&gt;
+  &lt;div class="stepItem" data-step-title="Step 2: Priority"&gt;
+    &lt;div class="form"&gt;
+      &lt;div class="field"&gt;
+        &lt;label&gt;Priority&lt;/label&gt;
+        &lt;select name="priority"&gt;
+          &lt;option value="low"&gt;Low&lt;/option&gt;
+          &lt;option value="normal"&gt;Normal&lt;/option&gt;
+          &lt;option value="high"&gt;High&lt;/option&gt;
+        &lt;/select&gt;
+      &lt;/div&gt;
+    &lt;/div&gt;
+  &lt;/div&gt;
 &lt;/div&gt;</pre>
 
       <pre id="css-code" class="css-view">/* This page hides DOM blocks by default:
@@ -211,7 +260,7 @@ then restore it back (and restore display/hidden state) on close. */</pre>
 
       <div class="feature-desc">
         <strong>功能说明:</strong>
-        <code class="inline">dom</code> 支持把指定 DOM(可默认隐藏)挂进弹窗并在关闭时还原;步骤流通过同一弹窗内平滑切换实现“分步填写”体验,最终 <code class="inline">res.value</code> 返回每一步的 <code class="inline">preConfirm</code> 结果数组。
+        <code class="inline">dom</code> 支持把指定 DOM(可默认隐藏)挂进弹窗并在关闭时还原;步骤流通过同一弹窗内平滑切换实现“分步填写”体验,最终 <code class="inline">res.value</code> 返回每一步的 <code class="inline">preConfirm</code> 结果数组。容器式 steps 会自动汇总表单数据到 <code class="inline">res.data</code>。
       </div>
     </div>
 
@@ -252,6 +301,33 @@ then restore it back (and restore display/hidden state) on close. */</pre>
       </div>
     </div>
 
+    <div id="step_container" class="hidden-dom">
+      <div class="stepItem" data-step-title="Step 1: Basics">
+        <div class="form">
+          <div class="field">
+            <label>Project</label>
+            <input name="project" placeholder="Project name">
+          </div>
+          <div class="field">
+            <label>Owner</label>
+            <input name="owner" placeholder="Owner name">
+          </div>
+        </div>
+      </div>
+      <div class="stepItem" data-step-title="Step 2: Priority">
+        <div class="form">
+          <div class="field">
+            <label>Priority</label>
+            <select name="priority">
+              <option value="low">Low</option>
+              <option value="normal">Normal</option>
+              <option value="high">High</option>
+            </select>
+          </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>
@@ -363,6 +439,25 @@ then restore it back (and restore display/hidden state) on close. */</pre>
           }
         });
       });
+
+      $('#openStepContainer').click(function () {
+        $.layer({
+          step: '#step_container',
+          stepItem: '.stepItem',
+          title: 'Create task',
+          showCancelButton: true
+        }).then((res) => {
+          if (res.isConfirmed) {
+            $.layer({
+              title: 'Collected data',
+              icon: 'success',
+              popupAnimation: false,
+              replace: true,
+              html: '<pre>' + JSON.stringify(res.data, null, 2) + '</pre>'
+            });
+          }
+        });
+      });
     }
 
     function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }

+ 136 - 7
layer.js

@@ -133,6 +133,7 @@
       this._flowBase = null; // base options merged into each step
       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
     }
 
     // Constructor helper when called as function: const popup = Layer({...})
@@ -188,16 +189,27 @@
     
     // Instance entry point (chainable)
     run(options) {
-      // Flow mode: if configured via .step()/.steps(), ignore per-call options and use steps
+      const hasArgs = (options !== undefined && options !== null);
+      const normalized = hasArgs ? normalizeOptions(options, arguments[1], arguments[2]) : null;
+      const merged = hasArgs ? normalized : (this.params || {});
+
+      // If no explicit steps yet, allow shorthand: { step: '#container', stepItem: '.item' }
+      if (!this._flowSteps || !this._flowSteps.length) {
+        const didInit = this._initFlowFromStepContainer(merged);
+        if (didInit) {
+          this.params = { ...(this.params || {}), ...this._stripStepOptions(merged) };
+        }
+      }
+
+      // Flow mode: if configured via .step()/.steps() or step container shorthand, ignore per-call options and use steps
       if (this._flowSteps && this._flowSteps.length) {
-        if (options !== undefined && options !== null) {
+        if (hasArgs) {
           // allow providing base options at fire-time
-          this.params = { ...(this.params || {}), ...normalizeOptions(options, arguments[1], arguments[2]) };
+          this.params = { ...(this.params || {}), ...this._stripStepOptions(merged) };
         }
         return this._fireFlow();
       }
 
-      const merged = (options === undefined) ? (this.params || {}) : options;
       return this._fire(merged);
     }
     // Backward-compatible alias
@@ -275,6 +287,50 @@
       return (typeof x === 'function') ? x : null;
     }
 
+    _stripStepOptions(options) {
+      const out = { ...(options || {}) };
+      try { delete out.step; } catch {}
+      try { delete out.stepItem; } catch {}
+      return out;
+    }
+
+    _normalizeStepContainer(options) {
+      const opts = options || {};
+      const raw = opts.step;
+      if (!raw || typeof document === 'undefined') return null;
+      const container = (typeof raw === 'string') ? document.querySelector(raw) : raw;
+      if (!container || (typeof Element !== 'undefined' && !(container instanceof Element))) return null;
+      const itemSelector = opts.stepItem;
+      let items = [];
+      if (typeof itemSelector === 'string' && itemSelector.trim()) {
+        try { items = Array.from(container.querySelectorAll(itemSelector)); } catch {}
+      } else {
+        try { items = Array.from(container.children || []); } catch {}
+      }
+      return { container, items };
+    }
+
+    _initFlowFromStepContainer(options) {
+      const spec = this._normalizeStepContainer(options);
+      if (!spec) return false;
+      const { items } = 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 };
+        try {
+          const title =
+            (item.getAttribute('data-step-title') || item.getAttribute('data-layer-title') || item.getAttribute('title') || '').trim();
+          if (title) step.title = title;
+        } catch {}
+        return step;
+      });
+      this._flowAutoForm = { enabled: true };
+      return true;
+    }
+
     _render(meta = null) {
       // Remove existing if any (but first, try to restore any mounted DOM from the previous instance)
       const existing = document.querySelector(`.${PREFIX}overlay`);
@@ -939,13 +995,86 @@
       } catch {}
 
       const value = (this._flowSteps && this._flowSteps.length) ? (this._flowValues || []) : undefined;
+      let data;
+      if (this._flowSteps && this._flowSteps.length && this._flowAutoForm && this._flowAutoForm.enabled) {
+        const root = (this.dom && (this.dom.stepStack || this.dom.content || this.dom.popup)) || null;
+        data = this._collectFormData(root);
+      }
       if (isConfirmed === true) {
-        this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false, value });
+        const payload = { isConfirmed: true, isDenied: false, isDismissed: false, value };
+        if (data !== undefined) payload.data = data;
+        this.resolve(payload);
       } else if (isConfirmed === false) {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'cancel', value });
+        const payload = { isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'cancel', value };
+        if (data !== undefined) payload.data = data;
+        this.resolve(payload);
       } else {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'backdrop', value });
+        const payload = { isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'backdrop', value };
+        if (data !== undefined) payload.data = data;
+        this.resolve(payload);
+      }
+    }
+
+    _assignFormValue(data, name, value, forceArray) {
+      if (!data || !name) return;
+      let key = name;
+      let asArray = !!forceArray;
+      if (key.endsWith('[]')) {
+        key = key.slice(0, -2);
+        asArray = true;
+      }
+      if (!(key in data)) {
+        data[key] = asArray ? [value] : value;
+        return;
+      }
+      if (Array.isArray(data[key])) {
+        data[key].push(value);
+        return;
       }
+      data[key] = [data[key], value];
+    }
+
+    _collectFormData(root) {
+      const data = {};
+      if (!root || !root.querySelectorAll) return data;
+      const fields = root.querySelectorAll('input, select, textarea');
+      fields.forEach((el) => {
+        try {
+          if (!el || el.disabled) return;
+          const name = (el.getAttribute('name') || '').trim();
+          if (!name) return;
+          const tag = (el.tagName || '').toLowerCase();
+          if (tag === 'select') {
+            if (el.multiple) {
+              const values = Array.from(el.options || []).filter((o) => o.selected).map((o) => o.value);
+              values.forEach((v) => this._assignFormValue(data, name, v, true));
+            } else {
+              this._assignFormValue(data, name, el.value, false);
+            }
+            return;
+          }
+          if (tag === 'textarea') {
+            this._assignFormValue(data, name, el.value, false);
+            return;
+          }
+          const type = (el.getAttribute('type') || 'text').toLowerCase();
+          if (type === 'radio') {
+            if (el.checked) this._assignFormValue(data, name, el.value, false);
+            return;
+          }
+          if (type === 'checkbox') {
+            if (el.checked) this._assignFormValue(data, name, el.value, true);
+            return;
+          }
+          if (type === 'file') {
+            const files = el.files ? Array.from(el.files) : [];
+            this._assignFormValue(data, name, files, true);
+            return;
+          }
+          this._assignFormValue(data, name, el.value, false);
+        } catch {}
+      });
+      return data;
     }
 
     _normalizeDomSpec(params) {