diff --git a/docs/assets/screenshots/segmented-button.png b/docs/assets/screenshots/segmented-button.png new file mode 100644 index 0000000000..bf7297a15a Binary files /dev/null and b/docs/assets/screenshots/segmented-button.png differ diff --git a/docs/assets/screenshots/segmentedbuttons.gif b/docs/assets/screenshots/segmentedbuttons.gif new file mode 100644 index 0000000000..72bb80868d Binary files /dev/null and b/docs/assets/screenshots/segmentedbuttons.gif differ diff --git a/docs/pages/10.migration-guide-to-5.0.md b/docs/pages/10.migration-guide-to-5.0.md index a4c6937674..da46723a25 100644 --- a/docs/pages/10.migration-guide-to-5.0.md +++ b/docs/pages/10.migration-guide-to-5.0.md @@ -491,6 +491,39 @@ Previously `elevation` was passed inside the `style` prop. Since it supported no + ``` +## SegmentedButtons + +`SegmentedButtons` is a completely new component introduced in the latest version. It allows people to select options, switch views, or sort elements. It supports single and multiselect select variant and provide a lot of customization options. + +![segmentedButtons](screenshots/segmentedbuttons.gif) + +```js +const MyComponent = () => { + const [value, setValue] = React.useState(''); + + return ( + + ); +}; +``` + ## TextInput.Icon The property `name` was renamed to `icon`, since the scope and type of that prop is much wider than just the icon name – it accepts also the function which receives an object with color and size properties and diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 05c034b835..fb09009a45 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -38,6 +38,7 @@ import TouchableRippleExample from './Examples/TouchableRippleExample'; import ThemeExample from './Examples/ThemeExample'; import RadioButtonItemExample from './Examples/RadioButtonItemExample'; import AnimatedFABExample from './Examples/AnimatedFABExample'; +import SegmentedButtonExample from './Examples/SegmentedButtonsExample'; export const examples: Record< string, @@ -69,6 +70,7 @@ export const examples: Record< radioGroup: RadioButtonGroupExample, radioItem: RadioButtonItemExample, searchbar: SearchbarExample, + segmentedButton: SegmentedButtonExample, snackbar: SnackbarExample, surface: SurfaceExample, switch: SwitchExample, diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonDefault.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonDefault.tsx new file mode 100644 index 0000000000..794680985a --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonDefault.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonDefault = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + flex: 1, + }, + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonDefault; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonDisabled.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonDisabled.tsx new file mode 100644 index 0000000000..dc2cd99717 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonDisabled.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonDisabled = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + flex: 1, + }, + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonDisabled; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselect.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselect.tsx new file mode 100644 index 0000000000..5d5c17c094 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselect.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonMultiselect = () => { + const [value, setValue] = React.useState([]); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + group: { paddingHorizontal: 20, justifyContent: 'center' }, + button: { + flex: 1, + }, +}); + +export default SegmentedButtonMultiselect; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx new file mode 100644 index 0000000000..4ae2f9991a --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonMultiselectIcons = () => { + const [value, setValue] = React.useState([]); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonMultiselectIcons; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx new file mode 100644 index 0000000000..276b9ab406 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonOnlyIcons = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonOnlyIcons; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx new file mode 100644 index 0000000000..7ba9fcfba3 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonOnlyIconsWithCheck = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonOnlyIconsWithCheck; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonWithDensity.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonWithDensity.tsx new file mode 100644 index 0000000000..0012d14d64 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonWithDensity.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonWithDensity = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + flex: 1, + }, + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonWithDensity; diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonWithSelectedCheck.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonWithSelectedCheck.tsx new file mode 100644 index 0000000000..ce2cc85463 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonWithSelectedCheck.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, SegmentedButtons } from 'react-native-paper'; + +const SegmentedButtonWithSelectedCheck = () => { + const [value, setValue] = React.useState(''); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + flex: 1, + }, + group: { paddingHorizontal: 20, justifyContent: 'center' }, +}); + +export default SegmentedButtonWithSelectedCheck; diff --git a/example/src/Examples/SegmentedButtons/index.ts b/example/src/Examples/SegmentedButtons/index.ts new file mode 100644 index 0000000000..d430fd3212 --- /dev/null +++ b/example/src/Examples/SegmentedButtons/index.ts @@ -0,0 +1,8 @@ +export { default as SegmentedButtonDefault } from './SegmentedButtonDefault'; +export { default as SegmentedButtonWithSelectedCheck } from './SegmentedButtonWithSelectedCheck'; +export { default as SegmentedButtonWithDensity } from './SegmentedButtonWithDensity'; +export { default as SegmentedButtonOnlyIcons } from './SegmentedButtonOnlyIcons'; +export { default as SegmentedButtonOnlyIconsWithCheck } from './SegmentedButtonOnlyIconsWithCheck'; +export { default as SegmentedButtonMultiselect } from './SegmentedButtonMultiselect'; +export { default as SegmentedButtonMultiselectIcons } from './SegmentedButtonMultiselectIcons'; +export { default as SegmentedButtonDisabled } from './SegmentedButtonDisabled'; diff --git a/example/src/Examples/SegmentedButtonsExample.tsx b/example/src/Examples/SegmentedButtonsExample.tsx new file mode 100644 index 0000000000..76b26e7029 --- /dev/null +++ b/example/src/Examples/SegmentedButtonsExample.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import ScreenWrapper from '../ScreenWrapper'; +import { + SegmentedButtonDefault, + SegmentedButtonDisabled, + SegmentedButtonMultiselect, + SegmentedButtonMultiselectIcons, + SegmentedButtonOnlyIcons, + SegmentedButtonOnlyIconsWithCheck, + SegmentedButtonWithDensity, + SegmentedButtonWithSelectedCheck, +} from './SegmentedButtons'; + +const SegmentedButtonExample = () => ( + + + + + + + + + + +); + +SegmentedButtonExample.title = 'Segmented Buttons'; + +export default SegmentedButtonExample; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx new file mode 100644 index 0000000000..4a829ee1a1 --- /dev/null +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import { + StyleProp, + ViewStyle, + GestureResponderEvent, + StyleSheet, + View, + TextStyle, + Animated, +} from 'react-native'; +import { useTheme } from '../../core/theming'; +import Text from '../Typography/Text'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { IconSource } from '../Icon'; +import color from 'color'; +import Icon from '../Icon'; +import { + getSegmentedButtonBorderRadius, + getSegmentedButtonColors, + getSegmentedButtonDensityPadding, +} from './utils'; + +export type Props = { + /** + * Whether the segmented button is checked + */ + checked: boolean; + /** + * Icon to display for the `SegmentedButtonItem`. + */ + icon?: IconSource; + /** + * Whether the button is disabled. + */ + disabled?: boolean; + /** + * Accessibility label for the `SegmentedButtonItem`. This is read by the screen reader when the user taps the button. + */ + accessibilityLabel?: string; + /** + * Function to execute on press. + */ + onPress?: (event: GestureResponderEvent) => void; + /** + * Value of button. + */ + value: string; + /** + * Label text of the button. + */ + label?: string; + /** + * Button segment. + */ + segment?: 'first' | 'last'; + /** + * Show optional check icon to indicate selected state + */ + showSelectedCheck?: boolean; + /** + * Density is applied to the height, to allow usage in denser UIs. + */ + density?: 'regular' | 'small' | 'medium' | 'high'; + style?: StyleProp; + /** + * testID to be used on tests. + */ + testID?: string; +}; + +const SegmentedButtonItem = ({ + checked, + accessibilityLabel, + disabled, + style, + showSelectedCheck, + icon, + testID, + label, + onPress, + segment, + density = 'regular', +}: Props) => { + const theme = useTheme(); + + const checkScale = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + if (!showSelectedCheck) { + return; + } + if (checked) { + Animated.spring(checkScale, { + toValue: 1, + useNativeDriver: true, + }).start(); + } else { + Animated.spring(checkScale, { + toValue: 0, + useNativeDriver: true, + }).start(); + } + }, [checked, checkScale, showSelectedCheck]); + + const { roundness, isV3 } = theme; + const { borderColor, textColor, borderWidth, backgroundColor } = + getSegmentedButtonColors({ + checked, + theme, + disabled, + }); + + const borderRadius = (isV3 ? 5 : 1) * roundness; + const segmentBorderRadius = getSegmentedButtonBorderRadius({ + theme, + segment, + }); + const rippleColor = color(textColor).alpha(0.12).rgb().string(); + + const iconSize = isV3 ? 18 : 16; + const iconStyle = { + marginRight: label ? 5 : checked && showSelectedCheck ? 3 : 0, + ...(label && { + transform: [ + { + scale: checkScale.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }), + }, + ], + }), + }; + + const buttonStyle: ViewStyle = { + backgroundColor, + borderColor, + borderWidth, + borderRadius, + ...segmentBorderRadius, + }; + const paddingVertical = getSegmentedButtonDensityPadding({ density }); + const rippleStyle: ViewStyle = { + borderRadius, + ...segmentBorderRadius, + }; + const showIcon = icon && !label ? true : checked ? !showSelectedCheck : true; + const textStyle: TextStyle = { + ...(!isV3 && { + textTransform: 'uppercase', + fontWeight: '500', + }), + color: textColor, + }; + + return ( + + + + {checked && showSelectedCheck ? ( + + + + ) : null} + {showIcon ? ( + + + + ) : null} + + {label} + + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + minWidth: 76, + borderStyle: 'solid', + }, + label: { + textAlign: 'center', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 9, + paddingHorizontal: 16, + }, +}); + +export default SegmentedButtonItem; + +const SegmentedButtonWithTheme = SegmentedButtonItem; +export { SegmentedButtonWithTheme as SegmentedButton }; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx new file mode 100644 index 0000000000..c6fe2a2007 --- /dev/null +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -0,0 +1,178 @@ +import * as React from 'react'; +import { + GestureResponderEvent, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; +import { useTheme } from '../../core/theming'; +import SegmentedButtonItem from './SegmentedButtonItem'; +import { getDisabledSegmentedButtonStyle } from './utils'; +import type { IconSource } from '../Icon'; + +type ConditionalValue = + | { + /** + * Array of the currently selected segmented button values. + */ + value: string[]; + /** + * Support multiple selected options. + */ + multiSelect: true; + /** + * Function to execute on selection change + */ + onValueChange: (value: string[]) => void; + } + | { + /** + * Value of the currently selected segmented button. + */ + value: string; + /** + * Support multiple selected options. + */ + multiSelect?: false; + /** + * Function to execute on selection change + */ + onValueChange: (value: string) => void; + }; + +export type Props = { + /** + * Buttons to display as options in toggle button. + * Button should contain the following properties: + * - `value`: value of button (required) + * - `icon`: icon to display for the item + * - `disabled`: whether the button is disabled + * - `accessibilityLabel`: acccessibility label for the button. This is read by the screen reader when the user taps the button. + * - `onPress`: callback that is called when button is pressed + * - `label`: label text of the button + * - `showSelectedCheck`: show optional check icon to indicate selected state + * - `style`: pass additional styles for the button + * - `testID`: testID to be used on tests + */ + buttons: { + value: string; + icon?: IconSource; + disabled?: boolean; + accessibilityLabel?: string; + onPress?: (event: GestureResponderEvent) => void; + label?: string; + showSelectedCheck?: boolean; + style?: StyleProp; + testID?: string; + }[]; + /** + * Density is applied to the height, to allow usage in denser UIs + */ + density?: 'regular' | 'small' | 'medium' | 'high'; + style?: StyleProp; +} & ConditionalValue; + +/** + * @supported Available in v5.x + * Segmented buttons can be used to select options, switch views or sort elements.
+ * + *
+ * + *
+ * + * ## Usage + * ```js + * import * as React from 'react'; + * import { SegmentedButtons } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [value, setValue] = React.useState(''); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + *``` + */ +const SegmentedButtons = ({ + value, + onValueChange, + buttons, + multiSelect, + density, + style, +}: Props) => { + const theme = useTheme(); + return ( + + {buttons.map((item, i) => { + const disabledChildStyle = getDisabledSegmentedButtonStyle({ + theme, + buttons, + index: i, + }); + const segment = + i === 0 ? 'first' : i === buttons.length - 1 ? 'last' : undefined; + + const checked = + multiSelect && Array.isArray(value) + ? value.includes(item.value) + : value === item.value; + + const onPress = (e: GestureResponderEvent) => { + item.onPress?.(e); + + const nextValue = + multiSelect && Array.isArray(value) + ? checked + ? value.filter((val) => item.value !== val) + : [...value, item.value] + : item.value; + + // @ts-expect-error: TS doesn't preserve types after destructuring, so the type isn't inferred correctly + onValueChange(nextValue); + }; + + return ( + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + }, +}); + +export default SegmentedButtons; + +// @component-docs ignore-next-line +export { SegmentedButtons as SegmentedButtons }; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts new file mode 100644 index 0000000000..d1ba1acd94 --- /dev/null +++ b/src/components/SegmentedButtons/utils.ts @@ -0,0 +1,157 @@ +import { StyleSheet, ViewStyle } from 'react-native'; +import color from 'color'; +import type { Theme } from '../../types'; +import { black, white } from '../../styles/themes/v2/colors'; + +type BaseProps = { + theme: Theme; + disabled?: boolean; + checked: boolean; +}; + +const DEFAULT_PADDING = 9; + +export const getSegmentedButtonDensityPadding = ({ + density, +}: { + density?: 'regular' | 'small' | 'medium' | 'high'; +}) => { + let padding = DEFAULT_PADDING; + + switch (density) { + case 'small': + return padding - 2; + case 'medium': + return padding - 4; + case 'high': + return padding - 8; + default: + return padding; + } +}; + +export const getDisabledSegmentedButtonStyle = ({ + theme, + index, + buttons, +}: { + theme: Theme; + buttons: { disabled?: boolean }[]; + index: number; +}): ViewStyle => { + const width = getSegmentedButtonBorderWidth({ theme }); + const isDisabled = buttons[index]?.disabled; + const isNextDisabled = buttons[index + 1]?.disabled; + + if (!isDisabled && isNextDisabled) { + return { + borderRightWidth: width, + }; + } + return {}; +}; + +export const getSegmentedButtonBorderRadius = ({ + segment, + theme, +}: { + theme: Theme; + segment?: 'first' | 'last'; +}): ViewStyle => { + if (segment === 'first') { + return { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + ...(theme.isV3 && { borderRightWidth: 0 }), + }; + } else if (segment === 'last') { + return { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }; + } else { + return { + borderRadius: 0, + ...(theme.isV3 && { borderRightWidth: 0 }), + }; + } +}; + +const getSegmentedButtonBackgroundColor = ({ checked, theme }: BaseProps) => { + if (checked) { + if (theme.isV3) { + return theme.colors.secondaryContainer; + } else { + return color(theme.colors.primary).alpha(0.12).rgb().string(); + } + } + return 'transparent'; +}; + +const getSegmentedButtonBorderColor = ({ + theme, + disabled, + checked, +}: BaseProps) => { + if (theme.isV3) { + if (disabled) { + return theme.colors.surfaceDisabled; + } + return theme.colors.outline; + } + if (checked) { + return theme.colors.primary; + } + + return color(theme.dark ? white : black) + .alpha(0.29) + .rgb() + .string(); +}; + +const getSegmentedButtonBorderWidth = ({ + theme, +}: Omit) => { + if (theme.isV3) { + return 1; + } + + return StyleSheet.hairlineWidth; +}; + +const getSegmentedButtonTextColor = ({ + theme, + disabled, +}: Omit) => { + if (theme.isV3) { + if (disabled) { + return theme.colors.onSurfaceDisabled; + } + return theme.colors.onSurface; + } else { + if (disabled) { + return theme.colors.disabled; + } + return theme.colors.primary; + } +}; + +export const getSegmentedButtonColors = ({ + theme, + disabled, + checked, +}: BaseProps) => { + const backgroundColor = getSegmentedButtonBackgroundColor({ + theme, + checked, + }); + const borderColor = getSegmentedButtonBorderColor({ + theme, + disabled, + checked, + }); + const textColor = getSegmentedButtonTextColor({ theme, disabled }); + const borderWidth = getSegmentedButtonBorderWidth({ theme }); + + return { backgroundColor, borderColor, textColor, borderWidth }; +}; diff --git a/src/components/__tests__/SegmentedButton.test.js b/src/components/__tests__/SegmentedButton.test.js new file mode 100644 index 0000000000..8d1cf5d172 --- /dev/null +++ b/src/components/__tests__/SegmentedButton.test.js @@ -0,0 +1,281 @@ +import * as React from 'react'; +import renderer from 'react-test-renderer'; +import { + getDisabledSegmentedButtonStyle, + getSegmentedButtonColors, +} from '../SegmentedButtons/utils'; +import { getTheme } from '../../core/theming'; +import { black } from '../../styles/themes/v2/colors'; +import color from 'color'; +import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; +import { render } from '@testing-library/react-native'; + +it('renders segmented button', () => { + const tree = renderer + .create( + {}} + value={'walk'} + buttons={[{ value: 'walk' }, { value: 'ride' }]} + /> + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders disabled segmented button', () => { + const tree = renderer + .create( + {}} + value={'walk'} + buttons={[{ value: 'walk' }, { value: 'ride', disabled: true }]} + /> + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders checked segmented button with selected check', () => { + const tree = renderer + .create( + {}} + value={'walk'} + buttons={[ + { value: 'walk', showSelectedCheck: true }, + { value: 'ride', disabled: true }, + ]} + /> + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +describe('getSegmentedButtonColors', () => { + it('should return correct background color when checked and theme version 3', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(), + disabled: false, + checked: true, + }) + ).toMatchObject({ backgroundColor: getTheme().colors.secondaryContainer }); + }); + + it('should return correct background color when checked and theme version 2', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(false, false), + disabled: false, + checked: true, + }) + ).toMatchObject({ + backgroundColor: color(getTheme(false, false).colors.primary) + .alpha(0.12) + .rgb() + .string(), + }); + }); + + it('should return correct background color when uncheked (V3 & V2)', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(false, false), + disabled: false, + checked: false, + }) + ).toMatchObject({ + backgroundColor: 'transparent', + }); + }); + + it('should return correct border color with theme version 3', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(), + disabled: false, + checked: false, + }) + ).toMatchObject({ + borderColor: getTheme().colors.outline, + }); + }); + + it('should return correct border color with theme version 2', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(false, false), + disabled: false, + checked: false, + }) + ).toMatchObject({ + borderColor: color(black).alpha(0.29).rgb().string(), + }); + }); + + it('should return correct border color when disabled and theme version 3', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(), + disabled: true, + checked: false, + }) + ).toMatchObject({ + borderColor: getTheme().colors.surfaceDisabled, + }); + }); + + it('should return correct textColor with theme version 3', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(), + disabled: false, + checked: false, + }) + ).toMatchObject({ + textColor: getTheme().colors.onSurface, + }); + }); + + it('should return correct textColor with theme version 2', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(false, false), + disabled: false, + checked: false, + }) + ).toMatchObject({ + textColor: getTheme(false, false).colors.primary, + }); + }); + + it('should return correct textColor when disabled and theme version 3', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(), + disabled: true, + checked: false, + }) + ).toMatchObject({ + textColor: getTheme().colors.onSurfaceDisabled, + }); + }); + + it('should return correct textColor when disabled and theme version 2', () => { + expect( + getSegmentedButtonColors({ + theme: getTheme(false, false), + disabled: true, + checked: false, + }) + ).toMatchObject({ + textColor: getTheme(false, false).colors.disabled, + }); + }); +}); + +describe('getDisabledSegmentedButtonBorderWidth', () => { + it('Returns empty style object for all enabled buttons', () => { + [0, 1, 2].forEach((index) => { + expect( + getDisabledSegmentedButtonStyle({ + theme: getTheme(), + buttons: [ + { disabled: false }, + { disabled: false }, + { disabled: false }, + ], + index, + }) + ).toMatchObject({}); + }); + }); + + it('Returns empty style object for all disabled buttons', () => { + [0, 1, 2].forEach((index) => { + expect( + getDisabledSegmentedButtonStyle({ + theme: getTheme(), + buttons: [{ disabled: true }, { disabled: true }, { disabled: true }], + index, + }) + ).toMatchObject({}); + }); + }); + + it('Returns proper style object for one disabled button', () => { + expect( + getDisabledSegmentedButtonStyle({ + theme: getTheme(), + buttons: [{ disabled: false }, { disabled: true }, { disabled: true }], + index: 0, + }) + ).toMatchObject({ borderRightWidth: 1 }); + }); + + it('Returns proper style object for two disabled buttons (alternately)', () => { + [0, 2].forEach((index) => { + expect( + getDisabledSegmentedButtonStyle({ + theme: getTheme(), + buttons: [ + { disabled: false }, + { disabled: true }, + { disabled: false }, + { disabled: true }, + ], + index, + }) + ).toMatchObject({ borderRightWidth: 1 }); + }); + }); +}); + +describe('should have `accessibilityState={ checked: true }` when selected', () => { + it('should have two button selected', () => { + const onValueChange = jest.fn(); + const { getAllByA11yState } = render( + + ); + + const checkedButtons = getAllByA11yState({ checked: true }); + expect(checkedButtons).toHaveLength(2); + }); + + it('show selected check icon should be shown', () => { + const onValueChange = jest.fn(); + + const { getByTestId } = render( + + ); + + expect(getByTestId('walking-check-icon')).toBeDefined(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.js.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.js.snap new file mode 100644 index 0000000000..9e82770d43 --- /dev/null +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.js.snap @@ -0,0 +1,727 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders checked segmented button with selected check 1`] = ` + + + + + + + □ + + + + + + + + + + + + + + + +`; + +exports[`renders disabled segmented button 1`] = ` + + + + + + + + + + + + + + + + + + +`; + +exports[`renders segmented button 1`] = ` + + + + + + + + + + + + + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index bb0ad65ee2..c1b4962811 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -53,6 +53,7 @@ export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; export { default as TextInput } from './components/TextInput/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; +export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; export { Caption, @@ -143,6 +144,7 @@ export type { Props as ParagraphProps } from './components/Typography/v2/Paragra export type { Props as SubheadingProps } from './components/Typography/v2/Subheading'; export type { Props as TitleProps } from './components/Typography/v2/Title'; export type { Props as TextProps } from './components/Typography/Text'; +export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { MD2Theme,