@ledgerhq/lumen-ui-rnative 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/module/i18n/locales/de.json +2 -1
  2. package/dist/module/i18n/locales/en.json +2 -1
  3. package/dist/module/i18n/locales/es.json +2 -1
  4. package/dist/module/i18n/locales/fr.json +2 -1
  5. package/dist/module/i18n/locales/ja.json +3 -2
  6. package/dist/module/i18n/locales/ko.json +3 -2
  7. package/dist/module/i18n/locales/pt.json +2 -1
  8. package/dist/module/i18n/locales/ru.json +2 -1
  9. package/dist/module/i18n/locales/th.json +2 -1
  10. package/dist/module/i18n/locales/tr.json +3 -2
  11. package/dist/module/i18n/locales/zh.json +3 -2
  12. package/dist/module/lib/Components/Avatar/Avatar.figma.js +2 -1
  13. package/dist/module/lib/Components/Avatar/Avatar.figma.js.map +1 -1
  14. package/dist/module/lib/Components/Avatar/Avatar.js +8 -2
  15. package/dist/module/lib/Components/Avatar/Avatar.js.map +1 -1
  16. package/dist/module/lib/Components/Avatar/Avatar.mdx +3 -2
  17. package/dist/module/lib/Components/Avatar/Avatar.stories.js +16 -0
  18. package/dist/module/lib/Components/Avatar/Avatar.stories.js.map +1 -1
  19. package/dist/module/lib/Components/Avatar/Avatar.test.js +17 -0
  20. package/dist/module/lib/Components/Avatar/Avatar.test.js.map +1 -1
  21. package/dist/module/lib/Components/Card/Card.stories.js +19 -19
  22. package/dist/module/lib/Components/Card/Card.stories.js.map +1 -1
  23. package/dist/module/lib/Components/DotIcon/DotIcon.js +1 -1
  24. package/dist/module/lib/Components/DotIndicator/DotIndicator.js +2 -1
  25. package/dist/module/lib/Components/DotIndicator/DotIndicator.js.map +1 -1
  26. package/dist/module/lib/Components/DotIndicator/DotIndicator.mdx +3 -2
  27. package/dist/module/lib/Components/DotIndicator/DotIndicator.stories.js +20 -4
  28. package/dist/module/lib/Components/DotIndicator/DotIndicator.stories.js.map +1 -1
  29. package/dist/module/lib/Components/DotIndicator/DotIndicator.test.js +10 -0
  30. package/dist/module/lib/Components/DotIndicator/DotIndicator.test.js.map +1 -1
  31. package/dist/module/lib/Components/ListItem/ListItem.stories.js +3 -3
  32. package/dist/module/lib/Components/ListItem/ListItem.stories.js.map +1 -1
  33. package/dist/module/lib/Components/MediaButton/MediaButton.stories.js +4 -4
  34. package/dist/module/lib/Components/MediaButton/MediaButton.stories.js.map +1 -1
  35. package/dist/module/lib/Components/MediaCard/MediaCard.stories.js +3 -3
  36. package/dist/module/lib/Components/MediaCard/MediaCard.stories.js.map +1 -1
  37. package/dist/module/lib/Components/MediaImage/MediaImage.js +41 -7
  38. package/dist/module/lib/Components/MediaImage/MediaImage.js.map +1 -1
  39. package/dist/module/lib/Components/MediaImage/MediaImage.mdx +38 -5
  40. package/dist/module/lib/Components/MediaImage/MediaImage.stories.js +92 -0
  41. package/dist/module/lib/Components/MediaImage/MediaImage.stories.js.map +1 -1
  42. package/dist/module/lib/Components/MediaImage/MediaImage.test.js +117 -0
  43. package/dist/module/lib/Components/MediaImage/MediaImage.test.js.map +1 -1
  44. package/dist/module/lib/Components/NavBar/NavBar.mdx +0 -1
  45. package/dist/module/lib/Components/NavBar/NavBar.stories.js +2 -2
  46. package/dist/module/lib/Components/NavBar/NavBar.stories.js.map +1 -1
  47. package/dist/module/lib/Components/OptionList/OptionList.stories.js +7 -7
  48. package/dist/module/lib/Components/OptionList/OptionList.stories.js.map +1 -1
  49. package/dist/typescript/src/lib/Components/Avatar/Avatar.d.ts.map +1 -1
  50. package/dist/typescript/src/lib/Components/Avatar/types.d.ts +1 -1
  51. package/dist/typescript/src/lib/Components/Avatar/types.d.ts.map +1 -1
  52. package/dist/typescript/src/lib/Components/DotIndicator/types.d.ts +1 -1
  53. package/dist/typescript/src/lib/Components/DotIndicator/types.d.ts.map +1 -1
  54. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts +9 -3
  55. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts.map +1 -1
  56. package/dist/typescript/src/lib/Components/MediaImage/types.d.ts +12 -0
  57. package/dist/typescript/src/lib/Components/MediaImage/types.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/i18n/locales/de.json +2 -1
  60. package/src/i18n/locales/en.json +2 -1
  61. package/src/i18n/locales/es.json +2 -1
  62. package/src/i18n/locales/fr.json +2 -1
  63. package/src/i18n/locales/ja.json +3 -2
  64. package/src/i18n/locales/ko.json +3 -2
  65. package/src/i18n/locales/pt.json +2 -1
  66. package/src/i18n/locales/ru.json +2 -1
  67. package/src/i18n/locales/th.json +2 -1
  68. package/src/i18n/locales/tr.json +3 -2
  69. package/src/i18n/locales/zh.json +3 -2
  70. package/src/lib/Components/Avatar/Avatar.figma.tsx +1 -0
  71. package/src/lib/Components/Avatar/Avatar.mdx +3 -2
  72. package/src/lib/Components/Avatar/Avatar.stories.tsx +9 -0
  73. package/src/lib/Components/Avatar/Avatar.test.tsx +17 -0
  74. package/src/lib/Components/Avatar/Avatar.tsx +4 -1
  75. package/src/lib/Components/Avatar/types.ts +1 -1
  76. package/src/lib/Components/Card/Card.stories.tsx +19 -19
  77. package/src/lib/Components/DotIcon/DotIcon.tsx +1 -1
  78. package/src/lib/Components/DotIndicator/DotIndicator.mdx +3 -2
  79. package/src/lib/Components/DotIndicator/DotIndicator.stories.tsx +11 -1
  80. package/src/lib/Components/DotIndicator/DotIndicator.test.tsx +10 -0
  81. package/src/lib/Components/DotIndicator/DotIndicator.tsx +2 -1
  82. package/src/lib/Components/DotIndicator/types.ts +1 -1
  83. package/src/lib/Components/ListItem/ListItem.stories.tsx +3 -3
  84. package/src/lib/Components/MediaButton/MediaButton.stories.tsx +4 -4
  85. package/src/lib/Components/MediaCard/MediaCard.stories.tsx +3 -3
  86. package/src/lib/Components/MediaImage/MediaImage.mdx +38 -5
  87. package/src/lib/Components/MediaImage/MediaImage.stories.tsx +32 -0
  88. package/src/lib/Components/MediaImage/MediaImage.test.tsx +108 -0
  89. package/src/lib/Components/MediaImage/MediaImage.tsx +37 -3
  90. package/src/lib/Components/MediaImage/types.ts +12 -0
  91. package/src/lib/Components/NavBar/NavBar.mdx +0 -1
  92. package/src/lib/Components/NavBar/NavBar.stories.tsx +2 -2
  93. package/src/lib/Components/OptionList/OptionList.stories.tsx +11 -11
