iter(g344): 错误汇总条随字段校验实时重算
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "ui-arch-registry-index/0.1",
|
"$schema": "ui-arch-registry-index/0.1",
|
||||||
"generatedAt": "2026-10-26T18:24:21.676Z",
|
"generatedAt": "2026-10-26T18:27:12.002Z",
|
||||||
"repoRoot": ".",
|
"repoRoot": ".",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
@@ -2054,7 +2054,7 @@
|
|||||||
{
|
{
|
||||||
"n": "errorSummary",
|
"n": "errorSummary",
|
||||||
"t": "boolean",
|
"t": "boolean",
|
||||||
"d": "是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false"
|
"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 }))])",
|
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||||
|
|||||||
+1
-1
@@ -585,7 +585,7 @@ const content = h(
|
|||||||
section(
|
section(
|
||||||
'sec-formlab',
|
'sec-formlab',
|
||||||
'表单校验',
|
'表单校验',
|
||||||
'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。错误文案带唯一 id 并被 aria-describedby 关联到控件,读屏除了「无效」还会念出具体原因。点「重置」逐项复位:原生输入框回初始值、错误态与汇总条一起清空,自绘控件(城市下拉)靠 formItem 的 reset 钩子回写信号。校验失败只把控件描红,危险色光环聚焦时才亮——常驻红环会让整片字段看着像一起报错。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。',
|
'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。错误文案带唯一 id 并被 aria-describedby 关联到控件,读屏除了「无效」还会念出具体原因。点「重置」逐项复位:原生输入框回初始值、错误态与汇总条一起清空,自绘控件(城市下拉)靠 formItem 的 reset 钩子回写信号。校验失败只把控件描红,危险色光环聚焦时才亮——常驻红环会让整片字段看着像一起报错;汇总条的条数随字段校验实时重算,改好一项就少一项、全好即消失。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。',
|
||||||
form(
|
form(
|
||||||
{ columns: 2, submitText: '注册', resetText: '重置', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) },
|
{ columns: 2, submitText: '注册', resetText: '重置', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) },
|
||||||
[
|
[
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||||
|
|
||||||
> 自动生成于 2026-10-26T18:24:21.676Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
> 自动生成于 2026-10-26T18:27:12.002Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||||
|
|
||||||
## 快速上手
|
## 快速上手
|
||||||
@@ -996,7 +996,7 @@ const open = signal(false); drawer({ title: '详情', open, width: 360, onClose:
|
|||||||
- (formItem)reset:() => void —— 自绘控件的复位钩子:select / dataTable 这类没有原生 input 的控件拿不到值,由调用方在这里回写信号;原生输入框由 formItem 自己还原,不用管
|
- (formItem)reset:() => void —— 自绘控件的复位钩子:select / dataTable 这类没有原生 input 的控件拿不到值,由调用方在这里回写信号;原生输入框由 formItem 自己还原,不用管
|
||||||
- onReset:() => void —— 重置按钮点击回调;组件已先逐项复位(原生控件回初始值、错误态与汇总条清空),此处只做额外清理;不传 resetText 不渲染按钮
|
- onReset:() => void —— 重置按钮点击回调;组件已先逐项复位(原生控件回初始值、错误态与汇总条清空),此处只做额外清理;不传 resetText 不渲染按钮
|
||||||
- onError(回调):(count: number) => void —— 校验不通过时的回调,参数为不通字段数(适合埋点或顶部提示)
|
- onError(回调):(count: number) => void —— 校验不通过时的回调,参数为不通字段数(适合埋点或顶部提示)
|
||||||
- errorSummary:boolean —— 是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false
|
- errorSummary:boolean —— 是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false。条数随字段校验实时重算,改好一项就少一项,全部通过后自动消失
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,8 @@
|
|||||||
import { effect, h } from '@ui-arch/frame';
|
import { effect, h } from '@ui-arch/frame';
|
||||||
import { button } from './button.js';
|
import { button } from './button.js';
|
||||||
|
import { checkRule, checkRuleAsync } from './formRules.js';
|
||||||
import { grid } from './grid.js';
|
import { grid } from './grid.js';
|
||||||
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
|
import type { FormItemHandle, FormItemProps, FormProps } from './types.js';
|
||||||
|
|
||||||
/*** 执行规则的同步部分(required / min / max / pattern);null 通过,字符串为错误消息 ***/
|
|
||||||
function syncPart(value: string, rule: FormRule): string | null {
|
|
||||||
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
|
||||||
if (!value) return null; // 非必填且为空:其余规则跳过
|
|
||||||
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
|
||||||
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
|
||||||
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*** 同步求值单条规则:validator 返回 Promise 时视为待定(交给异步版),null 通过 ***/
|
|
||||||
function checkRule(value: string, rule: FormRule): string | null {
|
|
||||||
const syncMsg = syncPart(value, rule);
|
|
||||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
|
||||||
const r = rule.validator(value);
|
|
||||||
if (typeof r === 'object' && r !== null) return null; // Promise:异步规则,此处不下结论
|
|
||||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
|
||||||
}
|
|
||||||
|
|
||||||
/*** 异步求值单条规则:await validator,同步返回值的规则结论与同步版一致 ***/
|
|
||||||
async function checkRuleAsync(value: string, rule: FormRule): Promise<string | null> {
|
|
||||||
const syncMsg = syncPart(value, rule);
|
|
||||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
|
||||||
const r = await rule.validator(value);
|
|
||||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
|
||||||
}
|
|
||||||
|
|
||||||
let uid = 0; // 字段序号:错误区 / 辅助说明的 id 必须全页唯一,否则多表单时读屏关联到别人家的文案
|
let uid = 0; // 字段序号:错误区 / 辅助说明的 id 必须全页唯一,否则多表单时读屏关联到别人家的文案
|
||||||
|
|
||||||
@@ -71,6 +45,7 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
|
|||||||
if (described) node.setAttribute('aria-describedby', described); // 只说「无效」不够,要把原因一起念出来
|
if (described) node.setAttribute('aria-describedby', described); // 只说「无效」不够,要把原因一起念出来
|
||||||
else node.removeAttribute('aria-describedby'); // 无文案可念时摘掉,别留一个指空的关联
|
else node.removeAttribute('aria-describedby'); // 无文案可念时摘掉,别留一个指空的关联
|
||||||
}
|
}
|
||||||
|
el.dispatchEvent(new CustomEvent('ua:field-validated')); // 不冒泡:只让归属本字段的表单重算汇总条
|
||||||
};
|
};
|
||||||
|
|
||||||
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
|
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
|
||||||
@@ -131,6 +106,17 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
|||||||
// —— 状态集中声明 ——
|
// —— 状态集中声明 ——
|
||||||
const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改
|
const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改
|
||||||
summary.hidden = true;
|
summary.hidden = true;
|
||||||
|
let quiet = false; // 重算中标记:validate → paint → 事件 → 重算 会自激,必须挡住
|
||||||
|
/*** 汇总条实时重算:字段改了错误数要跟着掉,否则「有 2 项需要修改」会一直挂到下次提交 ***/
|
||||||
|
const refreshSummary = (): void => {
|
||||||
|
if (quiet || summary.hidden) return; // 卫语句:只有条子已经亮着才需要刷新
|
||||||
|
quiet = true;
|
||||||
|
const bad = items.filter((item) => item.validate()).length; // 同步校验足够:异步规则本就到提交时才下结论
|
||||||
|
quiet = false;
|
||||||
|
summary.hidden = bad === 0;
|
||||||
|
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
|
||||||
|
};
|
||||||
|
for (const item of items) item.el.addEventListener('ua:field-validated', refreshSummary); // 逐字段订阅
|
||||||
/*** 重置:逐项复位 + 收起汇总条,最后交给调用方回调(只跑回调等于点了没反应) ***/
|
/*** 重置:逐项复位 + 收起汇总条,最后交给调用方回调(只跑回调等于点了没反应) ***/
|
||||||
const doReset = (): void => {
|
const doReset = (): void => {
|
||||||
for (const item of items) item.reset();
|
for (const item of items) item.reset();
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { FormRule } from './types.js';
|
||||||
|
|
||||||
|
/*** 执行规则的同步部分(required / min / max / pattern);null 通过,字符串为错误消息 ***/
|
||||||
|
export function syncPart(value: string, rule: FormRule): string | null {
|
||||||
|
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||||
|
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||||
|
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||||
|
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||||
|
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 同步求值单条规则:validator 返回 Promise 时视为待定(交给异步版),null 通过 ***/
|
||||||
|
export function checkRule(value: string, rule: FormRule): string | null {
|
||||||
|
const syncMsg = syncPart(value, rule);
|
||||||
|
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||||
|
const r = rule.validator(value);
|
||||||
|
if (typeof r === 'object' && r !== null) return null; // Promise:异步规则,此处不下结论
|
||||||
|
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 异步求值单条规则:await validator,同步返回值的规则结论与同步版一致 ***/
|
||||||
|
export async function checkRuleAsync(value: string, rule: FormRule): Promise<string | null> {
|
||||||
|
const syncMsg = syncPart(value, rule);
|
||||||
|
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||||
|
const r = await rule.validator(value);
|
||||||
|
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||||
|
}
|
||||||
@@ -1928,7 +1928,7 @@
|
|||||||
{
|
{
|
||||||
"n": "errorSummary",
|
"n": "errorSummary",
|
||||||
"t": "boolean",
|
"t": "boolean",
|
||||||
"d": "是否在表单顶部显示「有 N 项需要修改」汇总条;缺省 true,自带错误 UI 时传 false"
|
"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 }))])",
|
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||||
|
|||||||
@@ -1726,6 +1726,21 @@ function checkInvalidRing(pkgs) {
|
|||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*** 校验汇总条实时性:条子只在提交时算一次,用户改好字段后「有 3 项需要修改」会一直骗人 ***/
|
||||||
|
function checkFormSummary(pkgs) {
|
||||||
|
const issues = [];
|
||||||
|
for (const pkg of pkgs) {
|
||||||
|
if (pkg.type !== 'skin-dom') continue;
|
||||||
|
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
|
||||||
|
if (!fs.existsSync(formPath)) continue;
|
||||||
|
const src = fs.readFileSync(formPath, 'utf8');
|
||||||
|
if (!/dispatchEvent\(new CustomEvent\('ua:field-validated'\)\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 的字段落笔没有通知:表单算不出实时错误数`);
|
||||||
|
if (!/addEventListener\('ua:field-validated', refreshSummary\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 没订阅字段校验事件:汇总条会停在提交那一刻的数字`);
|
||||||
|
if (!/if \(quiet \|\| summary\.hidden\) return;/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重算缺自激防护:validate → paint → 事件 → 再 validate 会无限递归`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
|
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
|
||||||
function checkSynonyms(ids) {
|
function checkSynonyms(ids) {
|
||||||
const issues = [];
|
const issues = [];
|
||||||
@@ -1811,6 +1826,7 @@ function cmdVerify() {
|
|||||||
issues.push(...checkFormErrorLink(pkgs)); // 表单错误文案必须被控件关联
|
issues.push(...checkFormErrorLink(pkgs)); // 表单错误文案必须被控件关联
|
||||||
issues.push(...checkFormReset(pkgs)); // 重置按钮必须真复位
|
issues.push(...checkFormReset(pkgs)); // 重置按钮必须真复位
|
||||||
issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮
|
issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮
|
||||||
|
issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算
|
||||||
issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在
|
issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在
|
||||||
checkDarkTokens(issues);
|
checkDarkTokens(issues);
|
||||||
checkTokenParity(issues);
|
checkTokenParity(issues);
|
||||||
|
|||||||
Reference in New Issue
Block a user