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 {