OpenMetadata Radio 组件完全指南:react-aria RadioGroup 驱动的单选项表单控件实现与使用
【免费下载链接】OpenMetadataThe Open Context Layer for Data and AI , OpenMetadata is the open platform for building trusted data context and business semantics for humans, AI assistants, and agents.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMetadata
本文是 OpenMetadata UI 设计规范体系中Radio(单选框)组件的权威技术指南,完整覆盖@openmetadata/ui-core-components包中RadioGroup、RadioButton、RadioButtonBase的适用场景、结构解剖、Tailwind 设计 Token、Props/API、交互状态与测试验证。读完本文,你将掌握如何在 OpenMetadata 前端项目中正确使用 Radio 组件实现互斥单选表单,并理解其基于 react-aria 的受控/非受控模型与"border 画在::after上"的底层视觉规范。
组件定位与适用场景
Radio 组件属于 OpenMetadata UI 规范中的Base / form(基础表单)类别,状态为Stable(稳定),对应组件为@openmetadata/ui-core-components包中的RadioGroup+RadioButton(以及底层的RadioButtonBase)。官方规格文档位于 openmetadata-ui/src/main/resources/ui/specs/untitled/radio.md,源码位于 openmetadata-ui-core-components/src/main/resources/ui/src/components/base/radio-buttons。
Use when(适用场景):当用户需要从一组互斥的小选项(通常 2–5 个可见选项)中恰好选择一个时使用。所有RadioButton必须放在RadioGroup内部渲染,由RadioGroup统一持有选中值,并通过 React Context 向所有按钮下发size。
Don't use when(不适用场景):
- 允许多选 → 应使用 Checkbox;
- 二选一的开/关布尔设置 → 应使用 Toggle;
- 选项列表过长 → 应使用 Select。
这一"用什么、不用什么"的判定逻辑,在源码中同样体现在 Checkbox 的规格文档中:Checkbox 明确标注"选项为一组互斥单选时应使用RadioGroup",两个组件互为对照,共同构成 OpenMetadata 表单选择控件的完整决策树。
组件架构:三层结构与 Context 传递
从源码结构看,Radio 组件由三层构成,职责逐层分离:
RadioGroup:最外层容器,持有选中值与size,通过RadioGroupContext.Provider向下传递;RadioButton:单个选项的标签包装层,基于 react-aria 的AriaRadio,负责渲染 label 与 hint 文本;RadioButtonBase:纯视觉层,只负责绘制圆圈外观(表面、边框、内点、聚焦环、禁用态),不关心交互逻辑。
核心实现位于 radio-buttons.tsx,其 Context 定义如下:
export interface RadioGroupContextType { size?: 'sm' | 'md'; } const RadioGroupContext = createContext<RadioGroupContextType | null>(null);RadioButton在渲染时读取该 Context 并覆盖自身的size默认值:
const context = useContext(RadioGroupContext); size = context?.size ?? size;这意味着:你永远不需要在单个RadioButton上手动重复设置尺寸,只要在RadioGroup上声明一次size,整组按钮会通过 Context 自动对齐——这是该组件区别于普通"样式组件"的关键设计。从interface RadioGroupProps extends RadioGroupContextType, AriaRadioGroupProps可以看出,size同时出现在RadioGroup的 props 与 Context 类型中,类型层面也保证了这一点。
底层交互能力(选中态管理、键盘导航、焦点管理、aria-checked等无障碍语义)全部委托给 react-aria 的AriaRadioGroup/AriaRadio,OpenMetadata 侧只做视觉定制与文案包装。
Anatomy:组件结构解剖
官方规格文档给出如下结构示意:
( ) Option A ← RadioButton: circle + ::after border + inner dot when selected (•) Option B Hint ← selected shows the brand-filled dot组件由四个部分构成:
| Part | 说明 |
|---|---|
| group | RadioGroup容器,垂直flex列布局(tw:flex tw:flex-col tw:gap-4) |
| circle | RadioButtonBase绘制的圆圈:表面 +::after边框 + 内部选中圆点 |
| label | 选项主文本,位于圆圈右侧 |
| hint | 标签下方的次级说明文本(可选) |
对应到源码 radio-buttons.tsx,RadioButton的 DOM 结构是:react-aria 的AriaRadio(渲染为<label>)内部依次放置RadioButtonBase圆圈和一个tw:inline-flex tw:flex-col的文本包装层,文本层内label用<p>、hint用<span>渲染。特别值得注意的是,hint 的<span>上绑定了onClick={(event) => event.stopPropagation()},防止点击提示文本时误触发放选项的选中行为——这是一个容易被忽略但很实用的交互细节。
设计 Token 与视觉实现
Radio 组件的全部视觉呈现由tw:前缀的 Tailwind utility 类完成,规格文档给出了完整的 Token 映射表:
| Part | tw:utility |
|---|---|
| Circle surface | tw:bg-primarytw:rounded-full |
| Circle border | borderAfter→tw:after:outline-primary |
| Selected | tw:bg-brand-solidtw:after:outline-brand-solid |
| Inner dot | tw:bg-fg-white(tw:size-1.5,md 尺寸下tw:size-2) |
| Focus ring | tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring |
| Disabled | tw:border-disabledtw:bg-disabled_subtle,dot 用tw:bg-fg-disabled_subtle |
| Group layout | tw:flex tw:flex-col tw:gap-4 |
| Label / hint | tw:text-secondary/tw:text-tertiary |
对应源码 RadioButtonBase:
<div className={cx( // Border on ::after — the element's own outline is reserved for the focus ring below. 'tw:relative tw:flex tw:size-4 tw:min-h-4 tw:min-w-4 tw:cursor-pointer tw:appearance-none tw:items-center tw:justify-center tw:rounded-full tw:bg-primary', `${borderAfter} tw:after:outline-primary`, size === 'md' && 'tw:size-5 tw:min-h-5 tw:min-w-5', isSelected && !isDisabled && 'tw:bg-brand-solid tw:after:outline-brand-solid', isDisabled && 'tw:cursor-not-allowed tw:border-disabled tw:bg-disabled_subtle', isFocusVisible && 'tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring', className )}> <div className={cx( 'tw:size-1.5 tw:rounded-full tw:bg-fg-white tw:opacity-0 tw:transition-inherit-all', size === 'md' && 'tw:size-2', isDisabled && 'tw:bg-fg-disabled_subtle', isSelected && 'tw:opacity-100' )} /> </div>这段实现有两个值得深挖的设计决策:
1. 边框画在::after上,绝不使用tw:ring-*。borderAfter是定义在 tailwindClasses.ts 中的复合工具类:
export const borderAfter = 'tw:after:pointer-events-none tw:after:absolute tw:after:inset-0 tw:after:rounded-[inherit] tw:after:outline-1 tw:after:-outline-offset-1';它把边框绘制为一个绝对定位、继承圆角(tw:after:rounded-[inherit])、outline-1+-outline-offset-1的::after伪元素。规格文档特别强调:"Border is drawn on::after(viaborderAfter), nevertw:ring-*",依据是 colors.md §2.3.1:Tailwind 的ring-*实际编译为box-shadow,而WebKit 不对 box-shadow 做像素对齐(pixel-snap),在 Safari 缩放时会出现边框变细甚至消失的问题。因此项目规则明确:可见边缘禁止使用tw:ring-*,占用布局空间用tw:border-*,不占布局空间用tw:outline-*。圆圈自身的 outline 被保留给焦点环使用,边框则完全交给::after。
2. 选中圆点用透明度控制而非条件挂载。内部白色圆点始终渲染,默认tw:opacity-0,选中时切换为tw:opacity-100,并带有tw:transition-inherit-all过渡——这让选中/取消选中有平滑的动画效果,同时避免状态切换时 DOM 重建。
Props / API 详解
RadioGroup
| Prop | Type / values | Purpose |
|---|---|---|
size | sm|md(默认sm) | 通过 Context 应用到组内每个按钮 |
value/defaultValue | string | 当前选中值(受控 / 非受控两种模式) |
onChange | (value: string) => void | 选择变更回调 |
isDisabled/isRequired/isInvalid | boolean(react-aria) | 组级状态 |
orientation | horizontal|vertical | 布局方向 |
children | ReactNode | 组内的RadioButton列表 |
需要说明的是:规格文档中onChange回调签名是(value: string) => void。从源码看,RadioGroup直接透传 react-aria 的AriaRadioGroupProps,而 react-aria 的onChange原生签名是(value: T) => void——本项目约定泛型T为 string,因此实际使用中 onChange 拿到的是被选中按钮的value字符串,可直接用于状态更新或表单提交。
RadioButton
| Prop | Type / values | Purpose |
|---|---|---|
value | string(必填) | 该选项被选中时对外抛出的值 |
label | ReactNode | 圆圈右侧的主文本 |
hint | ReactNode | 主文本下方的次级说明文字 |
size | sm|md | 会被组级 Context 的 size 覆盖 |
isDisabled | boolean | 仅禁用当前单个选项 |
源码中RadioButtonProps extends AriaRadioProps且额外声明了size、label、hint与ref,其中ref类型为Ref<HTMLLabelElement>,说明单个选项最终渲染为原生<label>元素,点击整行(含文本)均可触发选中。
sm与md两种尺寸的完整样式差异如下(来自源码sizes映射):
| 维度 | sm | md |
|---|---|---|
| 圆圈尺寸 | tw:size-4(16px) | tw:size-5(20px) |
| 圆圈与文本间距 | tw:gap-2 | tw:gap-3 |
| 内点尺寸 | tw:size-1.5 | tw:size-2 |
| 文本层级间距 | 无('') | tw:gap-0.5 |
| label / hint 字号 | tw:text-sm | tw:text-md |
| 圆圈与文本对齐 | tw:mt-0.5(有文本时) | 同左 |
交互状态(States)
规格文档定义了四种核心状态:
| State | Treatment |
|---|---|
| Default | 空圆圈:tw:bg-primary+tw:after:outline-primary,cursor-pointer |
| Focus | tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring(焦点环画在元素自身 outline 上) |
| Selected | tw:bg-brand-solid+tw:after:outline-brand-solid,内部白色圆点可见 |
| Disabled | tw:border-disabled+tw:bg-disabled_subtle,cursor-not-allowed,内点tw:bg-fg-disabled_subtle |
源码中状态的组合逻辑值得注意:选中态类名带!isDisabled条件(isSelected && !isDisabled && 'tw:bg-brand-solid ...'),而禁用态类名是独立追加的——当"选中但禁用"同时成立时,视觉上以禁用态为准,内点会使用tw:bg-fg-disabled_subtle灰化处理,避免出现"高亮但不可用"的矛盾视觉。焦点环仅在键盘导航等场景下由 react-aria 的isFocusVisible驱动显示,鼠标点击不会出现多余的外圈。
实战代码示例
基础用法(受控/非受控)
规格文档给出的最小示例,实现了可见性范围选择(public / private):
import { RadioButton, RadioGroup } from '@openmetadata/ui-core-components'; <RadioGroup aria-label={t('label.visibility')} size="md" onChange={setScope}> <RadioButton label={t('label.public')} value="public" /> <RadioButton label={t('label.private')} value="private" /> </RadioGroup>;其中t(...)是 OpenMetadata 的 i18n 翻译函数,aria-label用于为无可见标题的组提供无障碍名称。如果不传onChange而传defaultValue,则进入非受控模式,由组件内部维护选中值。
Storybook 场景四件套
仓库中的 RadioButtons.stories.tsx 提供了四个官方 Story 场景,可直接作为开发参照:
1. Default(基础单选)—— 非受控模式 + 默认值:
<RadioGroup label="Select an option" defaultValue="option1"> <RadioButton label="Option 1" value="option1" /> <RadioButton label="Option 2" value="option2" /> <RadioButton label="Option 3" value="option3" /> </RadioGroup>2. Sizes(尺寸对比)—— 并排渲染两组sm/md,验证 Context 尺寸下发:
<RadioGroup defaultValue="sm1" label="Small" size="sm"> {/* Option A / B / C */} </RadioGroup> <RadioGroup defaultValue="md1" label="Medium" size="md"> {/* Option A / B / C */} </RadioGroup>3. WithHints(带提示文本)—— 适合套餐/定价类场景:
<RadioGroup defaultValue="pro" label="Pricing plan"> <RadioButton hint="Up to 5 users, 10 GB storage" label="Basic" value="basic" /> <RadioButton hint="Up to 50 users, 100 GB storage" label="Pro" value="pro" /> <RadioButton hint="Unlimited users, unlimited storage" label="Enterprise" value="enterprise" /> </RadioGroup>4. WithDisabled(单选项禁用):
<RadioGroup defaultValue="available" label="Options"> <RadioButton label="Available" value="available" /> <RadioButton isDisabled label="Disabled option" value="disabled" /> <RadioButton label="Another option" value="another" /> </RadioGroup>测试验证
组件测试位于 radio-buttons.stories.test.tsx,使用 Testing Library + Vitest,覆盖了以下关键契约:
- Story 导出面约束:
RadioButtons故事模块只允许导出Default、Sizes、WithDisabled、WithHints四个命名 Story,且明确断言不得存在已移除的StandaloneButtons(独立按钮)Story——这从测试层面强制了"RadioButton必须放在RadioGroup内使用"的组件契约; - 默认 Story 渲染:渲染后能查到 Option 1/2/3 文本;
- Sizes Story:页面中应存在恰好 2 个
radiogroup角色(getAllByRole('radiogroup')长度为 2),且每组都有完整的 A/B/C 选项; - WithHints:hint 文本(如 "Up to 5 users, 10 GB storage")正常渲染;
- WithDisabled:
getByLabelText('Disabled option')断言被禁用(toBeDisabled())。
这套测试一方面守护了无障碍语义(radiogrouprole、label 关联),另一方面把"禁用态必须真正禁用"和"禁止独立使用单选按钮"固化为回归防线。
交叉引用与延伸阅读
Radio 组件与同属 Base / form 系列的其他选择控件共同构成完整体系,官方规格文档给出如下交叉引用:
- 兄弟组件:Checkbox(多选)· Toggle(开关)· Input(文本输入)
- 样式基础:Tailwind 规范 · Tailwind 工具类参考 · colors.md(含 §2.3.1 边框规范)
如需在真实业务中查看 RadioGroup 的消费方式,可以参考 tree-select-node.tsx 等应用层组件中的实际调用模式。掌握本文所述的结构、Token 与 API 约定后,你便可以在 OpenMetadata 前端中稳定地使用 Radio 组件构建一致、可访问、符合设计规范的单选表单。
【免费下载链接】OpenMetadataThe Open Context Layer for Data and AI , OpenMetadata is the open platform for building trusted data context and business semantics for humans, AI assistants, and agents.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMetadata
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考