@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.
- package/dist/module/lib/Components/AddressInput/AddressInput.js +21 -10
- package/dist/module/lib/Components/AddressInput/AddressInput.js.map +1 -1
- package/dist/module/lib/Components/AddressInput/AddressInput.mdx +18 -2
- package/dist/module/lib/Components/AddressInput/AddressInput.stories.js +1 -23
- package/dist/module/lib/Components/AddressInput/AddressInput.stories.js.map +1 -1
- package/dist/module/lib/Components/AmountInput/AmountInput.js +7 -6
- package/dist/module/lib/Components/AmountInput/AmountInput.js.map +1 -1
- package/dist/module/lib/Components/AmountInput/AmountInput.mdx +5 -1
- package/dist/module/lib/Components/AmountInput/AmountInput.stories.js +1 -36
- package/dist/module/lib/Components/AmountInput/AmountInput.stories.js.map +1 -1
- package/dist/module/lib/Components/BaseInput/BaseInput.js +54 -48
- package/dist/module/lib/Components/BaseInput/BaseInput.js.map +1 -1
- package/dist/module/lib/Components/MediaImage/MediaImage.js +102 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.js.map +1 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.mdx +103 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.stories.js +91 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.stories.js.map +1 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.test.js +204 -0
- package/dist/module/lib/Components/MediaImage/MediaImage.test.js.map +1 -0
- package/dist/module/lib/Components/MediaImage/index.js +5 -0
- package/dist/module/lib/Components/MediaImage/index.js.map +1 -0
- package/dist/module/lib/Components/MediaImage/types.js +4 -0
- package/dist/module/lib/Components/MediaImage/types.js.map +1 -0
- package/dist/module/lib/Components/SearchInput/SearchInput.js +11 -2
- package/dist/module/lib/Components/SearchInput/SearchInput.js.map +1 -1
- package/dist/module/lib/Components/SearchInput/SearchInput.mdx +14 -2
- package/dist/module/lib/Components/SearchInput/SearchInput.stories.js +1 -19
- package/dist/module/lib/Components/SearchInput/SearchInput.stories.js.map +1 -1
- package/dist/module/lib/Components/TextInput/TextInput.mdx +14 -2
- package/dist/module/lib/Components/TextInput/TextInput.stories.js +1 -28
- package/dist/module/lib/Components/TextInput/TextInput.stories.js.map +1 -1
- package/dist/module/lib/Components/index.js +1 -0
- package/dist/module/lib/Components/index.js.map +1 -1
- package/dist/typescript/src/lib/Components/AddressInput/AddressInput.d.ts +1 -1
- package/dist/typescript/src/lib/Components/AddressInput/AddressInput.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts +1 -1
- package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/AmountInput/types.d.ts +7 -0
- package/dist/typescript/src/lib/Components/AmountInput/types.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/BaseInput/BaseInput.d.ts +1 -1
- package/dist/typescript/src/lib/Components/BaseInput/BaseInput.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/BaseInput/types.d.ts +7 -0
- package/dist/typescript/src/lib/Components/BaseInput/types.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts +18 -0
- package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/MediaImage/index.d.ts +3 -0
- package/dist/typescript/src/lib/Components/MediaImage/index.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/MediaImage/types.d.ts +25 -0
- package/dist/typescript/src/lib/Components/MediaImage/types.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/SearchInput/SearchInput.d.ts +1 -1
- package/dist/typescript/src/lib/Components/SearchInput/SearchInput.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/index.d.ts +1 -0
- package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/lib/Components/AddressInput/AddressInput.mdx +18 -2
- package/src/lib/Components/AddressInput/AddressInput.stories.tsx +1 -23
- package/src/lib/Components/AddressInput/AddressInput.tsx +15 -7
- package/src/lib/Components/AmountInput/AmountInput.mdx +5 -1
- package/src/lib/Components/AmountInput/AmountInput.stories.tsx +1 -36
- package/src/lib/Components/AmountInput/AmountInput.tsx +4 -3
- package/src/lib/Components/AmountInput/types.ts +7 -0
- package/src/lib/Components/BaseInput/BaseInput.tsx +66 -60
- package/src/lib/Components/BaseInput/types.ts +7 -0
- package/src/lib/Components/MediaImage/MediaImage.mdx +103 -0
- package/src/lib/Components/MediaImage/MediaImage.stories.tsx +55 -0
- package/src/lib/Components/MediaImage/MediaImage.test.tsx +179 -0
- package/src/lib/Components/MediaImage/MediaImage.tsx +117 -0
- package/src/lib/Components/MediaImage/index.ts +2 -0
- package/src/lib/Components/MediaImage/types.ts +27 -0
- package/src/lib/Components/SearchInput/SearchInput.mdx +14 -2
- package/src/lib/Components/SearchInput/SearchInput.stories.tsx +1 -19
- package/src/lib/Components/SearchInput/SearchInput.tsx +8 -1
- package/src/lib/Components/TextInput/TextInput.mdx +14 -2
- package/src/lib/Components/TextInput/TextInput.stories.tsx +1 -28
- package/src/lib/Components/index.ts +1 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
</
|
|
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,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 `
|
|
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 `
|
|
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} />
|