feat(components): InsightRow with stagger reveal animation

This commit is contained in:
fightrate-bot committed 2026-06-16 01:48:50 -03:00
1 parent edc10826aa
commit 2c51fd2623
2 files changed
+196

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
jest.mock('react-native-reanimated', () => {
return {
__esModule: true,
default: { View: require('react-native').View, Text: require('react-native').Text },
Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
useSharedValue: (initial: number) => ({ value: initial }),
useAnimatedStyle: (fn: () => unknown) => fn(),
withTiming: (to: number) => to,
withDelay: (delay: number, value: any) => value,
withRepeat: (to: number) => to,
cancelAnimation: () => undefined,
};
});
import { InsightRow } from '../components/InsightRow';
describe('InsightRow', () => {
it('renders index zero-padded', () => {
let tree: any;
TestRenderer.act(() => {
tree = TestRenderer.create(React.createElement(InsightRow, {
index: 3, label: 'Carga', statusLabel: 'ok', summary: 'Alta', detail: 'TRIMP 145', value: '145', tone: 'good', testID: 'row',
}));
});
const json = JSON.stringify(tree.toJSON());
expect(json).toContain('03');
});
it('renders label, status, summary, detail, and value', () => {
let tree: any;
TestRenderer.act(() => {
tree = TestRenderer.create(React.createElement(InsightRow, {
index: 1, label: 'Carga', statusLabel: 'ok', summary: 'Carga alta', detail: 'TRIMP 145', value: '145', unit: 'TRIMP', tone: 'good', testID: 'row',
}));
});
const json = JSON.stringify(tree.toJSON());
expect(json).toContain('Carga');
expect(json).toContain('ok');
expect(json).toContain('Carga alta');
expect(json).toContain('TRIMP 145');
expect(json).toContain('145');
expect(json).toContain('TRIMP');
});
});
+149
View File
@@ -0,0 +1,149 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated';
import { colors, motion, spacing, typography } from '../theme';
type Tone = 'good' | 'warning' | 'danger' | 'neutral';
type Props = {
readonly index: number;
readonly label: string;
readonly statusLabel: string;
readonly summary: string;
readonly detail: string;
readonly value: string;
readonly unit?: string;
readonly tone: Tone;
readonly testID?: string;
};
function toneColor(tone: Tone): string {
if (tone === 'good') return colors.success;
if (tone === 'danger') return colors.action;
if (tone === 'warning') return colors.warning;
return colors.text;
}
export function InsightRow({
index,
label,
statusLabel,
summary,
detail,
value,
unit,
tone,
testID,
}: Props) {
const indexLabel = String(index).padStart(2, '0');
const opacity = useSharedValue(0);
const translateY = useSharedValue(8);
const color = toneColor(tone);
useEffect(() => {
const delay = index < 8 ? index * motion.stagger.row : 0;
opacity.value = withDelay(
delay,
withTiming(1, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
);
translateY.value = withDelay(
delay,
withTiming(0, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
);
}, [index, opacity, translateY]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
const indexStyle: ViewStyle = { color };
return (
<Animated.View
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
style={[styles.row, animatedStyle]}
testID={testID}
>
<Text style={styles.index}>{indexLabel}</Text>
<View style={styles.body}>
<View style={styles.topLine}>
<Text style={styles.label}>{label}</Text>
<Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
</View>
<Text style={styles.summary}>{summary}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
<View style={styles.valueBox}>
<Text style={[styles.value, indexStyle]}>{value}</Text>
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
row: {
minHeight: 92,
borderBottomWidth: 1,
borderBottomColor: colors.line,
flexDirection: 'row',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
index: {
...typography.label,
color: colors.textDim,
width: 28,
},
body: {
flex: 1,
gap: spacing.xxs,
},
topLine: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.sm,
},
label: {
...typography.label,
color: colors.text,
flexShrink: 1,
},
status: {
...typography.label,
textAlign: 'right',
},
summary: {
color: colors.text,
fontSize: 16,
lineHeight: 20,
fontWeight: '800',
letterSpacing: -0.45,
},
detail: {
color: colors.textMuted,
fontSize: 12,
lineHeight: 16,
fontWeight: '600',
},
valueBox: {
width: 84,
alignItems: 'flex-end',
justifyContent: 'center',
},
value: {
...typography.metricNumeric,
textAlign: 'right',
},
unit: {
...typography.label,
color: colors.textDim,
textAlign: 'right',
},
});