diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..2a926a894b 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -159,6 +159,22 @@ e.g.: - The default elevation changed from level `1` to level `3`. - The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead. +### 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`, ...). + +| 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` (state-layer style) | + +```diff +- ++ +``` + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 4196e8e7bd..7f4fe831d4 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 92bf8f2788..a8add900d4 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/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..4d5ed4b315 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -19,6 +19,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../theme/types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import { getMinTouchTargetHitSlop } from '../../utils/touchTarget'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -76,6 +77,10 @@ const { stateLayerSize: STATE_LAYER_SIZE, } = CheckboxTokens; +// The state layer is 40dp per spec, which is below the 48dp minimum touch +// target. Expand the pressable area without changing the visual bounds. +const HIT_SLOP = getMinTouchTargetHitSlop(STATE_LAYER_SIZE); + const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; // Focus indicator is a circular ring at the 40dp state-layer boundary. // We don't apply `focusIndicator.outerOffset` here because the surrounding @@ -234,6 +239,7 @@ const Checkbox = ({ return ( , + 'children' +> & { /** * Value of the radio button */ @@ -31,6 +49,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 +65,17 @@ export type Props = { testID?: string; }; +const { + ringSize, + dotSize, + outlineWidth: OUTLINE_WIDTH, + stateLayerSize, +} = RadioButtonTokens; + +// The state layer is 40dp per spec, which is below the 48dp minimum touch +// target. Expand the pressable area without changing the visual bounds. +const HIT_SLOP = getMinTouchTargetHitSlop(stateLayerSize); + /** * Radio buttons allow the selection a single option from a set. * @@ -71,16 +106,169 @@ 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