diff --git a/.ua/index.json b/.ua/index.json index ae5bf01..b58a558 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T18:09:53.780Z", + "generatedAt": "2026-10-26T18:24:21.676Z", "repoRoot": ".", "packages": [ { @@ -2035,10 +2035,15 @@ "t": "Signal", "d": "条件显示信号:为假时整项隐藏且不参与校验(未显示的字段不该拦住提交)" }, + { + "n": "(formItem)reset", + "t": "() => void", + "d": "自绘控件的复位钩子:select / dataTable 这类没有原生 input 的控件拿不到值,由调用方在这里回写信号;原生输入框由 formItem 自己还原,不用管" + }, { "n": "onReset", "t": "() => void", - "d": "重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮" + "d": "重置按钮点击回调;组件已先逐项复位(原生控件回初始值、错误态与汇总条清空),此处只做额外清理;不传 resetText 不渲染按钮" }, { "n": "onError", diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 36d6c25..975ab5b 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -585,13 +585,13 @@ const content = h( section( 'sec-formlab', '表单校验', - 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', + 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。错误文案带唯一 id 并被 aria-describedby 关联到控件,读屏除了「无效」还会念出具体原因。点「重置」逐项复位:原生输入框回初始值、错误态与汇总条一起清空,自绘控件(城市下拉)靠 formItem 的 reset 钩子回写信号。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', form( - { columns: 2, submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, + { columns: 2, submitText: '注册', resetText: '重置', 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, hint: '用于登录与找回密码,不会公开', 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: city, reset: () => 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 }), diff --git a/llms-full.txt b/llms-full.txt index 5ab2e8e..5dedf51 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T18:09:53.780Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T18:24:21.676Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -993,7 +993,8 @@ const open = signal(false); drawer({ title: '详情', open, width: 360, onClose: - (formItem)hint:string —— 控件下方辅助说明(格式提示等);报错时让位给错误文案 - (formItem)span:number —— 多列表单里本字段占几列(如整宽的多行备注);单列布局或窄屏下自动退回整行 - (formItem)visible:Signal —— 条件显示信号:为假时整项隐藏且不参与校验(未显示的字段不该拦住提交) -- onReset:() => void —— 重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮 +- (formItem)reset:() => void —— 自绘控件的复位钩子:select / dataTable 这类没有原生 input 的控件拿不到值,由调用方在这里回写信号;原生输入框由 formItem 自己还原,不用管 +- 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 959d7fa..aa62408 100644 --- a/packages/skin/ui/src/form.ts +++ b/packages/skin/ui/src/form.ts @@ -50,6 +50,9 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa // 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交 if (props.visible) effect(() => { el.hidden = !props.visible?.(); if (el.hidden) paint(null); }); let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖) + // 初始值快照:重置要回到「刚渲染时」而不是空串(带默认值的字段不能被清成空) + const initial = Array.from(control.querySelectorAll('input, textarea, select')) + .map((node) => ({ node, value: node.value, checked: (node as HTMLInputElement).checked })); /*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/ const marked = (): HTMLElement[] => [ @@ -98,6 +101,19 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa return msg; }; + /*** 复位:先跑调用方的自绘控件钩子,再把原生控件还原到初始值并同步信号,最后清错误态 ***/ + const reset = (): void => { + props.reset?.(); // 自绘控件(无原生 input)拿不到值,只能交给调用方回写信号 + for (const { node, value, checked } of initial) { + const boxed = node instanceof HTMLInputElement && (node.type === 'checkbox' || node.type === 'radio'); // 勾选类走 checked + if (boxed) (node as HTMLInputElement).checked = checked; + else node.value = value; + node.dispatchEvent(new Event('input', { bubbles: true })); // 冒泡给组件监听器:信号跟着改,否则下次渲染又把旧值写回 DOM + if (boxed) node.dispatchEvent(new Event('change', { bubbles: true })); // 勾选类只认 change,input 未必触发 + } + paint(null); // 红框与错误文案一起退场 + }; + control.addEventListener('input', () => { if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误 }); @@ -107,17 +123,24 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa control.addEventListener('focusout', () => { if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效 }); - return { el, validate, validateAsync }; + return { el, validate, validateAsync, reset }; } /*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/ 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; + /*** 重置:逐项复位 + 收起汇总条,最后交给调用方回调(只跑回调等于点了没反应) ***/ + const doReset = (): void => { + for (const item of items) item.reset(); + summary.hidden = true; + summary.textContent = ''; + props.onReset?.(); + }; + 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: doReset }) : null; // 重置不得提交表单 + const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn); let running = false; // 提交中标记:异步校验期间屏蔽重复提交 /*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/ diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 828deec..a2ccfb7 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -473,6 +473,7 @@ export interface FormItemProps { hint?: string; // 控件下方辅助说明(格式提示等);报错时让位给错误文案 span?: number; // 多列表单里本字段占几列(如整宽的多行文本);单列布局下无效 visible?: Signal; // 条件显示信号:为假时整项隐藏且不参与校验(未显示的字段不该拦住提交) + reset?: () => void; // 自绘控件的复位钩子:无原生 input 时值由调用方回写信号(原生控件由 formItem 自己还原) } /*** 表单字段句柄:form 聚合校验用 ***/ @@ -480,6 +481,7 @@ export interface FormItemHandle { el: HTMLElement; // 字段根元素(label + 控件 + 错误区) validate(): string | null; // 同步校验:null 通过 / 字符串为错误消息(异步规则不在此时下结论) validateAsync(): Promise; // 异步校验:逐条 await,异步规则生效(form 提交走这里) + reset(): void; // 复位:原生控件回初始值并同步信号,错误态与红框一起退场 } /*** 表单容器入参 ***/ @@ -489,7 +491,7 @@ export interface FormProps { submitText?: string; // 提交按钮文案;缺省「提交」 resetText?: string; // 重置按钮文案;缺省不渲染重置按钮 onSubmit?: () => void; // 全部字段校验通过后的提交回调 - onReset?: () => 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 14c0c69..dcd17a9 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1909,10 +1909,15 @@ "t": "Signal", "d": "条件显示信号:为假时整项隐藏且不参与校验(未显示的字段不该拦住提交)" }, + { + "n": "(formItem)reset", + "t": "() => void", + "d": "自绘控件的复位钩子:select / dataTable 这类没有原生 input 的控件拿不到值,由调用方在这里回写信号;原生输入框由 formItem 自己还原,不用管" + }, { "n": "onReset", "t": "() => void", - "d": "重置按钮点击回调;组件不自行清空字段,由调用方重置信号;不传 resetText 不渲染按钮" + "d": "重置按钮点击回调;组件已先逐项复位(原生控件回初始值、错误态与汇总条清空),此处只做额外清理;不传 resetText 不渲染按钮" }, { "n": "onError", diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index d7b58d7..c792b1f 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -1685,6 +1685,21 @@ function checkFormErrorLink(pkgs) { return issues; } +/*** 校验表单重置:只跑 onReset 回调等于「点了没反应」,按钮名叫重置就得真复位 ***/ +function checkFormReset(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 (!/return \{ el, validate, validateAsync, reset \}/.test(src)) issues.push(`${pkg.name}/src/form.ts 的字段句柄没暴露 reset:form 拿不到复位入口`); + if (!/for \(const item of items\) item\.reset\(\);/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重置没逐项复位:只调 onReset 时点「重置」等于什么都不干,红框还留在屏幕上`); + if (!/type: 'button', onClick: doReset/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重置按钮没接到 doReset:会退回只跑回调的空壳`); + } + return issues; +} + /*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ function checkSynonyms(ids) { const issues = []; @@ -1768,6 +1783,7 @@ function cmdVerify() { issues.push(...checkAriaNames(pkgs)); // 输入控件必须有可访问名 issues.push(...checkToastLive(pkgs)); // toast 必须有 live 区域与 alert 抢占 issues.push(...checkFormErrorLink(pkgs)); // 表单错误文案必须被控件关联 + issues.push(...checkFormReset(pkgs)); // 重置按钮必须真复位 issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在 checkDarkTokens(issues); checkTokenParity(issues);