@@ -1,4 +1,4 @@
1
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
1
+ import CryptoIcon from '@ledgerhq/crypto-icons/native';
2
2
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
3
3
  import { useState } from 'react';
4
4
  import { Settings, ChevronRight, Wallet } from '../../Symbols';
@@ -102,7 +102,7 @@ export const DensityShowcase: Story = {
102
102
  <Box lx={{ flexDirection: 'column', maxWidth: 's320', gap: 's8' }}>
103
103
  <ListItem density='compact' onPress={() => {}}>
104
104
  <ListItemLeading>
105
- <CryptoIcon ledgerId='bitcoin' ticker='BTC' size='24px' />
105
+ <CryptoIcon ledgerId='bitcoin' ticker='BTC' size={24} />
106
106
  <ListItemContent>
107
107
  <ListItemTitle>Compact with icon</ListItemTitle>
108
108
  </ListItemContent>
@@ -126,7 +126,7 @@ export const DensityShowcase: Story = {
126
126
 
127
127
  <ListItem density='expanded' onPress={() => {}}>
128
128
  <ListItemLeading>
129
- <CryptoIcon ledgerId='bitcoin' ticker='BTC' size='48px' />
129
+ <CryptoIcon ledgerId='bitcoin' ticker='BTC' size={48} />
130
130
  <ListItemContent>
131
131
  <ListItemTitle>Expanded with icon</ListItemTitle>
132
132
  <ListItemDescription>Additional information</ListItemDescription>
@@ -1,4 +1,4 @@
1
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
1
+ import CryptoIcon from '@ledgerhq/crypto-icons/native';
2
2
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
3
3
  import { Settings, Star } from '../../Symbols';
4
4
  import { Box } from '../Utility';
@@ -66,7 +66,7 @@ export const IconTypeShowcase: Story = {
66
66
  Flat icon (md)
67
67
  </MediaButton>
68
68
  <MediaButton
69
- icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='32px' />}
69
+ icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size={32} />}
70
70
  iconType='rounded'
71
71
  appearance='gray'
72
72
  >
@@ -84,7 +84,7 @@ export const IconTypeShowcase: Story = {
84
84
  Flat icon (sm)
85
85
  </MediaButton>
86
86
  <MediaButton
87
- icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='24px' />}
87
+ icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size={24} />}
88
88
  iconType='rounded'
