diff --git a/.nx/version-plans/version-plan-1777911187465-react.md b/.nx/version-plans/version-plan-1777911187465-react.md new file mode 100644 index 000000000..a1ff5594d --- /dev/null +++ b/.nx/version-plans/version-plan-1777911187465-react.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-react': patch +--- + +feat(ui-react): add Trend component diff --git a/.nx/version-plans/version-plan-1777911187465-rnative.md b/.nx/version-plans/version-plan-1777911187465-rnative.md new file mode 100644 index 000000000..5904533f6 --- /dev/null +++ b/.nx/version-plans/version-plan-1777911187465-rnative.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(ui-rnative): add Trend component diff --git a/apps/app-sandbox-rnative/src/app/App.tsx b/apps/app-sandbox-rnative/src/app/App.tsx index 98b4f7cbd..55c3cd335 100644 --- a/apps/app-sandbox-rnative/src/app/App.tsx +++ b/apps/app-sandbox-rnative/src/app/App.tsx @@ -28,21 +28,23 @@ import { Checkboxes, ContentBanners, Dividers, + DotCounts, DotIcons, + DotIndicators, DotSymbols, - MediaImages, ExampleTabBar, Gradients, IconButtons, InteractiveIcons, LineCharts, - OptionLists, Links, ListItems, MediaBanners, MediaButtons, MediaCards, + MediaImages, NavBars, + OptionLists, PageIndicators, SegmentedControls, Selects, @@ -57,8 +59,7 @@ import { ToggleLocaleSwitch, ToggleThemeSwitch, Tooltips, - DotCounts, - DotIndicators, + Trends, } from './blocks'; import { SandboxBlock } from './SandboxBlock'; import { SandboxProvider } from './SandboxContext'; @@ -268,6 +269,9 @@ const AppContent = ({ + + + diff --git a/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx b/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx new file mode 100644 index 000000000..ef3d6eace --- /dev/null +++ b/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx @@ -0,0 +1,24 @@ +import { Box, Trend } from '@ledgerhq/lumen-ui-rnative'; + +export function Trends() { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app-sandbox-rnative/src/app/blocks/index.ts b/apps/app-sandbox-rnative/src/app/blocks/index.ts index ad2c53926..e67a5bcd2 100644 --- a/apps/app-sandbox-rnative/src/app/blocks/index.ts +++ b/apps/app-sandbox-rnative/src/app/blocks/index.ts @@ -2,21 +2,31 @@ export * from './AmountDisplays'; export * from './AmountInputs'; export * from './Animations'; export * from './Avatars'; -export * from './DotCounts'; -export * from './DotIndicators'; +export * from './Banners'; export * from './BottomSheets'; export * from './Buttons'; -export * from './Checkboxes'; +export * from './CardButtons'; export * from './Cards'; +export * from './Checkboxes'; +export * from './ContentBanners'; export * from './Dividers'; +export * from './DotCounts'; export * from './DotIcons'; +export * from './DotIndicators'; export * from './DotSymbols'; -export * from './MediaButtons'; -export * from './MediaImages'; +export * from './Gradients'; export * from './IconButtons'; +export * from './InteractiveIcons'; +export * from './LineCharts'; export * from './Links'; +export * from './ListItems'; export * from './MediaBanners'; +export * from './MediaButtons'; +export * from './MediaCards'; +export * from './MediaImages'; export * from './NavBars'; +export * from './OptionLists'; +export * from './PageIndicators'; export * from './SegmentedControls'; export * from './Selects'; export * from './Skeletons'; @@ -28,16 +38,7 @@ export * from './TabBars'; export * from './Tags'; export * from './TextInputs'; export * from './Tiles'; -export * from './ListItems'; -export * from './PageIndicators'; export * from './ToggleLocaleSwitch'; export * from './ToggleThemeSwitch'; -export * from './InteractiveIcons'; -export * from './Banners'; -export * from './CardButtons'; -export * from './ContentBanners'; -export * from './MediaCards'; export * from './Tooltips'; -export * from './Gradients'; -export * from './LineCharts'; -export * from './OptionLists'; +export * from './Trends'; diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx new file mode 100644 index 000000000..29b26cd94 --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx @@ -0,0 +1,28 @@ +import figma from '@figma/code-connect'; +import { Trend } from './Trend'; + +figma.connect( + Trend, + 'https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7?node-id=892%3A5393', + { + imports: ["import { Trend } from '@ledgerhq/lumen-ui-react'"], + props: { + type: figma.enum('type', { + increasing: 5.25, + decreasing: -3.14, + neutral: 0, + }), + size: figma.enum('size', { + md: 'md', + sm: 'sm', + }), + disabled: figma.enum('state', { + enabled: false, + disabled: true, + }), + }, + example: (props) => ( + + ), + }, +); diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.mdx b/libs/ui-react/src/lib/Components/Trend/Trend.mdx new file mode 100644 index 000000000..8cdf790ee --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/Trend.mdx @@ -0,0 +1,106 @@ +import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'; +import * as TrendStories from './Trend.stories'; +import { CustomTabs, Tab } from '../../../../.storybook/components'; +import { DoVsDontRow, DoBlockItem, DontBlockItem } from '../../../../.storybook/components/DoVsDont'; +import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx'; + + + +# Trend + + + + +## Introduction + +A compact indicator used to communicate directional change in a numeric value, typically a percentage. It combines an icon and a formatted label to convey whether a value has gone up, down, or stayed flat. + +> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7/2.-Components-Library?node-id=892-5393). + +## Anatomy + +- **Icon:** A directional triangle (up or down) or a minus symbol that visually reinforces the direction of change at a glance. +- **Value:** The numeric percentage formatted to exactly two decimal places, ensuring consistent alignment across lists and tables. + +## Properties + +### Overview + + + + +### Variants + +The variant is derived automatically from the `value` prop. No manual selection needed! This ensures the color and icon always stay in sync with the actual data. + +- **Positive** - value greater than 0: green color, triangle up icon +- **Negative** - value less than 0: red color, triangle down icon +- **Neutral** - value equal to 0: muted color, minus icon + + + +--- + +### Size + +Two sizes are available to accommodate different layout densities. Use `md` in most contexts and `sm` when space is constrained, such as inside a table cell or a compact list item. + +- **md** (default) - standard body text and icon sizes +- **sm** - smaller text and icon, suitable for dense UIs + + + +--- + +### Disabled + +When `disabled` is `true`, the icon and text both render in a muted, non-interactive style regardless of the underlying value. Use this when the data is unavailable, loading, or the surrounding context is non-interactive. + + + + + + +## Setup + +Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs). + +### Basic Usage + +Pass any numeric value - the component handles color, icon, and formatting automatically. + +```tsx +import { Trend } from '@ledgerhq/lumen-ui-react'; + +function MyComponent() { + return ; +} +``` + +### Sizes + +Choose `sm` for dense layouts like tables or compact list rows, and `md` for standard content areas. + +```tsx + + +``` + +### Disabled State + +Wrap or pass `disabled` when the value is unavailable or the parent context is non-interactive. The component style updates automatically - no extra styling needed. + +```tsx + +``` + +### Layout Adjustments + +Use `className` for layout only - not for overriding colors or typography. The component's visual appearance is controlled exclusively via props. + +```tsx + +``` + + + diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx new file mode 100644 index 000000000..62187c572 --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Trend } from './Trend'; + +const meta: Meta = { + component: Trend, + title: 'Communication/Trend', + argTypes: { + value: { + control: 'number', + }, + size: { + control: 'radio', + options: ['sm', 'md'], + }, + disabled: { + control: 'boolean', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Base: Story = { + args: { + value: 5.25, + size: 'md', + disabled: false, + }, +}; + +export const VariantShowcase: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const SizeShowcase: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const DisabledShowcase: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx new file mode 100644 index 000000000..33a00db2a --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, it, expect } from 'vitest'; +import '@testing-library/jest-dom'; +import { Trend } from './Trend'; + +describe('Trend Component', () => { + it('should render positive value', () => { + render(); + expect(screen.getByText('5.50%')).toBeInTheDocument(); + }); + + it('should render negative value', () => { + render(); + expect(screen.getByText('-3.20%')).toBeInTheDocument(); + }); + + it('should render neutral when value is zero', () => { + render(); + expect(screen.getByText('0.00%')).toBeInTheDocument(); + }); + + it('should render with sm size', () => { + render(); + expect(screen.getByText('1.50%')).toBeInTheDocument(); + }); + + it('should render with md size', () => { + render(); + expect(screen.getByText('1.50%')).toBeInTheDocument(); + }); + + it('should render in disabled state', () => { + render(); + expect(screen.getByText('5.00%')).toBeInTheDocument(); + }); + + it('should pass data-testid', () => { + render(); + expect(screen.getByTestId('trend-id')).toBeInTheDocument(); + }); + + it('should format value to 2 decimal places', () => { + render(); + expect(screen.getByText('1.12%')).toBeInTheDocument(); + }); + + it('should render all variants side by side', () => { + render( + <> + + + + , + ); + expect(screen.getByText('10.00%')).toBeInTheDocument(); + expect(screen.getByText('-10.00%')).toBeInTheDocument(); + expect(screen.getByText('0.00%')).toBeInTheDocument(); + }); + + it('should apply positive color class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('text-success'); + }); + + it('should apply negative color class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('text-error'); + }); + + it('should apply neutral color class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('text-muted'); + }); + + it('should apply disabled color class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('text-disabled'); + }); + + it('should apply md typography class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('body-2'); + }); + + it('should apply sm typography class', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('body-3'); + }); + + it('should merge custom className', () => { + render(); + expect(screen.getByTestId('trend')).toHaveClass('mt-4'); + }); + + it('should set aria-label for positive variant', () => { + render(); + expect(screen.getByTestId('trend')).toHaveAttribute( + 'aria-label', + 'components.trend.positiveAriaLabel', + ); + }); + + it('should set aria-label for negative variant', () => { + render(); + expect(screen.getByTestId('trend')).toHaveAttribute( + 'aria-label', + 'components.trend.negativeAriaLabel', + ); + }); + + it('should set aria-label for neutral variant', () => { + render(); + expect(screen.getByTestId('trend')).toHaveAttribute( + 'aria-label', + 'components.trend.neutralAriaLabel', + ); + }); + + it('should forward ref', () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLSpanElement); + }); +}); diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.tsx new file mode 100644 index 000000000..f8b045623 --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/Trend.tsx @@ -0,0 +1,68 @@ +import { cn, useDisabledContext } from '@ledgerhq/lumen-utils-shared'; +import { useCommonTranslation } from '../../../i18n'; +import { Minus, TriangleDown, TriangleUp } from '../../Symbols'; +import type { TrendProps } from './types'; + +type TrendVariant = 'positive' | 'negative' | 'neutral'; + +function getVariant(value: number): TrendVariant { + if (value === 0) return 'neutral'; + return value > 0 ? 'positive' : 'negative'; +} + +const variantColor: Record = { + positive: 'text-success', + negative: 'text-error', + neutral: 'text-muted', +}; + +const sizeClass: Record, string> = { + md: 'body-2 gap-2', + sm: 'body-3 gap-2', +}; + +const iconSize: Record, 12 | 16> = { + md: 16, + sm: 12, +}; + +export function Trend({ + value, + size = 'md', + disabled: disabledProp = false, + className, + ...props +}: TrendProps) { + const variant = getVariant(value); + const disabled = useDisabledContext({ + consumerName: 'Trend', + mergeWith: { disabled: disabledProp }, + }); + const { t } = useCommonTranslation(); + + const Icon = { positive: TriangleUp, negative: TriangleDown, neutral: Minus }[ + variant + ]; + + const absoluteFormattedValue = `${Math.abs(value).toFixed(2)}%`; + const formattedValue = + value < 0 ? `-${absoluteFormattedValue}` : absoluteFormattedValue; + + return ( + + + {formattedValue} + + ); +} diff --git a/libs/ui-react/src/lib/Components/Trend/index.ts b/libs/ui-react/src/lib/Components/Trend/index.ts new file mode 100644 index 000000000..723f0d45b --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/index.ts @@ -0,0 +1,2 @@ +export * from './Trend'; +export * from './types'; diff --git a/libs/ui-react/src/lib/Components/Trend/types.ts b/libs/ui-react/src/lib/Components/Trend/types.ts new file mode 100644 index 000000000..9bd8a6638 --- /dev/null +++ b/libs/ui-react/src/lib/Components/Trend/types.ts @@ -0,0 +1,20 @@ +import type { ComponentPropsWithRef } from 'react'; + +export type TrendProps = { + /** + * The value to display in the trend. This value affects the appearance of the component in terms of color and icon. + * @required + */ + value: number; + /** + * The size of the trend component. + * @default md + */ + size?: 'sm' | 'md'; + /** + * When `true`, shows a muted appearance on the trend, regardless of value. + * + * @default false + */ + disabled?: boolean; +} & Omit, 'children'>; diff --git a/libs/ui-react/src/lib/Components/index.ts b/libs/ui-react/src/lib/Components/index.ts index 4e38885b4..ef71e39fa 100644 --- a/libs/ui-react/src/lib/Components/index.ts +++ b/libs/ui-react/src/lib/Components/index.ts @@ -44,4 +44,5 @@ export * from './TextInput'; export * from './Tile'; export * from './TileButton'; export * from './Tooltip'; +export * from './Trend'; export * from './ThemeProvider'; diff --git a/libs/ui-rnative/src/i18n/i18n.ts b/libs/ui-rnative/src/i18n/i18n.ts index 80182a5dc..2bcf483b0 100644 --- a/libs/ui-rnative/src/i18n/i18n.ts +++ b/libs/ui-rnative/src/i18n/i18n.ts @@ -34,10 +34,21 @@ const loadedLocales = new Set(); const initializeI18n = (): I18nInstance => { const instance = i18next.createInstance(); + const resources = Object.fromEntries( + Object.entries(localeResources).map(([locale, translations]) => [ + locale, + { [I18N_DEFAULT_NAMESPACE]: translations }, + ]), + ); + + Object.keys(localeResources).forEach((locale) => + loadedLocales.add(locale as SupportedLocale), + ); + instance .use(initReactI18next) .init({ - resources: {}, + resources, lng: DEFAULT_LANGUAGE, defaultNS: I18N_DEFAULT_NAMESPACE, fallbackLng: DEFAULT_LANGUAGE, diff --git a/libs/ui-rnative/src/i18n/locales/en.json b/libs/ui-rnative/src/i18n/locales/en.json index f488514ee..6c0f5437a 100644 --- a/libs/ui-rnative/src/i18n/locales/en.json +++ b/libs/ui-rnative/src/i18n/locales/en.json @@ -31,6 +31,11 @@ }, "stepper": { "progressAriaLabel": "Step {{currentStep}} of {{totalSteps}}" + }, + "trend": { + "positiveAriaLabel": "Trending up by {{value}}", + "negativeAriaLabel": "Trending down by {{value}}", + "neutralAriaLabel": "No change, {{value}}" } } } diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx b/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx new file mode 100644 index 000000000..614405d63 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx @@ -0,0 +1,106 @@ +import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'; +import * as TrendStories from './Trend.stories'; +import { CustomTabs, Tab } from '../../../../.storybook/components'; +import { DoVsDontRow, DoBlockItem, DontBlockItem } from '../../../../.storybook/components/DoVsDont'; +import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx'; + + + +# Trend + + + + +## Introduction + +A compact indicator used to communicate directional change in a numeric value, typically a percentage. It combines an icon and a formatted label to convey whether a value has gone up, down, or stayed flat. + +> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7/2.-Components-Library?node-id=892-5393). + +## Anatomy + +- **Icon:** A directional triangle (up or down) or a minus symbol that visually reinforces the direction of change at a glance. +- **Value:** The numeric percentage formatted to exactly two decimal places, ensuring consistent alignment across lists and tables. + +## Properties + +### Overview + + + + +### Variants + +The variant is derived automatically from the `value` prop. No manual selection needed! This ensures the color and icon always stay in sync with the actual data. + +- **Positive** - value greater than 0: green color, triangle up icon +- **Negative** - value less than 0: red color, triangle down icon +- **Neutral** - value equal to 0: muted color, minus icon + + + +--- + +### Size + +Two sizes are available to accommodate different layout densities. Use `md` in most contexts and `sm` when space is constrained, such as inside a table cell or a compact list item. + +- **md** (default) - standard body text and icon sizes +- **sm** - smaller text and icon, suitable for dense UIs + + + +--- + +### Disabled + +When `disabled` is `true`, the icon and text both render in a muted, non-interactive style regardless of the underlying value. Use this when the data is unavailable, loading, or the surrounding context is non-interactive. + + + + + + +## Setup + +Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs). + +### Basic Usage + +Pass any numeric value - the component handles color, icon, and formatting automatically. + +```tsx +import { Trend } from '@ledgerhq/lumen-ui-rnative'; + +function MyComponent() { + return ; +} +``` + +### Sizes + +Choose `sm` for dense layouts like tables or compact list rows, and `md` for standard content areas. + +```tsx + + +``` + +### Disabled State + +Wrap or pass `disabled` when the value is unavailable or the parent context is non-interactive. The component style updates automatically - no extra styling needed. + +```tsx + +``` + +### Layout Adjustments + +Use `lx` for layout only - not for overriding colors or typography. The component's visual appearance is controlled exclusively via props. + +```tsx + +``` + + + diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx new file mode 100644 index 000000000..d0b5915a3 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-native-web-vite'; +import { Box } from '../Utility/Box'; +import { Trend } from './Trend'; + +const meta: Meta = { + component: Trend, + title: 'Communication/Trend', + argTypes: { + value: { + control: 'number', + }, + size: { + control: 'radio', + options: ['sm', 'md'], + }, + disabled: { + control: 'boolean', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Base: Story = { + args: { + value: 5.25, + size: 'md', + disabled: false, + }, +}; + +export const VariantShowcase: Story = { + render: () => ( + + + + + + ), +}; + +export const SizeShowcase: Story = { + render: () => ( + + + + + ), +}; + +export const DisabledShowcase: Story = { + render: () => ( + + + + + + ), +}; diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx new file mode 100644 index 000000000..3af75205d --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect } from '@jest/globals'; +import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; +import { render } from '@testing-library/react-native'; +import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; +import { Trend } from './Trend'; + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('Trend Component', () => { + it('should render positive value', () => { + const { getByText } = render( + + + , + ); + expect(getByText('5.50%')).toBeTruthy(); + }); + + it('should render negative value', () => { + const { getByText } = render( + + + , + ); + expect(getByText('-3.20%')).toBeTruthy(); + }); + + it('should render neutral when value is zero', () => { + const { getByText } = render( + + + , + ); + expect(getByText('0.00%')).toBeTruthy(); + }); + + it('should render with sm size', () => { + const { getByText } = render( + + + , + ); + expect(getByText('1.50%')).toBeTruthy(); + }); + + it('should render with md size', () => { + const { getByText } = render( + + + , + ); + expect(getByText('1.50%')).toBeTruthy(); + }); + + it('should render in disabled state', () => { + const { getByText } = render( + + + , + ); + expect(getByText('5.00%')).toBeTruthy(); + }); + + it('should pass testID to root element', () => { + const { getByTestId } = render( + + + , + ); + expect(getByTestId('trend-id')).toBeTruthy(); + }); + + it('should format value to 2 decimal places', () => { + const { getByText } = render( + + + , + ); + expect(getByText('1.12%')).toBeTruthy(); + }); + + it('should render all variants side by side', () => { + const { getByText } = render( + + + + + , + ); + expect(getByText('10.00%')).toBeTruthy(); + expect(getByText('-10.00%')).toBeTruthy(); + expect(getByText('0.00%')).toBeTruthy(); + }); + + it('should expose positive accessibility label', () => { + const { getByLabelText } = render( + + + , + ); + expect(getByLabelText('Trending up by 5.50%')).toBeTruthy(); + }); + + it('should expose negative accessibility label with absolute value', () => { + const { getByLabelText } = render( + + + , + ); + expect(getByLabelText('Trending down by 3.20%')).toBeTruthy(); + }); + + it('should expose neutral accessibility label', () => { + const { getByLabelText } = render( + + + , + ); + expect(getByLabelText('No change, 0.00%')).toBeTruthy(); + }); +}); diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx new file mode 100644 index 000000000..0a13a4450 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx @@ -0,0 +1,118 @@ +import { useDisabledContext } from '@ledgerhq/lumen-utils-shared'; +import { StyleSheet } from 'react-native'; +import { useCommonTranslation } from '../../../i18n'; +import type { LumenTextStyle } from '../../../styles'; +import { useStyleSheet } from '../../../styles'; +import { Minus, TriangleDown, TriangleUp } from '../../Symbols'; +import type { IconSize } from '../Icon'; +import { Box, Text } from '../Utility'; +import type { TrendProps } from './types'; + +type TrendVariant = 'positive' | 'negative' | 'neutral'; + +function getVariant(value: number): TrendVariant { + if (value === 0) { + return 'neutral'; + } + return value > 0 ? 'positive' : 'negative'; +} + +export function Trend({ + value, + size = 'md', + lx = {}, + disabled: disabledProp = false, + style, + ...props +}: TrendProps) { + const variant = getVariant(value); + + const disabled = useDisabledContext({ + consumerName: 'Trend', + mergeWith: { disabled: disabledProp }, + }); + const { t } = useCommonTranslation(); + + const styles = useStyles({ size, variant, disabled }); + + const Icon = { + positive: TriangleUp, + negative: TriangleDown, + neutral: Minus, + }[variant]; + + const iconSize = ( + { + md: 16, + sm: 12, + } as const + )[size] as IconSize; + + const iconColor = ( + { + positive: 'success', + negative: 'error', + neutral: 'muted', + } as const + )[variant] as LumenTextStyle['color']; + + const absoluteFormattedValue = `${Math.abs(value).toFixed(2)}%`; + const formattedValue = + value < 0 ? `-${absoluteFormattedValue}` : absoluteFormattedValue; + + return ( + + + {formattedValue} + + ); +} + +const useStyles = ({ + size, + variant, + disabled, +}: { + size: NonNullable; + variant: TrendVariant; + disabled: boolean; +}) => + useStyleSheet( + (t) => { + const color = { + positive: t.colors.text.success, + negative: t.colors.text.error, + neutral: t.colors.text.muted, + }[variant]; + + const sizeMap = { + sm: t.typographies.body3, + md: t.typographies.body2, + }[size]; + + return { + container: { + flexDirection: 'row', + alignItems: 'center', + gap: t.spacings.s2, + }, + text: StyleSheet.flatten([ + { + ...sizeMap, + color, + }, + disabled && { color: t.colors.text.disabled }, + ]), + }; + }, + [size, variant, disabled], + ); diff --git a/libs/ui-rnative/src/lib/Components/Trend/index.ts b/libs/ui-rnative/src/lib/Components/Trend/index.ts new file mode 100644 index 000000000..723f0d45b --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/index.ts @@ -0,0 +1,2 @@ +export * from './Trend'; +export * from './types'; diff --git a/libs/ui-rnative/src/lib/Components/Trend/types.ts b/libs/ui-rnative/src/lib/Components/Trend/types.ts new file mode 100644 index 000000000..7a976de49 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/Trend/types.ts @@ -0,0 +1,20 @@ +import type { BoxProps } from '../Utility'; + +export type TrendProps = { + /** + * The value to display in the trend. This value affects the appearance of the component in terms of color and icon. + * @required + */ + value: number; + /** + * The size of the trend component. + * @default md + */ + size?: 'sm' | 'md'; + /** + * When `true`, shows a muted appearance on the trend, regardless of value. + * + * @default false + */ + disabled?: boolean; +} & Omit; diff --git a/libs/ui-rnative/src/lib/Components/index.ts b/libs/ui-rnative/src/lib/Components/index.ts index 1d3d41596..02f5274d6 100644 --- a/libs/ui-rnative/src/lib/Components/index.ts +++ b/libs/ui-rnative/src/lib/Components/index.ts @@ -4,15 +4,15 @@ export * from './AmountInput'; export * from './Avatar'; export * from './Banner'; export * from './BottomSheet'; -export * from './DotCount'; -export * from './DotIndicator'; export * from './Button'; export * from './Card'; export * from './CardButton'; -export * from './ContentBanner'; export * from './Checkbox'; +export * from './ContentBanner'; export * from './Divider'; +export * from './DotCount'; export * from './DotIcon'; +export * from './DotIndicator'; export * from './DotSymbol'; export * from './Icon'; export * from './IconButton'; @@ -37,9 +37,10 @@ export * from './Subheader'; export * from './Switch'; export * from './TabBar'; export * from './Tag'; -export * from './Utility'; export * from './TextInput'; export * from './ThemeProvider'; export * from './Tile'; export * from './TileButton'; export * from './Tooltip'; +export * from './Trend'; +export * from './Utility';