diff --git a/docs/6.x/docs/components/SplitButton/SplitButton.mdx b/docs/6.x/docs/components/SplitButton/SplitButton.mdx new file mode 100644 index 0000000000..7bcb58993f --- /dev/null +++ b/docs/6.x/docs/components/SplitButton/SplitButton.mdx @@ -0,0 +1,343 @@ +--- +title: SplitButton +--- + +import PropTable from '@docs/components/PropTable.tsx'; +import ExtendsLink from '@docs/components/ExtendsLink.tsx'; +import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx'; +import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; +import ExtendedExample from '@docs/components/ExtendedExample.tsx'; + +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; +``` + + + ## Props + + + + +
+ +### mode + +
+ + + +
+ +### size + +
+ + + +
+ +### label (required) + +
+ + + +
+ +### icon + +
+ + + +
+ +### trailingIcon + +
+ + + +
+ +### loading + +
+ + + +
+ +### disabled + +
+ + + +
+ +### buttonColor + +
+ + + +
+ +### textColor + +
+ + + +
+ +### rippleColor + +
+ + + +
+ +### onPress + +
+ + + +
+ +### onTrailingPress + +
+ + + +
+ +### onPressIn + +
+ + + +
+ +### onPressOut + +
+ + + +
+ +### onTrailingPressIn + +
+ + + +
+ +### onTrailingPressOut + +
+ + + +
+ +### onLongPress + +
+ + + +
+ +### onTrailingLongPress + +
+ + + +
+ +### delayLongPress + +
+ + + +
+ +### trailingDelayLongPress + +
+ + + +
+ +### accessibilityLabel + +
+ + + +
+ +### trailingAccessibilityLabel + +
+ + + +
+ +### accessibilityState + +
+ + + +
+ +### trailingAccessibilityState + +
+ + + +
+ +### background + +
+ + + +
+ +### style + +
+ + + +
+ +### buttonStyle + +
+ + + +
+ +### leadingButtonStyle + +
+ + + +
+ +### trailingButtonStyle + +
+ + + +
+ +### contentStyle + +
+ + + +
+ +### labelStyle + +
+ + + +
+ +### maxFontSizeMultiplier + +
+ + + +
+ +### hitSlop + +
+ + + +
+ +### trailingHitSlop + +
+ + + +
+ +### theme + +
+ + + +
+ +### testID + +
+ + + + + + + + + ## Theme colors + + + + + diff --git a/docs/6.x/docs/components/SplitButton/_meta.json b/docs/6.x/docs/components/SplitButton/_meta.json new file mode 100644 index 0000000000..e84ed30583 --- /dev/null +++ b/docs/6.x/docs/components/SplitButton/_meta.json @@ -0,0 +1,3 @@ +[ + "SplitButton" +] diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json index 6f793d41a3..b36cc317c3 100644 --- a/docs/6.x/docs/components/_meta.json +++ b/docs/6.x/docs/components/_meta.json @@ -127,6 +127,13 @@ "collapsed": false }, "Snackbar", + { + "type": "dir", + "name": "SplitButton", + "label": "SplitButton", + "collapsible": true, + "collapsed": false + }, "Surface", { "type": "dir", diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..a6934750a7 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -120,6 +120,9 @@ const pages = { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, Snackbar: 'Snackbar', + SplitButton: { + SplitButton: 'SplitButton/SplitButton', + }, Surface: 'Surface', Switch: { Switch: 'Switch/Switch', diff --git a/docs/public/screenshots/split-button-collapsed.png b/docs/public/screenshots/split-button-collapsed.png new file mode 100644 index 0000000000..aff2077440 Binary files /dev/null and b/docs/public/screenshots/split-button-collapsed.png differ diff --git a/docs/public/screenshots/split-button-expanded.png b/docs/public/screenshots/split-button-expanded.png new file mode 100644 index 0000000000..2d10bc6abe Binary files /dev/null and b/docs/public/screenshots/split-button-expanded.png differ diff --git a/docs/scripts/generate-rspress-content.ts b/docs/scripts/generate-rspress-content.ts index 1c9accd93a..04d494f008 100644 --- a/docs/scripts/generate-rspress-content.ts +++ b/docs/scripts/generate-rspress-content.ts @@ -107,9 +107,36 @@ const createNav = (version: string) => { ]; }; +// 6.x pages are (re)generated fresh from the current source on every run, +// so every `pages` entry always has a matching 6.x doc. 5.x is a static, +// frozen snapshot that's never regenerated — a `pages` entry only belongs +// in its nav if that entry's docs actually exist in the 5.x tree, otherwise +// newer, 6.x-only components (added to `pages` after 5.x was frozen) would +// produce a broken nav item pointing at a directory that doesn't exist. +const componentDocsExistFor5x = ( + version: string, + entryName: string, + value: string | Record +) => { + if (version !== '5.x') { + return true; + } + + const componentsDir = path.join(getVersionDocsDir(version), 'components'); + const entryPath = + typeof value === 'string' + ? path.join(componentsDir, `${entryName}.mdx`) + : path.join(componentsDir, entryName); + + return fs.existsSync(entryPath); +}; + const getVersionComponentOrder = (version: string): MetaEntry[] => { - const fromConfig = Object.entries(componentDocsConfig.pages).map( - ([entryName, value]) => { + const fromConfig = Object.entries(componentDocsConfig.pages) + .filter(([entryName, value]) => + componentDocsExistFor5x(version, entryName, value) + ) + .map(([entryName, value]) => { if (typeof value === 'string') { return entryName; } @@ -121,8 +148,7 @@ const getVersionComponentOrder = (version: string): MetaEntry[] => { collapsible: true, collapsed: false, }; - } - ); + }); if (version !== '5.x') { return fromConfig; diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 300c315b99..d4c6d2d44b 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -11142,6 +11142,454 @@ "src/components/Snackbar.tsx" ] }, + "SplitButton/SplitButton": { + "filepath": "SplitButton/SplitButton.tsx", + "title": "SplitButton", + "description": "Split buttons let people trigger a primary action from the leading button\nand open or trigger a contextual action from the trailing button.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SplitButton } from 'react-native-paper';\n\nconst MyComponent = () => (\n console.log('Send')}\n onTrailingPress={() => console.log('Show options')}\n />\n);\n\nexport default MyComponent;\n```", + "link": "split-button", + "data": { + "description": "Split buttons let people trigger a primary action from the leading button\nand open or trigger a contextual action from the trailing button.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SplitButton } from 'react-native-paper';\n\nconst MyComponent = () => (\n console.log('Send')}\n onTrailingPress={() => console.log('Show options')}\n />\n);\n\nexport default MyComponent;\n```", + "displayName": "SplitButton", + "methods": [], + "statics": [], + "props": { + "mode": { + "required": false, + "tsType": { + "name": "SplitButtonMode" + }, + "description": "Mode of the split button.\n- `filled` - high-emphasis split button for important or final actions.\n- `tonal` - medium-emphasis split button using secondary container colors.\n- `elevated` - tonal split button with elevation for separation from busy surfaces.\n- `outlined` - medium-emphasis split button with transparent containers and outline.", + "defaultValue": { + "value": "'filled'", + "computed": false + } + }, + "size": { + "required": false, + "tsType": { + "name": "SplitButtonSize" + }, + "description": "Size of the split button.", + "defaultValue": { + "value": "'small'", + "computed": false + } + }, + "label": { + "required": true, + "tsType": { + "name": "string" + }, + "description": "Label text for the leading button." + }, + "icon": { + "required": false, + "tsType": { + "name": "IconSource" + }, + "description": "Icon to display before the label in the leading button." + }, + "trailingIcon": { + "required": false, + "tsType": { + "name": "IconSource" + }, + "description": "Icon to display in the trailing button.", + "defaultValue": { + "value": "'chevron-down'", + "computed": false + } + }, + "loading": { + "required": false, + "tsType": { + "name": "boolean" + }, + "description": "Whether to show a loading indicator in the leading button." + }, + "disabled": { + "required": false, + "tsType": { + "name": "boolean" + }, + "description": "Whether both buttons are disabled." + }, + "buttonColor": { + "required": false, + "tsType": { + "name": "ColorValue" + }, + "description": "Custom container color for both buttons." + }, + "textColor": { + "required": false, + "tsType": { + "name": "ColorValue" + }, + "description": "Custom content color for icons and label." + }, + "rippleColor": { + "required": false, + "tsType": { + "name": "ColorValue" + }, + "description": "Custom ripple color for both buttons." + }, + "onPress": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the leading button is pressed." + }, + "onTrailingPress": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the trailing button is pressed." + }, + "onPressIn": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute as soon as the leading button is pressed." + }, + "onPressOut": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the leading button press is released." + }, + "onTrailingPressIn": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute as soon as the trailing button is pressed." + }, + "onTrailingPressOut": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the trailing button press is released." + }, + "onLongPress": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the leading button is long pressed." + }, + "onTrailingLongPress": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(e: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "e", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + } + }, + "description": "Function to execute when the trailing button is long pressed." + }, + "delayLongPress": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "The number of milliseconds a user must touch the leading button before executing `onLongPress`." + }, + "trailingDelayLongPress": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "The number of milliseconds a user must touch the trailing button before executing `onTrailingLongPress`." + }, + "accessibilityLabel": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Accessibility label for the leading button. Falls back to `label`.", + "defaultValue": { + "value": "label", + "computed": true + } + }, + "trailingAccessibilityLabel": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Accessibility label for the trailing button.", + "defaultValue": { + "value": "'Show options'", + "computed": false + } + }, + "accessibilityState": { + "required": false, + "tsType": { + "name": "AccessibilityState" + }, + "description": "Accessibility state for the leading button." + }, + "trailingAccessibilityState": { + "required": false, + "tsType": { + "name": "AccessibilityState" + }, + "description": "Accessibility state for the trailing button." + }, + "background": { + "required": false, + "tsType": { + "name": "PressableAndroidRippleConfig" + }, + "description": "Type of background drawable to display the feedback (Android).\nhttps://reactnative.dev/docs/pressable#rippleconfig" + }, + "style": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the outer split-button group." + }, + "buttonStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for both button containers." + }, + "leadingButtonStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the leading button container." + }, + "trailingButtonStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the trailing button container." + }, + "contentStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the leading button content row." + }, + "labelStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "TextStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the label." + }, + "maxFontSizeMultiplier": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Specifies the largest possible scale a label font can reach." + }, + "hitSlop": { + "required": false, + "tsType": { + "name": "TouchableRippleProps['hitSlop']", + "raw": "TouchableRippleProps['hitSlop']" + }, + "description": "Sets additional distance outside of the leading button in which a press can be detected." + }, + "trailingHitSlop": { + "required": false, + "tsType": { + "name": "TouchableRippleProps['hitSlop']", + "raw": "TouchableRippleProps['hitSlop']" + }, + "description": "Sets additional distance outside of the trailing button in which a press can be detected." + }, + "theme": { + "required": false, + "tsType": { + "name": "ThemeProp" + }, + "description": "" + }, + "testID": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "TestID used for testing purposes." + } + } + }, + "type": "component", + "dependencies": [ + "src/components/SplitButton/SplitButton.tsx" + ] + }, "Surface": { "filepath": "Surface.tsx", "title": "Surface", diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..d2ec21821d 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -134,6 +134,10 @@ export const screenshots = { multiselect: 'screenshots/segmented-button-multi-select.png', }, Snackbar: 'screenshots/snackbar.gif', + SplitButton: { + collapsed: 'screenshots/split-button-collapsed.png', + expanded: 'screenshots/split-button-expanded.png', + }, Surface: { elevated: 'screenshots/surface-elevated-full-width.png', flat: 'screenshots/surface-flat-full-width.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..a210904e0a 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -307,6 +307,37 @@ export 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.outlineVariant', + }, + }, + disabled: { + '-': { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', + }, + outlined: { + backgroundColor: 'transparent', + textColor: 'theme.colors.outlineVariant', + borderColor: 'theme.colors.outlineVariant', + }, + }, + }, Surface: { flat: { backgroundColor: 'theme.colors.elevation[elevation]', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 8a2646d798..a799344173 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -36,6 +36,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'; @@ -79,6 +80,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..4af66cb343 --- /dev/null +++ b/example/src/Examples/SplitButtonExample.tsx @@ -0,0 +1,149 @@ +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 sizes = [ + 'extra-small', + 'small', + 'medium', + 'large', + 'extra-large', +] 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`} + /> + ))} + + + + + + {sizes.map((size) => ( + {}} + onTrailingPress={() => {}} + trailingAccessibilityLabel={`${size} 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..b5e61091eb --- /dev/null +++ b/src/components/SplitButton/SplitButton.tsx @@ -0,0 +1,649 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { + AccessibilityState, + ColorValue, + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + TextStyle, + ViewProps, + ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { + splitButtonMinInteractiveSize, + splitButtonStateLayerOpacity, + type SplitButtonSize, +} from './tokens'; +import { + getSplitButtonColors, + getSplitButtonHitSlop, + getSplitButtonLeadingShape, + getSplitButtonRippleColor, + getSplitButtonSizeStyle, + getSplitButtonTrailingShape, + type SplitButtonMode, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { $Omit, ThemeProp } from '../../types'; +import hasTouchHandler from '../../utils/hasTouchHandler'; +import ActivityIndicator from '../ActivityIndicator'; +import { getButtonTouchableRippleStyle } from '../Button/utils'; +import Icon, { type IconSource } from '../Icon'; +import Surface from '../Surface'; +import SplitButtonSurface from './SplitButtonSurface'; +import TouchableRipple, { + type Props as TouchableRippleProps, +} from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type Props = $Omit & { + /** + * 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?: StyleProp; + /** + * 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; +}; + +/** + * 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 = ({ + mode = 'filled', + size = 'small', + label, + icon, + trailingIcon = 'chevron-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 sizeStyle = React.useMemo( + () => getSplitButtonSizeStyle({ size, theme }), + [size, theme] + ); + const isTrailingExpanded = trailingAccessibilityState?.expanded === true; + const trailingIconRotation = useSharedValue(isTrailingExpanded ? 1 : 0); + const trailingInnerRadiusProgress = useSharedValue( + isTrailingExpanded ? 1 : 0 + ); + const colors = React.useMemo( + () => + getSplitButtonColors({ + theme, + mode, + disabled, + customButtonColor, + customTextColor, + }), + [theme, mode, disabled, customButtonColor, customTextColor] + ); + const { color: customLabelColor, fontSize: customLabelSize } = + StyleSheet.flatten(labelStyle) || {}; + const contentColor = + typeof customLabelColor === 'string' + ? customLabelColor + : colors.contentColor; + const rippleColor = React.useMemo( + () => + getSplitButtonRippleColor({ + contentColor, + customRippleColor, + }), + [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] + ); + // Interpolated between `trailingShape`'s own Start radius (resting) and + // its End radius (expanded), so the Start corner always lands exactly on + // the same radius already driving the segment's static End corners. + // `trailingShape`'s corner properties are typed loosely (`ViewStyle`), so + // narrow to the `sizeStyle` values that `getSplitButtonTrailingShape` + // always assigns them from, rather than asserting the type. + const trailingRestingRadius = + typeof trailingShape.borderTopStartRadius === 'number' + ? trailingShape.borderTopStartRadius + : sizeStyle.innerRadius; + const trailingExpandedRadius = + typeof trailingShape.borderTopEndRadius === 'number' + ? trailingShape.borderTopEndRadius + : sizeStyle.containerRadius; + const trailingAnimatedShapeStyle = useAnimatedStyle(() => { + const radius = + trailingRestingRadius + + trailingInnerRadiusProgress.value * + (trailingExpandedRadius - trailingRestingRadius); + + return { + borderTopStartRadius: radius, + borderBottomStartRadius: radius, + }; + }); + const trailingIconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${trailingIconRotation.value * 180}deg` }], + })); + // Per the M3 spec, the trailing button's color doesn't change when + // selected (expanded) — only a state layer is applied on top of it. + const trailingStateLayerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: disabled + ? 0 + : trailingInnerRadiusProgress.value * splitButtonStateLayerOpacity, + })); + const leadingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop }), + [size, hitSlop] + ); + const resolvedTrailingHitSlop = React.useMemo( + () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }), + [size, trailingHitSlop] + ); + + const labelTextStyle: TextStyle = { + color: colors.contentColor, + }; + const disabledState = { disabled: true }; + const leadingAccessibilityState = disabled + ? { ...accessibilityState, ...disabledState } + : accessibilityState; + const trailingAccessibilityStateWithDisabled = disabled + ? { ...trailingAccessibilityState, ...disabledState } + : trailingAccessibilityState; + const leadingHasTouchHandler = hasTouchHandler({ + onPress, + onPressIn, + onPressOut, + onLongPress, + }); + const trailingHasTouchHandler = hasTouchHandler({ + onPress: onTrailingPress, + onPressIn: onTrailingPressIn, + onPressOut: onTrailingPressOut, + onLongPress: onTrailingLongPress, + }); + React.useEffect(() => { + const progress = isTrailingExpanded ? 1 : 0; + + trailingIconRotation.value = withTiming(progress, pressTimingConfig); + trailingInnerRadiusProgress.value = withTiming(progress, pressTimingConfig); + }, [ + isTrailingExpanded, + pressTimingConfig, + trailingIconRotation, + trailingInnerRadiusProgress, + ]); + + const commonButtonStyle: ViewStyle = { + height: sizeStyle.containerHeight, + backgroundColor: + colors.containerOpacity < 1 ? 'transparent' : colors.containerColor, + borderColor: colors.borderColor, + borderWidth: colors.borderWidth, + }; + const getTestID = (suffix: string) => + testID ? `${testID}-${suffix}` : undefined; + + 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: splitButtonMinInteractiveSize, + flexShrink: 1, + borderStyle: 'solid', + }, + trailing: { + minWidth: splitButtonMinInteractiveSize, + borderStyle: 'solid', + }, + // Clips the ripple and background to the trailing segment's current + // (possibly animated) shape, since `TouchableRipple`'s own clip is static + // and would let the ripple show past the segment once its corners morph. + trailingClip: { + overflow: 'hidden', + }, + ripple: { + height: '100%', + }, + leadingContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + label: { + flexShrink: 1, + }, + trailingContent: { + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default SplitButton; diff --git a/src/components/SplitButton/SplitButtonSurface.tsx b/src/components/SplitButton/SplitButtonSurface.tsx new file mode 100644 index 0000000000..9785e03e25 --- /dev/null +++ b/src/components/SplitButton/SplitButtonSurface.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; +import type { ViewProps, ViewStyle } from 'react-native'; + +import Animated from 'react-native-reanimated'; + +import { useInternalTheme } from '../../core/theming'; +import { + androidElevationLevels, + shadow, +} from '../../theme/tokens/sys/elevation'; +import type { ThemeProp } from '../../types'; + +type Props = ViewProps & { + children: React.ReactNode; + elevation: number; + // `SplitButton` mixes plain style objects with Reanimated + // `useAnimatedStyle()` results in the same array, which TS can't unify + // structurally, so this internal-only prop is intentionally untyped. + style?: any; + theme?: ThemeProp; +}; + +/** + * A bare `Surface` stand-in used only by `SplitButton`'s trailing segment, + * so its Reanimated-driven shape style reaches a single native view + * directly instead of going through `Surface`'s own JS-side style + * splitting (which doesn't propagate Reanimated updates reliably). + */ +const SplitButtonSurface = ({ + elevation, + style, + theme: themeOverrides, + children, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const elevationStyle: ViewStyle = + Platform.OS === 'android' + ? { elevation: androidElevationLevels[elevation] } + : shadow(elevation, theme.colors.shadow); + + return ( + + {children} + + ); +}; + +export default SplitButtonSurface; diff --git a/src/components/SplitButton/tokens.ts b/src/components/SplitButton/tokens.ts new file mode 100644 index 0000000000..18e1b19ff3 --- /dev/null +++ b/src/components/SplitButton/tokens.ts @@ -0,0 +1,197 @@ +import type { + ColorRole, + Elevation, + ThemeShapeCorners, + TypescaleKey, +} from '../../theme/types'; + +export type SplitButtonMode = 'filled' | 'tonal' | 'elevated' | 'outlined'; + +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; + leadingButtonLeadingSpace: number; + leadingButtonTrailingSpace: number; + leadingIconSize: number; + iconLabelGap: 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', + leadingButtonLeadingSpace: 12, + leadingButtonTrailingSpace: 10, + leadingIconSize: 20, + iconLabelGap: 8, + trailingButtonLeadingSpace: 13, + trailingButtonTrailingSpace: 13, + trailingIconSize: 22, + labelVariant: 'labelLarge', + }, + small: { + betweenSpace: 2, + containerHeight: 40, + containerShape: 'full', + innerCornerShape: 'extraSmall', + leadingButtonLeadingSpace: 16, + leadingButtonTrailingSpace: 12, + leadingIconSize: 20, + iconLabelGap: 8, + trailingButtonLeadingSpace: 13, + trailingButtonTrailingSpace: 13, + trailingIconSize: 22, + labelVariant: 'labelLarge', + }, + medium: { + betweenSpace: 2, + containerHeight: 56, + containerShape: 'full', + innerCornerShape: 'extraSmall', + leadingButtonLeadingSpace: 24, + leadingButtonTrailingSpace: 24, + leadingIconSize: 24, + iconLabelGap: 8, + trailingButtonLeadingSpace: 15, + trailingButtonTrailingSpace: 15, + trailingIconSize: 26, + labelVariant: 'titleMedium', + }, + large: { + betweenSpace: 2, + containerHeight: 96, + containerShape: 'full', + innerCornerShape: 'small', + leadingButtonLeadingSpace: 48, + leadingButtonTrailingSpace: 48, + leadingIconSize: 32, + iconLabelGap: 12, + trailingButtonLeadingSpace: 29, + trailingButtonTrailingSpace: 29, + trailingIconSize: 38, + labelVariant: 'headlineSmall', + }, + 'extra-large': { + betweenSpace: 2, + containerHeight: 136, + containerShape: 'full', + innerCornerShape: 'medium', + leadingButtonLeadingSpace: 64, + leadingButtonTrailingSpace: 64, + leadingIconSize: 40, + iconLabelGap: 16, + trailingButtonLeadingSpace: 43, + trailingButtonTrailingSpace: 43, + trailingIconSize: 50, + labelVariant: 'headlineLarge', + }, +}; + +export const splitButtonMinInteractiveSize = 48; + +export const splitButtonStateLayerOpacity = 0.1; + +// Per-mode/state color roles, straight from the M3 split button spec. Filled, +// tonal, and elevated share the same disabled treatment (a 10%/38% tint of +// `onSurface` for container/content). `outlined` has no container fill in +// either state — only its content dims (to `outlineVariant` at 38%); its +// border stays `outlineVariant` at full opacity throughout, same as +// `Button`'s outlined mode never dims its border either. +export type SplitButtonColorTokens = { + containerColor?: ColorRole; + containerOpacity: number; + contentColor: ColorRole; + contentOpacity: number; + borderColor?: ColorRole; + elevation: Elevation; +}; + +export const splitButtonColorTokens: Record< + SplitButtonMode, + { enabled: SplitButtonColorTokens; disabled: SplitButtonColorTokens } +> = { + elevated: { + enabled: { + containerColor: 'surfaceContainerLow', + containerOpacity: 1, + contentColor: 'primary', + contentOpacity: 1, + elevation: 1, + }, + disabled: { + containerColor: 'onSurface', + containerOpacity: 0.1, + contentColor: 'onSurface', + contentOpacity: 0.38, + elevation: 0, + }, + }, + filled: { + enabled: { + containerColor: 'primary', + containerOpacity: 1, + contentColor: 'onPrimary', + contentOpacity: 1, + elevation: 0, + }, + disabled: { + containerColor: 'onSurface', + containerOpacity: 0.1, + contentColor: 'onSurface', + contentOpacity: 0.38, + elevation: 0, + }, + }, + tonal: { + enabled: { + containerColor: 'secondaryContainer', + containerOpacity: 1, + contentColor: 'onSecondaryContainer', + contentOpacity: 1, + elevation: 0, + }, + disabled: { + containerColor: 'onSurface', + containerOpacity: 0.1, + contentColor: 'onSurface', + contentOpacity: 0.38, + elevation: 0, + }, + }, + outlined: { + enabled: { + containerOpacity: 1, + contentColor: 'onSurfaceVariant', + contentOpacity: 1, + borderColor: 'outlineVariant', + elevation: 0, + }, + disabled: { + containerOpacity: 1, + contentColor: 'outlineVariant', + contentOpacity: 0.38, + borderColor: 'outlineVariant', + elevation: 0, + }, + }, +}; diff --git a/src/components/SplitButton/utils.ts b/src/components/SplitButton/utils.ts new file mode 100644 index 0000000000..7f179d8600 --- /dev/null +++ b/src/components/SplitButton/utils.ts @@ -0,0 +1,174 @@ +import type { ColorValue, ViewStyle } from 'react-native'; + +import color from 'color'; + +import { + splitButtonColorTokens, + splitButtonMinInteractiveSize, + splitButtonSizeTokens, + type SplitButtonMode, + type SplitButtonShapeKey, + type SplitButtonSize, +} from './tokens'; +import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { InternalTheme } from '../../types'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +const stateOpacity = tokens.md.sys.state.opacity; + +export type { SplitButtonMode } from './tokens'; + +export const resolveSplitButtonCorner = ( + theme: InternalTheme, + key: SplitButtonShapeKey +) => (key === 'full' ? cornerFull : theme.shapes.corner[key]); + +// `resolveSplitButtonCorner`'s 'full' case returns a large sentinel radius +// (`cornerFull`) meant for shapes whose corners are all resolved the same +// way. Paired on the same edge with the smaller `innerRadius`, that +// sentinel triggers RN's corner-overlap correction and collapses the inner +// radius too — so the container shape is resolved on its own, relative to +// its own height, instead: 'full' means "fully rounded", i.e. a stadium +// whose radius is exactly half its height. +export const resolveSplitButtonContainerRadius = ( + theme: InternalTheme, + shape: SplitButtonShapeKey, + containerHeight: number +) => + shape === 'full' + ? containerHeight / 2 + : resolveSplitButtonCorner(theme, shape); + +export const getSplitButtonSizeStyle = ({ + size, + theme, +}: { + size: SplitButtonSize; + theme: InternalTheme; +}) => { + const sizeTokens = splitButtonSizeTokens[size]; + + return { + ...sizeTokens, + containerRadius: resolveSplitButtonContainerRadius( + theme, + sizeTokens.containerShape, + sizeTokens.containerHeight + ), + innerRadius: resolveSplitButtonCorner(theme, sizeTokens.innerCornerShape), + }; +}; + +export const getSplitButtonColors = ({ + theme, + mode, + disabled, + customButtonColor, + customTextColor, +}: { + theme: InternalTheme; + mode: SplitButtonMode; + disabled?: boolean; + customButtonColor?: ColorValue; + customTextColor?: ColorValue; +}) => { + const { colors } = theme; + const colorTokens = + splitButtonColorTokens[mode][disabled ? 'disabled' : 'enabled']; + + const containerColor = + customButtonColor && !disabled + ? customButtonColor + : colorTokens.containerColor + ? colors[colorTokens.containerColor] + : 'transparent'; + const contentColor = + customTextColor && !disabled + ? customTextColor + : colors[colorTokens.contentColor]; + + return { + containerColor, + contentColor, + borderColor: colorTokens.borderColor + ? colors[colorTokens.borderColor] + : 'transparent', + borderWidth: colorTokens.borderColor ? 1 : 0, + containerOpacity: colorTokens.containerOpacity, + contentOpacity: colorTokens.contentOpacity, + elevation: colorTokens.elevation, + }; +}; + +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; + } + + return { + ...hitSlop, + top: hitSlop?.top ?? verticalSlop, + bottom: hitSlop?.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..afd3c9e963 --- /dev/null +++ b/src/components/__tests__/SplitButton.test.tsx @@ -0,0 +1,249 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; + +import { expect, it, jest } from '@jest/globals'; + +import { getTheme } from '../../core/theming'; +import { fireEvent, render, screen, userEvent } from '../../test-utils'; +import SplitButton from '../SplitButton/SplitButton'; + +const styles = StyleSheet.create({ + leading: { + minWidth: 120, + }, + trailing: { + minWidth: 64, + }, + label: { + fontSize: 18, + }, +}); + +const renderSplitButton = ( + props: Partial> = {} +) => + render( + {}} + onTrailingPress={() => {}} + {...props} + /> + ); + +it('renders a filled split button by default', async () => { + await renderSplitButton(); + + expect(screen.getByTestId('split-button-label')).toHaveTextContent('Send'); + expect(screen.getByTestId('split-button-container')).toHaveStyle({ + height: 40, + }); + expect(screen.getByTestId('split-button-leading-container')).toBeTruthy(); + expect(screen.getByTestId('split-button-trailing-container')).toBeTruthy(); +}); + +it('calls leading and trailing press handlers separately', async () => { + const user = userEvent.setup(); + const onPress = jest.fn(); + const onTrailingPress = jest.fn(); + await renderSplitButton({ onPress, onTrailingPress }); + + await user.press(screen.getByTestId('split-button-leading')); + await user.press(screen.getByTestId('split-button-trailing')); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onTrailingPress).toHaveBeenCalledTimes(1); +}); + +it('calls leading and trailing press-in and press-out handlers separately', async () => { + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onTrailingPressIn = jest.fn(); + const onTrailingPressOut = jest.fn(); + await renderSplitButton({ + onPressIn, + onPressOut, + onTrailingPressIn, + onTrailingPressOut, + }); + + await fireEvent(screen.getByTestId('split-button-leading'), 'onPressIn'); + await fireEvent(screen.getByTestId('split-button-leading'), 'onPressOut'); + await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressIn'); + await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressOut'); + + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onTrailingPressIn).toHaveBeenCalledTimes(1); + expect(onTrailingPressOut).toHaveBeenCalledTimes(1); +}); + +it('keeps the outline color at full opacity for a disabled outlined split button', async () => { + const theme = getTheme(); + await renderSplitButton({ mode: 'outlined', disabled: true }); + + expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({ + borderColor: theme.colors.outlineVariant, + }); +}); + +it('applies the filled mode container color', async () => { + const theme = getTheme(); + await renderSplitButton({ mode: 'filled' }); + + expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({ + backgroundColor: theme.colors.primary, + }); +}); + +it('applies the tonal mode container color', async () => { + const theme = getTheme(); + await renderSplitButton({ mode: 'tonal' }); + + expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({ + backgroundColor: theme.colors.secondaryContainer, + }); +}); + +it('applies a transparent container and visible border for outlined mode', async () => { + const theme = getTheme(); + await renderSplitButton({ mode: 'outlined' }); + + expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({ + backgroundColor: 'transparent', + borderColor: theme.colors.outlineVariant, + }); +}); + +it('applies the container height for the extra-small size', async () => { + await renderSplitButton({ size: 'extra-small' }); + + expect(screen.getByTestId('split-button-container')).toHaveStyle({ + height: 32, + }); +}); + +it('applies the container height for the large size', async () => { + await renderSplitButton({ size: 'large' }); + + expect(screen.getByTestId('split-button-container')).toHaveStyle({ + height: 96, + }); +}); + +it('shows a progress indicator instead of the icon while loading', async () => { + await renderSplitButton({ icon: 'send', loading: true }); + + expect(screen.getByRole('progressbar')).toBeTruthy(); +}); + +it('defaults accessibility labels from label and to "Show options"', async () => { + await renderSplitButton(); + + expect(screen.getByTestId('split-button-leading')).toHaveProp( + 'accessibilityLabel', + 'Send' + ); + expect(screen.getByTestId('split-button-trailing')).toHaveProp( + 'accessibilityLabel', + 'Show options' + ); +}); + +it('calls leading and trailing long-press handlers separately', async () => { + const user = userEvent.setup(); + const onLongPress = jest.fn(); + const onTrailingLongPress = jest.fn(); + await renderSplitButton({ onLongPress, onTrailingLongPress }); + + await user.longPress(screen.getByTestId('split-button-leading')); + await user.longPress(screen.getByTestId('split-button-trailing')); + + expect(onLongPress).toHaveBeenCalledTimes(1); + expect(onTrailingLongPress).toHaveBeenCalledTimes(1); +}); + +it('applies a state layer instead of a color change when selected', async () => { + const theme = getTheme(); + await renderSplitButton({ + mode: 'filled', + trailingAccessibilityState: { expanded: true }, + }); + + // Per the M3 spec, the trailing button's color doesn't change when + // selected — only a state layer, tinted with the container's own "on" + // color, is applied on top of it. + expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle({ + backgroundColor: theme.colors.primary, + }); + expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({ + backgroundColor: theme.colors.onPrimary, + opacity: 0.1, + }); +}); + +it('hides the state layer when not selected', async () => { + await renderSplitButton({ + trailingAccessibilityState: { expanded: false }, + }); + + expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({ + opacity: 0, + }); +}); + +it('hides the state layer when disabled, even if selected', async () => { + await renderSplitButton({ + disabled: true, + trailingAccessibilityState: { expanded: true }, + }); + + expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({ + opacity: 0, + }); +}); + +it('marks both press targets disabled when disabled', async () => { + await renderSplitButton({ disabled: true }); + + expect(screen.getByTestId('split-button-leading')).toBeDisabled(); + expect(screen.getByTestId('split-button-trailing')).toBeDisabled(); +}); + +it('passes custom styles to the correct target', async () => { + await renderSplitButton({ + leadingButtonStyle: styles.leading, + trailingButtonStyle: styles.trailing, + labelStyle: styles.label, + }); + + expect(screen.getByTestId('split-button-leading-container')).toHaveStyle( + styles.leading + ); + expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle( + styles.trailing + ); + expect(screen.getByTestId('split-button-label')).toHaveStyle(styles.label); +}); + +it('merges trailing accessibility state with expanded state', async () => { + await renderSplitButton({ + trailingAccessibilityState: { expanded: true }, + }); + + expect(screen.getByTestId('split-button-trailing')).toHaveProp( + 'accessibilityState', + expect.objectContaining({ expanded: true }) + ); +}); + +it('does not add SplitButton test IDs unless testID is provided', async () => { + await render( + {}} onTrailingPress={() => {}} /> + ); + + expect(screen.queryByTestId('split-button-container')).toBeNull(); + expect(screen.queryByTestId('split-button-leading')).toBeNull(); + expect(screen.queryByTestId('split-button-trailing')).toBeNull(); +}); diff --git a/src/components/__tests__/SplitButtonUtils.test.tsx b/src/components/__tests__/SplitButtonUtils.test.tsx new file mode 100644 index 0000000000..fd8ebc18fa --- /dev/null +++ b/src/components/__tests__/SplitButtonUtils.test.tsx @@ -0,0 +1,326 @@ +import { PlatformColor } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; +import color from 'color'; + +import { getTheme } from '../../core/theming'; +import { + getSplitButtonColors, + getSplitButtonHitSlop, + getSplitButtonLeadingShape, + getSplitButtonRippleColor, + getSplitButtonSizeStyle, + getSplitButtonTrailingShape, + resolveSplitButtonContainerRadius, +} from '../SplitButton/utils'; + +describe('resolveSplitButtonContainerRadius', () => { + it('resolves a "full" shape to exactly half the container height, not the corner-overlap sentinel', () => { + const theme = getTheme(); + + const radius = resolveSplitButtonContainerRadius(theme, 'full', 40); + + expect(radius).toBe(20); + // Regression guard: 'full' used to resolve through the `cornerFull` + // sentinel (9999), which triggers RN's corner-overlap correction and + // silently collapses the paired inner radius too. + expect(radius).toBeLessThan(9999); + }); + + it('resolves a non-"full" shape from the theme, ignoring container height', () => { + const theme = getTheme(); + + const radius = resolveSplitButtonContainerRadius(theme, 'medium', 999); + + expect(radius).toBe(theme.shapes.corner.medium); + }); +}); + +describe('getSplitButtonSizeStyle', () => { + it('resolves the container radius to exactly half the container height', () => { + const theme = getTheme(); + + ( + ['extra-small', 'small', 'medium', 'large', 'extra-large'] as const + ).forEach((size) => { + const sizeStyle = getSplitButtonSizeStyle({ size, theme }); + + expect(sizeStyle.containerRadius).toBe(sizeStyle.containerHeight / 2); + expect(sizeStyle.containerRadius).toBeLessThan(9999); + }); + }); + + it('resolves the inner radius from the theme shape corner for the size', () => { + const theme = getTheme(); + const sizeStyle = getSplitButtonSizeStyle({ size: 'small', theme }); + + expect(sizeStyle.innerRadius).toBe(theme.shapes.corner.extraSmall); + }); + + it('returns different inner radii for sizes with different corner shapes', () => { + const theme = getTheme(); + const small = getSplitButtonSizeStyle({ size: 'small', theme }); + const large = getSplitButtonSizeStyle({ size: 'large', theme }); + + expect(small.innerRadius).toBe(theme.shapes.corner.extraSmall); + expect(large.innerRadius).toBe(theme.shapes.corner.small); + }); +}); + +describe('getSplitButtonColors', () => { + it('returns filled mode colors', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ theme, mode: 'filled' }); + + expect(colors.containerColor).toBe(theme.colors.primary); + expect(colors.contentColor).toBe(theme.colors.onPrimary); + expect(colors.borderColor).toBe('transparent'); + expect(colors.borderWidth).toBe(0); + }); + + it('returns tonal mode colors', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ theme, mode: 'tonal' }); + + expect(colors.containerColor).toBe(theme.colors.secondaryContainer); + expect(colors.contentColor).toBe(theme.colors.onSecondaryContainer); + }); + + it('returns elevated mode colors', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ theme, mode: 'elevated' }); + + expect(colors.containerColor).toBe(theme.colors.surfaceContainerLow); + expect(colors.contentColor).toBe(theme.colors.primary); + }); + + it('returns outlined mode colors with a visible border', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ theme, mode: 'outlined' }); + + expect(colors.containerColor).toBe('transparent'); + expect(colors.contentColor).toBe(theme.colors.onSurfaceVariant); + expect(colors.borderColor).toBe(theme.colors.outlineVariant); + expect(colors.borderWidth).toBe(1); + }); + + it('prefers custom container and text colors when not disabled', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'filled', + customButtonColor: '#123456', + customTextColor: '#abcdef', + }); + + expect(colors.containerColor).toBe('#123456'); + expect(colors.contentColor).toBe('#abcdef'); + }); + + it('ignores custom colors when disabled', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'filled', + disabled: true, + customButtonColor: '#123456', + customTextColor: '#abcdef', + }); + + expect(colors.containerColor).toBe(theme.colors.onSurface); + expect(colors.contentColor).toBe(theme.colors.onSurface); + }); + + it('fades a disabled filled container instead of using a flat disabled color', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'filled', + disabled: true, + }); + + expect(colors.containerColor).toBe(theme.colors.onSurface); + expect(colors.containerOpacity).toBeLessThan(1); + }); + + it('shares the same disabled onSurface treatment across filled, tonal, and elevated', () => { + const theme = getTheme(); + + (['filled', 'tonal', 'elevated'] as const).forEach((mode) => { + const colors = getSplitButtonColors({ theme, mode, disabled: true }); + + expect(colors.containerColor).toBe(theme.colors.onSurface); + expect(colors.contentColor).toBe(theme.colors.onSurface); + expect(colors.containerOpacity).toBe(0.1); + expect(colors.contentOpacity).toBe(0.38); + }); + }); + + it('uses a transparent container for a disabled outlined split button', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'outlined', + disabled: true, + }); + + expect(colors.containerColor).toBe('transparent'); + expect(colors.containerOpacity).toBe(1); + }); + + it('keeps the outline color at full opacity for a disabled outlined split button', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'outlined', + disabled: true, + }); + + expect(colors.borderColor).toBe(theme.colors.outlineVariant); + }); + + it('uses outline-variant content color for a disabled outlined split button', () => { + const theme = getTheme(); + const colors = getSplitButtonColors({ + theme, + mode: 'outlined', + disabled: true, + }); + + expect(colors.contentColor).toBe(theme.colors.outlineVariant); + }); + + it('only grants elevation to an enabled elevated split button', () => { + const theme = getTheme(); + + (['elevated', 'filled', 'tonal', 'outlined'] as const).forEach((mode) => { + const enabled = getSplitButtonColors({ theme, mode }); + const disabled = getSplitButtonColors({ theme, mode, disabled: true }); + + expect(enabled.elevation).toBe(mode === 'elevated' ? 1 : 0); + expect(disabled.elevation).toBe(0); + }); + }); + + it('reduces content opacity when disabled', () => { + const theme = getTheme(); + const enabledColors = getSplitButtonColors({ theme, mode: 'filled' }); + const disabledColors = getSplitButtonColors({ + theme, + mode: 'filled', + disabled: true, + }); + + expect(disabledColors.contentOpacity).toBeLessThan( + enabledColors.contentOpacity + ); + }); +}); + +describe('getSplitButtonRippleColor', () => { + it('derives a translucent ripple color from the content color', () => { + const rippleColor = getSplitButtonRippleColor({ + contentColor: '#ffffff', + }); + + expect(rippleColor).toBe(color('#ffffff').alpha(0.1).rgb().string()); + }); + + it('prefers a custom ripple color when provided', () => { + const rippleColor = getSplitButtonRippleColor({ + contentColor: '#ffffff', + customRippleColor: '#ff0000', + }); + + expect(rippleColor).toBe('#ff0000'); + }); + + it('returns undefined when the content color is not a string', () => { + const rippleColor = getSplitButtonRippleColor({ + contentColor: PlatformColor('label'), + }); + + expect(rippleColor).toBeUndefined(); + }); +}); + +describe('getSplitButtonHitSlop', () => { + it('pads small sizes up to the minimum interactive size', () => { + const hitSlop = getSplitButtonHitSlop({ size: 'small' }); + + expect(hitSlop).toEqual({ top: 4, bottom: 4 }); + }); + + it('pads extra-small sizes further, since they are further from the minimum', () => { + const hitSlop = getSplitButtonHitSlop({ size: 'extra-small' }); + + expect(hitSlop).toEqual({ top: 8, bottom: 8 }); + }); + + it('does not add slop for sizes already at or above the minimum', () => { + const hitSlop = getSplitButtonHitSlop({ size: 'medium' }); + + expect(hitSlop).toBeUndefined(); + }); + + it('passes a numeric hitSlop through unchanged', () => { + const hitSlop = getSplitButtonHitSlop({ size: 'small', hitSlop: 10 }); + + expect(hitSlop).toBe(10); + }); + + it('does not override explicit top/bottom values in an object hitSlop', () => { + const hitSlop = getSplitButtonHitSlop({ + size: 'small', + hitSlop: { top: 1, left: 2 }, + }); + + expect(hitSlop).toEqual({ top: 1, left: 2, bottom: 4 }); + }); +}); + +describe('getSplitButtonLeadingShape / getSplitButtonTrailingShape', () => { + it('rounds the leading segment on the Start side and squares it on the End side', () => { + const shape = getSplitButtonLeadingShape({ + containerRadius: 20, + innerRadius: 4, + }); + + expect(shape).toEqual({ + borderTopStartRadius: 20, + borderBottomStartRadius: 20, + borderTopEndRadius: 4, + borderBottomEndRadius: 4, + }); + }); + + it('rounds the trailing segment on the End side and squares it on the Start side', () => { + const shape = getSplitButtonTrailingShape({ + containerRadius: 20, + innerRadius: 4, + }); + + expect(shape).toEqual({ + borderTopStartRadius: 4, + borderBottomStartRadius: 4, + borderTopEndRadius: 20, + borderBottomEndRadius: 20, + }); + }); + + it('mirrors the leading and trailing shapes around the shared inner edge', () => { + const leading = getSplitButtonLeadingShape({ + containerRadius: 20, + innerRadius: 4, + }); + const trailing = getSplitButtonTrailingShape({ + containerRadius: 20, + innerRadius: 4, + }); + + expect(leading.borderTopEndRadius).toBe(trailing.borderTopStartRadius); + expect(leading.borderBottomEndRadius).toBe( + trailing.borderBottomStartRadius + ); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..527190eea6 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/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/SplitButton'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch'; export type {