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..42f2b87249 --- /dev/null +++ b/src/components/SegmentedButtons/AnimatedOptionIcon.tsx @@ -0,0 +1,74 @@ +import { StyleSheet, View } 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; +}; + +type AnimatedIconProps = Omit; + +const AnimatedIcon = ({ + color, + opacity, + scale, + source, + testID, +}: AnimatedIconProps) => { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - scale.value }], + })); + + return ( + + + + ); +}; + +const AnimatedOptionIcon = ({ animated, ...props }: Props) => { + if (animated) { + return ; + } + + const { color, opacity, source, testID } = props; + + 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 new file mode 100644 index 0000000000..094d812e40 --- /dev/null +++ b/src/components/SegmentedButtons/SegmentedButtonContent.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { StyleProp, TextStyle } from 'react-native'; + +import { + ReduceMotion, + useSharedValue, + withSpring, +} 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 Text from '../Typography/Text'; + +type Props = { + checked: boolean; + icon?: IconSource; + iconColor: TextStyle['color']; + iconOpacity: number; + label?: string; + labelColor: TextStyle['color']; + labelMaxFontSizeMultiplier?: number; + labelOpacity: number; + labelStyle?: StyleProp; + showSelectedCheck?: boolean; + testID?: string; + theme: Theme; +}; + +const SegmentedButtonContent = ({ + checked, + icon, + iconColor, + iconOpacity, + label, + labelColor, + labelMaxFontSizeMultiplier, + labelOpacity, + labelStyle, + showSelectedCheck, + testID, + theme, +}: Props) => { + const showCheckIcon = !!(checked && showSelectedCheck); + const optionIcon = icon && (!label || !showCheckIcon) ? icon : undefined; + + const reduceMotion = useReduceMotion(); + const checkmarkScale = useSharedValue(showCheckIcon ? 1 : 0); + + React.useEffect(() => { + checkmarkScale.value = withSpring(showCheckIcon ? 1 : 0, { + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, + }); + }, [checkmarkScale, reduceMotion, showCheckIcon]); + + const labelTextStyle: TextStyle = { + ...theme.fonts[SegmentedButtonTokens.labelTextType], + color: labelColor, + }; + + return ( + + {showCheckIcon && ( + + )} + {optionIcon && ( + + )} + {label && ( + + {label} + + )} + + ); +}; + +const styles = StyleSheet.create({ + content: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: SegmentedButtonTokens.horizontalPadding, + columnGap: SegmentedButtonTokens.iconLabelGap, + }, + label: { + flexShrink: 1, + textAlign: 'center', + }, +}); + +export default SegmentedButtonContent; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4f701f0b48..a90d08f67b 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { GestureResponderEvent, PressableAndroidRippleConfig, @@ -8,24 +8,29 @@ import type { ViewStyle, } from 'react-native'; +import SegmentedButtonContent from './SegmentedButtonContent'; +import { FOCUS_RING_OUTSET, SegmentedButtonTokens } from './tokens'; import { getSegmentedButtonBorderRadius, - getSegmentedButtonColors, - getSegmentedButtonDensityPadding, + getSegmentedButtonBorderStyles, + resolveColors, } from './utils'; -import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; +import type { SegmentedButtonPosition } from './utils'; +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'; 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`. */ @@ -44,8 +49,9 @@ export type Props = { * Whether the button is disabled. */ disabled?: boolean; + previousDisabled?: 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; @@ -57,10 +63,6 @@ export type Props = { * Function to execute on press. */ onPress?: (event: GestureResponderEvent) => void; - /** - * Value of button. - */ - value: string; /** * Label text of the button. */ @@ -68,7 +70,7 @@ export type Props = { /** * Button segment. */ - segment?: 'first' | 'last'; + segment: SegmentedButtonPosition; /** * Show optional check icon to indicate selected state */ @@ -95,15 +97,17 @@ export type Props = { */ hitSlop?: TouchableRippleProps['hitSlop']; /** - * @optional + * Resolved theme inherited from the segmented button group. */ - theme?: ThemeProp; + theme: Theme; }; const SegmentedButtonItem = ({ checked, + role, 'aria-label': ariaLabel, - disabled, + disabled = false, + previousDisabled = false, style, labelStyle, showSelectedCheck, @@ -116,143 +120,110 @@ const SegmentedButtonItem = ({ onPress, segment, density = 'regular', - theme: themeOverrides, + theme, labelMaxFontSizeMultiplier, hitSlop, }: Props) => { - const theme = useInternalTheme(themeOverrides); - - const checkScale = React.useRef(new Animated.Value(0)).current; + const accessibilityLabel = ariaLabel ?? label; - React.useEffect(() => { - if (!showSelectedCheck) { - return; - } - if (checked) { - Animated.spring(checkScale, { - toValue: 1, - useNativeDriver: true, - }).start(); - } else { - Animated.spring(checkScale, { - toValue: 0, - useNativeDriver: true, - }).start(); - } - }, [checked, checkScale, showSelectedCheck]); + const [focused, setFocused] = React.useState(false); + const showFocusRing = focused && !disabled; - const { borderColor, textColor, textOpacity, borderWidth, backgroundColor } = - getSegmentedButtonColors({ - checked, - theme, - disabled, - checkedColor, - uncheckedColor, - }); - - const borderRadius = theme.shapes.corner.largeIncreased; - const segmentBorderRadius = getSegmentedButtonBorderRadius({ - theme, - segment, + const colors = resolveColors(theme, { + checked, + disabled, + contentColor: checked ? checkedColor : uncheckedColor, + dividerDisabled: disabled && previousDisabled, }); - const showIcon = !icon ? false : label && checked ? !showSelectedCheck : true; - const showCheckedIcon = checked && showSelectedCheck; - const iconSize = 18; - const iconStyle = { - marginRight: label ? 5 : showCheckedIcon ? 3 : 0, - ...(label && { - transform: [ - { - scale: checkScale.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0], - }), - }, - ], - }), - }; + const borderRadius = getSegmentedButtonBorderRadius(segment); + const borderStyles = getSegmentedButtonBorderStyles(segment, colors); - 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 height = SegmentedButtonTokens.containerHeight[density]; return ( - + { + if (!disabled && isKeyboardFocusEvent(event)) { + setFocused(true); + } + }} + onBlur={() => setFocused(false)} > - - {showCheckedIcon ? ( - - - - ) : null} - {showIcon ? ( - - - - ) : null} - - {label} - - + + {showFocusRing ? ( + + ) : null} ); }; const styles = StyleSheet.create({ - button: { + wrapper: { flex: 1, - minWidth: 76, - borderStyle: 'solid', }, - label: { - textAlign: 'center', - }, - content: { - flexDirection: 'row', - alignItems: 'center', + touchable: { + width: '100%', justifyContent: 'center', - paddingVertical: 9, - paddingHorizontal: 16, + }, + focusRing: { + position: 'absolute', + top: -FOCUS_RING_OUTSET, + bottom: -FOCUS_RING_OUTSET, + left: -FOCUS_RING_OUTSET, + right: -FOCUS_RING_OUTSET, + borderWidth: SegmentedButtonTokens.focusIndicatorThickness, + pointerEvents: 'none', }, }); diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx index c5fceeaeae..4f657b288d 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'; @@ -46,7 +46,7 @@ export type Props = { /** * 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. @@ -133,51 +133,55 @@ 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; + + {buttons.map( + ({ value: itemValue, onPress: onItemPress, ...itemProps }, index) => { + const segment = + index === 0 + ? 'first' + : index === buttons.length - 1 + ? 'last' + : 'middle'; - const checked = - multiSelect && Array.isArray(value) - ? value.includes(item.value) - : value === item.value; + const checked = multiSelect + ? value.includes(itemValue) + : value === itemValue; - const onPress = (e: GestureResponderEvent) => { - item.onPress?.(e); + const handlePress = (event: GestureResponderEvent) => { + onItemPress?.(event); - const nextValue = - multiSelect && Array.isArray(value) - ? checked - ? value.filter((val) => item.value !== val) - : [...value, item.value] - : item.value; + if (multiSelect) { + onValueChange( + checked + ? value.filter((selectedValue) => itemValue !== selectedValue) + : [...value, itemValue] + ); + } else { + onValueChange(itemValue); + } + }; - // @ts-expect-error: TS doesn't preserve types after destructuring, so the type isn't inferred correctly - onValueChange(nextValue); - }; - - return ( - - ); - })} + return ( + + ); + } + )} ); }; diff --git a/src/components/SegmentedButtons/tokens.ts b/src/components/SegmentedButtons/tokens.ts new file mode 100644 index 0000000000..662eba16be --- /dev/null +++ b/src/components/SegmentedButtons/tokens.ts @@ -0,0 +1,57 @@ +import { tokens } from '../../theme/tokens'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { ColorRole } from '../../theme/types'; + +const stateTokens = tokens.md.sys.state; + +const sizes = { + containerHeight: { + regular: 40, + small: 36, + medium: 32, + high: 28, + } as const satisfies Record<'regular' | 'small' | 'medium' | 'high', number>, + horizontalPadding: 12, + iconSize: 18, + iconLabelGap: 8, + outlineWidth: 1, + containerShape: cornerFull, + labelTextType: 'labelLarge', + disabledLabelTextOpacity: stateTokens.opacity.disabled, + disabledIconOpacity: stateTokens.opacity.disabled, + disabledOutlineOpacity: 0.12, + focusIndicatorThickness: stateTokens.focusIndicator.thickness, + focusIndicatorOutlineOffset: stateTokens.focusIndicator.outerOffset, +} as const; + +const baseColors = { + selectedContainerColor: 'secondaryContainer', + outlineColor: 'outline', + disabledOutlineColor: 'onSurface', + disabledLabelTextColor: 'onSurface', + disabledIconColor: 'onSurface', + focusIndicatorColor: 'secondary', +} as const satisfies Record; + +const contentColors = { + selectedLabelTextColor: 'onSecondaryContainer', + unselectedLabelTextColor: 'onSurface', + selectedIconColor: 'onSecondaryContainer', + unselectedIconColor: 'onSurface', +} as const satisfies Record< + | 'selectedLabelTextColor' + | 'unselectedLabelTextColor' + | 'selectedIconColor' + | 'unselectedIconColor', + ColorRole +>; + +export const SegmentedButtonTokens = { + ...sizes, + ...baseColors, + ...contentColors, +}; + +export const FOCUS_RING_OUTSET = + SegmentedButtonTokens.focusIndicatorThickness + + SegmentedButtonTokens.focusIndicatorOutlineOffset; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index 1c74f5c49f..698631c875 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -1,150 +1,131 @@ -import type { ViewStyle } from 'react-native'; +import type { ColorValue, ViewStyle } from 'react-native'; -import { tokens } from '../../theme/tokens'; -import type { InternalTheme } from '../../types'; +import color from 'color'; -const stateOpacity = tokens.md.sys.state.opacity; +import { SegmentedButtonTokens } from './tokens'; +import type { InternalTheme } from '../../types'; -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; +type SegmentedButtonColorState = { checked: boolean; + disabled: boolean; }; -type SegmentedButtonProps = { - checkedColor?: string; - uncheckedColor?: string; -} & BaseProps; - -const DEFAULT_PADDING = 9; - -export const getSegmentedButtonDensityPadding = ({ - density, -}: { - density?: 'regular' | 'small' | 'medium' | 'high'; -}) => { - let padding = DEFAULT_PADDING; - - switch (density) { - case 'small': - return padding - 2; - case 'medium': - return padding - 4; - case 'high': - return padding - 8; - default: - return padding; - } +type SegmentedButtonColorOptions = SegmentedButtonColorState & { + contentColor?: string; + dividerDisabled: boolean; }; -export const getDisabledSegmentedButtonStyle = ({ - theme, - index, - buttons, -}: { - theme: InternalTheme; - buttons: { disabled?: boolean }[]; - index: number; -}): ViewStyle => { - const width = getSegmentedButtonBorderWidth({ theme }); - const isDisabled = buttons[index]?.disabled; - const isNextDisabled = buttons[index + 1]?.disabled; - - if (!isDisabled && isNextDisabled) { - return { - borderRightWidth: width, - }; - } - return {}; -}; +export type SegmentedButtonPosition = 'first' | 'last' | 'middle'; -export const getSegmentedButtonBorderRadius = ({ - segment, -}: { - theme: InternalTheme; - segment?: 'first' | 'last'; -}): ViewStyle => { +export const getSegmentedButtonBorderRadius = ( + segment: SegmentedButtonPosition +): ViewStyle => { if (segment === 'first') { return { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderEndWidth: 0, - }; - } else if (segment === 'last') { - return { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, + borderTopStartRadius: SegmentedButtonTokens.containerShape, + borderBottomStartRadius: SegmentedButtonTokens.containerShape, + borderTopEndRadius: 0, + borderBottomEndRadius: 0, }; - } else { + } + + if (segment === 'last') { return { - borderRadius: 0, - borderEndWidth: 0, + borderTopStartRadius: 0, + borderBottomStartRadius: 0, + borderTopEndRadius: SegmentedButtonTokens.containerShape, + borderBottomEndRadius: SegmentedButtonTokens.containerShape, }; } -}; -const getSegmentedButtonBackgroundColor = ({ checked, theme }: BaseProps) => { - if (checked) { - return theme.colors.secondaryContainer; - } - return 'transparent'; + return { + borderRadius: 0, + }; }; -const getSegmentedButtonBorderColor = ({ theme, disabled }: BaseProps) => { - if (disabled) { - return theme.colors.outlineVariant; - } - return theme.colors.outline; +type SegmentedButtonBorderColors = { + outline: ColorValue; + divider: ColorValue; }; -const getSegmentedButtonBorderWidth = ({ - theme: _t, -}: Omit) => { - return 1; +export const getSegmentedButtonBorderStyles = ( + segment: SegmentedButtonPosition, + { outline, divider }: SegmentedButtonBorderColors +): ViewStyle => { + const outlineWidth = SegmentedButtonTokens.outlineWidth; + + return { + borderColor: outline, + borderStartColor: segment === 'first' ? outline : divider, + borderTopWidth: outlineWidth, + borderBottomWidth: outlineWidth, + borderStartWidth: outlineWidth, + borderEndWidth: segment === 'last' ? outlineWidth : 0, + }; }; -const getSegmentedButtonTextColor = ({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, -}: SegmentedButtonProps) => { +const resolveContentColors = ( + theme: InternalTheme, + { checked, disabled }: SegmentedButtonColorState, + contentColor?: string +) => { if (disabled) { - return theme.colors.onSurface; + return { + labelColor: theme.colors[SegmentedButtonTokens.disabledLabelTextColor], + labelOpacity: SegmentedButtonTokens.disabledLabelTextOpacity, + iconColor: theme.colors[SegmentedButtonTokens.disabledIconColor], + iconOpacity: SegmentedButtonTokens.disabledIconOpacity, + }; } - if (checked) { - return checkedColor ?? theme.colors.onSecondaryContainer; + + const labelColor = checked + ? SegmentedButtonTokens.selectedLabelTextColor + : SegmentedButtonTokens.unselectedLabelTextColor; + const iconColor = checked + ? SegmentedButtonTokens.selectedIconColor + : SegmentedButtonTokens.unselectedIconColor; + + return { + labelColor: contentColor ?? theme.colors[labelColor], + labelOpacity: 1, + iconColor: contentColor ?? theme.colors[iconColor], + iconOpacity: 1, + }; +}; + +const applyOpacity = (value: ColorValue, opacity: number): ColorValue => { + if (opacity === 1 || typeof value !== 'string') { + return value; } - return uncheckedColor ?? theme.colors.onSurface; + + 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 applyOpacity(theme.colors[colorToken], opacity); }; -export const getSegmentedButtonColors = ({ - theme, - disabled, - checked, - 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 textOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; - - return { backgroundColor, borderColor, textColor, textOpacity, borderWidth }; +export const resolveColors = ( + theme: InternalTheme, + options: SegmentedButtonColorOptions +) => { + const { checked, disabled, contentColor, dividerDisabled } = options; + + return { + wrapper: checked + ? theme.colors[SegmentedButtonTokens.selectedContainerColor] + : 'transparent', + content: resolveContentColors(theme, options, contentColor), + 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 ce950f671f..f626cdbdc9 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -1,15 +1,90 @@ +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'; import { getTheme } from '../../core/theming'; -import { render, screen } from '../../test-utils'; -import { tokens } from '../../theme/tokens'; +import { fireEvent, render, screen, userEvent } from '../../test-utils'; +import { ReduceMotionContext } from '../../theme/accessibility/ReduceMotionContext'; import SegmentedButtons from '../SegmentedButtons/SegmentedButtons'; import { - getDisabledSegmentedButtonStyle, - getSegmentedButtonColors, + FOCUS_RING_OUTSET, + SegmentedButtonTokens, +} from '../SegmentedButtons/tokens'; +import { + getSegmentedButtonBorderRadius, + getSegmentedButtonBorderStyles, + resolveColors, } 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' }]; + 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 = ( @@ -17,7 +92,10 @@ it('renders segmented button', async () => { {}} value={'walk'} - buttons={[{ value: 'walk' }, { value: 'ride' }]} + buttons={[ + { value: 'walk', label: 'Walking' }, + { value: 'ride', label: 'Riding' }, + ]} /> ) ).toJSON(); @@ -26,248 +104,722 @@ 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', label: 'Walking' }, + { + value: 'ride', + label: 'Riding', + icon: 'car', + disabled: true, + testID: 'ride', + }, + ]} + /> + ); - process.nextTick(() => { - expect(tree).toMatchSnapshot(); + 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, + }); + expect(screen.getByTestId('ride-icon')).toHaveStyle({ + opacity: SegmentedButtonTokens.disabledIconOpacity, }); }); it('renders checked segmented button with selected check', async () => { - const tree = ( - await render( + await render( + {}} + value="walk" + buttons={[ + { + value: 'walk', + label: 'Walking', + showSelectedCheck: true, + testID: 'walk', + }, + { value: 'ride', label: 'Riding', disabled: true }, + ]} + /> + ); + + 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( {}} - value={'walk'} + value="walk" + onValueChange={initialValueChange} buttons={[ - { value: 'walk', showSelectedCheck: true }, - { value: 'ride', disabled: true }, + { + value: 'walk', + label: 'Walking', + onPress: initialItemOnPress, + testID: 'walk', + }, + { value: 'ride', label: 'Riding' }, ]} /> - ) - ).toJSON(); + ); + + 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('cancels an in-flight press when the item becomes disabled', async () => { + const itemOnPress = jest.fn(); + const onValueChange = jest.fn(); + const buttons = [ + { + value: 'walk', + label: 'Walking', + onPress: itemOnPress, + testID: 'walk', + }, + { value: 'ride', label: 'Riding' }, + ]; + const { rerender } = await render( + + ); + const pressedButton = screen.getByTestId('walk'); + + await fireEvent(pressedButton, 'pressIn'); + await rerender( + + button.value === 'walk' ? { ...button, disabled: true } : button + )} + /> + ); + + expect(screen.getByTestId('walk')).not.toHaveProp('onPress'); + + 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 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(); + 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'); + + await fireEvent(pressedButton, 'focus'); + await fireEvent(pressedButton, 'pressIn'); + await rerender( + + ); + + expect(screen.getByTestId('walk-focus-ring')).toBeOnTheScreen(); + expect(screen.queryByTestId('ride-focus-ring')).not.toBeOnTheScreen(); - process.nextTick(() => { - expect(tree).toMatchSnapshot(); + 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(walkOnPress).toHaveBeenCalledTimes(1); + expect(rideOnPress).not.toHaveBeenCalled(); + expect(onValueChange).toHaveBeenCalledWith('walk'); + }); + + it('preserves multiselect append order and removes duplicate values', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + const buttons = [ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'ride', label: 'Riding' }, + { value: 'drive', label: 'Driving', 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', label: 'Walking', testID: 'walk' }, + { value: 'ride', label: 'Riding' }, + ]} + theme={{ + colors: { + secondaryContainer: '#123456', + stateLayerPressed: '#654321', + }, + fonts: { labelLarge: { fontSize: 18 } }, + }} + /> + ); + + 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('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', - expected: '000', - }, - { - disabled: false, - checked: false, - checkedColor: 'a125f5', - uncheckedColor: '000', + customColor: '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 }); + disabled, + 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', + }, + ]} + /> + ); + + expect(screen.getByTestId('walk-label')).toHaveStyle({ color: '#123456' }); + expect(screen.getByTestId('drive-label')).toHaveStyle({ color: '#654321' }); }); - it('should return correct background color when uncheked (V3 & V2)', () => { + it.each([ + { + state: 'enabled', + disabled: false, + expected: theme.colors.outline, + }, + { + state: 'disabled', + disabled: true, + expected: color(theme.colors.onSurface as string) + .fade(1 - SegmentedButtonTokens.disabledOutlineOpacity) + .rgb() + .string(), + }, + ])('resolves the $state outline', ({ disabled, expected }) => { expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: false, + resolveColors(theme, { checked: false, - }) - ).toMatchObject({ - backgroundColor: 'transparent', - }); + disabled, + dividerDisabled: false, + }).outline + ).toBe(expected); }); - it('should return correct border color with 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(), + resolveColors(theme, { + checked, disabled: false, - checked: false, - }) - ).toMatchObject({ - borderColor: getTheme().colors.outline, - }); + dividerDisabled: false, + }).wrapper + ).toBe(expected); }); +}); - it('should return correct border color when disabled and theme version 3', () => { - expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: true, - checked: false, - }) - ).toMatchObject({ - borderColor: getTheme().colors.outlineVariant, - }); - }); +describe('segmented button topology helpers', () => { + const borderColors = { outline: 'outline', divider: 'divider' }; - it('should return correct textColor with theme version 3', () => { - expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: false, - checked: false, - }) - ).toMatchObject({ - textColor: getTheme().colors.onSurface, - }); + 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('should return correct textColor when disabled and theme version 3', () => { - expect( - getSegmentedButtonColors({ - theme: getTheme(), - disabled: true, - checked: false, - }) - ).toMatchObject({ - textColor: getTheme().colors.onSurface, - textOpacity: stateOpacity.disabled, - }); + it.each([ + { + segment: 'first' as const, + expected: { + borderColor: borderColors.outline, + borderStartColor: borderColors.outline, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, + }, + }, + { + segment: 'middle' as const, + expected: { + borderColor: borderColors.outline, + borderStartColor: borderColors.divider, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth: 0, + }, + }, + { + segment: 'last' as const, + expected: { + 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, borderColors)).toEqual( + expected + ); }); }); -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({}); - }); - }); +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('Returns empty style object for all disabled buttons', () => { - [0, 1, 2].forEach((index) => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [{ disabled: true }, { disabled: true }, { disabled: true }], - index, - }) - ).toMatchObject({}); + it.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}-wrapper`)).toHaveStyle(radii); + expect(screen.getByTestId(id)).toHaveStyle({ + ...radii, + borderColor: getTheme().colors.outline, + borderStartColor: getTheme().colors.outline, + borderTopWidth: SegmentedButtonTokens.outlineWidth, + borderBottomWidth: SegmentedButtonTokens.outlineWidth, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + borderEndWidth, + }); + + await fireEvent(screen.getByTestId(id), 'focus'); + 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(); + } + } + ); + + 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 }); + 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])).toHaveStyle({ + borderColor: disabledStates[index] + ? disabledOutlineColor + : getTheme().colors.outline, + borderStartColor: dividerDisabled + ? disabledOutlineColor + : getTheme().colors.outline, + borderStartWidth: SegmentedButtonTokens.outlineWidth, + }); + }); + } + ); + + 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', + }; + + await render( + {}} + buttons={[ + { + value: 'walk', + label: 'Walking', + testID: 'walk', + style, + }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + expect(screen.getByTestId('walk-wrapper')).toHaveStyle(style); + expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, + }); + 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')).not.toHaveStyle({ + borderColor: style.borderColor, + borderWidth: style.borderWidth, }); }); - it('Returns proper style object for one disabled button', () => { - expect( - getDisabledSegmentedButtonStyle({ - theme: getTheme(), - buttons: [{ disabled: false }, { disabled: true }, { disabled: true }], - index: 0, - }) - ).toMatchObject({ borderRightWidth: 1 }); + it('always suppresses the user-agent outline on web', async () => { + const platformSelect = jest + .spyOn(Platform, 'select') + .mockImplementation((specifics) => specifics.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 { + platformSelect.mockRestore(); + } }); - 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 }); - }); + 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 visual height without local target constraints', + async ({ density, expected }) => { + expect(SegmentedButtonTokens.containerHeight[density]).toBe(expected); + + await render( + {}} + buttons={[ + { value: 'walk', label: 'Walking', testID: 'walk' }, + { value: 'drive', label: 'Driving' }, + ]} + /> + ); + + expect(screen.getByTestId('walk-wrapper')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, + }); + expect(screen.getByTestId('walk')).not.toHaveStyle({ + minHeight: 48, + minWidth: 48, + }); + expect(screen.getByTestId('walk')).toHaveStyle({ + height: expected, + }); + } + ); + + 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', + disabled: true, + }, + ]} + /> + ); + + await fireEvent(screen.getByTestId('drive'), 'focus'); + expect(screen.queryByTestId('drive-focus-ring')).not.toBeOnTheScreen(); }); }); @@ -280,11 +832,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', }, ]} @@ -296,6 +850,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( { buttons={[ { value: 'walk', + label: 'Walking', testID: 'walking-button', }, { value: 'drive', + label: 'Driving', testID: 'driving-button', }, ]} @@ -404,38 +992,262 @@ 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']} + {}} + /> + ); + + 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(); + }); +}); + +describe('accessibility semantics', () => { + it('prioritizes aria-label and falls back to visible text', async () => { + await render( + {}} /> ); - const buttons = screen.getAllByRole('button'); + expect(screen.getByRole('radio', { name: 'Walking' })).toBeOnTheScreen(); + expect( + screen.getByRole('radio', { name: 'Travel by car' }) + ).toBeOnTheScreen(); + expect(screen.getByRole('radio', { name: 'Transit' })).toBeOnTheScreen(); + expect( + screen.queryByRole('radio', { name: 'Driving' }) + ).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: { role: 'radiogroup' }, + }); + expect(radios).toHaveLength(3); + expect(radios[0]).toHaveProp( + 'accessibilityState', + expect.objectContaining({ checked: true }) + ); + 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( + + 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(buttons[0]).toHaveProp( + expect(group).toMatchObject({ + props: { role: 'group' }, + }); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).toHaveProp( 'accessibilityState', expect.objectContaining({ checked: true }) ); - expect(buttons[1]).toHaveProp( + 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(); @@ -448,7 +1260,7 @@ describe('should have `accessibilityState={ checked: true }` when selected', () value: 'walk', label: 'Walking', showSelectedCheck: true, - testID: 'walking-check-icon', + testID: 'walking', }, { value: 'transit', label: 'Transit' }, { value: 'drive', label: 'Driving' }, @@ -459,6 +1271,118 @@ describe('should have `accessibilityState={ checked: true }` when selected', () 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; + }; + 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', () => { @@ -471,7 +1395,7 @@ describe('labelStyle is handled', () => { label: 'Walking', value: 'walk', testID: 'walking-button', - labelStyle: { fontSize: 10 }, + labelStyle: { fontSize: 10, opacity: 0.5 }, }, { label: 'Driving', @@ -486,6 +1410,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 4de6f40b6f..8908c867b7 100644 --- a/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SegmentedButton.test.tsx.snap @@ -2,11 +2,15 @@ exports[`renders segmented button 1`] = ` - + + > + Walking + @@ -143,26 +161,23 @@ exports[`renders segmented button 1`] = ` style={ [ { - "backgroundColor": "transparent", - "borderBottomLeftRadius": 0, - "borderColor": "rgba(121, 116, 126, 1)", - "borderRadius": 20, - "borderTopLeftRadius": 0, - "borderWidth": 1, + "flex": 1, }, { - "borderStyle": "solid", - "flex": 1, - "minWidth": 76, + "borderBottomEndRadius": 9999, + "borderBottomStartRadius": 0, + "borderTopEndRadius": 9999, + "borderTopStartRadius": 0, }, - [ - undefined, - {}, - ], + { + "backgroundColor": "transparent", + }, + undefined, ] } > - + + > + Riding +