深入解析 Carbon Web Components 的 cds-radio-button:渲染快照、属性语义与组内交互机制
【免费下载链接】carbonA design system built by IBM项目地址: https://gitcode.com/GitHub_Trending/carbo/carbon
cds-radio-button是 IBM Carbon Design System 的 Web Components 实现(@carbon/web-components,当前仓库版本为 2.63.0)中用于"互斥单选"场景的表单控件。它通过 Shadow DOM 封装原生<input type="radio">与<label>,配合父组件cds-radio-button-group维护受控状态。本文以仓库中的渲染快照文档 packages/web-components/tests/snapshots/cds-radio-button.md 为主体,逐行剖析其 Shadow DOM 渲染结构,并结合 radio-button.ts 与 radio-button-group.ts 的源码,讲清每个属性的取值、默认值、语义以及组内通信、键盘导航与表单提交的底层实现。
一、快照文档说明了什么
渲染快照是组件测试体系中的"契约文件":单元测试通过toMatchSnapshot将组件渲染出的 Shadow DOM 与快照逐一比对,任何结构性变更都会造成快照 diff,从而驱动开发者审慎评估改动。快照文档记录了cds-radio-button的两类典型渲染结果:
- 最小属性渲染(Should render with minimum attributes):组件不带任何显式属性时的默认 DOM 结构;
- 多属性渲染(Should render with various attributes):同时携带
disabled、name、label-position、orientation、hide-label、label-text等属性时的 DOM 结构。
对应的测试用例定义在 radio-button_spec.ts 中,通过Playground模板渲染cds-radio-button-group,再断言document.body.querySelector('cds-radio-button[value="staging"]')的快照(mode: 'shadow',即只比较 Shadow DOM)。这说明快照里的value="staging"来自测试模板传入的value属性,而非硬编码。
二、Shadow DOM 渲染结构逐层拆解
2.1 最小属性渲染:组件的"默认骨架"
当<cds-radio-button>不带任何属性时,渲染结果如下(摘录自快照文档):
<input class="cds--radio-button" id="input" tabindex="-1" type="radio" value="staging" > <label class="cds--radio-button__label" for="input" > <span class="cds--radio-button__appearance"> </span> <span> <slot> </slot> </span> </label>这段模板与 radio-button.ts 中的render()方法一一对应,共包含三层结构:
- 原生
<input type="radio">:承载真实的表单语义。默认class="cds--radio-button"、id="input"、tabindex="-1",type恒为radio。注意tabindex的默认值是-1,即默认状态下所有单选项都不在 Tab 键序内——这是"单选组内只保留一个可聚焦项"这一无障碍约定的直接体现。 <label for="input">:通过for/id配对把整块可点击区域(圆形外观 + 文本)关联到隐藏的原生 input。__tests__/radio-button-test.js中的should associate the label with the input via matching for/id用例专门验证了这一关联(见 radio-button-test.js)。- 外观与插槽:
<span class="cds--radio-button__appearance">:纯 CSS 绘制的圆形外观(选中态的小圆点由::before伪元素呈现,样式定义在 radio-button.scss);- 内层
<span>包裹一个<slot>,用于投射使用者的自定义标签内容。快照中该<slot>为空,对应"最小属性"状态。
从源码看,value属性通过ifDefined(value)注入 input(见 radio-button.ts),因此未设置value时 input 上不会出现value属性。快照中出现的value="staging"即测试模板显式传入的属性。
2.2 多属性渲染:状态属性的 DOM 反馈
快照文档记录的第二种渲染结果如下:
<input class="cds--radio-button" disabled="" id="input" name="name-foo" tabindex="0" type="radio" value="staging" > <label class="cds--radio-button__label" for="input" > <span class="cds--radio-button__appearance"> </span> <span class="cds--visually-hidden"> <slot> label-text-foo </slot> </span> </label>与最小属性渲染相比,出现了四处差异,逐一说明其来源与语义:
disabled="":组件的disabled布尔属性开启后,?disabled="${disabledItem || disabled}"将属性透传到原生 input(radio-button.ts)。测试模板中cds-radio-button-group传入了disabled: true,并通过组的属性传播机制下发到每个子项(下文详述)。name="name-foo":name是单选组的分组标识,同组单选按钮共享同一个name。测试模板在组上设置了name: 'name-foo',同样被传播到子项。该属性是RadioGroupManager按组管理的键(见 radio-group-manager.ts)。tabindex="0":这是快照中一个关键状态。在"多属性"用例里,当前按钮value="staging"被组选中(value与组的值匹配),因此RadioGroupManager.shouldBeFocusable()返回true,input 的tabindex被置为0(可聚焦);其余未选中项保持-1。该逻辑位于 radio-button.ts 的updated()钩子中。class="cds--visually-hidden"与插槽回退内容label-text-foo:hideLabel为true时,标签文本 span 追加cds--visually-hidden类(视觉隐藏但保留给屏幕阅读器),且<slot>内以labelText作为回退内容渲染(<slot> ${labelText} </slot>)。测试模板传入了label-text="label-text-foo"与hideLabel: true,对应快照中的插槽文本。
三、属性全景:属性名、取值与默认值
结合 radio-button.ts、radio-button-group.ts 与 defs.ts,将两个组件的核心属性整理如下。
3.1cds-radio-button属性
| 属性(Attribute) | 类型 | 默认值 | 说明 |
|---|---|---|---|
checked | Boolean | false | 当前是否选中,reflect: true(会反映为属性) |
default-checked | Boolean | false | 是否默认选中;在firstUpdated()中若用户未显式设置checked则应用(radio-button.ts) |
disabled | Boolean | false | 是否禁用(组级禁用会传播到此) |
disabled-item | Boolean | false | 仅禁用当前单项 |
hide-label | Boolean | false | 视觉隐藏标签但保留可访问性 |
invalid | Boolean | false | 校验失败状态 |
warn/warn-text | Boolean / String | false/'' | 警告状态及提示文本 |
label-position | 'left' \| 'right' | 'right' | 标签位置,枚举定义见 defs.ts |
label-text | String | '' | 标签文本,作为<slot>的回退内容 |
name | String | 未定义 | 分组名,组内互斥的键 |
orientation | 'horizontal' \| 'vertical' | 'horizontal' | 布局方向,枚举见 defs.ts |
read-only | Boolean | false | 只读:点击/键盘激活均不改变状态,input 上反映aria-readonly |
required | Boolean | false | 必填标记,透传到 input |
value | String | 未定义 | 提交给表单的值 |
3.2cds-radio-button-group属性
| 属性(Attribute) | 类型 | 默认值 | 说明 |
|---|---|---|---|
legend-text | String | '' | 组标题,渲染为<fieldset>内的<legend>(radio-button-group.ts) |
helper-text | String | 未定义 | 辅助说明文本,通过aria-describedby关联到<fieldset> |
invalid/invalid-text | Boolean / String | false/'' | 校验失败状态与错误消息,消息通过aria-describedby关联 |
warn/warn-text | Boolean / String | false/'' | 警告状态与警告消息 |
name | String | 未定义 | 组名,传播到所有子项 |
value | String | 未定义 | 当前选中值;变化时同步各子项checked |
label-position | 'left' \| 'right' | 'right' | 传播到子项 |
orientation | 'horizontal' \| 'vertical' | 'horizontal' | 传播到子项 |
disabled/read-only/required | Boolean | false | 传播到子项;disabled还会原生禁用<fieldset> |
注意orientation与label-position都带有reflect: true,因此组件实例的 HTML 属性会同步反映这些状态,SCSS 侧也依赖属性选择器做样式适配(见 radio-button.scss)。
四、组与项:受控状态的通信机制
cds-radio-button的官方用法强调以cds-radio-button-group为父容器来维护受控状态。在快照测试中,所有用例也都是以组为单位渲染的。二者的协作分为三个层面。
4.1 属性向下传播
在 radio-button-group.ts 的updated()中,组会监听disabled、labelPosition、orientation、readOnly、name、required、value、invalid等属性变化,并遍历this.querySelectorAll('cds-radio-button')逐个赋值。源码注释明确指出这是为了绕开:host-context()尚未在所有主流浏览器得到完整支持的限制,用显式传播保证子树一致性。
radio-button_spec.ts的Communication between <cds-radio-button-group> and <cds-radio-button>分组(radio-button_spec.ts)用断言验证了这种传播:例如组设置disabled: true后,所有cds-radio-button的disabled均为true;组设置label-position="left"后,所有子项labelPosition均为'left'。
4.2 事件向上冒泡
单选按钮点击后,CDSRadioButton._handleClick会触发cds-radio-button-changed自定义事件(bubbles: true, composed: true,见 radio-button.ts)。组通过@HostListener('eventChangeRadioButton')监听该事件(radio-button-group.ts),从子项中找出checked的那一个,将其value同步为组的value;若值确有变化,再向上冒泡cds-radio-button-group-changed事件,携带{ value, name, event }。
4.3 表单参与(formdata 事件)
组继承自FormMixin,实现了_handleFormdata(radio-button-group.ts):当且仅当组未禁用、且name与value均已定义时,才向FormData追加name -> value。radio-button_spec.ts的Event-based form participation分组(radio-button_spec.ts)用四个用例覆盖了边界:缺name不提交、未选中不提交、disabled不提交,而{ name: 'name-foo', value: 'staging' }时提交结果恰为{ 'name-foo': 'staging' }。
五、键盘导航:RadioGroupManager 的实现细节
单选组遵循 ARIA 单选组模式:组内同一时刻只有一个可聚焦项。这一机制的底层是 radio-group-manager.ts 中的RadioGroupManager——每个 document 只维护一个实例(WeakMap缓存,见 L218-L227),以name为键管理组内成员。
核心方法包括:
shouldBeFocusable(radio)(L84-L98):可聚焦的条件是"该项已选中"或"组内无选中项且该项是组内第一个",由此决定 input 的tabindex是0还是-1。select(radio, readOnly?)(L167-L186):将被选中项checked置为true、tabIndex置0并聚焦,同时把组内其他项checked置false、tabIndex置-1;若目标是禁用项则提前返回。readOnly模式下也会同步选中状态但不允许用户改选。navigate(radio, direction)(L193-L213):在按 DOM 顺序排序后的组内做环形(circular)查找,跳过禁用项;若全部禁用则停留在当前项。
方向映射在 radio-button.ts 中定义:
- 水平布局(horizontal):
ArrowLeft/Left(IE)为后退,ArrowRight/Right为前进; - 垂直布局(vertical):
ArrowUp/Up为后退,ArrowDown/Down为前进。
keydown处理器(L196-L237)还支持' '(空格)与Enter直接选中当前聚焦项。radio-button_spec.ts的Keyboard navigation分组(radio-button_spec.ts)验证了:水平模式下按ArrowRight后tabindex数组由[-1, 0, -1]变为[0, -1, -1](焦点随选中项移动),垂直模式同理。
六、样式、校验状态与骨架屏
6.1 样式入口
radio-button.scss 通过@use '@carbon/styles/scss/components/radio-button/radio-button'引入 Carbon 基础样式,并针对 Web Components 的:host()选择器做适配:
:host(cds-radio-button-group)继承cds--form-item,label-position='left'时追加cds--radio-button-group--label-left;- 垂直布局下按钮间距改为
margin-block-end: 6px且无水平间距(L67-L70); - 校验失败时
cds--radio-button__appearance的边框使用$support-error(L72-L79); disabled/disabled-item下标签变灰($text-disabled)、光标为not-allowed(L95-L109)。
6.2 校验与警告状态的优先级
组在渲染时计算三个互斥分支(radio-button-group.ts):
showInvalid = !readOnly && !disabled && invalid showWarning = !readOnly && !disabled && !invalid && warn showHelper = !invalid && !disabled && !warn即invalid > warn > helper的优先级,且readOnly、disabled会抑制全部校验文案。错误/警告图标通过iconLoader注入WarningFilled16/WarningAltFilled16。对应单元测试见 radio-button-group-test.js。
6.3 骨架屏
加载占位由cds-radio-button-skeleton提供,其模板仅含两个带cds--skeleton类的 div/span(见 radio-button-skeleton.ts),在 Storybook 的Skeletonstory 中展示。
七、组件入口与使用示例
7.1 注册入口
index.ts 会同时注册三个元素:
import './radio-button'; import './radio-button-group'; import './radio-button-skeleton';在浏览器中可直接按包内子路径引入:
import '@carbon/web-components/es/components/radio-button/index.js';(与 radio-button-test.js 的引用方式一致。)
7.2 最小可用示例
<cds-radio-button-group legend-text="部署环境" name="env" value="staging" orientation="vertical"> <cds-radio-button label-text="生产环境" value="prod"></cds-radio-button> <cds-radio-button label-text="预发环境" value="staging"></cds-radio-button> <cds-radio-button label-text="测试环境" value="test" disabled-item></cds-radio-button> </cds-radio-button-group>7.3 结合校验与辅助文本的完整表单示例
<cds-radio-button-group legend-text="部署环境" name="env" helper-text="请选择目标环境" invalid invalid-text="必须选择一个环境" warn warn-text="该环境即将下线" orientation="vertical"> <cds-radio-button label-text="生产环境" value="prod"></cds-radio-button> <cds-radio-button label-text="预发环境" value="staging"></cds-radio-button> </cds-radio-button-group>如需监听选中变化,可订阅组的自定义事件:
groupEl.addEventListener('cds-radio-button-group-changed', (event) => { console.log(event.detail.value, event.detail.name); });八、快照测试与回归保障
快照文档是组件测试契约的一部分,仓库中围绕cds-radio-button共有三层测试:
- 快照测试:radio-button_spec.ts 驱动生成 cds-radio-button.md 快照,验证 Shadow DOM 结构;
- Open WC 单元测试:radio-button-test.js 与 radio-button-group-test.js 覆盖渲染、属性反射、
readOnly防点击、aria-readonly、校验/警告文案展示、legend与fieldset结构、禁用防更改等行为; - Storybook 交互示例:radio-button.stories.ts 提供
Default、Vertical、Skeleton、WithAILabel等 story,其中argTypes完整罗列了组件 API 与控件类型,可直接用于调试。
开发者如需更新快照,可在packages/web-components目录下运行yarn test:updateSnapshots(脚本定义见 package.json),但要先确认 DOM 结构变更是有意为之。
九、总结
cds-radio-button的渲染快照揭示了其"原生 input + label + slot"的稳固 DOM 骨架:tabindex的-1/0切换体现了单选组只保留一个可聚焦项的无障碍规范,hide-label下的cds--visually-hidden兼顾视觉与可访问性,而name、value的透传则保证了原生表单语义不被 Shadow DOM 隔离破坏。配合cds-radio-button-group的属性传播、事件冒泡与RadioGroupManager的环形键盘导航,整套实现既保持了 Web Components 的封装性,又完整继承了原生 radio 的可访问性与表单行为——这正是快照文档背后值得深入理解的设计价值。
【免费下载链接】carbonA design system built by IBM项目地址: https://gitcode.com/GitHub_Trending/carbo/carbon
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考