Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 22 additions & 47 deletions src/components/ui/SegmentControl.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
key: string;
Expand All @@ -21,60 +21,40 @@ interface SegmentControlProps<T> {
}

export function SegmentControl<T>({ segments, selectedValue, onSelect, style, testID }: SegmentControlProps<T>) {
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 (
<View
testID={testID}
style={[styles.container, { backgroundColor: colors.background }, style]}
onLayout={(e) => {
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 && (
<Animated.View
style={[
styles.indicator,
{
width: itemWidth,
left: spacing.xs,
backgroundColor: colors.card,
borderRadius: radius.md,
transform: [{ translateX: slideAnim }],
},
]}
/>
)}
{segments.map((seg) => {
const isActive = seg.value === selectedValue;
return (
<TouchableOpacity
key={seg.key}
testID={seg.testID}
style={styles.button}
style={[
styles.button,
{
borderRadius: radius.md,
backgroundColor: isActive ? colors.accent : 'transparent',
borderColor: isActive ? colors.accent : 'transparent',
},
]}
onPress={() => onSelect(seg.value)}
activeOpacity={0.8}
{...getSelectableA11y(seg.label, isActive)}
>
<Text
style={{
color: isActive ? colors.foreground : colors.mutedForeground,
color: isActive ? colors.accentForeground : colors.foreground,
fontSize: typography.sizes.sm,
fontWeight: isActive ? '600' : '500',
fontWeight: isActive ? typography.weights.semibold : typography.weights.regular,
textTransform: 'capitalize',
}}
>
Expand All @@ -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,
},
});
122 changes: 122 additions & 0 deletions src/components/ui/__tests__/SegmentControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>[] = [
{ 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(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

expect(getByText('Expense')).toBeTruthy();
expect(getByText('Income')).toBeTruthy();
expect(getByText('Transfer')).toBeTruthy();
});

it('marks only the selected segment as selected', () => {
const { getByTestId } = render(
<SegmentControl segments={SEGMENTS} selectedValue="income" onSelect={() => { }} />
);

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(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

expect(getByTestId('add_tx_type_expense').props.accessibilityState).toEqual({ selected: true });

rerender(
<SegmentControl segments={SEGMENTS} selectedValue="transfer" onSelect={() => { }} />
);

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(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

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(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={onSelect} />
);

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(
<SegmentControl segments={SEGMENTS} selectedValue="expense" onSelect={() => { }} />
);

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<string, unknown> {
if (Array.isArray(style)) {
return style.reduce<Record<string, unknown>>(
(acc, s) => ({ ...acc, ...StyleSheetFlatten(s) }),
{},
);
}
return (style ?? {}) as Record<string, unknown>;
}
19 changes: 18 additions & 1 deletion src/utils/__tests__/accessibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
25 changes: 25 additions & 0 deletions src/utils/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Loading