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..601a757762 100644 --- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx +++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx @@ -8,6 +8,14 @@ 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` +instead — it takes the same `buttons`, `value` and `onValueChange` props for +both the single- and multi-select models. The only prop without a direct +equivalent is `density`: pick a `size` from the Material Design 3 +button-group scale instead, where the default `"small"` (40dp) is the +closest match to a `"regular"` density segmented button. + 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 300c315b99..3b0563efcb 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -4661,6 +4661,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. On a button\n * that also has a label the check replaces the leading icon.\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", @@ -10704,10 +10912,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 `ConnectedButtonGroup`\ninstead — it takes the same `buttons`, `value` and `onValueChange` props for\nboth the single- and multi-select models. The only prop without a direct\nequivalent is `density`: pick a `size` from the Material Design 3\nbutton-group scale instead, where the default `\"small\"` (40dp) is the\nclosest match to a `\"regular\"` density segmented button.\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 `ConnectedButtonGroup`\ninstead — it takes the same `buttons`, `value` and `onValueChange` props for\nboth the single- and multi-select models. The only prop without a direct\nequivalent is `density`: pick a `size` from the Material Design 3\nbutton-group scale instead, where the default `\"small\"` (40dp) is the\nclosest match to a `\"regular\"` density segmented button.\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 8a2646d798..854fd84382 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..92be909471 --- /dev/null +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -0,0 +1,296 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { GestureResponderEvent } from 'react-native'; + +import Animated, { + ReduceMotion, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import type { ConnectedButtonProps as Props } from './types'; +import { + getConnectedButtonColors, + getConnectedButtonSizeStyle, + getTestID, + morphingCorners, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import Icon from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +/** + * 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 pressed = useSharedValue(false); + + // Selection-check behaviour matches `SegmentedButtonItem` for migration + // parity: the check scales in and takes the place of the leading icon on a + // labelled button, while an icon-only button keeps its icon alongside it. + const showCheck = Boolean(checked && showSelectedCheck); + const showIcon = Boolean(icon) && !(label && showCheck); + const checkScale = useSharedValue(showCheck ? 1 : 0); + + 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] + ); + + React.useEffect(() => { + // Springs to the value it already holds on mount, which is a no-op. + checkScale.value = withSpring(showCheck ? 1 : 0, springConfig); + }, [showCheck, checkScale, springConfig]); + + // Only the raw pressed flag is tracked here; the radius itself is derived + // from props in the worklet below, so no handler writes a radius directly. + const handlePressIn = React.useCallback(() => { + pressed.value = true; + }, [pressed]); + const handlePressOut = React.useCallback(() => { + pressed.value = false; + }, [pressed]); + // Clearing on press as well as press-out matters: re-pressing the already + // selected button reports the same value, so the group never re-renders and + // nothing else would restore the shape. A press-out swallowed by a gesture + // race used to leave the button stuck at its pressed corner indefinitely. + const handlePress = React.useCallback( + (event: GestureResponderEvent) => { + pressed.value = false; + onPress?.(event); + }, + [pressed, onPress] + ); + + const { start: animateStart, end: animateEnd } = morphingCorners[position]; + + // A single spring owns the morphing radius, derived from the current props + // rather than written by the press handlers and the selection separately — + // those used to fire competing springs on one shared value, so a tap ran + // three transitions (pressed, old rest, new rest) instead of one. + const cornerRadius = useDerivedValue( + () => withSpring(pressed.value ? pressedRadius : restRadius, springConfig), + [restRadius, pressedRadius, springConfig] + ); + + const animatedShapeStyle = useAnimatedStyle(() => { + // Read the plain number. A `withSpring` result is a stateful animation + // object and must never be handed to more than one style prop: four + // corners sharing one object step the same state machine four times a + // frame and desync. + 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 checkIconStyle = useAnimatedStyle(() => ({ + transform: [{ scale: checkScale.value }], + })); + + // The mirror of `checkIconStyle`, and it cannot be flattened to a constant. + // `useAnimatedStyle` caches its initial style from the component's *first* + // render, so whichever of the two remounts is painted at that cached value + // before the animation can take over. The icon only has something to grow + // from because `checkScale` still holds the previous selection when it + // mounts — see the effect above, whose after-paint timing is load-bearing. + // Icon-only buttons show both at once and never swap, so they stay at 1. + const hasLabel = Boolean(label); + const leadingIconStyle = useAnimatedStyle( + () => ({ + transform: [{ scale: hasLabel ? 1 - checkScale.value : 1 }], + }), + [hasLabel] + ); + + // A gap is needed before the label, and between the check and the leading + // icon when an icon-only button shows both. + const iconGap = + label || (showCheck && showIcon) + ? { marginEnd: sizeStyle.iconLabelGap } + : null; + + // When the container is translucent (disabled), the fill is drawn by the + // overlay below, so the base view stays transparent. + const hasContainerOverlay = colors.containerOpacity < 1; + const containerBackground = hasContainerOverlay + ? undefined + : colors.containerColor; + + return ( + + {hasContainerOverlay ? ( + // 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: { + // `flex: 1` (basis 0), like `SegmentedButtonItem`, so every button in the + // group is the same width. With a content-derived basis the row reflows + // whenever the selection check mounts or unmounts, which reads as the + // buttons snapping to a new width on every tap. + flex: 1, + 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/types.ts b/src/components/ConnectedButtonGroup/types.ts new file mode 100644 index 0000000000..2c07eaf7c2 --- /dev/null +++ b/src/components/ConnectedButtonGroup/types.ts @@ -0,0 +1,116 @@ +import type { + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import type { ConnectedButtonGroupSize } from './tokens'; +import type { ThemeProp } from '../../types'; +import type { IconSource } from '../Icon'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +/** + * Position of a button within the connected group. Determines which corners + * stay pinned to the outer (fully-rounded) radius and which morph. + */ +export const connectedButtonPositions = { + single: 'single', + first: 'first', + middle: 'middle', + last: 'last', +} as const; + +export type ConnectedButtonPosition = + (typeof connectedButtonPositions)[keyof typeof connectedButtonPositions]; + +/** + * Props of a single button rendered by the group. Everything a + * `ConnectedButtonConfig` entry carries, plus the state the group derives for + * it (`checked`, `position`, `size`, `multiSelect`). + * + * The overlap with `ConnectedButtonConfig` is deliberate: the docs generator + * only expands the `buttons` API table when the config is an inline object + * literal in `ConnectedButtonGroup.tsx`. + */ +export type ConnectedButtonProps = { + /** + * 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 to indicate the selected state. When the + * button also has a label, the check replaces the leading icon. + */ + 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; +}; diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts new file mode 100644 index 0000000000..10b1a9a897 --- /dev/null +++ b/src/components/ConnectedButtonGroup/utils.ts @@ -0,0 +1,149 @@ +import type { ColorValue } from 'react-native'; + +import { + connectedButtonSizeTokens, + type ConnectedButtonGroupSize, +} from './tokens'; +import { + connectedButtonPositions, + type ConnectedButtonPosition, +} from './types'; +import { tokens } from '../../theme/tokens'; +import { resolveCornerRadius, type ShapeToken } 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; + +export const getConnectedButtonPosition = ( + index: number, + count: number +): ConnectedButtonPosition => { + if (count <= 1) { + return connectedButtonPositions.single; + } + if (index === 0) { + return connectedButtonPositions.first; + } + if (index === count - 1) { + return connectedButtonPositions.last; + } + return connectedButtonPositions.middle; +}; + +/** + * Which of a button's two sides morph with selection and press. + */ +export const morphingCorners: Record< + ConnectedButtonPosition, + { start: boolean; end: boolean } +> = { + single: { start: false, end: false }, + first: { start: false, end: true }, + middle: { start: true, end: true }, + last: { start: true, end: false }, +}; + +export const getConnectedButtonSizeStyle = ({ + size, + theme, +}: { + size: ConnectedButtonGroupSize; + theme: InternalTheme; +}) => { + const sizeTokens = connectedButtonSizeTokens[size]; + + const pillRadius = sizeTokens.containerHeight / 2; + const resolve = (token: ShapeToken) => + token === 'full' ? pillRadius : resolveCornerRadius(theme, token); + + return { + ...sizeTokens, + outerRadius: resolve(sizeTokens.outerShape), + innerRadius: resolve(sizeTokens.innerShape), + pressedRadius: resolve(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 }; +}; + +export const getTestID = (testID: string | undefined, suffix: string) => + testID ? `${testID}-${suffix}` : undefined; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index c5fceeaeae..b260dde711 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -81,6 +81,14 @@ export type Props = { } & ConditionalValue; /** + * @deprecated Segmented buttons are deprecated in the Material Design 3 spec + * and replaced by the Connected Button Group. Use `ConnectedButtonGroup` + * instead — it takes the same `buttons`, `value` and `onValueChange` props for + * both the single- and multi-select models. The only prop without a direct + * equivalent is `density`: pick a `size` from the Material Design 3 + * button-group scale instead, where the default `"small"` (40dp) is the + * closest match to a `"regular"` density segmented button. + * * 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..bbffa2902e --- /dev/null +++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx @@ -0,0 +1,404 @@ +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 { cornerFull } from '../../theme/tokens/sys/shape'; +import ConnectedButtonGroup from '../ConnectedButtonGroup/ConnectedButtonGroup'; +import { + connectedButtonSizeTokens, + type ConnectedButtonGroupSize, +} 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('swaps the leading icon for the selection check on labelled buttons', async () => { + await renderGroup({ + buttons: [ + { + value: 'walk', + label: 'Walking', + icon: 'walk', + showSelectedCheck: true, + testID: 'walk', + }, + { + value: 'train', + label: 'Transit', + icon: 'train', + showSelectedCheck: true, + testID: 'train', + }, + ], + }); + + expect(screen.getByTestId('walk-check-icon')).toBeTruthy(); + expect(screen.queryByTestId('walk-icon')).toBeNull(); + + expect(screen.getByTestId('train-icon')).toBeTruthy(); + expect(screen.queryByTestId('train-check-icon')).toBeNull(); +}); + +it('keeps the icon of a selected icon-only button', async () => { + await renderGroup({ + buttons: [ + { value: 'walk', icon: 'walk', showSelectedCheck: true, testID: 'walk' }, + ], + }); + + expect(screen.getByTestId('walk-check-icon')).toBeTruthy(); + expect(screen.getByTestId('walk-icon')).toBeTruthy(); +}); + +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'); +}); + +it('morphs only the connected corners of each position', async () => { + const { innerRadius, outerRadius } = getConnectedButtonSizeStyle({ + size: 'small', + theme, + }); + + await renderGroup({ value: 'train' }); + + // first, unselected — leading corner pinned, trailing corner morphs + expect(screen.getByTestId('walk-container')).toHaveStyle({ + borderTopStartRadius: outerRadius, + borderTopEndRadius: innerRadius, + }); + // middle, selected — both sides morph, so both reach the pill + expect(screen.getByTestId('train-container')).toHaveStyle({ + borderTopStartRadius: outerRadius, + borderTopEndRadius: outerRadius, + }); + // last, unselected — leading corner morphs, trailing corner pinned + expect(screen.getByTestId('drive-container')).toHaveStyle({ + borderTopStartRadius: innerRadius, + borderTopEndRadius: outerRadius, + }); +}); + +it('leaves a lone button fully rounded on both sides', async () => { + const { outerRadius } = getConnectedButtonSizeStyle({ + size: 'small', + theme, + }); + + await renderGroup({ + buttons: [{ value: 'walk', label: 'Walking', testID: 'walk' }], + }); + + expect(screen.getByTestId('walk-container')).toHaveStyle({ + borderTopStartRadius: outerRadius, + borderTopEndRadius: outerRadius, + }); +}); + +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('resolves the pill corner to half the height, not the full-corner sentinel', () => { + const sizes: ConnectedButtonGroupSize[] = [ + 'extra-small', + 'small', + 'medium', + 'large', + 'extra-large', + ]; + expect(sizes).toHaveLength(Object.keys(connectedButtonSizeTokens).length); + + sizes.forEach((size) => { + const { outerRadius, containerHeight } = getConnectedButtonSizeStyle({ + size, + theme, + }); + + expect(connectedButtonSizeTokens[size].outerShape).toBe('full'); + expect(outerRadius).toBe(containerHeight / 2); + expect(outerRadius).toBeLessThan(cornerFull); + }); + }); + + 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..8dd4817855 --- /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';