Skip to content

Commit 01e1b36

Browse files
feat(perps): stack change below price with absolute + percent
Match the asset overview screen for the prominent market-summary header: stack the 24h change below the price and show both the absolute change and the percentage (e.g. "+$3.57 (+0.21%)") using a medium-weight BodySm line. The absolute change is derived from the current price and the 24h percentage. The compact header keeps its inline, percentage-only layout.
1 parent afdbe36 commit 01e1b36

2 files changed

Lines changed: 106 additions & 12 deletions

File tree

app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { StyleProp, StyleSheet, ViewStyle } from 'react-native';
23
import { render } from '@testing-library/react-native';
34
import {
45
Text,
@@ -295,13 +296,67 @@ describe('LivePriceHeader', () => {
295296
(el) => el.props.children === '$3,000',
296297
);
297298
const changeText = textElements.find(
298-
(el) => el.props.children === '+5.50%',
299+
(el) =>
300+
typeof el.props.children === 'string' &&
301+
el.props.children.includes('(+5.50%)'),
299302
);
300303

301-
expect(priceText?.props.variant).toBe(TextVariant.HeadingLg);
304+
expect(priceText?.props.variant).toBe(TextVariant.DisplayLg);
302305
expect(priceText?.props.color).toBe(TextColor.TextDefault);
303-
expect(changeText?.props.variant).toBe(TextVariant.BodyMd);
306+
expect(changeText?.props.variant).toBe(TextVariant.BodySm);
304307
expect(changeText?.props.fontWeight).toBe(FontWeight.Medium);
308+
expect(changeText?.props.color).toBe(TextColor.SuccessDefault);
309+
});
310+
311+
it('shows the absolute change and percentage for the large variant', () => {
312+
const { UNSAFE_getAllByType } = render(
313+
<LivePriceHeader symbol="ETH" currentPrice={3000} size="large" />,
314+
);
315+
316+
const changeText = UNSAFE_getAllByType(Text).find(
317+
(el) =>
318+
typeof el.props.children === 'string' &&
319+
el.props.children.includes('(+5.50%)'),
320+
);
321+
322+
// 3000 - 3000 / 1.055 ≈ 156.4
323+
expect(changeText?.props.children).toBe('+$156.4 (+5.50%)');
324+
});
325+
326+
it('shows only the percentage for the compact (default) variant', () => {
327+
const { UNSAFE_getAllByType } = render(
328+
<LivePriceHeader symbol="ETH" currentPrice={3000} />,
329+
);
330+
331+
const changeText = UNSAFE_getAllByType(Text).find(
332+
(el) => el.props.children === '+5.50%',
333+
);
334+
335+
expect(changeText).toBeDefined();
336+
});
337+
338+
it('stacks the change below the price for the large variant', () => {
339+
const tree = render(
340+
<LivePriceHeader symbol="ETH" currentPrice={3000} size="large" />,
341+
).toJSON();
342+
const containerStyle = StyleSheet.flatten(
343+
(tree as { props: { style?: StyleProp<ViewStyle> } } | null)?.props
344+
.style,
345+
);
346+
347+
expect(containerStyle.flexDirection).toBe('column');
348+
});
349+
350+
it('keeps the change inline with the price for the default variant', () => {
351+
const tree = render(
352+
<LivePriceHeader symbol="ETH" currentPrice={3000} />,
353+
).toJSON();
354+
const containerStyle = StyleSheet.flatten(
355+
(tree as { props: { style?: StyleProp<ViewStyle> } } | null)?.props
356+
.style,
357+
);
358+
359+
expect(containerStyle.flexDirection).toBe('row');
305360
});
306361
});
307362

