Kibana EUI 浮层组件无障碍命名规范:EuiModal、EuiFlyout 与 EuiPopover 的 aria-labelledby 正确接线
【免费下载链接】kibanaYour window into all of your data项目地址: https://gitcode.com/GitHub_Trending/ki/kibana
本篇基于 Kibana 仓库内置的无障碍技能文档 overlays.md,系统讲解 EUI 浮层组件(EuiModal、EuiFlyout、EuiFlyoutResizable、EuiConfirmModal、EuiPopover)如何提供与可见标题保持一致的程序化可访问名称,并给出可复制的完整代码模式。读完后,你将能够在编写或审查 Kibana 插件中的模态框、飞出面板与弹出层时,正确使用aria-labelledby、titleProps与useGeneratedHtmlId,避免屏幕阅读器读不到或读错浮层标题的常见缺陷。
为什么浮层组件需要程序化可访问名称
在 Kibana 的无障碍标准中(见 SKILL.md 与 shared_principles.md),界面需要满足WCAG 2.2 AA,并且组件文档明确要求:
需要捕获焦点(trap focus)或转移焦点的分层 UI(layered UI),必须拥有一个与可见标题保持对齐的程序化可访问名称(programmatic accessible name)。
原因在于:模态框和飞出面板打开时会把键盘焦点“锁”进浮层内部,用户(尤其是屏幕阅读器用户)此时离开页面上下文,只能依赖浮层自身的可访问名称来理解"我现在在哪个界面"。如果浮层容器没有aria-labelledby或aria-label,屏幕阅读器在焦点进入时要么播报空白,要么播报一个与用户肉眼所见不一致的字符串——这正是该文档要解决的核心问题。
选型:五种浮层组件的使用场景
原文档给出了按交互形态区分的选型标准,这里完整保留并补充判断依据:
EuiModal—— 阻塞式确认或表单。用户必须先关闭或完成才能继续操作主页面。EuiFlyout/EuiFlyoutResizable—— 非阻塞的详情或设置面板,通常与左侧列表 / 页面选择器搭配使用("主从布局",主页面保持可交互)。EuiConfirmModal—— 是/否确认或破坏性操作(删除、覆盖、重置),采用titleprop 的 API 形态——标题不是子元素,而是通过 prop 传入。EuiPopover—— 上下文菜单、过滤器、短小的锚定内容;可能有、也可能没有可见标题,这是四种浮层中唯一需要区分"有标题 / 无标题"两条接线路径的组件。
标准接线方式:六步 canonical 模式
文档给出的核心原则是一句话:优先使用指向"可见标题"的aria-labelledby,让屏幕阅读器播报的名字与视觉用户看到的标题完全一致。具体步骤如下(原文档六步完整继承):
- 在浮层内部渲染一个真实的标题元素(
EuiModalTitle、EuiFlyoutTitle、EuiPopoverTitle、EuiTitle,或一个原生 heading)。 - 给该标题元素一个稳定的
id—— 使用useGeneratedHtmlId()(函数组件)或htmlIdGenerator()(class 组件)。id 生成规范见 shared_principles.md 的 "HTML ids" 一节。 - 在浮层容器上设置
aria-labelledby,指向该id。 - 同一个 ID 变量必须同时用于标题的
id和容器的aria-labelledby—— 绝不产生"孤儿引用"(指向不存在元素的引用)。 EuiConfirmModal例外:它把标题暴露为 prop 而非子元素,因此需要通过titleProps={{ id }}把该 id 透传进它渲染出的 DOM,使 id 与aria-labelledby匹配。- 没有合适可见标题时(Popover 中较少见):改用
aria-label,且字符串必须经过i18n.translate本地化。
文档同时建议了统一的变量命名约定:modalTitleId、flyoutTitleId、confirmModalTitleId、popoverTitleId。
关于第 2 步,shared_principles.md 给出了两类组件的具体写法,本文档的示例都建立在这套约定之上:
// 函数组件 —— useGeneratedHtmlId,须在首次 return 之前调用(保证 hooks 顺序稳定) import { useGeneratedHtmlId } from '@elastic/eui'; const labelId = useGeneratedHtmlId();// class 组件 —— htmlIdGenerator,在 render() 内以稳定后缀调用 import { htmlIdGenerator } from '@elastic/eui'; render() { const labelId = htmlIdGenerator()('myLabel'); }使用 EUI 提供的 id 生成器(而非手写字符串 id)的意义在于:Kibana 单页中同一类浮层可能被实例化多次,生成器保证 id 在页面内唯一且跨渲染稳定,避免aria-labelledby指向到另一个实例的同名元素。
完整代码示例(按组件逐一展开)
EuiModal / EuiFlyout / EuiFlyoutResizable:标题元素直接接 id
这类组件的标题是子元素,接线最直白——标题拿id={...TitleId},容器拿匹配的aria-labelledby:
const flyoutTitleId = useGeneratedHtmlId(); <EuiFlyout aria-labelledby={flyoutTitleId}> <EuiFlyoutTitle id={flyoutTitleId}>My title</EuiFlyoutTitle> </EuiFlyout>EuiModal与EuiFlyoutResizable同理:EuiModalTitle id={modalTitleId}+ 容器aria-labelledby={modalTitleId}。
EuiConfirmModal:必须补上 titleProps.id
EuiConfirmModal的title是 prop,如果你只写aria-labelledby={id}而不在标题 DOM 上落 id,就会形成孤儿引用。正确写法是双管齐下:
const confirmModalTitleId = useGeneratedHtmlId(); return ( <EuiConfirmModal aria-labelledby={confirmModalTitleId} title={i18nTexts.modalTitle} titleProps={{ id: confirmModalTitleId }} > <p>{i18nTexts.modalDescription}</p> </EuiConfirmModal> );注意示例中标题文案来自共享对象i18nTexts.modalTitle而不是内联字面量——这与 shared_principles.md 的 i18n 原则一致:"当文件已经暴露了共享对象(如i18nTexts.modalTitle)时,新字符串应沿用该本地模式,而不是添加内联i18n.translate调用"。
EuiPopover:有可见标题时
const popoverTitleId = useGeneratedHtmlId(); <EuiPopover aria-labelledby={popoverTitleId}> <EuiPopoverTitle> <h2 id={popoverTitleId}>Title</h2> </EuiPopoverTitle> </EuiPopover>Popover 没有专属的EuiPopoverTitle内置 id 透传机制示例,因此标题由EuiPopoverTitle包裹一个带 id 的 heading 承担。
EuiPopover:无标题时回退到 aria-label
<EuiPopover aria-label={i18n.translate('myFeature.filterPopover', { defaultMessage: 'Filter options', })} > {popoverContent} </EuiPopover>回退路径的要点:aria-label的字符串必须走i18n.translate(可见与辅助技术字符串一律本地化,禁止裸字面量),并且此时不要再同时设置aria-labelledby——shared_principles.md 明确规定"每个控件只使用一种命名机制,aria-label与aria-labelledby不可并用"。
常见错误对照(原文档 Common mistakes 完整保留)
// 错误 1 —— 用 aria-label 把可见标题重复成一条隐藏字符串 <EuiModal aria-label="Settings"> <EuiModalTitle>Settings</EuiModalTitle> </EuiModal> // 错误 2 —— aria-labelledby 指向虚空(EuiConfirmModal 没有接 titleProps.id) <EuiConfirmModal aria-labelledby={id} title="Delete?" /> // 正确 <EuiConfirmModal aria-labelledby={id} title="Delete?" titleProps={{ id }} />三种形态对应的失效率由低到高:
- 错误 1 的代价是双份维护——视觉标题改版后,隐藏的
aria-label字符串会悄悄过期,屏幕阅读器用户看到的"名字"与鼠标用户看到的不一致;同时违反了"每种控件只用一种命名机制"的原则。 - 错误 2 的代价更隐蔽:
aria-labelledby指向一个不存在的 id 时不会报错,但浮层可访问名称直接失效(回退逻辑因浏览器而异),静态检查器未必能拦截,属于典型的"看起来写了 a11y,实际没写"。 - 正确写法的关键在
titleProps={{ id }}这一行——它把"标题在哪个 DOM 上"与"引用它的 id"绑定在同一条变量链上。
仓库内的真实用法佐证
Kibana 仓库自带的 flyout_system 示例 展示了生产代码中这一模式的落地形态:
- examples/flyout_system/public/components/_flyout_with_component.tsx 中多个
EuiFlyout均携带aria-labelledby(如aria-labelledby="sessionFlyoutTitle"、aria-labelledby="childFlyoutATitle"),且子飞出(child flyout)也各自接线,印证了"每一个捕获焦点的分层都要有名字"。 - examples/flyout_system/public/utils/flyout_props.ts 把
['aria-labelledby']: 'flyoutTitle'收敛到共享的 props 工厂里,说明大型插件中这类属性适合集中管理,避免每个调用点重复书写。
从源码结构看,Kibana 的飞出/模态底层还承担了共享原则中提到的行为义务——"模态框和飞出会捕获焦点并在关闭时把焦点归还给触发元素"(见 shared_principles.md 的 Keyboard and focus 一节)。也就是说,EUI 组件已经把 focus trap 与焦点归还做成了内置行为,开发者的职责只剩两件事:命名接线(本文主线)与不要移除或隐藏可见焦点指示器。
与 ESLint 规则的联动:require-aria-label-for-modals
这套规范不是靠自觉维持的——Kibana 配套了eslint-plugin-elastic-eui。在规则映射表 eslint.md 中,与本文档直接对应的规则是:
| Rule id | 对应组件指南 | 需要人工审查的情形 |
|---|---|---|
@elastic/eui/require-aria-label-for-modals | components/overlays.md | {...props}展开会遮蔽接线,规则无法确认aria-labelledby/aria-label是否已被提供;或浮层没有可见标题而添加标题会改变 UX —— 此时应升级给人工评审而非自动修改 |
结合 SKILL.md 的工作流,修 lint 错误时的路径是:从 rule id 出发 → 跳转到本文档(overlays.md)→ 按 canonical 模式修复。这也解释了为什么文档反复强调"可访问性是写组件的一部分,而不是 lint 报错后才做的事"。
收尾自检清单
结合文档与共享原则,落地一个浮层组件时可按以下顺序自检:
- 选对了组件吗(阻塞用 Modal、非阻塞面板用 Flyout、破坏性操作用 ConfirmModal、锚定短内容用 Popover)?
- 容器上有没有
aria-labelledby(或无标题 Popover 的aria-label),且只有一种命名机制? - 引用的 id 是否来自
useGeneratedHtmlId()/htmlIdGenerator(),并且同一个变量同时挂在标题id和容器aria-labelledby上? EuiConfirmModal是否额外接了titleProps={{ id }}?- 所有进入
aria-label的文案是否走了i18n.translate(或本地i18nTexts共享对象)? - 关闭浮层后焦点是否回到触发元素(EUI 默认行为,自定义包装时不要破坏它)?
以上六条与 overlays.md 的规范一一对应,可作为 code review 中的可执行检查项。
【免费下载链接】kibanaYour window into all of your data项目地址: https://gitcode.com/GitHub_Trending/ki/kibana
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考