iter(g341): 表单错误文案补 aria-describedby 关联

This commit is contained in:
HIsheR
2026-10-27 02:22:51 +08:00
parent f6e1d30068
commit 5d02c8183f
2 changed files with 28 additions and 4 deletions
+12 -4
View File
@@ -30,6 +30,8 @@ async function checkRuleAsync(value: string, rule: FormRule): Promise<string | n
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过'; return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
} }
let uid = 0; // 字段序号:错误区 / 辅助说明的 id 必须全页唯一,否则多表单时读屏关联到别人家的文案
/*** 字段容器里的焦点落点:自绘控件(非搜索式下拉、评分、开关)没有原生 input,只查 input 会拿到 null,退到按钮 / 带 tabindex 的节点 ***/ /*** 字段容器里的焦点落点:自绘控件(非搜索式下拉、评分、开关)没有原生 input,只查 input 会拿到 null,退到按钮 / 带 tabindex 的节点 ***/
const focusableOf = (box: HTMLElement): HTMLElement | null => const focusableOf = (box: HTMLElement): HTMLElement | null =>
box.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select, button, [tabindex]:not([tabindex="-1"])'); box.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select, button, [tabindex]:not([tabindex="-1"])');
@@ -39,8 +41,9 @@ 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 error = h('div', { class: 'ua-form-error' }); const fid = `ua-form-${(uid += 1)}`; // 本字段唯一后缀:错误区与辅助说明各自派生 id
const hint = props.hint ? h('div', { class: 'ua-form-hint' }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起 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' }, 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 还原为整行
@@ -54,12 +57,17 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')), ...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
]; ];
/*** 落笔:错误文案 + 控件红框 + aria-invalid,视觉与读屏同源 ***/ /*** 落笔:错误文案 + 控件红框 + aria-invalid + 说明关联,视觉与读屏同源 ***/
const paint = (msg: string | null): void => { const paint = (msg: string | null): void => {
error.textContent = msg ?? ''; error.textContent = msg ?? '';
if (hint) hint.hidden = !!msg; // 有错时让位:同一位置只留一条提示 if (hint) hint.hidden = !!msg; // 有错时让位:同一位置只留一条提示
control.classList.toggle('ua-invalid', !!msg); control.classList.toggle('ua-invalid', !!msg);
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); const described = msg ? error.id : hint && !hint.hidden ? hint.id : ''; // 读屏该念哪条:报错优先,其次辅助说明
for (const node of marked()) {
node.setAttribute('aria-invalid', String(!!msg));
if (described) node.setAttribute('aria-describedby', described); // 只说「无效」不够,要把原因一起念出来
else node.removeAttribute('aria-describedby'); // 无文案可念时摘掉,别留一个指空的关联
}
}; };
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/ /*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
+16
View File
@@ -1670,6 +1670,21 @@ function checkToastLive(pkgs) {
return issues; return issues;
} }
/*** 校验表单错误关联:只标 aria-invalid 时读屏只说「无效」,原因(哪条规则没过)听不到 ***/
function checkFormErrorLink(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 (!/id: `\$\{fid\}-error`/.test(src)) issues.push(`${pkg.name}/src/form.ts 的错误区缺派生 id:没有 id 就无法被 aria-describedby 指过来`);
if (!/setAttribute\('aria-describedby', described\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 落笔:校验失败时读屏只会念「无效」,念不出「至少 6 个字符」这类原因`);
if (!/removeAttribute\('aria-describedby'\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 清除:校验通过后关联还指着错误区,读屏会把空节点当说明`);
}
return issues;
}
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ /*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
function checkSynonyms(ids) { function checkSynonyms(ids) {
const issues = []; const issues = [];
@@ -1752,6 +1767,7 @@ function cmdVerify() {
issues.push(...checkReadme(pkgs)); issues.push(...checkReadme(pkgs));
issues.push(...checkAriaNames(pkgs)); // 输入控件必须有可访问名 issues.push(...checkAriaNames(pkgs)); // 输入控件必须有可访问名
issues.push(...checkToastLive(pkgs)); // toast 必须有 live 区域与 alert 抢占 issues.push(...checkToastLive(pkgs)); // toast 必须有 live 区域与 alert 抢占
issues.push(...checkFormErrorLink(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);