iter(g348): 表单标签与控件建立 for / aria-labelledby 关联

This commit is contained in:
HIsheR
2026-10-27 02:33:02 +08:00
parent 840025b66b
commit 3a46b24c02
3 changed files with 27 additions and 4 deletions
+1 -1
View File
@@ -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 钩子回写信号。校验失败只把控件描红,危险色光环聚焦时才亮——常驻红环会让整片字段看着像一起报错;汇总条的条数随字段校验实时重算,改好一项就少一项、全好即消失;点提交后按钮转圈并禁点(异步规则要等 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( form(
{ columns: 2, submitText: '注册', resetText: '重置', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, { columns: 2, submitText: '注册', resetText: '重置', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) },
[ [
+9 -2
View File
@@ -15,10 +15,17 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
// —— 状态集中声明 —— // —— 状态集中声明 ——
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验 const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前 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<HTMLElement>('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 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 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)); 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 还原为整行 if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行
// 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交 // 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交
+17 -1
View File
@@ -1756,6 +1756,21 @@ function checkFormBusy(pkgs) {
return issues; 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 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ /*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
function checkSynonyms(ids) { function checkSynonyms(ids) {
const issues = []; const issues = [];
@@ -1843,6 +1858,7 @@ function cmdVerify() {
issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮 issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮
issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算 issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算
issues.push(...checkFormBusy(pkgs)); // 提交忙碌态必须有可见反馈 issues.push(...checkFormBusy(pkgs)); // 提交忙碌态必须有可见反馈
issues.push(...checkFormLabel(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);
@@ -1855,7 +1871,7 @@ function cmdVerify() {
console.error(`${issues.length} violation(s)`); console.error(`${issues.length} violation(s)`);
process.exit(1); 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 ***/ /*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/