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