diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..f53d8f565f 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -285,6 +285,24 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
```
+### RadioButton
+
+`RadioButton` renders one Material 3 circular radio control on every platform. The platform-specific components and their prop types are gone. On iOS checked controls no longer render a checkmark; they use the same ring-and-dot indicator as other platforms. `RadioButton` now also forwards `TouchableRipple` props (`accessible`, `onFocus`, `onBlur`, `borderless`, `rippleColor`, ...).
+
+The control now occupies a 48×48dp layout box instead of 40×40dp, so rows containing it may become 8dp taller. The 20dp radio indicator is unchanged. The minimum touch target is part of the layout on every platform, rather than an invisible area added with `hitSlop`.
+
+| v5 | v6 |
+| ------------------------------------------------ | ----------------------------------------------------------------------- |
+| `RadioButton.Android`, `RadioButton.IOS` | `RadioButton` |
+| `RadioButtonAndroidProps`, `RadioButtonIOSProps` | `RadioButtonProps` |
+| `RadioButton.Item` `mode="android" \| "ios"` | `mode` removed; `RadioButton.Item` always renders `RadioButton` |
+| n/a | new `error` (uses `theme.colors.error`) and `style` (touch-target style) |
+
+```diff
+-
++
+```
+
### Searchbar
The misspelled `traileringIcon` props have been renamed:
diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 2cf106849c..1eb407109e 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -125,9 +125,7 @@ const pages = {
ProgressBar: 'ProgressBar',
RadioButton: {
RadioButton: 'RadioButton/RadioButton',
- RadioButtonAndroid: 'RadioButton/RadioButtonAndroid',
RadioButtonGroup: 'RadioButton/RadioButtonGroup',
- RadioButtonIOS: 'RadioButton/RadioButtonIOS',
RadioButtonItem: 'RadioButton/RadioButtonItem',
},
Searchbar: 'Searchbar',
diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts
index b7c59d424d..47cdf889c8 100644
--- a/docs/src/data/screenshots.ts
+++ b/docs/src/data/screenshots.ts
@@ -107,12 +107,6 @@ export const screenshots = {
Modal: 'screenshots/modal.gif',
ProgressBar: 'screenshots/progress-bar.png',
RadioButton: {
- 'Android (enabled)': 'screenshots/radio-enabled.android.png',
- 'Android (disabled)': 'screenshots/radio-disabled.android.png',
- 'iOS (enabled)': 'screenshots/radio-enabled.ios.png',
- 'iOS (disabled)': 'screenshots/radio-disabled.ios.png',
- },
- 'RadioButton.Android': {
enabled: 'screenshots/radio-enabled.android.png',
disabled: 'screenshots/radio-disabled.android.png',
},
@@ -120,10 +114,6 @@ export const screenshots = {
Android: 'screenshots/radio-button-group-android.gif',
iOS: 'screenshots/radio-button-group-ios.gif',
},
- 'RadioButton.IOS': {
- enabled: 'screenshots/radio-enabled.ios.png',
- disabled: 'screenshots/radio-disabled.ios.png',
- },
'RadioButton.Item': 'screenshots/radio-item.ios.png',
Searchbar: {
bar: 'screenshots/searchbar.png',
diff --git a/example/src/Examples/RadioButtonExample.tsx b/example/src/Examples/RadioButtonExample.tsx
index 6ab1869dd9..c30e49e00f 100644
--- a/example/src/Examples/RadioButtonExample.tsx
+++ b/example/src/Examples/RadioButtonExample.tsx
@@ -10,7 +10,7 @@ import {
import ScreenWrapper from '../ScreenWrapper';
-type State = 'normal' | 'normal-ios' | 'normal-item' | 'custom';
+type State = 'normal' | 'normal-item' | 'custom';
const RadioButtonExample = () => {
const [checked, setChecked] = React.useState('normal');
@@ -19,26 +19,15 @@ const RadioButtonExample = () => {
setChecked('normal')}>
- Normal - Material Design
+ Normal
-
- setChecked('normal-ios')}>
-
- Normal 2 - IOS
-
-
-
-
-
setChecked('custom')}>
Custom
@@ -58,11 +47,31 @@ const RadioButtonExample = () => {
onPress={() => setChecked('normal-item')}
/>
- Checked (Disabled)
+ Error (Checked)
+
+
+
+ Error (Unchecked)
+
+
+
+
+
+ Checked (Disabled)
- Unchecked (Disabled)
+ Unchecked (Disabled)
{
Second
-
+
Third
-
+
diff --git a/example/src/Examples/RadioButtonItemExample.tsx b/example/src/Examples/RadioButtonItemExample.tsx
index e640a1dd0a..efd4eee32a 100644
--- a/example/src/Examples/RadioButtonItemExample.tsx
+++ b/example/src/Examples/RadioButtonItemExample.tsx
@@ -7,55 +7,47 @@ import ScreenWrapper from '../ScreenWrapper';
const RadioButtonItemExample = () => {
const [checkedDefault, setCheckedDefault] = React.useState(true);
- const [checkedAndroid, setCheckedAndroid] = React.useState(true);
- const [checkedIOS, setCheckedIOS] = React.useState(true);
const [checkedLeadingControl, setCheckedLeadingControl] =
React.useState(true);
const [checkedDisabled, setCheckedDisabled] = React.useState(true);
+ const [checkedError, setCheckedError] = React.useState(true);
const [checkedLabelVariant, setCheckedLabelVariant] = React.useState(true);
return (
setCheckedDefault(!checkedDefault)}
value="default"
/>
setCheckedAndroid(!checkedAndroid)}
- value="android"
- />
- setCheckedIOS(!checkedIOS)}
- value="iOS"
- />
- setCheckedLeadingControl(!checkedLeadingControl)}
- value="iOS"
+ value="leading"
position="leading"
/>
setCheckedError(!checkedError)}
+ value="error"
+ error
+ />
+ setCheckedDisabled(!checkedDisabled)}
- value="iOS"
+ value="disabled"
disabled
/>
setCheckedLabelVariant(!checkedLabelVariant)}
- value="default"
+ value="variant"
/>
);
diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx
index af39325c33..4167e9b94c 100644
--- a/src/components/RadioButton/RadioButton.tsx
+++ b/src/components/RadioButton/RadioButton.tsx
@@ -1,12 +1,29 @@
-import { Platform } from 'react-native';
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
import type { GestureResponderEvent } from 'react-native';
-import RadioButtonAndroid from './RadioButtonAndroid';
-import RadioButtonIOS from './RadioButtonIOS';
+import Animated, {
+ Easing,
+ ReduceMotion,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { RadioButtonContext } from './RadioButtonGroup';
+import { RadioButtonTokens } from './tokens';
+import { getSelectionControlColor, handlePress, isChecked } from './utils';
import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../../theme/types';
+import type { PressableStateCallbackType } from '../TouchableRipple/Pressable';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
-export type Props = {
+export type Props = Omit<
+ React.PropsWithoutRef,
+ 'children'
+> & {
/**
* Value of the radio button
*/
@@ -31,6 +48,12 @@ export type Props = {
* Custom color for radio.
*/
color?: string;
+ /**
+ * Whether the radio button is in an error state. When true, the ring
+ * (unchecked) and dot (selected) use `theme.colors.error`. `disabled`
+ * and explicit `color`/`uncheckedColor` overrides take precedence.
+ */
+ error?: boolean;
/**
* @optional
*/
@@ -41,6 +64,14 @@ export type Props = {
testID?: string;
};
+const {
+ ringSize,
+ dotSize,
+ outlineWidth: OUTLINE_WIDTH,
+ stateLayerSize,
+ touchTargetSize,
+} = RadioButtonTokens;
+
/**
* Radio buttons allow the selection a single option from a set.
*
@@ -71,16 +102,177 @@ export type Props = {
*
* export default MyComponent;
* ```
+ *
+ * @extends TouchableRipple props https://callstack.github.io/react-native-paper/docs/components/TouchableRipple
*/
-const RadioButton = ({ theme: themeOverrides, ...props }: Props) => {
+const RadioButton = ({
+ disabled,
+ onPress,
+ theme: themeOverrides,
+ value,
+ status,
+ testID,
+ error,
+ ...rest
+}: Props) => {
const theme = useInternalTheme(themeOverrides);
+ const context = React.useContext(RadioButtonContext);
+
+ const checked =
+ isChecked({
+ contextValue: context?.value,
+ status,
+ value,
+ }) === 'checked';
+
+ const reduceMotion = useReduceMotion();
+ const reanimatedReduceMotion = reduceMotion
+ ? ReduceMotion.Always
+ : ReduceMotion.Never;
+
+ // Single selection animation path: the dot scales in (with a slight
+ // overshoot) when the radio becomes checked. The ring outline stays a
+ // constant width. Keyed on `checked` (not `status`) so radios driven by a
+ // `RadioButton.Group` animate too.
+ const dotScale = useSharedValue(1);
+ const isFirstRendering = React.useRef(true);
- const Button = Platform.select({
- default: RadioButtonAndroid,
- ios: RadioButtonIOS,
- });
+ const dotTimingConfig = React.useMemo(
+ () => ({
+ duration: theme.motion.duration.short3,
+ easing: Easing.bezier(...theme.motion.easing.standard),
+ reduceMotion: reanimatedReduceMotion,
+ }),
+ [
+ theme.motion.duration.short3,
+ theme.motion.easing.standard,
+ reanimatedReduceMotion,
+ ]
+ );
- return ;
+ React.useEffect(() => {
+ // Do not run animation on very first rendering
+ if (isFirstRendering.current) {
+ isFirstRendering.current = false;
+ return;
+ }
+
+ if (checked) {
+ // Jump to the overshoot value, then settle back to 1.
+ dotScale.value = 1.2;
+ dotScale.value = withTiming(1, dotTimingConfig);
+ }
+ }, [checked, dotScale, dotTimingConfig]);
+
+ const dotAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: dotScale.value }],
+ }));
+
+ const { selectionControlColor, selectionControlOpacity } =
+ getSelectionControlColor({
+ theme,
+ disabled,
+ checked,
+ error,
+ customColor: rest.color,
+ customUncheckedColor: rest.uncheckedColor,
+ });
+
+ // When `accessible={false}` is passed (typically by `RadioButton.Item`,
+ // which owns the a11y tree for the wrapped row), suppress our own role and
+ // state so the same logical control doesn't expose two `checked` states to
+ // assistive tech.
+ const accessibilityProps =
+ rest.accessible === false
+ ? {}
+ : {
+ role: 'radio' as const,
+ 'aria-disabled': !!disabled,
+ 'aria-checked': checked,
+ };
+
+ const { style: restStyle, ...restProps } = rest;
+ const combinedStyle =
+ typeof restStyle === 'function'
+ ? (state: PressableStateCallbackType) => [
+ styles.container,
+ restStyle(state),
+ ]
+ : [styles.container, restStyle];
+
+ return (
+ {
+ handlePress({
+ onPress,
+ onValueChange: context?.onValueChange,
+ value,
+ event,
+ });
+ }}
+ disabled={disabled}
+ {...accessibilityProps}
+ style={combinedStyle}
+ testID={testID}
+ theme={theme}
+ >
+
+
+ {checked ? (
+
+ ) : null}
+
+
+
+ );
};
+RadioButton.displayName = 'RadioButton';
+
+const styles = StyleSheet.create({
+ container: {
+ width: touchTargetSize,
+ height: touchTargetSize,
+ borderRadius: touchTargetSize / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ stateLayer: {
+ width: stateLayerSize,
+ height: stateLayerSize,
+ borderRadius: stateLayerSize / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ radio: {
+ height: ringSize,
+ width: ringSize,
+ borderRadius: ringSize / 2,
+ borderWidth: OUTLINE_WIDTH,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ dot: {
+ height: dotSize,
+ width: dotSize,
+ borderRadius: dotSize / 2,
+ },
+});
+
export default RadioButton;
diff --git a/src/components/RadioButton/RadioButtonAndroid.tsx b/src/components/RadioButton/RadioButtonAndroid.tsx
deleted file mode 100644
index 91520db69c..0000000000
--- a/src/components/RadioButton/RadioButtonAndroid.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-import * as React from 'react';
-// eslint-disable-next-line no-restricted-imports -- TODO: migrate RadioButtonAndroid to Reanimated.
-import { Animated, StyleSheet, View } from 'react-native';
-
-import { RadioButtonContext } from './RadioButtonGroup';
-import type { RadioButtonContextType } from './RadioButtonGroup';
-import { getSelectionControlColor, handlePress, isChecked } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../theme/types';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
-
-export type Props = Omit<
- React.PropsWithoutRef,
- 'children'
-> & {
- /**
- * Value of the radio button
- */
- value: string;
- /**
- * Status of radio button.
- */
- status?: 'checked' | 'unchecked';
- /**
- * Whether radio is disabled.
- */
- disabled?: boolean;
- /**
- * Function to execute on press.
- */
- onPress?: (param?: any) => void;
- /**
- * Custom color for unchecked radio.
- */
- uncheckedColor?: string;
- /**
- * Custom color for radio.
- */
- color?: string;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * testID to be used on tests.
- */
- testID?: string;
-};
-
-const BORDER_WIDTH = 2;
-
-/**
- * Radio buttons allow the selection a single option from a set.
- * This component follows platform guidelines for Android, but can be used
- * on any platform.
- *
- * @extends TouchableRipple props https://callstack.github.io/react-native-paper/docs/components/TouchableRipple
- */
-const RadioButtonAndroid = ({
- disabled,
- onPress,
- theme: themeOverrides,
- value,
- status,
- testID,
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { current: borderAnim } = React.useRef(
- new Animated.Value(BORDER_WIDTH)
- );
-
- const { current: radioAnim } = React.useRef(
- new Animated.Value(1)
- );
-
- const isFirstRendering = React.useRef(true);
-
- const { scale } = theme.animation;
-
- React.useEffect(() => {
- // Do not run animation on very first rendering
- if (isFirstRendering.current) {
- isFirstRendering.current = false;
- return;
- }
-
- if (status === 'checked') {
- radioAnim.setValue(1.2);
-
- Animated.timing(radioAnim, {
- toValue: 1,
- duration: 150 * scale,
- useNativeDriver: true,
- }).start();
- } else {
- borderAnim.setValue(10);
-
- Animated.timing(borderAnim, {
- toValue: BORDER_WIDTH,
- duration: 150 * scale,
- useNativeDriver: false,
- }).start();
- }
- }, [status, borderAnim, radioAnim, scale]);
-
- return (
-
- {(context: RadioButtonContextType | null) => {
- const checked =
- isChecked({
- contextValue: context?.value,
- status,
- value,
- }) === 'checked';
-
- const { selectionControlColor } = getSelectionControlColor({
- theme,
- disabled,
- checked,
- customColor: rest.color,
- customUncheckedColor: rest.uncheckedColor,
- });
-
- return (
- {
- handlePress({
- onPress,
- onValueChange: context?.onValueChange,
- value,
- event,
- });
- }
- }
- role="radio"
- aria-disabled={disabled}
- aria-checked={checked}
- aria-live="polite"
- style={styles.container}
- testID={testID}
- theme={theme}
- >
-
- {checked ? (
-
-
-
- ) : null}
-
-
- );
- }}
-
- );
-};
-
-RadioButtonAndroid.displayName = 'RadioButton.Android';
-
-const styles = StyleSheet.create({
- container: {
- borderRadius: 18,
- },
- radioContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- },
- radio: {
- height: 20,
- width: 20,
- borderRadius: 10,
- margin: 8,
- },
- dot: {
- height: 10,
- width: 10,
- borderRadius: 5,
- },
-});
-
-export default RadioButtonAndroid;
-
-// @component-docs ignore-next-line
-export { RadioButtonAndroid };
diff --git a/src/components/RadioButton/RadioButtonGroup.tsx b/src/components/RadioButton/RadioButtonGroup.tsx
index ef76cd30aa..c413ff0a05 100644
--- a/src/components/RadioButton/RadioButtonGroup.tsx
+++ b/src/components/RadioButton/RadioButtonGroup.tsx
@@ -1,6 +1,8 @@
import * as React from 'react';
import { View } from 'react-native';
+import useLatestCallback from 'use-latest-callback';
+
export type Props = {
/**
* Function to execute on selection change.
@@ -53,11 +55,21 @@ export const RadioButtonContext =
* export default MyComponent;
*```
*/
-const RadioButtonGroup = ({ value, onValueChange, children }: Props) => (
-
- {children}
-
-);
+const RadioButtonGroup = ({ value, onValueChange, children }: Props) => {
+ // Stabilize the callback so the memoized context value only changes when
+ // `value` changes — not on every parent render.
+ const stableOnValueChange = useLatestCallback(onValueChange);
+ const context = React.useMemo(
+ () => ({ value, onValueChange: stableOnValueChange }),
+ [value, stableOnValueChange]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
RadioButtonGroup.displayName = 'RadioButton.Group';
export default RadioButtonGroup;
diff --git a/src/components/RadioButton/RadioButtonIOS.tsx b/src/components/RadioButton/RadioButtonIOS.tsx
deleted file mode 100644
index 9ca15a7e4a..0000000000
--- a/src/components/RadioButton/RadioButtonIOS.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import type * as React from 'react';
-import { StyleSheet, View } from 'react-native';
-import type { GestureResponderEvent } from 'react-native';
-
-import { RadioButtonContext } from './RadioButtonGroup';
-import type { RadioButtonContextType } from './RadioButtonGroup';
-import { handlePress, isChecked } from './utils';
-import { getSelectionControlIOSColor } from './utils';
-import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../theme/types';
-import MaterialCommunityIcon from '../MaterialCommunityIcon';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
-
-export type Props = Omit<
- React.PropsWithoutRef,
- 'children'
-> & {
- /**
- * Value of the radio button
- */
- value: string;
- /**
- * Status of radio button.
- */
- status?: 'checked' | 'unchecked';
- /**
- * Whether radio is disabled.
- */
- disabled?: boolean;
- /**
- * Function to execute on press.
- */
- onPress?: (e: GestureResponderEvent) => void;
- /**
- * Custom color for radio.
- */
- color?: string;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * testID to be used on tests.
- */
- testID?: string;
-};
-
-/**
- * Radio buttons allow the selection a single option from a set.
- * This component follows platform guidelines for iOS, but can be used
- * on any platform.
- *
- * @extends TouchableRipple props https://callstack.github.io/react-native-paper/docs/components/TouchableRipple
- */
-const RadioButtonIOS = ({
- disabled,
- onPress,
- theme: themeOverrides,
- status,
- value,
- testID,
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
-
- return (
-
- {(context: RadioButtonContextType | null) => {
- const checked =
- isChecked({
- contextValue: context?.value,
- status,
- value,
- }) === 'checked';
-
- const { checkedColor } = getSelectionControlIOSColor({
- theme,
- disabled,
- customColor: rest.color,
- });
- const opacity = checked ? 1 : 0;
-
- return (
- {
- handlePress({
- onPress,
- value,
- onValueChange: context?.onValueChange,
- event,
- });
- }
- }
- role="radio"
- aria-disabled={disabled}
- aria-checked={checked}
- aria-live="polite"
- style={styles.container}
- testID={testID}
- theme={theme}
- >
-
-
-
-
- );
- }}
-
- );
-};
-
-RadioButtonIOS.displayName = 'RadioButton.IOS';
-
-const styles = StyleSheet.create({
- container: {
- borderRadius: 18,
- padding: 6,
- },
-});
-
-export default RadioButtonIOS;
-
-// @component-docs ignore-next-line
-export { RadioButtonIOS };
diff --git a/src/components/RadioButton/RadioButtonItem.tsx b/src/components/RadioButton/RadioButtonItem.tsx
index b987f7f0e7..6517d53e48 100644
--- a/src/components/RadioButton/RadioButtonItem.tsx
+++ b/src/components/RadioButton/RadioButtonItem.tsx
@@ -1,3 +1,4 @@
+import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import type {
GestureResponderEvent,
@@ -8,10 +9,7 @@ import type {
} from 'react-native';
import RadioButton from './RadioButton';
-import RadioButtonAndroid from './RadioButtonAndroid';
import { RadioButtonContext } from './RadioButtonGroup';
-import type { RadioButtonContextType } from './RadioButtonGroup';
-import RadioButtonIOS from './RadioButtonIOS';
import { handlePress, isChecked } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { ThemeProp, TypescaleKey } from '../../theme/types';
@@ -62,6 +60,11 @@ export type Props = {
* Status of radio button.
*/
status?: 'checked' | 'unchecked';
+ /**
+ * Whether the radio button is in an error state. When true, the control
+ * uses `theme.colors.error`.
+ */
+ error?: boolean;
/**
* Additional styles for container View.
*/
@@ -99,11 +102,6 @@ export type Props = {
* testID to be used on tests.
*/
testID?: string;
- /**
- * Whether `` or `` should be used.
- * Left undefined `` will be used.
- */
- mode?: 'android' | 'ios';
/**
* Radio button control position.
*/
@@ -147,17 +145,18 @@ const RadioButtonItem = ({
color,
uncheckedColor,
status,
+ error,
theme: themeOverrides,
background,
'aria-label': ariaLabel = label,
testID,
- mode,
position = 'trailing',
labelVariant = 'bodyLarge',
- labelMaxFontSizeMultiplier,
+ labelMaxFontSizeMultiplier = 1.5,
hitSlop,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
+ const context = React.useContext(RadioButtonContext);
const radioButtonProps = {
value,
disabled,
@@ -165,17 +164,20 @@ const RadioButtonItem = ({
color,
theme,
uncheckedColor,
+ error,
};
const isLeading = position === 'leading';
- let radioButton: any;
-
- if (mode === 'android') {
- radioButton = ;
- } else if (mode === 'ios') {
- radioButton = ;
- } else {
- radioButton = ;
- }
+ // 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 = (
+
+ );
const textAlign = isLeading ? 'right' : 'left';
@@ -184,51 +186,51 @@ const RadioButtonItem = ({
textAlign,
};
+ const checked =
+ isChecked({
+ contextValue: context?.value,
+ status,
+ value,
+ }) === 'checked';
+
return (
-
- {(context: RadioButtonContextType | null) => {
- const checked =
- isChecked({
- contextValue: context?.value,
- status,
- value,
- }) === 'checked';
- return (
-
- handlePress({
- onPress: onPress,
- onValueChange: context?.onValueChange,
- value,
- event,
- })
- }
- onLongPress={onLongPress}
- aria-label={ariaLabel}
- role="radio"
- aria-checked={checked}
- aria-disabled={disabled}
- testID={testID}
- disabled={disabled}
- background={background}
- theme={theme}
- hitSlop={hitSlop}
- >
-
- {isLeading && radioButton}
-
- {label}
-
- {!isLeading && radioButton}
-
-
- );
- }}
-
+
+ handlePress({
+ onPress: onPress,
+ onValueChange: context?.onValueChange,
+ value,
+ event,
+ })
+ }
+ onLongPress={onLongPress}
+ aria-label={ariaLabel}
+ role="radio"
+ aria-checked={checked}
+ aria-disabled={disabled}
+ testID={testID}
+ disabled={disabled}
+ background={background}
+ theme={theme}
+ hitSlop={hitSlop}
+ >
+
+ {isLeading && radioButton}
+
+ {label}
+
+ {!isLeading && radioButton}
+
+
);
};
diff --git a/src/components/RadioButton/index.ts b/src/components/RadioButton/index.ts
index 6ba5b8b873..7373ad4328 100644
--- a/src/components/RadioButton/index.ts
+++ b/src/components/RadioButton/index.ts
@@ -1,7 +1,5 @@
import RadioButtonComponent from './RadioButton';
-import RadioButtonAndroid from './RadioButtonAndroid';
import RadioButtonGroup from './RadioButtonGroup';
-import RadioButtonIOS from './RadioButtonIOS';
import RadioButtonItem from './RadioButtonItem';
const RadioButton = Object.assign(
@@ -10,10 +8,6 @@ const RadioButton = Object.assign(
{
// @component ./RadioButtonGroup.tsx
Group: RadioButtonGroup,
- // @component ./RadioButtonAndroid.tsx
- Android: RadioButtonAndroid,
- // @component ./RadioButtonIOS.tsx
- IOS: RadioButtonIOS,
// @component ./RadioButtonItem.tsx
Item: RadioButtonItem,
}
diff --git a/src/components/RadioButton/tokens.ts b/src/components/RadioButton/tokens.ts
new file mode 100644
index 0000000000..6a1d15d1fa
--- /dev/null
+++ b/src/components/RadioButton/tokens.ts
@@ -0,0 +1,22 @@
+import type { ColorRole } from '../../theme/types';
+
+/**
+ * MD3 Radio button spec dimensions and color-role tokens.
+ * @see https://m3.material.io/components/radio-button/specs
+ */
+const sizes = {
+ ringSize: 20,
+ dotSize: 10,
+ outlineWidth: 2,
+ stateLayerSize: 40,
+ touchTargetSize: 48,
+} as const;
+
+const colors = {
+ checkedColor: 'primary',
+ uncheckedColor: 'onSurfaceVariant',
+ disabledColor: 'onSurface',
+ errorColor: 'error',
+} as const satisfies Record;
+
+export const RadioButtonTokens = { ...sizes, ...colors };
diff --git a/src/components/RadioButton/utils.ts b/src/components/RadioButton/utils.ts
index f8190eb981..67c292cd23 100644
--- a/src/components/RadioButton/utils.ts
+++ b/src/components/RadioButton/utils.ts
@@ -1,5 +1,6 @@
import type { ColorValue, GestureResponderEvent } from 'react-native';
+import { RadioButtonTokens } from './tokens';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../theme/types';
@@ -41,59 +42,6 @@ export const isChecked = ({
}
};
-const getIOSCheckedColor = ({
- theme,
- disabled,
- customColor,
- error,
-}: {
- theme: InternalTheme;
- customColor?: ColorValue;
- disabled?: boolean;
- error?: boolean;
-}) => {
- if (disabled) {
- return theme.colors.primary;
- }
-
- if (customColor) {
- return customColor;
- }
-
- if (error) {
- return theme.colors.error;
- }
-
- return theme.colors.primary;
-};
-
-export const getSelectionControlIOSColor = ({
- theme,
- disabled,
- customColor,
- error,
-}: {
- theme: InternalTheme;
- disabled?: boolean;
- customColor?: ColorValue;
- error?: boolean;
-}) => {
- const checkedColor = getIOSCheckedColor({
- theme,
- disabled,
- customColor,
- error,
- });
- const checkedColorOpacity = disabled
- ? stateOpacity.disabled
- : stateOpacity.enabled;
-
- return {
- checkedColor,
- checkedColorOpacity,
- };
-};
-
export const getSelectionControlColor = ({
theme,
disabled,
@@ -113,15 +61,15 @@ export const getSelectionControlColor = ({
const checkedColor = customColor
? customColor
: error
- ? theme.colors.error
- : theme.colors.primary;
+ ? theme.colors[RadioButtonTokens.errorColor]
+ : theme.colors[RadioButtonTokens.checkedColor];
const uncheckedColor = customUncheckedColor
? customUncheckedColor
: error
- ? theme.colors.error
- : theme.colors.onSurfaceVariant;
+ ? theme.colors[RadioButtonTokens.errorColor]
+ : theme.colors[RadioButtonTokens.uncheckedColor];
const color = disabled
- ? theme.colors.onSurface
+ ? theme.colors[RadioButtonTokens.disabledColor]
: checked
? checkedColor
: uncheckedColor;
diff --git a/src/components/__tests__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx
index da8a04375d..5fd1c60ae8 100644
--- a/src/components/__tests__/RadioButton/RadioButton.test.tsx
+++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx
@@ -1,59 +1,35 @@
-import {
- beforeAll,
- describe,
- expect,
- it,
- jest as mockJest,
-} from '@jest/globals';
+import { describe, expect, it } from '@jest/globals';
-import { render } from '../../../test-utils';
+import { render, screen } from '../../../test-utils';
import RadioButton from '../../RadioButton';
import { RadioButtonContext } from '../../RadioButton/RadioButtonGroup';
describe('RadioButton', () => {
- describe('on default platform', () => {
- beforeAll(() => {
- mockJest.mock('react-native', () => {
- const RN =
- mockJest.requireActual('react-native');
+ it('renders properly', async () => {
+ const tree = (await render()).toJSON();
- return {
- ...RN,
- Platform: {
- ...RN.Platform,
- select: (objs: { default: object }) => objs.default,
- },
- };
- });
- });
-
- it('renders properly', async () => {
- const tree = (await render()).toJSON();
-
- expect(tree).toMatchSnapshot();
- });
+ expect(tree).toMatchSnapshot();
});
- describe('on ios platform', () => {
- beforeAll(() => {
- mockJest.mock('react-native', () => {
- const RN =
- mockJest.requireActual('react-native');
+ it('provides a 48dp touch target in its layout', async () => {
+ await render();
- return {
- ...RN,
- Platform: {
- ...RN.Platform,
- select: (objs: { ios: object }) => objs.ios,
- },
- };
- });
+ expect(screen.getByRole('radio')).toHaveStyle({
+ width: 48,
+ height: 48,
+ borderRadius: 24,
});
+ });
- it('renders properly', async () => {
- const tree = (await render()).toJSON();
+ it('preserves consumer layout styles on the touch target', async () => {
+ await render(
+
+ );
- expect(tree).toMatchSnapshot();
+ expect(screen.getByRole('radio')).toHaveStyle({
+ margin: 8,
+ width: 56,
+ height: 56,
});
});
diff --git a/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx b/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx
index 15bf4aa442..8c467fba18 100644
--- a/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx
+++ b/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx
@@ -1,7 +1,11 @@
+import * as React from 'react';
+
import { describe, expect, it } from '@jest/globals';
import { render } from '../../../test-utils';
import RadioButton from '../../RadioButton';
+import { RadioButtonContext } from '../../RadioButton/RadioButtonGroup';
+import type { RadioButtonContextType } from '../../RadioButton/RadioButtonGroup';
describe('RadioButtonGroup', () => {
it('renders properly', async () => {
@@ -15,4 +19,28 @@ describe('RadioButtonGroup', () => {
expect(tree).toMatchSnapshot();
});
+
+ it('keeps a stable context value when an unrelated parent prop changes', async () => {
+ const contexts: Array = [];
+
+ const Capture = ({ extra }: { extra: number }) => {
+ contexts.push(React.useContext(RadioButtonContext));
+ return ;
+ };
+
+ // New inline `onValueChange` on every render — `use-latest-callback` +
+ // `useMemo` should keep the provided context object identical.
+ const Parent = ({ extra }: { extra: number }) => (
+ {}}>
+
+
+ );
+
+ const { rerender } = await render();
+ await rerender();
+
+ expect(contexts.length).toBe(2);
+ expect(contexts[0]).not.toBeNull();
+ expect(contexts[0]).toBe(contexts[1]);
+ });
});
diff --git a/src/components/__tests__/RadioButton/RadioButtonItem.test.tsx b/src/components/__tests__/RadioButton/RadioButtonItem.test.tsx
index 821d9ebc5b..2a66715f08 100644
--- a/src/components/__tests__/RadioButton/RadioButtonItem.test.tsx
+++ b/src/components/__tests__/RadioButton/RadioButtonItem.test.tsx
@@ -1,5 +1,3 @@
-import { Platform } from 'react-native';
-
import { expect, it, jest } from '@jest/globals';
import { userEvent } from '@testing-library/react-native';
@@ -20,40 +18,7 @@ it('renders unchecked', async () => {
expect(tree).toMatchSnapshot();
});
-it('can render the iOS radio button on different platforms', async () => {
- Platform.OS = 'android';
- const tree = (
- await render(
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('can render the Android radio button on different platforms', async () => {
- Platform.OS = 'ios';
- const tree = (
- await render(
-
- )
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
it('can render leading radio button control', async () => {
- Platform.OS = 'ios';
const tree = (
await render(
{
expect(tree).toMatchSnapshot();
});
+it('exposes a single radio a11y node per item', async () => {
+ await render(
+
+ );
+
+ // The inner control is visual-only, so only the row is a radio.
+ expect(screen.queryAllByRole('radio')).toHaveLength(1);
+});
+
it('should execute onLongPress', async () => {
const onLongPress = jest.fn();
diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
index c20910f20e..e77501be23 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap
@@ -2,7 +2,6 @@
exports[`RadioButton RadioButton with custom testID renders properly 1`] = `
-
- check
-
-
-
-`;
-
-exports[`RadioButton on default platform renders properly 1`] = `
-
-
-
- check
-
+ />
`;
-exports[`RadioButton on ios platform renders properly 1`] = `
+exports[`RadioButton renders properly 1`] = `
-
- check
-
+ />
`;
exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider renders properly 1`] = `
-
+
- check
-
+ ]
+ }
+ />
+
`;
diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap
index 1ae7f560be..96ac5c44de 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap
@@ -5,7 +5,6 @@ exports[`RadioButtonGroup renders properly 1`] = `
role="radiogroup"
>
-
+
- check
-
+ ]
+ }
+ />
+
diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap
index 5867b1408c..737e6444a6 100644
--- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap
+++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap
@@ -41,6 +41,7 @@ exports[`can render leading radio button control 1`] = `
}
>
-
- check
-
+ />
`;
-exports[`can render the Android radio button on different platforms 1`] = `
+exports[`renders unchecked 1`] = `
- iOS Checkbox
+ Unchecked Button
-
-
-
-
-`;
-
-exports[`can render the iOS radio button on different platforms 1`] = `
-
-
-
- iOS Radio button
-
-
-
- check
-
-
-
-
-
-`;
-
-exports[`renders unchecked 1`] = `
-
-
-
- Unchecked Button
-
-
-
-
- check
-
+ />
diff --git a/src/components/__tests__/RadioButton/utils.test.tsx b/src/components/__tests__/RadioButton/utils.test.tsx
index be3cbc97d8..0b2cc27015 100644
--- a/src/components/__tests__/RadioButton/utils.test.tsx
+++ b/src/components/__tests__/RadioButton/utils.test.tsx
@@ -2,88 +2,118 @@ import { describe, expect, it } from '@jest/globals';
import { DarkTheme, LightTheme } from '../../../theme/schemes';
import { tokens } from '../../../theme/tokens';
-import { getSelectionControlIOSColor } from '../../RadioButton/utils';
+import { getSelectionControlColor } from '../../RadioButton/utils';
const stateOpacity = tokens.md.sys.state.opacity;
-describe('getSelectionControlIOSColor - checked color', () => {
- it('should return correct disabled color, for theme version 3', () => {
+describe('getSelectionControlColor', () => {
+ it('should return disabled color', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
disabled: true,
+ checked: true,
})
).toMatchObject({
- checkedColor: LightTheme.colors.primary,
- checkedColorOpacity: stateOpacity.disabled,
+ selectionControlColor: LightTheme.colors.onSurface,
+ selectionControlOpacity: stateOpacity.disabled,
});
});
it('should return custom color, checked', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
+ checked: true,
customColor: 'purple',
})
).toMatchObject({
- checkedColor: 'purple',
+ selectionControlColor: 'purple',
});
});
- it('should return theme color, for theme version 3, checked', () => {
+ it('should return primary color, checked', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
+ checked: true,
})
).toMatchObject({
- checkedColor: LightTheme.colors.primary,
+ selectionControlColor: LightTheme.colors.primary,
});
});
- it('should return error color when error is true', () => {
+ it('should return onSurfaceVariant color, unchecked', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
+ checked: false,
+ })
+ ).toMatchObject({
+ selectionControlColor: LightTheme.colors.onSurfaceVariant,
+ });
+ });
+
+ it('should return error color when error is true, checked', () => {
+ expect(
+ getSelectionControlColor({
+ theme: LightTheme,
+ checked: true,
+ error: true,
+ })
+ ).toMatchObject({
+ selectionControlColor: LightTheme.colors.error,
+ });
+ });
+
+ it('should return error color when error is true, unchecked', () => {
+ expect(
+ getSelectionControlColor({
+ theme: LightTheme,
+ checked: false,
error: true,
})
).toMatchObject({
- checkedColor: LightTheme.colors.error,
+ selectionControlColor: LightTheme.colors.error,
});
});
it('should return error color, dark mode, when error is true', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: DarkTheme,
+ checked: true,
error: true,
})
).toMatchObject({
- checkedColor: DarkTheme.colors.error,
+ selectionControlColor: DarkTheme.colors.error,
});
});
it('should return disabled color when both disabled and error are true (disabled wins)', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
+ checked: true,
disabled: true,
error: true,
})
).toMatchObject({
- checkedColor: LightTheme.colors.primary,
- checkedColorOpacity: stateOpacity.disabled,
+ selectionControlColor: LightTheme.colors.onSurface,
+ selectionControlOpacity: stateOpacity.disabled,
});
});
it('should return custom color when both customColor and error are true (customColor wins)', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: LightTheme,
+ checked: true,
customColor: 'purple',
error: true,
})
).toMatchObject({
- checkedColor: 'purple',
+ selectionControlColor: 'purple',
});
});
});
diff --git a/src/index.tsx b/src/index.tsx
index 1807d2f249..f5c8a728a8 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -119,9 +119,7 @@ export type { Props as PortalHostProps } from './components/Portal/PortalHost';
export type { Props as ProgressBarProps } from './components/ProgressBar';
export type { Props as ProviderProps } from './core/PaperProvider';
export type { Props as RadioButtonProps } from './components/RadioButton/RadioButton';
-export type { Props as RadioButtonAndroidProps } from './components/RadioButton/RadioButtonAndroid';
export type { Props as RadioButtonGroupProps } from './components/RadioButton/RadioButtonGroup';
-export type { Props as RadioButtonIOSProps } from './components/RadioButton/RadioButtonIOS';
export type { Props as RadioButtonItemProps } from './components/RadioButton/RadioButtonItem';
export type { Props as SearchbarProps } from './components/Searchbar';
export type { Props as SnackbarProps } from './components/Snackbar';