feat(components): InsightRow with stagger reveal animation
This commit is contained in:
1 parent
edc10826aa
commit
2c51fd2623
2 files changed
+196
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user