@ledgerhq/lumen-ui-rnative 0.1.15 → 0.1.16

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 (75) hide show
  1. package/dist/module/lib/Components/AddressInput/AddressInput.js +21 -10
  2. package/dist/module/lib/Components/AddressInput/AddressInput.js.map +1 -1
  3. package/dist/module/lib/Components/AddressInput/AddressInput.mdx +18 -2
  4. package/dist/module/lib/Components/AddressInput/AddressInput.stories.js +1 -23
  5. package/dist/module/lib/Components/AddressInput/AddressInput.stories.js.map +1 -1
  6. package/dist/module/lib/Components/AmountInput/AmountInput.js +7 -6
  7. package/dist/module/lib/Components/AmountInput/AmountInput.js.map +1 -1
  8. package/dist/module/lib/Components/AmountInput/AmountInput.mdx +5 -1
  9. package/dist/module/lib/Components/AmountInput/AmountInput.stories.js +1 -36
  10. package/dist/module/lib/Components/AmountInput/AmountInput.stories.js.map +1 -1
  11. package/dist/module/lib/Components/BaseInput/BaseInput.js +54 -48
  12. package/dist/module/lib/Components/BaseInput/BaseInput.js.map +1 -1
  13. package/dist/module/lib/Components/MediaImage/MediaImage.js +102 -0
  14. package/dist/module/lib/Components/MediaImage/MediaImage.js.map +1 -0
  15. package/dist/module/lib/Components/MediaImage/MediaImage.mdx +103 -0
  16. package/dist/module/lib/Components/MediaImage/MediaImage.stories.js +91 -0
  17. package/dist/module/lib/Components/MediaImage/MediaImage.stories.js.map +1 -0
  18. package/dist/module/lib/Components/MediaImage/MediaImage.test.js +204 -0
  19. package/dist/module/lib/Components/MediaImage/MediaImage.test.js.map +1 -0
  20. package/dist/module/lib/Components/MediaImage/index.js +5 -0
  21. package/dist/module/lib/Components/MediaImage/index.js.map +1 -0
  22. package/dist/module/lib/Components/MediaImage/types.js +4 -0
  23. package/dist/module/lib/Components/MediaImage/types.js.map +1 -0
  24. package/dist/module/lib/Components/SearchInput/SearchInput.js +11 -2
  25. package/dist/module/lib/Components/SearchInput/SearchInput.js.map +1 -1
  26. package/dist/module/lib/Components/SearchInput/SearchInput.mdx +14 -2
  27. package/dist/module/lib/Components/SearchInput/SearchInput.stories.js +1 -19
  28. package/dist/module/lib/Components/SearchInput/SearchInput.stories.js.map +1 -1
  29. package/dist/module/lib/Components/TextInput/TextInput.mdx +14 -2
  30. package/dist/module/lib/Components/TextInput/TextInput.stories.js +1 -28
  31. package/dist/module/lib/Components/TextInput/TextInput.stories.js.map +1 -1
  32. package/dist/module/lib/Components/index.js +1 -0
  33. package/dist/module/lib/Components/index.js.map +1 -1
  34. package/dist/typescript/src/lib/Components/AddressInput/AddressInput.d.ts +1 -1
  35. package/dist/typescript/src/lib/Components/AddressInput/AddressInput.d.ts.map +1 -1
  36. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts +1 -1
  37. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts.map +1 -1
  38. package/dist/typescript/src/lib/Components/AmountInput/types.d.ts +7 -0
  39. package/dist/typescript/src/lib/Components/AmountInput/types.d.ts.map +1 -1
  40. package/dist/typescript/src/lib/Components/BaseInput/BaseInput.d.ts +1 -1
  41. package/dist/typescript/src/lib/Components/BaseInput/BaseInput.d.ts.map +1 -1
  42. package/dist/typescript/src/lib/Components/BaseInput/types.d.ts +7 -0
  43. package/dist/typescript/src/lib/Components/BaseInput/types.d.ts.map +1 -1
  44. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts +18 -0
  45. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts.map +1 -0
  46. package/dist/typescript/src/lib/Components/MediaImage/index.d.ts +3 -0
  47. package/dist/typescript/src/lib/Components/MediaImage/index.d.ts.map +1 -0
  48. package/dist/typescript/src/lib/Components/MediaImage/types.d.ts +25 -0
  49. package/dist/typescript/src/lib/Components/MediaImage/types.d.ts.map +1 -0
  50. package/dist/typescript/src/lib/Components/SearchInput/SearchInput.d.ts +1 -1
  51. package/dist/typescript/src/lib/Components/SearchInput/SearchInput.d.ts.map +1 -1
  52. package/dist/typescript/src/lib/Components/index.d.ts +1 -0
  53. package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/lib/Components/AddressInput/AddressInput.mdx +18 -2
  56. package/src/lib/Components/AddressInput/AddressInput.stories.tsx +1 -23
  57. package/src/lib/Components/AddressInput/AddressInput.tsx +15 -7
  58. package/src/lib/Components/AmountInput/AmountInput.mdx +5 -1
  59. package/src/lib/Components/AmountInput/AmountInput.stories.tsx +1 -36
  60. package/src/lib/Components/AmountInput/AmountInput.tsx +4 -3
  61. package/src/lib/Components/AmountInput/types.ts +7 -0
  62. package/src/lib/Components/BaseInput/BaseInput.tsx +66 -60
  63. package/src/lib/Components/BaseInput/types.ts +7 -0
  64. package/src/lib/Components/MediaImage/MediaImage.mdx +103 -0
  65. package/src/lib/Components/MediaImage/MediaImage.stories.tsx +55 -0
  66. package/src/lib/Components/MediaImage/MediaImage.test.tsx +179 -0
  67. package/src/lib/Components/MediaImage/MediaImage.tsx +117 -0
  68. package/src/lib/Components/MediaImage/index.ts +2 -0
  69. package/src/lib/Components/MediaImage/types.ts +27 -0
  70. package/src/lib/Components/SearchInput/SearchInput.mdx +14 -2
  71. package/src/lib/Components/SearchInput/SearchInput.stories.tsx +1 -19
  72. package/src/lib/Components/SearchInput/SearchInput.tsx +8 -1
  73. package/src/lib/Components/TextInput/TextInput.mdx +14 -2
  74. package/src/lib/Components/TextInput/TextInput.stories.tsx +1 -28
  75. package/src/lib/Components/index.ts +1 -0
