From a0b96d5d301ea459781db61c4a5bc3d580edd9eb Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Mon, 15 Jun 2026 13:46:13 +0200 Subject: [PATCH 1/2] feat: add SplitButton component --- docs/docusaurus.config.js | 3 + docs/src/data/themeColors.js | 26 + docs/static/llms.txt | 1 + example/src/ExampleList.tsx | 2 + example/src/Examples/SplitButtonExample.tsx | 127 ++++ src/components/SplitButton/SplitButton.tsx | 671 ++++++++++++++++++ src/components/SplitButton/index.ts | 2 + src/components/SplitButton/tokens.ts | 108 +++ src/components/SplitButton/utils.ts | 242 +++++++ src/components/__tests__/SplitButton.test.tsx | 241 +++++++ src/index.tsx | 2 + 11 files changed, 1425 insertions(+) create mode 100644 example/src/Examples/SplitButtonExample.tsx create mode 100644 src/components/SplitButton/SplitButton.tsx create mode 100644 src/components/SplitButton/index.ts create mode 100644 src/components/SplitButton/tokens.ts create mode 100644 src/components/SplitButton/utils.ts create mode 100644 src/components/__tests__/SplitButton.test.tsx diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a4b672640c..6112c96a07 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -160,6 +160,9 @@ const config = { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, Snackbar: 'Snackbar', + SplitButton: { + SplitButton: 'SplitButton/SplitButton', + }, Surface: 'Surface', Switch: { Switch: 'Switch/Switch', diff --git a/docs/src/data/themeColors.js b/docs/src/data/themeColors.js index 2e97dd1bce..4fad140f61 100644 --- a/docs/src/data/themeColors.js +++ b/docs/src/data/themeColors.js @@ -307,6 +307,32 @@ const themeColors = { iconColor: 'theme.colors.inverseOnSurface', }, }, + SplitButton: { + active: { + filled: { + backgroundColor: 'theme.colors.primary', + textColor: 'theme.colors.onPrimary', + }, + tonal: { + backgroundColor: 'theme.colors.secondaryContainer', + textColor: 'theme.colors.onSecondaryContainer', + }, + elevated: { + backgroundColor: 'theme.colors.surfaceContainerLow', + textColor: 'theme.colors.primary', + }, + outlined: { + textColor: 'theme.colors.onSurfaceVariant', + borderColor: 'theme.colors.outline', + }, + }, + disabled: { + '-': { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', + }, + }, + }, Surface: { flat: { backgroundColor: 'theme.colors.elevation[elevation]', diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 3244326f67..38bb058c0b 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -51,6 +51,7 @@ - Searchbar: https://callstack.github.io/react-native-paper/docs/components/Searchbar - SegmentedButtons: https://callstack.github.io/react-native-paper/docs/components/SegmentedButtons - Snackbar: https://callstack.github.io/react-native-paper/docs/components/Snackbar +- SplitButton: https://callstack.github.io/react-native-paper/docs/components/SplitButton - Surface: https://callstack.github.io/react-native-paper/docs/components/Surface - Switch: https://callstack.github.io/react-native-paper/docs/components/Switch - Text: https://callstack.github.io/react-native-paper/docs/components/Text diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 18c958a4bb..4da8d4abea 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -37,6 +37,7 @@ import SegmentedButtonMultiselectRealCase from './Examples/SegmentedButtons/Segm import SegmentedButtonRealCase from './Examples/SegmentedButtons/SegmentedButtonRealCase'; import SegmentedButtonExample from './Examples/SegmentedButtonsExample'; import SnackbarExample from './Examples/SnackbarExample'; +import SplitButtonExample from './Examples/SplitButtonExample'; import SurfaceExample from './Examples/SurfaceExample'; import SwitchExample from './Examples/SwitchExample'; import TeamDetails from './Examples/TeamDetails'; @@ -80,6 +81,7 @@ export const mainExamples = { Searchbar: SearchbarExample, SegmentedButton: SegmentedButtonExample, Snackbar: SnackbarExample, + SplitButton: SplitButtonExample, Surface: SurfaceExample, Switch: SwitchExample, Text: TextExample, diff --git a/example/src/Examples/SplitButtonExample.tsx b/example/src/Examples/SplitButtonExample.tsx new file mode 100644 index 0000000000..e6ed5aebe4 --- /dev/null +++ b/example/src/Examples/SplitButtonExample.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { List, Menu, SplitButton, Switch, useTheme } from 'react-native-paper'; + +import ScreenWrapper from '../ScreenWrapper'; + +const modes = ['filled', 'tonal', 'elevated', 'outlined'] as const; +const SplitButtonExample = () => { + const [menuVisible, setMenuVisible] = React.useState(false); + const [disabled, setDisabled] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const theme = useTheme(); + + return ( + + + + setMenuVisible(false)} + anchorPosition="bottom" + anchor={ + {}} + onTrailingPress={() => setMenuVisible(true)} + trailingAccessibilityLabel="Show send options" + trailingAccessibilityState={{ expanded: menuVisible }} + /> + } + > + setMenuVisible(false)} + /> + setMenuVisible(false)} + /> + + + } + /> + } + /> + + + + + {modes.map((mode) => ( + {}} + onTrailingPress={() => {}} + trailingAccessibilityLabel={`${mode} options`} + /> + ))} + + + + + + {}} + onTrailingPress={() => {}} + trailingAccessibilityLabel="Custom color options" + /> + {}} + onTrailingPress={() => {}} + trailingAccessibilityLabel="Custom label options" + /> + + + + ); +}; + +SplitButtonExample.title = 'SplitButton'; + +const styles = StyleSheet.create({ + playground: { + paddingHorizontal: 16, + paddingVertical: 8, + alignItems: 'flex-start', + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + paddingHorizontal: 12, + gap: 12, + }, + column: { + alignItems: 'flex-start', + paddingHorizontal: 16, + gap: 16, + }, + boldLabel: { + fontWeight: '800', + }, +}); + +export default SplitButtonExample; diff --git a/src/components/SplitButton/SplitButton.tsx b/src/components/SplitButton/SplitButton.tsx new file mode 100644 index 0000000000..b4eaaee75d --- /dev/null +++ b/src/components/SplitButton/SplitButton.tsx @@ -0,0 +1,671 @@ +import * as React from 'react'; +import { + AccessibilityState, + Animated, + ColorValue, + Easing, + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + StyleSheet, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +import { splitButtonElevation, type SplitButtonSize } from './tokens'; +import { + getSplitButtonColors, + getSplitButtonHitSlop, + getSplitButtonLeadingShape, + getSplitButtonRippleColor, + getSplitButtonSizeStyle, + getSplitButtonTrailingShape, + normalizeSplitButtonMode, + type SplitButtonMode, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { $Omit, Theme, ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import ActivityIndicator from '../ActivityIndicator'; +import { getButtonTouchableRippleStyle } from '../Button/utils'; +import Icon, { IconSource } from '../Icon'; +import Surface from '../Surface'; +import TouchableRipple, { + Props as TouchableRippleProps, +} from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type Props = $Omit< + React.ComponentProps, + 'children' | 'mode' +> & { + /** + * Mode of the split button. + * - `filled` - high-emphasis split button for important or final actions. + * - `tonal` - medium-emphasis split button using secondary container colors. + * - `elevated` - tonal split button with elevation for separation from busy surfaces. + * - `outlined` - medium-emphasis split button with transparent containers and outline. + */ + mode?: SplitButtonMode; + /** + * Size of the split button. + */ + size?: SplitButtonSize; + /** + * Label text for the leading button. + */ + label: string; + /** + * Icon to display before the label in the leading button. + */ + icon?: IconSource; + /** + * Icon to display in the trailing button. + */ + trailingIcon?: IconSource; + /** + * Whether to show a loading indicator in the leading button. + */ + loading?: boolean; + /** + * Whether both buttons are disabled. + */ + disabled?: boolean; + /** + * Custom container color for both buttons. + */ + buttonColor?: ColorValue; + /** + * Custom content color for icons and label. + */ + textColor?: ColorValue; + /** + * Custom ripple color for both buttons. + */ + rippleColor?: ColorValue; + /** + * Function to execute when the leading button is pressed. + */ + onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute when the trailing button is pressed. + */ + onTrailingPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute as soon as the leading button is pressed. + */ + onPressIn?: (e: GestureResponderEvent) => void; + /** + * Function to execute when the leading button press is released. + */ + onPressOut?: (e: GestureResponderEvent) => void; + /** + * Function to execute as soon as the trailing button is pressed. + */ + onTrailingPressIn?: (e: GestureResponderEvent) => void; + /** + * Function to execute when the trailing button press is released. + */ + onTrailingPressOut?: (e: GestureResponderEvent) => void; + /** + * Function to execute when the leading button is long pressed. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute when the trailing button is long pressed. + */ + onTrailingLongPress?: (e: GestureResponderEvent) => void; + /** + * The number of milliseconds a user must touch the leading button before executing `onLongPress`. + */ + delayLongPress?: number; + /** + * The number of milliseconds a user must touch the trailing button before executing `onTrailingLongPress`. + */ + trailingDelayLongPress?: number; + /** + * Accessibility label for the leading button. Falls back to `label`. + */ + accessibilityLabel?: string; + /** + * Accessibility label for the trailing button. + */ + trailingAccessibilityLabel?: string; + /** + * Accessibility state for the leading button. + */ + accessibilityState?: AccessibilityState; + /** + * Accessibility state for the trailing button. + */ + trailingAccessibilityState?: AccessibilityState; + /** + * Type of background drawable to display the feedback (Android). + * https://reactnative.dev/docs/pressable#rippleconfig + */ + background?: PressableAndroidRippleConfig; + /** + * Style for the outer split-button group. + */ + style?: Animated.WithAnimatedValue>; + /** + * Style for both button containers. + */ + buttonStyle?: StyleProp; + /** + * Style for the leading button container. + */ + leadingButtonStyle?: StyleProp; + /** + * Style for the trailing button container. + */ + trailingButtonStyle?: StyleProp; + /** + * Style for the leading button content row. + */ + contentStyle?: StyleProp; + /** + * Style for the label. + */ + labelStyle?: StyleProp; + /** + * Specifies the largest possible scale a label font can reach. + */ + maxFontSizeMultiplier?: number; + /** + * Sets additional distance outside of the leading button in which a press can be detected. + */ + hitSlop?: TouchableRippleProps['hitSlop']; + /** + * Sets additional distance outside of the trailing button in which a press can be detected. + */ + trailingHitSlop?: TouchableRippleProps['hitSlop']; + /** + * @optional + */ + theme?: ThemeProp; + /** + * TestID used for testing purposes. + */ + testID?: string; + ref?: React.RefObject; +}; + +/** + * Split buttons let people trigger a primary action from the leading button + * and open or trigger a contextual action from the trailing button. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { SplitButton } from 'react-native-paper'; + * + * const MyComponent = () => ( + * console.log('Send')} + * onTrailingPress={() => console.log('Show options')} + * /> + * ); + * + * export default MyComponent; + * ``` + */ +const SplitButton = forwardRef( + ( + { + mode = 'filled', + size = 'small', + label, + icon, + trailingIcon = 'menu-down', + loading, + disabled, + buttonColor: customButtonColor, + textColor: customTextColor, + rippleColor: customRippleColor, + onPress, + onTrailingPress, + onPressIn, + onPressOut, + onTrailingPressIn, + onTrailingPressOut, + onLongPress, + onTrailingLongPress, + delayLongPress, + trailingDelayLongPress, + accessibilityLabel = label, + trailingAccessibilityLabel = 'Show options', + accessibilityState, + trailingAccessibilityState, + background, + style, + buttonStyle, + leadingButtonStyle, + trailingButtonStyle, + contentStyle, + labelStyle, + maxFontSizeMultiplier, + hitSlop, + trailingHitSlop, + theme: themeOverrides, + testID = 'split-button', + ...rest + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); + const normalizedMode = normalizeSplitButtonMode(mode); + const [pressedButton, setPressedButton] = React.useState< + 'leading' | 'trailing' | null + >(null); + const sizeStyle = React.useMemo( + () => getSplitButtonSizeStyle({ size, theme }), + [size, theme] + ); + const colors = React.useMemo( + () => + getSplitButtonColors({ + theme, + mode, + disabled, + customButtonColor, + customTextColor, + }), + [theme, mode, disabled, customButtonColor, customTextColor] + ); + const rippleColor = React.useMemo( + () => + getSplitButtonRippleColor({ + contentColor: colors.contentColor, + customRippleColor, + }), + [colors.contentColor, customRippleColor] + ); + const leadingShape = React.useMemo( + () => + getSplitButtonLeadingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: + pressedButton === 'leading' + ? sizeStyle.innerPressedRadius + : sizeStyle.innerRadius, + }), + [ + pressedButton, + sizeStyle.containerRadius, + sizeStyle.innerPressedRadius, + sizeStyle.innerRadius, + ] + ); + const trailingShape = React.useMemo( + () => + getSplitButtonTrailingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: + pressedButton === 'trailing' + ? sizeStyle.innerPressedRadius + : sizeStyle.innerRadius, + }), + [ + pressedButton, + sizeStyle.containerRadius, + sizeStyle.innerPressedRadius, + sizeStyle.innerRadius, + ] + ); + const leadingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop }), + [size, hitSlop] + ); + const resolvedTrailingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }), + [size, trailingHitSlop] + ); + + const { color: customLabelColor, fontSize: customLabelSize } = + StyleSheet.flatten(labelStyle) || {}; + const contentColor = + typeof customLabelColor === 'string' + ? customLabelColor + : colors.contentColor; + const labelTextStyle: TextStyle = { + color: colors.contentColor, + ...(theme as Theme).fonts[sizeStyle.labelVariant], + }; + const disabledState = { disabled: true }; + const leadingAccessibilityState = disabled + ? { ...accessibilityState, ...disabledState } + : accessibilityState; + const trailingAccessibilityStateWithDisabled = disabled + ? { ...trailingAccessibilityState, ...disabledState } + : trailingAccessibilityState; + const isElevationEntitled = !disabled && normalizedMode === 'elevated'; + const { current: elevation } = React.useRef( + new Animated.Value(isElevationEntitled ? splitButtonElevation.enabled : 0) + ); + const animateElevation = React.useCallback( + (toValue: number) => { + if (!isElevationEntitled) { + return; + } + + Animated.timing(elevation, { + toValue, + duration: + toValue === splitButtonElevation.pressed + ? theme.motion.duration.short4 + : theme.motion.duration.short3, + easing: Easing.bezier(...theme.motion.easing.standard), + useNativeDriver: false, + }).start(); + }, + [ + elevation, + isElevationEntitled, + theme.motion.duration.short3, + theme.motion.duration.short4, + theme.motion.easing.standard, + ] + ); + const leadingHasTouchHandler = hasTouchHandler({ + onPress, + onPressIn, + onPressOut, + onLongPress, + }); + const trailingHasTouchHandler = hasTouchHandler({ + onPress: onTrailingPress, + onPressIn: onTrailingPressIn, + onPressOut: onTrailingPressOut, + onLongPress: onTrailingLongPress, + }); + const handleLeadingPressIn = React.useCallback( + (e: GestureResponderEvent) => { + onPressIn?.(e); + setPressedButton('leading'); + animateElevation(splitButtonElevation.pressed); + }, + [animateElevation, onPressIn] + ); + const handleLeadingPressOut = React.useCallback( + (e: GestureResponderEvent) => { + onPressOut?.(e); + setPressedButton(null); + animateElevation(splitButtonElevation.enabled); + }, + [animateElevation, onPressOut] + ); + const handleTrailingPressIn = React.useCallback( + (e: GestureResponderEvent) => { + onTrailingPressIn?.(e); + setPressedButton('trailing'); + animateElevation(splitButtonElevation.pressed); + }, + [animateElevation, onTrailingPressIn] + ); + const handleTrailingPressOut = React.useCallback( + (e: GestureResponderEvent) => { + onTrailingPressOut?.(e); + setPressedButton(null); + animateElevation(splitButtonElevation.enabled); + }, + [animateElevation, onTrailingPressOut] + ); + React.useEffect(() => { + if (!isElevationEntitled) { + setPressedButton(null); + } + + Animated.timing(elevation, { + toValue: isElevationEntitled ? splitButtonElevation.enabled : 0, + duration: 0, + useNativeDriver: false, + }).start(); + }, [elevation, isElevationEntitled]); + + const commonButtonStyle: ViewStyle = { + height: sizeStyle.containerHeight, + backgroundColor: + colors.containerOpacity < 1 ? 'transparent' : colors.containerColor, + borderColor: colors.borderColor, + borderWidth: colors.borderWidth, + }; + + return ( + > + } + > + + + + + {icon && !loading ? ( + + ) : null} + {loading ? ( + + ) : null} + + {label} + + + + + + + + + + + + + + + ); + } +); + +const ButtonBackground = ({ + backgroundColor, + opacity, + borderRadiusStyle, +}: { + backgroundColor: ColorValue; + opacity: number; + borderRadiusStyle: ViewStyle; +}) => { + if (opacity >= 1) { + return null; + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + group: { + flexDirection: 'row', + alignItems: 'center', + maxWidth: '100%', + }, + leading: { + minWidth: 48, + flexShrink: 1, + borderStyle: 'solid', + }, + trailing: { + minWidth: 48, + borderStyle: 'solid', + }, + ripple: { + height: '100%', + }, + leadingContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + label: { + flexShrink: 1, + }, + trailingContent: { + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default SplitButton; + +// @component-docs ignore-next-line +export { SplitButton }; diff --git a/src/components/SplitButton/index.ts b/src/components/SplitButton/index.ts new file mode 100644 index 0000000000..8a70e0e614 --- /dev/null +++ b/src/components/SplitButton/index.ts @@ -0,0 +1,2 @@ +export { default } from './SplitButton'; +export type { Props } from './SplitButton'; diff --git a/src/components/SplitButton/tokens.ts b/src/components/SplitButton/tokens.ts new file mode 100644 index 0000000000..75c4d569cd --- /dev/null +++ b/src/components/SplitButton/tokens.ts @@ -0,0 +1,108 @@ +import type { ThemeShapeCorners, TypescaleKey } from '../../theme/types'; + +export type SplitButtonSize = + | 'extra-small' + | 'small' + | 'medium' + | 'large' + | 'extra-large'; + +export type SplitButtonShapeKey = keyof ThemeShapeCorners | 'full'; + +export type SplitButtonSizeTokens = { + betweenSpace: number; + containerHeight: number; + containerShape: SplitButtonShapeKey; + innerCornerShape: SplitButtonShapeKey; + innerPressedCornerShape: SplitButtonShapeKey; + leadingButtonLeadingSpace: number; + leadingButtonTrailingSpace: number; + leadingIconSize: number; + trailingButtonLeadingSpace: number; + trailingButtonTrailingSpace: number; + trailingIconSize: number; + labelVariant: TypescaleKey; +}; + +export const splitButtonSizeTokens: Record< + SplitButtonSize, + SplitButtonSizeTokens +> = { + 'extra-small': { + betweenSpace: 2, + containerHeight: 32, + containerShape: 'full', + innerCornerShape: 'extraSmall', + innerPressedCornerShape: 'small', + leadingButtonLeadingSpace: 12, + leadingButtonTrailingSpace: 10, + leadingIconSize: 20, + trailingButtonLeadingSpace: 13, + trailingButtonTrailingSpace: 13, + trailingIconSize: 22, + labelVariant: 'labelLarge', + }, + small: { + betweenSpace: 2, + containerHeight: 40, + containerShape: 'full', + innerCornerShape: 'extraSmall', + innerPressedCornerShape: 'medium', + leadingButtonLeadingSpace: 16, + leadingButtonTrailingSpace: 12, + leadingIconSize: 20, + trailingButtonLeadingSpace: 13, + trailingButtonTrailingSpace: 13, + trailingIconSize: 22, + labelVariant: 'labelLarge', + }, + medium: { + betweenSpace: 2, + containerHeight: 56, + containerShape: 'full', + innerCornerShape: 'extraSmall', + innerPressedCornerShape: 'medium', + leadingButtonLeadingSpace: 24, + leadingButtonTrailingSpace: 24, + leadingIconSize: 24, + trailingButtonLeadingSpace: 15, + trailingButtonTrailingSpace: 15, + trailingIconSize: 26, + labelVariant: 'titleMedium', + }, + large: { + betweenSpace: 2, + containerHeight: 96, + containerShape: 'full', + innerCornerShape: 'small', + innerPressedCornerShape: 'largeIncreased', + leadingButtonLeadingSpace: 48, + leadingButtonTrailingSpace: 48, + leadingIconSize: 32, + trailingButtonLeadingSpace: 29, + trailingButtonTrailingSpace: 29, + trailingIconSize: 38, + labelVariant: 'headlineSmall', + }, + 'extra-large': { + betweenSpace: 2, + containerHeight: 136, + containerShape: 'full', + innerCornerShape: 'medium', + innerPressedCornerShape: 'largeIncreased', + leadingButtonLeadingSpace: 64, + leadingButtonTrailingSpace: 64, + leadingIconSize: 40, + trailingButtonLeadingSpace: 43, + trailingButtonTrailingSpace: 43, + trailingIconSize: 50, + labelVariant: 'headlineLarge', + }, +}; + +export const splitButtonMinInteractiveSize = 48; + +export const splitButtonElevation = { + enabled: 1, + pressed: 2, +} as const; diff --git a/src/components/SplitButton/utils.ts b/src/components/SplitButton/utils.ts new file mode 100644 index 0000000000..1a2ae78f5f --- /dev/null +++ b/src/components/SplitButton/utils.ts @@ -0,0 +1,242 @@ +import type { ColorValue, Insets, ViewStyle } from 'react-native'; + +import color from 'color'; + +import { + splitButtonMinInteractiveSize, + splitButtonSizeTokens, + type SplitButtonShapeKey, + type SplitButtonSize, +} from './tokens'; +import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { InternalTheme, Theme } from '../../types'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +const stateOpacity = tokens.md.sys.state.opacity; + +export type SplitButtonMode = 'filled' | 'tonal' | 'elevated' | 'outlined'; + +export type SplitButtonNormalizedMode = + | 'filled' + | 'tonal' + | 'elevated' + | 'outlined'; + +export const normalizeSplitButtonMode = ( + mode: SplitButtonMode +): SplitButtonNormalizedMode => { + return mode; +}; + +export const resolveSplitButtonCorner = ( + theme: InternalTheme, + key: SplitButtonShapeKey +) => (key === 'full' ? cornerFull : (theme as Theme).shapes.corner[key]); + +export const getSplitButtonSizeStyle = ({ + size, + theme, +}: { + size: SplitButtonSize; + theme: InternalTheme; +}) => { + const sizeTokens = splitButtonSizeTokens[size]; + + return { + ...sizeTokens, + containerRadius: resolveSplitButtonCorner(theme, sizeTokens.containerShape), + innerRadius: resolveSplitButtonCorner(theme, sizeTokens.innerCornerShape), + innerPressedRadius: resolveSplitButtonCorner( + theme, + sizeTokens.innerPressedCornerShape + ), + }; +}; + +const getSplitButtonContainerColor = ({ + mode, + theme, + disabled, + customButtonColor, +}: { + mode: SplitButtonNormalizedMode; + theme: InternalTheme; + disabled?: boolean; + customButtonColor?: ColorValue; +}) => { + const { colors } = theme as Theme; + + if (customButtonColor && !disabled) { + return customButtonColor; + } + + if (disabled) { + return mode === 'outlined' ? 'transparent' : colors.onSurface; + } + + if (mode === 'filled') { + return colors.primary; + } + + if (mode === 'tonal') { + return colors.secondaryContainer; + } + + if (mode === 'elevated') { + return colors.surfaceContainerLow; + } + + return 'transparent'; +}; + +const getSplitButtonContentColor = ({ + mode, + theme, + disabled, + customTextColor, +}: { + mode: SplitButtonNormalizedMode; + theme: InternalTheme; + disabled?: boolean; + customTextColor?: ColorValue; +}) => { + const { colors } = theme as Theme; + + if (customTextColor && !disabled) { + return customTextColor; + } + + if (disabled) { + return colors.onSurface; + } + + if (mode === 'filled') { + return colors.onPrimary; + } + + if (mode === 'tonal') { + return colors.onSecondaryContainer; + } + + if (mode === 'outlined') { + return colors.onSurfaceVariant; + } + + return colors.primary; +}; + +export const getSplitButtonColors = ({ + theme, + mode, + disabled, + customButtonColor, + customTextColor, +}: { + theme: InternalTheme; + mode: SplitButtonMode; + disabled?: boolean; + customButtonColor?: ColorValue; + customTextColor?: ColorValue; +}) => { + const normalizedMode = normalizeSplitButtonMode(mode); + const containerColor = getSplitButtonContainerColor({ + mode: normalizedMode, + theme, + disabled, + customButtonColor, + }); + const contentColor = getSplitButtonContentColor({ + mode: normalizedMode, + theme, + disabled, + customTextColor, + }); + const isOutlined = normalizedMode === 'outlined'; + + return { + containerColor, + contentColor, + borderColor: isOutlined ? (theme as Theme).colors.outline : 'transparent', + borderWidth: isOutlined ? 1 : 0, + containerOpacity: + disabled && normalizedMode !== 'outlined' + ? stateOpacity.pressed + : stateOpacity.enabled, + contentOpacity: disabled ? stateOpacity.disabled : stateOpacity.enabled, + }; +}; + +export const getSplitButtonRippleColor = ({ + contentColor, + customRippleColor, +}: { + contentColor: ColorValue; + customRippleColor?: ColorValue; +}): ColorValue | undefined => { + if (customRippleColor) { + return customRippleColor; + } + + if (typeof contentColor !== 'string') { + return undefined; + } + + return color(contentColor).alpha(stateOpacity.pressed).rgb().string(); +}; + +export const getSplitButtonHitSlop = ({ + size, + hitSlop, +}: { + size: SplitButtonSize; + hitSlop?: TouchableRippleProps['hitSlop']; +}): TouchableRippleProps['hitSlop'] => { + if (typeof hitSlop === 'number') { + return hitSlop; + } + + const height = splitButtonSizeTokens[size].containerHeight; + const verticalSlop = Math.max( + 0, + (splitButtonMinInteractiveSize - height) / 2 + ); + + if (verticalSlop === 0) { + return hitSlop; + } + + const insetHitSlop = (hitSlop || {}) as Insets; + + return { + ...insetHitSlop, + top: insetHitSlop.top ?? verticalSlop, + bottom: insetHitSlop.bottom ?? verticalSlop, + }; +}; + +export const getSplitButtonLeadingShape = ({ + containerRadius, + innerRadius, +}: { + containerRadius: number; + innerRadius: number; +}): ViewStyle => ({ + borderTopStartRadius: containerRadius, + borderBottomStartRadius: containerRadius, + borderTopEndRadius: innerRadius, + borderBottomEndRadius: innerRadius, +}); + +export const getSplitButtonTrailingShape = ({ + containerRadius, + innerRadius, +}: { + containerRadius: number; + innerRadius: number; +}): ViewStyle => ({ + borderTopStartRadius: innerRadius, + borderBottomStartRadius: innerRadius, + borderTopEndRadius: containerRadius, + borderBottomEndRadius: containerRadius, +}); diff --git a/src/components/__tests__/SplitButton.test.tsx b/src/components/__tests__/SplitButton.test.tsx new file mode 100644 index 0000000000..3a7bb87c1d --- /dev/null +++ b/src/components/__tests__/SplitButton.test.tsx @@ -0,0 +1,241 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; + +import { fireEvent } from '@testing-library/react-native'; + +import { getTheme } from '../../core/theming'; +import { render } from '../../test-utils'; +import SplitButton from '../SplitButton/SplitButton'; +import { + getSplitButtonColors, + getSplitButtonHitSlop, + getSplitButtonLeadingShape, + getSplitButtonSizeStyle, + getSplitButtonTrailingShape, + normalizeSplitButtonMode, +} from '../SplitButton/utils'; + +const styles = StyleSheet.create({ + leading: { + minWidth: 120, + }, + trailing: { + minWidth: 64, + }, + label: { + fontSize: 18, + }, +}); + +it('renders a filled split button by default', () => { + const { getByTestId } = render( + {}} onTrailingPress={() => {}} /> + ); + + expect(getByTestId('split-button-label')).toHaveTextContent('Send'); + expect(getByTestId('split-button-container')).toHaveStyle({ + height: 40, + }); + expect(getByTestId('split-button-leading-container')).toBeTruthy(); + expect(getByTestId('split-button-trailing-container')).toBeTruthy(); +}); + +it('calls leading and trailing press handlers separately', () => { + const onPress = jest.fn(); + const onTrailingPress = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent.press(getByTestId('split-button-leading')); + fireEvent.press(getByTestId('split-button-trailing')); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onTrailingPress).toHaveBeenCalledTimes(1); +}); + +it('calls leading and trailing press-in and press-out handlers separately', () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onTrailingPress = jest.fn(); + const onTrailingPressIn = jest.fn(); + const onTrailingPressOut = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent(getByTestId('split-button-leading'), 'onPressIn'); + fireEvent(getByTestId('split-button-leading'), 'onPressOut'); + fireEvent(getByTestId('split-button-trailing'), 'onPressIn'); + fireEvent(getByTestId('split-button-trailing'), 'onPressOut'); + + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onTrailingPressIn).toHaveBeenCalledTimes(1); + expect(onTrailingPressOut).toHaveBeenCalledTimes(1); +}); + +it('uses pressed inner-corner tokens for the active side', () => { + const theme = getTheme(); + const { getByTestId } = render( + {}} onTrailingPress={() => {}} /> + ); + + fireEvent(getByTestId('split-button-leading'), 'onPressIn'); + + expect(getByTestId('split-button-leading-container')).toHaveStyle({ + borderTopEndRadius: theme.shapes.corner.medium, + borderBottomEndRadius: theme.shapes.corner.medium, + }); + expect(getByTestId('split-button-trailing-container')).toHaveStyle({ + borderTopStartRadius: theme.shapes.corner.extraSmall, + borderBottomStartRadius: theme.shapes.corner.extraSmall, + }); + + fireEvent(getByTestId('split-button-leading'), 'onPressOut'); + fireEvent(getByTestId('split-button-trailing'), 'onPressIn'); + + expect(getByTestId('split-button-trailing-container')).toHaveStyle({ + borderTopStartRadius: theme.shapes.corner.medium, + borderBottomStartRadius: theme.shapes.corner.medium, + }); +}); + +it('marks both press targets disabled when disabled', () => { + const { getByTestId } = render( + {}} + onTrailingPress={() => {}} + /> + ); + + expect(getByTestId('split-button-leading').props.accessibilityState).toEqual({ + disabled: true, + }); + expect(getByTestId('split-button-trailing').props.accessibilityState).toEqual( + { + disabled: true, + } + ); +}); + +it('passes custom styles to the correct target', () => { + const { getByTestId } = render( + {}} + onTrailingPress={() => {}} + leadingButtonStyle={styles.leading} + trailingButtonStyle={styles.trailing} + labelStyle={styles.label} + /> + ); + + expect(getByTestId('split-button-leading-container')).toHaveStyle( + styles.leading + ); + expect(getByTestId('split-button-trailing-container')).toHaveStyle( + styles.trailing + ); + expect(getByTestId('split-button-label')).toHaveStyle(styles.label); +}); + +it('merges trailing accessibility state with expanded state', () => { + const { getByTestId } = render( + {}} + onTrailingPress={() => {}} + trailingAccessibilityState={{ expanded: true }} + /> + ); + + expect( + getByTestId('split-button-trailing').props.accessibilityState + ).toMatchObject({ + expanded: true, + }); +}); + +describe('SplitButton utils', () => { + it('normalizes supported MD3 modes', () => { + expect(normalizeSplitButtonMode('filled')).toBe('filled'); + expect(normalizeSplitButtonMode('tonal')).toBe('tonal'); + expect(normalizeSplitButtonMode('outlined')).toBe('outlined'); + }); + + it('resolves MD3 color roles for modes', () => { + const theme = getTheme(); + + expect(getSplitButtonColors({ theme, mode: 'filled' }).containerColor).toBe( + theme.colors.primary + ); + expect(getSplitButtonColors({ theme, mode: 'tonal' }).contentColor).toBe( + theme.colors.onSecondaryContainer + ); + expect(getSplitButtonColors({ theme, mode: 'elevated' }).contentColor).toBe( + theme.colors.primary + ); + expect(getSplitButtonColors({ theme, mode: 'outlined' }).borderColor).toBe( + theme.colors.outline + ); + }); + + it('resolves per-size tokens against theme shape values', () => { + const theme = getTheme(); + const sizeStyle = getSplitButtonSizeStyle({ theme, size: 'large' }); + + expect(sizeStyle.containerHeight).toBe(96); + expect(sizeStyle.trailingIconSize).toBe(38); + expect(sizeStyle.innerRadius).toBe(theme.shapes.corner.small); + expect(sizeStyle.innerPressedRadius).toBe( + theme.shapes.corner.largeIncreased + ); + }); + + it('uses logical leading and trailing shapes', () => { + expect( + getSplitButtonLeadingShape({ containerRadius: 20, innerRadius: 4 }) + ).toEqual({ + borderTopStartRadius: 20, + borderBottomStartRadius: 20, + borderTopEndRadius: 4, + borderBottomEndRadius: 4, + }); + expect( + getSplitButtonTrailingShape({ containerRadius: 20, innerRadius: 4 }) + ).toEqual({ + borderTopStartRadius: 4, + borderBottomStartRadius: 4, + borderTopEndRadius: 20, + borderBottomEndRadius: 20, + }); + }); + + it('expands small visual sizes to a 48dp touch target', () => { + expect(getSplitButtonHitSlop({ size: 'extra-small' })).toEqual({ + top: 8, + bottom: 8, + }); + expect(getSplitButtonHitSlop({ size: 'small' })).toEqual({ + top: 4, + bottom: 4, + }); + expect(getSplitButtonHitSlop({ size: 'medium' })).toBeUndefined(); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..8280928eae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,6 +42,7 @@ export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton'; export { default as Searchbar } from './components/Searchbar'; export { default as Snackbar } from './components/Snackbar'; +export { default as SplitButton } from './components/SplitButton'; export { default as Surface } from './components/Surface'; export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; @@ -126,6 +127,7 @@ export type { Props as RadioButtonIOSProps } from './components/RadioButton/Radi 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'; +export type { Props as SplitButtonProps } from './components/SplitButton'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch'; export type { From 2c74f69bd86709f3ee291cc4817a5ff64e6e8fcd Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Thu, 25 Jun 2026 17:10:07 +0200 Subject: [PATCH 2/2] fix: address split button review feedback --- src/components/SplitButton/SplitButton.tsx | 771 +++++++++--------- src/components/SplitButton/tokens.ts | 1 + src/components/SplitButton/utils.ts | 10 +- src/components/__tests__/SplitButton.test.tsx | 132 +-- 4 files changed, 469 insertions(+), 445 deletions(-) diff --git a/src/components/SplitButton/SplitButton.tsx b/src/components/SplitButton/SplitButton.tsx index b4eaaee75d..97a4e818fd 100644 --- a/src/components/SplitButton/SplitButton.tsx +++ b/src/components/SplitButton/SplitButton.tsx @@ -1,18 +1,24 @@ import * as React from 'react'; import { AccessibilityState, - Animated, ColorValue, - Easing, GestureResponderEvent, PressableAndroidRippleConfig, StyleProp, StyleSheet, TextStyle, View, + ViewProps, ViewStyle, } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + import { splitButtonElevation, type SplitButtonSize } from './tokens'; import { getSplitButtonColors, @@ -25,8 +31,7 @@ import { type SplitButtonMode, } from './utils'; import { useInternalTheme } from '../../core/theming'; -import type { $Omit, Theme, ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; +import type { $Omit, ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import ActivityIndicator from '../ActivityIndicator'; import { getButtonTouchableRippleStyle } from '../Button/utils'; @@ -37,9 +42,11 @@ import TouchableRipple, { } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; +const AnimatedSurface = Animated.createAnimatedComponent(Surface); + export type Props = $Omit< - React.ComponentProps, - 'children' | 'mode' + ViewProps, + 'children' | 'style' | 'onPress' | 'onPressIn' | 'onPressOut' > & { /** * Mode of the split button. @@ -149,7 +156,7 @@ export type Props = $Omit< /** * Style for the outer split-button group. */ - style?: Animated.WithAnimatedValue>; + style?: StyleProp; /** * Style for both button containers. */ @@ -190,7 +197,6 @@ export type Props = $Omit< * TestID used for testing purposes. */ testID?: string; - ref?: React.RefObject; }; /** @@ -216,397 +222,412 @@ export type Props = $Omit< * export default MyComponent; * ``` */ -const SplitButton = forwardRef( - ( - { - mode = 'filled', - size = 'small', - label, - icon, - trailingIcon = 'menu-down', - loading, - disabled, - buttonColor: customButtonColor, - textColor: customTextColor, - rippleColor: customRippleColor, - onPress, - onTrailingPress, +const SplitButton = ({ + mode = 'filled', + size = 'small', + label, + icon, + trailingIcon = 'menu-down', + loading, + disabled, + buttonColor: customButtonColor, + textColor: customTextColor, + rippleColor: customRippleColor, + onPress, + onTrailingPress, + onPressIn, + onPressOut, + onTrailingPressIn, + onTrailingPressOut, + onLongPress, + onTrailingLongPress, + delayLongPress, + trailingDelayLongPress, + accessibilityLabel = label, + trailingAccessibilityLabel = 'Show options', + accessibilityState, + trailingAccessibilityState, + background, + style, + buttonStyle, + leadingButtonStyle, + trailingButtonStyle, + contentStyle, + labelStyle, + maxFontSizeMultiplier, + hitSlop, + trailingHitSlop, + theme: themeOverrides, + testID, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const normalizedMode = normalizeSplitButtonMode(mode); + const sizeStyle = React.useMemo( + () => getSplitButtonSizeStyle({ size, theme }), + [size, theme] + ); + const leadingInnerRadius = useSharedValue(sizeStyle.innerRadius); + const trailingInnerRadius = useSharedValue(sizeStyle.innerRadius); + const colors = React.useMemo( + () => + getSplitButtonColors({ + theme, + mode, + disabled, + customButtonColor, + customTextColor, + }), + [theme, mode, disabled, customButtonColor, customTextColor] + ); + const rippleColor = React.useMemo( + () => + getSplitButtonRippleColor({ + contentColor: colors.contentColor, + customRippleColor, + }), + [colors.contentColor, customRippleColor] + ); + const leadingShape = React.useMemo( + () => + getSplitButtonLeadingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: sizeStyle.innerRadius, + }), + [sizeStyle.containerRadius, sizeStyle.innerRadius] + ); + const trailingShape = React.useMemo( + () => + getSplitButtonTrailingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: sizeStyle.innerRadius, + }), + [sizeStyle.containerRadius, sizeStyle.innerRadius] + ); + const pressTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short4, + easing: Easing.bezier(...theme.motion.easing.standard), + }), + [theme.motion.duration.short4, theme.motion.easing.standard] + ); + const releaseTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short3, + easing: Easing.bezier(...theme.motion.easing.standard), + }), + [theme.motion.duration.short3, theme.motion.easing.standard] + ); + const leadingAnimatedShapeStyle = useAnimatedStyle( + () => ({ + ...getSplitButtonLeadingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: leadingInnerRadius.value, + }), + }), + [sizeStyle.containerRadius] + ); + const trailingAnimatedShapeStyle = useAnimatedStyle( + () => ({ + ...getSplitButtonTrailingShape({ + containerRadius: sizeStyle.containerRadius, + innerRadius: trailingInnerRadius.value, + }), + }), + [sizeStyle.containerRadius] + ); + const leadingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop }), + [size, hitSlop] + ); + const resolvedTrailingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }), + [size, trailingHitSlop] + ); + + const { color: customLabelColor, fontSize: customLabelSize } = + StyleSheet.flatten(labelStyle) || {}; + const contentColor = + typeof customLabelColor === 'string' + ? customLabelColor + : colors.contentColor; + const labelTextStyle: TextStyle = { + color: colors.contentColor, + ...theme.fonts[sizeStyle.labelVariant], + }; + const disabledState = { disabled: true }; + const leadingAccessibilityState = disabled + ? { ...accessibilityState, ...disabledState } + : accessibilityState; + const trailingAccessibilityStateWithDisabled = disabled + ? { ...trailingAccessibilityState, ...disabledState } + : trailingAccessibilityState; + const isElevationEntitled = !disabled && normalizedMode === 'elevated'; + const leadingHasTouchHandler = hasTouchHandler({ + onPress, + onPressIn, + onPressOut, + onLongPress, + }); + const trailingHasTouchHandler = hasTouchHandler({ + onPress: onTrailingPress, + onPressIn: onTrailingPressIn, + onPressOut: onTrailingPressOut, + onLongPress: onTrailingLongPress, + }); + const handleLeadingPressIn = React.useCallback( + (e: GestureResponderEvent) => { + onPressIn?.(e); + leadingInnerRadius.value = withTiming( + sizeStyle.innerPressedRadius, + pressTimingConfig + ); + trailingInnerRadius.value = withTiming( + sizeStyle.innerRadius, + releaseTimingConfig + ); + }, + [ + leadingInnerRadius, onPressIn, - onPressOut, - onTrailingPressIn, - onTrailingPressOut, - onLongPress, - onTrailingLongPress, - delayLongPress, - trailingDelayLongPress, - accessibilityLabel = label, - trailingAccessibilityLabel = 'Show options', - accessibilityState, - trailingAccessibilityState, - background, - style, - buttonStyle, - leadingButtonStyle, - trailingButtonStyle, - contentStyle, - labelStyle, - maxFontSizeMultiplier, - hitSlop, - trailingHitSlop, - theme: themeOverrides, - testID = 'split-button', - ...rest + pressTimingConfig, + releaseTimingConfig, + sizeStyle.innerPressedRadius, + sizeStyle.innerRadius, + trailingInnerRadius, + ] + ); + const handleLeadingPressOut = React.useCallback( + (e: GestureResponderEvent) => { + onPressOut?.(e); + leadingInnerRadius.value = withTiming( + sizeStyle.innerRadius, + releaseTimingConfig + ); }, - ref - ) => { - const theme = useInternalTheme(themeOverrides); - const normalizedMode = normalizeSplitButtonMode(mode); - const [pressedButton, setPressedButton] = React.useState< - 'leading' | 'trailing' | null - >(null); - const sizeStyle = React.useMemo( - () => getSplitButtonSizeStyle({ size, theme }), - [size, theme] - ); - const colors = React.useMemo( - () => - getSplitButtonColors({ - theme, - mode, - disabled, - customButtonColor, - customTextColor, - }), - [theme, mode, disabled, customButtonColor, customTextColor] - ); - const rippleColor = React.useMemo( - () => - getSplitButtonRippleColor({ - contentColor: colors.contentColor, - customRippleColor, - }), - [colors.contentColor, customRippleColor] - ); - const leadingShape = React.useMemo( - () => - getSplitButtonLeadingShape({ - containerRadius: sizeStyle.containerRadius, - innerRadius: - pressedButton === 'leading' - ? sizeStyle.innerPressedRadius - : sizeStyle.innerRadius, - }), - [ - pressedButton, - sizeStyle.containerRadius, + [leadingInnerRadius, onPressOut, releaseTimingConfig, sizeStyle.innerRadius] + ); + const handleTrailingPressIn = React.useCallback( + (e: GestureResponderEvent) => { + onTrailingPressIn?.(e); + trailingInnerRadius.value = withTiming( sizeStyle.innerPressedRadius, + pressTimingConfig + ); + leadingInnerRadius.value = withTiming( sizeStyle.innerRadius, - ] - ); - const trailingShape = React.useMemo( - () => - getSplitButtonTrailingShape({ - containerRadius: sizeStyle.containerRadius, - innerRadius: - pressedButton === 'trailing' - ? sizeStyle.innerPressedRadius - : sizeStyle.innerRadius, - }), - [ - pressedButton, - sizeStyle.containerRadius, - sizeStyle.innerPressedRadius, + releaseTimingConfig + ); + }, + [ + leadingInnerRadius, + onTrailingPressIn, + pressTimingConfig, + releaseTimingConfig, + sizeStyle.innerPressedRadius, + sizeStyle.innerRadius, + trailingInnerRadius, + ] + ); + const handleTrailingPressOut = React.useCallback( + (e: GestureResponderEvent) => { + onTrailingPressOut?.(e); + trailingInnerRadius.value = withTiming( sizeStyle.innerRadius, - ] - ); - const leadingHitSlop = React.useMemo( - () => getSplitButtonHitSlop({ size, hitSlop }), - [size, hitSlop] - ); - const resolvedTrailingHitSlop = React.useMemo( - () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }), - [size, trailingHitSlop] - ); - - const { color: customLabelColor, fontSize: customLabelSize } = - StyleSheet.flatten(labelStyle) || {}; - const contentColor = - typeof customLabelColor === 'string' - ? customLabelColor - : colors.contentColor; - const labelTextStyle: TextStyle = { - color: colors.contentColor, - ...(theme as Theme).fonts[sizeStyle.labelVariant], - }; - const disabledState = { disabled: true }; - const leadingAccessibilityState = disabled - ? { ...accessibilityState, ...disabledState } - : accessibilityState; - const trailingAccessibilityStateWithDisabled = disabled - ? { ...trailingAccessibilityState, ...disabledState } - : trailingAccessibilityState; - const isElevationEntitled = !disabled && normalizedMode === 'elevated'; - const { current: elevation } = React.useRef( - new Animated.Value(isElevationEntitled ? splitButtonElevation.enabled : 0) - ); - const animateElevation = React.useCallback( - (toValue: number) => { - if (!isElevationEntitled) { - return; - } - - Animated.timing(elevation, { - toValue, - duration: - toValue === splitButtonElevation.pressed - ? theme.motion.duration.short4 - : theme.motion.duration.short3, - easing: Easing.bezier(...theme.motion.easing.standard), - useNativeDriver: false, - }).start(); - }, - [ - elevation, - isElevationEntitled, - theme.motion.duration.short3, - theme.motion.duration.short4, - theme.motion.easing.standard, - ] - ); - const leadingHasTouchHandler = hasTouchHandler({ - onPress, - onPressIn, - onPressOut, - onLongPress, - }); - const trailingHasTouchHandler = hasTouchHandler({ - onPress: onTrailingPress, - onPressIn: onTrailingPressIn, - onPressOut: onTrailingPressOut, - onLongPress: onTrailingLongPress, - }); - const handleLeadingPressIn = React.useCallback( - (e: GestureResponderEvent) => { - onPressIn?.(e); - setPressedButton('leading'); - animateElevation(splitButtonElevation.pressed); - }, - [animateElevation, onPressIn] - ); - const handleLeadingPressOut = React.useCallback( - (e: GestureResponderEvent) => { - onPressOut?.(e); - setPressedButton(null); - animateElevation(splitButtonElevation.enabled); - }, - [animateElevation, onPressOut] - ); - const handleTrailingPressIn = React.useCallback( - (e: GestureResponderEvent) => { - onTrailingPressIn?.(e); - setPressedButton('trailing'); - animateElevation(splitButtonElevation.pressed); - }, - [animateElevation, onTrailingPressIn] - ); - const handleTrailingPressOut = React.useCallback( - (e: GestureResponderEvent) => { - onTrailingPressOut?.(e); - setPressedButton(null); - animateElevation(splitButtonElevation.enabled); - }, - [animateElevation, onTrailingPressOut] - ); - React.useEffect(() => { - if (!isElevationEntitled) { - setPressedButton(null); - } - - Animated.timing(elevation, { - toValue: isElevationEntitled ? splitButtonElevation.enabled : 0, - duration: 0, - useNativeDriver: false, - }).start(); - }, [elevation, isElevationEntitled]); + releaseTimingConfig + ); + }, + [ + onTrailingPressOut, + releaseTimingConfig, + sizeStyle.innerRadius, + trailingInnerRadius, + ] + ); + React.useEffect(() => { + leadingInnerRadius.value = sizeStyle.innerRadius; + trailingInnerRadius.value = sizeStyle.innerRadius; + }, [leadingInnerRadius, sizeStyle.innerRadius, trailingInnerRadius]); - const commonButtonStyle: ViewStyle = { - height: sizeStyle.containerHeight, - backgroundColor: - colors.containerOpacity < 1 ? 'transparent' : colors.containerColor, - borderColor: colors.borderColor, - borderWidth: colors.borderWidth, - }; + const commonButtonStyle: ViewStyle = { + height: sizeStyle.containerHeight, + backgroundColor: + colors.containerOpacity < 1 ? 'transparent' : colors.containerColor, + borderColor: colors.borderColor, + borderWidth: colors.borderWidth, + }; + const elevation = isElevationEntitled + ? splitButtonElevation.enabled + : splitButtonElevation.disabled; + const getTestID = (suffix: string) => + testID ? `${testID}-${suffix}` : undefined; - return ( - > - } + return ( + + - + - - - + ) : null} + {loading ? ( + + ) : null} + - {icon && !loading ? ( - - ) : null} - {loading ? ( - - ) : null} - - {label} - - - - + {label} + + + + - + + - - - - - - - - - ); - } -); + + + + + + ); +}; const ButtonBackground = ({ backgroundColor, diff --git a/src/components/SplitButton/tokens.ts b/src/components/SplitButton/tokens.ts index 75c4d569cd..575c296f66 100644 --- a/src/components/SplitButton/tokens.ts +++ b/src/components/SplitButton/tokens.ts @@ -103,6 +103,7 @@ export const splitButtonSizeTokens: Record< export const splitButtonMinInteractiveSize = 48; export const splitButtonElevation = { + disabled: 0, enabled: 1, pressed: 2, } as const; diff --git a/src/components/SplitButton/utils.ts b/src/components/SplitButton/utils.ts index 1a2ae78f5f..077c17f883 100644 --- a/src/components/SplitButton/utils.ts +++ b/src/components/SplitButton/utils.ts @@ -10,7 +10,7 @@ import { } from './tokens'; import { tokens } from '../../theme/tokens'; import { cornerFull } from '../../theme/tokens/sys/shape'; -import type { InternalTheme, Theme } from '../../types'; +import type { InternalTheme } from '../../types'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; const stateOpacity = tokens.md.sys.state.opacity; @@ -32,7 +32,7 @@ export const normalizeSplitButtonMode = ( export const resolveSplitButtonCorner = ( theme: InternalTheme, key: SplitButtonShapeKey -) => (key === 'full' ? cornerFull : (theme as Theme).shapes.corner[key]); +) => (key === 'full' ? cornerFull : theme.shapes.corner[key]); export const getSplitButtonSizeStyle = ({ size, @@ -65,7 +65,7 @@ const getSplitButtonContainerColor = ({ disabled?: boolean; customButtonColor?: ColorValue; }) => { - const { colors } = theme as Theme; + const { colors } = theme; if (customButtonColor && !disabled) { return customButtonColor; @@ -101,7 +101,7 @@ const getSplitButtonContentColor = ({ disabled?: boolean; customTextColor?: ColorValue; }) => { - const { colors } = theme as Theme; + const { colors } = theme; if (customTextColor && !disabled) { return customTextColor; @@ -157,7 +157,7 @@ export const getSplitButtonColors = ({ return { containerColor, contentColor, - borderColor: isOutlined ? (theme as Theme).colors.outline : 'transparent', + borderColor: isOutlined ? theme.colors.outlineVariant : 'transparent', borderWidth: isOutlined ? 1 : 0, containerOpacity: disabled && normalizedMode !== 'outlined' diff --git a/src/components/__tests__/SplitButton.test.tsx b/src/components/__tests__/SplitButton.test.tsx index 3a7bb87c1d..674b8ede72 100644 --- a/src/components/__tests__/SplitButton.test.tsx +++ b/src/components/__tests__/SplitButton.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet } from 'react-native'; +import { PlatformColor, StyleSheet } from 'react-native'; import { fireEvent } from '@testing-library/react-native'; @@ -10,6 +10,7 @@ import { getSplitButtonColors, getSplitButtonHitSlop, getSplitButtonLeadingShape, + getSplitButtonRippleColor, getSplitButtonSizeStyle, getSplitButtonTrailingShape, normalizeSplitButtonMode, @@ -27,11 +28,22 @@ const styles = StyleSheet.create({ }, }); -it('renders a filled split button by default', () => { - const { getByTestId } = render( - {}} onTrailingPress={() => {}} /> +const renderSplitButton = ( + props: Partial> = {} +) => + render( + {}} + onTrailingPress={() => {}} + {...props} + /> ); +it('renders a filled split button by default', () => { + const { getByTestId } = renderSplitButton(); + expect(getByTestId('split-button-label')).toHaveTextContent('Send'); expect(getByTestId('split-button-container')).toHaveStyle({ height: 40, @@ -43,13 +55,7 @@ it('renders a filled split button by default', () => { it('calls leading and trailing press handlers separately', () => { const onPress = jest.fn(); const onTrailingPress = jest.fn(); - const { getByTestId } = render( - - ); + const { getByTestId } = renderSplitButton({ onPress, onTrailingPress }); fireEvent.press(getByTestId('split-button-leading')); fireEvent.press(getByTestId('split-button-trailing')); @@ -65,17 +71,14 @@ it('calls leading and trailing press-in and press-out handlers separately', () = const onTrailingPress = jest.fn(); const onTrailingPressIn = jest.fn(); const onTrailingPressOut = jest.fn(); - const { getByTestId } = render( - - ); + const { getByTestId } = renderSplitButton({ + onPress, + onPressIn, + onPressOut, + onTrailingPress, + onTrailingPressIn, + onTrailingPressOut, + }); fireEvent(getByTestId('split-button-leading'), 'onPressIn'); fireEvent(getByTestId('split-button-leading'), 'onPressOut'); @@ -88,41 +91,22 @@ it('calls leading and trailing press-in and press-out handlers separately', () = expect(onTrailingPressOut).toHaveBeenCalledTimes(1); }); -it('uses pressed inner-corner tokens for the active side', () => { +it('uses resting inner-corner tokens for both sides', () => { const theme = getTheme(); - const { getByTestId } = render( - {}} onTrailingPress={() => {}} /> - ); - - fireEvent(getByTestId('split-button-leading'), 'onPressIn'); + const { getByTestId } = renderSplitButton(); expect(getByTestId('split-button-leading-container')).toHaveStyle({ - borderTopEndRadius: theme.shapes.corner.medium, - borderBottomEndRadius: theme.shapes.corner.medium, + borderTopEndRadius: theme.shapes.corner.extraSmall, + borderBottomEndRadius: theme.shapes.corner.extraSmall, }); expect(getByTestId('split-button-trailing-container')).toHaveStyle({ borderTopStartRadius: theme.shapes.corner.extraSmall, borderBottomStartRadius: theme.shapes.corner.extraSmall, }); - - fireEvent(getByTestId('split-button-leading'), 'onPressOut'); - fireEvent(getByTestId('split-button-trailing'), 'onPressIn'); - - expect(getByTestId('split-button-trailing-container')).toHaveStyle({ - borderTopStartRadius: theme.shapes.corner.medium, - borderBottomStartRadius: theme.shapes.corner.medium, - }); }); it('marks both press targets disabled when disabled', () => { - const { getByTestId } = render( - {}} - onTrailingPress={() => {}} - /> - ); + const { getByTestId } = renderSplitButton({ disabled: true }); expect(getByTestId('split-button-leading').props.accessibilityState).toEqual({ disabled: true, @@ -135,16 +119,11 @@ it('marks both press targets disabled when disabled', () => { }); it('passes custom styles to the correct target', () => { - const { getByTestId } = render( - {}} - onTrailingPress={() => {}} - leadingButtonStyle={styles.leading} - trailingButtonStyle={styles.trailing} - labelStyle={styles.label} - /> - ); + const { getByTestId } = renderSplitButton({ + leadingButtonStyle: styles.leading, + trailingButtonStyle: styles.trailing, + labelStyle: styles.label, + }); expect(getByTestId('split-button-leading-container')).toHaveStyle( styles.leading @@ -156,14 +135,9 @@ it('passes custom styles to the correct target', () => { }); it('merges trailing accessibility state with expanded state', () => { - const { getByTestId } = render( - {}} - onTrailingPress={() => {}} - trailingAccessibilityState={{ expanded: true }} - /> - ); + const { getByTestId } = renderSplitButton({ + trailingAccessibilityState: { expanded: true }, + }); expect( getByTestId('split-button-trailing').props.accessibilityState @@ -172,6 +146,16 @@ it('merges trailing accessibility state with expanded state', () => { }); }); +it('does not add SplitButton test IDs unless testID is provided', () => { + const { queryByTestId } = render( + {}} onTrailingPress={() => {}} /> + ); + + expect(queryByTestId('split-button-container')).toBeNull(); + expect(queryByTestId('split-button-leading')).toBeNull(); + expect(queryByTestId('split-button-trailing')).toBeNull(); +}); + describe('SplitButton utils', () => { it('normalizes supported MD3 modes', () => { expect(normalizeSplitButtonMode('filled')).toBe('filled'); @@ -192,10 +176,28 @@ describe('SplitButton utils', () => { theme.colors.primary ); expect(getSplitButtonColors({ theme, mode: 'outlined' }).borderColor).toBe( - theme.colors.outline + theme.colors.outlineVariant ); }); + it('resolves ripple colors from overrides, string content colors, and opaque colors', () => { + const theme = getTheme(); + const customRippleColor = 'rgba(1, 2, 3, 0.4)'; + + expect( + getSplitButtonRippleColor({ + contentColor: theme.colors.primary, + customRippleColor, + }) + ).toBe(customRippleColor); + expect( + getSplitButtonRippleColor({ contentColor: theme.colors.primary }) + ).toBe('rgba(103, 80, 164, 0.1)'); + expect( + getSplitButtonRippleColor({ contentColor: PlatformColor('label') }) + ).toBeUndefined(); + }); + it('resolves per-size tokens against theme shape values', () => { const theme = getTheme(); const sizeStyle = getSplitButtonSizeStyle({ theme, size: 'large' });