@hero-design/rn-work-uikit 1.1.0-alpha.0 → 1.2.0-alpha.0
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/.cursorrules +57 -0
- package/CHANGELOG.md +8 -3
- package/DEVELOPMENT.md +118 -0
- package/THEME_OVERRIDE.md +52 -0
- package/eslint.config.js +20 -0
- package/lib/index.js +1000 -4
- package/locales/en_AU.js +10 -0
- package/locales/en_AU.mjs +8 -0
- package/locales/en_CA.js +10 -0
- package/locales/en_CA.mjs +8 -0
- package/locales/index.js +11 -0
- package/locales/index.mjs +9 -0
- package/locales/types.js +2 -0
- package/locales/types.mjs +1 -0
- package/package.json +8 -4
- package/rollup.config.mjs +18 -2
- package/src/__tests__/__snapshots__/index.spec.tsx.snap +91 -116
- package/src/__tests__/index.spec.tsx +15 -0
- package/src/__tests__/theme-export-override.spec.ts +96 -0
- package/src/components/TextInput/ErrorOrHelpText.tsx +58 -0
- package/src/components/TextInput/FloatingLabel.tsx +120 -0
- package/src/components/TextInput/InputComponent.tsx +61 -0
- package/src/components/TextInput/InputRow.tsx +103 -0
- package/src/components/TextInput/MaxLengthMessage.tsx +66 -0
- package/src/components/TextInput/PrefixComponent.tsx +77 -0
- package/src/components/TextInput/StyledTextInput.tsx +134 -0
- package/src/components/TextInput/SuffixComponent.tsx +73 -0
- package/src/components/TextInput/__tests__/ErrorOrHelpText.spec.tsx +20 -0
- package/src/components/TextInput/__tests__/FloatingLabel.spec.tsx +203 -0
- package/src/components/TextInput/__tests__/InputComponent.spec.tsx +39 -0
- package/src/components/TextInput/__tests__/InputRow.spec.tsx +275 -0
- package/src/components/TextInput/__tests__/MaxLengthMessage.spec.tsx +17 -0
- package/src/components/TextInput/__tests__/PrefixComponent.spec.tsx +14 -0
- package/src/components/TextInput/__tests__/StyledTextInput.spec.tsx +114 -0
- package/src/components/TextInput/__tests__/SuffixComponent.spec.tsx +20 -0
- package/src/components/TextInput/__tests__/__snapshots__/StyledTextInput.spec.tsx.snap +571 -0
- package/src/components/TextInput/__tests__/__snapshots__/index.spec.tsx.snap +5671 -0
- package/src/components/TextInput/__tests__/getState.spec.tsx +89 -0
- package/src/components/TextInput/__tests__/index.spec.tsx +699 -0
- package/src/components/TextInput/constants.ts +1 -0
- package/src/components/TextInput/index.tsx +327 -0
- package/src/components/TextInput/types.ts +95 -0
- package/src/emotion.d.ts +15 -0
- package/src/index.ts +16 -1
- package/src/jest.d.ts +24 -0
- package/src/theme/ThemeProvider.ts +20 -0
- package/src/theme/ThemeSwitcher.tsx +76 -0
- package/src/theme/__tests__/ThemeProvider.spec.tsx +32 -0
- package/src/theme/__tests__/__snapshots__/index.spec.ts.snap +1851 -0
- package/src/theme/__tests__/index.spec.ts +7 -0
- package/src/theme/components/textInput.ts +92 -0
- package/src/theme/getTheme.ts +32 -0
- package/src/theme/index.ts +17 -0
- package/src/utils/__tests__/helpers.spec.ts +92 -0
- package/src/utils/helpers.ts +113 -0
- package/testUtils/renderWithTheme.tsx +6 -3
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing } from 'react-native';
|
|
3
|
+
import { StyledFloatingLabelContainer, StyledLabel } from './StyledTextInput';
|
|
4
|
+
import type { State } from './StyledTextInput';
|
|
5
|
+
import type { TextInputVariant } from './types';
|
|
6
|
+
import { useTheme } from '../../theme';
|
|
7
|
+
import { LABEL_ANIMATION_DURATION } from './constants';
|
|
8
|
+
|
|
9
|
+
export interface FloatingLabelProps {
|
|
10
|
+
/** The text to display in the label */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Input variant that affects animation positioning */
|
|
13
|
+
variant: TextInputVariant;
|
|
14
|
+
/** Current input state for styling */
|
|
15
|
+
state: State;
|
|
16
|
+
/** Whether the input is focused */
|
|
17
|
+
isFocused: boolean;
|
|
18
|
+
/** Whether field is required (affects optional text) */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Accessibility ID for screen readers */
|
|
21
|
+
accessibilityLabelledBy?: string;
|
|
22
|
+
/** Whether the input value is empty */
|
|
23
|
+
isEmptyValue: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* FloatingLabel Component
|
|
28
|
+
*
|
|
29
|
+
* Handles the animated floating label behavior for TextInput.
|
|
30
|
+
* The label starts positioned over the input and animates to a smaller size above the input
|
|
31
|
+
* when the field is focused or has content.
|
|
32
|
+
*
|
|
33
|
+
* Key Features:
|
|
34
|
+
* - Smooth scale and translate animations using React Native Animated API
|
|
35
|
+
* - Responds to focus state and content presence
|
|
36
|
+
* - Automatically appends "(Optional)" text for non-required fields
|
|
37
|
+
* - Supports different variants (text/textarea) with appropriate positioning
|
|
38
|
+
* - Maintains accessibility features with proper IDs and test IDs
|
|
39
|
+
* - Self-contained animation management
|
|
40
|
+
*
|
|
41
|
+
* Animation Behavior:
|
|
42
|
+
* - Scale: 1.5 → 1 (label shrinks when floating)
|
|
43
|
+
* - TranslateY: Variable based on variant → 0 (moves up)
|
|
44
|
+
* - Uses bezier easing for smooth transitions
|
|
45
|
+
*
|
|
46
|
+
* @param props - The component props (see FloatingLabelProps for details)
|
|
47
|
+
*/
|
|
48
|
+
const FloatingLabel: React.FC<FloatingLabelProps> = ({
|
|
49
|
+
label,
|
|
50
|
+
variant,
|
|
51
|
+
state,
|
|
52
|
+
isFocused,
|
|
53
|
+
required,
|
|
54
|
+
accessibilityLabelledBy,
|
|
55
|
+
isEmptyValue,
|
|
56
|
+
}) => {
|
|
57
|
+
const theme = useTheme();
|
|
58
|
+
const shouldFloat = isFocused || !isEmptyValue;
|
|
59
|
+
const focusAnimation = useRef(
|
|
60
|
+
new Animated.Value(shouldFloat ? 1 : 0)
|
|
61
|
+
).current;
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
Animated.timing(focusAnimation, {
|
|
65
|
+
toValue: shouldFloat ? 1 : 0,
|
|
66
|
+
duration: LABEL_ANIMATION_DURATION,
|
|
67
|
+
easing: Easing.bezier(0.4, 0, 0.2, 1),
|
|
68
|
+
useNativeDriver: true,
|
|
69
|
+
}).start();
|
|
70
|
+
}, [shouldFloat, focusAnimation]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<StyledFloatingLabelContainer
|
|
74
|
+
themeVariant={variant}
|
|
75
|
+
style={[
|
|
76
|
+
{
|
|
77
|
+
transformOrigin: 'left center',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
transform: [
|
|
81
|
+
{
|
|
82
|
+
translateY: focusAnimation.interpolate({
|
|
83
|
+
inputRange: [0, 1],
|
|
84
|
+
outputRange: [
|
|
85
|
+
variant !== 'textarea' ? 12 : theme.space.small,
|
|
86
|
+
0,
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
scale: focusAnimation.interpolate({
|
|
92
|
+
inputRange: [0, 1],
|
|
93
|
+
outputRange: [1.333, 1],
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
]}
|
|
99
|
+
>
|
|
100
|
+
<Animated.View>
|
|
101
|
+
<StyledLabel
|
|
102
|
+
nativeID={accessibilityLabelledBy}
|
|
103
|
+
testID="input-label"
|
|
104
|
+
themeState={state}
|
|
105
|
+
numberOfLines={1}
|
|
106
|
+
style={{
|
|
107
|
+
backgroundColor: 'transparent',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{label}
|
|
111
|
+
{!required && ' (Optional)'}
|
|
112
|
+
</StyledLabel>
|
|
113
|
+
</Animated.View>
|
|
114
|
+
</StyledFloatingLabelContainer>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
FloatingLabel.displayName = 'FloatingLabel';
|
|
119
|
+
|
|
120
|
+
export default React.memo(FloatingLabel);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { TextInput as RNTextInput } from 'react-native';
|
|
3
|
+
import type { TextInputProps as NativeTextInputProps } from 'react-native';
|
|
4
|
+
import { StyledTextInput } from './StyledTextInput';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
import type { TextInputVariant } from './types';
|
|
7
|
+
|
|
8
|
+
export interface InputComponentProps {
|
|
9
|
+
/** Input type ('text' or 'textarea') */
|
|
10
|
+
variant: TextInputVariant;
|
|
11
|
+
/** All props passed to the underlying TextInput */
|
|
12
|
+
nativeInputProps: NativeTextInputProps;
|
|
13
|
+
/** Optional custom input renderer function */
|
|
14
|
+
renderInputValue?: (inputProps: NativeTextInputProps) => React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* InputComponent
|
|
19
|
+
*
|
|
20
|
+
* Renders the actual text input element. Supports custom input rendering or uses the default StyledTextInput.
|
|
21
|
+
*
|
|
22
|
+
* Key Features:
|
|
23
|
+
* - Flexible rendering: supports custom input renderers via renderInputValue prop
|
|
24
|
+
* - Variant support: handles both 'text' and 'textarea' input types
|
|
25
|
+
* - Multiline handling: automatically enables multiline for textarea variant
|
|
26
|
+
* - Theme integration: applies theme colors for placeholder text
|
|
27
|
+
* - Ref forwarding: properly forwards refs to the underlying TextInput
|
|
28
|
+
*
|
|
29
|
+
* Rendering Logic:
|
|
30
|
+
* 1. If renderInputValue provided: Use custom renderer
|
|
31
|
+
* 2. Otherwise: Use default StyledTextInput with theme and variant support
|
|
32
|
+
*
|
|
33
|
+
* Multiline Behavior:
|
|
34
|
+
* - 'textarea' variant: Always multiline
|
|
35
|
+
* - 'text' variant: Single line unless explicitly set via nativeInputProps
|
|
36
|
+
*
|
|
37
|
+
* @param props - The component props (see InputComponentProps interface for details)
|
|
38
|
+
*/
|
|
39
|
+
const InputComponent = React.forwardRef<RNTextInput, InputComponentProps>(
|
|
40
|
+
({ variant, nativeInputProps, renderInputValue }, ref) => {
|
|
41
|
+
const theme = useTheme();
|
|
42
|
+
|
|
43
|
+
return renderInputValue ? (
|
|
44
|
+
// Custom input renderer provided
|
|
45
|
+
<>{renderInputValue(nativeInputProps)}</>
|
|
46
|
+
) : (
|
|
47
|
+
// Default styled input with theme and variant support
|
|
48
|
+
<StyledTextInput
|
|
49
|
+
{...nativeInputProps}
|
|
50
|
+
themeVariant={variant}
|
|
51
|
+
multiline={variant === 'textarea' || nativeInputProps.multiline}
|
|
52
|
+
ref={ref}
|
|
53
|
+
placeholderTextColor={theme.__hd__.textInput.colors.placeholder}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
InputComponent.displayName = 'InputComponent';
|
|
60
|
+
|
|
61
|
+
export default React.memo(InputComponent);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, TextInput as RNTextInput } from 'react-native';
|
|
3
|
+
import type { TextInputProps as NativeTextInputProps } from 'react-native';
|
|
4
|
+
import { IconName } from '@hero-design/rn';
|
|
5
|
+
import { StyledInputRow } from './StyledTextInput';
|
|
6
|
+
import PrefixComponent from './PrefixComponent';
|
|
7
|
+
import InputComponent from './InputComponent';
|
|
8
|
+
import type { State } from './StyledTextInput';
|
|
9
|
+
import type { TextInputVariant } from './types';
|
|
10
|
+
import { LABEL_ANIMATION_DURATION } from './constants';
|
|
11
|
+
|
|
12
|
+
interface InputRowProps {
|
|
13
|
+
/** Current state of the input (focused, error, disabled, etc.) */
|
|
14
|
+
state: State;
|
|
15
|
+
/** Whether the input is focused */
|
|
16
|
+
isFocused: boolean;
|
|
17
|
+
/** Optional prefix icon or component */
|
|
18
|
+
prefix?: IconName | React.ReactElement;
|
|
19
|
+
/** Input variant - 'text' or 'textarea' */
|
|
20
|
+
variant: TextInputVariant;
|
|
21
|
+
/** Native TextInput props passed to the input component */
|
|
22
|
+
nativeInputProps: NativeTextInputProps;
|
|
23
|
+
/** Optional custom render function for input value */
|
|
24
|
+
renderInputValue?: (inputProps: NativeTextInputProps) => React.ReactNode;
|
|
25
|
+
/** Whether the input value is empty */
|
|
26
|
+
isEmptyValue: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* InputRow Component
|
|
31
|
+
*
|
|
32
|
+
* Layout Structure:
|
|
33
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
34
|
+
* │ StyledInputRow │
|
|
35
|
+
* │ ┌──────────┐ ┌─────────────────────────────────────┐ │
|
|
36
|
+
* │ │ Prefix │ │ Input Field │ │
|
|
37
|
+
* │ │ (Icon or │ │ (TextInput or Custom) │ │
|
|
38
|
+
* │ │ Custom) │ │ flex: 1 │ │
|
|
39
|
+
* │ └──────────┘ └─────────────────────────────────────┘ │
|
|
40
|
+
* └─────────────────────────────────────────────────────────┘
|
|
41
|
+
*
|
|
42
|
+
* Rendering Behavior:
|
|
43
|
+
* - Components are always rendered but hidden with opacity in idle state
|
|
44
|
+
* - Uses accessibility props to hide components from screen readers when not visible
|
|
45
|
+
*
|
|
46
|
+
* Renders the main input row containing:
|
|
47
|
+
* 1. Conditionally visible prefix component (icon or custom element)
|
|
48
|
+
* 2. Conditionally visible input component (TextInput or custom rendered input)
|
|
49
|
+
*/
|
|
50
|
+
const InputRow = React.forwardRef<RNTextInput, InputRowProps>(
|
|
51
|
+
(
|
|
52
|
+
{
|
|
53
|
+
state,
|
|
54
|
+
isFocused,
|
|
55
|
+
prefix,
|
|
56
|
+
variant,
|
|
57
|
+
nativeInputProps,
|
|
58
|
+
renderInputValue,
|
|
59
|
+
isEmptyValue,
|
|
60
|
+
},
|
|
61
|
+
ref
|
|
62
|
+
) => {
|
|
63
|
+
const shouldShow = isFocused || !isEmptyValue;
|
|
64
|
+
const opacity = useRef(new Animated.Value(shouldShow ? 1 : 0)).current;
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
Animated.timing(opacity, {
|
|
68
|
+
toValue: shouldShow ? 1 : 0,
|
|
69
|
+
duration: LABEL_ANIMATION_DURATION,
|
|
70
|
+
useNativeDriver: true,
|
|
71
|
+
}).start();
|
|
72
|
+
}, [shouldShow, opacity]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<StyledInputRow>
|
|
76
|
+
{/* Prefix animation */}
|
|
77
|
+
<Animated.View style={{ opacity }}>
|
|
78
|
+
<PrefixComponent
|
|
79
|
+
state={state}
|
|
80
|
+
prefix={prefix}
|
|
81
|
+
accessibilityElementsHidden={!shouldShow}
|
|
82
|
+
/>
|
|
83
|
+
</Animated.View>
|
|
84
|
+
{/* Input animation */}
|
|
85
|
+
<Animated.View
|
|
86
|
+
style={{ flex: 1, opacity }}
|
|
87
|
+
testID="input-row-input-wrapper"
|
|
88
|
+
accessibilityLabel="Text input field"
|
|
89
|
+
accessibilityElementsHidden={!shouldShow}
|
|
90
|
+
>
|
|
91
|
+
<InputComponent
|
|
92
|
+
variant={variant}
|
|
93
|
+
nativeInputProps={nativeInputProps}
|
|
94
|
+
renderInputValue={renderInputValue}
|
|
95
|
+
ref={ref}
|
|
96
|
+
/>
|
|
97
|
+
</Animated.View>
|
|
98
|
+
</StyledInputRow>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
export default InputRow;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyledCharacterCount } from './StyledTextInput';
|
|
3
|
+
import type { State } from './StyledTextInput';
|
|
4
|
+
|
|
5
|
+
export interface MaxLengthMessageProps {
|
|
6
|
+
/** Maximum allowed characters (undefined = no limit) */
|
|
7
|
+
maxLength?: number;
|
|
8
|
+
/** Current input state for styling */
|
|
9
|
+
state: State;
|
|
10
|
+
/** Current number of characters entered */
|
|
11
|
+
currentLength: number;
|
|
12
|
+
/** Whether to hide the counter display */
|
|
13
|
+
hideCharacterCount: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MaxLengthMessage Component
|
|
18
|
+
*
|
|
19
|
+
* Displays character count information for TextInput fields with maximum length restrictions.
|
|
20
|
+
* Shows current/max character count with state-aware styling.
|
|
21
|
+
*
|
|
22
|
+
* Key Features:
|
|
23
|
+
* - Character count display: Shows "currentLength/maxLength" format
|
|
24
|
+
* - Conditional rendering: Only shows when maxLength is set and not hidden
|
|
25
|
+
* - State-aware styling: Colors change based on input state (error, disabled, etc.)
|
|
26
|
+
* - Real-time updates: Updates as user types to show current progress
|
|
27
|
+
* - Hide capability: Can be hidden via hideCharacterCount prop
|
|
28
|
+
*
|
|
29
|
+
* Display Logic:
|
|
30
|
+
* - Shows only when: maxLength is defined AND hideCharacterCount is false
|
|
31
|
+
* - Format: "currentLength/maxLength" (e.g., "25/100")
|
|
32
|
+
* - Hidden when: maxLength is undefined OR hideCharacterCount is true
|
|
33
|
+
*
|
|
34
|
+
* State-based Styling:
|
|
35
|
+
* - Uses themeState to apply appropriate colors from theme
|
|
36
|
+
* - Error state: Shows error color to indicate validation issues
|
|
37
|
+
* - Disabled state: Shows muted color for disabled inputs
|
|
38
|
+
* - Normal states: Shows standard text colors
|
|
39
|
+
*
|
|
40
|
+
* Usage Example:
|
|
41
|
+
* - Input with 25 characters and 100 max: displays "25/100"
|
|
42
|
+
* - Input approaching limit: color may change based on theme
|
|
43
|
+
* - Input over limit: error state styling applied
|
|
44
|
+
*
|
|
45
|
+
* @param props - The component props (see MaxLengthMessageProps interface for details)
|
|
46
|
+
*/
|
|
47
|
+
const MaxLengthMessage: React.FC<MaxLengthMessageProps> = ({
|
|
48
|
+
maxLength,
|
|
49
|
+
state,
|
|
50
|
+
currentLength,
|
|
51
|
+
hideCharacterCount,
|
|
52
|
+
}) => {
|
|
53
|
+
if (!maxLength || hideCharacterCount) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<StyledCharacterCount themeState={state}>
|
|
59
|
+
{currentLength}/{maxLength}
|
|
60
|
+
</StyledCharacterCount>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
MaxLengthMessage.displayName = 'MaxLengthMessage';
|
|
65
|
+
|
|
66
|
+
export default React.memo(MaxLengthMessage);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Icon, IconName } from '@hero-design/rn';
|
|
3
|
+
import { View, ViewProps } from 'react-native';
|
|
4
|
+
import type { State } from './StyledTextInput';
|
|
5
|
+
|
|
6
|
+
export interface PrefixComponentProps extends ViewProps {
|
|
7
|
+
/** Current input state for styling decisions */
|
|
8
|
+
state: State;
|
|
9
|
+
/** Icon name string or custom React element to display */
|
|
10
|
+
prefix?: IconName | React.ReactElement;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PrefixComponent
|
|
15
|
+
*
|
|
16
|
+
* Renders prefix content (icons or custom elements) on the left side of the TextInput,
|
|
17
|
+
* before the user's input cursor.
|
|
18
|
+
*
|
|
19
|
+
* Key Features:
|
|
20
|
+
* - Icon rendering: supports string icon names from the design system
|
|
21
|
+
* - Custom element support: can render any React element as prefix
|
|
22
|
+
* - State-aware styling: adjusts icon intent based on input state
|
|
23
|
+
* - Consistent sizing: uses 'xsmall' size for visual balance
|
|
24
|
+
* - Proper test IDs for automated testing
|
|
25
|
+
*
|
|
26
|
+
* Rendering Logic:
|
|
27
|
+
* 1. String icon name: Renders Icon component with state-based intent
|
|
28
|
+
* 2. Custom element: Renders the provided React element as-is
|
|
29
|
+
* 3. No prefix: Renders null
|
|
30
|
+
*
|
|
31
|
+
* Icon Intent Logic:
|
|
32
|
+
* - Disabled state: Uses 'disabled-text' intent for muted appearance
|
|
33
|
+
* - All other states: Uses 'text' intent for normal appearance
|
|
34
|
+
*
|
|
35
|
+
* Visual Positioning:
|
|
36
|
+
* - Positioned before the text input cursor
|
|
37
|
+
* - Uses smaller 'xsmall' size to avoid overwhelming the input
|
|
38
|
+
* - Maintains proper spacing via parent container
|
|
39
|
+
*
|
|
40
|
+
* @param props - The component props (see PrefixComponentProps interface for details)
|
|
41
|
+
*/
|
|
42
|
+
const PrefixComponent: React.FC<PrefixComponentProps> = ({
|
|
43
|
+
state,
|
|
44
|
+
prefix,
|
|
45
|
+
...rest
|
|
46
|
+
}) => {
|
|
47
|
+
const actualPrefix = typeof prefix === 'string' ? prefix : '';
|
|
48
|
+
|
|
49
|
+
if (typeof prefix === 'string' && actualPrefix) {
|
|
50
|
+
return (
|
|
51
|
+
<View testID="input-prefix" {...rest}>
|
|
52
|
+
<Icon
|
|
53
|
+
intent={state === 'disabled' ? 'disabled-text' : 'text'}
|
|
54
|
+
icon={actualPrefix}
|
|
55
|
+
size="xsmall"
|
|
56
|
+
testID="input-prefix-icon"
|
|
57
|
+
accessibilityLabel={`Prefix icon: ${actualPrefix}`}
|
|
58
|
+
accessibilityRole="image"
|
|
59
|
+
/>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (React.isValidElement(prefix)) {
|
|
65
|
+
return (
|
|
66
|
+
<View testID="input-prefix" {...rest}>
|
|
67
|
+
{prefix}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
PrefixComponent.displayName = 'PrefixComponent';
|
|
76
|
+
|
|
77
|
+
export default React.memo(PrefixComponent);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { TextInput, View, StyleSheet, Animated, Pressable } from 'react-native';
|
|
2
|
+
import { Typography, styled } from '@hero-design/rn';
|
|
3
|
+
|
|
4
|
+
export type State = 'default' | 'filled' | 'disabled' | 'readonly' | 'error';
|
|
5
|
+
|
|
6
|
+
type Variant = 'text' | 'textarea';
|
|
7
|
+
|
|
8
|
+
const StyledContainer = styled(Pressable)(({ theme }) => ({
|
|
9
|
+
width: '100%',
|
|
10
|
+
flexDirection: 'row',
|
|
11
|
+
paddingHorizontal: theme.__hd__.textInput.space.containerPadding,
|
|
12
|
+
minHeight: theme.__hd__.textInput.sizes.containerMinHeight,
|
|
13
|
+
marginTop: theme.__hd__.textInput.space.containerMarginTop,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const StyledFloatingLabelContainer = styled(Animated.View)<{
|
|
17
|
+
themeVariant: Variant;
|
|
18
|
+
}>(({ themeVariant }) => ({
|
|
19
|
+
flexDirection: 'row',
|
|
20
|
+
flex: 1,
|
|
21
|
+
alignItems: themeVariant === 'text' ? 'center' : 'flex-start',
|
|
22
|
+
backgroundColor: 'transparent',
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const StyledLabel = styled(Typography.Caption)<{
|
|
26
|
+
themeState: State;
|
|
27
|
+
}>(({ theme, themeState }) => ({
|
|
28
|
+
color: theme.__hd__.textInput.colors.labels[themeState],
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const StyledErrorRow = styled(View)(({ theme }) => ({
|
|
32
|
+
marginRight: theme.__hd__.textInput.space.errorContainerMarginRight,
|
|
33
|
+
flexDirection: 'row',
|
|
34
|
+
alignItems: 'center',
|
|
35
|
+
flex: 1,
|
|
36
|
+
flexGrow: 4,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const StyledError = styled(Typography.Caption)(({ theme }) => ({
|
|
40
|
+
color: theme.__hd__.textInput.colors.error,
|
|
41
|
+
marginLeft: theme.__hd__.textInput.space.errorMarginLeft,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const StyledCharacterCount = styled(Typography.Caption)<{
|
|
45
|
+
themeState: State;
|
|
46
|
+
}>(({ theme, themeState }) => ({
|
|
47
|
+
color: theme.__hd__.textInput.colors.maxLengthLabels[themeState],
|
|
48
|
+
flex: 1,
|
|
49
|
+
flexGrow: 1,
|
|
50
|
+
textAlign: 'right',
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const StyledHelperText = Typography.Caption;
|
|
54
|
+
const StyledTextInput = styled(TextInput)<{ themeVariant: Variant }>(
|
|
55
|
+
({ theme, themeVariant }) => ({
|
|
56
|
+
textAlignVertical: themeVariant === 'text' ? 'center' : 'top',
|
|
57
|
+
fontSize: theme.__hd__.textInput.fontSizes.text,
|
|
58
|
+
flexGrow: 2,
|
|
59
|
+
paddingVertical: 0,
|
|
60
|
+
maxHeight: theme.__hd__.textInput.sizes.textInputMaxHeight,
|
|
61
|
+
minHeight: theme.__hd__.textInput.sizes.textInputMinHeight,
|
|
62
|
+
height:
|
|
63
|
+
themeVariant === 'text'
|
|
64
|
+
? undefined
|
|
65
|
+
: theme.__hd__.textInput.sizes.textareaHeight,
|
|
66
|
+
fontFamily: theme.__hd__.textInput.fonts.text,
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const StyledBorder = styled(View)<{
|
|
71
|
+
themeState: State;
|
|
72
|
+
themeFocused: boolean;
|
|
73
|
+
}>(({ theme, themeFocused, themeState }) => ({
|
|
74
|
+
...StyleSheet.absoluteFillObject,
|
|
75
|
+
borderWidth: theme.__hd__.textInput.borderWidths.container.normal,
|
|
76
|
+
borderRadius: theme.__hd__.textInput.radii.container,
|
|
77
|
+
borderColor: themeFocused
|
|
78
|
+
? theme.__hd__.textInput.colors.borders.focused
|
|
79
|
+
: theme.__hd__.textInput.colors.borders[themeState],
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const StyledInputWrapper = styled(View)(({ theme }) => ({
|
|
83
|
+
flexDirection: 'column',
|
|
84
|
+
flex: 1,
|
|
85
|
+
backgroundColor: 'transparent',
|
|
86
|
+
borderRadius: theme.__hd__.textInput.radii.container,
|
|
87
|
+
marginBottom: theme.__hd__.textInput.space.inputWrapperMarginVertical,
|
|
88
|
+
marginTop: theme.__hd__.textInput.space.inputWrapperMarginVertical,
|
|
89
|
+
overflow: 'hidden',
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const StyledInputRow = styled(View)(({ theme }) => ({
|
|
93
|
+
flexDirection: 'row',
|
|
94
|
+
alignItems: 'center',
|
|
95
|
+
flexGrow: 2,
|
|
96
|
+
flexShrink: 1,
|
|
97
|
+
gap: theme.__hd__.textInput.space.prefixAndInputContainerGap,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const StyledErrorAndHelpTextContainer = styled(View)(({ theme }) => ({
|
|
101
|
+
paddingHorizontal:
|
|
102
|
+
theme.__hd__.textInput.space.errorAndHelpTextContainerHorizontalPadding,
|
|
103
|
+
minHeight: theme.__hd__.textInput.sizes.errorAndHelpTextContainerHeight,
|
|
104
|
+
paddingTop: theme.__hd__.textInput.space.errorAndHelpTextContainerPaddingTop,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const StyledBottomContainer = styled(View)(() => ({
|
|
108
|
+
flexDirection: 'row',
|
|
109
|
+
justifyContent: 'space-between',
|
|
110
|
+
alignItems: 'flex-start',
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const StyledSuffixContainer = styled(View)(() => ({
|
|
114
|
+
flexDirection: 'row',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
justifyContent: 'flex-end',
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
StyledInputWrapper,
|
|
121
|
+
StyledTextInput,
|
|
122
|
+
StyledError,
|
|
123
|
+
StyledCharacterCount,
|
|
124
|
+
StyledContainer,
|
|
125
|
+
StyledErrorRow,
|
|
126
|
+
StyledHelperText,
|
|
127
|
+
StyledInputRow,
|
|
128
|
+
StyledFloatingLabelContainer,
|
|
129
|
+
StyledErrorAndHelpTextContainer,
|
|
130
|
+
StyledBorder,
|
|
131
|
+
StyledBottomContainer,
|
|
132
|
+
StyledSuffixContainer,
|
|
133
|
+
StyledLabel,
|
|
134
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Icon, IconName } from '@hero-design/rn';
|
|
3
|
+
import type { State } from './StyledTextInput';
|
|
4
|
+
|
|
5
|
+
export interface SuffixComponentProps {
|
|
6
|
+
/** Current input state for styling decisions */
|
|
7
|
+
state: State;
|
|
8
|
+
/** Whether to show loading spinner instead of suffix */
|
|
9
|
+
loading: boolean;
|
|
10
|
+
/** Icon name string or custom React element to display */
|
|
11
|
+
suffix?: IconName | React.ReactElement;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SuffixComponent
|
|
16
|
+
*
|
|
17
|
+
* Renders suffix content (icons or custom elements) on the right side of the TextInput.
|
|
18
|
+
* Handles loading states and custom elements.
|
|
19
|
+
*
|
|
20
|
+
* Key Features:
|
|
21
|
+
* - Loading state handling: shows spinner when loading is true
|
|
22
|
+
* - Icon rendering: supports string icon names from the design system
|
|
23
|
+
* - Custom element support: can render any React element as suffix
|
|
24
|
+
* - State-aware styling: adjusts icon intent based on input state
|
|
25
|
+
* - Spinning animation: loading icons automatically spin
|
|
26
|
+
* - Proper test IDs for automated testing
|
|
27
|
+
*
|
|
28
|
+
* Rendering Priority:
|
|
29
|
+
* 1. Loading state: Shows 'loading' icon with spin animation
|
|
30
|
+
* 2. String icon name: Renders Icon component with state-based intent
|
|
31
|
+
* 3. Custom element: Renders the provided React element as-is
|
|
32
|
+
* 4. No suffix: Renders null
|
|
33
|
+
*
|
|
34
|
+
* Icon Intent Logic:
|
|
35
|
+
* - Disabled state: Uses 'disabled-text' intent
|
|
36
|
+
* - All other states: Uses 'text' intent
|
|
37
|
+
*
|
|
38
|
+
* @param props - The component props (see SuffixComponentProps interface for details)
|
|
39
|
+
*/
|
|
40
|
+
const SuffixComponent: React.FC<SuffixComponentProps> = ({
|
|
41
|
+
state,
|
|
42
|
+
loading,
|
|
43
|
+
suffix,
|
|
44
|
+
}) => {
|
|
45
|
+
// Loading state overrides suffix
|
|
46
|
+
const actualSuffix = loading ? 'loading' : suffix;
|
|
47
|
+
|
|
48
|
+
if (typeof actualSuffix === 'string') {
|
|
49
|
+
// Render icon from design system
|
|
50
|
+
return (
|
|
51
|
+
<Icon
|
|
52
|
+
intent={state === 'disabled' ? 'disabled-text' : 'text'}
|
|
53
|
+
testID="input-suffix"
|
|
54
|
+
accessibilityLabel={`Suffix icon: ${actualSuffix}`}
|
|
55
|
+
accessibilityRole="image"
|
|
56
|
+
icon={actualSuffix}
|
|
57
|
+
spin={actualSuffix === 'loading'} // Animate loading icon
|
|
58
|
+
size="medium"
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (actualSuffix) {
|
|
64
|
+
// Render custom element
|
|
65
|
+
return suffix as React.ReactElement;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
SuffixComponent.displayName = 'SuffixComponent';
|
|
72
|
+
|
|
73
|
+
export default React.memo(SuffixComponent);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import renderWithTheme from '../../../../testUtils/renderWithTheme';
|
|
3
|
+
import ErrorOrHelpText from '../ErrorOrHelpText';
|
|
4
|
+
|
|
5
|
+
describe('ErrorOrHelpText', () => {
|
|
6
|
+
it('renders correctly with error', () => {
|
|
7
|
+
const { queryAllByText } = renderWithTheme(
|
|
8
|
+
<ErrorOrHelpText error="This is error" helpText="This is help text" />
|
|
9
|
+
);
|
|
10
|
+
expect(queryAllByText('This is error')).toHaveLength(1);
|
|
11
|
+
expect(queryAllByText('This is help text')).toHaveLength(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders correctly with help text', () => {
|
|
15
|
+
const { queryAllByText } = renderWithTheme(
|
|
16
|
+
<ErrorOrHelpText error="" helpText="This is help text" />
|
|
17
|
+
);
|
|
18
|
+
expect(queryAllByText('This is help text')).toHaveLength(1);
|
|
19
|
+
});
|
|
20
|
+
});
|