@@ -1,4 +1,7 @@
1
- import { useDisabledContext } from '@ledgerhq/lumen-utils-shared';
1
+ import {
2
+ DisabledProvider,
3
+ useDisabledContext,
4
+ } from '@ledgerhq/lumen-utils-shared';
2
5
  import {
3
6
  useCallback,
4
7
  useEffect,
@@ -33,7 +36,8 @@ export const BaseInput = ({
33
36
  errorMessage,
34
37
  hideClearButton,
35
38
  onChangeText: onChangeTextProp,
36
- editable: editableProp = true,
39
+ editable,
40
+ disabled: disabledProp = false,
37
41
  prefix,
38
42
  suffix,
39
43
  ref,
@@ -41,7 +45,7 @@ export const BaseInput = ({
41
45
  }: BaseInputProps) => {
42
46
  const disabled = useDisabledContext({
43
47
  consumerName: 'BaseInput',
44
- mergeWith: { disabled: !editableProp },
48
+ mergeWith: { disabled: disabledProp },
45
49
  });
46
50
  const { t } = useCommonTranslation();
47
51
  const { theme } = useTheme();
@@ -97,68 +101,70 @@ export const BaseInput = ({
97
101
  });
98
102
 
99
103
  return (
100
- <Box lx={lx} style={style}>
101
- <Pressable
102
- style={StyleSheet.flatten([styles.container, containerStyle])}
103
- onPress={() => inputRef.current?.focus()}
104
- disabled={disabled}
105
- >
106
- {prefix}
104
+ <DisabledProvider value={{ disabled }}>
105
+ <Box lx={lx} style={style}>
106
+ <Pressable
107
+ style={StyleSheet.flatten([styles.container, containerStyle])}
108
+ onPress={() => inputRef.current?.focus()}
109
+ disabled={disabled}
110
+ >
111
+ {prefix}
107
112
 
108
- <TextInput
109
- ref={inputRef}
110
- value={value}
111
- style={StyleSheet.flatten([styles.input, inputStyle])}
112
- onFocus={() => setIsFocused(true)}
113
- onBlur={() => setIsFocused(false)}
114
- onChangeText={handleChangeText}
115
- editable={!disabled}
116
- autoCapitalize='none'
117
- autoCorrect={false}
118
- selectionColor={theme.colors.text.active}
119
- placeholderTextColor={theme.colors.text.muted}
120
- {...props}
121
- />
113
+ <TextInput
114
+ ref={inputRef}
115
+ value={value}
116
+ style={StyleSheet.flatten([styles.input, inputStyle])}
117
+ onFocus={() => setIsFocused(true)}
118
+ onBlur={() => setIsFocused(false)}
119
+ onChangeText={handleChangeText}
120
+ editable={editable !== false && !disabled}
121
+ autoCapitalize='none'
122
+ autoCorrect={false}
123
+ selectionColor={theme.colors.text.active}
124
+ placeholderTextColor={theme.colors.text.muted}
125
+ {...props}
126
+ />
122
127
 
123
- {label && (
124
- <Animated.Text
125
- style={[
126
- floatingLabelStyles.label,
127
- floatingLabelStyles.animatedStyle,
128
- labelStyle,
129
- ]}
130
- numberOfLines={1}
131
- >
132
- {label}
133
- </Animated.Text>
134
- )}
128
+ {label && (
129
+ <Animated.Text
130
+ style={[
131
+ floatingLabelStyles.label,
132
+ floatingLabelStyles.animatedStyle,
133
+ labelStyle,
134
+ ]}
135
+ numberOfLines={1}
136
+ >
137
+ {label}
138
+ </Animated.Text>
139
+ )}
135
140
 
136
- {(suffix || (!hideClearButton && !disabled)) && (
137
- <View style={styles.suffixContainer}>
138
- {showClearButton ? (
139
- <InteractiveIcon
140
- iconType='stroked'
141
- onPress={handleClear}
142
- accessibilityLabel={t(
143
- 'components.baseInput.clearInputAriaLabel',
144
- )}
145
- >
146
- <DeleteCircleFill size={20} />
147
- </InteractiveIcon>
148
- ) : (
149
- suffix
150
- )}
141
+ {(suffix || (!hideClearButton && !disabled)) && (
142
+ <View style={styles.suffixContainer}>
143
+ {showClearButton ? (
144
+ <InteractiveIcon
145
+ iconType='stroked'
146
+ onPress={handleClear}
147
+ accessibilityLabel={t(
148
+ 'components.baseInput.clearInputAriaLabel',
149
+ )}
150
+ >
151
+ <DeleteCircleFill size={20} />
152
+ </InteractiveIcon>
153
+ ) : (
154
+ suffix
155
+ )}
156
+ </View>
157
+ )}
158
+ </Pressable>
159
+
160
+ {errorMessage && (
161
+ <View style={styles.errorContainer}>
162
+ <DeleteCircleFill size={16} color='error' />
163
+ <Text style={styles.errorText}>{errorMessage}</Text>
151
164
  </View>
152
165
  )}
153
- </Pressable>
154
-
155
- {errorMessage && (
156
- <View style={styles.errorContainer}>
157
- <DeleteCircleFill size={16} color='error' />
158
- <Text style={styles.errorText}>{errorMessage}</Text>
159
- </View>
160
- )}
161
- </Box>
166
+ </Box>
167
+ </DisabledProvider>
162
168
  );
163
169
  };
164
170
 
@@ -11,6 +11,13 @@ export type BaseInputProps = {
11
11
  * The label text that floats above the input when focused or filled.
12
12
  */
13
13
  label?: string;
14
+ /**
15
+ * Whether the input is disabled.
16
+ * When true, the input is not editable and displays a muted visual style.
17
+ * This differs from `editable={false}` which only prevents interaction.
18
+ * @default false
19
+ */
20
+ disabled?: boolean;
14
21
  /**
15
22
  * Additional styles to apply to the outer wrapper element.
16
23
  */
@@ -0,0 +1,103 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
2
+ import * as MediaImageStories from './MediaImage.stories';
3
+ import { MediaImage } from './MediaImage';
4
+ import { CustomTabs, Tab } from '../../../../.storybook/components';
5
+ import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx';
6
+
7
+
8
+ <Meta title='Communication/MediaImage' of={MediaImageStories} />
9
+
10
+ # MediaImage
11
+
12
+ <CustomTabs>
13
+ <Tab label="Overview">
14
+
15
+ ## Introduction
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.
18
+
19
+ > View in [Figma](https://www.figma.com/design/zSkvGGiqcnhywp2l3HTHxA/1.-Symbol-Library?node-id=6159-1866).
20
+
21
+ ## Anatomy
22
+
23
+ <Canvas of={MediaImageStories.Base} />
24
+
25
+ - **Container**: Sized wrapper with rounded corners and overflow clipping
26
+ - **Image**: Fills the container using React Native's `Image` component
27
+ - **Fallback**: Background placeholder shown on missing or broken source
28
+
29
+ ## Properties
30
+
31
+ ### Overview
32
+
33
+ <Canvas of={MediaImageStories.Base} />
34
+ <Controls of={MediaImageStories.Base} />
35
+
36
+ ### Sizes
37
+
38
+ Eight sizes are available (12, 16, 20, 24, 32, 40, 48, 56). Border radius scales with size.
39
+
40
+ <Canvas of={MediaImageStories.SizeShowcase} />
41
+
42
+ ### Shapes
43
+
44
+ <Canvas of={MediaImageStories.ShapeShowcase} />
45
+
46
+ - **square** (default): Rounded corners that scale with size
47
+ - **circle**: Fully rounded
48
+
49
+ ## Accessibility
50
+
51
+ - The root element uses `accessibilityRole="image"` with `accessibilityLabel` derived from `alt`.
52
+ - The inner `Image` is marked `accessible={false}` to avoid duplicate announcements.
53
+ - Always provide a meaningful `alt` prop so screen readers can announce the image.
54
+
55
+ </Tab>
56
+ <Tab label="Implementation">
57
+
58
+ ## Setup
59
+
60
+ Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs).
61
+
62
+ ### Basic Usage
63
+
64
+ ```tsx
65
+ import { MediaImage } from '@ledgerhq/lumen-ui-rnative';
66
+
67
+ function MyComponent() {
68
+ return <MediaImage src='https://example.com/icon.png' alt='Bitcoin' size={32} />;
69
+ }
70
+ ```
71
+
72
+ ### With Circle Shape
73
+
74
+ ```tsx
75
+ <MediaImage src='https://example.com/icon.png' alt='Ethereum' size={48} shape='circle' />
76
+ ```
77
+
78
+ ### Custom Styling
79
+
80
+ Use the `lx` prop for token-based layout adjustments:
81
+
82
+ ```tsx
83
+ <MediaImage
84
+ src='https://example.com/icon.png'
85
+ alt='Token'
86
+ size={40}
87
+ lx={{ marginRight: 's8' }}
88
+ />
89
+ ```
90
+
91
+ You can also use the `style` prop for escape-hatch styling:
92
+
93
+ ```tsx
94
+ <MediaImage
95
+ src='https://example.com/icon.png'
96
+ alt='Token'
97
+ size={40}
98
+ style={{ marginRight: 8 }}
99
+ />
100
+ ```
101
+
102
+ </Tab>
103
+ </CustomTabs>
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+ import { Box } from '../Utility';
3
+ import { MediaImage } from './MediaImage';
4
+
5
+ const meta = {
6
+ component: MediaImage,
7
+ title: 'Communication/MediaImage',
8
+ parameters: {
9
+ docs: {
10
+ source: {
11
+ language: 'tsx',
12
+ format: true,
13
+ type: 'code',
14
+ },
15
+ },
16
+ },
17
+ } satisfies Meta<typeof MediaImage>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ const exampleSrc = 'https://crypto-icons.ledger.com/ADA.png';
23
+
24
+ export const Base: Story = {
25
+ args: {
26
+ src: exampleSrc,
27
+ alt: 'Cardano',
28
+ size: 40,
29
+ shape: 'square',
30
+ },
31
+ };
32
+
33
+ export const SizeShowcase: Story = {
34
+ render: () => (
35
+ <Box lx={{ flexDirection: 'row', alignItems: 'flex-end', gap: 's16' }}>
36
+ <MediaImage src={exampleSrc} alt='Size 12' size={12} />
37
+ <MediaImage src={exampleSrc} alt='Size 16' size={16} />
38
+ <MediaImage src={exampleSrc} alt='Size 20' size={20} />
39
+ <MediaImage src={exampleSrc} alt='Size 24' size={24} />
40
+ <MediaImage src={exampleSrc} alt='Size 32' size={32} />
41
+ <MediaImage src={exampleSrc} alt='Size 40' size={40} />
42
+ <MediaImage src={exampleSrc} alt='Size 48' size={48} />
43
+ <MediaImage src={exampleSrc} alt='Size 56' size={56} />
44
+ </Box>
45
+ ),
46
+ };
47
+
48
+ export const ShapeShowcase: Story = {
49
+ render: () => (
50
+ <Box lx={{ flexDirection: 'row', alignItems: 'center', gap: 's24' }}>
51
+ <MediaImage src={exampleSrc} alt='Square' size={48} shape='square' />
52
+ <MediaImage src={exampleSrc} alt='Circle' size={48} shape='circle' />
53
+ </Box>
54
+ ),
55
+ };
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
+ import { render, waitFor } from '@testing-library/react-native';
4
+ import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
5
+ import { MediaImage } from './MediaImage';
6
+
7
+ const { sizes, borderRadius } = ledgerLiveThemes.dark;
8
+
9
+ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
10
+ <ThemeProvider themes={ledgerLiveThemes} colorScheme='dark' locale='en'>
11
+ {children}
12
+ </ThemeProvider>
13
+ );
14
+
15
+ describe('MediaImage Component', () => {
16
+ const validSrc = 'https://crypto-icons.ledger.com/ADA.png';
17
+
18
+ it('should render with image when valid src is provided', () => {
19
+ const { getByTestId } = render(
20
+ <TestWrapper>
21
+ <MediaImage src={validSrc} alt='Cardano' />
22
+ </TestWrapper>,
23
+ );
24
+
25
+ const img = getByTestId('media-image-img');
26
+ expect(img.props.source).toEqual({ uri: validSrc });
27
+ });
28
+
29
+ it('should render fallback when no src is provided', () => {
30
+ const { queryByTestId } = render(
31
+ <TestWrapper>
32
+ <MediaImage alt='Empty' />
33
+ </TestWrapper>,
34
+ );
35
+
36
+ expect(queryByTestId('media-image-img')).toBeNull();
37
+ });
38
+
39
+ it('should render fallback when src is empty string', () => {
40
+ const { queryByTestId } = render(
41
+ <TestWrapper>
42
+ <MediaImage src='' alt='Empty' />
43
+ </TestWrapper>,
44
+ );
45
+
46
+ expect(queryByTestId('media-image-img')).toBeNull();
47
+ });
48
+
49
+ it('should render fallback when image fails to load', async () => {
50
+ const { getByTestId, queryByTestId, rerender } = render(
51
+ <TestWrapper>
52
+ <MediaImage src='https://broken-link.com/404.png' alt='Broken' />
53
+ </TestWrapper>,
54
+ );
55
+
56
+ const img = getByTestId('media-image-img');
57
+ img.props.onError();
58
+
59
+ rerender(
60
+ <TestWrapper>
61
+ <MediaImage src='https://broken-link.com/404.png' alt='Broken' />
62
+ </TestWrapper>,
63
+ );
64
+
65
+ await waitFor(() => {
66
+ expect(queryByTestId('media-image-img')).toBeNull();
67
+ });
68
+ });
69
+
70
+ it('should reset error state when src changes', async () => {
71
+ const { getByTestId, rerender } = render(
72
+ <TestWrapper>
73
+ <MediaImage src='https://broken-link.com/404.png' alt='Test' />
74
+ </TestWrapper>,
75
+ );
76
+
77
+ const img = getByTestId('media-image-img');
78
+ img.props.onError();
79
+
80
+ rerender(
81
+ <TestWrapper>
82
+ <MediaImage src={validSrc} alt='Test' />
83
+ </TestWrapper>,
84
+ );
85
+
86
+ await waitFor(() => {
87
+ const newImg = getByTestId('media-image-img');
88
+ expect(newImg.props.source).toEqual({ uri: validSrc });
89
+ });
90
+ });
91
+
92
+ it('should apply default size (48)', () => {
93
+ const { getByTestId } = render(
94
+ <TestWrapper>
95
+ <MediaImage testID='mi' src={validSrc} alt='Test' />
96
+ </TestWrapper>,
97
+ );
98
+
99
+ const root = getByTestId('mi');
100
+ expect(root.props.style.width).toBe(sizes.s48);
101
+ expect(root.props.style.height).toBe(sizes.s48);
102
+ });
103
+
104
+ it('should apply specified size', () => {
105
+ const { getByTestId } = render(
106
+ <TestWrapper>
107
+ <MediaImage testID='mi' src={validSrc} alt='Test' size={24} />
108
+ </TestWrapper>,
109
+ );
110
+
111
+ const root = getByTestId('mi');
112
+ expect(root.props.style.width).toBe(sizes.s24);
113
+ expect(root.props.style.height).toBe(sizes.s24);
114
+ });
115
+
116
+ it('should apply circle shape', () => {
117
+ const { getByTestId } = render(
118
+ <TestWrapper>
119
+ <MediaImage testID='mi' src={validSrc} alt='Test' shape='circle' />
120
+ </TestWrapper>,
121
+ );
122
+
123
+ const root = getByTestId('mi');
124
+ expect(root.props.style.borderRadius).toBe(borderRadius.full);
125
+ });
126
+
127
+ it('should apply square shape with correct border radius', () => {
128
+ const { getByTestId } = render(
129
+ <TestWrapper>
130
+ <MediaImage
131
+ testID='mi'
132
+ src={validSrc}
133
+ alt='Test'
134
+ size={48}
135
+ shape='square'
136
+ />
137
+ </TestWrapper>,
138
+ );
139
+
140
+ const root = getByTestId('mi');
141
+ expect(root.props.style.borderRadius).toBe(borderRadius.md);
142
+ });
143
+
144
+ it('should set accessibility label from alt prop', () => {
145
+ const { getByLabelText } = render(
146
+ <TestWrapper>
147
+ <MediaImage src={validSrc} alt='Cardano icon' />
148
+ </TestWrapper>,
149
+ );
150
+
151
+ expect(getByLabelText('Cardano icon')).toBeTruthy();
152
+ });
153
+
154
+ it('should apply custom styles', () => {
155
+ const { getByTestId } = render(
156
+ <TestWrapper>
157
+ <MediaImage
158
+ testID='mi'
159
+ src={validSrc}
160
+ alt='Test'
161
+ style={{ borderWidth: 2 }}
162
+ />
163
+ </TestWrapper>,
164
+ );
165
+
166
+ const root = getByTestId('mi');
167
+ expect(root.props.style.borderWidth).toBe(2);
168
+ });
169
+
170
+ it('should pass additional props', () => {
171
+ const { getByTestId } = render(
172
+ <TestWrapper>
173
+ <MediaImage testID='custom-id' src={validSrc} alt='Test' />
174
+ </TestWrapper>,
175
+ );
176
+
177
+ expect(getByTestId('custom-id')).toBeTruthy();
178
+ });
179
+ });
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Image, StyleSheet } from 'react-native';
3
+ import { useStyleSheet } from '../../../styles';
4
+ import { Box } from '../Utility';
5
+ import { MediaImageProps, MediaImageSize, MediaImageShape } from './types';
6
+
7
+ type BorderRadiusKey = 'xs' | 'sm' | 'md' | 'lg' | 'full';
8
+
9
+ const borderRadiusMap: Record<MediaImageSize, BorderRadiusKey> = {
10
+ 12: 'xs',
11
+ 16: 'xs',
12
+ 20: 'xs',
13
+ 24: 'sm',
14
+ 32: 'sm',
15
+ 40: 'md',
16
+ 48: 'md',
17
+ 56: 'lg',
18
+ };
19
+
20
+ export const mediaImageDotSizeMap: Record<MediaImageSize, number> = {
21
+ 12: 8,
22
+ 16: 8,
23
+ 20: 8,
24
+ 24: 10,
25
+ 32: 12,
26
+ 40: 16,
27
+ 48: 20,
28
+ 56: 24,
29
+ } as const;
30
+
31
+ const useStyles = ({
32
+ size,
33
+ shape,
34
+ }: {
35
+ size: MediaImageSize;
36
+ shape: MediaImageShape;
37
+ }) => {
38
+ return useStyleSheet(
39
+ (t) => {
40
+ const sizeValue = t.sizes[`s${size}` as keyof typeof t.sizes] as number;
41
+ const radius =
42
+ shape === 'circle'
43
+ ? t.borderRadius.full
44
+ : t.borderRadius[borderRadiusMap[size]];
45
+
46
+ return {
47
+ root: {
48
+ width: sizeValue,
49
+ height: sizeValue,
50
+ borderRadius: radius,
51
+ overflow: 'hidden' as const,
52
+ alignItems: 'center' as const,
53
+ justifyContent: 'center' as const,
54
+ backgroundColor: t.colors.bg.mutedTransparent,
55
+ },
56
+ image: {
57
+ width: '100%' as const,
58
+ height: '100%' as const,
59
+ },
60
+ };
61
+ },
62
+ [size, shape],
63
+ );
64
+ };
65
+
66
+ /**
67
+ * A generic media image component that displays an image with optional shape variants.
68
+ * Supports square and circular appearances with consistent sizing.
69
+ *
70
+ * When the image fails to load or no src is provided, displays a background placeholder.
71
+ *
72
+ * @example
73
+ * import { MediaImage } from '@ledgerhq/lumen-ui-rnative';
74
+ *
75
+ * <MediaImage src="https://example.com/icon.png" alt="Bitcoin" size={32} />
76
+ */
77
+ export const MediaImage = ({
78
+ src,
79
+ alt,
80
+ size = 48,
81
+ shape = 'square',
82
+ lx = {},
83
+ style,
84
+ ref,
85
+ ...props
86
+ }: MediaImageProps) => {
87
+ const [error, setError] = useState(false);
88
+ const shouldFallback = !src || error;
89
+ const styles = useStyles({ size, shape });
90
+
91
+ useEffect(() => {
92
+ setError(false);
93
+ }, [src]);
94
+
95
+ return (
96
+ <Box
97
+ ref={ref}
98
+ lx={lx}
99
+ style={StyleSheet.flatten([styles.root, style])}
100
+ accessibilityRole='image'
101
+ accessibilityLabel={alt}
102
+ {...props}
103
+ >
104
+ {!shouldFallback && (
105
+ <Image
106
+ source={{ uri: src }}
107
+ style={styles.image}
108
+ accessible={false}
109
+ onError={() => setError(true)}
110
+ testID='media-image-img'
111
+ />
112
+ )}
113
+ </Box>
114
+ );
115
+ };
116
+
117
+ MediaImage.displayName = 'MediaImage';
@@ -0,0 +1,2 @@
1
+ export { MediaImage, mediaImageDotSizeMap } from './MediaImage';
2
+ export * from './types';
@@ -0,0 +1,27 @@
1
+ import { StyledViewProps } from '../../../styles';
2
+
3
+ export type MediaImageSize = 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56;
4
+
5
+ export type MediaImageShape = 'square' | 'circle';
6
+
7
+ export type MediaImageProps = {
8
+ /**
9
+ * Image source URL. When undefined or on load error, displays a fallback.
10
+ * @optional
11
+ */
12
+ src?: string;
13
+ /**
14
+ * The shape of the media image.
15
+ * @default 'square'
16
+ */
17
+ shape?: MediaImageShape;
18
+ /**
19
+ * The size of the media image in pixels.
20
+ * @default 48
21
+ */
22
+ size?: MediaImageSize;
23
+ /**
24
+ * Alternative text for the image, used for accessibility.
25
+ */
26
+ alt?: string;
27
+ } & Omit<StyledViewProps, 'children'>;
@@ -57,10 +57,14 @@ Use `onClear` to extend the default clear behavior with custom logic.
57
57
 
58
58
  ### Disabled State
59
59
 
60
- The search input can be disabled using the `editable` prop set to `false`.
60
+ The search input can be fully disabled using the `disabled` prop, which prevents interaction and applies a muted visual style.
61
61
 
62
62
  <Canvas of={SearchInputStories.DisabledSearchInput} />
63
63
 
64
+ ### Read-Only State
65
+
66
+ Alternatively, use `editable={false}` to prevent editing without applying the muted visual style. This is useful for displaying non-editable values that should still look like regular inputs.
67
+
64
68
  ### Error State
65
69
 
66
70
  The search component supports error handling through `errorMessage` which displays an error message below the input with error styling including a red border and text color.
@@ -273,7 +277,15 @@ Use the `keyboardType` prop to show the appropriate keyboard:
273
277
 
274
278
  ### Disabled State
275
279
 
276
- Use the `editable` prop to disable the search input:
280
+ Use the `disabled` prop to disable the search input with a muted visual style:
281
+
282
+ ```tsx
283
+ <SearchInput placeholder='Search' value='Current search' disabled />
284
+ ```
285
+
286
+ ### Read-Only State
287
+
288
+ Use the `editable` prop to make the search input non-editable without the muted visual style:
277
289
 
278
290
  ```tsx
279
291
  <SearchInput placeholder='Search' value='Current search' editable={false} />