跳转到内容

a11y —— aria 属性构建器

aria 属性的拼写与取值是体力活:aria-haspopup 还是 ariaHaspopup?'true' 还是 true?假值要不要渲染?每个组件自己拼一遍,要么拼错,要么忘记在值为空时省略(读屏里出现一堆 aria-controls="undefined")。shared/a11y 用一组纯函数把约定固化下来。

所有布尔型 aria 构建器遵守同一规则:

属性 假值行为 理由
aria-required: false 返回 {},不渲染 读屏对「非必填」的显式声明是噪音
aria-haspopup: false 返回 {},不渲染 同上
aria-current: false 不会出现 构建器类型上就不收 false(多数读屏会忽略它)
aria-controls / aria-labelledby / aria-describedby id 为空时返回 {} 渲染出 aria-controls="undefined" 是常见 bug
aria-expanded: false 原样输出 折叠态需要被显式播报
aria-invalid: false 原样输出 「校验通过」需要被显式播报

两个「例外」是刻意的:aria-expanded / aria-invalid 的假值有语义信息,读屏会据此说「已折叠」「输入有效」。

import {
generateAriaId,
ariaExpanded,
ariaControls,
ariaLabelledby,
ariaDescribedby,
ariaInvalid,
ariaRequired,
ariaCurrent,
ariaHaspopup,
getPopupTriggerAria,
getDecorativeAria,
type LhAriaAttrs,
type LhAriaHaspopup,
} from '@lh-ui/share/core/shared/a11y'
// aria 属性包的形状:键为 'aria-*' 或 'role',直接 v-bind / {...spread}
export type LhAriaAttrs = Record<string, string | number | boolean | undefined>
// aria-haspopup 的合法取值(APG)
export type LhAriaHaspopup =
| 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'combobox'
函数 签名 作用
generateAriaId (prefix = 'lh-aria') => string 生成 aria 关联用的唯一 id
ariaExpanded (expanded: boolean) => LhAriaAttrs 展开 / 收起态
ariaControls (id: string | undefined) => LhAriaAttrs 引用所控制的面板
ariaLabelledby (id: string | undefined) => LhAriaAttrs 引用可见文案作为可达名称
ariaDescribedby (id: string | undefined) => LhAriaAttrs 引用补充描述
ariaInvalid (invalid: boolean) => LhAriaAttrs 校验失败标记
ariaRequired (required: boolean) => LhAriaAttrs 必填语义
ariaCurrent (current: 'page' | 'step' | 'location' | 'date' | 'time' | true | undefined) => LhAriaAttrs 当前位置标记
ariaHaspopup (kind: LhAriaHaspopup | boolean) => LhAriaAttrs 声明弹出何种浮层
getPopupTriggerAria (options: { expanded; popupId?; haspopup? }) => LhAriaAttrs 弹出层触发器公共属性包
getDecorativeAria (decorative = true) => LhAriaAttrs 装饰性元素对辅助技术隐藏

popover / popconfirm / select / cascader / date-picker / dropdown 的触发器全部用它,一次拿到三件事:

getPopupTriggerAria({ expanded: true, popupId: 'lh-popover-1' })
// {
// 'aria-expanded': true,
// 'aria-controls': 'lh-popover-1',
// 'aria-haspopup': 'true', // 默认值
// }
getPopupTriggerAria({ expanded: false, popupId: 'lh-popover-1', haspopup: 'menu' })
// { 'aria-expanded': false, 'aria-controls': 'lh-popover-1', 'aria-haspopup': 'menu' }
getPopupTriggerAria({ expanded: false }) // 没传 popupId
// { 'aria-expanded': false, 'aria-haspopup': 'true' } —— 不输出 aria-controls

haspopup 默认 'true'。语义上更精确的值(下拉是 listbox、级联是 menu、日历是 dialog)会让读屏播报更准确,各组件按自己的语义传。

getDecorativeAria() // { 'aria-hidden': true }
getDecorativeAria(false) // {} —— 显式取消隐藏

装饰性元素(纯分割线、loading 装饰、骨架屏的几何形状)对辅助技术是噪音。注意只对真正无信息的元素用——有文案的元素加 aria-hidden 是无障碍违规。传 false 返回空对象,用于「默认隐藏、某些场景要可见」的条件分支。

const id = generateAriaId('lh-popover') // 'lh-popover-1'、'lh-popover-2' ……

前缀固定为传入值,模块内自增计数器保证同进程多实例不撞 id。SSR 安全(无 DOM 访问)。

注意:计数器是模块级可变状态,这个函数不是纯函数——同样的输入在进程的不同时刻返回不同输出。服务端渲染时计数从 0 开始,客户端注水时也从 0 开始,只要渲染顺序一致 id 就一致;但若 SSR / CSR 渲染顺序不同(异步组件、条件渲染差异),可能出现 id 不匹配。各 UI 库的弹层组件在客户端才生成 id(onMounted 之后),规避了这个问题。

<template>
<button
v-bind="getPopupTriggerAria({ expanded: open, popupId: panelId, haspopup: 'dialog' })"
@click="toggle"
>
打开
</button>
<div :id="panelId" role="dialog" v-show="open">……</div>
</template>
<script setup lang="ts">
const panelId = generateAriaId('lh-popover')
</script>

v-bind 一个返回对象的函数是 Vue 的惯用写法——属性包里的 undefined 值会被 Vue 自动跳过,与「假值省略」约定天然吻合。

const panelId = generateAriaId('lh-popover')
return (
<>
<button {...getPopupTriggerAria({ expanded: open, popupId: panelId })} onClick={toggle} />
{open && <div id={panelId} role="dialog">……</div>}
</>
)
// input 组件
const aria = {
...ariaRequired(props.required), // 非必填时整个属性都不渲染
...ariaInvalid(!!errorMessage.value), // 校验通过也显式播报
...ariaDescribedby(errorMessage.value ? errorId : undefined),
}
ariaCurrent(isActive ? 'page' : undefined) // 非当前页不输出,避免噪音
ariaCurrent(isActive ? 'page' : undefined) // 分页用 'page',步骤条用 'step',日期用 'date'
  • aria-controls 的 id 必须真实存在:本模块只负责拼属性,id 对不对是组件的责任。浮层用 v-if 延迟渲染时,关闭态下面板不在 DOM 里,aria-controls 指向不存在的元素——这是可接受的(读屏会忽略),但比指向错误元素好。
  • 不要给有文案的元素加 aria-hidden:这是无障碍硬违规。
  • ariaCurrent 的取值按语义选:分页 'page'、步骤条 'step'、面包屑 'location'、日历 'date'。类型上收这些字面量 + true。
  • 组合属性包不可与原子函数混用覆盖:getPopupTriggerAria 已经包含 aria-haspopup,外面再 spread ariaHaspopup(...) 会被后展开的覆盖——按需选用一种。
@lh-ui/share/core/a11y → getPopupTriggerAria( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 风默文学 | 宁ICP备2024006574号 | 公安备案徽标苏公网安备32040002010537