app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { memo, useMemo } from 'react';
2-
import { View, StyleSheet } from 'react-native';
2+
import { View, StyleSheet, ViewStyle } from 'react-native';
33
import {
44
Text,
55
TextVariant,
@@ -33,9 +33,6 @@ interface LivePriceHeaderProps {
3333
const styleSheet = () =>
3434
StyleSheet.create({
3535
container: {
36-
flexDirection: 'row',
37-
alignItems: 'baseline',
38-
gap: 6,
3936
// Allow the row to shrink within a constrained parent so long prices or
4037
// larger system font sizes don't overflow adjacent content.
4138
flexShrink: 1,
@@ -45,6 +42,19 @@ const styleSheet = () =>
4542
},
4643
});
4744

45+
// Compact header keeps the change inline with the price; the prominent header
46+
// stacks the change below the price (matches the asset overview screen).
47+
const inlineLayout: ViewStyle = {
48+
flexDirection: 'row',
49+
alignItems: 'baseline',
50+
gap: 6,
51+
};
52+
const stackedLayout: ViewStyle = {
53+
flexDirection: 'column',
54+
alignItems: 'flex-start',
55+
gap: 2,
56+
};
57+
4858
/**
4959
* Component that displays live price and change for header
5060
* Uses currentPrice prop from candle stream, subscribes to price stream for 24h change only
@@ -58,11 +68,12 @@ const LivePriceHeader: React.FC<LivePriceHeaderProps> = ({
5868
size = 'default',
5969
}) => {
6070
const isLarge = size === 'large';
61-
const priceVariant = isLarge ? TextVariant.HeadingLg : TextVariant.BodySm;
71+
const priceVariant = isLarge ? TextVariant.DisplayLg : TextVariant.BodySm;
6272
const priceColor = isLarge
6373
? TextColor.TextDefault
6474
: TextColor.TextAlternative;
65-
const changeVariant = isLarge ? TextVariant.BodyMd : TextVariant.BodySm;
75+
const changeVariant = TextVariant.BodySm;
76+
// Match the asset overview screen, which uses a medium-weight change line.
6677
const changeFontWeight = isLarge ? FontWeight.Medium : FontWeight.Regular;
6778
const { styles } = useStyles(styleSheet, {});
6879
// Subscribe to price stream only for 24h change percentage
@@ -107,6 +118,18 @@ const LivePriceHeader: React.FC<LivePriceHeaderProps> = ({
107118
}
108119
}, [currentPrice]);
109120

121+
// Absolute 24h change derived from the current price and the 24h percentage
122+
// (the price stream only exposes the percentage). prevPrice = current / (1 + pct/100).
123+
const absoluteChange = useMemo(() => {
124+
if (displayChange === null) return null;
125+
if (!currentPrice || currentPrice <= 0 || !Number.isFinite(currentPrice)) {
126+
return null;
127+
}
128+
const previousPrice = currentPrice / (1 + displayChange / 100);
129+
if (!Number.isFinite(previousPrice)) return null;
130+
return currentPrice - previousPrice;
131+
}, [currentPrice, displayChange]);
132+
110133
const formattedChange = useMemo(() => {
111134
// If displayChange is null, we're still loading - show loading indicator
112135
if (displayChange === null) {
@@ -118,14 +141,30 @@ const LivePriceHeader: React.FC<LivePriceHeaderProps> = ({
118141
}
119142

120143
try {
121-
return formatPercentage(displayChange.toString());
144+
const percentage = formatPercentage(displayChange.toString());
145+
146+
// Compact header stays percentage-only; the prominent header mirrors the
147+
// asset overview screen by showing the absolute change and percentage,
148+
// e.g. "+$3.57 (+0.21%)".
149+
if (!isLarge || absoluteChange === null) {
150+
return percentage;
151+
}
152+
153+
const sign = absoluteChange > 0 ? '+' : absoluteChange < 0 ? '-' : '';
154+
const formattedAbsoluteChange = formatPerpsFiat(
155+
Math.abs(absoluteChange),
156+
{
157+
ranges: PRICE_RANGES_UNIVERSAL,
158+
},
159+
);
160+
return `${sign}${formattedAbsoluteChange} (${percentage})`;
122161
} catch {
123162
return PERPS_CONSTANTS.FallbackPercentageDisplay;
124163
}
125-
}, [currentPrice, displayChange]);
164+
}, [currentPrice, displayChange, isLarge, absoluteChange]);
126165

127166
return (
128-
<View style={styles.container}>
167+
<View style={[styles.container, isLarge ? stackedLayout : inlineLayout]}>
129168
<Text
130169
variant={priceVariant}
131170
color={priceColor}

0 commit comments

Comments
 (0)