From 5601e59cf4b796fe444d2cf4c60270ab7e6aaa01 Mon Sep 17 00:00:00 2001 From: HIsheR Date: Mon, 26 Oct 2026 14:01:07 +0800 Subject: [PATCH] =?UTF-8?q?iter(g108):=20=E8=A1=A8=E5=8D=95=E8=A1=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=B1=87=E6=80=BB=E6=9D=A1=E4=B8=8E=20onErro?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 8 ++++++++ .ua/index.json | 13 ++++++++++++- apps/demo/main.ts | 2 +- llms-full.txt | 4 +++- packages/skin/ui/src/form.ts | 13 +++++++++++-- packages/skin/ui/src/types.ts | 2 ++ packages/skin/ui/ua.manifest.json | 11 +++++++++++ packages/skin/ui/ui.css | 13 +++++++++++++ 8 files changed, 61 insertions(+), 5 deletions(-) diff --git a/.ua/backlog.json b/.ua/backlog.json index 9e0f78e..0436251 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1220,6 +1220,14 @@ "acceptance": "PopoverProps 新增受控 open 信号(不传行为不变);新增 popconfirm(props, trigger):title/description/confirmText/cancelText/variant/placement/onConfirm/onCancel/open,确定先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」;组件 58→59,manifest/docs/README/AGENTS/ARCHITECTURE/demo 同步;npm run check 全绿", "notes": "自补:组件补充;复用 popover + button,不重复实现定位与 Esc 栈", "status": "done" + }, + { + "id": "g108", + "title": "表单提交失败给汇总条与回调", + "motivation": "提交失败时只把焦点扔到第一处错误:视觉上用户得自己数还有几处红框,读屏用户更是只听到某个输入框的 aria-invalid,完全不知道总共有几项要改;调用方也拿不到「这次失败了几项」做埋点或顶部提示", + "acceptance": "form 提交时统计不通字段数,顶部渲染 role=alert 的汇总条「有 N 项需要修改,已定位到第一处」(通过则隐藏);FormProps 新增 onError(count) 与 errorSummary(缺省 true,传 false 不插节点);文档参数表与演示说明同步;npm run check 全绿", + "notes": "自补:无障碍与可用性;汇总条与逐项红框同源,均由一次提交的校验结果推导", + "status": "open" } ] } diff --git a/.ua/index.json b/.ua/index.json index 5b34d20..305cb88 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T05:59:36.078Z", + "generatedAt": "2026-10-26T06:00:53.659Z", "repoRoot": ".", "packages": [ { @@ -1754,6 +1754,17 @@ "n": "onReset", "t": "() => void", "d": "重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮" + }, + { + "n": "onError", + "t": "(count: number) => void", + "k": 1, + "d": "校验不通过时的回调,参数为不通字段数(适合埋点或顶部提示)" + }, + { + "n": "errorSummary", + "t": "boolean", + "d": "是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false" } ], "example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])", diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 479047c..6e4c626 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -437,7 +437,7 @@ const content = h( section( 'sec-formlab', '表单校验', - 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。', + 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处,onError 拿到不通字段数。', form( { submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, [ diff --git a/llms-full.txt b/llms-full.txt index beee31d..f7d44d0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T05:59:36.078Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T06:00:53.659Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -913,6 +913,8 @@ const open = signal(false); drawer({ title: '详情', open, width: 360, onClose: - (formItem)validateOn(默认 blur):'input' | 'blur' | 'submit' —— 校验时机;缺省 blur(输入时自动清已报错误) - (formItem)hint:string —— 控件下方辅助说明(格式提示等);报错时让位给错误文案 - onReset:() => void —— 重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮 +- onError(回调):(count: number) => void —— 校验不通过时的回调,参数为不通字段数(适合埋点或顶部提示) +- errorSummary:boolean —— 是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false 示例: diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts index d3ca38f..0bbccce 100644 --- a/packages/skin/ui/src/form.ts +++ b/packages/skin/ui/src/form.ts @@ -98,6 +98,8 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit' }) as HTMLButtonElement; // 走原生 submit,避免 click + submit 双触发 const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', type: 'button', onClick: props.onReset }) : null; // 重置不得提交表单 const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn); + const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改 + summary.hidden = true; let running = false; // 提交中标记:异步校验期间屏蔽重复提交 /*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/ @@ -106,19 +108,26 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { running = true; submitBtn.disabled = true; // 校验期间禁用,避免连点重复提交 let firstBad: HTMLElement | null = null; + let bad = 0; // 不通过的字段数 for (const item of items) { - if (!(await item.validateAsync()) || firstBad) continue; // 首错之前的项才记焦点目标 + const msg = await item.validateAsync(); + if (!msg) continue; // 卫语句:本项通过 + bad += 1; + if (firstBad) continue; // 只把首个错误控件留作焦点目标 firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null; } submitBtn.disabled = false; running = false; - if (firstBad) { firstBad.focus(); return; } // 卫语句:有错误不提交 + summary.hidden = bad === 0; + summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : ''; + if (firstBad) { firstBad.focus(); props.onError?.(bad); return; } // 卫语句:有错误不提交 props.onSubmit?.(); }; const el = h( 'form', { class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' }, + props.errorSummary === false ? null : summary, // 汇总条:关掉时连节点都不插 ...items.map((i) => i.el), actions, ); diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index a5b1b6a..3d96260 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -393,6 +393,8 @@ export interface FormProps { resetText?: string; // 重置按钮文案;缺省不渲染重置按钮 onSubmit?: () => void; // 全部字段校验通过后的提交回调 onReset?: () => void; // 重置回调 + onError?: (count: number) => void; // 校验不通过的回调,参数为不通字段数(适合埋点 / 顶部提示) + errorSummary?: boolean; // 是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true } /*** 时间线条目 ***/ diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index d44aab7..aff93b1 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1625,6 +1625,17 @@ "n": "onReset", "t": "() => void", "d": "重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮" + }, + { + "n": "onError", + "t": "(count: number) => void", + "k": 1, + "d": "校验不通过时的回调,参数为不通字段数(适合埋点或顶部提示)" + }, + { + "n": "errorSummary", + "t": "boolean", + "d": "是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false" } ], "example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])", diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 6e4c997..7aea6c2 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -3738,3 +3738,16 @@ button.ua-step-dot:hover { justify-content: flex-end; gap: var(--ua-space-2); } + +/* —— 表单错误汇总条 —— */ +.ua-form-summary { + padding: var(--ua-space-2) var(--ua-space-3); + border: 1px solid color-mix(in srgb, var(--ua-color-danger) 45%, transparent); + border-radius: var(--ua-radius-sm); + background: color-mix(in srgb, var(--ua-color-danger) 10%, transparent); + color: var(--ua-color-danger); + font-size: var(--ua-font-size-md); +} +.ua-form-summary[hidden] { + display: none; +}