diff --git a/packages/skin/ui/src/autoComplete.ts b/packages/skin/ui/src/autoComplete.ts index 7bc8e46..89fcbfa 100644 --- a/packages/skin/ui/src/autoComplete.ts +++ b/packages/skin/ui/src/autoComplete.ts @@ -45,7 +45,7 @@ export function autoComplete(props: AutoCompleteProps): HTMLElement { }, }) as HTMLInputElement; - const list = h('ul', { class: 'ua-autocomplete-list', role: 'listbox', id: `ua-ac-${(seq += 1)}` }) as HTMLElement; + const list = h('ul', { class: 'ua-autocomplete-list', role: 'listbox', 'aria-label': '输入建议', id: `ua-ac-${(seq += 1)}` }) as HTMLElement; input.setAttribute('aria-controls', list.id); const pop = h('div', { class: () => `ua-autocomplete-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement; const root = h('div', { class: 'ua-autocomplete' }, input, pop) as HTMLElement; diff --git a/packages/skin/ui/src/mentions.ts b/packages/skin/ui/src/mentions.ts index 60cf1b8..132b4de 100644 --- a/packages/skin/ui/src/mentions.ts +++ b/packages/skin/ui/src/mentions.ts @@ -33,7 +33,7 @@ export function mentions(props: MentionsProps): HTMLElement { 'aria-expanded': 'false', }) as HTMLTextAreaElement; input.value = value(); - const list = h('div', { class: 'ua-mentions-pop', role: 'listbox', id: uid }); + const list = h('div', { class: 'ua-mentions-pop', role: 'listbox', 'aria-label': '提及建议', id: uid }); const wrap = h('div', { class: 'ua-mentions' }, input, list); // 候选:空过滤词给全量,否则按名字 / 关键词做包含匹配 diff --git a/packages/skin/ui/src/multiSelect.ts b/packages/skin/ui/src/multiSelect.ts index b835d4e..01e0da2 100644 --- a/packages/skin/ui/src/multiSelect.ts +++ b/packages/skin/ui/src/multiSelect.ts @@ -65,7 +65,7 @@ export function multiSelect(props: MultiSelectProps): HTMLElement { }, }) as HTMLInputElement; box.append(tagWrap, input); // 标签区在前、输入框在后,横向排布 - const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-multiselectable': 'true' }) as HTMLElement; + const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-label': '选项列表', 'aria-multiselectable': 'true' }) as HTMLElement; const pop = h('div', { class: () => `ua-multiselect-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement; const clearBtn = h('button', { type: 'button', class: 'ua-select-clear', 'aria-label': '清空所选', hidden: true, onClick: (e: Event) => { e.stopPropagation(); update([]); input.focus(); } }, '×') as HTMLElement; // 复用 select 清空钮样式;阻止冒泡免得清完又展开 const root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, props.clearable ? clearBtn : null, pop) as HTMLElement; diff --git a/packages/skin/ui/src/segmented.ts b/packages/skin/ui/src/segmented.ts index 75443e5..8c77774 100644 --- a/packages/skin/ui/src/segmented.ts +++ b/packages/skin/ui/src/segmented.ts @@ -7,7 +7,7 @@ export function segmented(props: SegmentedProps): HTMLElement { // —— 状态集中声明 —— const buttons: HTMLButtonElement[] = []; // 分段按钮,顺序与 options 一致 const thumb = h('span', { class: 'ua-seg-thumb', 'aria-hidden': 'true' }); // 滑块:仅装饰,不参与可访问性树 - const root = h('div', { class: `ua-segmented ua-segmented-${props.size ?? 'md'}`, role: 'radiogroup' }, thumb); + const root = h('div', { class: `ua-segmented ua-segmented-${props.size ?? 'md'}`, role: 'radiogroup', 'aria-label': '分段选择' }, thumb); /*** 整组禁用判定:布尔直接取,信号在此读取以纳入 effect 依赖 ***/ const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true); diff --git a/packages/skin/ui/src/select.ts b/packages/skin/ui/src/select.ts index 6079125..75c5611 100644 --- a/packages/skin/ui/src/select.ts +++ b/packages/skin/ui/src/select.ts @@ -87,7 +87,7 @@ export function select(props: SelectProps): HTMLElement { const blank = h('div', { class: 'ua-select-blank' }, '无匹配选项'); // 过滤空结果提示 const pop = h( 'div', - { class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox' }, + { class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox', 'aria-label': '选项列表' }, props.searchable ? search : null, ...nodes, blank, diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index 4343bd4..529518f 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -1771,6 +1771,32 @@ function checkFormLabel(pkgs) { return issues; } +/*** 校验自定义角色容器的可访问名:无名 listbox / radiogroup 读屏只念「列表 / 组」,不说是哪一个 ***/ +function checkRoleNames(pkgs) { + const issues = []; + for (const pkg of pkgs) { + if (pkg.type !== 'skin-dom') continue; + const srcDir = path.join(ROOT, pkg.dir, 'src'); + if (!fs.existsSync(srcDir)) continue; + for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) { + const lines = fs.readFileSync(path.join(srcDir, file), 'utf8').split(/\r?\n/); + lines.forEach((line, i) => { + if (!/role: '(listbox|radiogroup)'/.test(line)) return; // 卫语句:只查这两个会被念成控件容器的角色 + let depth = 0; // 花括号深度:把这次 h(...) 的入参对象整段圈出来,别扫到隔壁控件 + let text = ''; + for (let k = i; k < lines.length && k < i + 20; k++) { + text += ' ' + lines[k]; + depth += (lines[k].match(/\{/g) ?? []).length - (lines[k].match(/\}/g) ?? []).length; + if (depth <= 0 && k > i) break; + } + if (/aria-label|aria-labelledby/.test(text)) return; + issues.push(`${pkg.name}/${file}:${i + 1} 的 ${line.trim().slice(0, 40)} 缺可访问名:读屏只会念「列表 / 组」,用户不知道是哪一个`); + }); + } + } + return issues; +} + /*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/ function checkSynonyms(ids) { const issues = []; @@ -1859,6 +1885,7 @@ function cmdVerify() { issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算 issues.push(...checkFormBusy(pkgs)); // 提交忙碌态必须有可见反馈 issues.push(...checkFormLabel(pkgs)); // 标签必须真正归属控件 + issues.push(...checkRoleNames(pkgs)); // listbox / radiogroup 必须有名字 issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在 checkDarkTokens(issues); checkTokenParity(issues); @@ -1871,7 +1898,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 关联、标签归属控件(for / aria-labelledby)、重置真复位、错误环只在聚焦时亮、汇总条随校验重算、提交按钮有忙碌态,视觉回归抽查清单十九组件 × 五维度齐全)'); + console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕双向一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00,每个 iter 提交都有留痕),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,combobox / slider / range 控件与 listbox / radiogroup 容器都有可访问名,toast 有 live 区域与 alert 抢占播报,表单错误文案被 aria-describedby 关联、标签归属控件(for / aria-labelledby)、重置真复位、错误环只在聚焦时亮、汇总条随校验重算、提交按钮有忙碌态,视觉回归抽查清单十九组件 × 五维度齐全)'); } /*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/