From db7b0e8c6bc33d7ffb93db5c35667e9b253a6e27 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 12 Aug 2026 18:57:20 +0100 Subject: [PATCH] fix(ui): give the segment control a visible selected state Why: - Selection was signalled by a soft shadow over two near-identical backgrounds: 1.06:1 contrast in the light theme, 1.27:1 in dark. There was effectively nothing to see, and nothing at all for a screen reader, since no accessibility helper in the codebase could emit accessibilityState. What: - The selected segment now takes the accent fill the rest of the app already uses for selection, reaching 9.12:1 and 4.54:1. The sliding indicator is gone: it never rendered under test, so selected state had never been asserted anywhere. State now lives on each button, which makes it announceable and testable, and removed the only left-to-right assumptions in the control. --- src/components/ui/SegmentControl.tsx | 69 ++++------ .../ui/__tests__/SegmentControl.test.tsx | 122 ++++++++++++++++++ src/utils/__tests__/accessibility.test.ts | 19 ++- src/utils/accessibility.ts | 25 ++++ 4 files changed, 187 insertions(+), 48 deletions(-) create mode 100644 src/components/ui/__tests__/SegmentControl.test.tsx diff --git a/src/components/ui/SegmentControl.tsx b/src/components/ui/SegmentControl.tsx index 1d9119e..833ca8f 100644 --- a/src/components/ui/SegmentControl.tsx +++ b/src/components/ui/SegmentControl.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Animated, Easing, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { radius } from '../../theme/radius'; -import { shadows } from '../../theme/shadows'; import { spacing } from '../../theme/spacing'; import { useTheme } from '../../theme/theme'; +import { getSelectableA11y } from '../../utils/accessibility'; export interface Segment { key: string; @@ -21,60 +21,40 @@ interface SegmentControlProps { } export function SegmentControl({ segments, selectedValue, onSelect, style, testID }: SegmentControlProps) { - const { colors, spacing, typography, radius } = useTheme(); - - const slideAnim = useRef(new Animated.Value(0)).current; - const [itemWidth, setItemWidth] = useState(0); - - useEffect(() => { - const index = segments.findIndex(s => s.value === selectedValue); - Animated.timing(slideAnim, { - toValue: index * itemWidth, - duration: 200, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }).start(); - }, [selectedValue, itemWidth]); + const { colors, typography, radius } = useTheme(); return ( { - const innerWidth = e.nativeEvent.layout.width - spacing.xs * 2; - setItemWidth(innerWidth / segments.length); - }} + style={[ + styles.container, + { backgroundColor: colors.background, borderColor: colors.border }, + style, + ]} > - {/* Sliding indicator */} - {itemWidth > 0 && ( - - )} {segments.map((seg) => { const isActive = seg.value === selectedValue; return ( onSelect(seg.value)} activeOpacity={0.8} + {...getSelectableA11y(seg.label, isActive)} > @@ -92,18 +72,13 @@ const styles = StyleSheet.create({ flexDirection: 'row', padding: spacing.xs, borderRadius: radius.lg, + borderWidth: 1, height: 44, }, - indicator: { - position: 'absolute', - top: spacing.xs, - bottom: spacing.xs, - ...shadows.soft, - }, button: { flex: 1, alignItems: 'center', justifyContent: 'center', - borderRadius: radius.md, + borderWidth: 1, }, }); diff --git a/src/components/ui/__tests__/SegmentControl.test.tsx b/src/components/ui/__tests__/SegmentControl.test.tsx new file mode 100644 index 0000000..7a9b8a5 --- /dev/null +++ b/src/components/ui/__tests__/SegmentControl.test.tsx @@ -0,0 +1,122 @@ +/** + * SegmentControl Component Tests + * + * Covers F-03: the selected segment must be distinguishable, and must say so + * to assistive technology rather than relying on a visual cue alone. + * + * Assertions target accessibilityState rather than style objects so they + * survive a future restyle. + */ + +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { SegmentControl, type Segment } from '../SegmentControl'; + +jest.mock('../../../theme/theme', () => ({ + useTheme: () => ({ + colors: { + background: '#f7f8f7', + foreground: '#2F241F', + accent: '#603b2e', + accentForeground: '#FAFAFA', + border: '#D5DBD8', + }, + spacing: { xs: 4, sm: 8, md: 16, lg: 20, xl: 24 }, + radius: { sm: 8, md: 12, lg: 16 }, + typography: { + sizes: { xs: 11, sm: 14, md: 16 }, + weights: { regular: '400', medium: '500', semibold: '600', bold: '700' }, + }, + }), +})); + +const SEGMENTS: Segment[] = [ + { key: 'expense', label: 'Expense', value: 'expense', testID: 'add_tx_type_expense' }, + { key: 'income', label: 'Income', value: 'income', testID: 'add_tx_type_income' }, + { key: 'transfer', label: 'Transfer', value: 'transfer', testID: 'add_tx_type_transfer' }, +]; + +describe('SegmentControl', () => { + it('renders every segment label', () => { + const { getByText } = render( + { }} /> + ); + + expect(getByText('Expense')).toBeTruthy(); + expect(getByText('Income')).toBeTruthy(); + expect(getByText('Transfer')).toBeTruthy(); + }); + + it('marks only the selected segment as selected', () => { + const { getByTestId } = render( + { }} /> + ); + + expect(getByTestId('add_tx_type_income').props.accessibilityState).toEqual({ selected: true }); + expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: false }); + expect(getByTestId('add_tx_type_transfer').props.accessibilityState).toEqual({ selected: false }); + }); + + it('moves the selected state when selectedValue changes', () => { + const { getByTestId, rerender } = render( + { }} /> + ); + + expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: true }); + + rerender( + { }} /> + ); + + expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: false }); + expect(getByTestId('add_tx_type_transfer').props.accessibilityState).toEqual({ selected: true }); + }); + + it('gives every segment a button role and its label', () => { + const { getByTestId } = render( + { }} /> + ); + + const expense = getByTestId('add_tx_type_expense'); + expect(expense.props.accessibilityRole).toBe('button'); + expect(expense.props.accessibilityLabel).toBe('Expense'); + }); + + it('fires onSelect with the pressed segment value', () => { + const onSelect = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent.press(getByTestId('add_tx_type_transfer')); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('transfer'); + }); + + it('distinguishes the selected segment by background and text colour', () => { + const { getByTestId, getByText } = render( + { }} /> + ); + + const selected = StyleSheetFlatten(getByTestId('add_tx_type_expense').props.style); + const unselected = StyleSheetFlatten(getByTestId('add_tx_type_income').props.style); + + expect(selected.backgroundColor).toBe('#603b2e'); + expect(unselected.backgroundColor).toBe('transparent'); + expect(selected.backgroundColor).not.toBe(unselected.backgroundColor); + + expect(getByText('Expense').props.style.color).toBe('#FAFAFA'); + expect(getByText('Income').props.style.color).toBe('#2F241F'); + }); +}); + +/** Collapses RN's nested style arrays into a single object. */ +function StyleSheetFlatten(style: unknown): Record { + if (Array.isArray(style)) { + return style.reduce>( + (acc, s) => ({ ...acc, ...StyleSheetFlatten(s) }), + {}, + ); + } + return (style ?? {}) as Record; +} diff --git a/src/utils/__tests__/accessibility.test.ts b/src/utils/__tests__/accessibility.test.ts index c80da53..2e5890d 100644 --- a/src/utils/__tests__/accessibility.test.ts +++ b/src/utils/__tests__/accessibility.test.ts @@ -2,7 +2,7 @@ * Accessibility Utility Tests */ -import { getA11y, getButtonA11y, getHeaderA11y, getImageA11y, getInputA11y, getLinkA11y } from '../../utils/accessibility'; +import { getA11y, getButtonA11y, getHeaderA11y, getImageA11y, getInputA11y, getLinkA11y, getSelectableA11y } from '../../utils/accessibility'; describe('Accessibility Helpers', () => { describe('getButtonA11y', () => { @@ -79,6 +79,23 @@ describe('Accessibility Helpers', () => { }); }); + describe('getSelectableA11y', () => { + it('reports the selected option as selected', () => { + const result = getSelectableA11y('Expense', true); + expect(result).toEqual({ + accessible: true, + accessibilityRole: 'button', + accessibilityLabel: 'Expense', + accessibilityState: { selected: true }, + }); + }); + + it('reports unselected options explicitly rather than omitting the state', () => { + const result = getSelectableA11y('Income', false); + expect(result.accessibilityState).toEqual({ selected: false }); + }); + }); + describe('getA11y', () => { it('returns generic props with role', () => { const result = getA11y('Tab content', 'tab'); diff --git a/src/utils/accessibility.ts b/src/utils/accessibility.ts index 8ae343f..381e94d 100644 --- a/src/utils/accessibility.ts +++ b/src/utils/accessibility.ts @@ -39,6 +39,13 @@ interface LinkA11yProps { accessibilityHint?: string; } +interface SelectableA11yProps { + accessible: true; + accessibilityRole: 'button'; + accessibilityLabel: string; + accessibilityState: { selected: boolean }; +} + interface GenericA11yProps { accessible: true; accessibilityRole?: AccessibilityRole; @@ -60,6 +67,24 @@ export function getButtonA11y(label: string, hint?: string): ButtonA11yProps { }; } +/** + * Returns accessibility props for one option in a set of mutually exclusive + * choices (segmented controls, chips, option rows). + * + * `selected` is always emitted, including when false, so assistive technology + * announces the unselected options as unselected rather than saying nothing. + * @param label - Translated label describing the option + * @param selected - Whether this option is the currently chosen one + */ +export function getSelectableA11y(label: string, selected: boolean): SelectableA11yProps { + return { + accessible: true, + accessibilityRole: 'button', + accessibilityLabel: label, + accessibilityState: { selected }, + }; +} + /** * Returns accessibility props for an input element. * @param label - Translated label describing the input