diff --git a/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx b/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx new file mode 100644 index 0000000000..0e14ef0adb --- /dev/null +++ b/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx @@ -0,0 +1,122 @@ +--- +title: ConnectedButtonGroup +--- + +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'; + +Connected button groups let people select from a set of related options, switch views or sort elements. They are the Material Design 3 successor to `SegmentedButtons`: selected buttons morph to a fully-rounded shape and the group supports single- and multi-select. + + + +## Usage + +```js +import * as React from 'react'; +import { SafeAreaView, StyleSheet } from 'react-native'; +import { ConnectedButtonGroup } from 'react-native-paper'; + +const MyComponent = () => { + const [value, setValue] = React.useState('walk'); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, +}); + +export default MyComponent; +``` + +## Props + + + +
+ +### buttons (required) + +
+ + + +
+ +### size + +
+ + + +
+ +### style + +
+ + + +
+ +### testID + +
+ + + +
+ +### theme + +
+ + + + + +## Theme colors + + + + diff --git a/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json b/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json new file mode 100644 index 0000000000..b9591f8c2e --- /dev/null +++ b/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json @@ -0,0 +1,3 @@ +[ + "ConnectedButtonGroup" +] diff --git a/docs/5.x/docs/components/_meta.json b/docs/5.x/docs/components/_meta.json index 548d39075a..246ebe03b0 100644 --- a/docs/5.x/docs/components/_meta.json +++ b/docs/5.x/docs/components/_meta.json @@ -51,6 +51,13 @@ "collapsible": true, "collapsed": false }, + { + "type": "dir", + "name": "ConnectedButtonGroup", + "label": "ConnectedButtonGroup", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "DataTable", diff --git a/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx b/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx new file mode 100644 index 0000000000..a7e32b069c --- /dev/null +++ b/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx @@ -0,0 +1,112 @@ +--- +title: ConnectedButtonGroup +--- + +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'; + +Connected button groups let people select from a set of related options, +switch views or sort elements. They are the Material Design 3 successor to +`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the +group supports single- and multi-select. + + + + + + + +## Usage +```js +import * as React from 'react'; +import { SafeAreaView, StyleSheet } from 'react-native'; +import { ConnectedButtonGroup } from 'react-native-paper'; + +const MyComponent = () => { + const [value, setValue] = React.useState('walk'); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, +}); + +export default MyComponent; +``` + + + ## Props + + + + +
+ +### buttons (required) + +
+ + + +
+ +### size + +
+ + + +
+ +### style + +
+ + + +
+ +### testID + +
+ + + +
+ +### theme + +
+ + + + + + + + + ## Theme colors + + + + + diff --git a/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json b/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json new file mode 100644 index 0000000000..b9591f8c2e --- /dev/null +++ b/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json @@ -0,0 +1,3 @@ +[ + "ConnectedButtonGroup" +] diff --git a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx index fbd734c612..c73f28b204 100644 --- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx +++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx @@ -8,6 +8,11 @@ import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx'; import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; import ExtendedExample from '@docs/components/ExtendedExample.tsx'; +@deprecated Segmented buttons are deprecated in the Material Design 3 spec +and replaced by the Connected Button Group. Use +[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the +same single/multi-select API with `buttons`, `value` and `onValueChange`. + Segmented buttons can be used to select options, switch views or sort elements. diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json index 6f793d41a3..9360746d96 100644 --- a/docs/6.x/docs/components/_meta.json +++ b/docs/6.x/docs/components/_meta.json @@ -51,6 +51,13 @@ "collapsible": true, "collapsed": false }, + { + "type": "dir", + "name": "ConnectedButtonGroup", + "label": "ConnectedButtonGroup", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "DataTable", diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..d5ea917743 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -59,6 +59,9 @@ const pages = { Chip: { Chip: 'Chip/Chip', }, + ConnectedButtonGroup: { + ConnectedButtonGroup: 'ConnectedButtonGroup/ConnectedButtonGroup', + }, DataTable: { DataTable: 'DataTable/DataTable', DataTableCell: 'DataTable/DataTableCell', diff --git a/docs/public/screenshots/connected-button-group.png b/docs/public/screenshots/connected-button-group.png new file mode 100644 index 0000000000..2239c4ceef Binary files /dev/null and b/docs/public/screenshots/connected-button-group.png differ diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..b3e54b628e 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -4652,6 +4652,214 @@ "src/components/Chip/Chip.tsx" ] }, + "ConnectedButtonGroup/ConnectedButtonGroup": { + "filepath": "ConnectedButtonGroup/ConnectedButtonGroup.tsx", + "title": "ConnectedButtonGroup", + "description": "Connected button groups let people select from a set of related options,\nswitch views or sort elements. They are the Material Design 3 successor to\n`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the\ngroup supports single- and multi-select.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { ConnectedButtonGroup } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('walk');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "link": "connected-button-group", + "data": { + "description": "Connected button groups let people select from a set of related options,\nswitch views or sort elements. They are the Material Design 3 successor to\n`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the\ngroup supports single- and multi-select.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { ConnectedButtonGroup } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('walk');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "displayName": "ConnectedButtonGroup", + "methods": [], + "statics": [], + "props": { + "buttons": { + "required": true, + "tsType": { + "name": "Array", + "elements": [ + { + "name": "signature", + "type": "object", + "raw": "{\n /**\n * Value of the button (required).\n */\n value: T;\n /**\n * Icon to display for the button.\n */\n icon?: IconSource;\n /**\n * Label text of the button.\n */\n label?: string;\n /**\n * Whether the button is disabled.\n */\n disabled?: boolean;\n /**\n * Accessibility label for the button. Read by the screen reader when the\n * user taps the button.\n */\n 'aria-label'?: string;\n /**\n * Custom color for the selected label and icon.\n */\n checkedColor?: string;\n /**\n * Custom color for the unselected label and icon.\n */\n uncheckedColor?: string;\n /**\n * Show an optional check icon to indicate the selected state.\n */\n showSelectedCheck?: boolean;\n /**\n * Type of background drawable to display the feedback (Android).\n * https://reactnative.dev/docs/pressable#rippleconfig\n */\n background?: PressableAndroidRippleConfig;\n /**\n * Sets additional distance outside of the button in which a press can be\n * detected.\n */\n hitSlop?: TouchableRippleProps['hitSlop'];\n /**\n * Callback that is called when the button is pressed, in addition to the\n * group's `onValueChange`.\n */\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Specifies the largest possible scale a label font can reach.\n */\n labelMaxFontSizeMultiplier?: number;\n /**\n * Pass additional styles for the button container.\n */\n style?: StyleProp;\n /**\n * Style for the button label.\n */\n labelStyle?: StyleProp;\n /**\n * testID to be used on tests.\n */\n testID?: string;\n}", + "signature": { + "properties": [ + { + "key": "value", + "value": { + "name": "T", + "required": true + } + }, + { + "key": "icon", + "value": { + "name": "IconSource", + "required": false + } + }, + { + "key": "label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "disabled", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "aria-label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "checkedColor", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "uncheckedColor", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "showSelectedCheck", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "background", + "value": { + "name": "PressableAndroidRippleConfig", + "required": false + } + }, + { + "key": "hitSlop", + "value": { + "name": "TouchableRippleProps['hitSlop']", + "raw": "TouchableRippleProps['hitSlop']", + "required": false + } + }, + { + "key": "onPress", + "value": { + "name": "signature", + "type": "function", + "raw": "(event: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "event", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" + } + }, + "required": false + } + }, + { + "key": "labelMaxFontSizeMultiplier", + "value": { + "name": "number", + "required": false + } + }, + { + "key": "style", + "value": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp", + "required": false + } + }, + { + "key": "labelStyle", + "value": { + "name": "StyleProp", + "elements": [ + { + "name": "TextStyle" + } + ], + "raw": "StyleProp", + "required": false + } + }, + { + "key": "testID", + "value": { + "name": "string", + "required": false + } + } + ] + } + } + ], + "raw": "ConnectedButtonConfig[]" + }, + "description": "Buttons to display as options in the group. Each button should contain the\nfollowing properties:\n- `value`: value of the button (required)\n- `icon`: icon to display for the button\n- `label`: label text of the button\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button\n- `checkedColor`: custom color for the selected label and icon\n- `uncheckedColor`: custom color for the unselected label and icon\n- `showSelectedCheck`: show an optional check icon to indicate the selected state\n- `onPress`: callback that is called when the button is pressed\n- `style`: pass additional styles for the button\n- `labelStyle`: style for the button label\n- `testID`: testID to be used on tests" + }, + "size": { + "required": false, + "tsType": { + "name": "ConnectedButtonGroupSize" + }, + "description": "Size of the buttons, following the Material Design 3 button-group scale.", + "defaultValue": { + "value": "'small'", + "computed": false + } + }, + "style": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "" + }, + "testID": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "testID to be used on tests." + }, + "theme": { + "required": false, + "tsType": { + "name": "ThemeProp" + }, + "description": "" + } + } + }, + "type": "component", + "dependencies": [ + "src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx" + ] + }, "DataTable/DataTable": { "filepath": "DataTable/DataTable.tsx", "title": "DataTable", @@ -10695,10 +10903,10 @@ "SegmentedButtons/SegmentedButtons": { "filepath": "SegmentedButtons/SegmentedButtons.tsx", "title": "SegmentedButtons", - "description": "Segmented buttons can be used to select options, switch views or sort elements.
\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "description": "@deprecated Segmented buttons are deprecated in the Material Design 3 spec\nand replaced by the Connected Button Group. Use\n[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the\nsame single/multi-select API with `buttons`, `value` and `onValueChange`.\n\nSegmented buttons can be used to select options, switch views or sort elements.
\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", "link": "segmented-buttons", "data": { - "description": "Segmented buttons can be used to select options, switch views or sort elements.
\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "description": "@deprecated Segmented buttons are deprecated in the Material Design 3 spec\nand replaced by the Connected Button Group. Use\n[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the\nsame single/multi-select API with `buttons`, `value` and `onValueChange`.\n\nSegmented buttons can be used to select options, switch views or sort elements.
\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", "displayName": "SegmentedButtons", "methods": [], "statics": [], diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..e98532e0be 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -55,6 +55,7 @@ export const screenshots = { flat: 'screenshots/chip-1.png', outlined: 'screenshots/chip-2.png', }, + ConnectedButtonGroup: 'screenshots/connected-button-group.png', DataTable: 'screenshots/data-table-full-width.png', 'DataTable.Cell': 'screenshots/data-table-row-cell.png', 'DataTable.Header': 'screenshots/data-table-header.png', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..b23e3a5dbb 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -101,6 +101,26 @@ export const themeColors = { borderColor: 'theme.colors.outline', }, }, + ConnectedButtonGroup: { + checked: { + '-': { + backgroundColor: 'theme.colors.secondaryContainer', + textColor: 'theme.colors.onSecondaryContainer', + }, + }, + unchecked: { + '-': { + backgroundColor: 'theme.colors.surfaceContainer', + textColor: 'theme.colors.onSurfaceVariant', + }, + }, + disabled: { + '-': { + backgroundColor: 'theme.colors.onSurface', + textColor: 'theme.colors.onSurface', + }, + }, + }, Dialog: { '-': { backgroundColor: 'theme.colors.elevation.level3', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..e8d0edaa49 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -16,6 +16,7 @@ import CardExample from './Examples/CardExample'; import CheckboxExample from './Examples/CheckboxExample'; import CheckboxItemExample from './Examples/CheckboxItemExample'; import ChipExample from './Examples/ChipExample'; +import ConnectedButtonGroupExample from './Examples/ConnectedButtonGroupExample'; import DataTableExample from './Examples/DataTableExample'; import DialogExample from './Examples/DialogExample'; import DividerExample from './Examples/DividerExample'; @@ -61,6 +62,7 @@ export const mainExamples = { Checkbox: CheckboxExample, CheckboxItem: CheckboxItemExample, Chip: ChipExample, + ConnectedButtonGroup: ConnectedButtonGroupExample, DataTable: DataTableExample, Dialog: DialogExample, Divider: DividerExample, diff --git a/example/src/Examples/ConnectedButtonGroupExample.tsx b/example/src/Examples/ConnectedButtonGroupExample.tsx new file mode 100644 index 0000000000..6f00cc3e8d --- /dev/null +++ b/example/src/Examples/ConnectedButtonGroupExample.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ConnectedButtonGroup, List } from 'react-native-paper'; + +import ScreenWrapper from '../ScreenWrapper'; + +const sizes = ['extra-small', 'small', 'medium'] as const; + +const ConnectedButtonGroupExample = () => { + const [transport, setTransport] = React.useState('walk'); + const [align, setAlign] = React.useState('center'); + const [formatting, setFormatting] = React.useState(['bold']); + const [view, setView] = React.useState('week'); + + return ( + + + + + + + + + + + + + + + + + + + + + {sizes.map((size) => ( + + + + ))} + + + + + {}} + buttons={[ + { value: 'day', label: 'Day', disabled: true }, + { value: 'week', label: 'Week', disabled: true }, + { value: 'month', label: 'Month', disabled: true }, + ]} + /> + + + + ); +}; + +ConnectedButtonGroupExample.title = 'Connected Button Group'; + +const styles = StyleSheet.create({ + row: { + paddingHorizontal: 16, + paddingVertical: 8, + }, +}); + +export default ConnectedButtonGroupExample; diff --git a/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx new file mode 100644 index 0000000000..d6c119f3ad --- /dev/null +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -0,0 +1,334 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import type { ConnectedButtonGroupSize } from './tokens'; +import { + getConnectedButtonColors, + getConnectedButtonSizeStyle, + type ConnectedButtonPosition, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../types'; +import Icon, { type IconSource } from '../Icon'; +import TouchableRipple, { + type Props as TouchableRippleProps, +} from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Whether the button is currently selected. + */ + checked: boolean; + /** + * Whether the parent group allows multiple selections. Controls the + * accessibility role (checkbox vs radio). + */ + multiSelect?: boolean; + /** + * Position of the button inside the connected group. Controls which corners + * stay pinned to the group's outer radius and which morph on selection/press. + */ + position: ConnectedButtonPosition; + /** + * Size of the button, matching the parent group. + */ + size: ConnectedButtonGroupSize; + /** + * Icon to display before the label. + */ + icon?: IconSource; + /** + * Label text of the button. + */ + label?: string; + /** + * Whether the button is disabled. + */ + disabled?: boolean; + /** + * Show an optional check icon in place of the leading icon to indicate the + * selected state. + */ + showSelectedCheck?: boolean; + /** + * Custom color for the selected label and icon. + */ + checkedColor?: string; + /** + * Custom color for the unselected label and icon. + */ + uncheckedColor?: string; + /** + * Type of background drawable to display the feedback (Android). + * https://reactnative.dev/docs/pressable#rippleconfig + */ + background?: PressableAndroidRippleConfig; + /** + * Accessibility label. Read by the screen reader when the button is focused. + */ + 'aria-label'?: string; + /** + * Function to execute on press. + */ + onPress?: (event: GestureResponderEvent) => void; + /** + * Specifies the largest possible scale a label font can reach. + */ + labelMaxFontSizeMultiplier?: number; + /** + * Sets additional distance outside of the button in which a press can be + * detected. + */ + hitSlop?: TouchableRippleProps['hitSlop']; + style?: StyleProp; + /** + * Style for the button label. + */ + labelStyle?: StyleProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * A single button within a {@link ConnectedButtonGroup}. Not exported on its + * own — render it through the group's `buttons` prop. + */ +const ConnectedButton = ({ + checked, + multiSelect, + position, + size, + icon, + label, + disabled, + showSelectedCheck, + checkedColor, + uncheckedColor, + background, + 'aria-label': ariaLabel, + onPress, + labelMaxFontSizeMultiplier, + hitSlop, + style, + labelStyle, + testID, + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + + const sizeStyle = React.useMemo( + () => getConnectedButtonSizeStyle({ size, theme }), + [size, theme] + ); + const colors = React.useMemo( + () => + getConnectedButtonColors({ + theme, + selected: checked, + disabled, + checkedColor, + uncheckedColor, + }), + [theme, checked, disabled, checkedColor, uncheckedColor] + ); + + const { outerRadius, innerRadius, pressedRadius } = sizeStyle; + const restRadius = checked ? outerRadius : innerRadius; + const cornerRadius = useSharedValue(restRadius); + + const reduceMotion = useReduceMotion(); + const springConfig = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, + }), + [theme.motion.spring.fast.spatial, reduceMotion] + ); + + const isFirstRender = React.useRef(true); + + React.useEffect(() => { + // The shared value is already initialised to the resting radius, so skip + // the mount render and only animate subsequent selection / size changes. + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + cornerRadius.value = withSpring(restRadius, springConfig); + }, [restRadius, cornerRadius, springConfig]); + + const handlePressIn = React.useCallback(() => { + // Pressed takes precedence over selection: even a selected (fully-rounded) + // button morphs its connected corner while pressed, matching the M3 spec. + cornerRadius.value = withSpring(pressedRadius, springConfig); + }, [cornerRadius, pressedRadius, springConfig]); + const handlePressOut = React.useCallback(() => { + cornerRadius.value = withSpring(restRadius, springConfig); + }, [cornerRadius, restRadius, springConfig]); + + // The "outer" side keeps the group's fully-rounded radius; the "inner" side + // (the connected edge) morphs between the resting, pressed and selected radii. + const animateStart = position === 'last' || position === 'middle'; + const animateEnd = position === 'first' || position === 'middle'; + + const animatedShapeStyle = useAnimatedStyle(() => { + const morph = cornerRadius.value; + const startRadius = animateStart ? morph : outerRadius; + const endRadius = animateEnd ? morph : outerRadius; + return { + borderTopStartRadius: startRadius, + borderBottomStartRadius: startRadius, + borderTopEndRadius: endRadius, + borderBottomEndRadius: endRadius, + }; + }, [animateStart, animateEnd, outerRadius]); + + const showCheck = checked && showSelectedCheck; + const showIcon = Boolean(icon) && !showCheck; + const iconGap = label ? { marginEnd: sizeStyle.iconLabelGap } : null; + + const getTestID = (suffix: string) => + testID ? `${testID}-${suffix}` : undefined; + + // When the container is translucent (disabled), the fill is drawn by the + // overlay below, so the base view stays transparent. + const containerBackground = + colors.containerOpacity < 1 ? undefined : colors.containerColor; + + return ( + + {colors.containerOpacity < 1 ? ( + // Opacity is applied as a style so PlatformColor container values + // (Android dynamic themes) render at the MD3 disabled 12% correctly. + + ) : null} + + + {showCheck ? ( + + + + ) : null} + {showIcon ? ( + + + + ) : null} + {label ? ( + + {label} + + ) : null} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 'auto', + overflow: 'hidden', + }, + ripple: { + flex: 1, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + label: { + flexShrink: 1, + textAlign: 'center', + }, +}); + +export default ConnectedButton; diff --git a/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx new file mode 100644 index 0000000000..690e001162 --- /dev/null +++ b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx @@ -0,0 +1,269 @@ +import { StyleSheet, View } from 'react-native'; +import type { + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import ConnectedButton from './ConnectedButton'; +import { + connectedButtonSizeTokens, + type ConnectedButtonGroupSize, +} from './tokens'; +import { getConnectedButtonPosition } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import type { IconSource } from '../Icon'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +type ConditionalValue = + | { + /** + * Array of the currently selected button values. + */ + value: T[]; + /** + * Allow more than one button to be selected at a time. + */ + multiSelect: true; + /** + * Function to execute on selection change. + */ + onValueChange: (value: T[]) => void; + } + | { + /** + * Value of the currently selected button. + */ + value: T; + /** + * Allow more than one button to be selected at a time. + */ + multiSelect?: false; + /** + * Function to execute on selection change. + */ + onValueChange: (value: T) => void; + }; + +/** + * Configuration for a single button rendered inside the group. + */ +export type ConnectedButtonConfig = { + /** + * Value of the button (required). + */ + value: T; + /** + * Icon to display for the button. + */ + icon?: IconSource; + /** + * Label text of the button. + */ + label?: string; + /** + * Whether the button is disabled. + */ + disabled?: boolean; + /** + * Accessibility label for the button. Read by the screen reader when the + * user taps the button. + */ + 'aria-label'?: string; + /** + * Custom color for the selected label and icon. + */ + checkedColor?: string; + /** + * Custom color for the unselected label and icon. + */ + uncheckedColor?: string; + /** + * Show an optional check icon to indicate the selected state. + */ + showSelectedCheck?: boolean; + /** + * Type of background drawable to display the feedback (Android). + * https://reactnative.dev/docs/pressable#rippleconfig + */ + background?: PressableAndroidRippleConfig; + /** + * Sets additional distance outside of the button in which a press can be + * detected. + */ + hitSlop?: TouchableRippleProps['hitSlop']; + /** + * Callback that is called when the button is pressed, in addition to the + * group's `onValueChange`. + */ + onPress?: (event: GestureResponderEvent) => void; + /** + * Specifies the largest possible scale a label font can reach. + */ + labelMaxFontSizeMultiplier?: number; + /** + * Pass additional styles for the button container. + */ + style?: StyleProp; + /** + * Style for the button label. + */ + labelStyle?: StyleProp; + /** + * testID to be used on tests. + */ + testID?: string; +}; + +export type Props = { + /** + * Buttons to display as options in the group. Each button should contain the + * following properties: + * - `value`: value of the button (required) + * - `icon`: icon to display for the button + * - `label`: label text of the button + * - `disabled`: whether the button is disabled + * - `aria-label`: accessibility label for the button + * - `checkedColor`: custom color for the selected label and icon + * - `uncheckedColor`: custom color for the unselected label and icon + * - `showSelectedCheck`: show an optional check icon to indicate the selected state + * - `onPress`: callback that is called when the button is pressed + * - `style`: pass additional styles for the button + * - `labelStyle`: style for the button label + * - `testID`: testID to be used on tests + */ + buttons: ConnectedButtonConfig[]; + /** + * Size of the buttons, following the Material Design 3 button-group scale. + */ + size?: ConnectedButtonGroupSize; + style?: StyleProp; + /** + * testID to be used on tests. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +} & ConditionalValue; + +/** + * Connected button groups let people select from a set of related options, + * switch views or sort elements. They are the Material Design 3 successor to + * `SegmentedButtons`: selected buttons morph to a fully-rounded shape and the + * group supports single- and multi-select. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { SafeAreaView, StyleSheet } from 'react-native'; + * import { ConnectedButtonGroup } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [value, setValue] = React.useState('walk'); + * + * return ( + * + * + * + * ); + * }; + * + * const styles = StyleSheet.create({ + * container: { + * flex: 1, + * alignItems: 'center', + * }, + * }); + * + * export default MyComponent; + * ``` + */ +const ConnectedButtonGroup = ({ + value, + onValueChange, + buttons, + multiSelect, + size = 'small', + style, + testID, + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { betweenSpace } = connectedButtonSizeTokens[size]; + + return ( + + {buttons.map((item, index) => { + const position = getConnectedButtonPosition(index, buttons.length); + const checked = + multiSelect && Array.isArray(value) + ? value.includes(item.value) + : value === item.value; + + const handlePress = (event: GestureResponderEvent) => { + item.onPress?.(event); + + 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 ConnectedButtonGroup; diff --git a/src/components/ConnectedButtonGroup/index.ts b/src/components/ConnectedButtonGroup/index.ts new file mode 100644 index 0000000000..40c0eaf058 --- /dev/null +++ b/src/components/ConnectedButtonGroup/index.ts @@ -0,0 +1,3 @@ +export { default } from './ConnectedButtonGroup'; +export type { Props, ConnectedButtonConfig } from './ConnectedButtonGroup'; +export type { ConnectedButtonGroupSize } from './tokens'; diff --git a/src/components/ConnectedButtonGroup/tokens.ts b/src/components/ConnectedButtonGroup/tokens.ts new file mode 100644 index 0000000000..b00a3dcc31 --- /dev/null +++ b/src/components/ConnectedButtonGroup/tokens.ts @@ -0,0 +1,134 @@ +import type { TypescaleKey } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +export type ConnectedButtonGroupSize = + | 'extra-small' + | 'small' + | 'medium' + | 'large' + | 'extra-large'; + +export type ConnectedButtonSizeTokens = { + /** + * Height of every button in the group. + */ + containerHeight: number; + /** + * Gap rendered between adjacent buttons. + */ + betweenSpace: number; + /** + * Corner shape applied to the outer edges of the group (leading corners of + * the first button, trailing corners of the last button) and to any + * selected button. + */ + outerShape: ShapeToken; + /** + * Corner shape applied to the connected (inner) edges of unselected buttons. + */ + innerShape: ShapeToken; + /** + * Corner shape the connected edges morph to while a button is pressed. + */ + pressedShape: ShapeToken; + /** + * Icon size for both the leading icon and the selected-state check icon. + */ + iconSize: number; + /** + * Leading (start) horizontal padding of a button's content. + */ + leadingSpace: number; + /** + * Trailing (end) horizontal padding of a button's content. + */ + trailingSpace: number; + /** + * Gap between the icon and the label. + */ + iconLabelGap: number; + /** + * Minimum width of a single button. + */ + minWidth: number; + /** + * Typescale variant used for the label. + */ + labelVariant: TypescaleKey; +}; + +/** + * Per-size specs for the connected button group, following the Material Design 3 + * button-group sizing scale (extra-small → extra-large). + * @see https://m3.material.io/components/button-groups/specs + */ +export const connectedButtonSizeTokens: Record< + ConnectedButtonGroupSize, + ConnectedButtonSizeTokens +> = { + 'extra-small': { + containerHeight: 32, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'extraSmall', + pressedShape: 'extraSmall', + iconSize: 20, + leadingSpace: 12, + trailingSpace: 12, + iconLabelGap: 8, + minWidth: 48, + labelVariant: 'labelLarge', + }, + small: { + containerHeight: 40, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'small', + pressedShape: 'extraSmall', + iconSize: 20, + leadingSpace: 16, + trailingSpace: 16, + iconLabelGap: 8, + minWidth: 48, + labelVariant: 'labelLarge', + }, + medium: { + containerHeight: 56, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'small', + pressedShape: 'extraSmall', + iconSize: 24, + leadingSpace: 24, + trailingSpace: 24, + iconLabelGap: 8, + minWidth: 56, + labelVariant: 'titleMedium', + }, + large: { + containerHeight: 96, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'large', + pressedShape: 'medium', + iconSize: 32, + leadingSpace: 48, + trailingSpace: 48, + iconLabelGap: 12, + minWidth: 96, + labelVariant: 'headlineSmall', + }, + 'extra-large': { + containerHeight: 136, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'largeIncreased', + pressedShape: 'large', + iconSize: 40, + leadingSpace: 64, + trailingSpace: 64, + iconLabelGap: 16, + minWidth: 136, + labelVariant: 'headlineLarge', + }, +}; diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts new file mode 100644 index 0000000000..190effba31 --- /dev/null +++ b/src/components/ConnectedButtonGroup/utils.ts @@ -0,0 +1,131 @@ +import type { ColorValue } from 'react-native'; + +import { + connectedButtonSizeTokens, + type ConnectedButtonGroupSize, +} from './tokens'; +import { tokens } from '../../theme/tokens'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import type { InternalTheme } from '../../types'; + +const stateOpacity = tokens.md.sys.state.opacity; + +/** + * The MD3 disabled container is `onSurface` composited at 12% opacity. + * @see https://m3.material.io/foundations/interaction/states/state-layers + */ +const DISABLED_CONTAINER_OPACITY = 0.12; + +/** + * Position of a button within the connected group. Determines which corners + * stay pinned to the outer (fully-rounded) radius and which morph. + */ +export type ConnectedButtonPosition = 'first' | 'middle' | 'last' | 'single'; + +export const getConnectedButtonPosition = ( + index: number, + count: number +): ConnectedButtonPosition => { + if (count <= 1) { + return 'single'; + } + if (index === 0) { + return 'first'; + } + if (index === count - 1) { + return 'last'; + } + return 'middle'; +}; + +export const getConnectedButtonSizeStyle = ({ + size, + theme, +}: { + size: ConnectedButtonGroupSize; + theme: InternalTheme; +}) => { + const sizeTokens = connectedButtonSizeTokens[size]; + + return { + ...sizeTokens, + outerRadius: resolveCornerRadius(theme, sizeTokens.outerShape), + innerRadius: resolveCornerRadius(theme, sizeTokens.innerShape), + pressedRadius: resolveCornerRadius(theme, sizeTokens.pressedShape), + }; +}; + +const getContainerColor = ({ + theme, + selected, + disabled, +}: { + theme: InternalTheme; + selected: boolean; + disabled?: boolean; +}): ColorValue => { + const { colors } = theme; + + if (disabled) { + return colors.onSurface; + } + + return selected ? colors.secondaryContainer : colors.surfaceContainer; +}; + +const getContentColor = ({ + theme, + selected, + disabled, + checkedColor, + uncheckedColor, +}: { + theme: InternalTheme; + selected: boolean; + disabled?: boolean; + checkedColor?: string; + uncheckedColor?: string; +}): ColorValue => { + const { colors } = theme; + + if (disabled) { + return colors.onSurface; + } + if (selected) { + return checkedColor ?? colors.onSecondaryContainer; + } + return uncheckedColor ?? colors.onSurfaceVariant; +}; + +export const getConnectedButtonColors = ({ + theme, + selected, + disabled, + checkedColor, + uncheckedColor, +}: { + theme: InternalTheme; + selected: boolean; + disabled?: boolean; + checkedColor?: string; + uncheckedColor?: string; +}) => { + const containerColor = getContainerColor({ theme, selected, disabled }); + const contentColor = getContentColor({ + theme, + selected, + disabled, + checkedColor, + uncheckedColor, + }); + // Opacity is applied as a style instead of blending it into the color so + // that PlatformColor values (Android dynamic themes) keep working. + const containerOpacity = disabled + ? DISABLED_CONTAINER_OPACITY + : stateOpacity.enabled; + const contentOpacity = disabled + ? stateOpacity.disabled + : stateOpacity.enabled; + + return { containerColor, containerOpacity, contentColor, contentOpacity }; +}; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index c5fceeaeae..96b12c8400 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -81,6 +81,11 @@ export type Props = { } & ConditionalValue; /** + * @deprecated Segmented buttons are deprecated in the Material Design 3 spec + * and replaced by the Connected Button Group. Use + * [`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the + * same single/multi-select API with `buttons`, `value` and `onValueChange`. + * * Segmented buttons can be used to select options, switch views or sort elements.
* * ## Usage diff --git a/src/components/__tests__/ConnectedButtonGroup.test.tsx b/src/components/__tests__/ConnectedButtonGroup.test.tsx new file mode 100644 index 0000000000..13e6098422 --- /dev/null +++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx @@ -0,0 +1,299 @@ +import * as React from 'react'; + +import { describe, expect, it, jest } from '@jest/globals'; + +import { getTheme } from '../../core/theming'; +import { render, screen, userEvent } from '../../test-utils'; +import ConnectedButtonGroup from '../ConnectedButtonGroup/ConnectedButtonGroup'; +import { connectedButtonSizeTokens } from '../ConnectedButtonGroup/tokens'; +import { + getConnectedButtonColors, + getConnectedButtonPosition, + getConnectedButtonSizeStyle, +} from '../ConnectedButtonGroup/utils'; + +const theme = getTheme(); + +type SingleSelectProps = Extract< + React.ComponentProps, + { multiSelect?: false | undefined } +>; + +const buttons = [ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'train', label: 'Transit', testID: 'train' }, + { value: 'drive', label: 'Driving', testID: 'drive' }, +]; + +const renderGroup = (props: Partial = {}) => + render( + {}} + buttons={buttons} + {...props} + /> + ); + +it('renders every button with its label', async () => { + await renderGroup(); + + expect(screen.getByTestId('walk-label')).toHaveTextContent('Walking'); + expect(screen.getByTestId('train-label')).toHaveTextContent('Transit'); + expect(screen.getByTestId('drive-label')).toHaveTextContent('Driving'); +}); + +it('defaults to the small size (40dp height)', async () => { + await renderGroup(); + + expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 40 }); +}); + +it('applies the requested size height', async () => { + await renderGroup({ size: 'medium' }); + + expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 56 }); +}); + +it('calls onValueChange with the pressed value in single-select mode', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + await renderGroup({ onValueChange }); + + await user.press(screen.getByTestId('train')); + + expect(onValueChange).toHaveBeenCalledWith('train'); +}); + +it('invokes the per-button onPress alongside onValueChange', async () => { + const user = userEvent.setup(); + const onPress = jest.fn(); + const onValueChange = jest.fn(); + await renderGroup({ + onValueChange, + buttons: [ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'train', label: 'Transit', testID: 'train', onPress }, + ], + }); + + await user.press(screen.getByTestId('train')); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith('train'); +}); + +it('toggles values in multi-select mode', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + await render( + + ); + + await user.press(screen.getByTestId('train')); + expect(onValueChange).toHaveBeenLastCalledWith(['walk', 'train']); + + await user.press(screen.getByTestId('walk')); + expect(onValueChange).toHaveBeenLastCalledWith([]); +}); + +it('fills the selected button with the secondary container color', async () => { + await renderGroup(); + + expect(screen.getByTestId('walk-container')).toHaveStyle({ + backgroundColor: theme.colors.secondaryContainer, + }); + expect(screen.getByTestId('train-container')).toHaveStyle({ + backgroundColor: theme.colors.surfaceContainer, + }); +}); + +it('does not fire onValueChange for a disabled button', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + await renderGroup({ + onValueChange, + buttons: [ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'train', label: 'Transit', disabled: true, testID: 'train' }, + ], + }); + + await user.press(screen.getByTestId('train')); + + expect(onValueChange).not.toHaveBeenCalled(); +}); + +it('shows the selection check only on the selected button', async () => { + await renderGroup({ + buttons: [ + { + value: 'walk', + label: 'Walking', + showSelectedCheck: true, + testID: 'walk', + }, + { + value: 'train', + label: 'Transit', + showSelectedCheck: true, + testID: 'train', + }, + ], + }); + + expect(screen.getByTestId('walk-check-icon')).toBeTruthy(); + expect(screen.queryByTestId('train-check-icon')).toBeNull(); +}); + +it('applies a custom checked color to the selected label', async () => { + await renderGroup({ + buttons: [ + { + value: 'walk', + label: 'Walking', + checkedColor: 'rgb(255, 0, 0)', + testID: 'walk', + }, + ], + }); + + expect(screen.getByTestId('walk-label')).toHaveStyle({ + color: 'rgb(255, 0, 0)', + }); +}); + +it('marks single-select buttons with the radio role', async () => { + await renderGroup(); + + expect(screen.getByTestId('walk')).toHaveProp('role', 'radio'); +}); + +it('marks multi-select buttons with the checkbox role', async () => { + await render( + {}} + buttons={buttons} + /> + ); + + expect(screen.getByTestId('walk')).toHaveProp('role', 'checkbox'); +}); + +describe('connected button shape tokens', () => { + it('presses the inner corner sharper than its resting radius (M3)', () => { + // small: inner = small (8dp), pressed inner = extraSmall (4dp) + const { innerRadius, pressedRadius, outerRadius } = + getConnectedButtonSizeStyle({ size: 'small', theme }); + expect(pressedRadius).toBeLessThan(innerRadius); + expect(innerRadius).toBe(theme.shapes.corner.small); + expect(pressedRadius).toBe(theme.shapes.corner.extraSmall); + // outer edge stays fully rounded + expect(outerRadius).toBeGreaterThan(innerRadius); + }); + + it('uses the spec inner corners for large and extra-large', () => { + expect(connectedButtonSizeTokens.large.innerShape).toBe('large'); + expect(connectedButtonSizeTokens['extra-large'].innerShape).toBe( + 'largeIncreased' + ); + }); +}); + +describe('getConnectedButtonPosition', () => { + it('classifies a lone button as single', () => { + expect(getConnectedButtonPosition(0, 1)).toBe('single'); + }); + + it('classifies first, middle and last positions', () => { + expect(getConnectedButtonPosition(0, 3)).toBe('first'); + expect(getConnectedButtonPosition(1, 3)).toBe('middle'); + expect(getConnectedButtonPosition(2, 3)).toBe('last'); + }); +}); + +describe('getConnectedButtonColors', () => { + it('uses MD3 selection color roles', () => { + const selected = getConnectedButtonColors({ theme, selected: true }); + expect(selected.containerColor).toBe(theme.colors.secondaryContainer); + expect(selected.contentColor).toBe(theme.colors.onSecondaryContainer); + + const unselected = getConnectedButtonColors({ theme, selected: false }); + expect(unselected.containerColor).toBe(theme.colors.surfaceContainer); + expect(unselected.contentColor).toBe(theme.colors.onSurfaceVariant); + }); + + it('dims content and keeps onSurface when disabled', () => { + const disabled = getConnectedButtonColors({ + theme, + selected: false, + disabled: true, + }); + expect(disabled.contentColor).toBe(theme.colors.onSurface); + expect(disabled.contentOpacity).toBe(0.38); + }); + + it('honours custom checked and unchecked colors', () => { + expect( + getConnectedButtonColors({ + theme, + selected: true, + checkedColor: 'red', + }).contentColor + ).toBe('red'); + expect( + getConnectedButtonColors({ + theme, + selected: false, + uncheckedColor: 'blue', + }).contentColor + ).toBe('blue'); + }); +}); + +it('renders connected button group', async () => { + const tree = (await renderGroup()).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders multi-select connected button group with icons', async () => { + const tree = ( + await render( + {}} + buttons={[ + { value: 'bold', icon: 'format-bold', 'aria-label': 'Bold' }, + { value: 'italic', icon: 'format-italic', 'aria-label': 'Italic' }, + ]} + /> + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders disabled connected button group', async () => { + const tree = ( + await renderGroup({ + buttons: buttons.map((button) => ({ ...button, disabled: true })), + }) + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders large connected button group', async () => { + const tree = (await renderGroup({ size: 'large' })).toJSON(); + + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap b/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap new file mode 100644 index 0000000000..b8390ef01d --- /dev/null +++ b/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap @@ -0,0 +1,1500 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders connected button group 1`] = ` + + + + + + Walking + + + + + + + + + Transit + + + + + + + + + Driving + + + + + +`; + +exports[`renders disabled connected button group 1`] = ` + + + + + + + Walking + + + + + + + + + + Transit + + + + + + + + + + Driving + + + + + +`; + +exports[`renders large connected button group 1`] = ` + + + + + + Walking + + + + + + + + + Transit + + + + + + + + + Driving + + + + + +`; + +exports[`renders multi-select connected button group with icons 1`] = ` + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..b528c64cfd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,6 +29,7 @@ export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; +export { default as ConnectedButtonGroup } from './components/ConnectedButtonGroup'; export { default as DataTable } from './components/DataTable/DataTable'; export { default as Dialog } from './components/Dialog/Dialog'; export { default as Divider } from './components/Divider'; @@ -78,6 +79,11 @@ export type { Props as CardTitleProps } from './components/Card/CardTitle'; export type { Props as CheckboxProps } from './components/Checkbox/Checkbox'; export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem'; export type { Props as ChipProps } from './components/Chip/Chip'; +export type { + Props as ConnectedButtonGroupProps, + ConnectedButtonConfig, + ConnectedButtonGroupSize, +} from './components/ConnectedButtonGroup'; export type { Props as DataTableProps } from './components/DataTable/DataTable'; export type { Props as DataTableCellProps } from './components/DataTable/DataTableCell'; export type { Props as DataTableHeaderProps } from './components/DataTable/DataTableHeader';