Material UI ToggleButton 与 ToggleButtonGroup 全解析:独占/多选、受控状态、间距定制与无障碍实现
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
ToggleButton(切换按钮)是 Material UI 中用于"把一组互相关联的选项组织在一起"的按钮组件:一组按钮共享同一个容器,选中某个选项时其他选项会相应变化。本文围绕 Material UI 官方的 Toggle Button 组件文档展开,结合当前仓库中 ToggleButton 与 ToggleButtonGroup 的源码实现,完整覆盖独占选择、多选、强制非空、尺寸/颜色/纵向布局、独立按钮、样式定制与无障碍规范,读完你可以直接照抄可用的受控代码,并理解每一处行为的底层原理与验证依据。
一、组件概览:ToggleButton 与 ToggleButtonGroup 的分工
文档给出的核心结论是:为了强调一组相关按钮的分组关系,它们应当共享一个公共容器;当ToggleButtonGroup自身带有valueprop 时,由它统一控制子按钮的选中状态。
从源码结构看,两者职责划分非常清晰:
ToggleButton(packages/mui-material/src/ToggleButton/ToggleButton.js):基于ButtonBase封装,负责渲染单个按钮、输出aria-pressed状态、响应点击。ToggleButtonGroup(packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroup.js):渲染根容器(role="group"的div),通过 React Context(ToggleButtonGroupContext)把value、size、color、fullWidth、disabled等属性下发给子按钮,并统一接管onChange。
子按钮的选中状态并不是自己判断的,而是由 isValueSelected.js 这个工具函数比对得出:
// packages/mui-material/src/ToggleButtonGroup/isValueSelected.js export default function isValueSelected(value, candidate) { if (candidate === undefined || value === undefined) { return false; } if (Array.isArray(candidate)) { return candidate.includes(value); } return value === candidate; }这说明了两点实现事实:多选时组内value是数组、用includes判断;独占选择时是单值、用===判断。同时源码中 PropTypes 的注释明确提示value 必须与组内value保持引用相等(reference equality)才能被选中——对对象、日期这类值要特别注意。
另外,子按钮在 ToggleButton.js 中通过resolveProps做属性合并,优先级为自身 props > Context(来自 ToggleButtonGroup)> 主题 defaultProps。这就是"独立按钮"与"受控按钮"能共用同一组件的原因。
二、独占选择(Exclusive selection)
独占选择下,选中某一项会自动取消其他项的选中。官方示例是文本对齐切换按钮组:左对齐、居中、右对齐三个可选,两端对齐项处于disabled状态,同一时间只有一项可被选中(见示例 ToggleButtons.js):
import * as React from 'react'; import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; export default function ToggleButtons() { const [alignment, setAlignment] = React.useState('left'); const handleAlignment = (event, newAlignment) => { setAlignment(newAlignment); }; return ( <ToggleButtonGroup value={alignment} exclusive onChange={handleAlignment} aria-label="text alignment" > <ToggleButton value="left" aria-label="left aligned"> <FormatAlignLeftIcon /> </ToggleButton> <ToggleButton value="center" aria-label="centered"> <FormatAlignCenterIcon /> </ToggleButton> <ToggleButton value="right" aria-label="right aligned"> <FormatAlignRightIcon /> </ToggleButton> <ToggleButton value="justify" aria-label="justified" disabled> <FormatAlignJustifyIcon /> </ToggleButton> </ToggleButtonGroup> ); }关键点:
exclusive让onChange的第二个参数变成单个值而非数组;- 再次点击当前选中项时,值会变成
null(文档特别提示:独占选择不强制"必须有一项处于激活状态",如需强制请见下文"强制非空"); - 每个
ToggleButton必须提供value(源码中value是isRequired的 prop),并建议用aria-label补充无障碍标签。
源码印证:ToggleButtonGroup.js 中,exclusive为 true 时 Context 下发的是handleExclusiveChange:
const handleExclusiveChange = React.useCallback( (event, buttonValue) => { if (!onChange) return; // 点击已选中项时返回 null,实现"可取消"的独占选择 onChange(event, value === buttonValue ? null : buttonValue); }, [onChange, value], );三、多选(Multiple selection)
不设置exclusive时进入多选模式,适合"加粗、斜体、下划线"这类逻辑分组选项,允许同时选中多项(示例见 ToggleButtonsMultiple.js):
const [formats, setFormats] = React.useState(() => ['bold', 'italic']); const handleFormat = (event, newFormats) => { setFormats(newFormats); }; return ( <ToggleButtonGroup value={formats} onChange={handleFormat} aria-label="text formatting"> <ToggleButton value="bold" aria-label="bold">...</ToggleButton> <ToggleButton value="italic" aria-label="italic">...</ToggleButton> <ToggleButton value="underlined" aria-label="underlined">...</ToggleButton> <ToggleButton value="color" aria-label="color" disabled>...</ToggleButton> </ToggleButtonGroup> );多选模式下onChange的第二个参数是已选中值的数组;若数组为空表示当前没有选中项。这个数组是组件内部算好再回传的,开发者无需自己维护增删逻辑。源码中的实现(ToggleButtonGroup.js)是典型的"命中则移除、未命中则追加":
const handleChange = React.useCallback( (event, buttonValue) => { if (!onChange) return; const index = value && value.indexOf(buttonValue); let newValue; if (value && index >= 0) { newValue = value.slice(); newValue.splice(index, 1); // 已选中 -> 移除 } else { newValue = value ? value.concat(buttonValue) : [buttonValue]; // 未选中 -> 追加 } onChange(event, newValue); }, [onChange, value], );四、强制非空(Enforce value set)
独占选择允许"全部取消"(value 为null),多选允许"全部反选"(数组为空)。如果你的业务要求至少一项始终处于激活状态,需要自己适配onChange处理函数,过滤掉空值。官方文档给出的标准写法(完整可运行示例见 ToggleButtonNotEmpty.js):
const handleAlignment = (event, newAlignment) => { if (newAlignment !== null) { setAlignment(newAlignment); } }; const handleDevices = (event, newDevices) => { if (newDevices.length) { setDevices(newDevices); } };- 独占组:当
newAlignment !== null时才setAlignment,拦截"点击已选项导致 null"的情况; - 多选组:当
newDevices.length非零时才setDevices,拦截"反选到空数组"的情况。
该示例用Stack并排放置了"文本对齐(独占)"和"设备类型(多选)"两组按钮,是理解两种拦截方式最直接的参照。
五、尺寸、颜色与纵向布局
size 尺寸
通过sizeprop 设置small/medium/large(默认medium)。从源码的 variants 可以看到三档对应的具体样式(ToggleButton.js):
| size | padding | 字号 |
|---|---|---|
| small | 7px | 13px(typography.pxToRem(13)) |
| medium(默认) | 11px | 继承theme.typography.button |
| large | 15px | 15px(typography.pxToRem(15)) |
size设在ToggleButtonGroup上会经 Context 下发到所有子按钮,单个ToggleButton上设置则只影响自身。
color 颜色
选中态颜色由colorprop 控制,取值'standard'(默认)、'primary'、'secondary'、'error'、'info'、'success'、'warning'或自定义调色板颜色。源码中:standard使用palette.text.primary按palette.action.selectedOpacity计算选中背景;其余颜色值遍历theme.palette后映射到palette[color].main计算。这里用了createSimplePaletteValueFilter过滤调色板,因此主题中自定义的颜色键同样可用。color同样支持在组或单个按钮上设置(示例 ColorToggleButton.js)。
orientation 纵向按钮
设置orientation="vertical"后按钮纵向堆叠(示例 VerticalToggleButtons.js)。从源码看,根容器默认是display: 'inline-flex',纵向模式通过 variant 切换为flexDirection: 'column',并相应调整首/中/末按钮的圆角与分隔边框。测试用例也验证了这一点:纵向组会带有MuiToggleButtonGroup-vertical类,且内部按钮带有MuiToggleButtonGroup-grouped类(见 ToggleButtonGroup.test.js)。
此外fullWidth(默认 false)可让按钮组撑满容器宽度;disabled(默认 false)设在组上会禁用全部子按钮——测试用例验证了此时每个按钮都带disabled属性。
六、独立切换按钮(Standalone toggle button)
ToggleButton不依赖ToggleButtonGroup也能独立工作(示例 StandaloneToggleButton.js):此时value与selected完全由你自己控制,onChange回调会收到(event, value)。
这与源码的 Context 合并机制一致:当按钮不在ToggleButtonGroup内部时,Context 中没有value,isValueSelected返回 false,selected完全取自身 prop;同时isRovingTabIndex为 falsy,按钮不会被包进RovingToggleButton,而是直接把ref挂到ToggleButtonRoot上。
七、样式定制:overrides 与按钮间距
用 styled 重写组内样式
官方定制示例(CustomizedDividers.js)用styled包裹ToggleButtonGroup,让按钮之间出现间隙、各自拥有完整圆角,并用Divider分隔两组:
import { styled } from '@mui/material/styles'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup, { toggleButtonGroupClasses, } from '@mui/material/ToggleButtonGroup'; const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ [`& .${toggleButtonGroupClasses.grouped}`]: { margin: theme.spacing(0.5), border: 0, borderRadius: theme.shape.borderRadius, [`&.${toggleButtonGroupClasses.disabled}`]: { border: 0, }, }, [`& .${toggleButtonGroupClasses.middleButton},& .${toggleButtonGroupClasses.lastButton}`]: { marginLeft: -1, borderLeft: '1px solid transparent', }, }));示例中再外层套一个带 1px 边框的Paper,内部用Divider flexItem orientation="vertical"分隔"对齐(独占)"和"格式(多选)"两个按钮组,得到类似工具栏分隔条的视觉效果。更完整的覆盖写法(含styleOverrides)可参考仓库定制文档目录 docs/data/material/customization/。
用 utility classes 精确控制间距
ToggleButtonGroup导出的类名常量定义在 toggleButtonGroupClasses.ts,共有 10 个类:root、selected、horizontal、vertical、disabled、grouped、fullWidth、firstButton、lastButton、middleButton。
默认样式下,相邻按钮通过marginLeft: -1(或纵向的marginTop: -1)与1px solid transparent边框实现"共享一条边框"的紧凑外观。要拉开间距,核心是补回每个按钮独立缺失的一侧边框并恢复四角圆角。官方横向间距示例(HorizontalSpacingToggleButton.js):
const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ gap: '2rem', [`& .${toggleButtonGroupClasses.firstButton}, & .${toggleButtonGroupClasses.middleButton}`]: { borderTopRightRadius: (theme.vars || theme).shape.borderRadius, borderBottomRightRadius: (theme.vars || theme).shape.borderRadius, }, [`& .${toggleButtonGroupClasses.lastButton}, & .${toggleButtonGroupClasses.middleButton}`]: { borderTopLeftRadius: (theme.vars || theme).shape.borderRadius, borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius, borderLeft: `1px solid ${(theme.vars || theme).palette.divider}`, }, [`& .${toggleButtonGroupClasses.lastButton}.${toggleButtonClasses.disabled}, & .${toggleButtonGroupClasses.middleButton}.${toggleButtonClasses.disabled}`]: { borderLeft: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, }, }));纵向间距示例(VerticalSpacingToggleButton.js)思路相同,只是把方向换成上下:给lastButton/middleButton补borderTop,并恢复各自的上/下角圆角,同时记得为 disabled 状态单独指定边框颜色,避免禁用按钮边框消失。
八、无障碍(Accessibility)
ARIA
ToggleButtonGroup渲染的根元素带role="group",需要为组提供可访问名称:aria-label="..."、aria-labelledby="id"或<label>均可;ToggleButton会根据选中状态设置aria-pressed="<bool>",每个按钮也应通过aria-label提供标签(尤其当按钮内容只有图标时)。
这两点都可在源码中直接验证:ToggleButtonGroup.js 的根节点硬编码了role="group",ToggleButton.js 中ToggleButtonRoot带aria-pressed={selected}。对应的测试断言了渲染结果中存在名为 "my group" 的group角色(ToggleButtonGroup.test.js)。
键盘交互
组使用单一 Tab 停靠点(single tab stop):
- 横向组用左右方向键移动焦点,纵向组用上下方向键;
Home/End跳到第一个/最后一个启用按钮;- 焦点移动会回绕、跳过禁用按钮,且不改变选中值;
Space或Enter切换当前聚焦按钮。
实现上由useRovingTabIndex体系驱动:ToggleButtonGroup通过useRovingTabIndexRoot在容器上接管onFocus/onKeyDown(ToggleButtonGroup.js),每个子按钮在组内会被RovingToggleButton包裹(RovingToggleButton.tsx)以动态分配tabIndex。该文件里还有一个值得注意的细节:在服务端渲染及客户端首帧(item 注册 effect 尚未运行)期间,会给已选中的按钮回退tabIndex={0},保证渲染出的 DOM 立即可键盘访问且水合一致。测试用例同样验证了"单一 tab stop 且跳过禁用按钮":渲染一个禁用按钮加两个正常按钮后,三者的tabIndex为[-1, 0, -1](ToggleButtonGroup.test.js)。
九、API 速查
ToggleButtonGroup
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | any | — | 当前选中值;exclusive为 false 时是数组。值需与子按钮 value 引用相等 |
exclusive | bool | false | 为 true 时仅允许选中一个子按钮 |
onChange | func | — | 值变化回调(event, value);独占时 value 为单值(可为 null),多选时 value 为数组(可为空数组) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | 布局方向 |
size | 'small' | 'medium' | 'large' | 'medium' | 按钮尺寸,下发给子按钮 |
color | 'standard' | 'primary' | ... | 'standard' | 选中态颜色,支持自定义主题色 |
fullWidth | bool | false | 是否占满容器宽度 |
disabled | bool | false | 禁用整组(所有子按钮) |
className/classes/sx | — | — | 样式定制入口 |
ToggleButton
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | any | 必填 | 按钮关联的值,组内据此判断选中 |
selected | bool | — | 独立使用时手动控制激活态 |
disabled | bool | false | 禁用 |
color/size/fullWidth | — | 'standard' / 'medium' / false | 独立设置时覆盖组下发值 |
onChange/onClick | func | — | 均收到(event, value);onClick中preventDefault()可阻断onChange |
disableFocusRipple/disableRipple | bool | false | 禁用焦点涟漪 / 全部涟漪(后者会失去:focus-visible默认样式,需自行补.Mui-focusVisible样式) |
十、小结与延伸阅读
本文基于 Toggle Button 组件文档 与仓库源码展开:ToggleButtonGroup通过 Context 下发受控状态并统一处理独占/多选的取值逻辑,ToggleButton通过属性合并与isValueSelected完成选中判断,roving tab index 体系保障了"单 Tab 停靠点 + 方向键/Space/Enter"的键盘体验。如需继续深入,建议按路径阅读:
- 组件实现:packages/mui-material/src/ToggleButton/ 与 packages/mui-material/src/ToggleButtonGroup/;
- 测试用例:ToggleButton.test.js、ToggleButtonGroup.test.js、isValueSelected.test.js;
- 官方示例:docs/data/material/components/toggle-button/ 目录下的各 demo 文件均可直接复制使用。
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考