feat: modernize RadioButton for MD3 - #5007
Conversation
|
can you rebase the PR against main? |
There was a problem hiding this comment.
Pull request overview
This PR modernizes RadioButton for Material Design 3 by replacing the prior platform-specific implementations with a single unified component, introducing MD3 token-based sizing/color resolution, and aligning behavior (animation, error/disabled states, a11y, and group performance) with the library’s other MD3 selection controls.
Changes:
- Unifies
RadioButtoninto a single MD3 implementation (removingRadioButton.Android,RadioButton.IOS, and theRadioButton.Itemmodeprop) and migrates selection animation toreact-native-reanimated. - Adds MD3 radio tokens +
errorsupport, applies disabled opacity consistently, and updates selection-control color resolution to use tokenized theme roles. - Improves
RadioButton.Groupcontext stability and updates tests/examples/docs accordingly (including removing platform-specific docs pages).
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.tsx | Removes exported platform-specific RadioButton prop types. |
| src/components/RadioButton/utils.ts | Updates selection-control color resolution to use RadioButton tokens and removes iOS-specific helper. |
| src/components/RadioButton/tokens.ts | Introduces MD3 radio sizing + color-role tokens. |
| src/components/RadioButton/RadioButtonItem.tsx | Removes mode, adds error, switches to useContext, and adjusts a11y/testID behavior. |
| src/components/RadioButton/RadioButtonGroup.tsx | Memoizes context value and stabilizes onValueChange with use-latest-callback. |
| src/components/RadioButton/RadioButtonAndroid.tsx | Deletes old Android-specific implementation. |
| src/components/RadioButton/RadioButtonIOS.tsx | Deletes old iOS-specific implementation. |
| src/components/RadioButton/RadioButton.tsx | Implements unified MD3 RadioButton with Reanimated dot-scale animation, tokenized sizes/colors, and updated a11y behavior. |
| src/components/RadioButton/index.ts | Removes .Android/.IOS static exports from RadioButton. |
| src/components/tests/RadioButton/utils.test.tsx | Updates tests to cover getSelectionControlColor including error/checked/unchecked cases. |
| src/components/tests/RadioButton/RadioButtonItem.test.tsx | Removes platform-mode tests and adds a11y single-node assertion. |
| src/components/tests/RadioButton/RadioButtonGroup.test.tsx | Adds test asserting stable context object across unrelated parent rerenders. |
| src/components/tests/RadioButton/RadioButton.test.tsx | Simplifies snapshots to single unified RadioButton implementation. |
| src/components/tests/RadioButton/snapshots/RadioButtonItem.test.tsx.snap | Updates snapshots for new unified control + a11y changes. |
| src/components/tests/RadioButton/snapshots/RadioButtonGroup.test.tsx.snap | Updates snapshots for new unified control rendering. |
| src/components/tests/RadioButton/snapshots/RadioButton.test.tsx.snap | Updates snapshots for unified control rendering. |
| example/src/Examples/RadioButtonItemExample.tsx | Updates example to remove mode variants and demonstrate error/disabled states. |
| example/src/Examples/RadioButtonGroupExample.tsx | Updates example usage to RadioButton (no platform statics). |
| example/src/Examples/RadioButtonExample.tsx | Updates example states, adds error demos, and adjusts disabled label styling. |
| docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonItem.mdx | Removes documentation for the deleted mode prop. |
| docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonIOS.mdx | Removes iOS-specific docs page. |
| docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonAndroid.mdx | Removes Android-specific docs page. |
| docs/docusaurus.config.js | Removes nav entries for the deleted platform-specific docs pages. |
| <Text | ||
| variant={labelVariant} | ||
| testID={`${testID}-text`} | ||
| style={[styles.label, computedStyle, labelStyle]} | ||
| maxFontSizeMultiplier={labelMaxFontSizeMultiplier} | ||
| > |
| /** | ||
| * Function to execute on press. | ||
| */ | ||
| onPress?: (e: GestureResponderEvent) => void; | ||
| onPress?: (param?: any) => void; | ||
| /** |
| return ( | ||
| <TouchableRipple | ||
| {...rest} | ||
| borderless | ||
| onPress={(event) => { | ||
| handlePress({ | ||
| onPress, | ||
| onValueChange: context?.onValueChange, | ||
| value, | ||
| event, | ||
| }); | ||
| }} | ||
| disabled={disabled} | ||
| {...accessibilityProps} | ||
| style={styles.container} | ||
| testID={testID} | ||
| theme={theme} | ||
| > |
cb74fc8 to
c0e1c18
Compare
- Fix accessibilityLabel variable reference (ariaLabel, not accessibilityLabel) - Make sync render() calls async in RadioButton and RadioButtonItem tests - Await rerender() in RadioButtonGroup stability test - Use screen.queryAllByRole instead of destructuring from render result - Update snapshots to reflect accessibilityRole instead of role prop
Nested ternary indentation left misaligned after conflict resolution.
RadioButtonAndroid and RadioButtonIOS were removed but the navigation index still referenced them, breaking the docs build.
RadioButtonAndroid and RadioButtonIOS source mappings caused the docs build to fail with ENOENT since the files no longer exist.
- Restore onPress type to (e: GestureResponderEvent) => void to match TouchableRipple - Merge consumer style with container style, handling function-style props - Guard RadioButtonItem label testID to avoid "undefined-text" when testID is not provided
| // The outer TouchableRipple is the interactable element + a11y radio; the | ||
| // inner control is purely visual, so exclude it from the a11y tree to | ||
| // avoid duplicate `checked` states. | ||
| const radioButton = <RadioButton {...radioButtonProps} accessible={false} />; |
There was a problem hiding this comment.
what about preventing visual-only radio from receiving keyboard focus?
accessible={false} removes its accessibility semantics, but updated snapshot still shows focusable={true} (source)
additionally, react-native-web defaults enabled Pressable to tabIndex={0} (RNW sourcecode), leaving unlabeled second tab stop inside RadioButton.Item
so what about this change?
| const radioButton = <RadioButton {...radioButtonProps} accessible={false} />; | |
| const radioButton = <RadioButton {...radioButtonProps} accessible={false} focusable={false} tabIndex={-1} />; |
| const sizes = { | ||
| ringSize: 20, | ||
| dotSize: 10, | ||
| outlineWidth: 2, | ||
| } as const; |
There was a problem hiding this comment.
what about adding an explicit state-layer size token & sizing touchable accordingly?
current 20dp ring with 8dp margins produces 36dp state layer, while MD3 defines radio state layer as 40dp.
modernized Checkbox component already uses the same 40dp state-layer token (source)
so could we change it to:
| const sizes = { | |
| ringSize: 20, | |
| dotSize: 10, | |
| outlineWidth: 2, | |
| } as const; | |
| const sizes = { | |
| ringSize: 20, | |
| dotSize: 10, | |
| outlineWidth: 2, | |
| stateLayerSize: 40, | |
| } as const; |
and use like that in RadioButton.tsx:
const {
ringSize,
dotSize,
outlineWidth: OUTLINE_WIDTH,
stateLayerSize,
} = RadioButtonTokens;
const styles = StyleSheet.create({
container: {
width: stateLayerSize,
height: stateLayerSize,
borderRadius: stateLayerSize / 2,
alignItems: 'center',
justifyContent: 'center',
},
radio: {
height: ringSize,
width: ringSize,
borderRadius: ringSize / 2,
borderWidth: OUTLINE_WIDTH,
},
});…into feat/radio-button-md3 # Conflicts: # src/components/RadioButton/RadioButtonAndroid.tsx # src/components/RadioButton/RadioButtonIOS.tsx # src/components/RadioButton/RadioButtonItem.tsx
MikitasK
left a comment
There was a problem hiding this comment.
- updated radio-button state layer to MD3 specified 40dp size while keeping visible ring centered at 20dp
- added reusable
stateLayerSizetoken & removed previous margin-based state-layer sizing - made nested visual control inside
RadioButton.Itemnon-focusable withfocusable={false}andtabIndex={-1}keeping outer item as the only interactive radio - added regression coverage for
40dpstate layer & updated affected accessibility & component snapshots
| const accessibilityProps = | ||
| rest.accessible === false | ||
| ? {} | ||
| : { | ||
| accessibilityRole: 'radio' as const, | ||
| accessibilityState: { disabled: !!disabled, checked }, | ||
| }; |
There was a problem hiding this comment.
- accessibilityRole: 'radio' as const,
- accessibilityState: { disabled: !!disabled, checked },
+ role: 'radio' as const,
+ 'aria-disabled': !!disabled,
+ 'aria-checked': checked,466d9c608 (#5005) moved the library to aria-* and src/ on main has none of these props left. Same revert at RadioButtonItem.tsx:207-212 (accessibilityLabel / Role / State) and RadioButtonGroup.tsx:69 (accessibilityRole="radiogroup"; main has role). Checkbox.tsx:249-256 is the shape to copy under the identical rest.accessible === false guard, and CheckboxItem.tsx:157-160 for the row. Snapshots need regenerating after.
| {checked ? ( | ||
| <View style={[StyleSheet.absoluteFill, styles.radioContainer]}> | ||
| <Animated.View | ||
| style={[ | ||
| styles.dot, | ||
| { backgroundColor: selectionControlColor }, | ||
| dotAnimatedStyle, | ||
| ]} | ||
| /> | ||
| </View> | ||
| ) : null} |
There was a problem hiding this comment.
The StyleSheet.absoluteFill + radioContainer wrapper only existed because the old code animated borderWidth. The outline is a constant now, so it can go: put alignItems: 'center' / justifyContent: 'center' on styles.radio, render the Animated.View dot directly as its child, and drop styles.radioContainer.
|
@MikitasK is this ready for another review? |
Motivation
RadioButtonstill shipped as two platform-specific implementations (RadioButtonAndroid,RadioButtonIOS) with the iOS variant rendering a checkmark glyph rather than a Material radio. This is inconsistent with the rest of the v6 MD3 work (Checkbox, Switch, TextInput, FAB) and diverges from the M3 radio button spec.This PR unifies RadioButton into a single MD3 component that mirrors the modernized Checkbox structure (single component,
tokens.ts,errorprop,accessible={false}inner control), without pulling in Checkbox's reanimated/focus-ring machinery (out of scope — radio ships with none today).Related issue
Closes #4938
What changed
RadioButtonAndroid+RadioButtonIOSinto a single MD3RadioButton(Android impl as base). DroppedRadioButton.Android,RadioButton.IOS,RadioButtonIOS, and theRadioButton.Itemmodeprop.RadioButton/tokens.ts(MD3 dims20/10/2+ color roles); colors resolved via tokens inutils.errorprop — added toRadioButtonandRadioButton.Item(ring + dot usetheme.colors.error;disabled/custom colors take precedence).Animated.Values into one shared value and migrated toreact-native-reanimated(matching Checkbox): dot scales in with overshoot on selection, keyed oncheckedso it also animates inside aRadioButton.Group. Usestheme.motionMD3 duration/easing tokens and respects reduce-motion.selectionControlOpacitywas previously computed but never applied).onValueChangewithuse-latest-callback; consumers switched fromContext.ConsumertouseContext.accessible={false}+ containerimportantForAccessibility="no-hide-descendants", so a screen reader sees one radio per row; dropped per-radioaccessibilityLiveRegion;labelMaxFontSizeMultiplier=1.5+${testID}-textfor CheckboxItem parity.RadioButtonAndroid/RadioButtonIOSdoc pages.Breaking changes
RadioButton.Android,RadioButton.IOS,RadioButtonIOS(andRadioButtonAndroidProps/RadioButtonIOSPropsexports).RadioButton.Itemmodeprop.errorprop added (no visual change for existing usage).Test plan
yarn typescript,yarn lint,yarn test(snapshots updated) — green.yarn --cwd docs build— green.Videos
radio_ios.mp4
radio_android.mp4
radio_web.mp4