From 3a46b24c02dd2a000a994b0fa31a77047dedad9a Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 02:33:02 +0800 Subject: [PATCH] =?UTF-8?q?iter(g348):=20=E8=A1=A8=E5=8D=95=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=B8=8E=E6=8E=A7=E4=BB=B6=E5=BB=BA=E7=AB=8B=20for=20?= =?UTF-8?q?/=20aria-labelledby=20=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/demo/main.ts | 2 +- packages/skin/ui/src/form.ts | 11 +++++++++-- packages/tools/cli/bin/ua.js | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 150b5b9..4396a48 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -585,7 +585,7 @@ const content = h( section( 'sec-formlab', '表单校验', - 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。错误文案带唯一 id 并被 aria-describedby 关联到控件,读屏除了「无效」还会念出具体原因。点「重置」逐项复位:原生输入框回初始值、错误态与汇总条一起清空,自绘控件(城市下拉)靠 formItem 的 reset 钩子回写信号。校验失败只把控件描红,危险色光环聚焦时才亮——常驻红环会让整片字段看着像一起报错;汇总条的条数随字段校验实时重算,改好一项就少一项、全好即消失;点提交后按钮转圈并禁点(异步规则要等 400ms,只变灰会让人以为点空了)。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', + 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。错误文案带唯一 id 并被 aria-describedby 关联到控件,读屏除了「无效」还会念出具体原因。点「重置」逐项复位:原生输入框回初始值、错误态与汇总条一起清空,自绘控件(城市下拉)靠 formItem 的 reset 钩子回写信号。校验失败只把控件描红,危险色光环聚焦时才亮——常驻红环会让整片字段看着像一起报错;汇总条的条数随字段校验实时重算,改好一项就少一项、全好即消失;点提交后按钮转圈并禁点(异步规则要等 400ms,只变灰会让人以为点空了)。标签与控件真关联:点「用户名」直接聚焦输入框,城市这类自绘下拉靠 aria-labelledby 让读屏报出字段名。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', form( { columns: 2, submitText: '注册', resetText: '重置', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, [ diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts index 0f2aff4..6e83744 100644 --- a/packages/skin/ui/src/form.ts +++ b/packages/skin/ui/src/form.ts @@ -15,10 +15,17 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa // —— 状态集中声明 —— const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验 const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前 - const fid = `ua-form-${(uid += 1)}`; // 本字段唯一后缀:错误区与辅助说明各自派生 id + const fid = `ua-form-${(uid += 1)}`; // 本字段唯一后缀:错误区 / 辅助说明 / 标签各自派生 id + // 标签归属:恰好一个原生控件才用 for(单选组的 for 会变成「点标签选中第一项」),组类控件改用 aria-labelledby + const native = Array.from(control.querySelectorAll('input:not([type="hidden"]), textarea, select')); + const only = (control.matches('input:not([type="hidden"]), textarea, select') ? [control] : native).length === 1 + ? (control.matches('input:not([type="hidden"]), textarea, select') ? control : native[0]) + : null; + if (only && !only.id) only.id = `${fid}-control`; // 组件自带的 id 不动:可能是它内部逻辑的锚 + if (!only) (control.hasAttribute('role') ? control : focusableOf(control))?.setAttribute('aria-labelledby', `${fid}-label`); // 自绘下拉 / 单选组靠标签 id 取名 const error = h('div', { class: 'ua-form-error', id: `${fid}-error` }); // 错误区:id 供 aria-describedby 指过来 const hint = props.hint ? h('div', { class: 'ua-form-hint', id: `${fid}-hint` }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起 - const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null); + const label = h('label', { class: 'ua-form-label', for: only?.id ?? null, id: `${fid}-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, hint, error)); if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行 // 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交 diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index 9f71c09..4343bd4 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -1756,6 +1756,21 @@ function checkFormBusy(pkgs) { return issues; } +/*** 校验标签归属:label 不带 for / 不指 aria-labelledby 时,点标签不聚焦、读屏也报不出字段名 ***/ +function checkFormLabel(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 (!/for: only\?\.id \?\? null/.test(src)) issues.push(`${pkg.name}/src/form.ts 的 label 没接 for:点「用户名」不会聚焦输入框`); + if (!/aria-labelledby', `\$\{fid\}-label`/.test(src)) issues.push(`${pkg.name}/src/form.ts 的组类控件缺 aria-labelledby:自绘下拉 / 单选组读屏报不出字段名`); + if (!/if \(only && !only\.id\) only\.id = /.test(src)) issues.push(`${pkg.name}/src/form.ts 没给控件补 id:没有 id 时 for 指向空气`); + } + return issues; +} + /*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ function checkSynonyms(ids) { const issues = []; @@ -1843,6 +1858,7 @@ function cmdVerify() { issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮 issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算 issues.push(...checkFormBusy(pkgs)); // 提交忙碌态必须有可见反馈 + issues.push(...checkFormLabel(pkgs)); // 标签必须真正归属控件 issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在 checkDarkTokens(issues); checkTokenParity(issues); @@ -1855,7 +1871,7 @@ function cmdVerify() { console.error(`${issues.length} violation(s)`); process.exit(1); } - console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕双向一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00,每个 iter 提交都有留痕),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,combobox / slider / range 控件都有可访问名,toast 有 live 区域与 alert 抢占播报,表单错误文案被 aria-describedby 关联、重置真复位、错误环只在聚焦时亮、汇总条随校验重算、提交按钮有忙碌态,视觉回归抽查清单十九组件 × 五维度齐全)'); + console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕双向一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00,每个 iter 提交都有留痕),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,combobox / slider / range 控件都有可访问名,toast 有 live 区域与 alert 抢占播报,表单错误文案被 aria-describedby 关联、标签归属控件(for / aria-labelledby)、重置真复位、错误环只在聚焦时亮、汇总条随校验重算、提交按钮有忙碌态,视觉回归抽查清单十九组件 × 五维度齐全)'); } /*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/