89
89
  appearance='gray'
90
90
  size='sm'
@@ -119,7 +119,7 @@ export const AppearanceShowcase: Story = {
119
119
  </MediaButton>
120
120
  <MediaButton
121
121
  appearance={appearance}
122
- icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='32px' />}
122
+ icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size={32} />}
123
123
  iconType='rounded'
124
124
  >
125
125
  {appearance}
@@ -1,4 +1,4 @@
1
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
1
+ import CryptoIcon from '@ledgerhq/crypto-icons/native';
2
2
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
3
3
  import { useState } from 'react';
4
4
  import { Button } from '../Button';
@@ -121,7 +121,7 @@ export const CompositionShowcase: Story = {
121
121
  </MediaCard>
122
122
 
123
123
  <MediaCard {...baseArgs}>
124
- <CryptoIcon ledgerId='bitcoin' ticker='BTC' size='32px' />
124
+ <CryptoIcon ledgerId='bitcoin' ticker='BTC' size={32} />
125
125
  <MediaCardTitle>With crypto icon</MediaCardTitle>
126
126
  </MediaCard>
127
127
  </Box>
@@ -143,7 +143,7 @@ export const CompositionShowcase: Story = {
143
143
 
144
144
  {/* With crypto icon */}
145
145
  <MediaCard imageUrl="/promo.jpg" onPress={() => {}} onClose={() => {}}>
146
- <CryptoIcon ledgerId="bitcoin" ticker="BTC" size="32px" />
146
+ <CryptoIcon ledgerId="bitcoin" ticker="BTC" size={32} />
147
147
  <MediaCardTitle>With crypto icon</MediaCardTitle>
148
148
  </MediaCard>`,
149
149
  },
@@ -14,7 +14,7 @@ import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/Com
14
14
 
15
15
  ## Introduction
16
16
 
17
- MediaImage displays an image with consistent sizing and shape. When the image fails to load or no source is provided, a background placeholder is shown automatically. This React Native implementation ensures consistent behavior across mobile platforms.
17
+ MediaImage displays an image with consistent sizing and shape. When the image fails to load or no source is provided, it falls back to a single-letter initial (via `fallback`) or a muted background placeholder. While data is loading, a `loading` prop triggers a pulsing skeleton overlay. This React Native implementation ensures consistent behavior across mobile platforms.
18
18
 
19
19
  > View in [Figma](https://www.figma.com/design/zSkvGGiqcnhywp2l3HTHxA/1.-Symbol-Library?node-id=6159-1866).
20
20
 
@@ -22,9 +22,11 @@ MediaImage displays an image with consistent sizing and shape. When the image fa
22
22
 
23
23
  <Canvas of={MediaImageStories.Base} />
24
24
 
25
- - **Container**: Sized wrapper with rounded corners and overflow clipping
25
+ - **Container**: Sized wrapper with rounded corners, overflow clipping, and a subtle 1px inset outline
26
26
  - **Image**: Fills the container using React Native's `Image` component
27
- - **Fallback**: Background placeholder shown on missing or broken source
27
+ - **Fallback letter**: Single uppercased character shown when `fallback` is set and no image is available
28
+ - **Fallback placeholder**: Muted background shown when neither a valid image nor `fallback` is available
29
+ - **Loading skeleton**: Pulsing overlay shown while `loading` is `true`, regardless of `src`
28
30
 
29
31
  ## Properties
30
32
 
@@ -35,7 +37,7 @@ MediaImage displays an image with consistent sizing and shape. When the image fa
35
37
 
36
38
  ### Sizes
37
39
 
38
- Eight sizes are available (12, 16, 20, 24, 32, 40, 48, 56). Border radius scales with size.
40
+ Nine sizes are available (12, 16, 20, 24, 32, 40, 48, 56, 64). Border radius scales with size.
39
41
 
40
42
  <Canvas of={MediaImageStories.SizeShowcase} />
41
43
 
@@ -46,10 +48,25 @@ Eight sizes are available (12, 16, 20, 24, 32, 40, 48, 56). Border radius scales
46
48
  - **square** (default): Rounded corners that scale with size
47
49
  - **circle**: Fully rounded
48
50
 
51
+ ### Fallback
52
+
53
+ When no `src` is provided or the image fails to load, the component degrades gracefully:
54
+
55
+ - If `fallback` is set, the first character is displayed uppercased at a size-appropriate font size.
56
+ - If `fallback` is not set, a muted background placeholder is shown.
57
+
58
+ <Canvas of={MediaImageStories.FallbackShowcase} />
59
+
60
+ ### Loading state
61
+
62
+ When `loading` is `true`, a pulsing skeleton overlay (using the `Pulse` animation component) covers the entire component regardless of whether a `src` is provided.
63
+
64
+ <Canvas of={MediaImageStories.LoadingShowcase} />
65
+
49
66
  ## Accessibility
50
67
 
51
68
  - The root element uses `accessibilityRole="image"` with `accessibilityLabel` derived from `alt`.
52
- - The inner `Image` is marked `accessible={false}` to avoid duplicate announcements.
69
+ - The inner `Image` and fallback elements are marked `accessible={false}` to avoid duplicate announcements.
53
70
  - Always provide a meaningful `alt` prop so screen readers can announce the image.
54
71
 
55
72
  </Tab>
@@ -75,6 +92,22 @@ function MyComponent() {
75
92
  <MediaImage src='https://example.com/icon.png' alt='Ethereum' size={48} shape='circle' />
76
93
  ```
77
94
 
95
+ ### With Fallback Letter
96
+
97
+ When no `src` is available or the image fails to load, pass a `fallback` string to show its first letter as a placeholder:
98
+
99
+ ```tsx
100
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={48} />
101
+ ```
102
+
103
+ ### Loading State
104
+
105
+ Show a pulsing skeleton while image data is being fetched:
106
+
107
+ ```tsx
108
+ <MediaImage loading alt='Loading…' size={48} />
109
+ ```
110
+
78
111
  ### Custom Styling
79
112
 
80
113
  Use the `lx` prop for token-based layout adjustments:
@@ -54,3 +54,35 @@ export const ShapeShowcase: Story = {
54
54
  </Box>
55
55
  ),
56
56
  };
57
+
58
+ export const FallbackShowcase: Story = {
59
+ render: () => (
60
+ <Box lx={{ flexDirection: 'row', alignItems: 'flex-end', gap: 's16' }}>
61
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={12} />
62
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={16} />
63
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={20} />
64
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={24} />
65
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={32} />
66
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={40} />
67
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={48} />
68
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={56} />
69
+ <MediaImage fallback='Bitcoin' alt='Bitcoin' size={64} />
70
+ </Box>
71
+ ),
72
+ };
73
+
74
+ export const LoadingShowcase: Story = {
75
+ render: () => (
76
+ <Box lx={{ flexDirection: 'row', alignItems: 'flex-end', gap: 's16' }}>
77
+ <MediaImage loading alt='Loading' size={12} />
78
+ <MediaImage loading alt='Loading' size={16} />
79
+ <MediaImage loading alt='Loading' size={20} />
80
+ <MediaImage loading alt='Loading' size={24} />
81
+ <MediaImage loading alt='Loading' size={32} />
82
+ <MediaImage loading alt='Loading' size={40} />
83
+ <MediaImage loading alt='Loading' size={48} />
84
+ <MediaImage loading alt='Loading' size={56} />
85
+ <MediaImage loading alt='Loading' size={64} />
86
+ </Box>
87
+ ),
88
+ };
@@ -46,6 +46,41 @@ describe('MediaImage Component', () => {
46
46
  expect(queryByTestId('media-image-img')).toBeNull();
47
47
  });
48
48
 
49
+ it('should render single-letter fallback (uppercased) when fallback is provided and src is missing', () => {
50
+ const { queryByTestId, getByText } = render(
51
+ <TestWrapper>
52
+ <MediaImage fallback='bitcoin' alt='BTC' />
53
+ </TestWrapper>,
54
+ );
55
+
56
+ expect(queryByTestId('media-image-img')).toBeNull();
57
+ expect(getByText('B')).toBeTruthy();
58
+ });
59
+
60
+ it('should render single-letter fallback when fallback is provided and src is empty string', () => {
61
+ const { getByText } = render(
62
+ <TestWrapper>
63
+ <MediaImage src='' fallback='ethereum' alt='ETH' />
64
+ </TestWrapper>,
65
+ );
66
+
67
+ expect(getByText('E')).toBeTruthy();
68
+ });
69
+
70
+ it('should size the fallback letter according to the size prop', () => {
71
+ const { getByText } = render(
72
+ <TestWrapper>
73
+ <MediaImage fallback='cardano' alt='ADA' size={32} />
74
+ </TestWrapper>,
75
+ );
76
+
77
+ const fallback = getByText('C');
78
+ const flatStyle = Array.isArray(fallback.props.style)
79
+ ? Object.assign({}, ...fallback.props.style)
80
+ : fallback.props.style;
81
+ expect(flatStyle.fontSize).toBe(16);
82
+ });
83
+
49
84
  it('should render fallback when image fails to load', async () => {
50
85
  const { getByTestId, queryByTestId, rerender } = render(
51
86
  <TestWrapper>
@@ -67,6 +102,36 @@ describe('MediaImage Component', () => {
67
102
  });
68
103
  });
69
104
 
105
+ it('should render single-letter fallback when image fails to load and fallback is provided', async () => {
106
+ const { getByTestId, queryByTestId, queryByText, rerender } = render(
107
+ <TestWrapper>
108
+ <MediaImage
109
+ src='https://broken-link.com/404.png'
110
+ fallback='solana'
111
+ alt='SOL'
112
+ />
113
+ </TestWrapper>,
114
+ );
115
+
116
+ const img = getByTestId('media-image-img');
117
+ img.props.onError();
118
+
119
+ rerender(
120
+ <TestWrapper>
121
+ <MediaImage
122
+ src='https://broken-link.com/404.png'
123
+ fallback='solana'
124
+ alt='SOL'
125
+ />
126
+ </TestWrapper>,
127
+ );
128
+
129
+ await waitFor(() => {
130
+ expect(queryByTestId('media-image-img')).toBeNull();
131
+ expect(queryByText('S')).toBeTruthy();
132
+ });
133
+ });
134
+
70
135
  it('should reset error state when src changes', async () => {
71
136
  const { getByTestId, rerender } = render(
72
137
  <TestWrapper>
@@ -176,4 +241,47 @@ describe('MediaImage Component', () => {
176
241
 
177
242
  expect(getByTestId('custom-id')).toBeTruthy();
178
243
  });
244
+
245
+ describe('loading state', () => {
246
+ it('should render the skeleton overlay when loading is true', () => {
247
+ const { getByTestId } = render(
248
+ <TestWrapper>
249
+ <MediaImage src={validSrc} alt='Test' loading />
250
+ </TestWrapper>,
251
+ );
252
+
253
+ expect(getByTestId('skeleton')).toBeTruthy();
254
+ });
255
+
256
+ it('should hide the image when loading is true even if src is valid', () => {
257
+ const { queryByTestId } = render(
258
+ <TestWrapper>
259
+ <MediaImage src={validSrc} alt='Test' loading />
260
+ </TestWrapper>,
261
+ );
262
+
263
+ expect(queryByTestId('media-image-img')).toBeNull();
264
+ });
265
+
266
+ it('should hide the fallback letter when loading is true', () => {
267
+ const { queryByText, getByTestId } = render(
268
+ <TestWrapper>
269
+ <MediaImage fallback='bitcoin' alt='BTC' loading />
270
+ </TestWrapper>,
271
+ );
272
+
273
+ expect(queryByText('B')).toBeNull();
274
+ expect(getByTestId('skeleton')).toBeTruthy();
275
+ });
276
+
277
+ it('should not render the skeleton when loading is false (default)', () => {
278
+ const { queryByTestId } = render(
279
+ <TestWrapper>
280
+ <MediaImage src={validSrc} alt='Test' />
281
+ </TestWrapper>,
282
+ );
283
+
284
+ expect(queryByTestId('skeleton')).toBeNull();
285
+ });
286
+ });
179
287
  });
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
2
2
  import { Image, StyleSheet } from 'react-native';
3
3
  import { useStyleSheet } from '../../../styles';
4
4
  import type { LumenStyleSheetTheme } from '../../../styles';
5
- import { Box } from '../Utility';
5
+ import { Skeleton } from '../Skeleton';
6
+ import { Box, Text } from '../Utility';
6
7
  import type { MediaImageProps, MediaImageSize, MediaImageShape } from './types';
7
8
 
8
9
  type BorderRadiusKey = keyof LumenStyleSheetTheme['borderRadius'];
@@ -19,6 +20,18 @@ const borderRadiusMap: Record<MediaImageSize, BorderRadiusKey> = {
19
20
  64: 'lg',
20
21
  };
21
22
 
23
+ export const fontSizeMap: Record<MediaImageSize, number> = {
24
+ 12: 10,
25
+ 16: 10,
26
+ 20: 12,
27
+ 24: 14,
28
+ 32: 16,
29
+ 40: 18,
30
+ 48: 24,
31
+ 56: 24,
32
+ 64: 24,
33
+ };
34
+
22
35
  const useStyles = ({
23
36
  size,
24
37
  shape,
@@ -43,6 +56,10 @@ const useStyles = ({
43
56
  alignItems: 'center',
44
57
  justifyContent: 'center',
45
58
  backgroundColor: t.colors.bg.muted,
59
+ outlineColor: t.colors.border.icon,
60
+ outlineWidth: 1,
61
+ outlineOffset: -1,
62
+ outlineStyle: 'solid',
46
63
  },
47
64
  image: {
48
65
  width: '100%',
@@ -58,18 +75,25 @@ const useStyles = ({
58
75
  * A generic media image component that displays an image with optional shape variants.
59
76
  * Supports square and circular appearances with consistent sizing.
60
77
  *
61
- * When the image fails to load or no src is provided, displays a background placeholder.
78
+ * When the image fails to load or no src is provided, displays a fallback letter (if `fallback`
79
+ * is provided) or a muted background placeholder.
80
+ *
81
+ * While `loading` is true, a pulsing skeleton overlay is shown regardless of `src`.
62
82
  *
63
83
  * @example
64
84
  * import { MediaImage } from '@ledgerhq/lumen-ui-rnative';
65
85
  *
66
86
  * <MediaImage src="https://example.com/icon.png" alt="Bitcoin" size={32} />
87
+ * <MediaImage fallback="Bitcoin" size={32} />
88
+ * <MediaImage loading size={32} />
67
89
  */
68
90
  export const MediaImage = ({
69
91
  src,
70
92
  alt,
71
93
  size = 48,
72
94
  shape = 'square',
95
+ fallback,
96
+ loading = false,
73
97
  lx = {},
74
98
  style,
75
99
  ref,
@@ -92,7 +116,17 @@ export const MediaImage = ({
92
116
  accessibilityLabel={alt}
93
117
  {...props}
94
118
  >
95
- {!shouldFallback && (
119
+ {loading && <Skeleton style={StyleSheet.absoluteFillObject} />}
120
+ {!loading && shouldFallback && fallback && (
121
+ <Text
122
+ style={{ fontSize: fontSizeMap[size] }}
123
+ lx={{ color: 'base' }}
124
+ accessible={false}
125
+ >
126
+ {fallback[0]?.toUpperCase()}
127
+ </Text>
128
+ )}
129
+ {!loading && !shouldFallback && (
96
130
  <Image
97
131
  source={{ uri: src }}
98
132
  style={styles.image}
@@ -24,4 +24,16 @@ export type MediaImageProps = {
24
24
  * Alternative text for the image, used for accessibility.
25
25
  */
26
26
  alt?: string;
27
+ /**
28
+ * Text used to derive a single-letter fallback when no `src` is provided or the image fails to load.
29
+ * The first character is displayed, uppercased.
30
+ * @optional
31
+ */
32
+ fallback?: string;
33
+ /**
34
+ * When true, displays a pulsing skeleton placeholder instead of the image or fallback.
35
+ * @optional
36
+ * @default false
37
+ */
38
+ loading?: boolean;
27
39
  } & Omit<StyledViewProps, 'children'>;
@@ -1,5 +1,4 @@
1
1
  import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
2
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
3
2
  import { Box } from '../Utility';
4
3
  import * as NavBarStories from './NavBar.stories';
5
4
  import { NavBar, NavBarBackButton, NavBarContent } from './NavBar';
@@ -1,4 +1,4 @@
1
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
1
+ import CryptoIcon from '@ledgerhq/crypto-icons/native';
2
2
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
3
3
  import { MoreHorizontal, Settings } from '../../Symbols';
4
4
  import { IconButton } from '../IconButton';
@@ -111,7 +111,7 @@ export const WithCoinCapsule: Story = {
111
111
  <NavBarContent>
112
112
  <NavBarCoinCapsule
113
113
  ticker='BTC'
114
- icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='24px' />}
114
+ icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size={24} />}
115
115
  />
116
116
  </NavBarContent>
117
117
  <NavBarTrailing>
@@ -1,4 +1,4 @@
1
- import { CryptoIcon } from '@ledgerhq/crypto-icons';
1
+ import CryptoIcon from '@ledgerhq/crypto-icons/native';
2
2
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
3
3
  import { useState } from 'react';
4
4
  import { Settings } from '../../Symbols';
@@ -132,9 +132,9 @@ export const Base: Story = {
132
132
  <OptionListItem value={item.value}>
133
133
  <OptionListItemLeading>
134
134
  <CryptoIcon
135
- ledgerId={item.meta?.ledgerId ?? ''}
135
+ ledgerId={(item.meta?.ledgerId as string) ?? ''}
136
136
  ticker={ticker}
137
- size='32px'
137
+ size={32}
138
138
  />
139
139
  </OptionListItemLeading>
140
140
  <OptionListItemContent>
@@ -277,7 +277,7 @@ export const WithContentRow: Story = {
277
277
  <CryptoIcon
278
278
  ledgerId={meta.ledgerId}
279
279
  ticker={meta.ticker}
280
- size='32px'
280
+ size={32}
281
281
  />
282
282
  </OptionListItemLeading>
283
283
  <OptionListItemContent>
@@ -453,7 +453,7 @@ export const GroupedWithContentRow: Story = {
453
453
  <CryptoIcon
454
454
  ledgerId={meta.ledgerId}
455
455
  ticker={meta.ticker}
456
- size='32px'
456
+ size={32}
457
457
  />
458
458
  </OptionListItemLeading>
459
459
  <OptionListItemContent>
@@ -623,9 +623,9 @@ export const TriggerShowcase: Story = {
623
623
  icon={
624
624
  selectedCrypto?.meta ? (
625
625
  <CryptoIcon
626
- ledgerId={selectedCrypto.meta.ledgerId}
627
- ticker={selectedCrypto.meta.ticker}
628
- size='32px'
626
+ ledgerId={selectedCrypto.meta.ledgerId as string}
627
+ ticker={selectedCrypto.meta.ticker as string}
628
+ size={32}
629
629
  />
630
630
  ) : undefined
631
631
  }
@@ -688,9 +688,9 @@ export const TriggerShowcase: Story = {
688
688
  <OptionListItem value={item.value}>
689
689
  <OptionListItemLeading>
690
690
  <CryptoIcon
691
- ledgerId={item.meta?.ledgerId ?? ''}
691
+ ledgerId={(item.meta?.ledgerId as string) ?? ''}
692
692
  ticker={ticker}
693
- size='32px'
693
+ size={32}
694
694
  />
695
695
  </OptionListItemLeading>
696
696
  <OptionListItemContent>
@@ -736,7 +736,7 @@ export const WithDefaultValue: Story = {
736
736
  <CryptoIcon
737
737
  ledgerId={meta.ledgerId}
738
738
  ticker={meta.ticker}
739
- size='32px'
739
+ size={32}
740
740
  />
741
741
  </OptionListItemLeading>
742
742
  <OptionListItemContent>