diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index ee54135042..6ec539ecd4 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -155,9 +155,7 @@ const config = {
ProgressBar: 'ProgressBar',
RadioButton: {
RadioButton: 'RadioButton/RadioButton',
- RadioButtonAndroid: 'RadioButton/RadioButtonAndroid',
RadioButtonGroup: 'RadioButton/RadioButtonGroup',
- RadioButtonIOS: 'RadioButton/RadioButtonIOS',
RadioButtonItem: 'RadioButton/RadioButtonItem',
},
Searchbar: 'Searchbar',
diff --git a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonAndroid.mdx b/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonAndroid.mdx
deleted file mode 100644
index 2faf286f16..0000000000
--- a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonAndroid.mdx
+++ /dev/null
@@ -1,99 +0,0 @@
----
-title: RadioButton.Android
----
-
-import PropTable from '@site/src/components/PropTable.tsx';
-import ExtendsLink from '@site/src/components/ExtendsLink.tsx';
-import ThemeColorsTable from '@site/src/components/ThemeColorsTable.tsx';
-import ScreenshotTabs from '@site/src/components/ScreenshotTabs.tsx';
-import ExtendedExample from '@site/src/components/ExtendedExample.tsx';
-
-
-
-
-
-
-
-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.
-
-
-
-
- ## Props
- ### TouchableRipple props
-
-
-
-
-
-### value (required)
-
-
-
-
-
-
-
-### status
-
-
-
-
-
-
-
-### disabled
-
-
-
-
-
-
-
-### onPress
-
-
-
-
-
-
-
-### uncheckedColor
-
-
-
-
-
-
-
-### color
-
-
-
-
-
-
-
-### theme
-
-
-
-
-
-
-
-### testID
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonIOS.mdx b/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonIOS.mdx
deleted file mode 100644
index 83db4152cc..0000000000
--- a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonIOS.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: RadioButton.IOS
----
-
-import PropTable from '@site/src/components/PropTable.tsx';
-import ExtendsLink from '@site/src/components/ExtendsLink.tsx';
-import ThemeColorsTable from '@site/src/components/ThemeColorsTable.tsx';
-import ScreenshotTabs from '@site/src/components/ScreenshotTabs.tsx';
-import ExtendedExample from '@site/src/components/ExtendedExample.tsx';
-
-
-
-
-
-
-
-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.
-
-
-
-
- ## Props
- ### TouchableRipple props
-
-
-
-
-
-### value (required)
-
-
-
-
-
-
-
-### status
-
-
-
-
-
-
-
-### disabled
-
-
-
-
-
-
-
-### onPress
-
-
-
-
-
-
-
-### color
-
-
-
-
-
-
-
-### theme
-
-
-
-
-
-
-
-### testID
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonItem.mdx b/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonItem.mdx
index c5f863d44b..34aaf28c73 100644
--- a/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonItem.mdx
+++ b/docs/versioned_docs/version-6.x/components/RadioButton/RadioButtonItem.mdx
@@ -171,14 +171,6 @@ export default MyComponent;
-### mode
-
-
-
-
-
-
-
### position
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 cb8a621e2d..384f734d3d 100644
--- a/src/components/RadioButton/RadioButton.tsx
+++ b/src/components/RadioButton/RadioButton.tsx
@@ -1,12 +1,23 @@
-import { Platform } from 'react-native';
-import type { GestureResponderEvent } from 'react-native';
+import * as React from 'react';
+import { StyleSheet, View } 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 type { ThemeProp } from '../../types';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import type { $RemoveChildren, ThemeProp } from '../../types';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
-export type Props = {
+export type Props = $RemoveChildren & {
/**
* Value of the radio button
*/
@@ -22,7 +33,7 @@ export type Props = {
/**
* Function to execute on press.
*/
- onPress?: (e: GestureResponderEvent) => void;
+ onPress?: (param?: any) => void;
/**
* Custom color for unchecked radio.
*/
@@ -31,6 +42,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 +58,8 @@ export type Props = {
testID?: string;
};
+const { ringSize, dotSize, outlineWidth: OUTLINE_WIDTH } = RadioButtonTokens;
+
/**
* Radio buttons allow the selection a single option from a set.
*
@@ -71,16 +90,159 @@ 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
+ ? {}
+ : {
+ accessibilityRole: 'radio' as const,
+ accessibilityState: { disabled: !!disabled, checked },
+ };
+
+ return (
+ {
+ handlePress({
+ onPress,
+ onValueChange: context?.onValueChange,
+ value,
+ event,
+ });
+ }}
+ disabled={disabled}
+ {...accessibilityProps}
+ style={styles.container}
+ testID={testID}
+ theme={theme}
+ >
+
+ {checked ? (
+
+
+
+ ) : null}
+
+
+ );
};
+RadioButton.displayName = 'RadioButton';
+
+const styles = StyleSheet.create({
+ container: {
+ borderRadius: 18,
+ },
+ radioContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ radio: {
+ height: ringSize,
+ width: ringSize,
+ borderRadius: ringSize / 2,
+ borderWidth: OUTLINE_WIDTH,
+ margin: 8,
+ },
+ 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 a907b46b09..0000000000
--- a/src/components/RadioButton/RadioButtonAndroid.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import * as React from 'react';
-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 { $RemoveChildren, ThemeProp } from '../../types';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-
-export type Props = $RemoveChildren & {
- /**
- * 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) => {
- 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,
- });
- }
- }
- accessibilityRole="radio"
- accessibilityState={{ disabled, checked }}
- accessibilityLiveRegion="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 2bdd596561..5305e5bdeb 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.
@@ -54,11 +56,21 @@ export const RadioButtonContext = React.createContext(
* 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 e2e5f06c7c..0000000000
--- a/src/components/RadioButton/RadioButtonIOS.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-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 { $RemoveChildren, ThemeProp } from '../../types';
-import MaterialCommunityIcon from '../MaterialCommunityIcon';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-
-export type Props = $RemoveChildren & {
- /**
- * 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) => {
- 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,
- });
- }
- }
- accessibilityRole="radio"
- accessibilityState={{ disabled, checked }}
- accessibilityLiveRegion="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 1591760980..1ab6c05f03 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 { getStateLayer } from '../../theme/utils/state';
@@ -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,
accessibilityLabel = 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,13 @@ 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,53 +179,53 @@ const RadioButtonItem = ({
textAlign,
} as TextStyle;
+ const checked =
+ isChecked({
+ contextValue: context?.value,
+ status,
+ value,
+ }) === 'checked';
+
return (
-
- {(context?: RadioButtonContextType) => {
- const checked =
- isChecked({
- contextValue: context?.value,
- status,
- value,
- }) === 'checked';
- return (
-
- handlePress({
- onPress: onPress,
- onValueChange: context?.onValueChange,
- value,
- event,
- })
- }
- onLongPress={onLongPress}
- accessibilityLabel={accessibilityLabel}
- accessibilityRole="radio"
- accessibilityState={{
- checked,
- 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}
+ accessibilityLabel={accessibilityLabel}
+ accessibilityRole="radio"
+ accessibilityState={{
+ checked,
+ 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..447c85eae8
--- /dev/null
+++ b/src/components/RadioButton/tokens.ts
@@ -0,0 +1,20 @@
+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,
+} 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 a4a3904d8a..7380da403f 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 '../../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 75497ea77e..3cc7112a8b 100644
--- a/src/components/__tests__/RadioButton/RadioButton.test.tsx
+++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx
@@ -1,60 +1,14 @@
-import {
- beforeAll,
- describe,
- expect,
- it,
- jest as mockJest,
-} from '@jest/globals';
+import { describe, expect, it } from '@jest/globals';
import { render } 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', () => {
+ const tree = render().toJSON();
- return {
- ...RN,
- Platform: {
- ...RN.Platform,
- select: (objs: { default: object }) => objs.default,
- },
- };
- });
- });
-
- it('renders properly', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- describe('on ios platform', () => {
- beforeAll(() => {
- mockJest.mock('react-native', () => {
- const RN =
- mockJest.requireActual('react-native');
-
- return {
- ...RN,
- Platform: {
- ...RN.Platform,
- select: (objs: { ios: object }) => objs.ios,
- },
- };
- });
- });
-
- it('renders properly', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
- });
+ expect(tree).toMatchSnapshot();
});
describe('when RadioButton is wrapped by RadioButtonContext.Provider', () => {
diff --git a/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx b/src/components/__tests__/RadioButton/RadioButtonGroup.test.tsx
index b4fe69463e..7437984f39 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', () => {
@@ -13,4 +17,27 @@ describe('RadioButtonGroup', () => {
expect(tree).toMatchSnapshot();
});
+
+ it('keeps a stable context value when an unrelated parent prop changes', () => {
+ const contexts: RadioButtonContextType[] = [];
+
+ 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 } = render();
+ rerender();
+
+ expect(contexts.length).toBe(2);
+ 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 b8714367bb..3cf5958d9b 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 { act, fireEvent } from '@testing-library/react-native';
@@ -18,36 +16,7 @@ it('renders unchecked', () => {
expect(tree).toMatchSnapshot();
});
-it('can render the iOS radio button on different platforms', () => {
- Platform.OS = 'android';
- const tree = render(
-
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('can render the Android radio button on different platforms', () => {
- Platform.OS = 'ios';
- const tree = render(
-
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
it('can render leading radio button control', () => {
- Platform.OS = 'ios';
const tree = render(
{
expect(tree).toMatchSnapshot();
});
+it('exposes a single radio a11y node per item', () => {
+ const { queryAllByRole } = render(
+
+ );
+
+ // The inner control is `accessible={false}`, so only the row is a radio.
+ expect(queryAllByRole('radio')).toHaveLength(1);
+});
+
it('should execute onLongPress', () => {
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 5560a80f36..f6b7f805ca 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`] = `
+exports[`RadioButton renders properly 1`] = `
-
- check
-
-
+ />
`;
-exports[`RadioButton on ios platform renders properly 1`] = `
+exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider 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 5c3513d769..542e61d049 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`] = `
accessibilityRole="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 39405cd07a..a907f9f739 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
-
-
-
-
- Default with leading control
-
-
-
-`;
-
-exports[`can render the Android radio button on different platforms 1`] = `
-
-
-
- iOS Checkbox
-
-
-
-
-
-`;
-
-exports[`can render the iOS radio button on different platforms 1`] = `
-
-
- iOS Radio button
+ Default with leading control
-
-
-
- check
-
-
-
`;
@@ -549,6 +198,7 @@ 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 e0a6736bd6..5798b56e69 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 { getTheme } from '../../../core/theming';
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: getTheme(),
disabled: true,
+ checked: true,
})
).toMatchObject({
- checkedColor: getTheme().colors.primary,
- checkedColorOpacity: stateOpacity.disabled,
+ selectionControlColor: getTheme().colors.onSurface,
+ selectionControlOpacity: stateOpacity.disabled,
});
});
it('should return custom color, checked', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: getTheme(),
+ 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: getTheme(),
+ checked: true,
})
).toMatchObject({
- checkedColor: getTheme().colors.primary,
+ selectionControlColor: getTheme().colors.primary,
});
});
- it('should return error color when error is true', () => {
+ it('should return onSurfaceVariant color, unchecked', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: getTheme(),
+ checked: false,
+ })
+ ).toMatchObject({
+ selectionControlColor: getTheme().colors.onSurfaceVariant,
+ });
+ });
+
+ it('should return error color when error is true, checked', () => {
+ expect(
+ getSelectionControlColor({
+ theme: getTheme(),
+ checked: true,
+ error: true,
+ })
+ ).toMatchObject({
+ selectionControlColor: getTheme().colors.error,
+ });
+ });
+
+ it('should return error color when error is true, unchecked', () => {
+ expect(
+ getSelectionControlColor({
+ theme: getTheme(),
+ checked: false,
error: true,
})
).toMatchObject({
- checkedColor: getTheme().colors.error,
+ selectionControlColor: getTheme().colors.error,
});
});
it('should return error color, dark mode, when error is true', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: getTheme(true),
+ checked: true,
error: true,
})
).toMatchObject({
- checkedColor: getTheme(true).colors.error,
+ selectionControlColor: getTheme(true).colors.error,
});
});
it('should return disabled color when both disabled and error are true (disabled wins)', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: getTheme(),
+ checked: true,
disabled: true,
error: true,
})
).toMatchObject({
- checkedColor: getTheme().colors.primary,
- checkedColorOpacity: stateOpacity.disabled,
+ selectionControlColor: getTheme().colors.onSurface,
+ selectionControlOpacity: stateOpacity.disabled,
});
});
it('should return custom color when both customColor and error are true (customColor wins)', () => {
expect(
- getSelectionControlIOSColor({
+ getSelectionControlColor({
theme: getTheme(),
+ checked: true,
customColor: 'purple',
error: true,
})
).toMatchObject({
- checkedColor: 'purple',
+ selectionControlColor: 'purple',
});
});
});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..b04f5c0a2d 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -120,9 +120,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';