diff --git a/.ua/backlog.json b/.ua/backlog.json
index 7e789c7..df04310 100644
--- a/.ua/backlog.json
+++ b/.ua/backlog.json
@@ -184,6 +184,14 @@
"acceptance": "dataTable 渲染 colgroup:数字 width 落成固定像素
,'flex' 或不设交给自适应,选择列固定 44px;demo 表格演示 name 160 / age 100 / city flex;manifest 列定义说明与手把手同步;npm run check 全绿",
"status": "done",
"notes": "用 colgroup 而非逐 th 设宽,保证表头与表体一致且不入侵单元格渲染;未开 table-layout:fixed(nowrap 内容会被裁),宽高仍由内容兜底;pinned 固定列仍未消费(多列 sticky 偏移需测量),留作后续条目"
+ },
+ {
+ "id": "d17",
+ "title": "表单错误态覆盖包裹式控件并修正校验时机事件",
+ "motivation": "① 现有 blur 不冒泡,checkbox/select/radioGroup 这类包裹式控件失焦永远不校验;② ua-invalid 只加在控件根,自绘 select 的边框在内部触发器上,视觉上无错误提示;③ 缺 aria-invalid,读屏感知不到",
+ "acceptance": "formItem 用 focusout 取代 blur、补 change(select/checkbox 变更即校验);错误态同时落到控件根与内部原生控件,并同步 aria-invalid;CSS 覆盖 .ua-invalid 后代 .ua-input/.ua-textarea/.ua-select-trigger/.ua-radio/.ua-slider-input;demo 表单加必填 select 演示;manifest/文档/llms-full.txt 同步;npm run check 全绿",
+ "status": "done",
+ "notes": "marked() 统一收集控件根 + 内部 input/textarea/select/.ua-select-trigger 两处落点;.ua-invalid 后代选择器让包裹式控件描红,无需组件各自改签名(低侵入);demo formlab 新增城市必填 select,提交结果显示城市"
}
]
}
diff --git a/.ua/index.json b/.ua/index.json
index 136ac65..d8660c5 100644
--- a/.ua/index.json
+++ b/.ua/index.json
@@ -1,6 +1,6 @@
{
"$schema": "ui-arch-registry-index/0.1",
- "generatedAt": "2026-10-23T08:58:43.429Z",
+ "generatedAt": "2026-10-23T08:59:45.207Z",
"repoRoot": ".",
"packages": [
{
@@ -1205,7 +1205,9 @@
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
- "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
+ "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)",
+ "错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致",
+ "触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验"
]
},
{
diff --git a/.ua/iterations.log b/.ua/iterations.log
index f520720..0246e0c 100644
--- a/.ua/iterations.log
+++ b/.ua/iterations.log
@@ -21,3 +21,4 @@
{"time": "2026-10-23T16:56:36+08:00", "id": "d14", "result": "done", "summary": "新增抽屉 drawer:open 信号驱动侧滑、placement/width 可配、footer 操作区、遮罩/×/Esc 关闭并聚焦首元素;demo/manifest/llms 同步,check 全绿"}
{"time": "2026-10-23T16:57:22+08:00", "id": "d15", "result": "done", "summary": "CLI 包补 ua.manifest.json(type: tooling,七个子命令 + 约定),注册表 5 包;修正 AGENTS/ARCHITECTURE 过期组件数 17→27;check 全绿"}
{"time": "2026-10-23T16:58:51+08:00", "id": "d16", "result": "done", "summary": "dataTable 消费内核列宽 width:colgroup 固定像素 / flex 自适应,选择列 44px;demo 三列演示宽档;manifest/llms 同步,check 全绿"}
+{"time": "2026-10-23T16:59:46+08:00", "id": "d17", "result": "done", "summary": "表单错误态打通包裹式控件:focusout/change 取代 blur + aria-invalid + .ua-invalid 后代选择器覆盖 select/radio/slider;demo 加必填 select;check 全绿"}
diff --git a/apps/demo/main.ts b/apps/demo/main.ts
index 9adbe9a..b8a1007 100644
--- a/apps/demo/main.ts
+++ b/apps/demo/main.ts
@@ -268,10 +268,11 @@ const content = h(
'表单校验',
'form + formItem:必填 / 长度 / 正则校验,全部通过才触发提交;失焦校验、输入清错。',
form(
- { submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()}`) },
+ { 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: '请选择城市…' })),
],
),
h('span', { class: 'out' }, () => formResult() || '等待提交…'),
diff --git a/llms-full.txt b/llms-full.txt
index 34be8cc..7a66a54 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版)
-> 自动生成于 2026-10-23T08:58:43.429Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
+> 自动生成于 2026-10-23T08:59:45.207Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手
@@ -679,6 +679,8 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label:
4. 提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项
5. 规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)
6. 非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)
+7. 错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致
+8. 触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验
现场演示:apps/docs/demo.html#sec-formlab
diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts
index 489029c..4beebcb 100644
--- a/packages/skin/ui/src/form.ts
+++ b/packages/skin/ui/src/form.ts
@@ -26,7 +26,13 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
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));
- /*** 执行全部规则:返回首个错误消息,同时同步错误区与控件态 ***/
+ /*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
+ const marked = (): HTMLElement[] => [
+ control,
+ ...Array.from(control.querySelectorAll('input, textarea, select, .ua-select-trigger')),
+ ];
+
+ /*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/
const validate = () => {
const value = props.value();
let msg: string | null = null;
@@ -36,14 +42,18 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
}
error.textContent = msg ?? '';
control.classList.toggle('ua-invalid', !!msg);
+ for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态
return msg;
};
control.addEventListener('input', () => {
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
});
- control.addEventListener('blur', () => {
- if (mode !== 'submit') validate();
+ control.addEventListener('change', () => {
+ if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
+ });
+ control.addEventListener('focusout', () => {
+ if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
});
return { el, validate };
}
diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json
index 0f11661..f48e1e1 100644
--- a/packages/skin/ui/ua.manifest.json
+++ b/packages/skin/ui/ua.manifest.json
@@ -1076,7 +1076,9 @@
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
- "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
+ "非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)",
+ "错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致",
+ "触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验"
]
},
{
diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css
index a372aa5..3e9ffed 100644
--- a/packages/skin/ui/ui.css
+++ b/packages/skin/ui/ui.css
@@ -1003,8 +1003,13 @@
.ua-form-error:empty {
display: none;
}
-/* 校验失败:控件描红 + 危险色光环 */
-.ua-invalid {
+/* 校验失败:控件描红 + 危险色光环(root 与包裹式控件的内部触发器一并覆盖) */
+.ua-invalid,
+.ua-invalid .ua-input,
+.ua-invalid .ua-textarea,
+.ua-invalid .ua-select-trigger,
+.ua-invalid .ua-radio,
+.ua-invalid .ua-slider-input {
border-color: var(--ua-color-danger) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 15%, transparent) !important;
}