diff --git a/.ua/index.json b/.ua/index.json index 31e8fc1..47cd7f3 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T04:19:05.352Z", + "generatedAt": "2026-10-26T04:21:58.071Z", "repoRoot": ".", "packages": [ { @@ -1568,7 +1568,9 @@ "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)", "错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致", "触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验", - "单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发" + "单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发", + "rules.validator 返回 Promise 即为异步规则(如「邀请码是否已被使用」远程校验):提交时逐项 await,校验期间提交按钮禁用,结论按轮次取舍避免乱序覆盖", + "formItem 返回 { el, validate, validateAsync } 句柄:validate 只跑同步规则(供 blur/input 即时反馈),validateAsync 逐条 await 供 form 聚合提交" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 1c6bde2..6597a82 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -150,6 +150,7 @@ const modalOpen = signal(false); const modalPick = signal('a'); // 弹窗内下拉的取值,用于演示 Esc 逐层关闭 // —— 表单校验状态集中声明 —— +const inviteCode = signal(''); // 异步校验演示:邀请码 const username = signal(''); const email = signal(''); const formResult = signal(''); @@ -398,13 +399,17 @@ const content = h( section( 'sec-formlab', '表单校验', - 'form + formItem:必填 / 长度 / 正则校验,全部通过才触发提交;失焦校验、输入清错。', + 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。', form( { submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, [ formItem({ label: '用户名', required: true, value: username, rules: [{ min: 2, max: 12, message: '用户名需 2-12 个字符' }] }, textField({ placeholder: '2-12 个字符', value: username })), formItem({ label: '邮箱', required: true, value: email, rules: [{ pattern: /^[\w.-]+@[\w-]+\.[\w.]+$/, message: '邮箱格式不正确' }] }, textField({ placeholder: 'you@example.com', value: email })), formItem({ label: '城市', required: true, value: city }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '请选择城市…' })), + formItem( + { label: '邀请码', required: true, value: inviteCode, rules: [{ validator: async (v) => { await new Promise((r) => setTimeout(r, 400)); return v === 'USED' ? '该邀请码已被使用' : true; }, message: '校验未通过' }] }, + textField({ placeholder: '异步规则:输入 USED 会校验失败', value: inviteCode }), + ), ], ), h('span', { class: 'out' }, () => formResult() || '等待提交…'), diff --git a/llms-full.txt b/llms-full.txt index 31783a7..5d52a14 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T04:19:05.352Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T04:21:58.071Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -873,6 +873,8 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: 7. 错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致 8. 触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验 9. 单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type="submit",不会与点击回调重复触发 +10. rules.validator 返回 Promise 即为异步规则(如「邀请码是否已被使用」远程校验):提交时逐项 await,校验期间提交按钮禁用,结论按轮次取舍避免乱序覆盖 +11. formItem 返回 { el, validate, validateAsync } 句柄:validate 只跑同步规则(供 blur/input 即时反馈),validateAsync 逐条 await 供 form 聚合提交 现场演示:apps/docs/demo.html#sec-formlab diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts index afc1ce3..a5967ac 100644 --- a/packages/skin/ui/src/form.ts +++ b/packages/skin/ui/src/form.ts @@ -1,30 +1,43 @@ import { h } from '@ui-arch/frame'; import { button } from './button.js'; -import { textField } from './textField.js'; import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js'; -/*** 执行单条规则:null 通过,字符串为错误消息 ***/ -function checkRule(value: string, rule: FormRule): string | null { +/*** 执行规则的同步部分(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 ?? '格式不正确'; - if (rule.validator) { - const r = rule.validator(value); - if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过'; - } return null; } -/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/ +/*** 同步求值单条规则: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 { + 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 ?? '校验未通过'; +} + +/*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle { // —— 状态集中声明 —— const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验 - const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; + const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前 const error = h('div', { class: 'ua-form-error' }); const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null); const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error)); + let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖) /*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/ const marked = (): HTMLElement[] => [ @@ -32,48 +45,75 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa ...Array.from(control.querySelectorAll('input, textarea, select, .ua-select-trigger')), ]; - /*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/ - const validate = () => { + /*** 落笔:错误文案 + 控件红框 + aria-invalid,视觉与读屏同源 ***/ + const paint = (msg: string | null): void => { + error.textContent = msg ?? ''; + control.classList.toggle('ua-invalid', !!msg); + for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); + }; + + /*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/ + const validate = (): string | null => { const value = props.value(); let msg: string | null = null; for (const rule of rules) { msg = checkRule(value, rule); if (msg) break; // 首个失败即停止 } - error.textContent = msg ?? ''; - control.classList.toggle('ua-invalid', !!msg); - for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态 + paint(msg); + return msg; + }; + + /*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/ + const validateAsync = async (): Promise => { + seq += 1; + const mine = seq; // 本轮编号 + const value = props.value(); + let msg: string | null = null; + for (const rule of rules) { + msg = await checkRuleAsync(value, rule); + if (msg) break; + } + if (mine === seq) paint(msg); // 期间又有新校验:交给最新一轮落笔 return msg; }; control.addEventListener('input', () => { - if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误 + if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误 }); control.addEventListener('change', () => { - if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡) + if (mode === 'input') void validateAsync(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡) }); control.addEventListener('focusout', () => { - if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效 + if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效 }); - return { el, validate }; + return { el, validate, validateAsync }; } -/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/ +/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { // —— 状态集中声明 —— - /*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/ - const submit = () => { - for (const item of items) { - if (item.validate()) { - (item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus(); - return; // 卫语句:有错误不提交 - } - } - props.onSubmit?.(); - }; - const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit' }); // 走原生 submit,避免 click + submit 双触发 + 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); + let running = false; // 提交中标记:异步校验期间屏蔽重复提交 + + /*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/ + const submit = async (): Promise => { + if (running) return; // 卫语句:上一轮异步校验还没结束 + running = true; + submitBtn.disabled = true; // 校验期间禁用,避免连点重复提交 + let firstBad: HTMLElement | null = null; + for (const item of items) { + if (!(await item.validateAsync()) || firstBad) continue; // 首错之前的项才记焦点目标 + firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null; + } + submitBtn.disabled = false; + running = false; + if (firstBad) { firstBad.focus(); return; } // 卫语句:有错误不提交 + props.onSubmit?.(); + }; + const el = h( 'form', { class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' }, @@ -82,13 +122,13 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { ); el.addEventListener('submit', (e) => { e.preventDefault(); // 拦截原生提交,走校验流程 - submit(); + void submit(); }); el.addEventListener('keydown', (e) => { if (e.key !== 'Enter' || e.target instanceof HTMLTextAreaElement) return; // 多行输入框允许换行 if (!(e.target instanceof HTMLInputElement)) return; // 只接管单行输入框 e.preventDefault(); // 拦下浏览器隐式提交,统一走校验 - submit(); + void submit(); }); return el; } diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 70756e0..d4f252f 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -337,7 +337,7 @@ export interface FormRule { min?: number; // 最小长度 max?: number; // 最大长度 pattern?: RegExp; // 正则格式校验 - validator?: (value: string) => boolean | string; // 自定义校验:true 通过 / 字符串为错误消息 + validator?: (value: string) => boolean | string | Promise; // 自定义校验:true 通过 / 字符串为错误消息 / Promise 为异步规则 message?: string; // 该规则的错误消息;缺省按规则类型给默认文案 } @@ -353,7 +353,8 @@ export interface FormItemProps { /*** 表单字段句柄:form 聚合校验用 ***/ export interface FormItemHandle { el: HTMLElement; // 字段根元素(label + 控件 + 错误区) - validate(): string | null; // 执行校验:null 通过 / 字符串为错误消息 + validate(): string | null; // 同步校验:null 通过 / 字符串为错误消息(异步规则不在此时下结论) + validateAsync(): Promise; // 异步校验:逐条 await,异步规则生效(form 提交走这里) } /*** 表单容器入参 ***/ diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 935f484..131a365 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1439,7 +1439,9 @@ "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)", "错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致", "触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验", - "单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发" + "单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发", + "rules.validator 返回 Promise 即为异步规则(如「邀请码是否已被使用」远程校验):提交时逐项 await,校验期间提交按钮禁用,结论按轮次取舍避免乱序覆盖", + "formItem 返回 { el, validate, validateAsync } 句柄:validate 只跑同步规则(供 blur/input 即时反馈),validateAsync 逐条 await 供 form 聚合提交" ] }, {