diff --git a/.ua/backlog.json b/.ua/backlog.json index a6e6d0d..7c91fe9 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3248,6 +3248,15 @@ "motivation": "全站没有任何 ::selection 规则,用户选中文本时走浏览器默认选区——亮色下是系统蓝、暗色下是亮蓝底黑字,与 --ua-* 主题色系完全不搭,代码块 / diffView 这类大量复制的场景尤其刺眼;caret-color 已跟随 accent,选区却没有,两者不一致。", "acceptance": "① ui.css 新增全局 ::selection 规则,选区底色由既有 --ua-color-accent 经 color-mix 派生(低透明度,与焦点环同族),不新增任何硬编码颜色;② 亮 / 暗 / 高对比主题下选区均可辨认,audit:visual / audit:contrast / audit:surface 全绿;③ 文档(llms-full.txt 经 npm run docs 同步)令牌说明处补一句选区口径;④ npm run check 通过。", "notes": "" + }, + { + "id": "g318", + "domain": "tooling", + "status": "open", + "title": "配方 20:分步表单向导(步进条 + 按步校验)", + "motivation": "多步向导是后台最高频的表单形态之一,现有 19 条配方没有覆盖「步进条 + 分步字段 + 步间校验 + 回跳」的组合:stepper 的 active 双向绑定与 formItem 句柄的 validate() 恰好能拼出这个场景,缺一条示范怎么把校验拦在步与步之间,而不是最后一步集体报错。", + "acceptance": "① recipes.md 新增配方 20(编号连续、含代码块与要点,代码块能被 ua recipes 认出 stepper / formItem / button / card / toast 组件);② 配方代码与真实签名一致(FormRule.message、button disabled 收 Signal、formItem 句柄 validate);③ ua recipes 20 与 ua search 均能命中;④ npm run check 通过。", + "notes": "" } ] } diff --git a/llms-full.txt b/llms-full.txt index bfb1e4c..ae7d5f6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3327,6 +3327,42 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' }, - 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 +## 配方 20:分步表单向导(步进条 + 按步校验 + 完成反馈) + +```ts +const step = signal(0); // 当前步索引:stepper 与内容区共用一份信号 +const name = signal(''); const phone = signal(''); const remark = signal(''); +const account = formItem({ label: '账号名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name })); +const contact = formItem({ label: '手机号', required: true, value: phone, rules: [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }] }, textField({ value: phone })); +const note = formItem({ label: '备注', value: remark }, textarea({ value: remark })); +const pages = [[account], [contact, note], []]; // 每步的字段句柄,末页是完成页:推进校验与回跳重放都读这一份 +const body = h('div', {}); +effect(() => { body.replaceChildren(...pages[step()].map((f) => f.el)); }); // 步进即换页:只挂当前步的字段,末页自然留白 +const last = pages.length - 1; // 末步下标:步进条、边界钳制与提交判断共用 +const go = (delta) => { // delta>0 是「下一步」:先过当前步校验,不过就不换页 + if (delta > 0 && pages[step()].some((f) => !f.validate())) return; + step(Math.min(Math.max(step() + delta, 0), last)); +}; +card({ title: '开通向导' }, + stepper({ steps: [{ label: '账号' }, { label: '联系' }, { label: '完成' }], active: step, clickable: true }), + body, + button({ label: '上一步', variant: 'ghost', disabled: computed(() => step() === 0), onClick: () => go(-1) }), + button({ label: '下一步', onClick: () => { + if (step() === last) { toast.success('已提交开通申请'); step(0); return; } // 末步即提交:同步校验已在前两步拦过,直接收尾归零 + go(1); + } }), +); +``` + +要点: +- `stepper` 的 `active` 与字段容器读同一个 `step` 信号:点步骤条回跳、点「上一步」、提交后归零都只改这一个信号,不存在两份步号。 +- 「下一步」先对当前步的 formItem 句柄逐个 `validate()`,有失败项就停在原地标红——把校验拦在步与步之间,不要攒到最后一步一起报错,用户会迷失在哪一步填错。 +- `clickable: true` 只放行**已完成**步骤的回跳,点不存在的未来步骤不会生效,所以回跳不会绕过校验;回跳后再次「下一步」会重放该步校验,已填过的值不会丢(信号还在)。 +- 换页用 `body.replaceChildren()` 重挂当前步字段:formItem 句柄是稳定对象,重复挂载不会重建控件,信号值与错误态都保留;末页句柄数组为空,`replaceChildren()` 空参即清空,不用特判。 +- 「下一步」按钮的 label 是静态的,想在末步显示「提交」就用两个按钮按 `step()` 条件渲染(`h` 里读信号的函数会跟随重渲染);上面的示例为省篇幅在 onClick 里判末步。 +- 末步提交前若还有异步规则(如邀请码查重),改用 formItem 的 `validateAsync` 聚合 await,校验期间禁用提交按钮;纯同步表单 `validate()` 已足够。 +- 提交成功给 `toast.success` 反馈并把 `step` 归零,向导可以立即重开;要保留数据就别归零,改跳一个只读的完成页。 + ## 通用约定 - 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。 diff --git a/packages/tools/cli/recipes.md b/packages/tools/cli/recipes.md index 85140bd..e08639f 100644 --- a/packages/tools/cli/recipes.md +++ b/packages/tools/cli/recipes.md @@ -431,6 +431,42 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' }, - 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 +## 配方 20:分步表单向导(步进条 + 按步校验 + 完成反馈) + +```ts +const step = signal(0); // 当前步索引:stepper 与内容区共用一份信号 +const name = signal(''); const phone = signal(''); const remark = signal(''); +const account = formItem({ label: '账号名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name })); +const contact = formItem({ label: '手机号', required: true, value: phone, rules: [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }] }, textField({ value: phone })); +const note = formItem({ label: '备注', value: remark }, textarea({ value: remark })); +const pages = [[account], [contact, note], []]; // 每步的字段句柄,末页是完成页:推进校验与回跳重放都读这一份 +const body = h('div', {}); +effect(() => { body.replaceChildren(...pages[step()].map((f) => f.el)); }); // 步进即换页:只挂当前步的字段,末页自然留白 +const last = pages.length - 1; // 末步下标:步进条、边界钳制与提交判断共用 +const go = (delta) => { // delta>0 是「下一步」:先过当前步校验,不过就不换页 + if (delta > 0 && pages[step()].some((f) => !f.validate())) return; + step(Math.min(Math.max(step() + delta, 0), last)); +}; +card({ title: '开通向导' }, + stepper({ steps: [{ label: '账号' }, { label: '联系' }, { label: '完成' }], active: step, clickable: true }), + body, + button({ label: '上一步', variant: 'ghost', disabled: computed(() => step() === 0), onClick: () => go(-1) }), + button({ label: '下一步', onClick: () => { + if (step() === last) { toast.success('已提交开通申请'); step(0); return; } // 末步即提交:同步校验已在前两步拦过,直接收尾归零 + go(1); + } }), +); +``` + +要点: +- `stepper` 的 `active` 与字段容器读同一个 `step` 信号:点步骤条回跳、点「上一步」、提交后归零都只改这一个信号,不存在两份步号。 +- 「下一步」先对当前步的 formItem 句柄逐个 `validate()`,有失败项就停在原地标红——把校验拦在步与步之间,不要攒到最后一步一起报错,用户会迷失在哪一步填错。 +- `clickable: true` 只放行**已完成**步骤的回跳,点不存在的未来步骤不会生效,所以回跳不会绕过校验;回跳后再次「下一步」会重放该步校验,已填过的值不会丢(信号还在)。 +- 换页用 `body.replaceChildren()` 重挂当前步字段:formItem 句柄是稳定对象,重复挂载不会重建控件,信号值与错误态都保留;末页句柄数组为空,`replaceChildren()` 空参即清空,不用特判。 +- 「下一步」按钮的 label 是静态的,想在末步显示「提交」就用两个按钮按 `step()` 条件渲染(`h` 里读信号的函数会跟随重渲染);上面的示例为省篇幅在 onClick 里判末步。 +- 末步提交前若还有异步规则(如邀请码查重),改用 formItem 的 `validateAsync` 聚合 await,校验期间禁用提交按钮;纯同步表单 `validate()` 已足够。 +- 提交成功给 `toast.success` 反馈并把 `step` 归零,向导可以立即重开;要保留数据就别归零,改跳一个只读的完成页。 + ## 通用约定 - 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。