rn-inputforge 0.1.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/LICENSE +21 -0
- package/README.md +273 -0
- package/lib/commonjs/components/ChatInput/ChatInput.js +170 -0
- package/lib/commonjs/components/ChatInput/ChatInput.js.map +1 -0
- package/lib/commonjs/components/ChatInput/index.js +13 -0
- package/lib/commonjs/components/ChatInput/index.js.map +1 -0
- package/lib/commonjs/components/ChatInput/types.js +6 -0
- package/lib/commonjs/components/ChatInput/types.js.map +1 -0
- package/lib/commonjs/components/DescriptionField/DescriptionField.js +40 -0
- package/lib/commonjs/components/DescriptionField/DescriptionField.js.map +1 -0
- package/lib/commonjs/components/DescriptionField/index.js +13 -0
- package/lib/commonjs/components/DescriptionField/index.js.map +1 -0
- package/lib/commonjs/components/FieldFlowInput/FieldFlowInput.js +207 -0
- package/lib/commonjs/components/FieldFlowInput/FieldFlowInput.js.map +1 -0
- package/lib/commonjs/components/FieldFlowInput/index.js +13 -0
- package/lib/commonjs/components/FieldFlowInput/index.js.map +1 -0
- package/lib/commonjs/components/FieldFlowInput/styles.js +99 -0
- package/lib/commonjs/components/FieldFlowInput/styles.js.map +1 -0
- package/lib/commonjs/components/FieldFlowInput/types.js +6 -0
- package/lib/commonjs/components/FieldFlowInput/types.js.map +1 -0
- package/lib/commonjs/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.js +44 -0
- package/lib/commonjs/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.js.map +1 -0
- package/lib/commonjs/components/KeyboardAwareFormWrapper/index.js +13 -0
- package/lib/commonjs/components/KeyboardAwareFormWrapper/index.js.map +1 -0
- package/lib/commonjs/hooks/index.js +27 -0
- package/lib/commonjs/hooks/index.js.map +1 -0
- package/lib/commonjs/hooks/useFieldFlowForm.js +108 -0
- package/lib/commonjs/hooks/useFieldFlowForm.js.map +1 -0
- package/lib/commonjs/hooks/useFieldState.js +27 -0
- package/lib/commonjs/hooks/useFieldState.js.map +1 -0
- package/lib/commonjs/hooks/useKeyboardManager.js +68 -0
- package/lib/commonjs/hooks/useKeyboardManager.js.map +1 -0
- package/lib/commonjs/icons/Icon.js +388 -0
- package/lib/commonjs/icons/Icon.js.map +1 -0
- package/lib/commonjs/icons/index.js +13 -0
- package/lib/commonjs/icons/index.js.map +1 -0
- package/lib/commonjs/index.js +158 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/theme/ThemeProvider.js +48 -0
- package/lib/commonjs/theme/ThemeProvider.js.map +1 -0
- package/lib/commonjs/theme/defaultTheme.js +62 -0
- package/lib/commonjs/theme/defaultTheme.js.map +1 -0
- package/lib/commonjs/theme/index.js +38 -0
- package/lib/commonjs/theme/index.js.map +1 -0
- package/lib/commonjs/types/index.js +6 -0
- package/lib/commonjs/types/index.js.map +1 -0
- package/lib/commonjs/utils/formatters.js +25 -0
- package/lib/commonjs/utils/formatters.js.map +1 -0
- package/lib/commonjs/utils/keyboardTypes.js +103 -0
- package/lib/commonjs/utils/keyboardTypes.js.map +1 -0
- package/lib/commonjs/validation/index.js +80 -0
- package/lib/commonjs/validation/index.js.map +1 -0
- package/lib/commonjs/validation/useValidation.js +34 -0
- package/lib/commonjs/validation/useValidation.js.map +1 -0
- package/lib/commonjs/validation/validators.js +118 -0
- package/lib/commonjs/validation/validators.js.map +1 -0
- package/lib/module/components/ChatInput/ChatInput.js +165 -0
- package/lib/module/components/ChatInput/ChatInput.js.map +1 -0
- package/lib/module/components/ChatInput/index.js +4 -0
- package/lib/module/components/ChatInput/index.js.map +1 -0
- package/lib/module/components/ChatInput/types.js +4 -0
- package/lib/module/components/ChatInput/types.js.map +1 -0
- package/lib/module/components/DescriptionField/DescriptionField.js +35 -0
- package/lib/module/components/DescriptionField/DescriptionField.js.map +1 -0
- package/lib/module/components/DescriptionField/index.js +4 -0
- package/lib/module/components/DescriptionField/index.js.map +1 -0
- package/lib/module/components/FieldFlowInput/FieldFlowInput.js +202 -0
- package/lib/module/components/FieldFlowInput/FieldFlowInput.js.map +1 -0
- package/lib/module/components/FieldFlowInput/index.js +4 -0
- package/lib/module/components/FieldFlowInput/index.js.map +1 -0
- package/lib/module/components/FieldFlowInput/styles.js +92 -0
- package/lib/module/components/FieldFlowInput/styles.js.map +1 -0
- package/lib/module/components/FieldFlowInput/types.js +4 -0
- package/lib/module/components/FieldFlowInput/types.js.map +1 -0
- package/lib/module/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.js +39 -0
- package/lib/module/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.js.map +1 -0
- package/lib/module/components/KeyboardAwareFormWrapper/index.js +4 -0
- package/lib/module/components/KeyboardAwareFormWrapper/index.js.map +1 -0
- package/lib/module/hooks/index.js +6 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useFieldFlowForm.js +104 -0
- package/lib/module/hooks/useFieldFlowForm.js.map +1 -0
- package/lib/module/hooks/useFieldState.js +23 -0
- package/lib/module/hooks/useFieldState.js.map +1 -0
- package/lib/module/hooks/useKeyboardManager.js +64 -0
- package/lib/module/hooks/useKeyboardManager.js.map +1 -0
- package/lib/module/icons/Icon.js +383 -0
- package/lib/module/icons/Icon.js.map +1 -0
- package/lib/module/icons/index.js +4 -0
- package/lib/module/icons/index.js.map +1 -0
- package/lib/module/index.js +18 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +42 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/defaultTheme.js +58 -0
- package/lib/module/theme/defaultTheme.js.map +1 -0
- package/lib/module/theme/index.js +5 -0
- package/lib/module/theme/index.js.map +1 -0
- package/lib/module/types/index.js +4 -0
- package/lib/module/types/index.js.map +1 -0
- package/lib/module/utils/formatters.js +19 -0
- package/lib/module/utils/formatters.js.map +1 -0
- package/lib/module/utils/keyboardTypes.js +98 -0
- package/lib/module/utils/keyboardTypes.js.map +1 -0
- package/lib/module/validation/index.js +5 -0
- package/lib/module/validation/index.js.map +1 -0
- package/lib/module/validation/useValidation.js +30 -0
- package/lib/module/validation/useValidation.js.map +1 -0
- package/lib/module/validation/validators.js +103 -0
- package/lib/module/validation/validators.js.map +1 -0
- package/lib/typescript/components/ChatInput/ChatInput.d.ts +10 -0
- package/lib/typescript/components/ChatInput/ChatInput.d.ts.map +1 -0
- package/lib/typescript/components/ChatInput/index.d.ts +3 -0
- package/lib/typescript/components/ChatInput/index.d.ts.map +1 -0
- package/lib/typescript/components/ChatInput/types.d.ts +30 -0
- package/lib/typescript/components/ChatInput/types.d.ts.map +1 -0
- package/lib/typescript/components/DescriptionField/DescriptionField.d.ts +16 -0
- package/lib/typescript/components/DescriptionField/DescriptionField.d.ts.map +1 -0
- package/lib/typescript/components/DescriptionField/index.d.ts +3 -0
- package/lib/typescript/components/DescriptionField/index.d.ts.map +1 -0
- package/lib/typescript/components/FieldFlowInput/FieldFlowInput.d.ts +5 -0
- package/lib/typescript/components/FieldFlowInput/FieldFlowInput.d.ts.map +1 -0
- package/lib/typescript/components/FieldFlowInput/index.d.ts +3 -0
- package/lib/typescript/components/FieldFlowInput/index.d.ts.map +1 -0
- package/lib/typescript/components/FieldFlowInput/styles.d.ts +71 -0
- package/lib/typescript/components/FieldFlowInput/styles.d.ts.map +1 -0
- package/lib/typescript/components/FieldFlowInput/types.d.ts +50 -0
- package/lib/typescript/components/FieldFlowInput/types.d.ts.map +1 -0
- package/lib/typescript/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.d.ts +17 -0
- package/lib/typescript/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.d.ts.map +1 -0
- package/lib/typescript/components/KeyboardAwareFormWrapper/index.d.ts +3 -0
- package/lib/typescript/components/KeyboardAwareFormWrapper/index.d.ts.map +1 -0
- package/lib/typescript/hooks/index.d.ts +7 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -0
- package/lib/typescript/hooks/useFieldFlowForm.d.ts +31 -0
- package/lib/typescript/hooks/useFieldFlowForm.d.ts.map +1 -0
- package/lib/typescript/hooks/useFieldState.d.ts +12 -0
- package/lib/typescript/hooks/useFieldState.d.ts.map +1 -0
- package/lib/typescript/hooks/useKeyboardManager.d.ts +35 -0
- package/lib/typescript/hooks/useKeyboardManager.d.ts.map +1 -0
- package/lib/typescript/icons/Icon.d.ts +17 -0
- package/lib/typescript/icons/Icon.d.ts.map +1 -0
- package/lib/typescript/icons/index.d.ts +3 -0
- package/lib/typescript/icons/index.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +18 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/theme/ThemeProvider.d.ts +16 -0
- package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/theme/defaultTheme.d.ts +4 -0
- package/lib/typescript/theme/defaultTheme.d.ts.map +1 -0
- package/lib/typescript/theme/index.d.ts +4 -0
- package/lib/typescript/theme/index.d.ts.map +1 -0
- package/lib/typescript/types/index.d.ts +79 -0
- package/lib/typescript/types/index.d.ts.map +1 -0
- package/lib/typescript/utils/formatters.d.ts +5 -0
- package/lib/typescript/utils/formatters.d.ts.map +1 -0
- package/lib/typescript/utils/keyboardTypes.d.ts +18 -0
- package/lib/typescript/utils/keyboardTypes.d.ts.map +1 -0
- package/lib/typescript/validation/index.d.ts +4 -0
- package/lib/typescript/validation/index.d.ts.map +1 -0
- package/lib/typescript/validation/useValidation.d.ts +14 -0
- package/lib/typescript/validation/useValidation.d.ts.map +1 -0
- package/lib/typescript/validation/validators.d.ts +17 -0
- package/lib/typescript/validation/validators.d.ts.map +1 -0
- package/package.json +80 -0
- package/src/components/ChatInput/ChatInput.tsx +178 -0
- package/src/components/ChatInput/index.ts +2 -0
- package/src/components/ChatInput/types.ts +35 -0
- package/src/components/DescriptionField/DescriptionField.tsx +39 -0
- package/src/components/DescriptionField/index.ts +2 -0
- package/src/components/FieldFlowInput/FieldFlowInput.tsx +240 -0
- package/src/components/FieldFlowInput/index.ts +2 -0
- package/src/components/FieldFlowInput/styles.ts +94 -0
- package/src/components/FieldFlowInput/types.ts +55 -0
- package/src/components/KeyboardAwareFormWrapper/KeyboardAwareFormWrapper.tsx +45 -0
- package/src/components/KeyboardAwareFormWrapper/index.ts +2 -0
- package/src/hooks/index.ts +12 -0
- package/src/hooks/useFieldFlowForm.ts +133 -0
- package/src/hooks/useFieldState.ts +31 -0
- package/src/hooks/useKeyboardManager.ts +105 -0
- package/src/icons/Icon.tsx +338 -0
- package/src/icons/index.ts +2 -0
- package/src/index.ts +63 -0
- package/src/theme/ThemeProvider.tsx +49 -0
- package/src/theme/defaultTheme.ts +58 -0
- package/src/theme/index.ts +3 -0
- package/src/types/index.ts +98 -0
- package/src/utils/formatters.ts +18 -0
- package/src/utils/keyboardTypes.ts +85 -0
- package/src/validation/index.ts +15 -0
- package/src/validation/useValidation.ts +37 -0
- package/src/validation/validators.ts +93 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
KeyboardAvoidingView,
|
|
4
|
+
NativeSyntheticEvent,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
TextInput,
|
|
9
|
+
TextInputContentSizeChangeEventData,
|
|
10
|
+
View,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { useFieldFlowTheme, mergeDeep } from '../../theme/ThemeProvider';
|
|
13
|
+
import { defaultTheme } from '../../theme/defaultTheme';
|
|
14
|
+
import { FieldFlowIcon } from '../../icons/Icon';
|
|
15
|
+
import type { ChatAttachmentAction, ChatInputProps } from './types';
|
|
16
|
+
|
|
17
|
+
const ACTION_ICON: Record<ChatAttachmentAction, Parameters<typeof FieldFlowIcon>[0]['name']> = {
|
|
18
|
+
camera: 'camera',
|
|
19
|
+
gallery: 'image',
|
|
20
|
+
document: 'paperclip',
|
|
21
|
+
location: 'mapPin',
|
|
22
|
+
voice: 'mic',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A ready-to-drop-in chat composer for messaging screens: expandable input,
|
|
27
|
+
* a configurable row of attachment buttons (camera / gallery / document /
|
|
28
|
+
* location / voice, plus your own custom actions), and a send button that
|
|
29
|
+
* only lights up once there's something to send.
|
|
30
|
+
*/
|
|
31
|
+
export function ChatInput({
|
|
32
|
+
value,
|
|
33
|
+
defaultValue,
|
|
34
|
+
onChangeText,
|
|
35
|
+
onSend,
|
|
36
|
+
placeholder = 'Message',
|
|
37
|
+
attachmentActions = [],
|
|
38
|
+
onAttachmentPress,
|
|
39
|
+
customActions = [],
|
|
40
|
+
disabled,
|
|
41
|
+
loading,
|
|
42
|
+
maxLength,
|
|
43
|
+
minHeight = 40,
|
|
44
|
+
maxHeight = 140,
|
|
45
|
+
theme: themeOverride,
|
|
46
|
+
testID,
|
|
47
|
+
}: ChatInputProps) {
|
|
48
|
+
const contextTheme = useFieldFlowTheme();
|
|
49
|
+
const theme = useMemo(
|
|
50
|
+
() => (themeOverride ? mergeDeep(contextTheme ?? defaultTheme, themeOverride) : contextTheme ?? defaultTheme),
|
|
51
|
+
[contextTheme, themeOverride],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const isControlled = value !== undefined;
|
|
55
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? '');
|
|
56
|
+
const currentValue = isControlled ? value ?? '' : internalValue;
|
|
57
|
+
const [height, setHeight] = useState(minHeight);
|
|
58
|
+
const inputRef = useRef<TextInput>(null);
|
|
59
|
+
|
|
60
|
+
const handleChangeText = (next: string) => {
|
|
61
|
+
if (!isControlled) setInternalValue(next);
|
|
62
|
+
onChangeText?.(next);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleContentSizeChange = (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
|
66
|
+
setHeight(Math.min(maxHeight, Math.max(minHeight, e.nativeEvent.contentSize.height)));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleSend = () => {
|
|
70
|
+
const trimmed = currentValue.trim();
|
|
71
|
+
if (!trimmed || disabled || loading) return;
|
|
72
|
+
onSend(trimmed);
|
|
73
|
+
if (!isControlled) setInternalValue('');
|
|
74
|
+
inputRef.current?.clear();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const canSend = currentValue.trim().length > 0 && !disabled && !loading;
|
|
78
|
+
|
|
79
|
+
const styles = useMemo(() => createStyles(theme), [theme]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} testID={testID}>
|
|
83
|
+
<View style={styles.container}>
|
|
84
|
+
{attachmentActions.length > 0 || customActions.length > 0 ? (
|
|
85
|
+
<View style={styles.toolbar}>
|
|
86
|
+
{attachmentActions.map(action => (
|
|
87
|
+
<Pressable
|
|
88
|
+
key={action}
|
|
89
|
+
onPress={() => onAttachmentPress?.(action)}
|
|
90
|
+
style={styles.toolbarButton}
|
|
91
|
+
hitSlop={8}
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
>
|
|
94
|
+
<FieldFlowIcon name={ACTION_ICON[action]} color={theme.colors.icon} size={20} />
|
|
95
|
+
</Pressable>
|
|
96
|
+
))}
|
|
97
|
+
{customActions.map(action => (
|
|
98
|
+
<Pressable key={action.id} onPress={action.onPress} style={styles.toolbarButton} hitSlop={8}>
|
|
99
|
+
{typeof action.icon === 'string' ? (
|
|
100
|
+
<FieldFlowIcon name={action.icon as any} color={theme.colors.icon} size={20} />
|
|
101
|
+
) : (
|
|
102
|
+
action.icon
|
|
103
|
+
)}
|
|
104
|
+
</Pressable>
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
<View style={styles.inputRow}>
|
|
110
|
+
<TextInput
|
|
111
|
+
ref={inputRef}
|
|
112
|
+
value={currentValue}
|
|
113
|
+
onChangeText={handleChangeText}
|
|
114
|
+
onContentSizeChange={handleContentSizeChange}
|
|
115
|
+
placeholder={placeholder}
|
|
116
|
+
placeholderTextColor={theme.colors.placeholder}
|
|
117
|
+
multiline
|
|
118
|
+
maxLength={maxLength}
|
|
119
|
+
editable={!disabled && !loading}
|
|
120
|
+
style={[styles.input, { height }]}
|
|
121
|
+
/>
|
|
122
|
+
<Pressable
|
|
123
|
+
onPress={handleSend}
|
|
124
|
+
disabled={!canSend}
|
|
125
|
+
style={[styles.sendButton, { opacity: canSend ? 1 : 0.4 }]}
|
|
126
|
+
hitSlop={8}
|
|
127
|
+
>
|
|
128
|
+
<FieldFlowIcon name={loading ? 'spinner' : 'send'} color="#FFFFFF" size={16} />
|
|
129
|
+
</Pressable>
|
|
130
|
+
</View>
|
|
131
|
+
</View>
|
|
132
|
+
</KeyboardAvoidingView>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createStyles(theme: ReturnType<typeof useFieldFlowTheme>) {
|
|
137
|
+
return StyleSheet.create({
|
|
138
|
+
container: {
|
|
139
|
+
backgroundColor: theme.colors.background,
|
|
140
|
+
borderTopWidth: theme.spacing.borderWidth,
|
|
141
|
+
borderTopColor: theme.colors.border,
|
|
142
|
+
paddingHorizontal: theme.spacing.paddingHorizontal,
|
|
143
|
+
paddingVertical: theme.spacing.gap,
|
|
144
|
+
},
|
|
145
|
+
toolbar: {
|
|
146
|
+
flexDirection: 'row',
|
|
147
|
+
marginBottom: theme.spacing.gap,
|
|
148
|
+
},
|
|
149
|
+
toolbarButton: {
|
|
150
|
+
marginRight: theme.spacing.gap * 2,
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
justifyContent: 'center',
|
|
153
|
+
},
|
|
154
|
+
inputRow: {
|
|
155
|
+
flexDirection: 'row',
|
|
156
|
+
alignItems: 'flex-end',
|
|
157
|
+
},
|
|
158
|
+
input: {
|
|
159
|
+
flex: 1,
|
|
160
|
+
fontSize: theme.typography.fontSize,
|
|
161
|
+
color: theme.colors.text,
|
|
162
|
+
backgroundColor: theme.colors.backgroundDisabled,
|
|
163
|
+
borderRadius: theme.spacing.borderRadius * 1.6,
|
|
164
|
+
paddingHorizontal: theme.spacing.paddingHorizontal,
|
|
165
|
+
paddingVertical: theme.spacing.paddingVertical * 0.7,
|
|
166
|
+
textAlignVertical: 'top',
|
|
167
|
+
},
|
|
168
|
+
sendButton: {
|
|
169
|
+
width: 34,
|
|
170
|
+
height: 34,
|
|
171
|
+
borderRadius: 17,
|
|
172
|
+
backgroundColor: theme.colors.borderFocused,
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: 'center',
|
|
175
|
+
marginLeft: theme.spacing.gap,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { DeepPartial, FieldFlowTheme } from '../../types';
|
|
3
|
+
import type { FieldFlowIconName } from '../../icons';
|
|
4
|
+
|
|
5
|
+
export type ChatAttachmentAction = 'camera' | 'gallery' | 'document' | 'location' | 'voice';
|
|
6
|
+
|
|
7
|
+
export interface CustomChatAction {
|
|
8
|
+
id: string;
|
|
9
|
+
icon: FieldFlowIconName | React.ReactNode;
|
|
10
|
+
label?: string;
|
|
11
|
+
onPress: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChatInputProps {
|
|
15
|
+
value?: string;
|
|
16
|
+
defaultValue?: string;
|
|
17
|
+
onChangeText?: (value: string) => void;
|
|
18
|
+
onSend: (message: string) => void;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
|
|
21
|
+
/** Which built-in attachment buttons to show in the toolbar, in order. */
|
|
22
|
+
attachmentActions?: ChatAttachmentAction[];
|
|
23
|
+
onAttachmentPress?: (action: ChatAttachmentAction) => void;
|
|
24
|
+
/** Extra app-specific buttons appended after the built-in ones. */
|
|
25
|
+
customActions?: CustomChatAction[];
|
|
26
|
+
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
maxLength?: number;
|
|
30
|
+
minHeight?: number;
|
|
31
|
+
maxHeight?: number;
|
|
32
|
+
|
|
33
|
+
theme?: DeepPartial<FieldFlowTheme>;
|
|
34
|
+
testID?: string;
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { forwardRef, useState } from 'react';
|
|
2
|
+
import type { NativeSyntheticEvent, TextInput, TextInputContentSizeChangeEventData } from 'react-native';
|
|
3
|
+
import { FieldFlowInput } from '../FieldFlowInput/FieldFlowInput';
|
|
4
|
+
import type { FieldFlowInputProps } from '../FieldFlowInput/types';
|
|
5
|
+
|
|
6
|
+
export interface DescriptionFieldProps extends Omit<FieldFlowInputProps, 'type'> {
|
|
7
|
+
/** Minimum height in px before the field starts growing. Default 80. */
|
|
8
|
+
minHeight?: number;
|
|
9
|
+
/** Maximum height in px the field will grow to before scrolling internally. Default 200. */
|
|
10
|
+
maxHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A dedicated multi-line field for comments, notes, descriptions, and feedback.
|
|
15
|
+
* Grows automatically as the user types, up to `maxHeight`, and supports the
|
|
16
|
+
* same character-limit / validation / theming props as FieldFlowInput.
|
|
17
|
+
*/
|
|
18
|
+
export const DescriptionField = forwardRef<TextInput, DescriptionFieldProps>(function DescriptionField(
|
|
19
|
+
{ minHeight = 80, maxHeight = 200, inputStyle, showCharacterCount = true, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
const [height, setHeight] = useState(minHeight);
|
|
23
|
+
|
|
24
|
+
const handleContentSizeChange = (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
|
25
|
+
const next = Math.min(maxHeight, Math.max(minHeight, e.nativeEvent.contentSize.height));
|
|
26
|
+
setHeight(next);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<FieldFlowInput
|
|
31
|
+
ref={ref}
|
|
32
|
+
type="description"
|
|
33
|
+
showCharacterCount={showCharacterCount}
|
|
34
|
+
inputStyle={{ height, ...inputStyle }}
|
|
35
|
+
onContentSizeChange={handleContentSizeChange}
|
|
36
|
+
{...rest}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { useFieldFlowTheme, mergeDeep } from '../../theme/ThemeProvider';
|
|
4
|
+
import { defaultTheme } from '../../theme/defaultTheme';
|
|
5
|
+
import { useFieldState } from '../../hooks/useFieldState';
|
|
6
|
+
import { resolveFieldConfig, implicitRulesForType } from '../../utils/keyboardTypes';
|
|
7
|
+
import { sanitizeNumeric, sanitizePhone, truncate } from '../../utils/formatters';
|
|
8
|
+
import { runRules } from '../../validation/validators';
|
|
9
|
+
import { FieldFlowIcon } from '../../icons/Icon';
|
|
10
|
+
import { createStyles, iconColorFor } from './styles';
|
|
11
|
+
import type { FieldFlowInputProps } from './types';
|
|
12
|
+
|
|
13
|
+
function renderIconNode(
|
|
14
|
+
icon: FieldFlowInputProps['leftIcon'],
|
|
15
|
+
color: string,
|
|
16
|
+
): React.ReactNode {
|
|
17
|
+
if (!icon) return null;
|
|
18
|
+
if (typeof icon === 'string') {
|
|
19
|
+
return <FieldFlowIcon name={icon as any} color={color} size={18} />;
|
|
20
|
+
}
|
|
21
|
+
return icon;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const FieldFlowInput = forwardRef<TextInput, FieldFlowInputProps>(function FieldFlowInput(
|
|
25
|
+
{
|
|
26
|
+
type = 'text',
|
|
27
|
+
label,
|
|
28
|
+
helperText,
|
|
29
|
+
errorText,
|
|
30
|
+
hasError,
|
|
31
|
+
successText,
|
|
32
|
+
isSuccess,
|
|
33
|
+
loading,
|
|
34
|
+
disabled,
|
|
35
|
+
readOnly,
|
|
36
|
+
required,
|
|
37
|
+
value,
|
|
38
|
+
defaultValue,
|
|
39
|
+
onChangeText,
|
|
40
|
+
onFocus,
|
|
41
|
+
onBlur,
|
|
42
|
+
rules,
|
|
43
|
+
validateOnChange,
|
|
44
|
+
onValidate,
|
|
45
|
+
leftIcon,
|
|
46
|
+
rightIcon,
|
|
47
|
+
onLeftIconPress,
|
|
48
|
+
onRightIconPress,
|
|
49
|
+
clearable,
|
|
50
|
+
maxLength,
|
|
51
|
+
showCharacterCount,
|
|
52
|
+
onSubmitEditing,
|
|
53
|
+
containerStyle,
|
|
54
|
+
inputStyle,
|
|
55
|
+
labelStyle,
|
|
56
|
+
helperStyle,
|
|
57
|
+
theme: themeOverride,
|
|
58
|
+
testID,
|
|
59
|
+
...rest
|
|
60
|
+
},
|
|
61
|
+
forwardedRef,
|
|
62
|
+
) {
|
|
63
|
+
const contextTheme = useFieldFlowTheme();
|
|
64
|
+
const theme = useMemo(
|
|
65
|
+
() => (themeOverride ? mergeDeep(contextTheme ?? defaultTheme, themeOverride) : contextTheme ?? defaultTheme),
|
|
66
|
+
[contextTheme, themeOverride],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const innerRef = useRef<TextInput>(null);
|
|
70
|
+
useImperativeHandle(forwardedRef, () => innerRef.current as TextInput);
|
|
71
|
+
|
|
72
|
+
const isControlled = value !== undefined;
|
|
73
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? '');
|
|
74
|
+
const currentValue = isControlled ? value ?? '' : internalValue;
|
|
75
|
+
|
|
76
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
77
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
78
|
+
const [localError, setLocalError] = useState<string | undefined>(undefined);
|
|
79
|
+
|
|
80
|
+
const fieldConfig = useMemo(() => resolveFieldConfig(type), [type]);
|
|
81
|
+
const effectiveRules = useMemo(
|
|
82
|
+
() => ({ ...implicitRulesForType(type), ...(required ? { required: true } : {}), ...rules }),
|
|
83
|
+
[type, required, rules],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const resolvedError = errorText ?? localError;
|
|
87
|
+
const state = useFieldState({
|
|
88
|
+
isFocused,
|
|
89
|
+
hasError: Boolean(hasError || resolvedError),
|
|
90
|
+
isSuccess,
|
|
91
|
+
disabled,
|
|
92
|
+
readOnly,
|
|
93
|
+
loading,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const styles = useMemo(
|
|
97
|
+
() => createStyles(theme, state, fieldConfig.multiline && type !== 'chat'),
|
|
98
|
+
[theme, state, fieldConfig.multiline, type],
|
|
99
|
+
);
|
|
100
|
+
const iconColor = iconColorFor(theme, state);
|
|
101
|
+
|
|
102
|
+
const runValidation = useCallback(
|
|
103
|
+
(nextValue: string) => {
|
|
104
|
+
const result = runRules(nextValue, effectiveRules);
|
|
105
|
+
setLocalError(result.valid ? undefined : result.message);
|
|
106
|
+
onValidate?.(result);
|
|
107
|
+
return result.valid;
|
|
108
|
+
},
|
|
109
|
+
[effectiveRules, onValidate],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const handleChangeText = useCallback(
|
|
113
|
+
(raw: string) => {
|
|
114
|
+
let next = raw;
|
|
115
|
+
if (type === 'number') next = sanitizeNumeric(next, false);
|
|
116
|
+
if (type === 'decimal') next = sanitizeNumeric(next, true);
|
|
117
|
+
if (type === 'phone') next = sanitizePhone(next);
|
|
118
|
+
next = truncate(next, maxLength);
|
|
119
|
+
|
|
120
|
+
if (!isControlled) setInternalValue(next);
|
|
121
|
+
onChangeText?.(next);
|
|
122
|
+
if (validateOnChange) runValidation(next);
|
|
123
|
+
},
|
|
124
|
+
[type, maxLength, isControlled, onChangeText, validateOnChange, runValidation],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const handleFocus = useCallback(() => {
|
|
128
|
+
setIsFocused(true);
|
|
129
|
+
onFocus?.();
|
|
130
|
+
}, [onFocus]);
|
|
131
|
+
|
|
132
|
+
const handleBlur = useCallback(() => {
|
|
133
|
+
setIsFocused(false);
|
|
134
|
+
runValidation(currentValue);
|
|
135
|
+
onBlur?.();
|
|
136
|
+
}, [runValidation, currentValue, onBlur]);
|
|
137
|
+
|
|
138
|
+
const handleClear = useCallback(() => {
|
|
139
|
+
if (!isControlled) setInternalValue('');
|
|
140
|
+
onChangeText?.('');
|
|
141
|
+
innerRef.current?.focus();
|
|
142
|
+
}, [isControlled, onChangeText]);
|
|
143
|
+
|
|
144
|
+
const isClearable = clearable ?? type === 'search';
|
|
145
|
+
const showClearButton = isClearable && currentValue.length > 0 && !disabled && !readOnly && !loading;
|
|
146
|
+
|
|
147
|
+
const resolvedLeftIcon = leftIcon ?? (type === 'search' ? 'search' : undefined);
|
|
148
|
+
let resolvedRightIcon = rightIcon;
|
|
149
|
+
let rightIconPress = onRightIconPress;
|
|
150
|
+
|
|
151
|
+
if (!resolvedRightIcon) {
|
|
152
|
+
if (loading) {
|
|
153
|
+
resolvedRightIcon = 'spinner';
|
|
154
|
+
} else if (type === 'password') {
|
|
155
|
+
resolvedRightIcon = showPassword ? 'eyeOff' : 'eye';
|
|
156
|
+
rightIconPress = () => setShowPassword(v => !v);
|
|
157
|
+
} else if (showClearButton) {
|
|
158
|
+
resolvedRightIcon = 'clear';
|
|
159
|
+
rightIconPress = handleClear;
|
|
160
|
+
} else if (state === 'error') {
|
|
161
|
+
resolvedRightIcon = 'alert';
|
|
162
|
+
} else if (state === 'success') {
|
|
163
|
+
resolvedRightIcon = 'check';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<View style={[styles.wrapper, containerStyle]} testID={testID}>
|
|
169
|
+
{label ? (
|
|
170
|
+
<Text style={[styles.label, labelStyle]}>
|
|
171
|
+
{label}
|
|
172
|
+
{required ? ' *' : ''}
|
|
173
|
+
</Text>
|
|
174
|
+
) : null}
|
|
175
|
+
|
|
176
|
+
<View style={styles.inputContainer}>
|
|
177
|
+
{resolvedLeftIcon ? (
|
|
178
|
+
<Pressable
|
|
179
|
+
onPress={onLeftIconPress}
|
|
180
|
+
disabled={!onLeftIconPress}
|
|
181
|
+
style={[styles.iconSlot, styles.iconSlotLeft]}
|
|
182
|
+
hitSlop={8}
|
|
183
|
+
>
|
|
184
|
+
{renderIconNode(resolvedLeftIcon, iconColor)}
|
|
185
|
+
</Pressable>
|
|
186
|
+
) : null}
|
|
187
|
+
|
|
188
|
+
<TextInput
|
|
189
|
+
ref={innerRef}
|
|
190
|
+
value={currentValue}
|
|
191
|
+
onChangeText={handleChangeText}
|
|
192
|
+
onFocus={handleFocus}
|
|
193
|
+
onBlur={handleBlur}
|
|
194
|
+
onSubmitEditing={onSubmitEditing}
|
|
195
|
+
editable={!disabled && !readOnly && !loading}
|
|
196
|
+
placeholderTextColor={theme.colors.placeholder}
|
|
197
|
+
keyboardType={fieldConfig.keyboardType}
|
|
198
|
+
secureTextEntry={fieldConfig.secureTextEntry && !showPassword}
|
|
199
|
+
autoCapitalize={fieldConfig.autoCapitalize}
|
|
200
|
+
autoCorrect={fieldConfig.autoCorrect}
|
|
201
|
+
multiline={fieldConfig.multiline && type !== 'chat'}
|
|
202
|
+
maxLength={maxLength}
|
|
203
|
+
returnKeyType={fieldConfig.multiline ? 'default' : 'done'}
|
|
204
|
+
style={[styles.input, inputStyle]}
|
|
205
|
+
{...rest}
|
|
206
|
+
/>
|
|
207
|
+
|
|
208
|
+
{resolvedRightIcon ? (
|
|
209
|
+
<Pressable
|
|
210
|
+
onPress={rightIconPress}
|
|
211
|
+
disabled={!rightIconPress}
|
|
212
|
+
style={[styles.iconSlot, styles.iconSlotRight]}
|
|
213
|
+
hitSlop={8}
|
|
214
|
+
>
|
|
215
|
+
{renderIconNode(resolvedRightIcon, iconColor)}
|
|
216
|
+
</Pressable>
|
|
217
|
+
) : null}
|
|
218
|
+
</View>
|
|
219
|
+
|
|
220
|
+
{(helperText || resolvedError || successText || (showCharacterCount && maxLength)) && (
|
|
221
|
+
<View style={styles.footerRow}>
|
|
222
|
+
<View style={{ flex: 1 }}>
|
|
223
|
+
{resolvedError ? (
|
|
224
|
+
<Text style={styles.errorText}>{resolvedError}</Text>
|
|
225
|
+
) : successText ? (
|
|
226
|
+
<Text style={styles.successText}>{successText}</Text>
|
|
227
|
+
) : helperText ? (
|
|
228
|
+
<Text style={styles.helperText}>{helperText}</Text>
|
|
229
|
+
) : null}
|
|
230
|
+
</View>
|
|
231
|
+
{showCharacterCount && maxLength ? (
|
|
232
|
+
<Text style={styles.charCount}>
|
|
233
|
+
{currentValue.length}/{maxLength}
|
|
234
|
+
</Text>
|
|
235
|
+
) : null}
|
|
236
|
+
</View>
|
|
237
|
+
)}
|
|
238
|
+
</View>
|
|
239
|
+
);
|
|
240
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native';
|
|
2
|
+
import type { FieldFlowState, FieldFlowTheme } from '../../types';
|
|
3
|
+
|
|
4
|
+
export function borderColorFor(theme: FieldFlowTheme, state: FieldFlowState): string {
|
|
5
|
+
switch (state) {
|
|
6
|
+
case 'focused':
|
|
7
|
+
return theme.colors.borderFocused;
|
|
8
|
+
case 'error':
|
|
9
|
+
return theme.colors.borderError;
|
|
10
|
+
case 'success':
|
|
11
|
+
return theme.colors.borderSuccess;
|
|
12
|
+
default:
|
|
13
|
+
return theme.colors.border;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function labelColorFor(theme: FieldFlowTheme, state: FieldFlowState): string {
|
|
18
|
+
if (state === 'error') return theme.colors.error;
|
|
19
|
+
if (state === 'focused') return theme.colors.labelFocused;
|
|
20
|
+
return theme.colors.label;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function iconColorFor(theme: FieldFlowTheme, state: FieldFlowState): string {
|
|
24
|
+
return state === 'focused' ? theme.colors.iconFocused : theme.colors.icon;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createStyles(theme: FieldFlowTheme, state: FieldFlowState, multiline: boolean) {
|
|
28
|
+
return StyleSheet.create({
|
|
29
|
+
wrapper: {
|
|
30
|
+
marginBottom: theme.spacing.gap * 2,
|
|
31
|
+
},
|
|
32
|
+
label: {
|
|
33
|
+
fontSize: theme.typography.labelFontSize,
|
|
34
|
+
marginBottom: theme.spacing.gap / 1.5,
|
|
35
|
+
color: labelColorFor(theme, state),
|
|
36
|
+
fontWeight: '500',
|
|
37
|
+
},
|
|
38
|
+
inputContainer: {
|
|
39
|
+
flexDirection: 'row',
|
|
40
|
+
alignItems: multiline ? 'flex-start' : 'center',
|
|
41
|
+
backgroundColor: state === 'disabled' ? theme.colors.backgroundDisabled : theme.colors.background,
|
|
42
|
+
borderRadius: theme.spacing.borderRadius,
|
|
43
|
+
borderWidth: state === 'focused' ? theme.spacing.borderWidthFocused : theme.spacing.borderWidth,
|
|
44
|
+
borderColor: borderColorFor(theme, state),
|
|
45
|
+
paddingHorizontal: theme.spacing.paddingHorizontal,
|
|
46
|
+
paddingVertical: multiline ? theme.spacing.paddingVertical : 0,
|
|
47
|
+
minHeight: multiline ? undefined : theme.spacing.paddingVertical * 2 + theme.typography.fontSize + 6,
|
|
48
|
+
},
|
|
49
|
+
input: {
|
|
50
|
+
flex: 1,
|
|
51
|
+
fontSize: theme.typography.fontSize,
|
|
52
|
+
color: theme.colors.text,
|
|
53
|
+
paddingVertical: multiline ? 0 : theme.spacing.paddingVertical,
|
|
54
|
+
textAlignVertical: multiline ? 'top' : 'center',
|
|
55
|
+
minHeight: multiline ? 80 : undefined,
|
|
56
|
+
},
|
|
57
|
+
iconSlot: {
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'center',
|
|
60
|
+
marginTop: multiline ? theme.spacing.paddingVertical : 0,
|
|
61
|
+
},
|
|
62
|
+
iconSlotLeft: {
|
|
63
|
+
marginRight: theme.spacing.gap,
|
|
64
|
+
},
|
|
65
|
+
iconSlotRight: {
|
|
66
|
+
marginLeft: theme.spacing.gap,
|
|
67
|
+
},
|
|
68
|
+
footerRow: {
|
|
69
|
+
flexDirection: 'row',
|
|
70
|
+
justifyContent: 'space-between',
|
|
71
|
+
marginTop: theme.spacing.gap / 1.5,
|
|
72
|
+
},
|
|
73
|
+
helperText: {
|
|
74
|
+
fontSize: theme.typography.helperFontSize,
|
|
75
|
+
color: theme.colors.label,
|
|
76
|
+
flexShrink: 1,
|
|
77
|
+
},
|
|
78
|
+
errorText: {
|
|
79
|
+
fontSize: theme.typography.helperFontSize,
|
|
80
|
+
color: theme.colors.error,
|
|
81
|
+
flexShrink: 1,
|
|
82
|
+
},
|
|
83
|
+
successText: {
|
|
84
|
+
fontSize: theme.typography.helperFontSize,
|
|
85
|
+
color: theme.colors.success,
|
|
86
|
+
flexShrink: 1,
|
|
87
|
+
},
|
|
88
|
+
charCount: {
|
|
89
|
+
fontSize: theme.typography.helperFontSize,
|
|
90
|
+
color: theme.colors.placeholder,
|
|
91
|
+
marginLeft: theme.spacing.gap,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { TextInputProps, ViewStyle, TextStyle } from 'react-native';
|
|
3
|
+
import type { FieldFlowFieldType, FieldFlowRules, DeepPartial, FieldFlowTheme } from '../../types';
|
|
4
|
+
import type { FieldFlowIconName } from '../../icons';
|
|
5
|
+
|
|
6
|
+
export interface FieldFlowInputProps
|
|
7
|
+
extends Omit<TextInputProps, 'style' | 'onChangeText' | 'onFocus' | 'onBlur' | 'onSubmitEditing'> {
|
|
8
|
+
/** Determines keyboard, secure entry, validation defaults, and default icon behavior. */
|
|
9
|
+
type?: FieldFlowFieldType;
|
|
10
|
+
label?: string;
|
|
11
|
+
helperText?: string;
|
|
12
|
+
errorText?: string;
|
|
13
|
+
/** Set true to force the error visual state even without `errorText` (e.g. from a form hook). */
|
|
14
|
+
hasError?: boolean;
|
|
15
|
+
successText?: string;
|
|
16
|
+
isSuccess?: boolean;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
readOnly?: boolean;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
|
|
22
|
+
value?: string;
|
|
23
|
+
defaultValue?: string;
|
|
24
|
+
onChangeText?: (value: string) => void;
|
|
25
|
+
onFocus?: () => void;
|
|
26
|
+
onBlur?: () => void;
|
|
27
|
+
|
|
28
|
+
/** Validation rules; if omitted, sensible defaults are inferred from `type` (e.g. email format). */
|
|
29
|
+
rules?: FieldFlowRules;
|
|
30
|
+
/** Runs validation on every keystroke instead of just on blur. */
|
|
31
|
+
validateOnChange?: boolean;
|
|
32
|
+
/** Called with the validation result whenever a validation pass runs. */
|
|
33
|
+
onValidate?: (result: { valid: boolean; message?: string }) => void;
|
|
34
|
+
|
|
35
|
+
leftIcon?: FieldFlowIconName | React.ReactNode;
|
|
36
|
+
rightIcon?: FieldFlowIconName | React.ReactNode;
|
|
37
|
+
onLeftIconPress?: () => void;
|
|
38
|
+
onRightIconPress?: () => void;
|
|
39
|
+
/** Shows an "x" clear button when there's text (defaults to true for `search`, false otherwise). */
|
|
40
|
+
clearable?: boolean;
|
|
41
|
+
|
|
42
|
+
maxLength?: number;
|
|
43
|
+
showCharacterCount?: boolean;
|
|
44
|
+
|
|
45
|
+
/** Return-key handling used by useKeyboardManager; if provided, overrides default onSubmitEditing wiring. */
|
|
46
|
+
onSubmitEditing?: () => void;
|
|
47
|
+
|
|
48
|
+
containerStyle?: ViewStyle;
|
|
49
|
+
inputStyle?: TextStyle;
|
|
50
|
+
labelStyle?: TextStyle;
|
|
51
|
+
helperStyle?: TextStyle;
|
|
52
|
+
theme?: DeepPartial<FieldFlowTheme>;
|
|
53
|
+
|
|
54
|
+
testID?: string;
|
|
55
|
+
}
|