From 76e24a5d023056a738d767489e0c346170acfb16 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 04:17:06 +0200 Subject: [PATCH 1/7] feat(connected-button-group): add Material Design 3 connected button group Introduces ConnectedButtonGroup, the MD3 successor to SegmentedButtons. Selected buttons morph to a fully-rounded shape, the connected inner corners expand on press, and the group supports single- and multi-select across the extra-small to extra-large size scale. Component-specific tokens are extracted into tokens.ts; size, shape and color resolution live in utils.ts. SegmentedButtons is marked as deprecated in favour of the new component. --- .../ConnectedButtonGroup/ConnectedButton.tsx | 333 ++++++++++++++++++ .../ConnectedButtonGroup.tsx | 259 ++++++++++++++ src/components/ConnectedButtonGroup/index.ts | 2 + src/components/ConnectedButtonGroup/tokens.ts | 141 ++++++++ src/components/ConnectedButtonGroup/utils.ts | 189 ++++++++++ .../SegmentedButtons/SegmentedButtons.tsx | 5 + src/index.tsx | 5 + 7 files changed, 934 insertions(+) create mode 100644 src/components/ConnectedButtonGroup/ConnectedButton.tsx create mode 100644 src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx create mode 100644 src/components/ConnectedButtonGroup/index.ts create mode 100644 src/components/ConnectedButtonGroup/tokens.ts create mode 100644 src/components/ConnectedButtonGroup/utils.ts diff --git a/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx new file mode 100644 index 0000000000..4a0ee2bbc1 --- /dev/null +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -0,0 +1,333 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { + GestureResponderEvent, + PressableAndroidRippleConfig, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import type { ConnectedButtonGroupSize } from './tokens'; +import { + getConnectedButtonColors, + getConnectedButtonHitSlop, + getConnectedButtonRippleColor, + getConnectedButtonSizeStyle, + type ConnectedButtonPosition, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +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; + /** + * 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; + /** + * Custom ripple color. + */ + rippleColor?: 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, + position, + size, + icon, + label, + disabled, + showSelectedCheck, + checkedColor, + uncheckedColor, + rippleColor: customRippleColor, + 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 rippleColor = React.useMemo( + () => + getConnectedButtonRippleColor({ + contentColor: colors.contentColor, + customRippleColor, + }), + [colors.contentColor, customRippleColor] + ); + const resolvedHitSlop = React.useMemo( + () => getConnectedButtonHitSlop({ size, hitSlop }), + [size, hitSlop] + ); + + const { outerRadius, innerRadius, pressedRadius } = sizeStyle; + const restRadius = checked ? outerRadius : innerRadius; + const cornerRadius = useSharedValue(restRadius); + + const pressTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short4, + easing: Easing.bezier(...theme.motion.easing.standard), + }), + [theme.motion.duration.short4, theme.motion.easing.standard] + ); + const releaseTimingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.short3, + easing: Easing.bezier(...theme.motion.easing.standard), + }), + [theme.motion.duration.short3, theme.motion.easing.standard] + ); + + 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 = withTiming(restRadius, releaseTimingConfig); + }, [restRadius, cornerRadius, releaseTimingConfig]); + + const handlePressIn = React.useCallback(() => { + if (!checked) { + cornerRadius.value = withTiming(pressedRadius, pressTimingConfig); + } + }, [checked, cornerRadius, pressedRadius, pressTimingConfig]); + const handlePressOut = React.useCallback(() => { + if (!checked) { + cornerRadius.value = withTiming(innerRadius, releaseTimingConfig); + } + }, [checked, cornerRadius, innerRadius, releaseTimingConfig]); + + // 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; + + return ( + + + + {showCheck ? ( + + + + ) : null} + {showIcon ? ( + + + + ) : null} + {label ? ( + + {label} + + ) : null} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + 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..136131b653 --- /dev/null +++ b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx @@ -0,0 +1,259 @@ +import { StyleSheet, View } from 'react-native'; +import type { + GestureResponderEvent, + 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'; + +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; + /** + * Custom ripple color for the button. + */ + rippleColor?: string; + /** + * Show an optional check icon to indicate the selected state. + */ + showSelectedCheck?: boolean; + /** + * 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 + * - `rippleColor`: custom ripple color for the button + * - `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..64fd0f3e53 --- /dev/null +++ b/src/components/ConnectedButtonGroup/index.ts @@ -0,0 +1,2 @@ +export { default } from './ConnectedButtonGroup'; +export type { Props, ConnectedButtonConfig } from './ConnectedButtonGroup'; diff --git a/src/components/ConnectedButtonGroup/tokens.ts b/src/components/ConnectedButtonGroup/tokens.ts new file mode 100644 index 0000000000..7cc416ee71 --- /dev/null +++ b/src/components/ConnectedButtonGroup/tokens.ts @@ -0,0 +1,141 @@ +import type { ThemeShapeCorners, TypescaleKey } from '../../theme/types'; + +export type ConnectedButtonGroupSize = + | 'extra-small' + | 'small' + | 'medium' + | 'large' + | 'extra-large'; + +export type ConnectedButtonShapeKey = keyof ThemeShapeCorners | 'full'; + +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: ConnectedButtonShapeKey; + /** + * Corner shape applied to the connected (inner) edges of unselected buttons. + */ + innerShape: ConnectedButtonShapeKey; + /** + * Corner shape the connected edges morph to while a button is pressed. + */ + pressedShape: ConnectedButtonShapeKey; + /** + * 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: 'small', + iconSize: 20, + leadingSpace: 12, + trailingSpace: 12, + iconLabelGap: 8, + minWidth: 48, + labelVariant: 'labelLarge', + }, + small: { + containerHeight: 40, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'small', + pressedShape: 'medium', + iconSize: 20, + leadingSpace: 16, + trailingSpace: 16, + iconLabelGap: 8, + minWidth: 48, + labelVariant: 'labelLarge', + }, + medium: { + containerHeight: 56, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'small', + pressedShape: 'medium', + iconSize: 24, + leadingSpace: 24, + trailingSpace: 24, + iconLabelGap: 8, + minWidth: 56, + labelVariant: 'titleMedium', + }, + large: { + containerHeight: 96, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'medium', + pressedShape: 'largeIncreased', + iconSize: 32, + leadingSpace: 48, + trailingSpace: 48, + iconLabelGap: 12, + minWidth: 96, + labelVariant: 'headlineSmall', + }, + 'extra-large': { + containerHeight: 136, + betweenSpace: 2, + outerShape: 'full', + innerShape: 'large', + pressedShape: 'largeIncreased', + iconSize: 40, + leadingSpace: 64, + trailingSpace: 64, + iconLabelGap: 16, + minWidth: 136, + labelVariant: 'headlineLarge', + }, +}; + +/** + * Minimum interactive size guaranteed via `hitSlop` for the smaller button + * sizes, per WCAG / MD3 touch-target guidance. + */ +export const connectedButtonMinInteractiveSize = 48; diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts new file mode 100644 index 0000000000..856452ab72 --- /dev/null +++ b/src/components/ConnectedButtonGroup/utils.ts @@ -0,0 +1,189 @@ +import type { ColorValue, Insets } from 'react-native'; + +import color from 'color'; + +import { + connectedButtonMinInteractiveSize, + connectedButtonSizeTokens, + type ConnectedButtonGroupSize, + type ConnectedButtonShapeKey, +} from './tokens'; +import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { InternalTheme } from '../../types'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +const stateOpacity = tokens.md.sys.state.opacity; + +/** + * 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 resolveConnectedButtonCorner = ( + theme: InternalTheme, + key: ConnectedButtonShapeKey +): number => (key === 'full' ? cornerFull : theme.shapes.corner[key]); + +export const getConnectedButtonSizeStyle = ({ + size, + theme, +}: { + size: ConnectedButtonGroupSize; + theme: InternalTheme; +}) => { + const sizeTokens = connectedButtonSizeTokens[size]; + + return { + ...sizeTokens, + outerRadius: resolveConnectedButtonCorner(theme, sizeTokens.outerShape), + innerRadius: resolveConnectedButtonCorner(theme, sizeTokens.innerShape), + pressedRadius: resolveConnectedButtonCorner(theme, sizeTokens.pressedShape), + }; +}; + +const getContainerColor = ({ + theme, + selected, + disabled, +}: { + theme: InternalTheme; + selected: boolean; + disabled?: boolean; +}): ColorValue => { + const { colors } = theme; + + if (disabled) { + const base = colors.onSurface; + return typeof base === 'string' + ? color(base).alpha(DISABLED_CONTAINER_OPACITY).rgb().string() + : base; + } + + 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, + }); + const contentOpacity = disabled + ? stateOpacity.disabled + : stateOpacity.enabled; + + return { containerColor, contentColor, contentOpacity }; +}; + +export const getConnectedButtonRippleColor = ({ + contentColor, + customRippleColor, +}: { + contentColor: ColorValue; + customRippleColor?: ColorValue; +}): ColorValue | undefined => { + if (customRippleColor) { + return customRippleColor; + } + if (typeof contentColor !== 'string') { + return undefined; + } + return color(contentColor).alpha(stateOpacity.pressed).rgb().string(); +}; + +/** + * Expands the touch target of shorter buttons to the minimum interactive size + * (48dp) without changing their visual height. + */ +export const getConnectedButtonHitSlop = ({ + size, + hitSlop, +}: { + size: ConnectedButtonGroupSize; + hitSlop?: TouchableRippleProps['hitSlop']; +}): TouchableRippleProps['hitSlop'] => { + if (typeof hitSlop === 'number') { + return hitSlop; + } + + const height = connectedButtonSizeTokens[size].containerHeight; + const verticalSlop = Math.max( + 0, + (connectedButtonMinInteractiveSize - height) / 2 + ); + + if (verticalSlop === 0) { + return hitSlop; + } + + const insetHitSlop = (hitSlop || {}) as Insets; + + return { + ...insetHitSlop, + top: insetHitSlop.top ?? verticalSlop, + bottom: insetHitSlop.bottom ?? verticalSlop, + }; +}; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index c5fceeaeae..343e51ef6b 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) 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/index.tsx b/src/index.tsx index 8863e2fa20..18cf0054ac 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,10 @@ 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, +} 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'; From 91f1f79a051a506d9aa74e2403b3c073a0a504b2 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 04:17:06 +0200 Subject: [PATCH 2/7] test(connected-button-group): cover selection, sizing and colors --- .../__tests__/ConnectedButtonGroup.test.tsx | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/components/__tests__/ConnectedButtonGroup.test.tsx diff --git a/src/components/__tests__/ConnectedButtonGroup.test.tsx b/src/components/__tests__/ConnectedButtonGroup.test.tsx new file mode 100644 index 0000000000..dc45d1076e --- /dev/null +++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx @@ -0,0 +1,238 @@ +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 { + getConnectedButtonColors, + getConnectedButtonHitSlop, + getConnectedButtonPosition, +} 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)', + }); +}); + +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'); + }); +}); + +describe('getConnectedButtonHitSlop', () => { + it('expands short buttons to the minimum interactive size', () => { + expect(getConnectedButtonHitSlop({ size: 'extra-small' })).toMatchObject({ + top: 8, + bottom: 8, + }); + }); + + it('leaves tall buttons untouched', () => { + expect(getConnectedButtonHitSlop({ size: 'medium' })).toBeUndefined(); + }); + + it('respects a numeric hitSlop override', () => { + expect(getConnectedButtonHitSlop({ size: 'extra-small', hitSlop: 4 })).toBe( + 4 + ); + }); +}); From 9a4f5d0d66a649be483cb9403743f43c62328376 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 04:17:06 +0200 Subject: [PATCH 3/7] docs(connected-button-group): showcase in example app and theme colors --- docs/src/data/themeColors.ts | 20 +++ example/src/ExampleList.tsx | 2 + .../Examples/ConnectedButtonGroupExample.tsx | 127 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 example/src/Examples/ConnectedButtonGroupExample.tsx 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; From 09967f934ce52431cbdc86a5adbb94038967dbb4 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 04:49:03 +0200 Subject: [PATCH 4/7] fix(connected-button-group): apply disabled container color via opacity overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-blending the disabled container color with color().alpha() breaks on Android dynamic themes where onSurface is a PlatformColor object rather than a string — the fallback rendered a fully opaque near-black container. Apply the MD3 12% disabled opacity as a style on an absolute-fill overlay instead, which works with any ColorValue. Found during on-device Android verification. --- .../ConnectedButtonGroup/ConnectedButton.tsx | 17 ++++++++++++++++- src/components/ConnectedButtonGroup/utils.ts | 12 +++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx index 4a0ee2bbc1..7d656f0710 100644 --- a/src/components/ConnectedButtonGroup/ConnectedButton.tsx +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -240,12 +240,27 @@ const ConnectedButton = ({ { height: sizeStyle.containerHeight, minWidth: sizeStyle.minWidth, - backgroundColor: colors.containerColor, + backgroundColor: + colors.containerOpacity < 1 ? 'transparent' : colors.containerColor, }, animatedShapeStyle, style, ]} > + {colors.containerOpacity < 1 ? ( + // Opacity is applied as a style so PlatformColor container values + // (Android dynamic themes) render at the MD3 disabled 12% correctly. + + ) : null} Date: Tue, 14 Jul 2026 05:20:29 +0200 Subject: [PATCH 5/7] fix(connected-button-group): align shapes and roles with M3 spec - tokens: pressed inner corner now morphs sharper than rest (M3 ConnectedButtonGroup*Tokens: Inner=Small, PressedInner=ExtraSmall), and large/extra-large rest inner corners corrected to large(16)/largeIncreased(20). - press morph: selected buttons also morph on press (pressed precedence over checked, matching Compose ToggleButton); pressOut returns to the rest radius. - accessibility: buttons now use the radio (single-select) / checkbox (multi-select) role instead of button+aria-checked, valid on web and better announced natively. - reduce-motion: shape morph honours the reduce-motion setting (ReduceMotion), matching Switch/FAB. - docs: register ConnectedButtonGroup in component-docs.config so the deprecation link and generated page resolve. - tests: cover shape tokens and role semantics (22 total). --- docs/component-docs.config.ts | 3 ++ .../ConnectedButtonGroup/ConnectedButton.tsx | 51 ++++++++++++++----- .../ConnectedButtonGroup.tsx | 5 ++ src/components/ConnectedButtonGroup/tokens.ts | 14 ++--- .../__tests__/ConnectedButtonGroup.test.tsx | 41 +++++++++++++++ 5 files changed, 94 insertions(+), 20 deletions(-) diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..256a41539d 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -52,6 +52,9 @@ const pages = { CardCover: 'Card/CardCover', CardTitle: 'Card/CardTitle', }, + ConnectedButtonGroup: { + ConnectedButtonGroup: 'ConnectedButtonGroup/ConnectedButtonGroup', + }, Checkbox: { Checkbox: 'Checkbox/Checkbox', CheckboxItem: 'Checkbox/CheckboxItem', diff --git a/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx index 7d656f0710..4b3d505f3c 100644 --- a/src/components/ConnectedButtonGroup/ConnectedButton.tsx +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -10,6 +10,7 @@ import type { import Animated, { Easing, + ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, @@ -24,6 +25,7 @@ import { type ConnectedButtonPosition, } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../types'; import Icon, { type IconSource } from '../Icon'; import TouchableRipple, { @@ -36,6 +38,11 @@ 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. @@ -117,6 +124,7 @@ export type Props = { */ const ConnectedButton = ({ checked, + multiSelect, position, size, icon, @@ -170,19 +178,34 @@ const ConnectedButton = ({ const restRadius = checked ? outerRadius : innerRadius; const cornerRadius = useSharedValue(restRadius); + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + const pressTimingConfig = React.useMemo( () => ({ duration: theme.motion.duration.short4, easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, }), - [theme.motion.duration.short4, theme.motion.easing.standard] + [ + theme.motion.duration.short4, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] ); const releaseTimingConfig = React.useMemo( () => ({ duration: theme.motion.duration.short3, easing: Easing.bezier(...theme.motion.easing.standard), + reduceMotion: reanimatedReduceMotion, }), - [theme.motion.duration.short3, theme.motion.easing.standard] + [ + theme.motion.duration.short3, + theme.motion.easing.standard, + reanimatedReduceMotion, + ] ); const isFirstRender = React.useRef(true); @@ -198,15 +221,13 @@ const ConnectedButton = ({ }, [restRadius, cornerRadius, releaseTimingConfig]); const handlePressIn = React.useCallback(() => { - if (!checked) { - cornerRadius.value = withTiming(pressedRadius, pressTimingConfig); - } - }, [checked, cornerRadius, pressedRadius, pressTimingConfig]); + // Pressed takes precedence over selection: even a selected (fully-rounded) + // button morphs its connected corner while pressed, matching the M3 spec. + cornerRadius.value = withTiming(pressedRadius, pressTimingConfig); + }, [cornerRadius, pressedRadius, pressTimingConfig]); const handlePressOut = React.useCallback(() => { - if (!checked) { - cornerRadius.value = withTiming(innerRadius, releaseTimingConfig); - } - }, [checked, cornerRadius, innerRadius, releaseTimingConfig]); + cornerRadius.value = withTiming(restRadius, releaseTimingConfig); + }, [cornerRadius, restRadius, releaseTimingConfig]); // The "outer" side keeps the group's fully-rounded radius; the "inner" side // (the connected edge) morphs between the resting, pressed and selected radii. @@ -232,6 +253,11 @@ const ConnectedButton = ({ 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 ( ({ position={position} size={size} checked={checked} + multiSelect={Boolean(multiSelect)} icon={item.icon} label={item.label} disabled={item.disabled} @@ -253,6 +254,10 @@ const ConnectedButtonGroup = ({ const styles = StyleSheet.create({ row: { flexDirection: 'row', + // Guarantees a 48dp interactive band so the shorter button sizes' hitSlop + // enlarges the touch target within the row's bounds (WCAG / MD3 target). + minHeight: 48, + alignItems: 'center', }, }); diff --git a/src/components/ConnectedButtonGroup/tokens.ts b/src/components/ConnectedButtonGroup/tokens.ts index 7cc416ee71..e048e95b98 100644 --- a/src/components/ConnectedButtonGroup/tokens.ts +++ b/src/components/ConnectedButtonGroup/tokens.ts @@ -72,7 +72,7 @@ export const connectedButtonSizeTokens: Record< betweenSpace: 2, outerShape: 'full', innerShape: 'extraSmall', - pressedShape: 'small', + pressedShape: 'extraSmall', iconSize: 20, leadingSpace: 12, trailingSpace: 12, @@ -85,7 +85,7 @@ export const connectedButtonSizeTokens: Record< betweenSpace: 2, outerShape: 'full', innerShape: 'small', - pressedShape: 'medium', + pressedShape: 'extraSmall', iconSize: 20, leadingSpace: 16, trailingSpace: 16, @@ -98,7 +98,7 @@ export const connectedButtonSizeTokens: Record< betweenSpace: 2, outerShape: 'full', innerShape: 'small', - pressedShape: 'medium', + pressedShape: 'extraSmall', iconSize: 24, leadingSpace: 24, trailingSpace: 24, @@ -110,8 +110,8 @@ export const connectedButtonSizeTokens: Record< containerHeight: 96, betweenSpace: 2, outerShape: 'full', - innerShape: 'medium', - pressedShape: 'largeIncreased', + innerShape: 'large', + pressedShape: 'medium', iconSize: 32, leadingSpace: 48, trailingSpace: 48, @@ -123,8 +123,8 @@ export const connectedButtonSizeTokens: Record< containerHeight: 136, betweenSpace: 2, outerShape: 'full', - innerShape: 'large', - pressedShape: 'largeIncreased', + innerShape: 'largeIncreased', + pressedShape: 'large', iconSize: 40, leadingSpace: 64, trailingSpace: 64, diff --git a/src/components/__tests__/ConnectedButtonGroup.test.tsx b/src/components/__tests__/ConnectedButtonGroup.test.tsx index dc45d1076e..0aac8f04ab 100644 --- a/src/components/__tests__/ConnectedButtonGroup.test.tsx +++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx @@ -5,10 +5,12 @@ 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, getConnectedButtonHitSlop, getConnectedButtonPosition, + getConnectedButtonSizeStyle, } from '../ConnectedButtonGroup/utils'; const theme = getTheme(); @@ -167,6 +169,45 @@ it('applies a custom checked color to the selected label', async () => { }); }); +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'); From 4fce0ef1fa8b80337e5c022ef62c53dfe0df34f7 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 12:56:44 +0200 Subject: [PATCH 6/7] refactor(connected-button-group): reuse shared theme utilities - resolve corner shapes through the shared ShapeToken/resolveCornerRadius from theme/utils/shape instead of a local re-implementation. - drop the per-button rippleColor prop and runtime ripple color derivation; TouchableRipple's stateLayerPressed default applies, consistent with the removal of customRippleColor across components. - expose background and hitSlop passthroughs per button, mirroring SegmentedButtons, and remove the ineffective automatic touch-target expansion. - drive the corner morph with the theme's fast spatial spring (toRawSpring), matching Switch and FAB, instead of timing curves. - add radiogroup semantics to the single-select row and collision-proof list keys. - honour intrinsic button widths in content-sized parents (flexBasis auto). - export ConnectedButtonGroupSize, register a docs screenshot, keep the docs config alphabetical, and extend tests with render snapshots (23). --- docs/component-docs.config.ts | 6 +- .../screenshots/connected-button-group.png | Bin 0 -> 47199 bytes docs/src/data/screenshots.ts | 1 + .../ConnectedButtonGroup/ConnectedButton.tsx | 71 +- .../ConnectedButtonGroup.tsx | 27 +- src/components/ConnectedButtonGroup/index.ts | 1 + src/components/ConnectedButtonGroup/tokens.ts | 17 +- src/components/ConnectedButtonGroup/utils.ts | 70 +- .../__tests__/ConnectedButtonGroup.test.tsx | 52 +- .../ConnectedButtonGroup.test.tsx.snap | 1500 +++++++++++++++++ src/index.tsx | 1 + 11 files changed, 1584 insertions(+), 162 deletions(-) create mode 100644 docs/public/screenshots/connected-button-group.png create mode 100644 src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 256a41539d..d5ea917743 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -52,9 +52,6 @@ const pages = { CardCover: 'Card/CardCover', CardTitle: 'Card/CardTitle', }, - ConnectedButtonGroup: { - ConnectedButtonGroup: 'ConnectedButtonGroup/ConnectedButtonGroup', - }, Checkbox: { Checkbox: 'Checkbox/Checkbox', CheckboxItem: 'Checkbox/CheckboxItem', @@ -62,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 0000000000000000000000000000000000000000..2239c4ceeff47b59aa1b25e934be4241e9986d2c GIT binary patch literal 47199 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 literal 0 HcmV?d00001 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/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx index 4b3d505f3c..d6c119f3ad 100644 --- a/src/components/ConnectedButtonGroup/ConnectedButton.tsx +++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx @@ -9,23 +9,21 @@ import type { } from 'react-native'; import Animated, { - Easing, ReduceMotion, useAnimatedStyle, useSharedValue, - withTiming, + withSpring, } from 'react-native-reanimated'; import type { ConnectedButtonGroupSize } from './tokens'; import { getConnectedButtonColors, - getConnectedButtonHitSlop, - getConnectedButtonRippleColor, 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, { @@ -77,10 +75,6 @@ export type Props = { * Custom color for the unselected label and icon. */ uncheckedColor?: string; - /** - * Custom ripple color. - */ - rippleColor?: string; /** * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig @@ -133,7 +127,6 @@ const ConnectedButton = ({ showSelectedCheck, checkedColor, uncheckedColor, - rippleColor: customRippleColor, background, 'aria-label': ariaLabel, onPress, @@ -161,51 +154,18 @@ const ConnectedButton = ({ }), [theme, checked, disabled, checkedColor, uncheckedColor] ); - const rippleColor = React.useMemo( - () => - getConnectedButtonRippleColor({ - contentColor: colors.contentColor, - customRippleColor, - }), - [colors.contentColor, customRippleColor] - ); - const resolvedHitSlop = React.useMemo( - () => getConnectedButtonHitSlop({ size, hitSlop }), - [size, hitSlop] - ); const { outerRadius, innerRadius, pressedRadius } = sizeStyle; const restRadius = checked ? outerRadius : innerRadius; const cornerRadius = useSharedValue(restRadius); const reduceMotion = useReduceMotion(); - const reanimatedReduceMotion = reduceMotion - ? ReduceMotion.Always - : ReduceMotion.Never; - - const pressTimingConfig = React.useMemo( - () => ({ - duration: theme.motion.duration.short4, - easing: Easing.bezier(...theme.motion.easing.standard), - reduceMotion: reanimatedReduceMotion, - }), - [ - theme.motion.duration.short4, - theme.motion.easing.standard, - reanimatedReduceMotion, - ] - ); - const releaseTimingConfig = React.useMemo( + const springConfig = React.useMemo( () => ({ - duration: theme.motion.duration.short3, - easing: Easing.bezier(...theme.motion.easing.standard), - reduceMotion: reanimatedReduceMotion, + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, }), - [ - theme.motion.duration.short3, - theme.motion.easing.standard, - reanimatedReduceMotion, - ] + [theme.motion.spring.fast.spatial, reduceMotion] ); const isFirstRender = React.useRef(true); @@ -217,17 +177,17 @@ const ConnectedButton = ({ isFirstRender.current = false; return; } - cornerRadius.value = withTiming(restRadius, releaseTimingConfig); - }, [restRadius, cornerRadius, releaseTimingConfig]); + 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 = withTiming(pressedRadius, pressTimingConfig); - }, [cornerRadius, pressedRadius, pressTimingConfig]); + cornerRadius.value = withSpring(pressedRadius, springConfig); + }, [cornerRadius, pressedRadius, springConfig]); const handlePressOut = React.useCallback(() => { - cornerRadius.value = withTiming(restRadius, releaseTimingConfig); - }, [cornerRadius, restRadius, releaseTimingConfig]); + 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. @@ -292,13 +252,12 @@ const ConnectedButton = ({ onPressIn={handlePressIn} onPressOut={handlePressOut} disabled={disabled} - rippleColor={rippleColor} background={background} aria-label={ariaLabel} aria-disabled={disabled} aria-checked={checked} role={multiSelect ? 'checkbox' : 'radio'} - hitSlop={resolvedHitSlop} + hitSlop={hitSlop} style={styles.ripple} theme={theme} testID={testID} @@ -353,7 +312,9 @@ const ConnectedButton = ({ const styles = StyleSheet.create({ container: { - flex: 1, + flexGrow: 1, + flexShrink: 1, + flexBasis: 'auto', overflow: 'hidden', }, ripple: { diff --git a/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx index 364914d87f..690e001162 100644 --- a/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx +++ b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx @@ -1,6 +1,7 @@ import { StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, + PressableAndroidRippleConfig, StyleProp, TextStyle, ViewStyle, @@ -15,6 +16,7 @@ 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 = | { @@ -79,14 +81,20 @@ export type ConnectedButtonConfig = { * Custom color for the unselected label and icon. */ uncheckedColor?: string; - /** - * Custom ripple color for the button. - */ - rippleColor?: 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`. @@ -121,7 +129,6 @@ export type Props = { * - `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 - * - `rippleColor`: custom ripple color for the button * - `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 @@ -199,6 +206,7 @@ const ConnectedButtonGroup = ({ return ( @@ -225,7 +233,7 @@ const ConnectedButtonGroup = ({ return ( ({ showSelectedCheck={item.showSelectedCheck} checkedColor={item.checkedColor} uncheckedColor={item.uncheckedColor} - rippleColor={item.rippleColor} + background={item.background} + hitSlop={item.hitSlop} aria-label={item['aria-label']} onPress={handlePress} labelMaxFontSizeMultiplier={item.labelMaxFontSizeMultiplier} @@ -254,10 +263,6 @@ const ConnectedButtonGroup = ({ const styles = StyleSheet.create({ row: { flexDirection: 'row', - // Guarantees a 48dp interactive band so the shorter button sizes' hitSlop - // enlarges the touch target within the row's bounds (WCAG / MD3 target). - minHeight: 48, - alignItems: 'center', }, }); diff --git a/src/components/ConnectedButtonGroup/index.ts b/src/components/ConnectedButtonGroup/index.ts index 64fd0f3e53..40c0eaf058 100644 --- a/src/components/ConnectedButtonGroup/index.ts +++ b/src/components/ConnectedButtonGroup/index.ts @@ -1,2 +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 index e048e95b98..b00a3dcc31 100644 --- a/src/components/ConnectedButtonGroup/tokens.ts +++ b/src/components/ConnectedButtonGroup/tokens.ts @@ -1,4 +1,5 @@ -import type { ThemeShapeCorners, TypescaleKey } from '../../theme/types'; +import type { TypescaleKey } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; export type ConnectedButtonGroupSize = | 'extra-small' @@ -7,8 +8,6 @@ export type ConnectedButtonGroupSize = | 'large' | 'extra-large'; -export type ConnectedButtonShapeKey = keyof ThemeShapeCorners | 'full'; - export type ConnectedButtonSizeTokens = { /** * Height of every button in the group. @@ -23,15 +22,15 @@ export type ConnectedButtonSizeTokens = { * the first button, trailing corners of the last button) and to any * selected button. */ - outerShape: ConnectedButtonShapeKey; + outerShape: ShapeToken; /** * Corner shape applied to the connected (inner) edges of unselected buttons. */ - innerShape: ConnectedButtonShapeKey; + innerShape: ShapeToken; /** * Corner shape the connected edges morph to while a button is pressed. */ - pressedShape: ConnectedButtonShapeKey; + pressedShape: ShapeToken; /** * Icon size for both the leading icon and the selected-state check icon. */ @@ -133,9 +132,3 @@ export const connectedButtonSizeTokens: Record< labelVariant: 'headlineLarge', }, }; - -/** - * Minimum interactive size guaranteed via `hitSlop` for the smaller button - * sizes, per WCAG / MD3 touch-target guidance. - */ -export const connectedButtonMinInteractiveSize = 48; diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts index e40a8ae8c6..190effba31 100644 --- a/src/components/ConnectedButtonGroup/utils.ts +++ b/src/components/ConnectedButtonGroup/utils.ts @@ -1,17 +1,12 @@ -import type { ColorValue, Insets } from 'react-native'; - -import color from 'color'; +import type { ColorValue } from 'react-native'; import { - connectedButtonMinInteractiveSize, connectedButtonSizeTokens, type ConnectedButtonGroupSize, - type ConnectedButtonShapeKey, } from './tokens'; import { tokens } from '../../theme/tokens'; -import { cornerFull } from '../../theme/tokens/sys/shape'; +import { resolveCornerRadius } from '../../theme/utils/shape'; import type { InternalTheme } from '../../types'; -import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; const stateOpacity = tokens.md.sys.state.opacity; @@ -43,11 +38,6 @@ export const getConnectedButtonPosition = ( return 'middle'; }; -export const resolveConnectedButtonCorner = ( - theme: InternalTheme, - key: ConnectedButtonShapeKey -): number => (key === 'full' ? cornerFull : theme.shapes.corner[key]); - export const getConnectedButtonSizeStyle = ({ size, theme, @@ -59,9 +49,9 @@ export const getConnectedButtonSizeStyle = ({ return { ...sizeTokens, - outerRadius: resolveConnectedButtonCorner(theme, sizeTokens.outerShape), - innerRadius: resolveConnectedButtonCorner(theme, sizeTokens.innerShape), - pressedRadius: resolveConnectedButtonCorner(theme, sizeTokens.pressedShape), + outerRadius: resolveCornerRadius(theme, sizeTokens.outerShape), + innerRadius: resolveCornerRadius(theme, sizeTokens.innerShape), + pressedRadius: resolveCornerRadius(theme, sizeTokens.pressedShape), }; }; @@ -139,53 +129,3 @@ export const getConnectedButtonColors = ({ return { containerColor, containerOpacity, contentColor, contentOpacity }; }; - -export const getConnectedButtonRippleColor = ({ - contentColor, - customRippleColor, -}: { - contentColor: ColorValue; - customRippleColor?: ColorValue; -}): ColorValue | undefined => { - if (customRippleColor) { - return customRippleColor; - } - if (typeof contentColor !== 'string') { - return undefined; - } - return color(contentColor).alpha(stateOpacity.pressed).rgb().string(); -}; - -/** - * Expands the touch target of shorter buttons to the minimum interactive size - * (48dp) without changing their visual height. - */ -export const getConnectedButtonHitSlop = ({ - size, - hitSlop, -}: { - size: ConnectedButtonGroupSize; - hitSlop?: TouchableRippleProps['hitSlop']; -}): TouchableRippleProps['hitSlop'] => { - if (typeof hitSlop === 'number') { - return hitSlop; - } - - const height = connectedButtonSizeTokens[size].containerHeight; - const verticalSlop = Math.max( - 0, - (connectedButtonMinInteractiveSize - height) / 2 - ); - - if (verticalSlop === 0) { - return hitSlop; - } - - const insetHitSlop = (hitSlop || {}) as Insets; - - return { - ...insetHitSlop, - top: insetHitSlop.top ?? verticalSlop, - bottom: insetHitSlop.bottom ?? verticalSlop, - }; -}; diff --git a/src/components/__tests__/ConnectedButtonGroup.test.tsx b/src/components/__tests__/ConnectedButtonGroup.test.tsx index 0aac8f04ab..13e6098422 100644 --- a/src/components/__tests__/ConnectedButtonGroup.test.tsx +++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx @@ -8,7 +8,6 @@ import ConnectedButtonGroup from '../ConnectedButtonGroup/ConnectedButtonGroup'; import { connectedButtonSizeTokens } from '../ConnectedButtonGroup/tokens'; import { getConnectedButtonColors, - getConnectedButtonHitSlop, getConnectedButtonPosition, getConnectedButtonSizeStyle, } from '../ConnectedButtonGroup/utils'; @@ -259,21 +258,42 @@ describe('getConnectedButtonColors', () => { }); }); -describe('getConnectedButtonHitSlop', () => { - it('expands short buttons to the minimum interactive size', () => { - expect(getConnectedButtonHitSlop({ size: 'extra-small' })).toMatchObject({ - top: 8, - bottom: 8, - }); - }); +it('renders connected button group', async () => { + const tree = (await renderGroup()).toJSON(); - it('leaves tall buttons untouched', () => { - expect(getConnectedButtonHitSlop({ size: 'medium' })).toBeUndefined(); - }); + expect(tree).toMatchSnapshot(); +}); - it('respects a numeric hitSlop override', () => { - expect(getConnectedButtonHitSlop({ size: 'extra-small', hitSlop: 4 })).toBe( - 4 - ); - }); +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 18cf0054ac..b528c64cfd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -82,6 +82,7 @@ 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'; From 91ed2b58291db97e9d3f3e3c852a91fe13821da5 Mon Sep 17 00:00:00 2001 From: matkoson Date: Tue, 14 Jul 2026 13:08:05 +0200 Subject: [PATCH 7/7] docs(connected-button-group): add docs page and fix deprecation link The docs build requires a committed page under 5.x/docs/components for every entry in component-docs.config; add the ConnectedButtonGroup page (usage, props, theme colors) mirroring SegmentedButtons, regenerate the 6.x content, and point the SegmentedButtons deprecation notice at the correct relative path so the link resolves. --- .../ConnectedButtonGroup.mdx | 122 ++++++++++ .../ConnectedButtonGroup/_meta.json | 3 + docs/5.x/docs/components/_meta.json | 7 + .../ConnectedButtonGroup.mdx | 112 +++++++++ .../ConnectedButtonGroup/_meta.json | 3 + .../SegmentedButtons/SegmentedButtons.mdx | 5 + docs/6.x/docs/components/_meta.json | 7 + docs/src/data/componentDocs6x.json | 212 +++++++++++++++++- .../SegmentedButtons/SegmentedButtons.tsx | 2 +- 9 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx create mode 100644 docs/5.x/docs/components/ConnectedButtonGroup/_meta.json create mode 100644 docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx create mode 100644 docs/6.x/docs/components/ConnectedButtonGroup/_meta.json 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/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/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index 343e51ef6b..96b12c8400 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -83,7 +83,7 @@ export type Props = { /** * @deprecated Segmented buttons are deprecated in the Material Design 3 spec * and replaced by the Connected Button Group. Use - * [`ConnectedButtonGroup`](./ConnectedButtonGroup) instead — it exposes the + * [`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.