diff --git a/.nx/version-plans/version-plan-1773849698407.md b/.nx/version-plans/version-plan-1773849698407.md new file mode 100644 index 000000000..d3652d2ee --- /dev/null +++ b/.nx/version-plans/version-plan-1773849698407.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +feat(MediaCard): introduce MediaCard component with docs, tests, and figma code connect. diff --git a/.nx/version-plans/version-plan-1774000912718.md b/.nx/version-plans/version-plan-1774000912718.md new file mode 100644 index 000000000..023408aea --- /dev/null +++ b/.nx/version-plans/version-plan-1774000912718.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-rnative': patch +--- + +- fix(ui-rnative): increase the width spacing of AmountDisplay char to prevent overflow diff --git a/.nx/version-plans/version-plan-1774000912719.md b/.nx/version-plans/version-plan-1774000912719.md new file mode 100644 index 000000000..44f58ecc1 --- /dev/null +++ b/.nx/version-plans/version-plan-1774000912719.md @@ -0,0 +1,6 @@ +--- +'@ledgerhq/lumen-ui-react': patch +--- + +- fix(ui-react): prevent baseline issues on AmountDisplay component +- fix(ui-react): increase the width spacing of AmountDisplay char to prevent overflow diff --git a/apps/app-sandbox-rnative/src/app/App.tsx b/apps/app-sandbox-rnative/src/app/App.tsx index 5b2eefc83..57194feb6 100644 --- a/apps/app-sandbox-rnative/src/app/App.tsx +++ b/apps/app-sandbox-rnative/src/app/App.tsx @@ -46,6 +46,7 @@ import { Banners, CardButtons, ContentBanners, + MediaCards, Tooltips, ListItems, Gradients, @@ -181,6 +182,9 @@ const AppContent = ({ + + + diff --git a/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx new file mode 100644 index 000000000..efc555e0a --- /dev/null +++ b/apps/app-sandbox-rnative/src/app/blocks/MediaCards.tsx @@ -0,0 +1,35 @@ +import { + Box, + MediaCard, + MediaCardTitle, + Tag, +} from '@ledgerhq/lumen-ui-rnative'; + +const EXAMPLE_SRC = + 'https://ledger-wp-website-s3-prd.ledger.com/uploads/2026/03/hero_visual-1.webp'; + +export const MediaCards = () => { + return ( + + ({})} + onClose={() => ({})} + > + + + Black Friday sale. 3 days with no fees on your transactions. + + + + ({})} + onClose={() => ({})} + > + Secure your crypto assets + Get started with Ledger and protect your digital assets today. + + + ); +}; diff --git a/apps/app-sandbox-rnative/src/app/blocks/index.ts b/apps/app-sandbox-rnative/src/app/blocks/index.ts index f26803349..0a99907c6 100644 --- a/apps/app-sandbox-rnative/src/app/blocks/index.ts +++ b/apps/app-sandbox-rnative/src/app/blocks/index.ts @@ -29,5 +29,6 @@ export * from './InteractiveIcons'; export * from './Banners'; export * from './CardButtons'; export * from './ContentBanners'; +export * from './MediaCards'; export * from './Tooltips'; export * from './Gradients'; diff --git a/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx b/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx index a90255ec2..472a2d3bb 100644 --- a/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx +++ b/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx @@ -190,10 +190,14 @@ describe('AmountDisplay', () => { ); const root = container.firstChild as HTMLElement; - const children = Array.from(root.children); - expect(children).toHaveLength(2); - expect(children[0]).toHaveAttribute('aria-hidden', 'true'); - expect(children[1]).toHaveAttribute('aria-hidden', 'true'); + // Traverse all descendants (not just immediate children) + const allDescendants = Array.from( + root.querySelectorAll('[aria-hidden="true"]'), + ); + expect(allDescendants.length).toBe(2); + allDescendants.forEach((node) => { + expect(node).toHaveAttribute('aria-hidden', 'true'); + }); }); it('renders group separators', () => { diff --git a/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.tsx b/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.tsx index da300583d..1ddb76c48 100644 --- a/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.tsx +++ b/libs/ui-react/src/lib/Components/AmountDisplay/AmountDisplay.tsx @@ -9,29 +9,29 @@ import { } from './types'; const INTEGER_DIGIT_WIDTHS = { - 0: 24.5, - 1: 15, - 2: 23, - 3: 24, - 4: 25, - 5: 23, - 6: 24.5, - 7: 21.5, - 8: 24, - 9: 24, + 0: 25, + 1: 15.5, + 2: 23.5, + 3: 24.5, + 4: 25.5, + 5: 23.5, + 6: 25, + 7: 22, + 8: 24.5, + 9: 24.5, }; const DECIMAL_DIGIT_WIDTHS = { - 0: 17, - 1: 10.5, - 2: 16, - 3: 16.5, - 4: 17.2, - 5: 15.7, - 6: 17, - 7: 14.7, - 8: 16.5, - 9: 16.5, + 0: 17.5, + 1: 11, + 2: 16.5, + 3: 17, + 4: 18, + 5: 16, + 6: 17.5, + 7: 15, + 8: 17, + 9: 17, }; const DigitStrip = memo(({ value, animate, type }: DigitStripProps) => { @@ -136,46 +136,50 @@ export const AmountDisplay = ({
- - +
+ + +
); }; diff --git a/libs/ui-rnative/src/i18n/locales/de.json b/libs/ui-rnative/src/i18n/locales/de.json index 7d5e822fb..9ddf818bd 100644 --- a/libs/ui-rnative/src/i18n/locales/de.json +++ b/libs/ui-rnative/src/i18n/locales/de.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Schließen" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QR-Code scannen" diff --git a/libs/ui-rnative/src/i18n/locales/en.json b/libs/ui-rnative/src/i18n/locales/en.json index 4fba1aa86..f488514ee 100644 --- a/libs/ui-rnative/src/i18n/locales/en.json +++ b/libs/ui-rnative/src/i18n/locales/en.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Close" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Scan QR code" diff --git a/libs/ui-rnative/src/i18n/locales/es.json b/libs/ui-rnative/src/i18n/locales/es.json index bfa44557d..cfa38549c 100644 --- a/libs/ui-rnative/src/i18n/locales/es.json +++ b/libs/ui-rnative/src/i18n/locales/es.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Cerrar" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Escanear código QR" diff --git a/libs/ui-rnative/src/i18n/locales/fr.json b/libs/ui-rnative/src/i18n/locales/fr.json index e01c21f7b..35b22fecd 100644 --- a/libs/ui-rnative/src/i18n/locales/fr.json +++ b/libs/ui-rnative/src/i18n/locales/fr.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Fermer" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Scanner le code QR" diff --git a/libs/ui-rnative/src/i18n/locales/ja.json b/libs/ui-rnative/src/i18n/locales/ja.json index 05e9d499d..457413add 100644 --- a/libs/ui-rnative/src/i18n/locales/ja.json +++ b/libs/ui-rnative/src/i18n/locales/ja.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "閉じる" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QRコードをスキャン" diff --git a/libs/ui-rnative/src/i18n/locales/ko.json b/libs/ui-rnative/src/i18n/locales/ko.json index eda729c7d..8e7235ec8 100644 --- a/libs/ui-rnative/src/i18n/locales/ko.json +++ b/libs/ui-rnative/src/i18n/locales/ko.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "닫기" + }, "components": { "addressInput": { "qrCodeAriaLabel": "QR 코드 스캔" diff --git a/libs/ui-rnative/src/i18n/locales/pt.json b/libs/ui-rnative/src/i18n/locales/pt.json index d217d715d..8e7acb9f3 100644 --- a/libs/ui-rnative/src/i18n/locales/pt.json +++ b/libs/ui-rnative/src/i18n/locales/pt.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Fechar" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Escanear QR Code" diff --git a/libs/ui-rnative/src/i18n/locales/ru.json b/libs/ui-rnative/src/i18n/locales/ru.json index 3cc7a7fe3..f48742aae 100644 --- a/libs/ui-rnative/src/i18n/locales/ru.json +++ b/libs/ui-rnative/src/i18n/locales/ru.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Закрыть" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Сканировать QR-код" diff --git a/libs/ui-rnative/src/i18n/locales/th.json b/libs/ui-rnative/src/i18n/locales/th.json index bd77a6503..142b91853 100644 --- a/libs/ui-rnative/src/i18n/locales/th.json +++ b/libs/ui-rnative/src/i18n/locales/th.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "ปิด" + }, "components": { "addressInput": { "qrCodeAriaLabel": "สแกน QR Code" diff --git a/libs/ui-rnative/src/i18n/locales/tr.json b/libs/ui-rnative/src/i18n/locales/tr.json index 0130f76ca..be0d0bfa2 100644 --- a/libs/ui-rnative/src/i18n/locales/tr.json +++ b/libs/ui-rnative/src/i18n/locales/tr.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "Kapat" + }, "components": { "addressInput": { "qrCodeAriaLabel": "Karekodu okut" diff --git a/libs/ui-rnative/src/i18n/locales/zh.json b/libs/ui-rnative/src/i18n/locales/zh.json index 74415432b..4509c3fba 100644 --- a/libs/ui-rnative/src/i18n/locales/zh.json +++ b/libs/ui-rnative/src/i18n/locales/zh.json @@ -1,4 +1,7 @@ { + "common": { + "closeAriaLabel": "关闭" + }, "components": { "addressInput": { "qrCodeAriaLabel": "扫描二维码" diff --git a/libs/ui-rnative/src/lib/Components/AmountDisplay/AmountDisplay.tsx b/libs/ui-rnative/src/lib/Components/AmountDisplay/AmountDisplay.tsx index 1c586c629..8ad673f0a 100644 --- a/libs/ui-rnative/src/lib/Components/AmountDisplay/AmountDisplay.tsx +++ b/libs/ui-rnative/src/lib/Components/AmountDisplay/AmountDisplay.tsx @@ -21,29 +21,29 @@ import { } from './types'; const INTEGER_DIGIT_WIDTHS = { - 0: 24.5, - 1: 15, - 2: 23, - 3: 24, - 4: 25, - 5: 23, - 6: 24.5, - 7: 21.5, - 8: 24, - 9: 24, + 0: 25, + 1: 15.5, + 2: 23.5, + 3: 24.5, + 4: 25.5, + 5: 23.5, + 6: 25, + 7: 22, + 8: 24.5, + 9: 24.5, }; const DECIMAL_DIGIT_WIDTHS = { - 0: 17, - 1: 10.5, - 2: 16, - 3: 16.5, - 4: 17.2, - 5: 15.7, - 6: 17, - 7: 14.7, - 8: 16.5, - 9: 16.5, + 0: 17.5, + 1: 11, + 2: 16.5, + 3: 17, + 4: 18, + 5: 16, + 6: 17.5, + 7: 15, + 8: 17, + 9: 17, }; const useStyles = () => { diff --git a/libs/ui-rnative/src/lib/Components/InteractiveIcon/InteractiveIcon.tsx b/libs/ui-rnative/src/lib/Components/InteractiveIcon/InteractiveIcon.tsx index 60b397359..b6ee8241d 100644 --- a/libs/ui-rnative/src/lib/Components/InteractiveIcon/InteractiveIcon.tsx +++ b/libs/ui-rnative/src/lib/Components/InteractiveIcon/InteractiveIcon.tsx @@ -9,13 +9,16 @@ import { Pressable } from '../Utility'; import { HIT_SLOP_MAP, InteractiveIconProps } from './types'; type IconType = InteractiveIconProps['iconType']; +type Appearance = NonNullable; const useStyles = ({ iconType, + appearance, pressed, disabled, }: { iconType: IconType; + appearance: Appearance; pressed: boolean; disabled: boolean; }) => { @@ -25,6 +28,21 @@ const useStyles = ({ filled: { backgroundColor: t.colors.bg.base }, stroked: { backgroundColor: t.colors.bg.baseTransparent }, }; + const appearanceColors = { + base: { + default: t.colors.text.base, + pressed: t.colors.text.basePressed, + }, + muted: { + default: t.colors.text.muted, + pressed: t.colors.text.mutedPressed, + }, + white: { + default: t.colors.text.white, + pressed: t.colors.text.whitePressed, + }, + }; + const colorSet = appearanceColors[appearance]; return { container: StyleSheet.flatten([ @@ -39,12 +57,12 @@ const useStyles = ({ color: disabled ? t.colors.text.disabled : pressed - ? t.colors.text.mutedPressed - : t.colors.text.muted, + ? colorSet.pressed + : colorSet.default, }, }; }, - [iconType, pressed, disabled], + [iconType, appearance, pressed, disabled], ); }; @@ -83,6 +101,7 @@ export const InteractiveIcon = ({ disabled: disabledProp = false, hitSlop: hitSlopProp, hitSlopType = 'comfortable', + appearance = 'muted', style, lx, ...props @@ -113,6 +132,7 @@ export const InteractiveIcon = ({ {({ pressed }) => ( @@ -125,15 +145,17 @@ export const InteractiveIcon = ({ const InteractiveIconContent = ({ iconType, + appearance, pressed, disabled, children, }: PropsWithChildren<{ iconType: IconType; + appearance: Appearance; pressed: boolean; disabled: boolean; }>) => { - const styles = useStyles({ iconType, pressed, disabled }); + const styles = useStyles({ iconType, appearance, pressed, disabled }); return ( diff --git a/libs/ui-rnative/src/lib/Components/InteractiveIcon/types.ts b/libs/ui-rnative/src/lib/Components/InteractiveIcon/types.ts index 4a7374861..1b560c7b1 100644 --- a/libs/ui-rnative/src/lib/Components/InteractiveIcon/types.ts +++ b/libs/ui-rnative/src/lib/Components/InteractiveIcon/types.ts @@ -52,6 +52,14 @@ export type InteractiveIconProps = { * Choose 'filled' for icons with solid backgrounds or 'stroked' for outlined icons. */ iconType: 'filled' | 'stroked'; + /** + * The color appearance of the icon. + * - `base`: Default high-contrast color. + * - `muted`: Subdued color for secondary actions. + * - `white`: White color for use on dark backgrounds. + * @default 'muted' + */ + appearance?: 'base' | 'muted' | 'white'; /** * Preset for the touchable area. Ignored if `hitSlop` is passed explicitly. * Automatically applies insets based on the child's icon size. diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx new file mode 100644 index 000000000..159b6dd18 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.mdx @@ -0,0 +1,96 @@ +import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'; +import * as MediaCardStories from './MediaCard.stories'; +import { CustomTabs, Tab } from '../../../../.storybook/components'; +import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx'; + + + +# MediaCard + + + + +## Introduction + +MediaCard is a promotional card component that displays a full-bleed background image with gradient overlays for text readability. It uses a simplified compound pattern with `MediaCardTitle` for the text and free-form children for leading content (e.g. tags, icons). + +> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7?node-id=15160-2853). + +## Anatomy + + + +- **MediaCard**: Root pressable container with background image and gradient overlays +- **MediaCardTitle**: Styled title text (clamps at 3 lines) +- **Leading content**: Any element rendered before `MediaCardTitle` (tags, badges, icons) — no wrapper needed +- **Close button**: Rendered via the `onClose` prop (positioned absolute top-right) + +## Properties + + + + +### Layout + +The card fills its parent width by default. + + + +### Compositions + +Leading content is optional — just place any element before `MediaCardTitle` inside `MediaCard`. + + + +## Accessibility + +- The root element uses `accessibilityRole='button'` and responds to press events +- The close button includes an `accessibilityLabel` (auto-translated via i18n) +- The background image is not accessible (`accessible={false}`) since it is decorative +- Components forward refs and spread props for accessibility support + + + + +## Setup + +Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs). + +## Basic Usage + +```tsx +import { MediaCard, MediaCardTitle, Tag } from '@ledgerhq/lumen-ui-rnative'; + +function MyComponent() { + return ( + console.log('pressed')} + onClose={() => console.log('closed')} + > + + Card title + + ); +} +``` + +### Layout Adjustments with lx + +Use the `lx` prop for layout adjustments like margins or positioning: + +```tsx + console.log('pressed')} + onClose={() => console.log('closed')} + lx={{ marginTop: 's16', marginBottom: 's8' }} +> + With margin + +``` + + + + + diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx new file mode 100644 index 000000000..1f6a228c2 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.stories.tsx @@ -0,0 +1,190 @@ +import { CryptoIcon } from '@ledgerhq/crypto-icons'; +import type { Meta, StoryObj } from '@storybook/react-native-web-vite'; +import { useState } from 'react'; +import { Button } from '../Button'; +import { Tag } from '../Tag'; +import { Box, Text } from '../Utility'; +import { MediaCard, MediaCardTitle } from './MediaCard'; + +const meta = { + component: MediaCard, + subcomponents: { MediaCardTitle }, + title: 'Communication/MediaCard', + parameters: { + layout: 'centered', + backgrounds: { default: 'light' }, + docs: { + source: { + language: 'tsx', + format: true, + type: 'code', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const EXAMPLE_SRC = + 'https://ledger-wp-website-s3-prd.ledger.com/uploads/2026/03/hero_visual-1.webp'; + +const baseArgs = { + imageUrl: EXAMPLE_SRC, + onPress: () => ({}), + onClose: () => ({}), +}; + +export const Base: Story = { + args: baseArgs, + render: (args) => ( + + + + + Black Friday sale. + + {' '} + 3 days with no fees{' '} + + on your transactions. + + + + ), + parameters: { + docs: { + source: { + code: ` + {}} onClose={() => {}}> + + + Black Friday sale. + + {' '} + 3 days with no fees{' '} + + on your transactions. + +`, + }, + }, + }, +}; + +export const LayoutShowcase: Story = { + render: () => ( + + + + Full width card + + + ), + parameters: { + docs: { + source: { + code: ` + {}} onClose={() => {}}> + + Full width card +`, + }, + }, + }, +}; + +export const CompositionShowcase: Story = { + render: () => ( + + + Title only + + + + + With tag and title + + + + + With crypto icon + + + ), + parameters: { + docs: { + source: { + code: ` +{/* Title only */} + {}} onClose={() => {}}> + Title only + + +{/* With tag */} + {}} onClose={() => {}}> + + With tag and title + + +{/* With crypto icon */} + {}} onClose={() => {}}> + + With crypto icon +`, + }, + }, + }, +}; + +export const WithClose: Story = { + render: () => { + const [visible, setVisible] = useState(true); + + if (!visible) { + return ( + + ); + } + + return ( + + ({})} + onClose={() => setVisible(false)} + > + + + Black Friday sale. + + {' '} + 3 days with no fees{' '} + + on your transactions. + + + + ); + }, +}; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx new file mode 100644 index 000000000..f9b67239a --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core'; +import { fireEvent, render } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; +import { ThemeProvider } from '../ThemeProvider/ThemeProvider'; +import { MediaCard, MediaCardTitle } from './MediaCard'; + +const TestWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const makeProps = () => ({ + imageUrl: 'https://example.com/image.jpg', + onPress: jest.fn(), + onClose: jest.fn(), +}); + +describe('MediaCard', () => { + it('should render title', () => { + const { getByText } = render( + + + Title + + , + ); + + getByText('Title'); + }); + + it('should render leading content and title', () => { + const { getByText } = render( + + + Tag + Title + + , + ); + + getByText('Tag'); + getByText('Title'); + }); + + it('should fire onPress on press', () => { + const props = makeProps(); + const { getByTestId } = render( + + + Title + + , + ); + + fireEvent.press(getByTestId('media-card')); + expect(props.onPress).toHaveBeenCalledTimes(1); + }); + + it('should show image after successful load', () => { + const { getByTestId } = render( + + + Title + + , + ); + + const img = getByTestId('media-card-image'); + expect(img.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + + fireEvent(img, 'onLoad'); + expect(img.props.style).not.toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + }); + + it('should hide image on error', () => { + const { getByTestId } = render( + + + Title + + , + ); + + const img = getByTestId('media-card-image'); + fireEvent(img, 'onLoad'); + fireEvent(img, 'onError'); + + expect(img.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + }); + + it('should fire onClose on close button press', () => { + const props = makeProps(); + const { getByTestId } = render( + + + Title + + , + ); + + fireEvent.press(getByTestId('media-card-close-button')); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx new file mode 100644 index 000000000..a4c817463 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/MediaCard.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { Image, StyleSheet, View } from 'react-native'; +import { useCommonTranslation } from '../../../i18n'; +import { useStyleSheet } from '../../../styles'; +import { Close } from '../../Symbols'; +import { InteractiveIcon } from '../InteractiveIcon'; +import { LinearGradient, Pressable, Text } from '../Utility'; +import { MediaCardProps, MediaCardTitleProps } from './types'; + +const CARD_HEIGHT = 164; + +const useStyles = () => + useStyleSheet( + (t) => ({ + root: { + position: 'relative', + width: t.sizes.full, + height: CARD_HEIGHT, + borderRadius: t.borderRadius.md, + overflow: 'hidden', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-end', + backgroundColor: t.colors.bg.muted, + }, + content: { + flexDirection: 'column', + alignItems: 'flex-start', + gap: t.spacings.s8, + width: t.sizes.full, + minWidth: 0, + padding: t.spacings.s12, + }, + title: { + ...t.typographies.heading3SemiBold, + color: t.colors.text.white, + }, + closeButton: { + position: 'absolute', + top: t.spacings.s12, + right: t.spacings.s12, + }, + }), + [], + ); + +/** + * Title text for the card, styled with heading typography and white color. + */ +export const MediaCardTitle = ({ + children, + lx = {}, + style, + ref, + ...props +}: MediaCardTitleProps) => { + const styles = useStyles(); + + return ( + + {children} + + ); +}; + +MediaCardTitle.displayName = 'MediaCardTitle'; + +const GradientOverlays = () => { + return ( + <> + + + + + ); +}; + +/** + * A media card component for displaying a full-bleed background image with + * composable content and a close button, using gradient overlays to ensure + * readability. + * + * @example + * import { MediaCard, MediaCardTitle } from '@ledgerhq/lumen-ui-rnative'; + * import { Tag } from '@ledgerhq/lumen-ui-rnative'; + * + * {}} onClose={() => {}}> + * + * Card title + * + */ +export const MediaCard = ({ + ref, + children, + imageUrl, + onPress, + onClose, + lx = {}, + style, + ...pressableProps +}: MediaCardProps) => { + const { t } = useCommonTranslation(); + const [imageLoaded, setImageLoaded] = useState(false); + + const styles = useStyles(); + + return ( + + setImageLoaded(true)} + onError={() => setImageLoaded(false)} + testID='media-card-image' + /> + + {imageLoaded && } + + {children} + + + + + + ); +}; + +MediaCard.displayName = 'MediaCard'; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/index.ts b/libs/ui-rnative/src/lib/Components/MediaCard/index.ts new file mode 100644 index 000000000..804bd2382 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/index.ts @@ -0,0 +1,2 @@ +export { MediaCard, MediaCardTitle } from './MediaCard'; +export * from './types'; diff --git a/libs/ui-rnative/src/lib/Components/MediaCard/types.ts b/libs/ui-rnative/src/lib/Components/MediaCard/types.ts new file mode 100644 index 000000000..f7d2237f5 --- /dev/null +++ b/libs/ui-rnative/src/lib/Components/MediaCard/types.ts @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { StyledPressableProps, StyledTextProps } from '../../../styles'; + +/** + * Props for the MediaCard root component + */ +export type MediaCardProps = { + /** + * The source URL for the background image. + */ + imageUrl: string; + /** + * Callback fired when the card is pressed. + */ + onPress: () => void; + /** + * Callback fired when the close button is pressed. + */ + onClose: () => void; + /** + * The card content — typically a `MediaCardTitle` and optional + * leading content such as tags or icons. + */ + children: ReactNode; +} & Omit; + +/** + * Props for the MediaCardTitle component + */ +export type MediaCardTitleProps = { + /** + * The title text or custom content. + */ + children: ReactNode; +} & Omit; diff --git a/libs/ui-rnative/src/lib/Components/index.ts b/libs/ui-rnative/src/lib/Components/index.ts index c394456f5..5ed1e0b59 100644 --- a/libs/ui-rnative/src/lib/Components/index.ts +++ b/libs/ui-rnative/src/lib/Components/index.ts @@ -15,6 +15,7 @@ export * from './IconButton'; export * from './InteractiveIcon'; export * from './Link'; export * from './ListItem'; +export * from './MediaCard'; export * from './NavBar'; export * from './PageIndicator'; export * from './SearchInput';