From a4865f28459ea753ec0f215c2a82726ce45b127e Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Fri, 21 Aug 2026 11:59:15 +0200 Subject: [PATCH 01/16] refactor: get segmented buttons up to ui spec --- .../SegmentedButtons/SegmentedButtonItem.tsx | 259 ++++++++++++---- .../SegmentedButtons/SegmentedButtons.tsx | 13 +- src/components/SegmentedButtons/tokens.ts | 32 ++ src/components/SegmentedButtons/utils.ts | 91 ++++-- .../__tests__/SegmentedButton.test.tsx | 112 +++++-- .../SegmentedButton.test.tsx.snap | 288 +++++++++++------- 6 files changed, 561 insertions(+), 234 deletions(-) create mode 100644 src/components/SegmentedButtons/tokens.ts diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4f701f0b48..4eb9ea6f46 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,26 +1,36 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { Animated, Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, + NativeSyntheticEvent, PressableAndroidRippleConfig, StyleProp, + TargetedEvent, TextStyle, ViewStyle, } from 'react-native'; +import { SegmentedButtonTokens } from './tokens'; import { getSegmentedButtonBorderRadius, getSegmentedButtonColors, - getSegmentedButtonDensityPadding, + getSegmentedButtonHeight, + getSegmentedButtonOutlineStyle, } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; import Text from '../Typography/Text'; +const stateTokens = tokens.md.sys.state; +const FOCUS_RING_INSET = + stateTokens.focusIndicator.thickness + stateTokens.focusIndicator.outerOffset; + export type Props = { /** * Whether the segmented button is checked @@ -122,6 +132,9 @@ const SegmentedButtonItem = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); + const [pressed, setPressed] = React.useState(false); + const [hovered, setHovered] = React.useState(false); + const [focused, setFocused] = React.useState(false); const checkScale = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { @@ -141,26 +154,32 @@ const SegmentedButtonItem = ({ } }, [checked, checkScale, showSelectedCheck]); - const { borderColor, textColor, textOpacity, borderWidth, backgroundColor } = - getSegmentedButtonColors({ - checked, - theme, - disabled, - checkedColor, - uncheckedColor, - }); + const { + borderColor, + borderOpacity, + textColor, + textOpacity, + backgroundColor, + stateLayerColor, + focusIndicatorColor, + } = getSegmentedButtonColors({ + checked, + theme, + disabled, + checkedColor, + uncheckedColor, + }); - const borderRadius = theme.shapes.corner.largeIncreased; const segmentBorderRadius = getSegmentedButtonBorderRadius({ theme, segment, }); + const outlineStyle = getSegmentedButtonOutlineStyle(segment); + const visualHeight = getSegmentedButtonHeight(density); const showIcon = !icon ? false : label && checked ? !showSelectedCheck : true; const showCheckedIcon = checked && showSelectedCheck; - const iconSize = 18; - const iconStyle = { - marginRight: label ? 5 : showCheckedIcon ? 3 : 0, + const optionIconStyle = { ...(label && { transform: [ { @@ -173,67 +192,149 @@ const SegmentedButtonItem = ({ }), }; - const buttonStyle: ViewStyle = { - backgroundColor, - borderColor, - borderWidth, - borderRadius, - ...segmentBorderRadius, - }; - const paddingVertical = getSegmentedButtonDensityPadding({ density }); - const rippleStyle: ViewStyle = { - borderRadius, - ...segmentBorderRadius, - }; const labelTextStyle: TextStyle = { ...theme.fonts.labelLarge, color: textColor, }; + const stateLayerOpacity = disabled + ? 0 + : pressed + ? stateTokens.opacity.pressed + : focused + ? stateTokens.opacity.focused + : hovered + ? stateTokens.opacity.hovered + : 0; + const focusRingVerticalInset = + (SegmentedButtonTokens.touchTargetHeight - visualHeight) / 2 - + FOCUS_RING_INSET; + + const handleFocus = (event: NativeSyntheticEvent) => { + if (!disabled && isKeyboardFocusEvent(event)) { + setFocused(true); + } + }; + + const handleBlur = () => { + setPressed(false); + setFocused(false); + }; return ( - + setPressed(true)} + onPressOut={() => setPressed(false)} + onHoverIn={() => setHovered(true)} + onHoverOut={() => setHovered(false)} + onFocus={handleFocus} + onBlur={handleBlur} aria-label={ariaLabel} aria-disabled={disabled} aria-checked={checked} role="button" disabled={disabled} testID={testID} - style={rippleStyle} + style={[ + styles.touchable, + segmentBorderRadius, + Platform.OS === 'web' ? webNoOutline : undefined, + ]} background={background} + rippleColor="transparent" + underlayColor="transparent" theme={theme} hitSlop={hitSlop} > - {showCheckedIcon ? ( - - - - ) : null} - {showIcon ? ( - - - - ) : null} - - {label} - + + + {showCheckedIcon ? ( + + + + ) : null} + {showIcon ? ( + + + + ) : null} + {label ? ( + + {label} + + ) : null} + + + {focused && !disabled ? ( + + ) : null} ); }; @@ -241,21 +342,67 @@ const SegmentedButtonItem = ({ const styles = StyleSheet.create({ button: { flex: 1, - minWidth: 76, - borderStyle: 'solid', + minWidth: SegmentedButtonTokens.minimumWidth, + minHeight: SegmentedButtonTokens.touchTargetHeight, + justifyContent: 'center', + overflow: 'visible', + }, + focusedButton: { + zIndex: 1, + }, + touchable: { + minHeight: SegmentedButtonTokens.touchTargetHeight, + justifyContent: 'center', + }, + visual: { + width: '100%', + justifyContent: 'center', + overflow: 'hidden', + }, + stateLayer: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, }, label: { + flexShrink: 1, textAlign: 'center', }, content: { + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 9, - paddingHorizontal: 16, + paddingHorizontal: SegmentedButtonTokens.horizontalPadding, + columnGap: SegmentedButtonTokens.iconLabelGap, + }, + icon: { + width: SegmentedButtonTokens.iconSize, + height: SegmentedButtonTokens.iconSize, + alignItems: 'center', + justifyContent: 'center', + }, + outline: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + pointerEvents: 'none', + }, + focusRing: { + position: 'absolute', + left: -FOCUS_RING_INSET, + right: -FOCUS_RING_INSET, + borderWidth: stateTokens.focusIndicator.thickness, + pointerEvents: 'none', }, }); +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + export default SegmentedButtonItem; export { SegmentedButtonItem as SegmentedButton }; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index c5fceeaeae..4b2162ef6e 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -7,7 +7,7 @@ import type { } from 'react-native'; import SegmentedButtonItem from './SegmentedButtonItem'; -import { getDisabledSegmentedButtonStyle } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; @@ -133,15 +133,11 @@ const SegmentedButtons = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); return ( - + {buttons.map((item, i) => { - const disabledChildStyle = getDisabledSegmentedButtonStyle({ - theme, - buttons, - index: i, - }); const segment = i === 0 ? 'first' : i === buttons.length - 1 ? 'last' : undefined; @@ -172,7 +168,7 @@ const SegmentedButtons = ({ segment={segment} density={density} onPress={onPress} - style={[item.style, disabledChildStyle]} + style={item.style} labelStyle={item.labelStyle} theme={theme} /> @@ -185,6 +181,7 @@ const SegmentedButtons = ({ const styles = StyleSheet.create({ row: { flexDirection: 'row', + overflow: 'visible', }, }); diff --git a/src/components/SegmentedButtons/tokens.ts b/src/components/SegmentedButtons/tokens.ts new file mode 100644 index 0000000000..7194bf5bca --- /dev/null +++ b/src/components/SegmentedButtons/tokens.ts @@ -0,0 +1,32 @@ +import type { ColorRole } from '../../theme/types'; + +const sizes = { + containerHeight: { + regular: 40, + small: 36, + medium: 32, + high: 28, + } as const satisfies Record<'regular' | 'small' | 'medium' | 'high', number>, + touchTargetHeight: 48, + minimumWidth: 48, + horizontalPadding: 12, + iconSize: 18, + iconLabelGap: 8, + outlineWidth: 1, + disabledContentOpacity: 0.38, + disabledOutlineOpacity: 0.12, +} as const; + +const colors = { + selectedContainerColor: 'secondaryContainer', + selectedContentColor: 'onSecondaryContainer', + unselectedContentColor: 'onSurface', + outlineColor: 'outline', + disabledContentColor: 'onSurface', + disabledOutlineColor: 'onSurface', + selectedStateLayerColor: 'onSecondaryContainer', + unselectedStateLayerColor: 'onSurface', + focusIndicatorColor: 'secondary', +} as const satisfies Record; + +export const SegmentedButtonTokens = { ...sizes, ...colors }; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 1c74f5c49f..63a3d0acaa 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -1,6 +1,8 @@ import type { ViewStyle } from 'react-native'; +import { SegmentedButtonTokens } from './tokens'; import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import type { InternalTheme } from '../../types'; const stateOpacity = tokens.md.sys.state.opacity; @@ -16,25 +18,21 @@ type SegmentedButtonProps = { uncheckedColor?: string; } & BaseProps; -const DEFAULT_PADDING = 9; +export const getSegmentedButtonHeight = ( + density: 'regular' | 'small' | 'medium' | 'high' = 'regular' +) => SegmentedButtonTokens.containerHeight[density]; export const getSegmentedButtonDensityPadding = ({ density, }: { density?: 'regular' | 'small' | 'medium' | 'high'; }) => { - let padding = DEFAULT_PADDING; - - switch (density) { - case 'small': - return padding - 2; - case 'medium': - return padding - 4; - case 'high': - return padding - 8; - default: - return padding; - } + return ( + (getSegmentedButtonHeight(density) - + tokens.md.sys.typescale.labelLarge.lineHeight - + SegmentedButtonTokens.outlineWidth * 2) / + 2 + ); }; export const getDisabledSegmentedButtonStyle = ({ @@ -66,41 +64,52 @@ export const getSegmentedButtonBorderRadius = ({ }): ViewStyle => { if (segment === 'first') { return { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderEndWidth: 0, + borderTopStartRadius: cornerFull, + borderBottomStartRadius: cornerFull, + borderTopEndRadius: 0, + borderBottomEndRadius: 0, }; } else if (segment === 'last') { return { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, + borderTopStartRadius: 0, + borderBottomStartRadius: 0, + borderTopEndRadius: cornerFull, + borderBottomEndRadius: cornerFull, }; } else { return { borderRadius: 0, - borderEndWidth: 0, }; } }; +export const getSegmentedButtonOutlineStyle = ( + segment?: 'first' | 'last' +): ViewStyle => ({ + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: segment === 'last' ? SegmentedButtonTokens.outlineWidth : 0, +}); + const getSegmentedButtonBackgroundColor = ({ checked, theme }: BaseProps) => { if (checked) { - return theme.colors.secondaryContainer; + return theme.colors[SegmentedButtonTokens.selectedContainerColor]; } return 'transparent'; }; const getSegmentedButtonBorderColor = ({ theme, disabled }: BaseProps) => { if (disabled) { - return theme.colors.outlineVariant; + return theme.colors[SegmentedButtonTokens.disabledOutlineColor]; } - return theme.colors.outline; + return theme.colors[SegmentedButtonTokens.outlineColor]; }; const getSegmentedButtonBorderWidth = ({ theme: _t, }: Omit) => { - return 1; + return SegmentedButtonTokens.outlineWidth; }; const getSegmentedButtonTextColor = ({ @@ -111,12 +120,16 @@ const getSegmentedButtonTextColor = ({ uncheckedColor, }: SegmentedButtonProps) => { if (disabled) { - return theme.colors.onSurface; + return theme.colors[SegmentedButtonTokens.disabledContentColor]; } if (checked) { - return checkedColor ?? theme.colors.onSecondaryContainer; + return ( + checkedColor ?? theme.colors[SegmentedButtonTokens.selectedContentColor] + ); } - return uncheckedColor ?? theme.colors.onSurface; + return ( + uncheckedColor ?? theme.colors[SegmentedButtonTokens.unselectedContentColor] + ); }; export const getSegmentedButtonColors = ({ @@ -143,8 +156,26 @@ export const getSegmentedButtonColors = ({ uncheckedColor, }); const borderWidth = getSegmentedButtonBorderWidth({ theme }); - - const textOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; - - return { backgroundColor, borderColor, textColor, textOpacity, borderWidth }; + const borderOpacity = disabled + ? SegmentedButtonTokens.disabledOutlineOpacity + : stateOpacity.enabled; + const textOpacity = disabled + ? SegmentedButtonTokens.disabledContentOpacity + : stateOpacity.enabled; + const stateLayerColor = checked + ? theme.colors[SegmentedButtonTokens.selectedStateLayerColor] + : theme.colors[SegmentedButtonTokens.unselectedStateLayerColor]; + const focusIndicatorColor = + theme.colors[SegmentedButtonTokens.focusIndicatorColor]; + + return { + backgroundColor, + borderColor, + borderOpacity, + textColor, + textOpacity, + borderWidth, + stateLayerColor, + focusIndicatorColor, + }; }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index ce950f671f..b36ed3241d 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1,12 +1,14 @@ import { describe, expect, it, jest } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render, screen } from '../../test-utils'; +import { fireEvent, render, screen } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; +import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; import { getDisabledSegmentedButtonStyle, getSegmentedButtonColors, + getSegmentedButtonHeight, } from '../SegmentedButtons/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -26,38 +28,36 @@ it('renders segmented button', async () => { }); it('renders disabled segmented button', async () => { - const tree = ( - await render( - {}} - value={'walk'} - buttons={[{ value: 'walk' }, { value: 'ride', disabled: true }]} - /> - ) - ).toJSON(); + await render( + {}} + value="walk" + buttons={[ + { value: 'walk' }, + { value: 'ride', disabled: true, testID: 'ride' }, + ]} + /> + ); - process.nextTick(() => { - expect(tree).toMatchSnapshot(); + expect(screen.getByTestId('ride-outline')).toHaveStyle({ + borderColor: getTheme().colors.onSurface, + opacity: SegmentedButtonTokens.disabledOutlineOpacity, }); }); it('renders checked segmented button with selected check', async () => { - const tree = ( - await render( - {}} - value={'walk'} - buttons={[ - { value: 'walk', showSelectedCheck: true }, - { value: 'ride', disabled: true }, - ]} - /> - ) - ).toJSON(); + await render( + {}} + value="walk" + buttons={[ + { value: 'walk', showSelectedCheck: true, testID: 'walk' }, + { value: 'ride', disabled: true }, + ]} + /> + ); - process.nextTick(() => { - expect(tree).toMatchSnapshot(); - }); + expect(screen.getByTestId('walk-check-icon')).toBeOnTheScreen(); }); describe('getSegmentedButtonColors', () => { @@ -184,7 +184,8 @@ describe('getSegmentedButtonColors', () => { checked: false, }) ).toMatchObject({ - borderColor: getTheme().colors.outlineVariant, + borderColor: getTheme().colors.onSurface, + borderOpacity: SegmentedButtonTokens.disabledOutlineOpacity, }); }); @@ -214,6 +215,61 @@ describe('getSegmentedButtonColors', () => { }); }); +describe('segmented button presentation', () => { + it.each([ + { density: 'regular' as const, expected: 40 }, + { density: 'small' as const, expected: 36 }, + { density: 'medium' as const, expected: 32 }, + { density: 'high' as const, expected: 28 }, + ])('uses the $density density height', ({ density, expected }) => { + expect(getSegmentedButtonHeight(density)).toBe(expected); + }); + + it('keeps a 48dp target around the visual container', async () => { + await render( + {}} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + expect(screen.getByTestId('walk')).toHaveStyle({ + minHeight: SegmentedButtonTokens.touchTargetHeight, + }); + expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 40 }); + }); + + it('renders token opacity for hover and keyboard focus states', async () => { + await render( + {}} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + const button = screen.getByTestId('walk'); + const stateLayer = screen.getByTestId('walk-state-layer'); + + await fireEvent(button, 'hoverIn'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); + + await fireEvent(button, 'focus'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); + expect(screen.getByTestId('walk-focus-ring')).toHaveStyle({ + borderWidth: tokens.md.sys.state.focusIndicator.thickness, + borderColor: getTheme().colors.secondary, + }); + }); +}); + describe('getDisabledSegmentedButtonBorderWidth', () => { it('Returns empty style object for all enabled buttons', () => { [0, 1, 2].forEach((index) => { diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 4de6f40b6f..deae7b039b 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -6,6 +6,10 @@ exports[`renders segmented button 1`] = ` [ { "flexDirection": "row", + "overflow": "visible", + }, + { + "direction": "ltr", }, undefined, ] @@ -15,23 +19,14 @@ exports[`renders segmented button 1`] = ` style={ [ { - "backgroundColor": "rgba(232, 222, 248, 1)", - "borderBottomRightRadius": 0, - "borderColor": "rgba(121, 116, 126, 1)", - "borderEndWidth": 0, - "borderRadius": 20, - "borderTopRightRadius": 0, - "borderWidth": 1, - }, - { - "borderStyle": "solid", "flex": 1, - "minWidth": 76, + "justifyContent": "center", + "minHeight": 48, + "minWidth": 48, + "overflow": "visible", }, - [ - undefined, - {}, - ], + false, + undefined, ] } > @@ -71,12 +66,19 @@ exports[`renders segmented button 1`] = ` { "overflow": "hidden", }, - { - "borderBottomRightRadius": 0, - "borderEndWidth": 0, - "borderRadius": 20, - "borderTopRightRadius": 0, - }, + [ + { + "justifyContent": "center", + "minHeight": 48, + }, + { + "borderBottomEndRadius": 0, + "borderBottomStartRadius": 9999, + "borderTopEndRadius": 0, + "borderTopStartRadius": 9999, + }, + undefined, + ], ] } > @@ -84,57 +86,88 @@ exports[`renders segmented button 1`] = ` style={ [ { - "alignItems": "center", - "flexDirection": "row", "justifyContent": "center", - "paddingHorizontal": 16, - "paddingVertical": 9, + "overflow": "hidden", + "width": "100%", }, { - "opacity": 1, - "paddingVertical": 9, + "borderBottomEndRadius": 0, + "borderBottomStartRadius": 9999, + "borderTopEndRadius": 0, + "borderTopStartRadius": 9999, + }, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + "height": 40, }, ] } > - + + @@ -143,22 +176,14 @@ exports[`renders segmented button 1`] = ` style={ [ { - "backgroundColor": "transparent", - "borderBottomLeftRadius": 0, - "borderColor": "rgba(121, 116, 126, 1)", - "borderRadius": 20, - "borderTopLeftRadius": 0, - "borderWidth": 1, - }, - { - "borderStyle": "solid", "flex": 1, - "minWidth": 76, + "justifyContent": "center", + "minHeight": 48, + "minWidth": 48, + "overflow": "visible", }, - [ - undefined, - {}, - ], + false, + undefined, ] } > @@ -198,11 +223,19 @@ exports[`renders segmented button 1`] = ` { "overflow": "hidden", }, - { - "borderBottomLeftRadius": 0, - "borderRadius": 20, - "borderTopLeftRadius": 0, - }, + [ + { + "justifyContent": "center", + "minHeight": 48, + }, + { + "borderBottomEndRadius": 9999, + "borderBottomStartRadius": 0, + "borderTopEndRadius": 9999, + "borderTopStartRadius": 0, + }, + undefined, + ], ] } > @@ -210,57 +243,88 @@ exports[`renders segmented button 1`] = ` style={ [ { - "alignItems": "center", - "flexDirection": "row", "justifyContent": "center", - "paddingHorizontal": 16, - "paddingVertical": 9, + "overflow": "hidden", + "width": "100%", + }, + { + "borderBottomEndRadius": 9999, + "borderBottomStartRadius": 0, + "borderTopEndRadius": 9999, + "borderTopStartRadius": 0, }, { - "opacity": 1, - "paddingVertical": 9, + "backgroundColor": "transparent", + "height": 40, }, ] } > - + + From dc12c8482f85b3d43f580dbd8f38713f7cc5cb24 Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Mon, 24 Aug 2026 13:50:51 +0200 Subject: [PATCH 02/16] refactor: extract SegmentedButtonContent --- .../SegmentedButtonContent.tsx | 146 +++++++++++ .../SegmentedButtons/SegmentedButtonItem.tsx | 237 +++++++----------- .../SegmentedButtons/SegmentedButtons.tsx | 35 ++- src/components/SegmentedButtons/utils.ts | 102 ++++---- .../__tests__/SegmentedButton.test.tsx | 151 ++++++++++- 5 files changed, 450 insertions(+), 221 deletions(-) create mode 100644 src/components/SegmentedButtons/SegmentedButtonContent.tsx diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx new file mode 100644 index 0000000000..094415718f --- /dev/null +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -0,0 +1,146 @@ +import { StyleSheet, View } from 'react-native'; +import type { StyleProp, TextStyle } from 'react-native'; + +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; + +import { SegmentedButtonTokens } from './tokens'; +import type { IconSource } from '../Icon'; +import Icon from '../Icon'; +import Text from '../Typography/Text'; + +type AnimatedIconProps = { + color: TextStyle['color']; + scale: SharedValue; + testID?: string; +}; + +const AnimatedCheckIcon = ({ color, scale, testID }: AnimatedIconProps) => { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + + + + ); +}; + +type AnimatedOptionIconProps = AnimatedIconProps & { + animated: boolean; + source: IconSource; +}; + +const AnimatedOptionIcon = ({ + animated, + color, + scale, + source, + testID, +}: AnimatedOptionIconProps) => { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - scale.value }], + })); + + return ( + + + + ); +}; + +type Props = { + checkmarkScale: SharedValue; + icon?: IconSource; + label?: string; + labelMaxFontSizeMultiplier?: number; + labelStyle?: StyleProp; + labelTextStyle: TextStyle; + shouldShowCheckIcon: boolean; + shouldShowOptionIcon: boolean; + testID?: string; + textColor: TextStyle['color']; + textOpacity: number; +}; + +const SegmentedButtonContent = ({ + checkmarkScale, + icon, + label, + labelMaxFontSizeMultiplier, + labelStyle, + labelTextStyle, + shouldShowCheckIcon, + shouldShowOptionIcon, + testID, + textColor, + textOpacity, +}: Props) => { + return ( + + {shouldShowCheckIcon ? ( + + ) : null} + {shouldShowOptionIcon ? ( + + ) : null} + {label ? ( + + {label} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + content: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: SegmentedButtonTokens.horizontalPadding, + columnGap: SegmentedButtonTokens.iconLabelGap, + }, + icon: { + width: SegmentedButtonTokens.iconSize, + height: SegmentedButtonTokens.iconSize, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + flexShrink: 1, + textAlign: 'center', + }, +}); + +export default SegmentedButtonContent; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4eb9ea6f46..b9ca780544 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, NativeSyntheticEvent, @@ -10,6 +10,9 @@ import type { ViewStyle, } from 'react-native'; +import { useSharedValue, withSpring } from 'react-native-reanimated'; + +import SegmentedButtonContent from './SegmentedButtonContent'; import { SegmentedButtonTokens } from './tokens'; import { getSegmentedButtonBorderRadius, @@ -17,19 +20,17 @@ import { getSegmentedButtonHeight, getSegmentedButtonOutlineStyle, } from './utils'; -import { useInternalTheme } from '../../core/theming'; import { tokens } from '../../theme/tokens'; -import type { ThemeProp } from '../../types'; +import type { Theme } from '../../types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import type { IconSource } from '../Icon'; -import Icon from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -import Text from '../Typography/Text'; -const stateTokens = tokens.md.sys.state; -const FOCUS_RING_INSET = - stateTokens.focusIndicator.thickness + stateTokens.focusIndicator.outerOffset; +const focusIndicatorTokens = tokens.md.sys.state.focusIndicator; +const stateOpacity = tokens.md.sys.state.opacity; +const FOCUS_RING_OUTSET = + focusIndicatorTokens.thickness + focusIndicatorTokens.outerOffset; export type Props = { /** @@ -105,9 +106,9 @@ export type Props = { */ hitSlop?: TouchableRippleProps['hitSlop']; /** - * @optional + * Resolved theme inherited from the segmented button group. */ - theme?: ThemeProp; + theme: Theme; }; const SegmentedButtonItem = ({ @@ -126,42 +127,31 @@ const SegmentedButtonItem = ({ onPress, segment, density = 'regular', - theme: themeOverrides, + theme, labelMaxFontSizeMultiplier, hitSlop, }: Props) => { - const theme = useInternalTheme(themeOverrides); - const [pressed, setPressed] = React.useState(false); const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); - const checkScale = React.useRef(new Animated.Value(0)).current; + const checkmarkScale = useSharedValue(0); React.useEffect(() => { if (!showSelectedCheck) { return; } - if (checked) { - Animated.spring(checkScale, { - toValue: 1, - useNativeDriver: true, - }).start(); - } else { - Animated.spring(checkScale, { - toValue: 0, - useNativeDriver: true, - }).start(); - } - }, [checked, checkScale, showSelectedCheck]); + + checkmarkScale.value = withSpring(checked ? 1 : 0); + }, [checked, checkmarkScale, showSelectedCheck]); const { + backgroundColor, borderColor, borderOpacity, + focusIndicatorColor, + stateLayerColor, textColor, textOpacity, - backgroundColor, - stateLayerColor, - focusIndicatorColor, } = getSegmentedButtonColors({ checked, theme, @@ -169,50 +159,67 @@ const SegmentedButtonItem = ({ checkedColor, uncheckedColor, }); - const segmentBorderRadius = getSegmentedButtonBorderRadius({ theme, segment, }); const outlineStyle = getSegmentedButtonOutlineStyle(segment); - const visualHeight = getSegmentedButtonHeight(density); - const showIcon = !icon ? false : label && checked ? !showSelectedCheck : true; - const showCheckedIcon = checked && showSelectedCheck; - - const optionIconStyle = { - ...(label && { - transform: [ - { - scale: checkScale.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0], - }), - }, - ], - }), - }; - + const containerHeight = getSegmentedButtonHeight(density); + const focusRingVerticalInset = + (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - + FOCUS_RING_OUTSET; const labelTextStyle: TextStyle = { ...theme.fonts.labelLarge, color: textColor, }; + const touchableStyle = [ + styles.touchable, + segmentBorderRadius, + Platform.OS === 'web' ? webNoOutline : undefined, + ]; + const visualStyle = [ + styles.visual, + segmentBorderRadius, + { height: containerHeight, backgroundColor }, + ]; + const outlineContainerStyle = [ + styles.outline, + segmentBorderRadius, + outlineStyle, + { borderColor, opacity: borderOpacity }, + ]; + const focusRingStyle = [ + styles.focusRing, + segmentBorderRadius, + { + top: focusRingVerticalInset, + bottom: focusRingVerticalInset, + borderColor: focusIndicatorColor, + }, + ]; + + const shouldShowCheckIcon = Boolean(checked && showSelectedCheck); + const shouldShowOptionIcon = Boolean( + icon && (!label || !shouldShowCheckIcon) + ); + const stateLayerOpacity = disabled ? 0 : pressed - ? stateTokens.opacity.pressed + ? stateOpacity.pressed : focused - ? stateTokens.opacity.focused + ? stateOpacity.focused : hovered - ? stateTokens.opacity.hovered + ? stateOpacity.hovered : 0; - const focusRingVerticalInset = - (SegmentedButtonTokens.touchTargetHeight - visualHeight) / 2 - - FOCUS_RING_INSET; + const showFocusRing = focused && !disabled; const handleFocus = (event: NativeSyntheticEvent) => { - if (!disabled && isKeyboardFocusEvent(event)) { - setFocused(true); + if (disabled || !isKeyboardFocusEvent(event)) { + return; } + + setFocused(true); }; const handleBlur = () => { @@ -221,13 +228,7 @@ const SegmentedButtonItem = ({ }; return ( - + - - {showCheckedIcon ? ( - - - - ) : null} - {showIcon ? ( - - - - ) : null} - {label ? ( - - {label} - - ) : null} - + - {focused && !disabled ? ( + {showFocusRing ? ( ) : null} @@ -366,24 +323,6 @@ const styles = StyleSheet.create({ bottom: 0, left: 0, }, - label: { - flexShrink: 1, - textAlign: 'center', - }, - content: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SegmentedButtonTokens.horizontalPadding, - columnGap: SegmentedButtonTokens.iconLabelGap, - }, - icon: { - width: SegmentedButtonTokens.iconSize, - height: SegmentedButtonTokens.iconSize, - alignItems: 'center', - justifyContent: 'center', - }, outline: { position: 'absolute', top: 0, @@ -394,9 +333,9 @@ const styles = StyleSheet.create({ }, focusRing: { position: 'absolute', - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - borderWidth: stateTokens.focusIndicator.thickness, + left: -FOCUS_RING_OUTSET, + right: -FOCUS_RING_OUTSET, + borderWidth: focusIndicatorTokens.thickness, pointerEvents: 'none', }, }); diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index 4b2162ef6e..b60b85f1db 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -135,26 +135,29 @@ const SegmentedButtons = ({ const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); + const selectedValues = + multiSelect && Array.isArray(value) ? value : undefined; + return ( - + {buttons.map((item, i) => { const segment = i === 0 ? 'first' : i === buttons.length - 1 ? 'last' : undefined; - const checked = - multiSelect && Array.isArray(value) - ? value.includes(item.value) - : value === item.value; + const checked = selectedValues + ? selectedValues.includes(item.value) + : value === item.value; - const onPress = (e: GestureResponderEvent) => { - item.onPress?.(e); + const onPress = (event: GestureResponderEvent) => { + item.onPress?.(event); - const nextValue = - multiSelect && Array.isArray(value) - ? checked - ? value.filter((val) => item.value !== val) - : [...value, item.value] - : item.value; + const nextValue = selectedValues + ? checked + ? selectedValues.filter((val) => item.value !== val) + : [...selectedValues, item.value] + : item.value; // @ts-expect-error: TS doesn't preserve types after destructuring, so the type isn't inferred correctly onValueChange(nextValue); @@ -183,6 +186,12 @@ const styles = StyleSheet.create({ flexDirection: 'row', overflow: 'visible', }, + ltr: { + direction: 'ltr', + }, + rtl: { + direction: 'rtl', + }, }); export default SegmentedButtons; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 63a3d0acaa..5feea42f63 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -36,7 +36,6 @@ export const getSegmentedButtonDensityPadding = ({ }; export const getDisabledSegmentedButtonStyle = ({ - theme, index, buttons, }: { @@ -44,13 +43,12 @@ export const getDisabledSegmentedButtonStyle = ({ buttons: { disabled?: boolean }[]; index: number; }): ViewStyle => { - const width = getSegmentedButtonBorderWidth({ theme }); const isDisabled = buttons[index]?.disabled; const isNextDisabled = buttons[index + 1]?.disabled; if (!isDisabled && isNextDisabled) { return { - borderRightWidth: width, + borderRightWidth: SegmentedButtonTokens.outlineWidth, }; } return {}; @@ -69,18 +67,20 @@ export const getSegmentedButtonBorderRadius = ({ borderTopEndRadius: 0, borderBottomEndRadius: 0, }; - } else if (segment === 'last') { + } + + if (segment === 'last') { return { borderTopStartRadius: 0, borderBottomStartRadius: 0, borderTopEndRadius: cornerFull, borderBottomEndRadius: cornerFull, }; - } else { - return { - borderRadius: 0, - }; } + + return { + borderRadius: 0, + }; }; export const getSegmentedButtonOutlineStyle = ( @@ -92,44 +92,34 @@ export const getSegmentedButtonOutlineStyle = ( borderEndWidth: segment === 'last' ? SegmentedButtonTokens.outlineWidth : 0, }); -const getSegmentedButtonBackgroundColor = ({ checked, theme }: BaseProps) => { - if (checked) { - return theme.colors[SegmentedButtonTokens.selectedContainerColor]; - } - return 'transparent'; -}; - -const getSegmentedButtonBorderColor = ({ theme, disabled }: BaseProps) => { +export const getSegmentedButtonStateLayerOpacity = ({ + disabled, + pressed, + focused, + hovered, +}: { + disabled?: boolean; + pressed: boolean; + focused: boolean; + hovered: boolean; +}) => { if (disabled) { - return theme.colors[SegmentedButtonTokens.disabledOutlineColor]; + return 0; } - return theme.colors[SegmentedButtonTokens.outlineColor]; -}; -const getSegmentedButtonBorderWidth = ({ - theme: _t, -}: Omit) => { - return SegmentedButtonTokens.outlineWidth; -}; + if (pressed) { + return stateOpacity.pressed; + } -const getSegmentedButtonTextColor = ({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, -}: SegmentedButtonProps) => { - if (disabled) { - return theme.colors[SegmentedButtonTokens.disabledContentColor]; + if (focused) { + return stateOpacity.focused; } - if (checked) { - return ( - checkedColor ?? theme.colors[SegmentedButtonTokens.selectedContentColor] - ); + + if (hovered) { + return stateOpacity.hovered; } - return ( - uncheckedColor ?? theme.colors[SegmentedButtonTokens.unselectedContentColor] - ); + + return 0; }; export const getSegmentedButtonColors = ({ @@ -139,23 +129,19 @@ export const getSegmentedButtonColors = ({ checkedColor, uncheckedColor, }: SegmentedButtonProps) => { - const backgroundColor = getSegmentedButtonBackgroundColor({ - theme, - checked, - }); - const borderColor = getSegmentedButtonBorderColor({ - theme, - disabled, - checked, - }); - const textColor = getSegmentedButtonTextColor({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, - }); - const borderWidth = getSegmentedButtonBorderWidth({ theme }); + const backgroundColor = checked + ? theme.colors[SegmentedButtonTokens.selectedContainerColor] + : 'transparent'; + const borderColor = disabled + ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] + : theme.colors[SegmentedButtonTokens.outlineColor]; + const textColor = disabled + ? theme.colors[SegmentedButtonTokens.disabledContentColor] + : checked + ? (checkedColor ?? + theme.colors[SegmentedButtonTokens.selectedContentColor]) + : (uncheckedColor ?? + theme.colors[SegmentedButtonTokens.unselectedContentColor]); const borderOpacity = disabled ? SegmentedButtonTokens.disabledOutlineOpacity : stateOpacity.enabled; @@ -174,7 +160,7 @@ export const getSegmentedButtonColors = ({ borderOpacity, textColor, textOpacity, - borderWidth, + borderWidth: SegmentedButtonTokens.outlineWidth, stateLayerColor, focusIndicatorColor, }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index b36ed3241d..0589eb032f 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { fireEvent, render, screen } from '../../test-utils'; +import { fireEvent, render, screen, userEvent } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; @@ -9,6 +9,7 @@ import { getDisabledSegmentedButtonStyle, getSegmentedButtonColors, getSegmentedButtonHeight, + getSegmentedButtonStateLayerOpacity, } from '../SegmentedButtons/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -60,6 +61,97 @@ it('renders checked segmented button with selected check', async () => { expect(screen.getByTestId('walk-check-icon')).toBeOnTheScreen(); }); +describe('selection behavior', () => { + it('uses updated item and value callbacks while preserving their order', async () => { + const user = userEvent.setup(); + const initialItemOnPress = jest.fn(); + const initialValueChange = jest.fn(); + const callOrder: string[] = []; + const itemOnPress = jest.fn(() => callOrder.push('item')); + const onValueChange = jest.fn(() => callOrder.push('value')); + + const { rerender } = await render( + + ); + + await rerender( + + ); + await user.press(screen.getByTestId('walk')); + + expect(initialItemOnPress).not.toHaveBeenCalled(); + expect(initialValueChange).not.toHaveBeenCalled(); + expect(itemOnPress).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith('walk'); + expect(callOrder).toEqual(['item', 'value']); + }); + + it('preserves multiselect append order and removes duplicate values', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + const buttons = [ + { value: 'walk', testID: 'walk' }, + { value: 'ride' }, + { value: 'drive', testID: 'drive' }, + ]; + const { rerender } = await render( + + ); + + await user.press(screen.getByTestId('walk')); + expect(onValueChange).toHaveBeenLastCalledWith(['ride']); + + await rerender( + + ); + await user.press(screen.getByTestId('drive')); + expect(onValueChange).toHaveBeenLastCalledWith(['ride', 'drive']); + }); +}); + +it('applies group theme overrides to items', async () => { + await render( + {}} + buttons={[{ value: 'walk', testID: 'walk' }, { value: 'ride' }]} + theme={{ colors: { secondaryContainer: '#123456' } }} + /> + ); + + expect(screen.getByTestId('walk-container')).toHaveStyle({ + backgroundColor: '#123456', + }); +}); + describe('getSegmentedButtonColors', () => { const theme = getTheme(); @@ -215,6 +307,63 @@ describe('getSegmentedButtonColors', () => { }); }); +describe('getSegmentedButtonStateLayerOpacity', () => { + it.each([ + { + state: 'disabled', + disabled: true, + pressed: true, + focused: true, + hovered: true, + expected: 0, + }, + { + state: 'pressed', + disabled: false, + pressed: true, + focused: true, + hovered: true, + expected: stateOpacity.pressed, + }, + { + state: 'focused', + disabled: false, + pressed: false, + focused: true, + hovered: true, + expected: stateOpacity.focused, + }, + { + state: 'hovered', + disabled: false, + pressed: false, + focused: false, + hovered: true, + expected: stateOpacity.hovered, + }, + { + state: 'idle', + disabled: false, + pressed: false, + focused: false, + hovered: false, + expected: 0, + }, + ])( + 'returns the $state state opacity', + ({ disabled, pressed, focused, hovered, expected }) => { + expect( + getSegmentedButtonStateLayerOpacity({ + disabled, + pressed, + focused, + hovered, + }) + ).toBe(expected); + } + ); +}); + describe('segmented button presentation', () => { it.each([ { density: 'regular' as const, expected: 40 }, From 2cdbb9a2aae374314befe5394e136ed7765a370a Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Mon, 24 Aug 2026 17:02:32 +0200 Subject: [PATCH 03/16] refactor: accessibility --- .../SegmentedButtons/SegmentedButtons.mdx | 9 + docs/src/data/componentDocs6x.json | 248 ++++----- .../SegmentedButtonMultiselectIcons.tsx | 5 + .../SegmentedButtonOnlyIcons.tsx | 3 + .../SegmentedButtonOnlyIconsWithCheck.tsx | 3 + .../SegmentedButtons/SegmentedButtonItem.tsx | 12 +- .../SegmentedButtons/SegmentedButtons.tsx | 58 ++- src/components/SegmentedButtons/tokens.ts | 4 +- .../__tests__/SegmentedButton.test.tsx | 480 +++++++++++++++++- .../__snapshots__/ListSection.test.tsx.snap | 3 + .../SegmentedButton.test.tsx.snap | 97 +++- src/theme/schemes/DynamicTheme.android.tsx | 13 + src/theme/tokens/sys/color.ts | 2 + src/theme/types/color.ts | 1 + 14 files changed, 768 insertions(+), 170 deletions(-) diff --git a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx index fbd734c612..3ca08b694b 100644 --- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx +++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx @@ -28,6 +28,7 @@ const MyComponent = () => { return ( + +### aria-label + + + + +
### buttons (required) diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..7ff576f91e 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -10695,143 +10695,159 @@ "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": "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```", "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": "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```", "displayName": "SegmentedButtons", "methods": [], "statics": [], "props": { + "aria-label": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Accessibility label for the segmented button group." + }, "buttons": { "required": true, "tsType": { "name": "Array", "elements": [ { - "name": "signature", - "type": "object", - "raw": "{\n value: T;\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}", - "signature": { - "properties": [ - { - "key": "value", - "value": { - "name": "T", - "required": true - } - }, - { - "key": "icon", - "value": { - "name": "IconSource", - "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": "onPress", - "value": { - "name": "signature", - "type": "function", - "raw": "(event: GestureResponderEvent) => void", - "signature": { - "arguments": [ - { - "name": "event", - "type": { - "name": "GestureResponderEvent" + "name": "intersection", + "raw": "{\n value: T;\n /**\n * Icon to display for the segment. Required when `label` is omitted.\n */\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Non-empty visible label text. This is also used as the accessibility label.\n */\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n} & (\n | { label: string }\n | { label?: never; icon: IconSource; 'aria-label': string }\n)", + "elements": [ + { + "name": "signature", + "type": "object", + "raw": "{\n value: T;\n /**\n * Icon to display for the segment. Required when `label` is omitted.\n */\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Non-empty visible label text. This is also used as the accessibility label.\n */\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}", + "signature": { + "properties": [ + { + "key": "value", + "value": { + "name": "T", + "required": true + } + }, + { + "key": "icon", + "value": { + "name": "IconSource", + "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": "onPress", + "value": { + "name": "signature", + "type": "function", + "raw": "(event: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "event", + "type": { + "name": "GestureResponderEvent" + } + } + ], + "return": { + "name": "void" } - } - ], - "return": { - "name": "void" + }, + "required": false } }, - "required": false - } - }, - { - "key": "label", - "value": { - "name": "string", - "required": false - } - }, - { - "key": "showSelectedCheck", - "value": { - "name": "boolean", - "required": false - } - }, - { - "key": "style", - "value": { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" + { + "key": "label", + "value": { + "name": "string", + "required": false } - ], - "raw": "StyleProp", - "required": false - } - }, - { - "key": "labelStyle", - "value": { - "name": "StyleProp", - "elements": [ - { - "name": "TextStyle" + }, + { + "key": "showSelectedCheck", + "value": { + "name": "boolean", + "required": false } - ], - "raw": "StyleProp", - "required": false - } - }, - { - "key": "testID", - "value": { - "name": "string", - "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 + } + } + ] } - ] - } + }, + { + "name": "unknown" + } + ] } ], - "raw": "{\n value: T;\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}[]" + "raw": "SegmentedButton[]" }, - "description": "Buttons to display as options in toggle button.\nButton should contain the following properties:\n- `value`: value of button (required)\n- `icon`: icon to display for the item\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button.\n- `checkedColor`: custom color for checked Text and Icon\n- `uncheckedColor`: custom color for unchecked Text and Icon\n- `onPress`: callback that is called when button is pressed\n- `label`: label text of the button\n- `showSelectedCheck`: show optional check icon to indicate selected state\n- `style`: pass additional styles for the button\n- `testID`: testID to be used on tests" + "description": "Buttons to display as options in toggle button.\nEach button must contain a non-empty `label`, an `icon`, or both.\nButton should contain the following properties:\n- `value`: value of button (required)\n- `icon`: icon to display for the item (required when `label` is omitted)\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button.\n- `checkedColor`: custom color for checked Text and Icon\n- `uncheckedColor`: custom color for unchecked Text and Icon\n- `onPress`: callback that is called when button is pressed\n- `label`: non-empty visible label text of the button, also used as its accessibility label\n- `showSelectedCheck`: show optional check icon to indicate selected state\n- `style`: pass additional styles for the button\n- `testID`: testID to be used on tests" }, "density": { "required": false, diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx index 39877fa19b..1b1a77487d 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx @@ -19,22 +19,27 @@ const SegmentedButtonMultiselectIcons = () => { { value: 'size-s', icon: 'size-s', + 'aria-label': 'Small', }, { value: 'size-m', icon: 'size-m', + 'aria-label': 'Medium', }, { value: 'size-l', icon: 'size-l', + 'aria-label': 'Large', }, { value: 'size-xl', icon: 'size-xl', + 'aria-label': 'Extra large', }, { value: 'size-xxl', icon: 'size-xxl', + 'aria-label': 'Extra extra large', }, ]} /> diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx index 2456e3e11a..5b17fe8df7 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx @@ -18,14 +18,17 @@ const SegmentedButtonOnlyIcons = () => { { icon: 'walk', value: 'walk', + 'aria-label': 'Walking', }, { icon: 'train', value: 'train', + 'aria-label': 'Transit', }, { icon: 'car', value: 'drive', + 'aria-label': 'Driving', }, ]} /> diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx index d6219645bb..f0808563d1 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx @@ -18,16 +18,19 @@ const SegmentedButtonOnlyIconsWithCheck = () => { { icon: 'walk', value: 'walk', + 'aria-label': 'Walking', showSelectedCheck: true, }, { icon: 'train', value: 'transit', + 'aria-label': 'Transit', showSelectedCheck: true, }, { icon: 'car', value: 'drive', + 'aria-label': 'Driving', showSelectedCheck: true, }, ]} diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index b9ca780544..85720c3fc5 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -37,6 +37,10 @@ export type Props = { * Whether the segmented button is checked */ checked: boolean; + /** + * Accessibility role determined by the segmented button selection variant. + */ + role: 'radio' | 'checkbox'; /** * Icon to display for the `SegmentedButtonItem`. */ @@ -113,6 +117,7 @@ export type Props = { const SegmentedButtonItem = ({ checked, + role, 'aria-label': ariaLabel, disabled, style, @@ -144,6 +149,8 @@ const SegmentedButtonItem = ({ checkmarkScale.value = withSpring(checked ? 1 : 0); }, [checked, checkmarkScale, showSelectedCheck]); + const accessibilityLabel = label || ariaLabel; + const { backgroundColor, borderColor, @@ -238,11 +245,12 @@ const SegmentedButtonItem = ({ onHoverOut={() => setHovered(false)} onFocus={handleFocus} onBlur={handleBlur} - aria-label={ariaLabel} + aria-label={accessibilityLabel} aria-disabled={disabled} aria-checked={checked} - role="button" + role={role} disabled={disabled} + focusable={!disabled} testID={testID} style={touchableStyle} background={background} diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index b60b85f1db..55c7eb078d 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -42,36 +42,52 @@ type ConditionalValue = onValueChange: (value: T) => void; }; +type SegmentedButton = { + value: T; + /** + * Icon to display for the segment. Required when `label` is omitted. + */ + icon?: IconSource; + disabled?: boolean; + 'aria-label'?: string; + checkedColor?: string; + uncheckedColor?: string; + onPress?: (event: GestureResponderEvent) => void; + /** + * Non-empty visible label text. This is also used as the accessibility label. + */ + label?: string; + showSelectedCheck?: boolean; + style?: StyleProp; + labelStyle?: StyleProp; + testID?: string; +} & ( + | { label: string } + | { label?: never; icon: IconSource; 'aria-label': string } +); + export type Props = { + /** + * Accessibility label for the segmented button group. + */ + 'aria-label'?: string; /** * Buttons to display as options in toggle button. + * Each button must contain a non-empty `label`, an `icon`, or both. * Button should contain the following properties: * - `value`: value of button (required) - * - `icon`: icon to display for the item + * - `icon`: icon to display for the item (required when `label` is omitted) * - `disabled`: whether the button is disabled * - `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button. * - `checkedColor`: custom color for checked Text and Icon * - `uncheckedColor`: custom color for unchecked Text and Icon * - `onPress`: callback that is called when button is pressed - * - `label`: label text of the button + * - `label`: non-empty visible label text of the button, also used as its accessibility label * - `showSelectedCheck`: show optional check icon to indicate selected state * - `style`: pass additional styles for the button * - `testID`: testID to be used on tests */ - buttons: { - value: T; - icon?: IconSource; - disabled?: boolean; - 'aria-label'?: string; - checkedColor?: string; - uncheckedColor?: string; - onPress?: (event: GestureResponderEvent) => void; - label?: string; - showSelectedCheck?: boolean; - style?: StyleProp; - labelStyle?: StyleProp; - testID?: string; - }[]; + buttons: SegmentedButton[]; /** * Density is applied to the height, to allow usage in denser UIs */ @@ -95,6 +111,7 @@ export type Props = { * return ( * * = { *``` */ const SegmentedButtons = ({ + 'aria-label': ariaLabel, value, onValueChange, buttons, @@ -137,9 +155,14 @@ const SegmentedButtons = ({ const selectedValues = multiSelect && Array.isArray(value) ? value : undefined; + const singleSelectedIndex = selectedValues + ? -1 + : buttons.findIndex((item) => value === item.value); return ( {buttons.map((item, i) => { @@ -148,7 +171,7 @@ const SegmentedButtons = ({ const checked = selectedValues ? selectedValues.includes(item.value) - : value === item.value; + : i === singleSelectedIndex; const onPress = (event: GestureResponderEvent) => { item.onPress?.(event); @@ -168,6 +191,7 @@ const SegmentedButtons = ({ {...item} key={i} checked={checked} + role={multiSelect ? 'checkbox' : 'radio'} segment={segment} density={density} onPress={onPress} diff --git a/src/components/SegmentedButtons/tokens.ts b/src/components/SegmentedButtons/tokens.ts index 7194bf5bca..ce647c8f0f 100644 --- a/src/components/SegmentedButtons/tokens.ts +++ b/src/components/SegmentedButtons/tokens.ts @@ -19,12 +19,12 @@ const sizes = { const colors = { selectedContainerColor: 'secondaryContainer', - selectedContentColor: 'onSecondaryContainer', + selectedContentColor: 'onSecondaryContainerVariant', unselectedContentColor: 'onSurface', outlineColor: 'outline', disabledContentColor: 'onSurface', disabledOutlineColor: 'onSurface', - selectedStateLayerColor: 'onSecondaryContainer', + selectedStateLayerColor: 'onSecondaryContainerVariant', unselectedStateLayerColor: 'onSurface', focusIndicatorColor: 'secondary', } as const satisfies Record; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 0589eb032f..4768f33b4c 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1,5 +1,8 @@ +import { Text } from 'react-native'; + import { describe, expect, it, jest } from '@jest/globals'; +import { LocaleProvider } from '../../core/locale'; import { getTheme } from '../../core/theming'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; import { tokens } from '../../theme/tokens'; @@ -20,7 +23,10 @@ it('renders segmented button', async () => { {}} value={'walk'} - buttons={[{ value: 'walk' }, { value: 'ride' }]} + buttons={[ + { value: 'walk', label: 'Walking' }, + { value: 'ride', label: 'Riding' }, + ]} /> ) ).toJSON(); @@ -34,8 +40,13 @@ it('renders disabled segmented button', async () => { onValueChange={() => {}} value="walk" buttons={[ - { value: 'walk' }, - { value: 'ride', disabled: true, testID: 'ride' }, + { value: 'walk', label: 'Walking' }, + { + value: 'ride', + label: 'Riding', + disabled: true, + testID: 'ride', + }, ]} /> ); @@ -52,8 +63,13 @@ it('renders checked segmented button with selected check', async () => { onValueChange={() => {}} value="walk" buttons={[ - { value: 'walk', showSelectedCheck: true, testID: 'walk' }, - { value: 'ride', disabled: true }, + { + value: 'walk', + label: 'Walking', + showSelectedCheck: true, + testID: 'walk', + }, + { value: 'ride', label: 'Riding', disabled: true }, ]} /> ); @@ -77,10 +93,11 @@ describe('selection behavior', () => { buttons={[ { value: 'walk', + label: 'Walking', onPress: initialItemOnPress, testID: 'walk', }, - { value: 'ride' }, + { value: 'ride', label: 'Riding' }, ]} /> ); @@ -90,8 +107,13 @@ describe('selection behavior', () => { value="ride" onValueChange={onValueChange} buttons={[ - { value: 'walk', onPress: itemOnPress, testID: 'walk' }, - { value: 'ride' }, + { + value: 'walk', + label: 'Walking', + onPress: itemOnPress, + testID: 'walk', + }, + { value: 'ride', label: 'Riding' }, ]} /> ); @@ -104,13 +126,89 @@ describe('selection behavior', () => { expect(callOrder).toEqual(['item', 'value']); }); + it('selects only the first matching item when single-select values are duplicated', async () => { + const user = userEvent.setup(); + const duplicateOnPress = jest.fn(); + const onValueChange = jest.fn(); + + await render( + + ); + + const radios = screen.getAllByRole('radio'); + + expect(radios[0]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: true }) + ); + expect(radios[1]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: false }) + ); + + await user.press(screen.getByTestId('second-walk')); + + expect(duplicateOnPress).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith('walk'); + expect(screen.getAllByRole('radio')[1]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: false }) + ); + }); + + it('keeps duplicate button values selected and toggleable in multiselect', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + + await render( + + multiSelect + value={['walk']} + onValueChange={onValueChange} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'first-walk' }, + { value: 'walk', label: 'Walking again', testID: 'second-walk' }, + { value: 'ride', label: 'Riding' }, + ]} + /> + ); + + const checkboxes = screen.getAllByRole('checkbox'); + + expect(checkboxes[0]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: true }) + ); + expect(checkboxes[1]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: true }) + ); + + await user.press(screen.getByTestId('second-walk')); + + expect(onValueChange).toHaveBeenCalledWith([]); + }); + it('preserves multiselect append order and removes duplicate values', async () => { const user = userEvent.setup(); const onValueChange = jest.fn(); const buttons = [ - { value: 'walk', testID: 'walk' }, - { value: 'ride' }, - { value: 'drive', testID: 'drive' }, + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'ride', label: 'Riding' }, + { value: 'drive', label: 'Driving', testID: 'drive' }, ]; const { rerender } = await render( { {}} - buttons={[{ value: 'walk', testID: 'walk' }, { value: 'ride' }]} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'ride', label: 'Riding' }, + ]} theme={{ colors: { secondaryContainer: '#123456' } }} /> ); @@ -155,13 +256,67 @@ it('applies group theme overrides to items', async () => { describe('getSegmentedButtonColors', () => { const theme = getTheme(); + it('maps the default light selected colors to secondary tone 30', () => { + const selectedColor = tokens.md.ref.palette.secondary30; + + expect(theme.colors.onSecondaryContainer).toBe( + tokens.md.ref.palette.secondary10 + ); + expect(theme.colors[SegmentedButtonTokens.selectedContentColor]).toBe( + selectedColor + ); + expect(theme.colors[SegmentedButtonTokens.selectedStateLayerColor]).toBe( + selectedColor + ); + }); + + it('preserves dark, custom theme, and checked color resolution', () => { + const darkTheme = getTheme(true); + const customTheme = { + ...theme, + colors: { + ...theme.colors, + onSecondaryContainerVariant: '#123456', + }, + }; + + expect( + getSegmentedButtonColors({ + theme: darkTheme, + checked: true, + }) + ).toMatchObject({ + textColor: tokens.md.ref.palette.secondary90, + stateLayerColor: tokens.md.ref.palette.secondary90, + }); + expect( + getSegmentedButtonColors({ + theme: customTheme, + checked: true, + }) + ).toMatchObject({ + textColor: '#123456', + stateLayerColor: '#123456', + }); + expect( + getSegmentedButtonColors({ + theme, + checked: true, + checkedColor: '#654321', + }) + ).toMatchObject({ + textColor: '#654321', + stateLayerColor: tokens.md.ref.palette.secondary30, + }); + }); + it.each([ { disabled: false, checked: true, checkedColor: undefined, uncheckedColor: undefined, - expected: theme.colors.onSecondaryContainer, + expected: theme.colors.onSecondaryContainerVariant, }, { disabled: false, @@ -217,7 +372,7 @@ describe('getSegmentedButtonColors', () => { checked: true, checkedColor: undefined, uncheckedColor: '000', - expected: theme.colors.onSecondaryContainer, + expected: theme.colors.onSecondaryContainerVariant, }, ])( 'returns $expected when disabled: $disabled, checked: $checked, checkedColor is $checkedColor and uncheckedColor is $uncheckedColor', @@ -365,6 +520,49 @@ describe('getSegmentedButtonStateLayerOpacity', () => { }); describe('segmented button presentation', () => { + it('renders selected content and state layers with the default light color', async () => { + const selectedColor = tokens.md.ref.palette.secondary30; + + await render( + {}} + buttons={[ + { + value: 'walk', + icon: ({ color }) => , + label: 'Walking', + testID: 'walk', + }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + const button = screen.getByTestId('walk'); + const stateLayer = screen.getByTestId('walk-state-layer'); + + expect(screen.getByTestId('walk-label')).toHaveStyle({ + color: selectedColor, + }); + expect(screen.getByTestId('walk-glyph')).toHaveStyle({ + color: selectedColor, + }); + expect(stateLayer).toHaveStyle({ + backgroundColor: selectedColor, + opacity: 0, + }); + + await fireEvent(button, 'hoverIn'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); + + await fireEvent(button, 'focus'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); + + await fireEvent(button, 'pressIn'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.pressed }); + }); + it.each([ { density: 'regular' as const, expected: 40 }, { density: 'small' as const, expected: 36 }, @@ -485,11 +683,13 @@ describe('should render icon when', () => { { icon: 'walk', value: 'walk', + 'aria-label': 'Walking', testID: 'walking-button', }, { icon: 'car', value: 'drive', + 'aria-label': 'Driving', testID: 'driving-button', }, ]} @@ -564,10 +764,12 @@ describe('should not render icon when', () => { buttons={[ { value: 'walk', + label: 'Walking', testID: 'walking-button', }, { value: 'drive', + label: 'Driving', testID: 'driving-button', }, ]} @@ -609,38 +811,260 @@ describe('should not render icon when', () => { }); }); -describe('should have `accessibilityState={ checked: true }` when selected', () => { - it('should have two button selected', async () => { - const onValueChange = jest.fn(); +describe('segment content', () => { + it('accepts label-only, icon-only, and icon-and-label segments', async () => { await render( - - multiSelect - value={['walk', 'transit']} + {}} /> ); - const buttons = screen.getAllByRole('button'); + expect(screen.getByTestId('label-only-label')).toBeOnTheScreen(); + expect(screen.queryByTestId('label-only-icon')).not.toBeOnTheScreen(); + expect(screen.getByTestId('icon-only-icon')).toBeOnTheScreen(); + expect(screen.queryByTestId('icon-only-label')).not.toBeOnTheScreen(); + expect(screen.getByTestId('icon-and-label-icon')).toBeOnTheScreen(); + expect(screen.getByTestId('icon-and-label-label')).toBeOnTheScreen(); + }); +}); - expect(buttons[0]).toHaveProp( +describe('accessibility semantics', () => { + it('uses icon descriptions and visible text as segment names', async () => { + await render( + {}} + /> + ); + + expect(screen.getByRole('radio', { name: 'Walking' })).toBeOnTheScreen(); + expect(screen.getByRole('radio', { name: 'Driving' })).toBeOnTheScreen(); + expect( + screen.queryByRole('radio', { name: 'Travel by car' }) + ).not.toBeOnTheScreen(); + }); + + it('exposes a single-select radiogroup containing radio controls', async () => { + const group = ( + await render( + {}} + /> + ) + ).toJSON(); + const radios = screen.getAllByRole('radio'); + + expect(group).toMatchObject({ + props: { 'aria-label': 'Transport mode', role: 'radiogroup' }, + }); + expect(radios).toHaveLength(3); + expect(radios[0]).toHaveProp( 'accessibilityState', expect.objectContaining({ checked: true }) ); - expect(buttons[1]).toHaveProp( + expect(radios[1]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: false }) + ); + }); + + it('exposes a multi-select group containing checkbox controls', async () => { + const onValueChange = jest.fn(); + const group = ( + await render( + + aria-label="Transport modes" + multiSelect + value={['walk', 'transit']} + buttons={[ + { value: 'walk', label: 'Walking' }, + { value: 'transit', label: 'Transit' }, + { value: 'drive', label: 'Driving' }, + ]} + onValueChange={onValueChange} + /> + ) + ).toJSON(); + const checkboxes = screen.getAllByRole('checkbox'); + + expect(group).toMatchObject({ + props: { 'aria-label': 'Transport modes', role: 'group' }, + }); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: true }) + ); + expect(checkboxes[1]).toHaveProp( 'accessibilityState', expect.objectContaining({ checked: true }) ); - expect(buttons[2]).toHaveProp( + expect(checkboxes[2]).toHaveProp( 'accessibilityState', expect.objectContaining({ checked: false }) ); }); +}); + +describe('keyboard traversal', () => { + const buttons = [ + { value: 'first', label: 'First', testID: 'first' }, + { + value: 'disabled', + label: 'Disabled', + disabled: true, + testID: 'disabled', + }, + { value: 'last', label: 'Last', testID: 'last' }, + ]; + + it.each([ + { variant: 'single-select', multiSelect: false }, + { variant: 'multi-select', multiSelect: true }, + ])( + 'skips disabled segments in forward and reverse $variant tab order', + async ({ multiSelect }) => { + if (multiSelect) { + await render( + {}} + /> + ); + } else { + await render( + {}} + /> + ); + } + + const controls = screen.getAllByRole(multiSelect ? 'checkbox' : 'radio'); + + expect(controls[0]).toHaveProp('focusable', true); + expect(controls[1]).toHaveProp('focusable', false); + expect(controls[2]).toHaveProp('focusable', true); + } + ); + + it('has no keyboard focus target when every segment is disabled', async () => { + await render( + ({ ...button, disabled: true }))} + onValueChange={() => {}} + /> + ); + + screen + .getAllByRole('radio') + .forEach((control) => expect(control).toHaveProp('focusable', false)); + }); + + it.each(['ltr', 'rtl'] as const)( + 'keeps disabled group edges out of the %s keyboard order', + async (direction) => { + const view = await render( + + {}} + /> + + ); + const controls = screen.getAllByRole('radio'); + + expect(view.root).toHaveStyle({ direction }); + expect(controls[0]).toHaveProp('focusable', false); + expect(controls[1]).toHaveProp('focusable', true); + expect(controls[2]).toHaveProp('focusable', false); + } + ); + + it('updates the traversal targets when disabled segments change', async () => { + const view = await render( + {}} + /> + ); + + expect(screen.getByRole('radio', { name: 'First' })).toHaveProp( + 'focusable', + false + ); + expect(screen.getByRole('radio', { name: 'Last' })).toHaveProp( + 'focusable', + true + ); + + await view.rerender( + {}} + /> + ); + + expect(screen.getByRole('radio', { name: 'First' })).toHaveProp( + 'focusable', + true + ); + expect(screen.getByRole('radio', { name: 'Last' })).toHaveProp( + 'focusable', + false + ); + }); +}); +describe('selected check icon', () => { it('show selected check icon should be shown', async () => { const onValueChange = jest.fn(); diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 7e7dcc158c..260d66b15e 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -39,6 +39,7 @@ exports[`renders list section with custom title style 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", + "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", @@ -801,6 +802,7 @@ exports[`renders list section with subheader 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", + "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", @@ -1561,6 +1563,7 @@ exports[`renders list section without subheader 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", + "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index deae7b039b..c07ece7bd1 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -2,6 +2,7 @@ exports[`renders segmented button 1`] = ` + > + + Walking + + + > + + Riding + + Date: Wed, 26 Aug 2026 13:28:55 +0200 Subject: [PATCH 04/16] refactor: revert changes --- .../SegmentedButtons/SegmentedButtons.mdx | 9 - docs/src/data/componentDocs6x.json | 248 ++++++++---------- .../SegmentedButtonMultiselectIcons.tsx | 5 - .../SegmentedButtonOnlyIcons.tsx | 3 - .../SegmentedButtonOnlyIconsWithCheck.tsx | 3 - .../SegmentedButtons/SegmentedButtonItem.tsx | 34 ++- .../SegmentedButtons/SegmentedButtons.tsx | 51 ++-- src/components/SegmentedButtons/tokens.ts | 4 +- .../__tests__/SegmentedButton.test.tsx | 222 +++++++++------- .../__snapshots__/ListSection.test.tsx.snap | 3 - .../SegmentedButton.test.tsx.snap | 26 +- src/theme/schemes/DynamicTheme.android.tsx | 13 - src/theme/tokens/sys/color.ts | 2 - src/theme/types/color.ts | 1 - 14 files changed, 308 insertions(+), 316 deletions(-) diff --git a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx index 3ca08b694b..fbd734c612 100644 --- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx +++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx @@ -28,7 +28,6 @@ const MyComponent = () => { return ( - -### aria-label - -
- - -
### buttons (required) diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 7ff576f91e..56b356912b 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -10695,159 +10695,143 @@ "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": "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```", "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": "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```", "displayName": "SegmentedButtons", "methods": [], "statics": [], "props": { - "aria-label": { - "required": false, - "tsType": { - "name": "string" - }, - "description": "Accessibility label for the segmented button group." - }, "buttons": { "required": true, "tsType": { "name": "Array", "elements": [ { - "name": "intersection", - "raw": "{\n value: T;\n /**\n * Icon to display for the segment. Required when `label` is omitted.\n */\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Non-empty visible label text. This is also used as the accessibility label.\n */\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n} & (\n | { label: string }\n | { label?: never; icon: IconSource; 'aria-label': string }\n)", - "elements": [ - { - "name": "signature", - "type": "object", - "raw": "{\n value: T;\n /**\n * Icon to display for the segment. Required when `label` is omitted.\n */\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Non-empty visible label text. This is also used as the accessibility label.\n */\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}", - "signature": { - "properties": [ - { - "key": "value", - "value": { - "name": "T", - "required": true - } - }, - { - "key": "icon", - "value": { - "name": "IconSource", - "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": "onPress", - "value": { - "name": "signature", - "type": "function", - "raw": "(event: GestureResponderEvent) => void", - "signature": { - "arguments": [ - { - "name": "event", - "type": { - "name": "GestureResponderEvent" - } - } - ], - "return": { - "name": "void" - } - }, - "required": false - } - }, - { - "key": "label", - "value": { - "name": "string", - "required": false - } - }, - { - "key": "showSelectedCheck", - "value": { - "name": "boolean", - "required": false - } - }, - { - "key": "style", - "value": { - "name": "StyleProp", - "elements": [ - { - "name": "ViewStyle" + "name": "signature", + "type": "object", + "raw": "{\n value: T;\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}", + "signature": { + "properties": [ + { + "key": "value", + "value": { + "name": "T", + "required": true + } + }, + { + "key": "icon", + "value": { + "name": "IconSource", + "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": "onPress", + "value": { + "name": "signature", + "type": "function", + "raw": "(event: GestureResponderEvent) => void", + "signature": { + "arguments": [ + { + "name": "event", + "type": { + "name": "GestureResponderEvent" } - ], - "raw": "StyleProp", - "required": false + } + ], + "return": { + "name": "void" } }, - { - "key": "labelStyle", - "value": { - "name": "StyleProp", - "elements": [ - { - "name": "TextStyle" - } - ], - "raw": "StyleProp", - "required": false + "required": false + } + }, + { + "key": "label", + "value": { + "name": "string", + "required": false + } + }, + { + "key": "showSelectedCheck", + "value": { + "name": "boolean", + "required": false + } + }, + { + "key": "style", + "value": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" } - }, - { - "key": "testID", - "value": { - "name": "string", - "required": false + ], + "raw": "StyleProp", + "required": false + } + }, + { + "key": "labelStyle", + "value": { + "name": "StyleProp", + "elements": [ + { + "name": "TextStyle" } - } - ] + ], + "raw": "StyleProp", + "required": false + } + }, + { + "key": "testID", + "value": { + "name": "string", + "required": false + } } - }, - { - "name": "unknown" - } - ] + ] + } } ], - "raw": "SegmentedButton[]" + "raw": "{\n value: T;\n icon?: IconSource;\n disabled?: boolean;\n 'aria-label'?: string;\n checkedColor?: string;\n uncheckedColor?: string;\n onPress?: (event: GestureResponderEvent) => void;\n label?: string;\n showSelectedCheck?: boolean;\n style?: StyleProp;\n labelStyle?: StyleProp;\n testID?: string;\n}[]" }, - "description": "Buttons to display as options in toggle button.\nEach button must contain a non-empty `label`, an `icon`, or both.\nButton should contain the following properties:\n- `value`: value of button (required)\n- `icon`: icon to display for the item (required when `label` is omitted)\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button.\n- `checkedColor`: custom color for checked Text and Icon\n- `uncheckedColor`: custom color for unchecked Text and Icon\n- `onPress`: callback that is called when button is pressed\n- `label`: non-empty visible label text of the button, also used as its accessibility label\n- `showSelectedCheck`: show optional check icon to indicate selected state\n- `style`: pass additional styles for the button\n- `testID`: testID to be used on tests" + "description": "Buttons to display as options in toggle button.\nButton should contain the following properties:\n- `value`: value of button (required)\n- `icon`: icon to display for the item\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button.\n- `checkedColor`: custom color for checked Text and Icon\n- `uncheckedColor`: custom color for unchecked Text and Icon\n- `onPress`: callback that is called when button is pressed\n- `label`: label text of the button\n- `showSelectedCheck`: show optional check icon to indicate selected state\n- `style`: pass additional styles for the button\n- `testID`: testID to be used on tests" }, "density": { "required": false, diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx index 1b1a77487d..39877fa19b 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonMultiselectIcons.tsx @@ -19,27 +19,22 @@ const SegmentedButtonMultiselectIcons = () => { { value: 'size-s', icon: 'size-s', - 'aria-label': 'Small', }, { value: 'size-m', icon: 'size-m', - 'aria-label': 'Medium', }, { value: 'size-l', icon: 'size-l', - 'aria-label': 'Large', }, { value: 'size-xl', icon: 'size-xl', - 'aria-label': 'Extra large', }, { value: 'size-xxl', icon: 'size-xxl', - 'aria-label': 'Extra extra large', }, ]} /> diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx index 5b17fe8df7..2456e3e11a 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIcons.tsx @@ -18,17 +18,14 @@ const SegmentedButtonOnlyIcons = () => { { icon: 'walk', value: 'walk', - 'aria-label': 'Walking', }, { icon: 'train', value: 'train', - 'aria-label': 'Transit', }, { icon: 'car', value: 'drive', - 'aria-label': 'Driving', }, ]} /> diff --git a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx index f0808563d1..d6219645bb 100644 --- a/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx +++ b/example/src/Examples/SegmentedButtons/SegmentedButtonOnlyIconsWithCheck.tsx @@ -18,19 +18,16 @@ const SegmentedButtonOnlyIconsWithCheck = () => { { icon: 'walk', value: 'walk', - 'aria-label': 'Walking', showSelectedCheck: true, }, { icon: 'train', value: 'transit', - 'aria-label': 'Transit', showSelectedCheck: true, }, { icon: 'car', value: 'drive', - 'aria-label': 'Driving', showSelectedCheck: true, }, ]} diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 85720c3fc5..9f469abdea 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -23,6 +23,7 @@ import { import { tokens } from '../../theme/tokens'; import type { Theme } from '../../types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import { splitStyles } from '../../utils/splitStyles'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -172,6 +173,19 @@ const SegmentedButtonItem = ({ }); const outlineStyle = getSegmentedButtonOutlineStyle(segment); const containerHeight = getSegmentedButtonHeight(density); + const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; + const [visualStyleOverrides, borderRadiusStyleOverrides, borderOverrides] = + splitStyles( + flattenedStyle, + (property) => + property === 'borderCurve' || + (property.startsWith('border') && property.endsWith('Radius')), + (property) => property.startsWith('border') + ); + const borderRadiusStyle = { + ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), + ...borderRadiusStyleOverrides, + }; const focusRingVerticalInset = (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - FOCUS_RING_OUTSET; @@ -181,23 +195,25 @@ const SegmentedButtonItem = ({ }; const touchableStyle = [ styles.touchable, - segmentBorderRadius, + borderRadiusStyle, Platform.OS === 'web' ? webNoOutline : undefined, ]; const visualStyle = [ styles.visual, - segmentBorderRadius, + borderRadiusStyle, { height: containerHeight, backgroundColor }, + Object.keys(visualStyleOverrides).length ? visualStyleOverrides : undefined, ]; const outlineContainerStyle = [ styles.outline, - segmentBorderRadius, - outlineStyle, + borderRadiusStyle, + flattenedStyle.borderWidth === undefined ? outlineStyle : undefined, { borderColor, opacity: borderOpacity }, + Object.keys(borderOverrides).length ? borderOverrides : undefined, ]; const focusRingStyle = [ styles.focusRing, - segmentBorderRadius, + borderRadiusStyle, { top: focusRingVerticalInset, bottom: focusRingVerticalInset, @@ -235,7 +251,10 @@ const SegmentedButtonItem = ({ }; return ( - + = onValueChange: (value: T) => void; }; -type SegmentedButton = { - value: T; - /** - * Icon to display for the segment. Required when `label` is omitted. - */ - icon?: IconSource; - disabled?: boolean; - 'aria-label'?: string; - checkedColor?: string; - uncheckedColor?: string; - onPress?: (event: GestureResponderEvent) => void; - /** - * Non-empty visible label text. This is also used as the accessibility label. - */ - label?: string; - showSelectedCheck?: boolean; - style?: StyleProp; - labelStyle?: StyleProp; - testID?: string; -} & ( - | { label: string } - | { label?: never; icon: IconSource; 'aria-label': string } -); - export type Props = { - /** - * Accessibility label for the segmented button group. - */ - 'aria-label'?: string; /** * Buttons to display as options in toggle button. - * Each button must contain a non-empty `label`, an `icon`, or both. * Button should contain the following properties: * - `value`: value of button (required) - * - `icon`: icon to display for the item (required when `label` is omitted) + * - `icon`: icon to display for the item * - `disabled`: whether the button is disabled * - `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button. * - `checkedColor`: custom color for checked Text and Icon * - `uncheckedColor`: custom color for unchecked Text and Icon * - `onPress`: callback that is called when button is pressed - * - `label`: non-empty visible label text of the button, also used as its accessibility label + * - `label`: label text of the button * - `showSelectedCheck`: show optional check icon to indicate selected state * - `style`: pass additional styles for the button * - `testID`: testID to be used on tests */ - buttons: SegmentedButton[]; + buttons: { + value: T; + icon?: IconSource; + disabled?: boolean; + 'aria-label'?: string; + checkedColor?: string; + uncheckedColor?: string; + onPress?: (event: GestureResponderEvent) => void; + label?: string; + showSelectedCheck?: boolean; + style?: StyleProp; + labelStyle?: StyleProp; + testID?: string; + }[]; /** * Density is applied to the height, to allow usage in denser UIs */ @@ -111,7 +95,6 @@ export type Props = { * return ( * * = { *``` */ const SegmentedButtons = ({ - 'aria-label': ariaLabel, value, onValueChange, buttons, @@ -161,7 +143,6 @@ const SegmentedButtons = ({ return ( diff --git a/src/components/SegmentedButtons/tokens.ts b/src/components/SegmentedButtons/tokens.ts index ce647c8f0f..7194bf5bca 100644 --- a/src/components/SegmentedButtons/tokens.ts +++ b/src/components/SegmentedButtons/tokens.ts @@ -19,12 +19,12 @@ const sizes = { const colors = { selectedContainerColor: 'secondaryContainer', - selectedContentColor: 'onSecondaryContainerVariant', + selectedContentColor: 'onSecondaryContainer', unselectedContentColor: 'onSurface', outlineColor: 'outline', disabledContentColor: 'onSurface', disabledOutlineColor: 'onSurface', - selectedStateLayerColor: 'onSecondaryContainerVariant', + selectedStateLayerColor: 'onSecondaryContainer', unselectedStateLayerColor: 'onSurface', focusIndicatorColor: 'secondary', } as const satisfies Record; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 4768f33b4c..6a92e81434 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1,5 +1,3 @@ -import { Text } from 'react-native'; - import { describe, expect, it, jest } from '@jest/globals'; import { LocaleProvider } from '../../core/locale'; @@ -256,67 +254,13 @@ it('applies group theme overrides to items', async () => { describe('getSegmentedButtonColors', () => { const theme = getTheme(); - it('maps the default light selected colors to secondary tone 30', () => { - const selectedColor = tokens.md.ref.palette.secondary30; - - expect(theme.colors.onSecondaryContainer).toBe( - tokens.md.ref.palette.secondary10 - ); - expect(theme.colors[SegmentedButtonTokens.selectedContentColor]).toBe( - selectedColor - ); - expect(theme.colors[SegmentedButtonTokens.selectedStateLayerColor]).toBe( - selectedColor - ); - }); - - it('preserves dark, custom theme, and checked color resolution', () => { - const darkTheme = getTheme(true); - const customTheme = { - ...theme, - colors: { - ...theme.colors, - onSecondaryContainerVariant: '#123456', - }, - }; - - expect( - getSegmentedButtonColors({ - theme: darkTheme, - checked: true, - }) - ).toMatchObject({ - textColor: tokens.md.ref.palette.secondary90, - stateLayerColor: tokens.md.ref.palette.secondary90, - }); - expect( - getSegmentedButtonColors({ - theme: customTheme, - checked: true, - }) - ).toMatchObject({ - textColor: '#123456', - stateLayerColor: '#123456', - }); - expect( - getSegmentedButtonColors({ - theme, - checked: true, - checkedColor: '#654321', - }) - ).toMatchObject({ - textColor: '#654321', - stateLayerColor: tokens.md.ref.palette.secondary30, - }); - }); - it.each([ { disabled: false, checked: true, checkedColor: undefined, uncheckedColor: undefined, - expected: theme.colors.onSecondaryContainerVariant, + expected: theme.colors.onSecondaryContainer, }, { disabled: false, @@ -372,7 +316,7 @@ describe('getSegmentedButtonColors', () => { checked: true, checkedColor: undefined, uncheckedColor: '000', - expected: theme.colors.onSecondaryContainerVariant, + expected: theme.colors.onSecondaryContainer, }, ])( 'returns $expected when disabled: $disabled, checked: $checked, checkedColor is $checkedColor and uncheckedColor is $uncheckedColor', @@ -520,8 +464,20 @@ describe('getSegmentedButtonStateLayerOpacity', () => { }); describe('segmented button presentation', () => { - it('renders selected content and state layers with the default light color', async () => { - const selectedColor = tokens.md.ref.palette.secondary30; + it('applies custom backgrounds, radii, and shadows to selected and unselected visual containers', async () => { + const selectedStyle = { + backgroundColor: '#112233', + borderRadius: 12, + elevation: 4, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 3, + }; + const unselectedStyle = { + backgroundColor: '#445566', + borderRadius: 6, + }; await render( { buttons={[ { value: 'walk', - icon: ({ color }) => , label: 'Walking', testID: 'walk', + style: selectedStyle, + }, + { + value: 'drive', + label: 'Driving', + testID: 'drive', + style: unselectedStyle, }, - { value: 'drive', label: 'Driving' }, ]} /> ); - const button = screen.getByTestId('walk'); - const stateLayer = screen.getByTestId('walk-state-layer'); - - expect(screen.getByTestId('walk-label')).toHaveStyle({ - color: selectedColor, + expect(screen.getByTestId('walk-container')).toHaveStyle(selectedStyle); + expect(screen.getByTestId('drive-container')).toHaveStyle(unselectedStyle); + expect(screen.getByTestId('walk-state-layer')).toHaveStyle({ + borderRadius: selectedStyle.borderRadius, }); - expect(screen.getByTestId('walk-glyph')).toHaveStyle({ - color: selectedColor, + expect(screen.getByTestId('drive-state-layer')).toHaveStyle({ + borderRadius: unselectedStyle.borderRadius, }); - expect(stateLayer).toHaveStyle({ - backgroundColor: selectedColor, - opacity: 0, + expect(screen.getByTestId('walk')).toHaveStyle({ overflow: 'visible' }); + expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ + backgroundColor: selectedStyle.backgroundColor, + shadowColor: selectedStyle.shadowColor, }); + }); - await fireEvent(button, 'hoverIn'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); - - await fireEvent(button, 'focus'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); + it('applies custom borders to the outline and lets borderWidth replace its edge widths', async () => { + await render( + {}} + buttons={[ + { + value: 'walk', + label: 'Walking', + testID: 'walk', + style: { + borderColor: '#123456', + borderStyle: 'dashed', + borderWidth: 3, + }, + }, + { + value: 'drive', + label: 'Driving', + testID: 'drive', + style: { + borderColor: '#654321', + borderTopWidth: 4, + }, + }, + ]} + /> + ); - await fireEvent(button, 'pressIn'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.pressed }); - }); + const selectedOutline = screen.getByTestId('walk-outline'); - it.each([ - { density: 'regular' as const, expected: 40 }, - { density: 'small' as const, expected: 36 }, - { density: 'medium' as const, expected: 32 }, - { density: 'high' as const, expected: 28 }, - ])('uses the $density density height', ({ density, expected }) => { - expect(getSegmentedButtonHeight(density)).toBe(expected); + expect(selectedOutline).toHaveStyle({ + borderColor: '#123456', + borderStyle: 'dashed', + borderWidth: 3, + }); + expect(selectedOutline).not.toHaveStyle({ + borderTopWidth: SegmentedButtonTokens.outlineWidth, + }); + expect(screen.getByTestId('drive-outline')).toHaveStyle({ + borderColor: '#654321', + borderTopWidth: 4, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: SegmentedButtonTokens.outlineWidth, + }); }); - it('keeps a 48dp target around the visual container', async () => { + it('does not move visual styles to the hit target', async () => { await render( {}} buttons={[ - { value: 'walk', label: 'Walking', testID: 'walk' }, + { + value: 'walk', + label: 'Walking', + testID: 'walk', + style: { flex: 3, backgroundColor: '#123456' }, + }, { value: 'drive', label: 'Driving' }, ]} /> ); - expect(screen.getByTestId('walk')).toHaveStyle({ + expect(screen.getByTestId('walk-container')).toHaveStyle({ + flex: 3, + backgroundColor: '#123456', + }); + expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ + flex: 1, minHeight: SegmentedButtonTokens.touchTargetHeight, }); - expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 40 }); }); + it.each([ + { density: 'regular' as const, expected: 40 }, + { density: 'small' as const, expected: 36 }, + { density: 'medium' as const, expected: 32 }, + { density: 'high' as const, expected: 28 }, + ])( + 'uses the $density density height inside a 48dp target', + async ({ density, expected }) => { + expect(getSegmentedButtonHeight(density)).toBe(expected); + + await render( + {}} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ + minHeight: SegmentedButtonTokens.touchTargetHeight, + }); + expect(screen.getByTestId('walk')).toHaveStyle({ + minHeight: SegmentedButtonTokens.touchTargetHeight, + }); + expect(screen.getByTestId('walk-container')).toHaveStyle({ + height: expected, + }); + } + ); + it('renders token opacity for hover and keyboard focus states', async () => { await render( { const group = ( await render( { const radios = screen.getAllByRole('radio'); expect(group).toMatchObject({ - props: { 'aria-label': 'Transport mode', role: 'radiogroup' }, + props: { role: 'radiogroup' }, }); expect(radios).toHaveLength(3); expect(radios[0]).toHaveProp( @@ -904,7 +937,6 @@ describe('accessibility semantics', () => { const group = ( await render( - aria-label="Transport modes" multiSelect value={['walk', 'transit']} buttons={[ @@ -919,7 +951,7 @@ describe('accessibility semantics', () => { const checkboxes = screen.getAllByRole('checkbox'); expect(group).toMatchObject({ - props: { 'aria-label': 'Transport modes', role: 'group' }, + props: { role: 'group' }, }); expect(checkboxes).toHaveLength(3); expect(checkboxes[0]).toHaveProp( diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 260d66b15e..7e7dcc158c 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -39,7 +39,6 @@ exports[`renders list section with custom title style 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", - "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", @@ -802,7 +801,6 @@ exports[`renders list section with subheader 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", - "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", @@ -1563,7 +1561,6 @@ exports[`renders list section without subheader 1`] = ` "onPrimaryFixedVariant": "rgba(79, 55, 139, 1)", "onSecondary": "rgba(255, 255, 255, 1)", "onSecondaryContainer": "rgba(29, 25, 43, 1)", - "onSecondaryContainerVariant": "rgba(74, 68, 88, 1)", "onSecondaryFixed": "rgba(29, 25, 43, 1)", "onSecondaryFixedVariant": "rgba(74, 68, 88, 1)", "onSurface": "rgba(29, 27, 32, 1)", diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index c07ece7bd1..14c9ec6120 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -27,7 +27,6 @@ exports[`renders segmented button 1`] = ` "overflow": "visible", }, false, - undefined, ] } > @@ -72,6 +71,7 @@ exports[`renders segmented button 1`] = ` { "justifyContent": "center", "minHeight": 48, + "overflow": "visible", }, { "borderBottomEndRadius": 0, @@ -89,7 +89,6 @@ exports[`renders segmented button 1`] = ` [ { "justifyContent": "center", - "overflow": "hidden", "width": "100%", }, { @@ -102,6 +101,7 @@ exports[`renders segmented button 1`] = ` "backgroundColor": "rgba(232, 222, 248, 1)", "height": 40, }, + undefined, ] } > @@ -117,7 +117,13 @@ exports[`renders segmented button 1`] = ` "top": 0, }, { - "backgroundColor": "rgba(74, 68, 88, 1)", + "borderBottomEndRadius": 0, + "borderBottomStartRadius": 9999, + "borderTopEndRadius": 0, + "borderTopStartRadius": 9999, + }, + { + "backgroundColor": "rgba(29, 25, 43, 1)", "opacity": 0, }, ] @@ -166,7 +172,7 @@ exports[`renders segmented button 1`] = ` "textAlign": "center", }, { - "color": "rgba(74, 68, 88, 1)", + "color": "rgba(29, 25, 43, 1)", "fontFamily": "System", "fontSize": 14, "fontWeight": "500", @@ -210,6 +216,7 @@ exports[`renders segmented button 1`] = ` "borderColor": "rgba(121, 116, 126, 1)", "opacity": 1, }, + undefined, ] } /> @@ -227,7 +234,6 @@ exports[`renders segmented button 1`] = ` "overflow": "visible", }, false, - undefined, ] } > @@ -272,6 +278,7 @@ exports[`renders segmented button 1`] = ` { "justifyContent": "center", "minHeight": 48, + "overflow": "visible", }, { "borderBottomEndRadius": 9999, @@ -289,7 +296,6 @@ exports[`renders segmented button 1`] = ` [ { "justifyContent": "center", - "overflow": "hidden", "width": "100%", }, { @@ -302,6 +308,7 @@ exports[`renders segmented button 1`] = ` "backgroundColor": "transparent", "height": 40, }, + undefined, ] } > @@ -316,6 +323,12 @@ exports[`renders segmented button 1`] = ` "right": 0, "top": 0, }, + { + "borderBottomEndRadius": 9999, + "borderBottomStartRadius": 0, + "borderTopEndRadius": 9999, + "borderTopStartRadius": 0, + }, { "backgroundColor": "rgba(29, 27, 32, 1)", "opacity": 0, @@ -410,6 +423,7 @@ exports[`renders segmented button 1`] = ` "borderColor": "rgba(121, 116, 126, 1)", "opacity": 1, }, + undefined, ] } /> diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx index 47891ab361..07cc9f93d2 100644 --- a/src/theme/schemes/DynamicTheme.android.tsx +++ b/src/theme/schemes/DynamicTheme.android.tsx @@ -130,19 +130,6 @@ const colorRoleMap: RoleEntry[] = [ Palette.secondary90, ], }, - { - role: 'onSecondaryContainerVariant', - light: [ - 'system_on_secondary_fixed_variant', - 'system_accent2_700', - Palette.secondary30, - ], - dark: [ - 'system_on_secondary_container_dark', - 'system_accent2_100', - Palette.secondary90, - ], - }, // Tertiary family { role: 'tertiary', diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts index 15392ab841..efdb08cbba 100644 --- a/src/theme/tokens/sys/color.ts +++ b/src/theme/tokens/sys/color.ts @@ -26,7 +26,6 @@ const roleToTone: Record< onSecondary: 'secondary100', secondaryContainer: 'secondary90', onSecondaryContainer: 'secondary10', - onSecondaryContainerVariant: 'secondary30', tertiary: 'tertiary40', onTertiary: 'tertiary100', tertiaryContainer: 'tertiary90', @@ -79,7 +78,6 @@ const roleToTone: Record< onSecondary: 'secondary20', secondaryContainer: 'secondary30', onSecondaryContainer: 'secondary90', - onSecondaryContainerVariant: 'secondary90', tertiary: 'tertiary80', onTertiary: 'tertiary20', tertiaryContainer: 'tertiary30', diff --git a/src/theme/types/color.ts b/src/theme/types/color.ts index 4f15772e3b..84879a36ec 100644 --- a/src/theme/types/color.ts +++ b/src/theme/types/color.ts @@ -29,7 +29,6 @@ export type ThemeColors = { onPrimaryContainer: ColorValue; onSecondary: ColorValue; onSecondaryContainer: ColorValue; - onSecondaryContainerVariant: ColorValue; onTertiary: ColorValue; onTertiaryContainer: ColorValue; onSurface: ColorValue; From c5fe8baafb197972c6aacf82c5afd7aa153bbe0e Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Wed, 26 Aug 2026 15:50:26 +0200 Subject: [PATCH 05/16] refactor: self review --- .../SegmentedButtons/SegmentedButtonItem.tsx | 46 +----- .../SegmentedButtons/SegmentedButtons.tsx | 25 ++-- .../useSegmentedButtonInteraction.ts | 45 ++++++ src/components/SegmentedButtons/utils.ts | 34 ----- .../__tests__/SegmentedButton.test.tsx | 133 ++++++++++-------- 5 files changed, 139 insertions(+), 144 deletions(-) create mode 100644 src/components/SegmentedButtons/useSegmentedButtonInteraction.ts diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 9f469abdea..e819c6e7f2 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -2,10 +2,8 @@ import * as React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, - NativeSyntheticEvent, PressableAndroidRippleConfig, StyleProp, - TargetedEvent, TextStyle, ViewStyle, } from 'react-native'; @@ -14,6 +12,7 @@ import { useSharedValue, withSpring } from 'react-native-reanimated'; import SegmentedButtonContent from './SegmentedButtonContent'; import { SegmentedButtonTokens } from './tokens'; +import { useSegmentedButtonInteraction } from './useSegmentedButtonInteraction'; import { getSegmentedButtonBorderRadius, getSegmentedButtonColors, @@ -22,14 +21,12 @@ import { } from './utils'; import { tokens } from '../../theme/tokens'; import type { Theme } from '../../types'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import { splitStyles } from '../../utils/splitStyles'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; const focusIndicatorTokens = tokens.md.sys.state.focusIndicator; -const stateOpacity = tokens.md.sys.state.opacity; const FOCUS_RING_OUTSET = focusIndicatorTokens.thickness + focusIndicatorTokens.outerOffset; @@ -137,10 +134,9 @@ const SegmentedButtonItem = ({ labelMaxFontSizeMultiplier, hitSlop, }: Props) => { - const [pressed, setPressed] = React.useState(false); - const [hovered, setHovered] = React.useState(false); - const [focused, setFocused] = React.useState(false); const checkmarkScale = useSharedValue(0); + const { interactionProps, stateLayerOpacity, showFocusRing } = + useSegmentedButtonInteraction(disabled); React.useEffect(() => { if (!showSelectedCheck) { @@ -167,10 +163,7 @@ const SegmentedButtonItem = ({ checkedColor, uncheckedColor, }); - const segmentBorderRadius = getSegmentedButtonBorderRadius({ - theme, - segment, - }); + const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); const outlineStyle = getSegmentedButtonOutlineStyle(segment); const containerHeight = getSegmentedButtonHeight(density); const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; @@ -226,30 +219,6 @@ const SegmentedButtonItem = ({ icon && (!label || !shouldShowCheckIcon) ); - const stateLayerOpacity = disabled - ? 0 - : pressed - ? stateOpacity.pressed - : focused - ? stateOpacity.focused - : hovered - ? stateOpacity.hovered - : 0; - const showFocusRing = focused && !disabled; - - const handleFocus = (event: NativeSyntheticEvent) => { - if (disabled || !isKeyboardFocusEvent(event)) { - return; - } - - setFocused(true); - }; - - const handleBlur = () => { - setPressed(false); - setFocused(false); - }; - return ( setPressed(true)} - onPressOut={() => setPressed(false)} - onHoverIn={() => setHovered(true)} - onHoverOut={() => setHovered(false)} - onFocus={handleFocus} - onBlur={handleBlur} + {...interactionProps} aria-label={accessibilityLabel} aria-disabled={disabled} aria-checked={checked} diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index 68e3e7b88a..f820c06c82 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -135,9 +135,7 @@ const SegmentedButtons = ({ const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); - const selectedValues = - multiSelect && Array.isArray(value) ? value : undefined; - const singleSelectedIndex = selectedValues + const singleSelectedIndex = multiSelect ? -1 : buttons.findIndex((item) => value === item.value); @@ -150,21 +148,22 @@ const SegmentedButtons = ({ const segment = i === 0 ? 'first' : i === buttons.length - 1 ? 'last' : undefined; - const checked = selectedValues - ? selectedValues.includes(item.value) + const checked = multiSelect + ? value.includes(item.value) : i === singleSelectedIndex; const onPress = (event: GestureResponderEvent) => { item.onPress?.(event); - const nextValue = selectedValues - ? checked - ? selectedValues.filter((val) => item.value !== val) - : [...selectedValues, item.value] - : item.value; - - // @ts-expect-error: TS doesn't preserve types after destructuring, so the type isn't inferred correctly - onValueChange(nextValue); + if (multiSelect) { + onValueChange( + checked + ? value.filter((selectedValue) => item.value !== selectedValue) + : [...value, item.value] + ); + } else { + onValueChange(item.value); + } }; return ( diff --git a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts new file mode 100644 index 0000000000..4bdc60406d --- /dev/null +++ b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; + +import { getSegmentedButtonStateLayerOpacity } from './utils'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; + +type InteractionProps = Pick< + TouchableRippleProps, + 'onPressIn' | 'onPressOut' | 'onHoverIn' | 'onHoverOut' | 'onFocus' | 'onBlur' +>; + +export const useSegmentedButtonInteraction = (disabled?: boolean) => { + const [pressed, setPressed] = React.useState(false); + const [hovered, setHovered] = React.useState(false); + const [focused, setFocused] = React.useState(false); + + const interactionProps: InteractionProps = { + onPressIn: () => setPressed(true), + onPressOut: () => setPressed(false), + onHoverIn: () => setHovered(true), + onHoverOut: () => setHovered(false), + onFocus: (event) => { + if (disabled || !isKeyboardFocusEvent(event)) { + return; + } + + setFocused(true); + }, + onBlur: () => { + setPressed(false); + setFocused(false); + }, + }; + + return { + interactionProps, + stateLayerOpacity: getSegmentedButtonStateLayerOpacity({ + disabled, + pressed, + focused, + hovered, + }), + showFocusRing: focused && !disabled, + }; +}; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 5feea42f63..2a75074078 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -22,42 +22,9 @@ export const getSegmentedButtonHeight = ( density: 'regular' | 'small' | 'medium' | 'high' = 'regular' ) => SegmentedButtonTokens.containerHeight[density]; -export const getSegmentedButtonDensityPadding = ({ - density, -}: { - density?: 'regular' | 'small' | 'medium' | 'high'; -}) => { - return ( - (getSegmentedButtonHeight(density) - - tokens.md.sys.typescale.labelLarge.lineHeight - - SegmentedButtonTokens.outlineWidth * 2) / - 2 - ); -}; - -export const getDisabledSegmentedButtonStyle = ({ - index, - buttons, -}: { - theme: InternalTheme; - buttons: { disabled?: boolean }[]; - index: number; -}): ViewStyle => { - const isDisabled = buttons[index]?.disabled; - const isNextDisabled = buttons[index + 1]?.disabled; - - if (!isDisabled && isNextDisabled) { - return { - borderRightWidth: SegmentedButtonTokens.outlineWidth, - }; - } - return {}; -}; - export const getSegmentedButtonBorderRadius = ({ segment, }: { - theme: InternalTheme; segment?: 'first' | 'last'; }): ViewStyle => { if (segment === 'first') { @@ -160,7 +127,6 @@ export const getSegmentedButtonColors = ({ borderOpacity, textColor, textOpacity, - borderWidth: SegmentedButtonTokens.outlineWidth, stateLayerColor, focusIndicatorColor, }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 6a92e81434..1e30cc12f2 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -7,7 +7,6 @@ import { tokens } from '../../theme/tokens'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; import { - getDisabledSegmentedButtonStyle, getSegmentedButtonColors, getSegmentedButtonHeight, getSegmentedButtonStateLayerOpacity, @@ -15,6 +14,72 @@ import { const stateOpacity = tokens.md.sys.state.opacity; +it('type checks single- and multi-select values with their callbacks', () => { + type Value = 'walk' | 'ride'; + const buttons: { value: Value }[] = [{ value: 'walk' }, { value: 'ride' }]; + const singleValue: Value = 'walk'; + const multiValue: Value[] = ['walk']; + const onSingleValueChange = (_value: Value) => {}; + const onMultiValueChange = (_value: Value[]) => {}; + + const validSingleSelect = ( + + value={singleValue} + buttons={buttons} + onValueChange={onSingleValueChange} + /> + ); + const validMultiSelect = ( + + multiSelect + value={multiValue} + buttons={buttons} + onValueChange={onMultiValueChange} + /> + ); + const invalidSingleSelectValue = ( + // @ts-expect-error Single-select value must be a string. + + value={multiValue} + buttons={buttons} + onValueChange={onSingleValueChange} + /> + ); + const invalidMultiSelectValue = ( + // @ts-expect-error Multi-select value must be an array. + + multiSelect + value={singleValue} + buttons={buttons} + onValueChange={onMultiValueChange} + /> + ); + const invalidSingleSelectCallback = ( + // @ts-expect-error Single-select callback must receive a string. + + value={singleValue} + buttons={buttons} + onValueChange={onMultiValueChange} + /> + ); + const invalidMultiSelectCallback = ( + // @ts-expect-error Multi-select callback must receive an array. + + multiSelect + value={multiValue} + buttons={buttons} + onValueChange={onSingleValueChange} + /> + ); + + expect(validSingleSelect).toBeDefined(); + expect(validMultiSelect).toBeDefined(); + void invalidSingleSelectValue; + void invalidMultiSelectValue; + void invalidSingleSelectCallback; + void invalidMultiSelectCallback; +}); + it('renders segmented button', async () => { const tree = ( await render( @@ -624,7 +689,7 @@ describe('segmented button presentation', () => { } ); - it('renders token opacity for hover and keyboard focus states', async () => { + it('renders state opacity with press, focus, and hover precedence', async () => { await render( { borderWidth: tokens.md.sys.state.focusIndicator.thickness, borderColor: getTheme().colors.secondary, }); - }); -}); -describe('getDisabledSegmentedButtonBorderWidth', () => { - it('Returns empty style object for all enabled buttons', () => { - [0, 1, 2].forEach((index) => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [ - { disabled: false }, - { disabled: false }, - { disabled: false }, - ], - index, - }) - ).toMatchObject({}); - }); - }); + await fireEvent(button, 'pressIn'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.pressed }); - it('Returns empty style object for all disabled buttons', () => { - [0, 1, 2].forEach((index) => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [{ disabled: true }, { disabled: true }, { disabled: true }], - index, - }) - ).toMatchObject({}); - }); - }); + await fireEvent(button, 'pressOut'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); - it('Returns proper style object for one disabled button', () => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [{ disabled: false }, { disabled: true }, { disabled: true }], - index: 0, - }) - ).toMatchObject({ borderRightWidth: 1 }); - }); + await fireEvent(button, 'blur'); + expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); + expect(screen.queryByTestId('walk-focus-ring')).not.toBeOnTheScreen(); - it('Returns proper style object for two disabled buttons (alternately)', () => { - [0, 2].forEach((index) => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [ - { disabled: false }, - { disabled: true }, - { disabled: false }, - { disabled: true }, - ], - index, - }) - ).toMatchObject({ borderRightWidth: 1 }); - }); + await fireEvent(button, 'hoverOut'); + expect(stateLayer).toHaveStyle({ opacity: 0 }); }); }); @@ -1109,7 +1130,7 @@ describe('selected check icon', () => { value: 'walk', label: 'Walking', showSelectedCheck: true, - testID: 'walking-check-icon', + testID: 'walking', }, { value: 'transit', label: 'Transit' }, { value: 'drive', label: 'Driving' }, From 830808846300a4d11bb182590d68a5da7c180f0b Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Wed, 26 Aug 2026 16:36:19 +0200 Subject: [PATCH 06/16] refactor: make SegmentedButtonItem more readable --- .../SegmentedButtonContent.tsx | 59 +++-- .../SegmentedButtons/SegmentedButtonItem.tsx | 211 +++++++++--------- .../SegmentedButtons/SegmentedButtons.tsx | 70 +++--- .../__tests__/SegmentedButton.test.tsx | 38 +++- 4 files changed, 222 insertions(+), 156 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index 094415718f..3dcd675116 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -1,10 +1,16 @@ +import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import type { SharedValue } from 'react-native-reanimated'; import { SegmentedButtonTokens } from './tokens'; +import type { Theme } from '../../types'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import Text from '../Typography/Text'; @@ -62,47 +68,62 @@ const AnimatedOptionIcon = ({ }; type Props = { - checkmarkScale: SharedValue; + checked: boolean; + contentColor: TextStyle['color']; + contentOpacity: number; icon?: IconSource; label?: string; labelMaxFontSizeMultiplier?: number; labelStyle?: StyleProp; - labelTextStyle: TextStyle; - shouldShowCheckIcon: boolean; - shouldShowOptionIcon: boolean; + showSelectedCheck?: boolean; testID?: string; - textColor: TextStyle['color']; - textOpacity: number; + theme: Theme; }; const SegmentedButtonContent = ({ - checkmarkScale, + checked, + contentColor, + contentOpacity, icon, label, labelMaxFontSizeMultiplier, labelStyle, - labelTextStyle, - shouldShowCheckIcon, - shouldShowOptionIcon, + showSelectedCheck, testID, - textColor, - textOpacity, + theme, }: Props) => { + const checkmarkScale = useSharedValue(0); + + React.useEffect(() => { + if (!showSelectedCheck) { + return; + } + + checkmarkScale.value = withSpring(checked ? 1 : 0); + }, [checked, checkmarkScale, showSelectedCheck]); + + const showCheckIcon = Boolean(checked && showSelectedCheck); + const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; + const labelTextStyle: TextStyle = { + ...theme.fonts.labelLarge, + color: contentColor, + }; + return ( - - {shouldShowCheckIcon ? ( + + {showCheckIcon ? ( ) : null} - {shouldShowOptionIcon ? ( + {optionIcon ? ( ) : null} diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index e819c6e7f2..bcaf1e6372 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, @@ -8,8 +7,6 @@ import type { ViewStyle, } from 'react-native'; -import { useSharedValue, withSpring } from 'react-native-reanimated'; - import SegmentedButtonContent from './SegmentedButtonContent'; import { SegmentedButtonTokens } from './tokens'; import { useSegmentedButtonInteraction } from './useSegmentedButtonInteraction'; @@ -30,6 +27,13 @@ const focusIndicatorTokens = tokens.md.sys.state.focusIndicator; const FOCUS_RING_OUTSET = focusIndicatorTokens.thickness + focusIndicatorTokens.outerOffset; +const isBorderRadiusStyle = (property: keyof ViewStyle) => + property === 'borderCurve' || + (property.startsWith('border') && property.endsWith('Radius')); + +const isBorderStyle = (property: keyof ViewStyle) => + property.startsWith('border'); + export type Props = { /** * Whether the segmented button is checked @@ -58,7 +62,7 @@ export type Props = { */ disabled?: boolean; /** - * Type of background drawabale to display the feedback (Android). + * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig */ background?: PressableAndroidRippleConfig; @@ -70,10 +74,6 @@ export type Props = { * Function to execute on press. */ onPress?: (event: GestureResponderEvent) => void; - /** - * Value of button. - */ - value: string; /** * Label text of the button. */ @@ -134,95 +134,30 @@ const SegmentedButtonItem = ({ labelMaxFontSizeMultiplier, hitSlop, }: Props) => { - const checkmarkScale = useSharedValue(0); const { interactionProps, stateLayerOpacity, showFocusRing } = useSegmentedButtonInteraction(disabled); - React.useEffect(() => { - if (!showSelectedCheck) { - return; - } - - checkmarkScale.value = withSpring(checked ? 1 : 0); - }, [checked, checkmarkScale, showSelectedCheck]); - const accessibilityLabel = label || ariaLabel; - const { - backgroundColor, - borderColor, - borderOpacity, - focusIndicatorColor, - stateLayerColor, - textColor, - textOpacity, - } = getSegmentedButtonColors({ + const colors = getSegmentedButtonColors({ checked, theme, disabled, checkedColor, uncheckedColor, }); - const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); - const outlineStyle = getSegmentedButtonOutlineStyle(segment); - const containerHeight = getSegmentedButtonHeight(density); - const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; - const [visualStyleOverrides, borderRadiusStyleOverrides, borderOverrides] = - splitStyles( - flattenedStyle, - (property) => - property === 'borderCurve' || - (property.startsWith('border') && property.endsWith('Radius')), - (property) => property.startsWith('border') - ); - const borderRadiusStyle = { - ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), - ...borderRadiusStyleOverrides, - }; - const focusRingVerticalInset = - (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - - FOCUS_RING_OUTSET; - const labelTextStyle: TextStyle = { - ...theme.fonts.labelLarge, - color: textColor, - }; - const touchableStyle = [ - styles.touchable, - borderRadiusStyle, - Platform.OS === 'web' ? webNoOutline : undefined, - ]; - const visualStyle = [ - styles.visual, - borderRadiusStyle, - { height: containerHeight, backgroundColor }, - Object.keys(visualStyleOverrides).length ? visualStyleOverrides : undefined, - ]; - const outlineContainerStyle = [ - styles.outline, - borderRadiusStyle, - flattenedStyle.borderWidth === undefined ? outlineStyle : undefined, - { borderColor, opacity: borderOpacity }, - Object.keys(borderOverrides).length ? borderOverrides : undefined, - ]; - const focusRingStyle = [ - styles.focusRing, - borderRadiusStyle, - { - top: focusRingVerticalInset, - bottom: focusRingVerticalInset, - borderColor: focusIndicatorColor, - }, - ]; - - const shouldShowCheckIcon = Boolean(checked && showSelectedCheck); - const shouldShowOptionIcon = Boolean( - icon && (!label || !shouldShowCheckIcon) - ); + const layerStyles = getSegmentedButtonItemStyles({ + colors, + density, + segment, + stateLayerOpacity, + style, + }); return ( @@ -281,7 +208,7 @@ const SegmentedButtonItem = ({ ) : null} @@ -289,14 +216,14 @@ const SegmentedButtonItem = ({ }; const styles = StyleSheet.create({ - button: { + wrapper: { flex: 1, minWidth: SegmentedButtonTokens.minimumWidth, minHeight: SegmentedButtonTokens.touchTargetHeight, justifyContent: 'center', overflow: 'visible', }, - focusedButton: { + focusedWrapper: { zIndex: 1, }, touchable: { @@ -304,7 +231,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', overflow: 'visible', }, - visual: { + container: { width: '100%', justifyContent: 'center', }, @@ -334,6 +261,84 @@ const styles = StyleSheet.create({ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; +type ItemStyleOptions = { + colors: ReturnType; + density: NonNullable; + segment: Props['segment']; + stateLayerOpacity: number; + style: Props['style']; +}; + +function getSegmentedButtonItemStyles({ + colors: { + backgroundColor: containerColor, + borderColor: outlineColor, + borderOpacity: outlineOpacity, + focusIndicatorColor, + stateLayerColor, + }, + density, + segment, + stateLayerOpacity, + style, +}: ItemStyleOptions) { + const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); + const segmentOutlineStyle = getSegmentedButtonOutlineStyle(segment); + const containerHeight = getSegmentedButtonHeight(density); + const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; + const [containerStyleOverrides, borderRadiusOverrides, borderOverrides] = + splitStyles(flattenedStyle, isBorderRadiusStyle, isBorderStyle); + const borderRadiusStyle = { + ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), + ...borderRadiusOverrides, + }; + const focusRingVerticalInset = + (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - + FOCUS_RING_OUTSET; + + return { + touchable: [ + styles.touchable, + borderRadiusStyle, + Platform.OS === 'web' ? webNoOutline : undefined, + ], + container: [ + styles.container, + borderRadiusStyle, + { height: containerHeight, backgroundColor: containerColor }, + Object.keys(containerStyleOverrides).length + ? containerStyleOverrides + : undefined, + ], + stateLayer: [ + styles.stateLayer, + borderRadiusStyle, + { + backgroundColor: stateLayerColor, + opacity: stateLayerOpacity, + }, + ], + outline: [ + styles.outline, + borderRadiusStyle, + flattenedStyle.borderWidth === undefined + ? segmentOutlineStyle + : undefined, + { borderColor: outlineColor, opacity: outlineOpacity }, + Object.keys(borderOverrides).length ? borderOverrides : undefined, + ], + focusRing: [ + styles.focusRing, + borderRadiusStyle, + { + top: focusRingVerticalInset, + bottom: focusRingVerticalInset, + borderColor: focusIndicatorColor, + }, + ], + }; +} + export default SegmentedButtonItem; export { SegmentedButtonItem as SegmentedButton }; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index f820c06c82..3f6b4fd84e 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -144,43 +144,47 @@ const SegmentedButtons = ({ role={multiSelect ? 'group' : 'radiogroup'} style={[styles.row, direction === 'rtl' ? styles.rtl : styles.ltr, style]} > - {buttons.map((item, i) => { - const segment = - i === 0 ? 'first' : i === buttons.length - 1 ? 'last' : undefined; + {buttons.map( + ({ value: itemValue, onPress: onItemPress, ...itemProps }, index) => { + const segment = + index === 0 + ? 'first' + : index === buttons.length - 1 + ? 'last' + : undefined; - const checked = multiSelect - ? value.includes(item.value) - : i === singleSelectedIndex; + const checked = multiSelect + ? value.includes(itemValue) + : index === singleSelectedIndex; - const onPress = (event: GestureResponderEvent) => { - item.onPress?.(event); + const handlePress = (event: GestureResponderEvent) => { + onItemPress?.(event); - if (multiSelect) { - onValueChange( - checked - ? value.filter((selectedValue) => item.value !== selectedValue) - : [...value, item.value] - ); - } else { - onValueChange(item.value); - } - }; + if (multiSelect) { + onValueChange( + checked + ? value.filter((selectedValue) => itemValue !== selectedValue) + : [...value, itemValue] + ); + } else { + onValueChange(itemValue); + } + }; - return ( - - ); - })} + return ( + + ); + } + )} ); }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 1e30cc12f2..4dd988b844 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -307,13 +307,17 @@ it('applies group theme overrides to items', async () => { { value: 'walk', label: 'Walking', testID: 'walk' }, { value: 'ride', label: 'Riding' }, ]} - theme={{ colors: { secondaryContainer: '#123456' } }} + theme={{ + colors: { secondaryContainer: '#123456' }, + fonts: { labelLarge: { fontSize: 18 } }, + }} /> ); expect(screen.getByTestId('walk-container')).toHaveStyle({ backgroundColor: '#123456', }); + expect(screen.getByTestId('walk-label')).toHaveStyle({ fontSize: 18 }); }); describe('getSegmentedButtonColors', () => { @@ -756,6 +760,38 @@ describe('should render icon when', () => { expect(screen.getByTestId('driving-button-icon')).toBeOnTheScreen(); }); + it('selected check is shown alongside an icon-only option', async () => { + await render( + {}} + /> + ); + + expect(screen.getByTestId('walking-button-check-icon')).toBeOnTheScreen(); + expect(screen.getByTestId('walking-button-icon')).toBeOnTheScreen(); + expect( + screen.queryByTestId('driving-button-check-icon') + ).not.toBeOnTheScreen(); + expect(screen.getByTestId('driving-button-icon')).toBeOnTheScreen(); + }); + it('icon prop is passed along with label, no matter if button is checked', async () => { await render( Date: Wed, 26 Aug 2026 16:40:11 +0200 Subject: [PATCH 07/16] refactor: make SegmentedButtonItem more readable --- src/components/SegmentedButtons/SegmentedButtonItem.tsx | 6 +++++- src/components/SegmentedButtons/utils.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index bcaf1e6372..4cfa6d4e93 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -146,6 +146,7 @@ const SegmentedButtonItem = ({ checkedColor, uncheckedColor, }); + const layerStyles = getSegmentedButtonItemStyles({ colors, density, @@ -285,13 +286,16 @@ function getSegmentedButtonItemStyles({ const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); const segmentOutlineStyle = getSegmentedButtonOutlineStyle(segment); const containerHeight = getSegmentedButtonHeight(density); - const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; + const flattenedStyle = StyleSheet.flatten(style) || {}; + const [containerStyleOverrides, borderRadiusOverrides, borderOverrides] = splitStyles(flattenedStyle, isBorderRadiusStyle, isBorderStyle); + const borderRadiusStyle = { ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), ...borderRadiusOverrides, }; + const focusRingVerticalInset = (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - FOCUS_RING_OUTSET; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 2a75074078..541743450f 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -99,9 +99,11 @@ export const getSegmentedButtonColors = ({ const backgroundColor = checked ? theme.colors[SegmentedButtonTokens.selectedContainerColor] : 'transparent'; + const borderColor = disabled ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] : theme.colors[SegmentedButtonTokens.outlineColor]; + const textColor = disabled ? theme.colors[SegmentedButtonTokens.disabledContentColor] : checked @@ -109,15 +111,19 @@ export const getSegmentedButtonColors = ({ theme.colors[SegmentedButtonTokens.selectedContentColor]) : (uncheckedColor ?? theme.colors[SegmentedButtonTokens.unselectedContentColor]); + const borderOpacity = disabled ? SegmentedButtonTokens.disabledOutlineOpacity : stateOpacity.enabled; + const textOpacity = disabled ? SegmentedButtonTokens.disabledContentOpacity : stateOpacity.enabled; + const stateLayerColor = checked ? theme.colors[SegmentedButtonTokens.selectedStateLayerColor] : theme.colors[SegmentedButtonTokens.unselectedStateLayerColor]; + const focusIndicatorColor = theme.colors[SegmentedButtonTokens.focusIndicatorColor]; From af6ff44e5f2529afbb31beadc7f19fe8f7b24e64 Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Mon, 31 Aug 2026 14:01:26 +0200 Subject: [PATCH 08/16] fix: border --- .../SegmentedButtons/SegmentedButtonItem.tsx | 59 +++- .../SegmentedButtons/SegmentedButtons.tsx | 3 +- src/components/SegmentedButtons/utils.ts | 27 +- .../__tests__/SegmentedButton.test.tsx | 272 +++++++++++++++++- .../SegmentedButton.test.tsx.snap | 33 ++- 5 files changed, 368 insertions(+), 26 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4cfa6d4e93..b0c8b5985b 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -16,6 +16,7 @@ import { getSegmentedButtonHeight, getSegmentedButtonOutlineStyle, } from './utils'; +import type { SegmentedButtonPosition } from './utils'; import { tokens } from '../../theme/tokens'; import type { Theme } from '../../types'; import { splitStyles } from '../../utils/splitStyles'; @@ -61,6 +62,7 @@ export type Props = { * Whether the button is disabled. */ disabled?: boolean; + previousDisabled?: boolean; /** * Type of background drawable to display the feedback (Android). * https://reactnative.dev/docs/pressable#rippleconfig @@ -81,7 +83,7 @@ export type Props = { /** * Button segment. */ - segment?: 'first' | 'last'; + segment: SegmentedButtonPosition; /** * Show optional check icon to indicate selected state */ @@ -118,6 +120,7 @@ const SegmentedButtonItem = ({ role, 'aria-label': ariaLabel, disabled, + previousDisabled, style, labelStyle, showSelectedCheck, @@ -143,6 +146,7 @@ const SegmentedButtonItem = ({ checked, theme, disabled, + previousDisabled, checkedColor, uncheckedColor, }); @@ -203,6 +207,13 @@ const SegmentedButtonItem = ({ testID={testID ? `${testID}-outline` : undefined} style={layerStyles.outline} /> + {layerStyles.sharedBorder ? ( + + ) : null} {showFocusRing ? ( @@ -277,6 +288,8 @@ function getSegmentedButtonItemStyles({ borderOpacity: outlineOpacity, focusIndicatorColor, stateLayerColor, + sharedBorderColor, + sharedBorderOpacity, }, density, segment, @@ -284,13 +297,40 @@ function getSegmentedButtonItemStyles({ style, }: ItemStyleOptions) { const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); - const segmentOutlineStyle = getSegmentedButtonOutlineStyle(segment); const containerHeight = getSegmentedButtonHeight(density); const flattenedStyle = StyleSheet.flatten(style) || {}; const [containerStyleOverrides, borderRadiusOverrides, borderOverrides] = splitStyles(flattenedStyle, isBorderRadiusStyle, isBorderStyle); + const outlineWidth = + borderOverrides.borderWidth ?? SegmentedButtonTokens.outlineWidth; + const explicitBorderOverrides = { ...borderOverrides }; + delete explicitBorderOverrides.borderWidth; + + const resolvedBorderStyle = { + ...getSegmentedButtonOutlineStyle(segment, outlineWidth), + ...explicitBorderOverrides, + }; + const hasSharedBorder = segment !== 'first'; + const { borderStartWidth, borderStartColor, ...nonSharedBorderStyle } = + resolvedBorderStyle; + const outlineBorderStyle = hasSharedBorder + ? nonSharedBorderStyle + : resolvedBorderStyle; + const sharedBorderStyle: ViewStyle | undefined = hasSharedBorder + ? { + borderStartWidth, + ...(resolvedBorderStyle.borderColor !== undefined + ? { borderColor: resolvedBorderStyle.borderColor } + : {}), + ...(resolvedBorderStyle.borderStyle !== undefined + ? { borderStyle: resolvedBorderStyle.borderStyle } + : {}), + ...(borderStartColor !== undefined ? { borderStartColor } : {}), + } + : undefined; + const borderRadiusStyle = { ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), ...borderRadiusOverrides, @@ -325,12 +365,19 @@ function getSegmentedButtonItemStyles({ outline: [ styles.outline, borderRadiusStyle, - flattenedStyle.borderWidth === undefined - ? segmentOutlineStyle - : undefined, { borderColor: outlineColor, opacity: outlineOpacity }, - Object.keys(borderOverrides).length ? borderOverrides : undefined, + outlineBorderStyle, ], + sharedBorder: sharedBorderStyle + ? [ + styles.outline, + { + borderColor: sharedBorderColor, + opacity: sharedBorderOpacity, + }, + sharedBorderStyle, + ] + : undefined, focusRing: [ styles.focusRing, borderRadiusStyle, diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index 3f6b4fd84e..6c4d635348 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -151,7 +151,7 @@ const SegmentedButtons = ({ ? 'first' : index === buttons.length - 1 ? 'last' - : undefined; + : 'middle'; const checked = multiSelect ? value.includes(itemValue) @@ -176,6 +176,7 @@ const SegmentedButtons = ({ {...itemProps} key={index} checked={checked} + previousDisabled={buttons[index - 1]?.disabled} role={multiSelect ? 'checkbox' : 'radio'} segment={segment} density={density} diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 541743450f..f99a3bb625 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -16,8 +16,11 @@ type BaseProps = { type SegmentedButtonProps = { checkedColor?: string; uncheckedColor?: string; + previousDisabled?: boolean; } & BaseProps; +export type SegmentedButtonPosition = 'first' | 'last' | 'middle'; + export const getSegmentedButtonHeight = ( density: 'regular' | 'small' | 'medium' | 'high' = 'regular' ) => SegmentedButtonTokens.containerHeight[density]; @@ -25,7 +28,7 @@ export const getSegmentedButtonHeight = ( export const getSegmentedButtonBorderRadius = ({ segment, }: { - segment?: 'first' | 'last'; + segment: SegmentedButtonPosition; }): ViewStyle => { if (segment === 'first') { return { @@ -51,12 +54,13 @@ export const getSegmentedButtonBorderRadius = ({ }; export const getSegmentedButtonOutlineStyle = ( - segment?: 'first' | 'last' + segment: SegmentedButtonPosition, + outlineWidth: ViewStyle['borderWidth'] = SegmentedButtonTokens.outlineWidth ): ViewStyle => ({ - borderTopWidth: SegmentedButtonTokens.outlineWidth, - borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderStartWidth: SegmentedButtonTokens.outlineWidth, - borderEndWidth: segment === 'last' ? SegmentedButtonTokens.outlineWidth : 0, + borderTopWidth: outlineWidth, + borderBottomWidth: outlineWidth, + borderStartWidth: outlineWidth, + borderEndWidth: segment === 'last' ? outlineWidth : 0, }); export const getSegmentedButtonStateLayerOpacity = ({ @@ -95,6 +99,7 @@ export const getSegmentedButtonColors = ({ checked, checkedColor, uncheckedColor, + previousDisabled, }: SegmentedButtonProps) => { const backgroundColor = checked ? theme.colors[SegmentedButtonTokens.selectedContainerColor] @@ -124,6 +129,14 @@ export const getSegmentedButtonColors = ({ ? theme.colors[SegmentedButtonTokens.selectedStateLayerColor] : theme.colors[SegmentedButtonTokens.unselectedStateLayerColor]; + const sharedBorderDisabled = Boolean(disabled && previousDisabled); + const sharedBorderColor = sharedBorderDisabled + ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] + : theme.colors[SegmentedButtonTokens.outlineColor]; + const sharedBorderOpacity = sharedBorderDisabled + ? SegmentedButtonTokens.disabledOutlineOpacity + : stateOpacity.enabled; + const focusIndicatorColor = theme.colors[SegmentedButtonTokens.focusIndicatorColor]; @@ -134,6 +147,8 @@ export const getSegmentedButtonColors = ({ textColor, textOpacity, stateLayerColor, + sharedBorderColor, + sharedBorderOpacity, focusIndicatorColor, }; }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 4dd988b844..6821499d8d 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -7,8 +7,10 @@ import { tokens } from '../../theme/tokens'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; import { + getSegmentedButtonBorderRadius, getSegmentedButtonColors, getSegmentedButtonHeight, + getSegmentedButtonOutlineStyle, getSegmentedButtonStateLayerOpacity, } from '../SegmentedButtons/utils'; @@ -532,7 +534,189 @@ describe('getSegmentedButtonStateLayerOpacity', () => { ); }); +describe('segmented button topology helpers', () => { + it.each([ + { + segment: 'first' as const, + expected: { + borderTopStartRadius: 9999, + borderBottomStartRadius: 9999, + borderTopEndRadius: 0, + borderBottomEndRadius: 0, + }, + }, + { + segment: 'middle' as const, + expected: { borderRadius: 0 }, + }, + { + segment: 'last' as const, + expected: { + borderTopStartRadius: 0, + borderBottomStartRadius: 0, + borderTopEndRadius: 9999, + borderBottomEndRadius: 9999, + }, + }, + ])('returns the $segment segment radii', ({ segment, expected }) => { + expect(getSegmentedButtonBorderRadius({ segment })).toEqual(expected); + }); + + it.each([ + { segment: 'first' as const, borderEndWidth: 0 }, + { segment: 'middle' as const, borderEndWidth: 0 }, + { segment: 'last' as const, borderEndWidth: 3 }, + ])( + 'returns the $segment segment outline widths', + ({ segment, borderEndWidth }) => { + expect(getSegmentedButtonOutlineStyle(segment, 3)).toEqual({ + borderTopWidth: 3, + borderBottomWidth: 3, + borderStartWidth: 3, + borderEndWidth, + }); + } + ); +}); + describe('segmented button presentation', () => { + const dividerCases = (['ltr', 'rtl'] as const).flatMap((direction) => + ( + [ + [false, false, false], + [false, false, true], + [false, true, false], + [false, true, true], + [true, false, false], + [true, false, true], + [true, true, false], + [true, true, true], + ] as const + ).map((disabledStates) => ({ direction, disabledStates })) + ); + + it.each(['ltr', 'rtl'] as const)( + 'renders first, middle, and last geometry in %s', + async (direction) => { + const view = await render( + + {}} + buttons={[ + { value: 'first', label: 'First', testID: 'first' }, + { value: 'middle', label: 'Middle', testID: 'middle' }, + { value: 'last', label: 'Last', testID: 'last' }, + ]} + /> + + ); + const segmentCases = [ + { + id: 'first', + radii: { + borderTopStartRadius: 9999, + borderBottomStartRadius: 9999, + borderTopEndRadius: 0, + borderBottomEndRadius: 0, + }, + borderEndWidth: 0, + }, + { + id: 'middle', + radii: { borderRadius: 0 }, + borderEndWidth: 0, + }, + { + id: 'last', + radii: { + borderTopStartRadius: 0, + borderBottomStartRadius: 0, + borderTopEndRadius: 9999, + borderBottomEndRadius: 9999, + }, + borderEndWidth: SegmentedButtonTokens.outlineWidth, + }, + ]; + + expect(view.root).toHaveStyle({ direction }); + + for (const { id, radii, borderEndWidth } of segmentCases) { + expect(screen.getByTestId(id)).toHaveStyle(radii); + expect(screen.getByTestId(`${id}-container`)).toHaveStyle(radii); + expect(screen.getByTestId(`${id}-state-layer`)).toHaveStyle(radii); + expect(screen.getByTestId(`${id}-outline`)).toHaveStyle({ + ...radii, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth, + }); + + await fireEvent(screen.getByTestId(id), 'focus'); + expect(screen.getByTestId(`${id}-focus-ring`)).toHaveStyle(radii); + await fireEvent(screen.getByTestId(id), 'blur'); + } + + expect(screen.getByTestId('first-outline')).toHaveStyle({ + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + expect(screen.queryByTestId('first-divider')).not.toBeOnTheScreen(); + + ['middle', 'last'].forEach((id) => { + expect(screen.getByTestId(`${id}-outline`)).not.toHaveStyle({ + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + expect(screen.getByTestId(`${id}-divider`)).toHaveStyle({ + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + }); + } + ); + + it.each(dividerCases)( + 'renders each $direction divider once for disabled states $disabledStates', + async ({ direction, disabledStates }) => { + const ids = ['first', 'middle', 'last'] as const; + + const view = await render( + + {}} + buttons={ids.map((id, index) => ({ + value: id, + label: id, + testID: id, + disabled: disabledStates[index], + }))} + /> + + ); + + expect(view.root).toHaveStyle({ direction }); + expect(screen.queryAllByTestId(/-divider$/)).toHaveLength(2); + expect(screen.queryByTestId('first-divider')).not.toBeOnTheScreen(); + + [1, 2].forEach((index) => { + const dividerDisabled = + disabledStates[index - 1] && disabledStates[index]; + + expect(screen.getByTestId(`${ids[index]}-divider`)).toHaveStyle({ + borderColor: dividerDisabled + ? getTheme().colors.onSurface + : getTheme().colors.outline, + opacity: dividerDisabled + ? SegmentedButtonTokens.disabledOutlineOpacity + : stateOpacity.enabled, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + expect(screen.getByTestId(`${ids[index]}-outline`)).not.toHaveStyle({ + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + }); + } + ); + it('applies custom backgrounds, radii, and shadows to selected and unselected visual containers', async () => { const selectedStyle = { backgroundColor: '#112233', @@ -584,7 +768,7 @@ describe('segmented button presentation', () => { }); }); - it('applies custom borders to the outline and lets borderWidth replace its edge widths', async () => { + it('applies custom borders without double-drawing an interior edge', async () => { await render( { testID: 'drive', style: { borderColor: '#654321', + borderStartColor: '#abcdef', + borderStyle: 'dotted', borderTopWidth: 4, }, }, @@ -618,20 +804,94 @@ describe('segmented button presentation', () => { expect(selectedOutline).toHaveStyle({ borderColor: '#123456', borderStyle: 'dashed', - borderWidth: 3, - }); - expect(selectedOutline).not.toHaveStyle({ - borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderTopWidth: 3, + borderBottomWidth: 3, + borderStartWidth: 3, + borderEndWidth: 0, }); + expect(selectedOutline).not.toHaveStyle({ borderWidth: 3 }); expect(screen.getByTestId('drive-outline')).toHaveStyle({ borderColor: '#654321', + borderStyle: 'dotted', borderTopWidth: 4, borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderStartWidth: SegmentedButtonTokens.outlineWidth, borderEndWidth: SegmentedButtonTokens.outlineWidth, }); + expect(screen.getByTestId('drive-outline')).not.toHaveStyle({ + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + expect(screen.getByTestId('drive-divider')).toHaveStyle({ + borderColor: '#654321', + borderStartColor: '#abcdef', + borderStyle: 'dotted', + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); }); + it.each(['ltr', 'rtl'] as const)( + 'keeps generic and explicit custom widths topology-aware in %s', + async (direction) => { + await render( + + {}} + buttons={[ + { + value: 'first', + label: 'First', + testID: 'custom-first', + style: { borderWidth: 3, borderTopWidth: 4 }, + }, + { + value: 'middle', + label: 'Middle', + testID: 'custom-middle', + style: { borderWidth: 3, borderStartWidth: 5 }, + }, + { + value: 'last', + label: 'Last', + testID: 'custom-last', + style: { + borderWidth: 3, + borderBottomWidth: 6, + borderEndWidth: 7, + }, + }, + ]} + /> + + ); + + expect(screen.getByTestId('custom-first-outline')).toHaveStyle({ + borderTopWidth: 4, + borderBottomWidth: 3, + borderStartWidth: 3, + borderEndWidth: 0, + }); + expect( + screen.queryByTestId('custom-first-divider') + ).not.toBeOnTheScreen(); + expect(screen.getByTestId('custom-middle-outline')).toHaveStyle({ + borderTopWidth: 3, + borderBottomWidth: 3, + borderEndWidth: 0, + }); + expect(screen.getByTestId('custom-middle-divider')).toHaveStyle({ + borderStartWidth: 5, + }); + expect(screen.getByTestId('custom-last-outline')).toHaveStyle({ + borderTopWidth: 3, + borderBottomWidth: 6, + borderEndWidth: 7, + }); + expect(screen.getByTestId('custom-last-divider')).toHaveStyle({ + borderStartWidth: 3, + }); + } + ); + it('does not move visual styles to the hit target', async () => { await render( @@ -413,17 +412,37 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 9999, "borderTopStartRadius": 0, }, + { + "borderColor": "rgba(121, 116, 126, 1)", + "opacity": 1, + }, { "borderBottomWidth": 1, "borderEndWidth": 1, - "borderStartWidth": 1, "borderTopWidth": 1, }, + ] + } + /> + From e6fd56377e10f9866e717ddc1d575d56839a5fcc Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Mon, 31 Aug 2026 15:31:52 +0200 Subject: [PATCH 09/16] fix: tokens --- .../SegmentedButtonContent.tsx | 49 ++++-- .../SegmentedButtons/SegmentedButtonItem.tsx | 73 ++++----- src/components/SegmentedButtons/tokens.ts | 96 +++++++++++- .../useSegmentedButtonInteraction.ts | 12 +- src/components/SegmentedButtons/utils.ts | 140 +++++++++++++++--- .../__tests__/SegmentedButton.test.tsx | 140 +++++++++++++++--- .../SegmentedButton.test.tsx.snap | 52 +++---- 7 files changed, 427 insertions(+), 135 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index 3dcd675116..c56fb191ad 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -17,17 +17,26 @@ import Text from '../Typography/Text'; type AnimatedIconProps = { color: TextStyle['color']; + opacity: number; scale: SharedValue; testID?: string; }; -const AnimatedCheckIcon = ({ color, scale, testID }: AnimatedIconProps) => { +const AnimatedCheckIcon = ({ + color, + opacity, + scale, + testID, +}: AnimatedIconProps) => { const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); return ( - + ; showSelectedCheck?: boolean; testID?: string; @@ -82,11 +94,13 @@ type Props = { const SegmentedButtonContent = ({ checked, - contentColor, - contentOpacity, icon, + iconColor, + iconOpacity, label, + labelColor, labelMaxFontSizeMultiplier, + labelOpacity, labelStyle, showSelectedCheck, testID, @@ -105,15 +119,16 @@ const SegmentedButtonContent = ({ const showCheckIcon = Boolean(checked && showSelectedCheck); const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; const labelTextStyle: TextStyle = { - ...theme.fonts.labelLarge, - color: contentColor, + ...theme.fonts[SegmentedButtonTokens.labelTextType], + color: labelColor, }; return ( - + {showCheckIcon ? ( @@ -121,7 +136,8 @@ const SegmentedButtonContent = ({ {optionIcon ? ( property === 'borderCurve' || (property.startsWith('border') && property.endsWith('Radius')); @@ -137,8 +132,12 @@ const SegmentedButtonItem = ({ labelMaxFontSizeMultiplier, hitSlop, }: Props) => { - const { interactionProps, stateLayerOpacity, showFocusRing } = - useSegmentedButtonInteraction(disabled); + const { + interactionProps, + interactionState, + stateLayerOpacity, + showFocusRing, + } = useSegmentedButtonInteraction(disabled); const accessibilityLabel = label || ariaLabel; @@ -149,6 +148,7 @@ const SegmentedButtonItem = ({ previousDisabled, checkedColor, uncheckedColor, + interactionState, }); const layerStyles = getSegmentedButtonItemStyles({ @@ -192,11 +192,13 @@ const SegmentedButtonItem = ({ /> ; + container: StyleProp; + stateLayer: StyleProp; + outline: StyleProp; + sharedBorder?: StyleProp; + focusRing: StyleProp; +}; + function getSegmentedButtonItemStyles({ colors: { backgroundColor: containerColor, @@ -295,50 +306,26 @@ function getSegmentedButtonItemStyles({ segment, stateLayerOpacity, style, -}: ItemStyleOptions) { +}: ItemStyleOptions): ItemLayerStyles { const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); const containerHeight = getSegmentedButtonHeight(density); const flattenedStyle = StyleSheet.flatten(style) || {}; + // Radius properties must be matched before the broader border filter. const [containerStyleOverrides, borderRadiusOverrides, borderOverrides] = splitStyles(flattenedStyle, isBorderRadiusStyle, isBorderStyle); - const outlineWidth = - borderOverrides.borderWidth ?? SegmentedButtonTokens.outlineWidth; - const explicitBorderOverrides = { ...borderOverrides }; - delete explicitBorderOverrides.borderWidth; - - const resolvedBorderStyle = { - ...getSegmentedButtonOutlineStyle(segment, outlineWidth), - ...explicitBorderOverrides, - }; - const hasSharedBorder = segment !== 'first'; - const { borderStartWidth, borderStartColor, ...nonSharedBorderStyle } = - resolvedBorderStyle; - const outlineBorderStyle = hasSharedBorder - ? nonSharedBorderStyle - : resolvedBorderStyle; - const sharedBorderStyle: ViewStyle | undefined = hasSharedBorder - ? { - borderStartWidth, - ...(resolvedBorderStyle.borderColor !== undefined - ? { borderColor: resolvedBorderStyle.borderColor } - : {}), - ...(resolvedBorderStyle.borderStyle !== undefined - ? { borderStyle: resolvedBorderStyle.borderStyle } - : {}), - ...(borderStartColor !== undefined ? { borderStartColor } : {}), - } - : undefined; + const { outlineBorderStyle, sharedBorderStyle } = + getSegmentedButtonBorderStyles({ segment, borderOverrides }); const borderRadiusStyle = { ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), ...borderRadiusOverrides, }; - const focusRingVerticalInset = - (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2 - - FOCUS_RING_OUTSET; + const containerVerticalInset = + (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2; + const focusRingVerticalInset = containerVerticalInset - FOCUS_RING_OUTSET; return { touchable: [ diff --git a/src/components/SegmentedButtons/tokens.ts b/src/components/SegmentedButtons/tokens.ts index 7194bf5bca..37b86be3e5 100644 --- a/src/components/SegmentedButtons/tokens.ts +++ b/src/components/SegmentedButtons/tokens.ts @@ -1,5 +1,20 @@ +import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import type { ColorRole } from '../../theme/types'; +export type SegmentedButtonInteractionState = + | 'enabled' + | 'hovered' + | 'focused' + | 'pressed'; + +type ActiveInteractionState = Exclude< + SegmentedButtonInteractionState, + 'enabled' +>; + +const stateTokens = tokens.md.sys.state; + const sizes = { containerHeight: { regular: 40, @@ -13,20 +28,85 @@ const sizes = { iconSize: 18, iconLabelGap: 8, outlineWidth: 1, - disabledContentOpacity: 0.38, + containerShape: cornerFull, + labelTextType: 'labelLarge', + disabledLabelTextOpacity: stateTokens.opacity.disabled, + disabledIconOpacity: stateTokens.opacity.disabled, disabledOutlineOpacity: 0.12, + stateLayerOpacity: { + hovered: stateTokens.opacity.hovered, + focused: stateTokens.opacity.focused, + pressed: stateTokens.opacity.pressed, + } as const satisfies Record, + focusIndicatorThickness: stateTokens.focusIndicator.thickness, + focusIndicatorOutlineOffset: stateTokens.focusIndicator.outerOffset, } as const; -const colors = { +const baseColors = { selectedContainerColor: 'secondaryContainer', - selectedContentColor: 'onSecondaryContainer', - unselectedContentColor: 'onSurface', outlineColor: 'outline', - disabledContentColor: 'onSurface', disabledOutlineColor: 'onSurface', - selectedStateLayerColor: 'onSecondaryContainer', - unselectedStateLayerColor: 'onSurface', + disabledLabelTextColor: 'onSurface', + disabledIconColor: 'onSurface', focusIndicatorColor: 'secondary', } as const satisfies Record; -export const SegmentedButtonTokens = { ...sizes, ...colors }; +const contentColors = { + selectedLabelTextColor: { + enabled: 'onSecondaryContainer', + hovered: 'onSecondaryContainer', + focused: 'onSecondaryContainer', + pressed: 'onSecondaryContainer', + }, + unselectedLabelTextColor: { + enabled: 'onSurface', + hovered: 'onSurface', + focused: 'onSurface', + pressed: 'onSurface', + }, + selectedIconColor: { + enabled: 'onSecondaryContainer', + hovered: 'onSecondaryContainer', + focused: 'onSecondaryContainer', + pressed: 'onSecondaryContainer', + }, + unselectedIconColor: { + enabled: 'onSurface', + hovered: 'onSurface', + focused: 'onSurface', + pressed: 'onSurface', + }, +} as const satisfies Record< + | 'selectedLabelTextColor' + | 'unselectedLabelTextColor' + | 'selectedIconColor' + | 'unselectedIconColor', + Record +>; + +const stateLayerColors = { + selectedStateLayerColor: { + hovered: 'onSecondaryContainer', + focused: 'onSecondaryContainer', + pressed: 'onSecondaryContainer', + }, + unselectedStateLayerColor: { + hovered: 'onSurface', + focused: 'onSurface', + pressed: 'onSurface', + }, +} as const satisfies Record< + 'selectedStateLayerColor' | 'unselectedStateLayerColor', + Record +>; + +export const SegmentedButtonTokens = { + ...sizes, + ...baseColors, + ...contentColors, + ...stateLayerColors, +}; + +export const FOCUS_RING_OUTSET = + SegmentedButtonTokens.focusIndicatorThickness + + SegmentedButtonTokens.focusIndicatorOutlineOffset; diff --git a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts index 4bdc60406d..6520a5b4e6 100644 --- a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts +++ b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts @@ -1,6 +1,9 @@ import * as React from 'react'; -import { getSegmentedButtonStateLayerOpacity } from './utils'; +import { + getSegmentedButtonInteractionState, + getSegmentedButtonStateLayerOpacity, +} from './utils'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -32,8 +35,15 @@ export const useSegmentedButtonInteraction = (disabled?: boolean) => { }, }; + const interactionState = getSegmentedButtonInteractionState({ + pressed, + focused, + hovered, + }); + return { interactionProps, + interactionState, stateLayerOpacity: getSegmentedButtonStateLayerOpacity({ disabled, pressed, diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index f99a3bb625..d1bf9e67c3 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -1,12 +1,9 @@ import type { ViewStyle } from 'react-native'; import { SegmentedButtonTokens } from './tokens'; -import { tokens } from '../../theme/tokens'; -import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { SegmentedButtonInteractionState } from './tokens'; import type { InternalTheme } from '../../types'; -const stateOpacity = tokens.md.sys.state.opacity; - type BaseProps = { theme: InternalTheme; disabled?: boolean; @@ -17,6 +14,7 @@ type SegmentedButtonProps = { checkedColor?: string; uncheckedColor?: string; previousDisabled?: boolean; + interactionState?: SegmentedButtonInteractionState; } & BaseProps; export type SegmentedButtonPosition = 'first' | 'last' | 'middle'; @@ -32,8 +30,8 @@ export const getSegmentedButtonBorderRadius = ({ }): ViewStyle => { if (segment === 'first') { return { - borderTopStartRadius: cornerFull, - borderBottomStartRadius: cornerFull, + borderTopStartRadius: SegmentedButtonTokens.containerShape, + borderBottomStartRadius: SegmentedButtonTokens.containerShape, borderTopEndRadius: 0, borderBottomEndRadius: 0, }; @@ -43,8 +41,8 @@ export const getSegmentedButtonBorderRadius = ({ return { borderTopStartRadius: 0, borderBottomStartRadius: 0, - borderTopEndRadius: cornerFull, - borderBottomEndRadius: cornerFull, + borderTopEndRadius: SegmentedButtonTokens.containerShape, + borderBottomEndRadius: SegmentedButtonTokens.containerShape, }; } @@ -63,6 +61,49 @@ export const getSegmentedButtonOutlineStyle = ( borderEndWidth: segment === 'last' ? outlineWidth : 0, }); +type SegmentedButtonBorderStyles = { + outlineBorderStyle: ViewStyle; + sharedBorderStyle?: ViewStyle; +}; + +export const getSegmentedButtonBorderStyles = ({ + segment, + borderOverrides, +}: { + segment: SegmentedButtonPosition; + borderOverrides: ViewStyle; +}): SegmentedButtonBorderStyles => { + const { borderWidth, ...explicitBorderOverrides } = borderOverrides; + const resolvedBorderStyle = { + ...getSegmentedButtonOutlineStyle( + segment, + borderWidth ?? SegmentedButtonTokens.outlineWidth + ), + ...explicitBorderOverrides, + }; + + if (segment === 'first') { + return { outlineBorderStyle: resolvedBorderStyle }; + } + + const { borderStartWidth, borderStartColor, ...outlineBorderStyle } = + resolvedBorderStyle; + + // The shared edge is separate so adjacent disabled items can style it once. + const sharedBorderStyle: ViewStyle = { + borderStartWidth, + ...(resolvedBorderStyle.borderColor !== undefined + ? { borderColor: resolvedBorderStyle.borderColor } + : {}), + ...(resolvedBorderStyle.borderStyle !== undefined + ? { borderStyle: resolvedBorderStyle.borderStyle } + : {}), + ...(borderStartColor !== undefined ? { borderStartColor } : {}), + }; + + return { outlineBorderStyle, sharedBorderStyle }; +}; + export const getSegmentedButtonStateLayerOpacity = ({ disabled, pressed, @@ -79,20 +120,44 @@ export const getSegmentedButtonStateLayerOpacity = ({ } if (pressed) { - return stateOpacity.pressed; + return SegmentedButtonTokens.stateLayerOpacity.pressed; } if (focused) { - return stateOpacity.focused; + return SegmentedButtonTokens.stateLayerOpacity.focused; } if (hovered) { - return stateOpacity.hovered; + return SegmentedButtonTokens.stateLayerOpacity.hovered; } return 0; }; +export const getSegmentedButtonInteractionState = ({ + pressed, + focused, + hovered, +}: { + pressed: boolean; + focused: boolean; + hovered: boolean; +}): SegmentedButtonInteractionState => { + if (pressed) { + return 'pressed'; + } + + if (focused) { + return 'focused'; + } + + if (hovered) { + return 'hovered'; + } + + return 'enabled'; +}; + export const getSegmentedButtonColors = ({ theme, disabled, @@ -100,6 +165,7 @@ export const getSegmentedButtonColors = ({ checkedColor, uncheckedColor, previousDisabled, + interactionState = 'enabled', }: SegmentedButtonProps) => { const backgroundColor = checked ? theme.colors[SegmentedButtonTokens.selectedContainerColor] @@ -109,25 +175,49 @@ export const getSegmentedButtonColors = ({ ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] : theme.colors[SegmentedButtonTokens.outlineColor]; + const customContentColor = checked ? checkedColor : uncheckedColor; const textColor = disabled - ? theme.colors[SegmentedButtonTokens.disabledContentColor] + ? theme.colors[SegmentedButtonTokens.disabledLabelTextColor] : checked - ? (checkedColor ?? - theme.colors[SegmentedButtonTokens.selectedContentColor]) - : (uncheckedColor ?? - theme.colors[SegmentedButtonTokens.unselectedContentColor]); + ? (customContentColor ?? + theme.colors[ + SegmentedButtonTokens.selectedLabelTextColor[interactionState] + ]) + : (customContentColor ?? + theme.colors[ + SegmentedButtonTokens.unselectedLabelTextColor[interactionState] + ]); + + const iconColor = disabled + ? theme.colors[SegmentedButtonTokens.disabledIconColor] + : checked + ? (customContentColor ?? + theme.colors[SegmentedButtonTokens.selectedIconColor[interactionState]]) + : (customContentColor ?? + theme.colors[ + SegmentedButtonTokens.unselectedIconColor[interactionState] + ]); const borderOpacity = disabled ? SegmentedButtonTokens.disabledOutlineOpacity - : stateOpacity.enabled; + : 1; const textOpacity = disabled - ? SegmentedButtonTokens.disabledContentOpacity - : stateOpacity.enabled; - - const stateLayerColor = checked - ? theme.colors[SegmentedButtonTokens.selectedStateLayerColor] - : theme.colors[SegmentedButtonTokens.unselectedStateLayerColor]; + ? SegmentedButtonTokens.disabledLabelTextOpacity + : 1; + + const iconOpacity = disabled ? SegmentedButtonTokens.disabledIconOpacity : 1; + + const stateLayerColor = + disabled || interactionState === 'enabled' + ? 'transparent' + : checked + ? theme.colors[ + SegmentedButtonTokens.selectedStateLayerColor[interactionState] + ] + : theme.colors[ + SegmentedButtonTokens.unselectedStateLayerColor[interactionState] + ]; const sharedBorderDisabled = Boolean(disabled && previousDisabled); const sharedBorderColor = sharedBorderDisabled @@ -135,7 +225,7 @@ export const getSegmentedButtonColors = ({ : theme.colors[SegmentedButtonTokens.outlineColor]; const sharedBorderOpacity = sharedBorderDisabled ? SegmentedButtonTokens.disabledOutlineOpacity - : stateOpacity.enabled; + : 1; const focusIndicatorColor = theme.colors[SegmentedButtonTokens.focusIndicatorColor]; @@ -146,6 +236,8 @@ export const getSegmentedButtonColors = ({ borderOpacity, textColor, textOpacity, + iconColor, + iconOpacity, stateLayerColor, sharedBorderColor, sharedBorderOpacity, diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 6821499d8d..8f6eedb5ee 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -3,19 +3,17 @@ import { describe, expect, it, jest } from '@jest/globals'; import { LocaleProvider } from '../../core/locale'; import { getTheme } from '../../core/theming'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; -import { tokens } from '../../theme/tokens'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; import { getSegmentedButtonBorderRadius, + getSegmentedButtonBorderStyles, getSegmentedButtonColors, getSegmentedButtonHeight, getSegmentedButtonOutlineStyle, getSegmentedButtonStateLayerOpacity, } from '../SegmentedButtons/utils'; -const stateOpacity = tokens.md.sys.state.opacity; - it('type checks single- and multi-select values with their callbacks', () => { type Value = 'walk' | 'ride'; const buttons: { value: Value }[] = [{ value: 'walk' }, { value: 'ride' }]; @@ -109,6 +107,7 @@ it('renders disabled segmented button', async () => { { value: 'ride', label: 'Riding', + icon: 'car', disabled: true, testID: 'ride', }, @@ -120,6 +119,12 @@ it('renders disabled segmented button', async () => { borderColor: getTheme().colors.onSurface, opacity: SegmentedButtonTokens.disabledOutlineOpacity, }); + expect(screen.getByTestId('ride-label')).toHaveStyle({ + opacity: SegmentedButtonTokens.disabledLabelTextOpacity, + }); + expect(screen.getByTestId('ride-icon')).toHaveStyle({ + opacity: SegmentedButtonTokens.disabledIconOpacity, + }); }); it('renders checked segmented button with selected check', async () => { @@ -400,7 +405,7 @@ describe('getSegmentedButtonColors', () => { checkedColor, uncheckedColor, }) - ).toMatchObject({ textColor: expected }); + ).toMatchObject({ textColor: expected, iconColor: expected }); } ); @@ -472,7 +477,9 @@ describe('getSegmentedButtonColors', () => { }) ).toMatchObject({ textColor: getTheme().colors.onSurface, - textOpacity: stateOpacity.disabled, + textOpacity: SegmentedButtonTokens.disabledLabelTextOpacity, + iconColor: getTheme().colors.onSurface, + iconOpacity: SegmentedButtonTokens.disabledIconOpacity, }); }); }); @@ -493,7 +500,7 @@ describe('getSegmentedButtonStateLayerOpacity', () => { pressed: true, focused: true, hovered: true, - expected: stateOpacity.pressed, + expected: SegmentedButtonTokens.stateLayerOpacity.pressed, }, { state: 'focused', @@ -501,7 +508,7 @@ describe('getSegmentedButtonStateLayerOpacity', () => { pressed: false, focused: true, hovered: true, - expected: stateOpacity.focused, + expected: SegmentedButtonTokens.stateLayerOpacity.focused, }, { state: 'hovered', @@ -509,7 +516,7 @@ describe('getSegmentedButtonStateLayerOpacity', () => { pressed: false, focused: false, hovered: true, - expected: stateOpacity.hovered, + expected: SegmentedButtonTokens.stateLayerOpacity.hovered, }, { state: 'idle', @@ -577,6 +584,76 @@ describe('segmented button topology helpers', () => { }); } ); + + it('keeps the first segment border on its outline', () => { + expect( + getSegmentedButtonBorderStyles({ + segment: 'first', + borderOverrides: { + borderWidth: 3, + borderColor: '#123456', + borderStartColor: '#abcdef', + borderStyle: 'dashed', + }, + }) + ).toEqual({ + outlineBorderStyle: { + borderTopWidth: 3, + borderBottomWidth: 3, + borderStartWidth: 3, + borderEndWidth: 0, + borderColor: '#123456', + borderStartColor: '#abcdef', + borderStyle: 'dashed', + }, + }); + }); + + it('moves a non-first segment start edge to the shared border', () => { + expect( + getSegmentedButtonBorderStyles({ + segment: 'middle', + borderOverrides: { + borderWidth: 3, + borderTopWidth: 4, + borderStartWidth: 5, + borderColor: '#123456', + borderStartColor: '#abcdef', + borderStyle: 'dotted', + }, + }) + ).toEqual({ + outlineBorderStyle: { + borderTopWidth: 4, + borderBottomWidth: 3, + borderEndWidth: 0, + borderColor: '#123456', + borderStyle: 'dotted', + }, + sharedBorderStyle: { + borderStartWidth: 5, + borderColor: '#123456', + borderStartColor: '#abcdef', + borderStyle: 'dotted', + }, + }); + }); + + it('preserves a zero border width', () => { + expect( + getSegmentedButtonBorderStyles({ + segment: 'last', + borderOverrides: { borderWidth: 0 }, + }) + ).toEqual({ + outlineBorderStyle: { + borderTopWidth: 0, + borderBottomWidth: 0, + borderEndWidth: 0, + }, + sharedBorderStyle: { borderStartWidth: 0 }, + }); + }); }); describe('segmented button presentation', () => { @@ -707,7 +784,7 @@ describe('segmented button presentation', () => { : getTheme().colors.outline, opacity: dividerDisabled ? SegmentedButtonTokens.disabledOutlineOpacity - : stateOpacity.enabled, + : 1, borderStartWidth: SegmentedButtonTokens.outlineWidth, }); expect(screen.getByTestId(`${ids[index]}-outline`)).not.toHaveStyle({ @@ -960,36 +1037,64 @@ describe('segmented button presentation', () => { onValueChange={() => {}} buttons={[ { value: 'walk', label: 'Walking', testID: 'walk' }, - { value: 'drive', label: 'Driving' }, + { value: 'drive', label: 'Driving', testID: 'drive' }, ]} /> ); const button = screen.getByTestId('walk'); const stateLayer = screen.getByTestId('walk-state-layer'); + const focusRingInset = + (SegmentedButtonTokens.touchTargetHeight - + SegmentedButtonTokens.containerHeight.regular) / + 2 - + SegmentedButtonTokens.focusIndicatorThickness - + SegmentedButtonTokens.focusIndicatorOutlineOffset; await fireEvent(button, 'hoverIn'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); + expect(stateLayer).toHaveStyle({ + backgroundColor: getTheme().colors.onSecondaryContainer, + opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, + }); await fireEvent(button, 'focus'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); + expect(stateLayer).toHaveStyle({ + opacity: SegmentedButtonTokens.stateLayerOpacity.focused, + }); expect(screen.getByTestId('walk-focus-ring')).toHaveStyle({ - borderWidth: tokens.md.sys.state.focusIndicator.thickness, + borderWidth: SegmentedButtonTokens.focusIndicatorThickness, borderColor: getTheme().colors.secondary, + top: focusRingInset, + bottom: focusRingInset, }); await fireEvent(button, 'pressIn'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.pressed }); + expect(stateLayer).toHaveStyle({ + opacity: SegmentedButtonTokens.stateLayerOpacity.pressed, + }); await fireEvent(button, 'pressOut'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.focused }); + expect(stateLayer).toHaveStyle({ + opacity: SegmentedButtonTokens.stateLayerOpacity.focused, + }); await fireEvent(button, 'blur'); - expect(stateLayer).toHaveStyle({ opacity: stateOpacity.hovered }); + expect(stateLayer).toHaveStyle({ + opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, + }); expect(screen.queryByTestId('walk-focus-ring')).not.toBeOnTheScreen(); await fireEvent(button, 'hoverOut'); expect(stateLayer).toHaveStyle({ opacity: 0 }); + + const unselectedButton = screen.getByTestId('drive'); + const unselectedStateLayer = screen.getByTestId('drive-state-layer'); + + await fireEvent(unselectedButton, 'hoverIn'); + expect(unselectedStateLayer).toHaveStyle({ + backgroundColor: getTheme().colors.onSurface, + opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, + }); }); }); @@ -1449,7 +1554,7 @@ describe('labelStyle is handled', () => { label: 'Walking', value: 'walk', testID: 'walking-button', - labelStyle: { fontSize: 10 }, + labelStyle: { fontSize: 10, opacity: 0.5 }, }, { label: 'Driving', @@ -1464,6 +1569,7 @@ describe('labelStyle is handled', () => { expect(screen.getByTestId('walking-button-label')).toHaveStyle({ fontSize: 10, + opacity: 0.5, }); expect(screen.getByTestId('driving-button-label')).toHaveStyle({ fontSize: 12, diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 95dc59676e..0a99463077 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -123,7 +123,7 @@ exports[`renders segmented button 1`] = ` "borderTopStartRadius": 9999, }, { - "backgroundColor": "rgba(29, 25, 43, 1)", + "backgroundColor": "transparent", "opacity": 0, }, ] @@ -131,19 +131,14 @@ exports[`renders segmented button 1`] = ` /> Date: Mon, 31 Aug 2026 17:03:46 +0200 Subject: [PATCH 10/16] fix: reduce complexity --- .../SegmentedButtonContent.tsx | 2 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 205 ++----- .../useSegmentedButtonInteraction.ts | 10 +- src/components/SegmentedButtons/utils.ts | 273 ++++----- .../__tests__/SegmentedButton.test.tsx | 539 +++++------------- .../SegmentedButton.test.tsx.snap | 6 +- 6 files changed, 327 insertions(+), 708 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index c56fb191ad..26926cbcd9 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -116,7 +116,7 @@ const SegmentedButtonContent = ({ checkmarkScale.value = withSpring(checked ? 1 : 0); }, [checked, checkmarkScale, showSelectedCheck]); - const showCheckIcon = Boolean(checked && showSelectedCheck); + const showCheckIcon = !!(checked && showSelectedCheck); const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; const labelTextStyle: TextStyle = { ...theme.fonts[SegmentedButtonTokens.labelTextType], diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 9b8723dfdd..d9254aa37a 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,4 +1,4 @@ -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, PressableAndroidRippleConfig, @@ -13,23 +13,14 @@ import { useSegmentedButtonInteraction } from './useSegmentedButtonInteraction'; import { getSegmentedButtonBorderRadius, getSegmentedButtonBorderStyles, - getSegmentedButtonColors, - getSegmentedButtonHeight, + resolveColors, } from './utils'; import type { SegmentedButtonPosition } from './utils'; import type { Theme } from '../../types'; -import { splitStyles } from '../../utils/splitStyles'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -const isBorderRadiusStyle = (property: keyof ViewStyle) => - property === 'borderCurve' || - (property.startsWith('border') && property.endsWith('Radius')); - -const isBorderStyle = (property: keyof ViewStyle) => - property.startsWith('border'); - export type Props = { /** * Whether the segmented button is checked @@ -114,8 +105,8 @@ const SegmentedButtonItem = ({ checked, role, 'aria-label': ariaLabel, - disabled, - previousDisabled, + disabled = false, + previousDisabled = false, style, labelStyle, showSelectedCheck, @@ -132,6 +123,8 @@ const SegmentedButtonItem = ({ labelMaxFontSizeMultiplier, hitSlop, }: Props) => { + const accessibilityLabel = label || ariaLabel; + const { interactionProps, interactionState, @@ -139,30 +132,26 @@ const SegmentedButtonItem = ({ showFocusRing, } = useSegmentedButtonInteraction(disabled); - const accessibilityLabel = label || ariaLabel; - - const colors = getSegmentedButtonColors({ + const colors = resolveColors(theme, { checked, - theme, disabled, - previousDisabled, - checkedColor, - uncheckedColor, interactionState, + contentColor: checked ? checkedColor : uncheckedColor, + dividerDisabled: disabled && previousDisabled, }); - const layerStyles = getSegmentedButtonItemStyles({ - colors, - density, - segment, - stateLayerOpacity, - style, - }); + const borderRadius = getSegmentedButtonBorderRadius(segment); + const { outline, divider } = getSegmentedButtonBorderStyles(segment); + + const containerHeight = SegmentedButtonTokens.containerHeight[density]; + const containerVerticalInset = + (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2; + const focusRingVerticalInset = containerVerticalInset - FOCUS_RING_OUTSET; return ( - {layerStyles.sharedBorder ? ( + {divider ? ( ) : null} @@ -222,7 +237,15 @@ const SegmentedButtonItem = ({ ) : null} @@ -273,110 +296,6 @@ const styles = StyleSheet.create({ }, }); -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - -type ItemStyleOptions = { - colors: ReturnType; - density: NonNullable; - segment: Props['segment']; - stateLayerOpacity: number; - style: Props['style']; -}; - -type ItemLayerStyles = { - touchable: StyleProp; - container: StyleProp; - stateLayer: StyleProp; - outline: StyleProp; - sharedBorder?: StyleProp; - focusRing: StyleProp; -}; - -function getSegmentedButtonItemStyles({ - colors: { - backgroundColor: containerColor, - borderColor: outlineColor, - borderOpacity: outlineOpacity, - focusIndicatorColor, - stateLayerColor, - sharedBorderColor, - sharedBorderOpacity, - }, - density, - segment, - stateLayerOpacity, - style, -}: ItemStyleOptions): ItemLayerStyles { - const segmentBorderRadius = getSegmentedButtonBorderRadius({ segment }); - const containerHeight = getSegmentedButtonHeight(density); - const flattenedStyle = StyleSheet.flatten(style) || {}; - - // Radius properties must be matched before the broader border filter. - const [containerStyleOverrides, borderRadiusOverrides, borderOverrides] = - splitStyles(flattenedStyle, isBorderRadiusStyle, isBorderStyle); - - const { outlineBorderStyle, sharedBorderStyle } = - getSegmentedButtonBorderStyles({ segment, borderOverrides }); - - const borderRadiusStyle = { - ...(flattenedStyle.borderRadius === undefined ? segmentBorderRadius : {}), - ...borderRadiusOverrides, - }; - - const containerVerticalInset = - (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2; - const focusRingVerticalInset = containerVerticalInset - FOCUS_RING_OUTSET; - - return { - touchable: [ - styles.touchable, - borderRadiusStyle, - Platform.OS === 'web' ? webNoOutline : undefined, - ], - container: [ - styles.container, - borderRadiusStyle, - { height: containerHeight, backgroundColor: containerColor }, - Object.keys(containerStyleOverrides).length - ? containerStyleOverrides - : undefined, - ], - stateLayer: [ - styles.stateLayer, - borderRadiusStyle, - { - backgroundColor: stateLayerColor, - opacity: stateLayerOpacity, - }, - ], - outline: [ - styles.outline, - borderRadiusStyle, - { borderColor: outlineColor, opacity: outlineOpacity }, - outlineBorderStyle, - ], - sharedBorder: sharedBorderStyle - ? [ - styles.outline, - { - borderColor: sharedBorderColor, - opacity: sharedBorderOpacity, - }, - sharedBorderStyle, - ] - : undefined, - focusRing: [ - styles.focusRing, - borderRadiusStyle, - { - top: focusRingVerticalInset, - bottom: focusRingVerticalInset, - borderColor: focusIndicatorColor, - }, - ], - }; -} - export default SegmentedButtonItem; export { SegmentedButtonItem as SegmentedButton }; diff --git a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts index 6520a5b4e6..3d0160f08e 100644 --- a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts +++ b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts @@ -44,12 +44,10 @@ export const useSegmentedButtonInteraction = (disabled?: boolean) => { return { interactionProps, interactionState, - stateLayerOpacity: getSegmentedButtonStateLayerOpacity({ - disabled, - pressed, - focused, - hovered, - }), + stateLayerOpacity: getSegmentedButtonStateLayerOpacity( + interactionState, + disabled + ), showFocusRing: focused && !disabled, }; }; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index d1bf9e67c3..b5f9b581c9 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -4,30 +4,22 @@ import { SegmentedButtonTokens } from './tokens'; import type { SegmentedButtonInteractionState } from './tokens'; import type { InternalTheme } from '../../types'; -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; +type SegmentedButtonColorState = { checked: boolean; + disabled: boolean; + interactionState: SegmentedButtonInteractionState; }; -type SegmentedButtonProps = { - checkedColor?: string; - uncheckedColor?: string; - previousDisabled?: boolean; - interactionState?: SegmentedButtonInteractionState; -} & BaseProps; +type SegmentedButtonColorOptions = SegmentedButtonColorState & { + contentColor?: string; + dividerDisabled: boolean; +}; export type SegmentedButtonPosition = 'first' | 'last' | 'middle'; -export const getSegmentedButtonHeight = ( - density: 'regular' | 'small' | 'medium' | 'high' = 'regular' -) => SegmentedButtonTokens.containerHeight[density]; - -export const getSegmentedButtonBorderRadius = ({ - segment, -}: { - segment: SegmentedButtonPosition; -}): ViewStyle => { +export const getSegmentedButtonBorderRadius = ( + segment: SegmentedButtonPosition +): ViewStyle => { if (segment === 'first') { return { borderTopStartRadius: SegmentedButtonTokens.containerShape, @@ -51,88 +43,37 @@ export const getSegmentedButtonBorderRadius = ({ }; }; -export const getSegmentedButtonOutlineStyle = ( - segment: SegmentedButtonPosition, - outlineWidth: ViewStyle['borderWidth'] = SegmentedButtonTokens.outlineWidth -): ViewStyle => ({ - borderTopWidth: outlineWidth, - borderBottomWidth: outlineWidth, - borderStartWidth: outlineWidth, - borderEndWidth: segment === 'last' ? outlineWidth : 0, -}); - type SegmentedButtonBorderStyles = { - outlineBorderStyle: ViewStyle; - sharedBorderStyle?: ViewStyle; + outline: ViewStyle; + divider?: ViewStyle; }; -export const getSegmentedButtonBorderStyles = ({ - segment, - borderOverrides, -}: { - segment: SegmentedButtonPosition; - borderOverrides: ViewStyle; -}): SegmentedButtonBorderStyles => { - const { borderWidth, ...explicitBorderOverrides } = borderOverrides; - const resolvedBorderStyle = { - ...getSegmentedButtonOutlineStyle( - segment, - borderWidth ?? SegmentedButtonTokens.outlineWidth - ), - ...explicitBorderOverrides, +export const getSegmentedButtonBorderStyles = ( + segment: SegmentedButtonPosition +): SegmentedButtonBorderStyles => { + const outlineWidth = SegmentedButtonTokens.outlineWidth; + const outline = { + borderTopWidth: outlineWidth, + borderBottomWidth: outlineWidth, + borderEndWidth: segment === 'last' ? outlineWidth : 0, }; if (segment === 'first') { - return { outlineBorderStyle: resolvedBorderStyle }; + return { + outline: { ...outline, borderStartWidth: outlineWidth }, + }; } - const { borderStartWidth, borderStartColor, ...outlineBorderStyle } = - resolvedBorderStyle; - - // The shared edge is separate so adjacent disabled items can style it once. - const sharedBorderStyle: ViewStyle = { - borderStartWidth, - ...(resolvedBorderStyle.borderColor !== undefined - ? { borderColor: resolvedBorderStyle.borderColor } - : {}), - ...(resolvedBorderStyle.borderStyle !== undefined - ? { borderStyle: resolvedBorderStyle.borderStyle } - : {}), - ...(borderStartColor !== undefined ? { borderStartColor } : {}), - }; - - return { outlineBorderStyle, sharedBorderStyle }; + return { outline, divider: { borderStartWidth: outlineWidth } }; }; -export const getSegmentedButtonStateLayerOpacity = ({ - disabled, - pressed, - focused, - hovered, -}: { - disabled?: boolean; - pressed: boolean; - focused: boolean; - hovered: boolean; -}) => { - if (disabled) { - return 0; - } - - if (pressed) { - return SegmentedButtonTokens.stateLayerOpacity.pressed; - } - - if (focused) { - return SegmentedButtonTokens.stateLayerOpacity.focused; - } - - if (hovered) { - return SegmentedButtonTokens.stateLayerOpacity.hovered; - } - - return 0; -}; +export const getSegmentedButtonStateLayerOpacity = ( + interactionState: SegmentedButtonInteractionState, + disabled?: boolean +) => + disabled || interactionState === 'enabled' + ? 0 + : SegmentedButtonTokens.stateLayerOpacity[interactionState]; export const getSegmentedButtonInteractionState = ({ pressed, @@ -158,89 +99,77 @@ export const getSegmentedButtonInteractionState = ({ return 'enabled'; }; -export const getSegmentedButtonColors = ({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, - previousDisabled, - interactionState = 'enabled', -}: SegmentedButtonProps) => { - const backgroundColor = checked - ? theme.colors[SegmentedButtonTokens.selectedContainerColor] - : 'transparent'; - - const borderColor = disabled - ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] - : theme.colors[SegmentedButtonTokens.outlineColor]; - - const customContentColor = checked ? checkedColor : uncheckedColor; - const textColor = disabled - ? theme.colors[SegmentedButtonTokens.disabledLabelTextColor] - : checked - ? (customContentColor ?? - theme.colors[ - SegmentedButtonTokens.selectedLabelTextColor[interactionState] - ]) - : (customContentColor ?? - theme.colors[ - SegmentedButtonTokens.unselectedLabelTextColor[interactionState] - ]); - - const iconColor = disabled - ? theme.colors[SegmentedButtonTokens.disabledIconColor] - : checked - ? (customContentColor ?? - theme.colors[SegmentedButtonTokens.selectedIconColor[interactionState]]) - : (customContentColor ?? - theme.colors[ - SegmentedButtonTokens.unselectedIconColor[interactionState] - ]); - - const borderOpacity = disabled - ? SegmentedButtonTokens.disabledOutlineOpacity - : 1; - - const textOpacity = disabled - ? SegmentedButtonTokens.disabledLabelTextOpacity - : 1; - - const iconOpacity = disabled ? SegmentedButtonTokens.disabledIconOpacity : 1; - - const stateLayerColor = - disabled || interactionState === 'enabled' - ? 'transparent' - : checked - ? theme.colors[ - SegmentedButtonTokens.selectedStateLayerColor[interactionState] - ] - : theme.colors[ - SegmentedButtonTokens.unselectedStateLayerColor[interactionState] - ]; - - const sharedBorderDisabled = Boolean(disabled && previousDisabled); - const sharedBorderColor = sharedBorderDisabled - ? theme.colors[SegmentedButtonTokens.disabledOutlineColor] - : theme.colors[SegmentedButtonTokens.outlineColor]; - const sharedBorderOpacity = sharedBorderDisabled - ? SegmentedButtonTokens.disabledOutlineOpacity - : 1; - - const focusIndicatorColor = - theme.colors[SegmentedButtonTokens.focusIndicatorColor]; +const resolveContentColors = ( + theme: InternalTheme, + { checked, disabled, interactionState }: SegmentedButtonColorState, + contentColor?: string +) => { + if (disabled) { + return { + labelColor: theme.colors[SegmentedButtonTokens.disabledLabelTextColor], + labelOpacity: SegmentedButtonTokens.disabledLabelTextOpacity, + iconColor: theme.colors[SegmentedButtonTokens.disabledIconColor], + iconOpacity: SegmentedButtonTokens.disabledIconOpacity, + }; + } + + const labelColorsByState = checked + ? SegmentedButtonTokens.selectedLabelTextColor + : SegmentedButtonTokens.unselectedLabelTextColor; + const iconColorsByState = checked + ? SegmentedButtonTokens.selectedIconColor + : SegmentedButtonTokens.unselectedIconColor; + + return { + labelColor: + contentColor ?? theme.colors[labelColorsByState[interactionState]], + labelOpacity: 1, + iconColor: + contentColor ?? theme.colors[iconColorsByState[interactionState]], + iconOpacity: 1, + }; +}; + +const resolveOutlineColors = (theme: InternalTheme, disabled: boolean) => { + const colorToken = disabled + ? SegmentedButtonTokens.disabledOutlineColor + : SegmentedButtonTokens.outlineColor; + + return { + color: theme.colors[colorToken], + opacity: disabled ? SegmentedButtonTokens.disabledOutlineOpacity : 1, + }; +}; + +const resolveStateLayerColor = ( + theme: InternalTheme, + { checked, disabled, interactionState }: SegmentedButtonColorState +) => { + if (disabled || interactionState === 'enabled') { + return 'transparent'; + } + + const colorsByState = checked + ? SegmentedButtonTokens.selectedStateLayerColor + : SegmentedButtonTokens.unselectedStateLayerColor; + + return theme.colors[colorsByState[interactionState]]; +}; + +export const resolveColors = ( + theme: InternalTheme, + options: SegmentedButtonColorOptions +) => { + const { checked, disabled, contentColor, dividerDisabled } = options; return { - backgroundColor, - borderColor, - borderOpacity, - textColor, - textOpacity, - iconColor, - iconOpacity, - stateLayerColor, - sharedBorderColor, - sharedBorderOpacity, - focusIndicatorColor, + container: checked + ? theme.colors[SegmentedButtonTokens.selectedContainerColor] + : 'transparent', + content: resolveContentColors(theme, options, contentColor), + outline: resolveOutlineColors(theme, disabled), + divider: resolveOutlineColors(theme, dividerDisabled), + stateLayer: resolveStateLayerColor(theme, options), + focusIndicator: theme.colors[SegmentedButtonTokens.focusIndicatorColor], }; }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 8f6eedb5ee..9fe2ff0b04 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -8,10 +8,8 @@ import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; import { getSegmentedButtonBorderRadius, getSegmentedButtonBorderStyles, - getSegmentedButtonColors, - getSegmentedButtonHeight, - getSegmentedButtonOutlineStyle, getSegmentedButtonStateLayerOpacity, + resolveColors, } from '../SegmentedButtons/utils'; it('type checks single- and multi-select values with their callbacks', () => { @@ -327,160 +325,160 @@ it('applies group theme overrides to items', async () => { expect(screen.getByTestId('walk-label')).toHaveStyle({ fontSize: 18 }); }); -describe('getSegmentedButtonColors', () => { +describe('segmented button colors', () => { const theme = getTheme(); it.each([ { disabled: false, checked: true, - checkedColor: undefined, - uncheckedColor: undefined, + customColor: undefined, expected: theme.colors.onSecondaryContainer, }, { disabled: false, checked: false, - checkedColor: undefined, - uncheckedColor: undefined, + customColor: undefined, expected: theme.colors.onSurface, }, { disabled: true, checked: true, - checkedColor: undefined, - uncheckedColor: undefined, + customColor: undefined, expected: theme.colors.onSurface, }, { disabled: true, checked: false, - checkedColor: undefined, - uncheckedColor: undefined, + customColor: 'custom', expected: theme.colors.onSurface, }, { disabled: false, checked: true, - checkedColor: 'a125f5', - uncheckedColor: undefined, + customColor: 'a125f5', expected: 'a125f5', }, { disabled: false, checked: false, - checkedColor: undefined, - uncheckedColor: '000', + customColor: '000', expected: '000', }, - { - disabled: false, - checked: false, - checkedColor: 'a125f5', - uncheckedColor: '000', - expected: '000', - }, - { - disabled: false, - checked: false, - checkedColor: 'a125f5', - uncheckedColor: undefined, - expected: theme.colors.onSurface, - }, - { - disabled: false, - checked: true, - checkedColor: undefined, - uncheckedColor: '000', - expected: theme.colors.onSecondaryContainer, - }, ])( - 'returns $expected when disabled: $disabled, checked: $checked, checkedColor is $checkedColor and uncheckedColor is $uncheckedColor', - ({ disabled, checked, checkedColor, uncheckedColor, expected }) => { + 'returns $expected when disabled: $disabled, checked: $checked, and customColor is $customColor', + ({ disabled, checked, customColor, expected }) => { expect( - getSegmentedButtonColors({ - theme, - disabled, + resolveColors(theme, { checked, - checkedColor, - uncheckedColor, - }) - ).toMatchObject({ textColor: expected, iconColor: expected }); + disabled, + interactionState: 'enabled', + contentColor: customColor, + dividerDisabled: false, + }).content + ).toMatchObject({ labelColor: expected, iconColor: expected }); } ); - it('should return correct background color when checked and theme version 3', () => { - expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: false, - checked: true, - }) - ).toMatchObject({ backgroundColor: getTheme().colors.secondaryContainer }); - }); + it('uses the content color override for each selection state', async () => { + await render( + {}} + buttons={[ + { + value: 'walk', + label: 'Walking', + checkedColor: '#123456', + uncheckedColor: '#aaaaaa', + testID: 'walk', + }, + { + value: 'drive', + label: 'Driving', + checkedColor: '#bbbbbb', + uncheckedColor: '#654321', + testID: 'drive', + }, + ]} + /> + ); - it('should return correct background color when uncheked (V3 & V2)', () => { - expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: false, - checked: false, - }) - ).toMatchObject({ - backgroundColor: 'transparent', - }); + expect(screen.getByTestId('walk-label')).toHaveStyle({ color: '#123456' }); + expect(screen.getByTestId('drive-label')).toHaveStyle({ color: '#654321' }); }); - it('should return correct border color with theme version 3', () => { + it.each([ + { + state: 'enabled', + disabled: false, + color: theme.colors.outline, + opacity: 1, + }, + { + state: 'disabled', + disabled: true, + color: theme.colors.onSurface, + opacity: SegmentedButtonTokens.disabledOutlineOpacity, + }, + ])('resolves the $state outline', ({ disabled, color, opacity }) => { expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: false, + resolveColors(theme, { checked: false, - }) - ).toMatchObject({ - borderColor: getTheme().colors.outline, - }); + disabled, + interactionState: 'enabled', + dividerDisabled: false, + }).outline + ).toEqual({ color, opacity }); }); - it('should return correct border color when disabled and theme version 3', () => { + it.each([ + { checked: true, expected: theme.colors.secondaryContainer }, + { checked: false, expected: 'transparent' }, + ])('resolves the checked: $checked container', ({ checked, expected }) => { expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: true, - checked: false, - }) - ).toMatchObject({ - borderColor: getTheme().colors.onSurface, - borderOpacity: SegmentedButtonTokens.disabledOutlineOpacity, - }); + resolveColors(theme, { + checked, + disabled: false, + interactionState: 'enabled', + dividerDisabled: false, + }).container + ).toBe(expected); }); - it('should return correct textColor with theme version 3', () => { + it('resolves state layer colors by selection and interaction', () => { expect( - getSegmentedButtonColors({ - theme: getTheme(), + resolveColors(theme, { + checked: true, disabled: false, + interactionState: 'hovered', + dividerDisabled: false, + }).stateLayer + ).toBe(theme.colors.onSecondaryContainer); + expect( + resolveColors(theme, { checked: false, - }) - ).toMatchObject({ - textColor: getTheme().colors.onSurface, - }); - }); - - it('should return correct textColor when disabled and theme version 3', () => { + disabled: false, + interactionState: 'pressed', + dividerDisabled: false, + }).stateLayer + ).toBe(theme.colors.onSurface); expect( - getSegmentedButtonColors({ - theme: getTheme(), + resolveColors(theme, { + checked: true, disabled: true, - checked: false, - }) - ).toMatchObject({ - textColor: getTheme().colors.onSurface, - textOpacity: SegmentedButtonTokens.disabledLabelTextOpacity, - iconColor: getTheme().colors.onSurface, - iconOpacity: SegmentedButtonTokens.disabledIconOpacity, - }); + interactionState: 'pressed', + dividerDisabled: false, + }).stateLayer + ).toBe('transparent'); + expect( + resolveColors(theme, { + checked: true, + disabled: false, + interactionState: 'enabled', + dividerDisabled: false, + }).stateLayer + ).toBe('transparent'); }); }); @@ -489,53 +487,38 @@ describe('getSegmentedButtonStateLayerOpacity', () => { { state: 'disabled', disabled: true, - pressed: true, - focused: true, - hovered: true, + interactionState: 'pressed' as const, expected: 0, }, { state: 'pressed', disabled: false, - pressed: true, - focused: true, - hovered: true, + interactionState: 'pressed' as const, expected: SegmentedButtonTokens.stateLayerOpacity.pressed, }, { state: 'focused', disabled: false, - pressed: false, - focused: true, - hovered: true, + interactionState: 'focused' as const, expected: SegmentedButtonTokens.stateLayerOpacity.focused, }, { state: 'hovered', disabled: false, - pressed: false, - focused: false, - hovered: true, + interactionState: 'hovered' as const, expected: SegmentedButtonTokens.stateLayerOpacity.hovered, }, { state: 'idle', disabled: false, - pressed: false, - focused: false, - hovered: false, + interactionState: 'enabled' as const, expected: 0, }, ])( 'returns the $state state opacity', - ({ disabled, pressed, focused, hovered, expected }) => { + ({ disabled, interactionState, expected }) => { expect( - getSegmentedButtonStateLayerOpacity({ - disabled, - pressed, - focused, - hovered, - }) + getSegmentedButtonStateLayerOpacity(interactionState, disabled) ).toBe(expected); } ); @@ -566,93 +549,45 @@ describe('segmented button topology helpers', () => { }, }, ])('returns the $segment segment radii', ({ segment, expected }) => { - expect(getSegmentedButtonBorderRadius({ segment })).toEqual(expected); + expect(getSegmentedButtonBorderRadius(segment)).toEqual(expected); }); it.each([ - { segment: 'first' as const, borderEndWidth: 0 }, - { segment: 'middle' as const, borderEndWidth: 0 }, - { segment: 'last' as const, borderEndWidth: 3 }, - ])( - 'returns the $segment segment outline widths', - ({ segment, borderEndWidth }) => { - expect(getSegmentedButtonOutlineStyle(segment, 3)).toEqual({ - borderTopWidth: 3, - borderBottomWidth: 3, - borderStartWidth: 3, - borderEndWidth, - }); - } - ); - - it('keeps the first segment border on its outline', () => { - expect( - getSegmentedButtonBorderStyles({ - segment: 'first', - borderOverrides: { - borderWidth: 3, - borderColor: '#123456', - borderStartColor: '#abcdef', - borderStyle: 'dashed', + { + segment: 'first' as const, + expected: { + outline: { + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, }, - }) - ).toEqual({ - outlineBorderStyle: { - borderTopWidth: 3, - borderBottomWidth: 3, - borderStartWidth: 3, - borderEndWidth: 0, - borderColor: '#123456', - borderStartColor: '#abcdef', - borderStyle: 'dashed', }, - }); - }); - - it('moves a non-first segment start edge to the shared border', () => { - expect( - getSegmentedButtonBorderStyles({ - segment: 'middle', - borderOverrides: { - borderWidth: 3, - borderTopWidth: 4, - borderStartWidth: 5, - borderColor: '#123456', - borderStartColor: '#abcdef', - borderStyle: 'dotted', + }, + { + segment: 'middle' as const, + expected: { + outline: { + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, }, - }) - ).toEqual({ - outlineBorderStyle: { - borderTopWidth: 4, - borderBottomWidth: 3, - borderEndWidth: 0, - borderColor: '#123456', - borderStyle: 'dotted', - }, - sharedBorderStyle: { - borderStartWidth: 5, - borderColor: '#123456', - borderStartColor: '#abcdef', - borderStyle: 'dotted', + divider: { borderStartWidth: SegmentedButtonTokens.outlineWidth }, }, - }); - }); - - it('preserves a zero border width', () => { - expect( - getSegmentedButtonBorderStyles({ - segment: 'last', - borderOverrides: { borderWidth: 0 }, - }) - ).toEqual({ - outlineBorderStyle: { - borderTopWidth: 0, - borderBottomWidth: 0, - borderEndWidth: 0, + }, + { + segment: 'last' as const, + expected: { + outline: { + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: SegmentedButtonTokens.outlineWidth, + }, + divider: { borderStartWidth: SegmentedButtonTokens.outlineWidth }, }, - sharedBorderStyle: { borderStartWidth: 0 }, - }); + }, + ])('returns the $segment segment borders', ({ segment, expected }) => { + expect(getSegmentedButtonBorderStyles(segment)).toEqual(expected); }); }); @@ -794,19 +729,16 @@ describe('segmented button presentation', () => { } ); - it('applies custom backgrounds, radii, and shadows to selected and unselected visual containers', async () => { - const selectedStyle = { - backgroundColor: '#112233', + it('applies custom styles to the outer segment', async () => { + const style = { + flex: 3, + marginHorizontal: 8, + backgroundColor: '#123456', + borderColor: '#654321', borderRadius: 12, + borderWidth: 3, elevation: 4, shadowColor: '#000000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.5, - shadowRadius: 3, - }; - const unselectedStyle = { - backgroundColor: '#445566', - borderRadius: 6, }; await render( @@ -818,182 +750,25 @@ describe('segmented button presentation', () => { value: 'walk', label: 'Walking', testID: 'walk', - style: selectedStyle, - }, - { - value: 'drive', - label: 'Driving', - testID: 'drive', - style: unselectedStyle, - }, - ]} - /> - ); - - expect(screen.getByTestId('walk-container')).toHaveStyle(selectedStyle); - expect(screen.getByTestId('drive-container')).toHaveStyle(unselectedStyle); - expect(screen.getByTestId('walk-state-layer')).toHaveStyle({ - borderRadius: selectedStyle.borderRadius, - }); - expect(screen.getByTestId('drive-state-layer')).toHaveStyle({ - borderRadius: unselectedStyle.borderRadius, - }); - expect(screen.getByTestId('walk')).toHaveStyle({ overflow: 'visible' }); - expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ - backgroundColor: selectedStyle.backgroundColor, - shadowColor: selectedStyle.shadowColor, - }); - }); - - it('applies custom borders without double-drawing an interior edge', async () => { - await render( - {}} - buttons={[ - { - value: 'walk', - label: 'Walking', - testID: 'walk', - style: { - borderColor: '#123456', - borderStyle: 'dashed', - borderWidth: 3, - }, - }, - { - value: 'drive', - label: 'Driving', - testID: 'drive', - style: { - borderColor: '#654321', - borderStartColor: '#abcdef', - borderStyle: 'dotted', - borderTopWidth: 4, - }, - }, - ]} - /> - ); - - const selectedOutline = screen.getByTestId('walk-outline'); - - expect(selectedOutline).toHaveStyle({ - borderColor: '#123456', - borderStyle: 'dashed', - borderTopWidth: 3, - borderBottomWidth: 3, - borderStartWidth: 3, - borderEndWidth: 0, - }); - expect(selectedOutline).not.toHaveStyle({ borderWidth: 3 }); - expect(screen.getByTestId('drive-outline')).toHaveStyle({ - borderColor: '#654321', - borderStyle: 'dotted', - borderTopWidth: 4, - borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderEndWidth: SegmentedButtonTokens.outlineWidth, - }); - expect(screen.getByTestId('drive-outline')).not.toHaveStyle({ - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - expect(screen.getByTestId('drive-divider')).toHaveStyle({ - borderColor: '#654321', - borderStartColor: '#abcdef', - borderStyle: 'dotted', - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - }); - - it.each(['ltr', 'rtl'] as const)( - 'keeps generic and explicit custom widths topology-aware in %s', - async (direction) => { - await render( - - {}} - buttons={[ - { - value: 'first', - label: 'First', - testID: 'custom-first', - style: { borderWidth: 3, borderTopWidth: 4 }, - }, - { - value: 'middle', - label: 'Middle', - testID: 'custom-middle', - style: { borderWidth: 3, borderStartWidth: 5 }, - }, - { - value: 'last', - label: 'Last', - testID: 'custom-last', - style: { - borderWidth: 3, - borderBottomWidth: 6, - borderEndWidth: 7, - }, - }, - ]} - /> - - ); - - expect(screen.getByTestId('custom-first-outline')).toHaveStyle({ - borderTopWidth: 4, - borderBottomWidth: 3, - borderStartWidth: 3, - borderEndWidth: 0, - }); - expect( - screen.queryByTestId('custom-first-divider') - ).not.toBeOnTheScreen(); - expect(screen.getByTestId('custom-middle-outline')).toHaveStyle({ - borderTopWidth: 3, - borderBottomWidth: 3, - borderEndWidth: 0, - }); - expect(screen.getByTestId('custom-middle-divider')).toHaveStyle({ - borderStartWidth: 5, - }); - expect(screen.getByTestId('custom-last-outline')).toHaveStyle({ - borderTopWidth: 3, - borderBottomWidth: 6, - borderEndWidth: 7, - }); - expect(screen.getByTestId('custom-last-divider')).toHaveStyle({ - borderStartWidth: 3, - }); - } - ); - - it('does not move visual styles to the hit target', async () => { - await render( - {}} - buttons={[ - { - value: 'walk', - label: 'Walking', - testID: 'walk', - style: { flex: 3, backgroundColor: '#123456' }, + style, }, { value: 'drive', label: 'Driving' }, ]} /> ); - expect(screen.getByTestId('walk-container')).toHaveStyle({ - flex: 3, - backgroundColor: '#123456', - }); expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ - flex: 1, + ...style, minHeight: SegmentedButtonTokens.touchTargetHeight, }); + expect(screen.getByTestId('walk-container')).not.toHaveStyle({ flex: 3 }); + expect(screen.getByTestId('walk-container')).not.toHaveStyle({ + backgroundColor: style.backgroundColor, + }); + expect(screen.getByTestId('walk-outline')).not.toHaveStyle({ + borderColor: style.borderColor, + borderWidth: style.borderWidth, + }); }); it.each([ @@ -1004,7 +779,7 @@ describe('segmented button presentation', () => { ])( 'uses the $density density height inside a 48dp target', async ({ density, expected }) => { - expect(getSegmentedButtonHeight(density)).toBe(expected); + expect(SegmentedButtonTokens.containerHeight[density]).toBe(expected); await render( @@ -79,7 +80,6 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 0, "borderTopStartRadius": 9999, }, - undefined, ], ] } @@ -101,7 +101,6 @@ exports[`renders segmented button 1`] = ` "backgroundColor": "rgba(232, 222, 248, 1)", "height": 40, }, - undefined, ] } > @@ -231,6 +230,7 @@ exports[`renders segmented button 1`] = ` "overflow": "visible", }, false, + undefined, ] } > @@ -283,7 +283,6 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 9999, "borderTopStartRadius": 0, }, - undefined, ], ] } @@ -305,7 +304,6 @@ exports[`renders segmented button 1`] = ` "backgroundColor": "transparent", "height": 40, }, - undefined, ] } > From 96e461555cd8131b13bca0eec67c387dd9a74207 Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Mon, 31 Aug 2026 22:47:10 +0200 Subject: [PATCH 11/16] fix: self review --- .../SegmentedButtonContent.tsx | 22 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 19 +- .../SegmentedButtons/SegmentedButtons.tsx | 12 +- .../__tests__/SegmentedButton.test.tsx | 256 ++++++++++++++---- .../SegmentedButton.test.tsx.snap | 2 + 5 files changed, 227 insertions(+), 84 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index 26926cbcd9..6d190bdef3 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -3,6 +3,7 @@ import { StyleSheet, View } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; import Animated, { + ReduceMotion, useAnimatedStyle, useSharedValue, withSpring, @@ -10,6 +11,7 @@ import Animated, { import type { SharedValue } from 'react-native-reanimated'; import { SegmentedButtonTokens } from './tokens'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { Theme } from '../../types'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; @@ -106,18 +108,18 @@ const SegmentedButtonContent = ({ testID, theme, }: Props) => { - const checkmarkScale = useSharedValue(0); + const showCheckIcon = !!(checked && showSelectedCheck); + const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; - React.useEffect(() => { - if (!showSelectedCheck) { - return; - } + const reduceMotion = useReduceMotion(); + const checkmarkScale = useSharedValue(checked ? 1 : 0); - checkmarkScale.value = withSpring(checked ? 1 : 0); - }, [checked, checkmarkScale, showSelectedCheck]); + React.useEffect(() => { + checkmarkScale.value = withSpring(showCheckIcon ? 1 : 0, { + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, + }); + }, [checkmarkScale, reduceMotion, showCheckIcon]); - const showCheckIcon = !!(checked && showSelectedCheck); - const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; const labelTextStyle: TextStyle = { ...theme.fonts[SegmentedButtonTokens.labelTextType], color: labelColor, @@ -135,7 +137,7 @@ const SegmentedButtonContent = ({ ) : null} {optionIcon ? ( { - const accessibilityLabel = label || ariaLabel; + const accessibilityLabel = ariaLabel ?? label; const { interactionProps, @@ -155,8 +155,7 @@ const SegmentedButtonItem = ({ > = { /** * Buttons to display as options in toggle button. * Button should contain the following properties: - * - `value`: value of button (required) + * - `value`: unique value of button (required) * - `icon`: icon to display for the item * - `disabled`: whether the button is disabled * - `aria-label`: accessibility label for the button. This is read by the screen reader when the user taps the button. @@ -142,7 +142,7 @@ const SegmentedButtons = ({ return ( {buttons.map( ({ value: itemValue, onPress: onItemPress, ...itemProps }, index) => { @@ -174,7 +174,7 @@ const SegmentedButtons = ({ return ( { expect(callOrder).toEqual(['item', 'value']); }); - it('selects only the first matching item when single-select values are duplicated', async () => { - const user = userEvent.setup(); - const duplicateOnPress = jest.fn(); + it('cancels an in-flight press when the item becomes disabled', async () => { + const itemOnPress = jest.fn(); const onValueChange = jest.fn(); - - await render( + const buttons = [ + { + value: 'walk', + label: 'Walking', + onPress: itemOnPress, + testID: 'walk', + }, + { value: 'ride', label: 'Riding' }, + ]; + const { rerender } = await render( ); + const pressedButton = screen.getByTestId('walk'); - const radios = screen.getAllByRole('radio'); - - expect(radios[0]).toHaveProp( - 'accessibilityState', - expect.objectContaining({ checked: true }) - ); - expect(radios[1]).toHaveProp( - 'accessibilityState', - expect.objectContaining({ checked: false }) + await fireEvent(pressedButton, 'pressIn'); + await rerender( + + button.value === 'walk' ? { ...button, disabled: true } : button + )} + /> ); - await user.press(screen.getByTestId('second-walk')); + expect(screen.getByTestId('walk')).not.toHaveProp('onPress'); - expect(duplicateOnPress).toHaveBeenCalledTimes(1); - expect(onValueChange).toHaveBeenCalledWith('walk'); - expect(screen.getAllByRole('radio')[1]).toHaveProp( - 'accessibilityState', - expect.objectContaining({ checked: false }) - ); + await fireEvent(pressedButton, 'pressOut'); + // userEvent.press cannot interleave a rerender with the press lifecycle. + // eslint-disable-next-line no-restricted-syntax + await fireEvent(pressedButton, 'onPress'); + + expect(itemOnPress).not.toHaveBeenCalled(); + expect(onValueChange).not.toHaveBeenCalled(); }); - it('keeps duplicate button values selected and toggleable in multiselect', async () => { - const user = userEvent.setup(); + it('keeps interaction state and an in-flight press with the same value after reordering', async () => { + const walkOnPress = jest.fn(); + const rideOnPress = jest.fn(); const onValueChange = jest.fn(); - - await render( - - multiSelect - value={['walk']} + const buttons = [ + { + value: 'walk', + label: 'Walking', + onPress: walkOnPress, + testID: 'walk', + }, + { + value: 'ride', + label: 'Riding', + onPress: rideOnPress, + testID: 'ride', + }, + ]; + const { rerender } = await render( + ); + const pressedButton = screen.getByTestId('walk'); - const checkboxes = screen.getAllByRole('checkbox'); - - expect(checkboxes[0]).toHaveProp( - 'accessibilityState', - expect.objectContaining({ checked: true }) - ); - expect(checkboxes[1]).toHaveProp( - 'accessibilityState', - expect.objectContaining({ checked: true }) + await fireEvent(pressedButton, 'focus'); + await fireEvent(pressedButton, 'pressIn'); + await rerender( + ); - await user.press(screen.getByTestId('second-walk')); + expect(screen.getByTestId('walk-focus-ring')).toBeOnTheScreen(); + expect(screen.queryByTestId('ride-focus-ring')).not.toBeOnTheScreen(); + expect(screen.getByTestId('walk-state-layer')).toHaveStyle({ + opacity: SegmentedButtonTokens.stateLayerOpacity.pressed, + }); + + await fireEvent(pressedButton, 'pressOut'); + // userEvent.press cannot interleave a rerender with the press lifecycle. + // eslint-disable-next-line no-restricted-syntax + await fireEvent(pressedButton, 'onPress'); - expect(onValueChange).toHaveBeenCalledWith([]); + expect(walkOnPress).toHaveBeenCalledTimes(1); + expect(rideOnPress).not.toHaveBeenCalled(); + expect(onValueChange).toHaveBeenCalledWith('walk'); }); it('preserves multiselect append order and removes duplicate values', async () => { @@ -771,6 +796,30 @@ describe('segmented button presentation', () => { }); }); + it('always suppresses the user-agent outline on web', async () => { + const originalPlatform = Platform.OS; + Platform.OS = 'web'; + + try { + await render( + {}} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + expect(screen.getByTestId('walk')).toHaveStyle({ + outline: 'none', + } as unknown as ViewStyle); + } finally { + Platform.OS = originalPlatform; + } + }); + it.each([ { density: 'regular' as const, expected: 40 }, { density: 'small' as const, expected: 36 }, @@ -1076,7 +1125,7 @@ describe('segment content', () => { }); describe('accessibility semantics', () => { - it('uses icon descriptions and visible text as segment names', async () => { + it('prioritizes aria-label and falls back to visible text', async () => { await render( { label: 'Driving', 'aria-label': 'Travel by car', }, + { value: 'transit', label: 'Transit' }, ]} onValueChange={() => {}} /> ); expect(screen.getByRole('radio', { name: 'Walking' })).toBeOnTheScreen(); - expect(screen.getByRole('radio', { name: 'Driving' })).toBeOnTheScreen(); expect( - screen.queryByRole('radio', { name: 'Travel by car' }) + screen.getByRole('radio', { name: 'Travel by car' }) + ).toBeOnTheScreen(); + expect(screen.getByRole('radio', { name: 'Transit' })).toBeOnTheScreen(); + expect( + screen.queryByRole('radio', { name: 'Driving' }) ).not.toBeOnTheScreen(); }); @@ -1317,6 +1370,93 @@ describe('selected check icon', () => { expect(screen.getByTestId('walking-check-icon')).toBeOnTheScreen(); }); + + it('restores the option icon and resets its scale when selected checks are disabled', async () => { + const reanimated = jest.requireMock('react-native-reanimated') as { + withSpring: typeof import('react-native-reanimated').withSpring; + }; + const withSpringSpy = jest.spyOn(reanimated, 'withSpring'); + const buttons = [ + { + value: 'walk', + icon: 'walk', + label: 'Walking', + showSelectedCheck: true, + testID: 'walking', + }, + ]; + + try { + const { rerender } = await render( + {}} + /> + ); + + expect(screen.getByTestId('walking-check-icon')).toHaveStyle({ + transform: [{ scale: 1 }], + }); + + await rerender( + ({ + ...button, + showSelectedCheck: false, + }))} + onValueChange={() => {}} + /> + ); + + const optionIcon = screen.getByTestId('walking-icon'); + expect(optionIcon).toBeOnTheScreen(); + expect(optionIcon).not.toHaveStyle({ transform: [{ scale: 0 }] }); + expect(withSpringSpy).toHaveBeenLastCalledWith(0, { + reduceMotion: ReduceMotion.Never, + }); + } finally { + withSpringSpy.mockRestore(); + } + }); + + it.each([ + { reduceMotion: true, expected: ReduceMotion.Always }, + { reduceMotion: false, expected: ReduceMotion.Never }, + ])( + 'uses the resolved reduce-motion policy when reduceMotion is $reduceMotion', + async ({ reduceMotion, expected }) => { + const reanimated = jest.requireMock('react-native-reanimated') as { + withSpring: typeof import('react-native-reanimated').withSpring; + }; + const withSpringSpy = jest.spyOn(reanimated, 'withSpring'); + + try { + await render( + + {}} + /> + + ); + + expect(withSpringSpy).toHaveBeenLastCalledWith(1, { + reduceMotion: expected, + }); + } finally { + withSpringSpy.mockRestore(); + } + } + ); }); describe('labelStyle is handled', () => { diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index a802ab9381..7d76b3952e 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -80,6 +80,7 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 0, "borderTopStartRadius": 9999, }, + false, ], ] } @@ -283,6 +284,7 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 9999, "borderTopStartRadius": 0, }, + false, ], ] } From 6d2cbf95c7066d71e305e4fa5764e26b6fa64ff0 Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Tue, 1 Sep 2026 00:46:59 +0200 Subject: [PATCH 12/16] fix: touchable ripple --- .../SegmentedButtons/SegmentedButtonItem.tsx | 74 ++----- src/components/SegmentedButtons/tokens.ts | 65 +----- .../useSegmentedButtonInteraction.ts | 53 ----- src/components/SegmentedButtons/utils.ts | 62 +----- .../__tests__/SegmentedButton.test.tsx | 205 +++++------------- .../SegmentedButton.test.tsx.snap | 96 ++------ 6 files changed, 101 insertions(+), 454 deletions(-) delete mode 100644 src/components/SegmentedButtons/useSegmentedButtonInteraction.ts diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index eb8cef4535..e0cc5c040a 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, @@ -9,7 +10,6 @@ import type { import SegmentedButtonContent from './SegmentedButtonContent'; import { FOCUS_RING_OUTSET, SegmentedButtonTokens } from './tokens'; -import { useSegmentedButtonInteraction } from './useSegmentedButtonInteraction'; import { getSegmentedButtonBorderRadius, getSegmentedButtonBorderStyles, @@ -17,6 +17,7 @@ import { } from './utils'; import type { SegmentedButtonPosition } from './utils'; import type { Theme } from '../../types'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -125,17 +126,12 @@ const SegmentedButtonItem = ({ }: Props) => { const accessibilityLabel = ariaLabel ?? label; - const { - interactionProps, - interactionState, - stateLayerOpacity, - showFocusRing, - } = useSegmentedButtonInteraction(disabled); + const [focused, setFocused] = React.useState(false); + const showFocusRing = focused && !disabled; const colors = resolveColors(theme, { checked, disabled, - interactionState, contentColor: checked ? checkedColor : uncheckedColor, dividerDisabled: disabled && previousDisabled, }); @@ -144,14 +140,17 @@ const SegmentedButtonItem = ({ const { outline, divider } = getSegmentedButtonBorderStyles(segment); const containerHeight = SegmentedButtonTokens.containerHeight[density]; - const containerVerticalInset = - (SegmentedButtonTokens.touchTargetHeight - containerHeight) / 2; - const focusRingVerticalInset = containerVerticalInset - FOCUS_RING_OUTSET; return ( { + if (!disabled && isKeyboardFocusEvent(event)) { + setFocused(true); + } + }} + onBlur={() => setFocused(false)} > - ; - const stateTokens = tokens.md.sys.state; const sizes = { @@ -22,8 +11,6 @@ const sizes = { medium: 32, high: 28, } as const satisfies Record<'regular' | 'small' | 'medium' | 'high', number>, - touchTargetHeight: 48, - minimumWidth: 48, horizontalPadding: 12, iconSize: 18, iconLabelGap: 8, @@ -33,11 +20,6 @@ const sizes = { disabledLabelTextOpacity: stateTokens.opacity.disabled, disabledIconOpacity: stateTokens.opacity.disabled, disabledOutlineOpacity: 0.12, - stateLayerOpacity: { - hovered: stateTokens.opacity.hovered, - focused: stateTokens.opacity.focused, - pressed: stateTokens.opacity.pressed, - } as const satisfies Record, focusIndicatorThickness: stateTokens.focusIndicator.thickness, focusIndicatorOutlineOffset: stateTokens.focusIndicator.outerOffset, } as const; @@ -52,59 +34,22 @@ const baseColors = { } as const satisfies Record; const contentColors = { - selectedLabelTextColor: { - enabled: 'onSecondaryContainer', - hovered: 'onSecondaryContainer', - focused: 'onSecondaryContainer', - pressed: 'onSecondaryContainer', - }, - unselectedLabelTextColor: { - enabled: 'onSurface', - hovered: 'onSurface', - focused: 'onSurface', - pressed: 'onSurface', - }, - selectedIconColor: { - enabled: 'onSecondaryContainer', - hovered: 'onSecondaryContainer', - focused: 'onSecondaryContainer', - pressed: 'onSecondaryContainer', - }, - unselectedIconColor: { - enabled: 'onSurface', - hovered: 'onSurface', - focused: 'onSurface', - pressed: 'onSurface', - }, + selectedLabelTextColor: 'onSecondaryContainer', + unselectedLabelTextColor: 'onSurface', + selectedIconColor: 'onSecondaryContainer', + unselectedIconColor: 'onSurface', } as const satisfies Record< | 'selectedLabelTextColor' | 'unselectedLabelTextColor' | 'selectedIconColor' | 'unselectedIconColor', - Record ->; - -const stateLayerColors = { - selectedStateLayerColor: { - hovered: 'onSecondaryContainer', - focused: 'onSecondaryContainer', - pressed: 'onSecondaryContainer', - }, - unselectedStateLayerColor: { - hovered: 'onSurface', - focused: 'onSurface', - pressed: 'onSurface', - }, -} as const satisfies Record< - 'selectedStateLayerColor' | 'unselectedStateLayerColor', - Record + ColorRole >; export const SegmentedButtonTokens = { ...sizes, ...baseColors, ...contentColors, - ...stateLayerColors, }; export const FOCUS_RING_OUTSET = diff --git a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts b/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts deleted file mode 100644 index 3d0160f08e..0000000000 --- a/src/components/SegmentedButtons/useSegmentedButtonInteraction.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react'; - -import { - getSegmentedButtonInteractionState, - getSegmentedButtonStateLayerOpacity, -} from './utils'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; -import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; - -type InteractionProps = Pick< - TouchableRippleProps, - 'onPressIn' | 'onPressOut' | 'onHoverIn' | 'onHoverOut' | 'onFocus' | 'onBlur' ->; - -export const useSegmentedButtonInteraction = (disabled?: boolean) => { - const [pressed, setPressed] = React.useState(false); - const [hovered, setHovered] = React.useState(false); - const [focused, setFocused] = React.useState(false); - - const interactionProps: InteractionProps = { - onPressIn: () => setPressed(true), - onPressOut: () => setPressed(false), - onHoverIn: () => setHovered(true), - onHoverOut: () => setHovered(false), - onFocus: (event) => { - if (disabled || !isKeyboardFocusEvent(event)) { - return; - } - - setFocused(true); - }, - onBlur: () => { - setPressed(false); - setFocused(false); - }, - }; - - const interactionState = getSegmentedButtonInteractionState({ - pressed, - focused, - hovered, - }); - - return { - interactionProps, - interactionState, - stateLayerOpacity: getSegmentedButtonStateLayerOpacity( - interactionState, - disabled - ), - showFocusRing: focused && !disabled, - }; -}; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index b5f9b581c9..18a679a21f 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -1,13 +1,11 @@ import type { ViewStyle } from 'react-native'; import { SegmentedButtonTokens } from './tokens'; -import type { SegmentedButtonInteractionState } from './tokens'; import type { InternalTheme } from '../../types'; type SegmentedButtonColorState = { checked: boolean; disabled: boolean; - interactionState: SegmentedButtonInteractionState; }; type SegmentedButtonColorOptions = SegmentedButtonColorState & { @@ -67,41 +65,9 @@ export const getSegmentedButtonBorderStyles = ( return { outline, divider: { borderStartWidth: outlineWidth } }; }; -export const getSegmentedButtonStateLayerOpacity = ( - interactionState: SegmentedButtonInteractionState, - disabled?: boolean -) => - disabled || interactionState === 'enabled' - ? 0 - : SegmentedButtonTokens.stateLayerOpacity[interactionState]; - -export const getSegmentedButtonInteractionState = ({ - pressed, - focused, - hovered, -}: { - pressed: boolean; - focused: boolean; - hovered: boolean; -}): SegmentedButtonInteractionState => { - if (pressed) { - return 'pressed'; - } - - if (focused) { - return 'focused'; - } - - if (hovered) { - return 'hovered'; - } - - return 'enabled'; -}; - const resolveContentColors = ( theme: InternalTheme, - { checked, disabled, interactionState }: SegmentedButtonColorState, + { checked, disabled }: SegmentedButtonColorState, contentColor?: string ) => { if (disabled) { @@ -113,19 +79,17 @@ const resolveContentColors = ( }; } - const labelColorsByState = checked + const labelColor = checked ? SegmentedButtonTokens.selectedLabelTextColor : SegmentedButtonTokens.unselectedLabelTextColor; - const iconColorsByState = checked + const iconColor = checked ? SegmentedButtonTokens.selectedIconColor : SegmentedButtonTokens.unselectedIconColor; return { - labelColor: - contentColor ?? theme.colors[labelColorsByState[interactionState]], + labelColor: contentColor ?? theme.colors[labelColor], labelOpacity: 1, - iconColor: - contentColor ?? theme.colors[iconColorsByState[interactionState]], + iconColor: contentColor ?? theme.colors[iconColor], iconOpacity: 1, }; }; @@ -141,21 +105,6 @@ const resolveOutlineColors = (theme: InternalTheme, disabled: boolean) => { }; }; -const resolveStateLayerColor = ( - theme: InternalTheme, - { checked, disabled, interactionState }: SegmentedButtonColorState -) => { - if (disabled || interactionState === 'enabled') { - return 'transparent'; - } - - const colorsByState = checked - ? SegmentedButtonTokens.selectedStateLayerColor - : SegmentedButtonTokens.unselectedStateLayerColor; - - return theme.colors[colorsByState[interactionState]]; -}; - export const resolveColors = ( theme: InternalTheme, options: SegmentedButtonColorOptions @@ -169,7 +118,6 @@ export const resolveColors = ( content: resolveContentColors(theme, options, contentColor), outline: resolveOutlineColors(theme, disabled), divider: resolveOutlineColors(theme, dividerDisabled), - stateLayer: resolveStateLayerColor(theme, options), focusIndicator: theme.colors[SegmentedButtonTokens.focusIndicatorColor], }; }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index e134ef8356..a632c1a370 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -9,11 +9,13 @@ import { getTheme } from '../../core/theming'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; import { ReduceMotionContext } from '../../theme/accessibility/ReduceMotionContext'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; -import { SegmentedButtonTokens } from '../SegmentedButtons/tokens'; +import { + FOCUS_RING_OUTSET, + SegmentedButtonTokens, +} from '../SegmentedButtons/tokens'; import { getSegmentedButtonBorderRadius, getSegmentedButtonBorderStyles, - getSegmentedButtonStateLayerOpacity, resolveColors, } from '../SegmentedButtons/utils'; @@ -242,7 +244,7 @@ describe('selection behavior', () => { expect(onValueChange).not.toHaveBeenCalled(); }); - it('keeps interaction state and an in-flight press with the same value after reordering', async () => { + it('keeps focus and an in-flight press with the same value after reordering', async () => { const walkOnPress = jest.fn(); const rideOnPress = jest.fn(); const onValueChange = jest.fn(); @@ -281,9 +283,6 @@ describe('selection behavior', () => { expect(screen.getByTestId('walk-focus-ring')).toBeOnTheScreen(); expect(screen.queryByTestId('ride-focus-ring')).not.toBeOnTheScreen(); - expect(screen.getByTestId('walk-state-layer')).toHaveStyle({ - opacity: SegmentedButtonTokens.stateLayerOpacity.pressed, - }); await fireEvent(pressedButton, 'pressOut'); // userEvent.press cannot interleave a rerender with the press lifecycle. @@ -338,16 +337,29 @@ it('applies group theme overrides to items', async () => { { value: 'ride', label: 'Riding' }, ]} theme={{ - colors: { secondaryContainer: '#123456' }, + colors: { + secondaryContainer: '#123456', + stateLayerPressed: '#654321', + }, fonts: { labelLarge: { fontSize: 18 } }, }} /> ); - expect(screen.getByTestId('walk-container')).toHaveStyle({ + expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ backgroundColor: '#123456', }); expect(screen.getByTestId('walk-label')).toHaveStyle({ fontSize: 18 }); + + const button = screen.getByTestId('walk'); + // Drive Pressability so the fallback render prop receives its pressed state. + await fireEvent(button, 'responderGrant', { + nativeEvent: {}, + persist: jest.fn(), + }); + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + backgroundColor: '#654321', + }); }); describe('segmented button colors', () => { @@ -397,7 +409,6 @@ describe('segmented button colors', () => { resolveColors(theme, { checked, disabled, - interactionState: 'enabled', contentColor: customColor, dividerDisabled: false, }).content @@ -451,7 +462,6 @@ describe('segmented button colors', () => { resolveColors(theme, { checked: false, disabled, - interactionState: 'enabled', dividerDisabled: false, }).outline ).toEqual({ color, opacity }); @@ -465,88 +475,10 @@ describe('segmented button colors', () => { resolveColors(theme, { checked, disabled: false, - interactionState: 'enabled', dividerDisabled: false, }).container ).toBe(expected); }); - - it('resolves state layer colors by selection and interaction', () => { - expect( - resolveColors(theme, { - checked: true, - disabled: false, - interactionState: 'hovered', - dividerDisabled: false, - }).stateLayer - ).toBe(theme.colors.onSecondaryContainer); - expect( - resolveColors(theme, { - checked: false, - disabled: false, - interactionState: 'pressed', - dividerDisabled: false, - }).stateLayer - ).toBe(theme.colors.onSurface); - expect( - resolveColors(theme, { - checked: true, - disabled: true, - interactionState: 'pressed', - dividerDisabled: false, - }).stateLayer - ).toBe('transparent'); - expect( - resolveColors(theme, { - checked: true, - disabled: false, - interactionState: 'enabled', - dividerDisabled: false, - }).stateLayer - ).toBe('transparent'); - }); -}); - -describe('getSegmentedButtonStateLayerOpacity', () => { - it.each([ - { - state: 'disabled', - disabled: true, - interactionState: 'pressed' as const, - expected: 0, - }, - { - state: 'pressed', - disabled: false, - interactionState: 'pressed' as const, - expected: SegmentedButtonTokens.stateLayerOpacity.pressed, - }, - { - state: 'focused', - disabled: false, - interactionState: 'focused' as const, - expected: SegmentedButtonTokens.stateLayerOpacity.focused, - }, - { - state: 'hovered', - disabled: false, - interactionState: 'hovered' as const, - expected: SegmentedButtonTokens.stateLayerOpacity.hovered, - }, - { - state: 'idle', - disabled: false, - interactionState: 'enabled' as const, - expected: 0, - }, - ])( - 'returns the $state state opacity', - ({ disabled, interactionState, expected }) => { - expect( - getSegmentedButtonStateLayerOpacity(interactionState, disabled) - ).toBe(expected); - } - ); }); describe('segmented button topology helpers', () => { @@ -679,9 +611,8 @@ describe('segmented button presentation', () => { expect(view.root).toHaveStyle({ direction }); for (const { id, radii, borderEndWidth } of segmentCases) { + expect(screen.getByTestId(`${id}-wrapper`)).toHaveStyle(radii); expect(screen.getByTestId(id)).toHaveStyle(radii); - expect(screen.getByTestId(`${id}-container`)).toHaveStyle(radii); - expect(screen.getByTestId(`${id}-state-layer`)).toHaveStyle(radii); expect(screen.getByTestId(`${id}-outline`)).toHaveStyle({ ...radii, borderTopWidth: SegmentedButtonTokens.outlineWidth, @@ -690,8 +621,17 @@ describe('segmented button presentation', () => { }); await fireEvent(screen.getByTestId(id), 'focus'); - expect(screen.getByTestId(`${id}-focus-ring`)).toHaveStyle(radii); + expect(screen.getByTestId(`${id}-focus-ring`)).toHaveStyle({ + ...radii, + top: -FOCUS_RING_OUTSET, + bottom: -FOCUS_RING_OUTSET, + left: -FOCUS_RING_OUTSET, + right: -FOCUS_RING_OUTSET, + borderWidth: SegmentedButtonTokens.focusIndicatorThickness, + borderColor: getTheme().colors.secondary, + }); await fireEvent(screen.getByTestId(id), 'blur'); + expect(screen.queryByTestId(`${id}-focus-ring`)).not.toBeOnTheScreen(); } expect(screen.getByTestId('first-outline')).toHaveStyle({ @@ -782,9 +722,10 @@ describe('segmented button presentation', () => { /> ); - expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ - ...style, - minHeight: SegmentedButtonTokens.touchTargetHeight, + expect(screen.getByTestId('walk-wrapper')).toHaveStyle(style); + expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, }); expect(screen.getByTestId('walk-container')).not.toHaveStyle({ flex: 3 }); expect(screen.getByTestId('walk-container')).not.toHaveStyle({ @@ -826,7 +767,7 @@ describe('segmented button presentation', () => { { density: 'medium' as const, expected: 32 }, { density: 'high' as const, expected: 28 }, ])( - 'uses the $density density height inside a 48dp target', + 'uses the $density visual height without local target constraints', async ({ density, expected }) => { expect(SegmentedButtonTokens.containerHeight[density]).toBe(expected); @@ -842,11 +783,13 @@ describe('segmented button presentation', () => { /> ); - expect(screen.getByTestId('walk-wrapper')).toHaveStyle({ - minHeight: SegmentedButtonTokens.touchTargetHeight, + expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, }); - expect(screen.getByTestId('walk')).toHaveStyle({ - minHeight: SegmentedButtonTokens.touchTargetHeight, + expect(screen.getByTestId('walk')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, }); expect(screen.getByTestId('walk-container')).toHaveStyle({ height: expected, @@ -854,71 +797,25 @@ describe('segmented button presentation', () => { } ); - it('renders state opacity with press, focus, and hover precedence', async () => { + it('does not render a focus ring for a disabled item', async () => { await render( {}} buttons={[ { value: 'walk', label: 'Walking', testID: 'walk' }, - { value: 'drive', label: 'Driving', testID: 'drive' }, + { + value: 'drive', + label: 'Driving', + testID: 'drive', + disabled: true, + }, ]} /> ); - const button = screen.getByTestId('walk'); - const stateLayer = screen.getByTestId('walk-state-layer'); - const focusRingInset = - (SegmentedButtonTokens.touchTargetHeight - - SegmentedButtonTokens.containerHeight.regular) / - 2 - - SegmentedButtonTokens.focusIndicatorThickness - - SegmentedButtonTokens.focusIndicatorOutlineOffset; - - await fireEvent(button, 'hoverIn'); - expect(stateLayer).toHaveStyle({ - backgroundColor: getTheme().colors.onSecondaryContainer, - opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, - }); - - await fireEvent(button, 'focus'); - expect(stateLayer).toHaveStyle({ - opacity: SegmentedButtonTokens.stateLayerOpacity.focused, - }); - expect(screen.getByTestId('walk-focus-ring')).toHaveStyle({ - borderWidth: SegmentedButtonTokens.focusIndicatorThickness, - borderColor: getTheme().colors.secondary, - top: focusRingInset, - bottom: focusRingInset, - }); - - await fireEvent(button, 'pressIn'); - expect(stateLayer).toHaveStyle({ - opacity: SegmentedButtonTokens.stateLayerOpacity.pressed, - }); - - await fireEvent(button, 'pressOut'); - expect(stateLayer).toHaveStyle({ - opacity: SegmentedButtonTokens.stateLayerOpacity.focused, - }); - - await fireEvent(button, 'blur'); - expect(stateLayer).toHaveStyle({ - opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, - }); - expect(screen.queryByTestId('walk-focus-ring')).not.toBeOnTheScreen(); - - await fireEvent(button, 'hoverOut'); - expect(stateLayer).toHaveStyle({ opacity: 0 }); - - const unselectedButton = screen.getByTestId('drive'); - const unselectedStateLayer = screen.getByTestId('drive-state-layer'); - - await fireEvent(unselectedButton, 'hoverIn'); - expect(unselectedStateLayer).toHaveStyle({ - backgroundColor: getTheme().colors.onSurface, - opacity: SegmentedButtonTokens.stateLayerOpacity.hovered, - }); + await fireEvent(screen.getByTestId('drive'), 'focus'); + expect(screen.queryByTestId('drive-focus-ring')).not.toBeOnTheScreen(); }); }); diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 7d76b3952e..d048501dee 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -21,11 +21,17 @@ exports[`renders segmented button 1`] = ` [ { "flex": 1, - "justifyContent": "center", - "minHeight": 48, - "minWidth": 48, "overflow": "visible", }, + { + "borderBottomEndRadius": 0, + "borderBottomStartRadius": 9999, + "borderTopEndRadius": 0, + "borderTopStartRadius": 9999, + }, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, false, undefined, ] @@ -69,11 +75,6 @@ exports[`renders segmented button 1`] = ` "overflow": "hidden", }, [ - { - "justifyContent": "center", - "minHeight": 48, - "overflow": "visible", - }, { "borderBottomEndRadius": 0, "borderBottomStartRadius": 9999, @@ -93,42 +94,11 @@ exports[`renders segmented button 1`] = ` "width": "100%", }, { - "borderBottomEndRadius": 0, - "borderBottomStartRadius": 9999, - "borderTopEndRadius": 0, - "borderTopStartRadius": 9999, - }, - { - "backgroundColor": "rgba(232, 222, 248, 1)", "height": 40, }, ] } > - - Date: Tue, 1 Sep 2026 00:53:10 +0200 Subject: [PATCH 13/16] fix: testID --- .../SegmentedButtons/SegmentedButtonContent.tsx | 6 +++--- .../SegmentedButtons/SegmentedButtonItem.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index 6d190bdef3..49c2bd4fc5 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -132,7 +132,7 @@ const SegmentedButtonContent = ({ color={iconColor} opacity={iconOpacity} scale={checkmarkScale} - testID={testID ? `${testID}-check-icon` : undefined} + testID={testID && `${testID}-check-icon`} /> ) : null} {optionIcon ? ( @@ -142,7 +142,7 @@ const SegmentedButtonContent = ({ opacity={iconOpacity} scale={checkmarkScale} source={optionIcon} - testID={testID ? `${testID}-icon` : undefined} + testID={testID && `${testID}-icon`} /> ) : null} {label ? ( @@ -157,7 +157,7 @@ const SegmentedButtonContent = ({ selectable={false} numberOfLines={1} maxFontSizeMultiplier={labelMaxFontSizeMultiplier} - testID={testID ? `${testID}-label` : undefined} + testID={testID && `${testID}-label`} > {label} diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index e0cc5c040a..4de097b2a0 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -143,7 +143,7 @@ const SegmentedButtonItem = ({ return ( setFocused(false)} > Date: Tue, 1 Sep 2026 01:07:06 +0200 Subject: [PATCH 14/16] refactor: extract icons --- .../SegmentedButtons/AnimatedCheckIcon.tsx | 45 +++++++++++ .../SegmentedButtons/AnimatedOptionIcon.tsx | 55 ++++++++++++++ .../SegmentedButtonContent.tsx | 75 +------------------ .../SegmentedButtons/SegmentedButtonItem.tsx | 8 +- .../SegmentedButtons/SegmentedButtons.tsx | 1 - .../__tests__/SegmentedButton.test.tsx | 7 +- .../SegmentedButton.test.tsx.snap | 7 +- 7 files changed, 111 insertions(+), 87 deletions(-) create mode 100644 src/components/SegmentedButtons/AnimatedCheckIcon.tsx create mode 100644 src/components/SegmentedButtons/AnimatedOptionIcon.tsx diff --git a/src/components/SegmentedButtons/AnimatedCheckIcon.tsx b/src/components/SegmentedButtons/AnimatedCheckIcon.tsx new file mode 100644 index 0000000000..b7c42c2a26 --- /dev/null +++ b/src/components/SegmentedButtons/AnimatedCheckIcon.tsx @@ -0,0 +1,45 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle } from 'react-native'; + +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; + +import { SegmentedButtonTokens } from './tokens'; +import Icon from '../Icon'; + +type Props = { + color: TextStyle['color']; + opacity: number; + scale: SharedValue; + testID?: string; +}; + +const AnimatedCheckIcon = ({ color, opacity, scale, testID }: Props) => { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + icon: { + width: SegmentedButtonTokens.iconSize, + height: SegmentedButtonTokens.iconSize, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default AnimatedCheckIcon; diff --git a/src/components/SegmentedButtons/AnimatedOptionIcon.tsx b/src/components/SegmentedButtons/AnimatedOptionIcon.tsx new file mode 100644 index 0000000000..91ba4d923a --- /dev/null +++ b/src/components/SegmentedButtons/AnimatedOptionIcon.tsx @@ -0,0 +1,55 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle } from 'react-native'; + +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; + +import { SegmentedButtonTokens } from './tokens'; +import type { IconSource } from '../Icon'; +import Icon from '../Icon'; + +type Props = { + animated: boolean; + color: TextStyle['color']; + opacity: number; + scale: SharedValue; + source: IconSource; + testID?: string; +}; + +const AnimatedOptionIcon = ({ + animated, + color, + opacity, + scale, + source, + testID, +}: Props) => { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - scale.value }], + })); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + icon: { + width: SegmentedButtonTokens.iconSize, + height: SegmentedButtonTokens.iconSize, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default AnimatedOptionIcon; diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index 49c2bd4fc5..fa2728f024 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -2,83 +2,20 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; -import Animated, { +import { ReduceMotion, - useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; -import type { SharedValue } from 'react-native-reanimated'; +import AnimatedCheckIcon from './AnimatedCheckIcon'; +import AnimatedOptionIcon from './AnimatedOptionIcon'; import { SegmentedButtonTokens } from './tokens'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { Theme } from '../../types'; import type { IconSource } from '../Icon'; -import Icon from '../Icon'; import Text from '../Typography/Text'; -type AnimatedIconProps = { - color: TextStyle['color']; - opacity: number; - scale: SharedValue; - testID?: string; -}; - -const AnimatedCheckIcon = ({ - color, - opacity, - scale, - testID, -}: AnimatedIconProps) => { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - return ( - - - - ); -}; - -type AnimatedOptionIconProps = AnimatedIconProps & { - animated: boolean; - source: IconSource; -}; - -const AnimatedOptionIcon = ({ - animated, - color, - opacity, - scale, - source, - testID, -}: AnimatedOptionIconProps) => { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: 1 - scale.value }], - })); - - return ( - - - - ); -}; - type Props = { checked: boolean; icon?: IconSource; @@ -175,12 +112,6 @@ const styles = StyleSheet.create({ paddingHorizontal: SegmentedButtonTokens.horizontalPadding, columnGap: SegmentedButtonTokens.iconLabelGap, }, - icon: { - width: SegmentedButtonTokens.iconSize, - height: SegmentedButtonTokens.iconSize, - alignItems: 'center', - justifyContent: 'center', - }, label: { flexShrink: 1, textAlign: 'center', diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4de097b2a0..7ff9f55fb5 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -154,7 +154,7 @@ const SegmentedButtonItem = ({ > { if (!disabled && isKeyboardFocusEvent(event)) { setFocused(true); @@ -240,7 +240,6 @@ const SegmentedButtonItem = ({ const styles = StyleSheet.create({ wrapper: { flex: 1, - overflow: 'visible', }, focusedWrapper: { zIndex: 1, @@ -268,9 +267,6 @@ const styles = StyleSheet.create({ }, }); -// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - export default SegmentedButtonItem; export { SegmentedButtonItem as SegmentedButton }; diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index aa9e1b334d..a1f9017578 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -193,7 +193,6 @@ const SegmentedButtons = ({ const styles = StyleSheet.create({ row: { flexDirection: 'row', - overflow: 'visible', }, }); diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index a632c1a370..b86b4f1c1a 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -738,8 +738,9 @@ describe('segmented button presentation', () => { }); it('always suppresses the user-agent outline on web', async () => { - const originalPlatform = Platform.OS; - Platform.OS = 'web'; + const platformSelect = jest + .spyOn(Platform, 'select') + .mockImplementation((specifics) => specifics.web); try { await render( @@ -757,7 +758,7 @@ describe('segmented button presentation', () => { outline: 'none', } as unknown as ViewStyle); } finally { - Platform.OS = originalPlatform; + platformSelect.mockRestore(); } }); diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index d048501dee..3dc6ee1500 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -7,7 +7,6 @@ exports[`renders segmented button 1`] = ` [ { "flexDirection": "row", - "overflow": "visible", }, { "direction": "ltr", @@ -21,7 +20,6 @@ exports[`renders segmented button 1`] = ` [ { "flex": 1, - "overflow": "visible", }, { "borderBottomEndRadius": 0, @@ -81,7 +79,7 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 0, "borderTopStartRadius": 9999, }, - false, + undefined, ], ] } @@ -195,7 +193,6 @@ exports[`renders segmented button 1`] = ` [ { "flex": 1, - "overflow": "visible", }, { "borderBottomEndRadius": 9999, @@ -255,7 +252,7 @@ exports[`renders segmented button 1`] = ` "borderTopEndRadius": 9999, "borderTopStartRadius": 0, }, - false, + undefined, ], ] } From 0d094a6fd191e80229fb4caa9de4bbd72ebcbb8d Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Tue, 1 Sep 2026 01:48:20 +0200 Subject: [PATCH 15/16] refactor: simplfy components tree --- .../SegmentedButtonContent.tsx | 12 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 92 ++---- src/components/SegmentedButtons/utils.ts | 54 ++-- .../__tests__/SegmentedButton.test.tsx | 123 ++++---- .../SegmentedButton.test.tsx.snap | 291 +++++++----------- 5 files changed, 230 insertions(+), 342 deletions(-) diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index fa2728f024..dbc55863c1 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -64,15 +64,15 @@ const SegmentedButtonContent = ({ return ( - {showCheckIcon ? ( + {showCheckIcon && ( - ) : null} - {optionIcon ? ( + )} + {optionIcon && ( - ) : null} - {label ? ( + )} + {label && ( {label} - ) : null} + )} ); }; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 7ff9f55fb5..6d7b5d5f6a 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -137,9 +137,9 @@ const SegmentedButtonItem = ({ }); const borderRadius = getSegmentedButtonBorderRadius(segment); - const { outline, divider } = getSegmentedButtonBorderStyles(segment); + const borderStyles = getSegmentedButtonBorderStyles(segment, colors); - const containerHeight = SegmentedButtonTokens.containerHeight[density]; + const height = SegmentedButtonTokens.containerHeight[density]; return ( @@ -165,7 +164,13 @@ const SegmentedButtonItem = ({ background={background} hitSlop={hitSlop} theme={theme} - style={[borderRadius, Platform.select({ web: { outline: 'none' } })]} + style={[ + styles.touchable, + borderRadius, + borderStyles, + Platform.select({ web: { outline: 'none' } }), + { height }, + ]} onFocus={(event) => { if (!disabled && isKeyboardFocusEvent(event)) { setFocused(true); @@ -173,52 +178,20 @@ const SegmentedButtonItem = ({ }} onBlur={() => setFocused(false)} > - - - - {divider ? ( - - ) : null} - + {showFocusRing ? ( ) : null} @@ -241,21 +212,10 @@ const styles = StyleSheet.create({ wrapper: { flex: 1, }, - focusedWrapper: { - zIndex: 1, - }, - container: { + touchable: { width: '100%', justifyContent: 'center', }, - outline: { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - pointerEvents: 'none', - }, focusRing: { position: 'absolute', top: -FOCUS_RING_OUTSET, diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 18a679a21f..698631c875 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -1,4 +1,6 @@ -import type { ViewStyle } from 'react-native'; +import type { ColorValue, ViewStyle } from 'react-native'; + +import color from 'color'; import { SegmentedButtonTokens } from './tokens'; import type { InternalTheme } from '../../types'; @@ -41,28 +43,25 @@ export const getSegmentedButtonBorderRadius = ( }; }; -type SegmentedButtonBorderStyles = { - outline: ViewStyle; - divider?: ViewStyle; +type SegmentedButtonBorderColors = { + outline: ColorValue; + divider: ColorValue; }; export const getSegmentedButtonBorderStyles = ( - segment: SegmentedButtonPosition -): SegmentedButtonBorderStyles => { + segment: SegmentedButtonPosition, + { outline, divider }: SegmentedButtonBorderColors +): ViewStyle => { const outlineWidth = SegmentedButtonTokens.outlineWidth; - const outline = { + + return { + borderColor: outline, + borderStartColor: segment === 'first' ? outline : divider, borderTopWidth: outlineWidth, borderBottomWidth: outlineWidth, + borderStartWidth: outlineWidth, borderEndWidth: segment === 'last' ? outlineWidth : 0, }; - - if (segment === 'first') { - return { - outline: { ...outline, borderStartWidth: outlineWidth }, - }; - } - - return { outline, divider: { borderStartWidth: outlineWidth } }; }; const resolveContentColors = ( @@ -94,15 +93,24 @@ const resolveContentColors = ( }; }; -const resolveOutlineColors = (theme: InternalTheme, disabled: boolean) => { +const applyOpacity = (value: ColorValue, opacity: number): ColorValue => { + if (opacity === 1 || typeof value !== 'string') { + return value; + } + + return color(value) + .fade(1 - opacity) + .rgb() + .string(); +}; + +const resolveOutlineColor = (theme: InternalTheme, disabled: boolean) => { const colorToken = disabled ? SegmentedButtonTokens.disabledOutlineColor : SegmentedButtonTokens.outlineColor; + const opacity = disabled ? SegmentedButtonTokens.disabledOutlineOpacity : 1; - return { - color: theme.colors[colorToken], - opacity: disabled ? SegmentedButtonTokens.disabledOutlineOpacity : 1, - }; + return applyOpacity(theme.colors[colorToken], opacity); }; export const resolveColors = ( @@ -112,12 +120,12 @@ export const resolveColors = ( const { checked, disabled, contentColor, dividerDisabled } = options; return { - container: checked + wrapper: checked ? theme.colors[SegmentedButtonTokens.selectedContainerColor] : 'transparent', content: resolveContentColors(theme, options, contentColor), - outline: resolveOutlineColors(theme, disabled), - divider: resolveOutlineColors(theme, dividerDisabled), + outline: resolveOutlineColor(theme, disabled), + divider: resolveOutlineColor(theme, dividerDisabled), focusIndicator: theme.colors[SegmentedButtonTokens.focusIndicatorColor], }; }; diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index b86b4f1c1a..916c964d78 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -2,6 +2,7 @@ import type { ViewStyle } from 'react-native'; import { Platform } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; +import color from 'color'; import { ReduceMotion } from 'react-native-reanimated'; import { LocaleProvider } from '../../core/locale'; @@ -120,9 +121,12 @@ it('renders disabled segmented button', async () => { /> ); - expect(screen.getByTestId('ride-outline')).toHaveStyle({ - borderColor: getTheme().colors.onSurface, - opacity: SegmentedButtonTokens.disabledOutlineOpacity, + expect(screen.getByTestId('ride')).toHaveStyle({ + borderColor: color(getTheme().colors.onSurface as string) + .fade(1 - SegmentedButtonTokens.disabledOutlineOpacity) + .rgb() + .string(), + borderStartColor: getTheme().colors.outline, }); expect(screen.getByTestId('ride-label')).toHaveStyle({ opacity: SegmentedButtonTokens.disabledLabelTextOpacity, @@ -448,23 +452,24 @@ describe('segmented button colors', () => { { state: 'enabled', disabled: false, - color: theme.colors.outline, - opacity: 1, + expected: theme.colors.outline, }, { state: 'disabled', disabled: true, - color: theme.colors.onSurface, - opacity: SegmentedButtonTokens.disabledOutlineOpacity, + expected: color(theme.colors.onSurface as string) + .fade(1 - SegmentedButtonTokens.disabledOutlineOpacity) + .rgb() + .string(), }, - ])('resolves the $state outline', ({ disabled, color, opacity }) => { + ])('resolves the $state outline', ({ disabled, expected }) => { expect( resolveColors(theme, { checked: false, disabled, dividerDisabled: false, }).outline - ).toEqual({ color, opacity }); + ).toBe(expected); }); it.each([ @@ -476,12 +481,14 @@ describe('segmented button colors', () => { checked, disabled: false, dividerDisabled: false, - }).container + }).wrapper ).toBe(expected); }); }); describe('segmented button topology helpers', () => { + const borderColors = { outline: 'outline', divider: 'divider' }; + it.each([ { segment: 'first' as const, @@ -513,38 +520,40 @@ describe('segmented button topology helpers', () => { { segment: 'first' as const, expected: { - outline: { - borderTopWidth: SegmentedButtonTokens.outlineWidth, - borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderStartWidth: SegmentedButtonTokens.outlineWidth, - borderEndWidth: 0, - }, + borderColor: borderColors.outline, + borderStartColor: borderColors.outline, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, }, }, { segment: 'middle' as const, expected: { - outline: { - borderTopWidth: SegmentedButtonTokens.outlineWidth, - borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderEndWidth: 0, - }, - divider: { borderStartWidth: SegmentedButtonTokens.outlineWidth }, + borderColor: borderColors.outline, + borderStartColor: borderColors.divider, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, }, }, { segment: 'last' as const, expected: { - outline: { - borderTopWidth: SegmentedButtonTokens.outlineWidth, - borderBottomWidth: SegmentedButtonTokens.outlineWidth, - borderEndWidth: SegmentedButtonTokens.outlineWidth, - }, - divider: { borderStartWidth: SegmentedButtonTokens.outlineWidth }, + borderColor: borderColors.outline, + borderStartColor: borderColors.divider, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: SegmentedButtonTokens.outlineWidth, }, }, ])('returns the $segment segment borders', ({ segment, expected }) => { - expect(getSegmentedButtonBorderStyles(segment)).toEqual(expected); + expect(getSegmentedButtonBorderStyles(segment, borderColors)).toEqual( + expected + ); }); }); @@ -612,11 +621,13 @@ describe('segmented button presentation', () => { for (const { id, radii, borderEndWidth } of segmentCases) { expect(screen.getByTestId(`${id}-wrapper`)).toHaveStyle(radii); - expect(screen.getByTestId(id)).toHaveStyle(radii); - expect(screen.getByTestId(`${id}-outline`)).toHaveStyle({ + expect(screen.getByTestId(id)).toHaveStyle({ ...radii, + borderColor: getTheme().colors.outline, + borderStartColor: getTheme().colors.outline, borderTopWidth: SegmentedButtonTokens.outlineWidth, borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, borderEndWidth, }); @@ -633,20 +644,6 @@ describe('segmented button presentation', () => { await fireEvent(screen.getByTestId(id), 'blur'); expect(screen.queryByTestId(`${id}-focus-ring`)).not.toBeOnTheScreen(); } - - expect(screen.getByTestId('first-outline')).toHaveStyle({ - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - expect(screen.queryByTestId('first-divider')).not.toBeOnTheScreen(); - - ['middle', 'last'].forEach((id) => { - expect(screen.getByTestId(`${id}-outline`)).not.toHaveStyle({ - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - expect(screen.getByTestId(`${id}-divider`)).toHaveStyle({ - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - }); } ); @@ -671,23 +668,28 @@ describe('segmented button presentation', () => { ); expect(view.root).toHaveStyle({ direction }); - expect(screen.queryAllByTestId(/-divider$/)).toHaveLength(2); - expect(screen.queryByTestId('first-divider')).not.toBeOnTheScreen(); + const disabledOutlineColor = color(getTheme().colors.onSurface as string) + .fade(1 - SegmentedButtonTokens.disabledOutlineOpacity) + .rgb() + .string(); + + expect(screen.getByTestId('first')).toHaveStyle({ + borderStartColor: disabledStates[0] + ? disabledOutlineColor + : getTheme().colors.outline, + }); [1, 2].forEach((index) => { const dividerDisabled = disabledStates[index - 1] && disabledStates[index]; - expect(screen.getByTestId(`${ids[index]}-divider`)).toHaveStyle({ - borderColor: dividerDisabled - ? getTheme().colors.onSurface + expect(screen.getByTestId(ids[index])).toHaveStyle({ + borderColor: disabledStates[index] + ? disabledOutlineColor + : getTheme().colors.outline, + borderStartColor: dividerDisabled + ? disabledOutlineColor : getTheme().colors.outline, - opacity: dividerDisabled - ? SegmentedButtonTokens.disabledOutlineOpacity - : 1, - borderStartWidth: SegmentedButtonTokens.outlineWidth, - }); - expect(screen.getByTestId(`${ids[index]}-outline`)).not.toHaveStyle({ borderStartWidth: SegmentedButtonTokens.outlineWidth, }); }); @@ -727,11 +729,12 @@ describe('segmented button presentation', () => { minHeight: 48, minWidth: 48, }); - expect(screen.getByTestId('walk-container')).not.toHaveStyle({ flex: 3 }); - expect(screen.getByTestId('walk-container')).not.toHaveStyle({ + expect(screen.queryByTestId('walk-container')).not.toBeOnTheScreen(); + expect(screen.getByTestId('walk')).not.toHaveStyle({ flex: 3 }); + expect(screen.getByTestId('walk')).not.toHaveStyle({ backgroundColor: style.backgroundColor, }); - expect(screen.getByTestId('walk-outline')).not.toHaveStyle({ + expect(screen.getByTestId('walk')).not.toHaveStyle({ borderColor: style.borderColor, borderWidth: style.borderWidth, }); @@ -792,7 +795,7 @@ describe('segmented button presentation', () => { minHeight: 48, minWidth: 48, }); - expect(screen.getByTestId('walk-container')).toHaveStyle({ + expect(screen.getByTestId('walk')).toHaveStyle({ height: expected, }); } diff --git a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap index 3dc6ee1500..8908c867b7 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -30,7 +30,6 @@ exports[`renders segmented button 1`] = ` { "backgroundColor": "rgba(232, 222, 248, 1)", }, - false, undefined, ] } @@ -73,118 +72,88 @@ exports[`renders segmented button 1`] = ` "overflow": "hidden", }, [ + { + "justifyContent": "center", + "width": "100%", + }, { "borderBottomEndRadius": 0, "borderBottomStartRadius": 9999, "borderTopEndRadius": 0, "borderTopStartRadius": 9999, }, + { + "borderBottomWidth": 1, + "borderColor": "rgba(121, 116, 126, 1)", + "borderEndWidth": 0, + "borderStartColor": "rgba(121, 116, 126, 1)", + "borderStartWidth": 1, + "borderTopWidth": 1, + }, undefined, + { + "height": 40, + }, ], ] } > - - - Walking - - - + > + Walking + @@ -203,7 +172,6 @@ exports[`renders segmented button 1`] = ` { "backgroundColor": "transparent", }, - false, undefined, ] } @@ -246,139 +214,88 @@ exports[`renders segmented button 1`] = ` "overflow": "hidden", }, [ + { + "justifyContent": "center", + "width": "100%", + }, { "borderBottomEndRadius": 9999, "borderBottomStartRadius": 0, "borderTopEndRadius": 9999, "borderTopStartRadius": 0, }, + { + "borderBottomWidth": 1, + "borderColor": "rgba(121, 116, 126, 1)", + "borderEndWidth": 1, + "borderStartColor": "rgba(121, 116, 126, 1)", + "borderStartWidth": 1, + "borderTopWidth": 1, + }, undefined, + { + "height": 40, + }, ], ] } > - - - Riding - - - - + > + Riding + From 0abe77e469ea3e2a70aa9672c65ede5a5bf8779b Mon Sep 17 00:00:00 2001 From: michalfedyna Date: Tue, 1 Sep 2026 10:43:50 +0200 Subject: [PATCH 16/16] refactor: self review --- .../SegmentedButtons/AnimatedOptionIcon.tsx | 29 +++++++++++++++---- .../SegmentedButtonContent.tsx | 2 +- .../SegmentedButtons/SegmentedButtonItem.tsx | 2 +- .../SegmentedButtons/SegmentedButtons.tsx | 6 +--- .../__tests__/SegmentedButton.test.tsx | 25 ++++++++++++++++ 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/components/SegmentedButtons/AnimatedOptionIcon.tsx b/src/components/SegmentedButtons/AnimatedOptionIcon.tsx index 91ba4d923a..42f2b87249 100644 --- a/src/components/SegmentedButtons/AnimatedOptionIcon.tsx +++ b/src/components/SegmentedButtons/AnimatedOptionIcon.tsx @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { TextStyle } from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; @@ -17,14 +17,15 @@ type Props = { testID?: string; }; -const AnimatedOptionIcon = ({ - animated, +type AnimatedIconProps = Omit; + +const AnimatedIcon = ({ color, opacity, scale, source, testID, -}: Props) => { +}: AnimatedIconProps) => { const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 - scale.value }], })); @@ -32,7 +33,7 @@ const AnimatedOptionIcon = ({ return ( { + if (animated) { + return ; + } + + const { color, opacity, source, testID } = props; + + return ( + + + + ); +}; + const styles = StyleSheet.create({ icon: { width: SegmentedButtonTokens.iconSize, diff --git a/src/components/SegmentedButtons/SegmentedButtonContent.tsx b/src/components/SegmentedButtons/SegmentedButtonContent.tsx index dbc55863c1..094d812e40 100644 --- a/src/components/SegmentedButtons/SegmentedButtonContent.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -49,7 +49,7 @@ const SegmentedButtonContent = ({ const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; const reduceMotion = useReduceMotion(); - const checkmarkScale = useSharedValue(checked ? 1 : 0); + const checkmarkScale = useSharedValue(showCheckIcon ? 1 : 0); React.useEffect(() => { checkmarkScale.value = withSpring(showCheckIcon ? 1 : 0, { diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 6d7b5d5f6a..a90d08f67b 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -163,7 +163,7 @@ const SegmentedButtonItem = ({ testID={testID} background={background} hitSlop={hitSlop} - theme={theme} + rippleColor={theme.colors.stateLayerPressed} style={[ styles.touchable, borderRadius, diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index a1f9017578..4f657b288d 100644 --- a/src/components/SegmentedButtons/SegmentedButtons.tsx +++ b/src/components/SegmentedButtons/SegmentedButtons.tsx @@ -135,10 +135,6 @@ const SegmentedButtons = ({ const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); - const singleSelectedIndex = multiSelect - ? -1 - : buttons.findIndex((item) => value === item.value); - return ( ({ const checked = multiSelect ? value.includes(itemValue) - : index === singleSelectedIndex; + : value === itemValue; const handlePress = (event: GestureResponderEvent) => { onItemPress?.(event); diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 916c964d78..f626cdbdc9 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1272,6 +1272,31 @@ describe('selected check icon', () => { expect(screen.getByTestId('walking-check-icon')).toBeOnTheScreen(); }); + it('avoids animation setup for a static option icon', async () => { + const reanimated = jest.requireMock('react-native-reanimated') as { + useAnimatedStyle: typeof import('react-native-reanimated').useAnimatedStyle; + useSharedValue: typeof import('react-native-reanimated').useSharedValue; + }; + const useAnimatedStyleSpy = jest.spyOn(reanimated, 'useAnimatedStyle'); + const useSharedValueSpy = jest.spyOn(reanimated, 'useSharedValue'); + + try { + await render( + {}} + /> + ); + + expect(useSharedValueSpy).toHaveBeenLastCalledWith(0); + expect(useAnimatedStyleSpy).not.toHaveBeenCalled(); + } finally { + useAnimatedStyleSpy.mockRestore(); + useSharedValueSpy.mockRestore(); + } + }); + it('restores the option icon and resets its scale when selected checks are disabled', async () => { const reanimated = jest.requireMock('react-native-reanimated') as { withSpring: typeof import('react-native-reanimated').withSpring;