diff --git a/src/components/CodeInput/CodeInput.tsx b/src/components/CodeInput/CodeInput.tsx index 85d268aa..f45694b4 100644 --- a/src/components/CodeInput/CodeInput.tsx +++ b/src/components/CodeInput/CodeInput.tsx @@ -7,12 +7,21 @@ import React, { useRef, useState, } from 'react' -import {KeyboardTypeOptions, StyleProp, TextInput, TextInputProps, TextStyle, ViewStyle} from 'react-native' +import { + KeyboardTypeOptions, + StyleProp, + TextInput, + TextInputProps, + TextProps, + TextStyle, + ViewStyle, +} from 'react-native' import styled from 'styled-components/native' import {useTheme} from '../../hooks' import {metrics} from '../../helpers' import {Cursor} from './Cursor' import {Text} from '../Text/Text' +import {ErrorText, HelperText} from './components' // Types type CodeInputValue = string @@ -102,9 +111,64 @@ interface CodeInputProps extends Omit + + /** Label text displayed above the code input */ + label?: string + + /** Custom label component to replace default label text */ + labelComponent?: ReactNode + + /** Styling for the label */ + labelStyle?: StyleProp + + /** Props to be passed to the label Text component */ + labelProps?: TextProps + + /** Show asterisk beside label for required fields */ + isRequire?: boolean + + /** Helper text displayed below the code input */ + helperText?: string + + /** Custom helper component to replace default helper text */ + helperComponent?: ReactNode + + /** Props to be passed to the helper text component */ + helperTextProps?: TextProps + + /** Error text displayed below the code input */ + errorText?: string + + /** Props to be passed to the error text component */ + errorProps?: TextProps + + /** Enable error state styling */ + error?: boolean + + /** Style for cell in error state */ + errorCellStyle?: StyleProp + + /** Enable success state styling */ + success?: boolean + + /** Style for cell in success state */ + successCellStyle?: StyleProp + + /** Style for cell in disabled state */ + disabledCellStyle?: StyleProp + + /** Style for cell in active state */ + activeCellStyle?: StyleProp + + /** React node to be rendered on the left side of the code input */ + leftComponent?: ReactNode + + /** React node to be rendered on the right side of the code input */ + rightComponent?: ReactNode +} // Main component export const CodeInput = forwardRef( @@ -135,6 +199,25 @@ export const CodeInput = forwardRef( autoFocus, disabled, testID = 'code-input', + containerStyle, + label, + labelComponent, + labelStyle, + labelProps, + isRequire, + helperText, + helperComponent, + helperTextProps, + errorText, + errorProps, + error, + errorCellStyle, + success, + successCellStyle, + disabledCellStyle, + activeCellStyle, + leftComponent, + rightComponent, ...textInputProps }, ref, @@ -145,7 +228,7 @@ export const CodeInput = forwardRef( const [isFocused, setIsFocused] = useState(false) // Use theme defaults with props override - const actualLength = length ?? CodeInputTheme.length + const actualLength = length ?? 6 // Default length is 6 // Use controlled or uncontrolled value const code = controlledValue !== undefined ? controlledValue : internalValue @@ -295,7 +378,15 @@ export const CodeInput = forwardRef( const isCellFocused = isFocused && cellIndex === focusedCellIndex const hasCellValue = Boolean(cellValue) - const cellStyles = [cellStyle, hasCellValue && filledCellStyle, isCellFocused && focusCellStyle] + // Apply styles in priority order: disabled > error > success > focused > filled > default + const cellStyles = [ + cellStyle, + hasCellValue && filledCellStyle, + isCellFocused && (activeCellStyle ?? focusCellStyle), + success && (successCellStyle ?? CodeInputTheme.successCellStyle), + error && (errorCellStyle ?? CodeInputTheme.errorCellStyle), + disabled && (disabledCellStyle ?? CodeInputTheme.disabledCellStyle), + ] const wrapperStyles = [cellWrapperStyle, isCellFocused && focusCellWrapperStyle] @@ -311,7 +402,11 @@ export const CodeInput = forwardRef( accessibilityLabel={`Code input cell ${cellIndex + 1} of ${length}${ cellValue ? `, contains ${cellValue}` : ', empty' }`} - accessibilityHint={`Tap to ${cellValue ? 'clear and ' : ''}enter code digit`}> + accessibilityHint={`Tap to ${cellValue ? 'clear and ' : ''}enter code digit`} + accessibilityState={{ + disabled: !!disabled, + selected: isCellFocused, + }}> {renderCellContent(cellIndex, cellValue)} @@ -324,13 +419,20 @@ export const CodeInput = forwardRef( cellStyle, filledCellStyle, focusCellStyle, + activeCellStyle, cellWrapperStyle, focusCellWrapperStyle, handleCellPress, disabled, + error, + success, + errorCellStyle, + successCellStyle, + disabledCellStyle, length, testID, renderCellContent, + CodeInputTheme, ], ) @@ -349,34 +451,74 @@ export const CodeInput = forwardRef( } return ( - - - - {cells} - + + {labelComponent ? ( + + {labelComponent} + {isRequire && *} + + ) : ( + !!label && ( + + {label} + {isRequire && *} + + ) + )} + + + + {!!leftComponent && leftComponent} + + {cells} + + {!!rightComponent && rightComponent} + + + {!!errorText && } + {!errorText && (!!helperText || !!helperComponent) && ( + + )} ) }, @@ -385,10 +527,33 @@ export const CodeInput = forwardRef( CodeInput.displayName = 'CodeInput' // Styled components -const Container = styled.View({ +const Container = styled.View({}) + +const InputWrapper = styled.View({ position: 'relative', }) +const LabelContainer = styled.View(({theme}) => ({ + marginBottom: theme?.spacing?.tiny || 8, + flexDirection: 'row', + alignItems: 'center', +})) + +const LabelText = styled.Text(({theme}) => ({ + fontSize: theme?.fontSizes?.sm || 14, + color: theme?.colors?.darkText || '#333', + marginBottom: theme?.spacing?.tiny || 8, +})) + +const RequiredStar = styled.Text(({theme}) => ({ + color: theme?.colors?.errorText || '#ff0000', +})) + +const ComponentRow = styled.View({ + flexDirection: 'row', + alignItems: 'center', +}) + const CellWrapperStyled = styled.View({}) const Cell = styled.Pressable<{disabled?: boolean}>(({theme, disabled}) => ({ diff --git a/src/components/CodeInput/README.md b/src/components/CodeInput/README.md index 3bb5ea88..471ae672 100644 --- a/src/components/CodeInput/README.md +++ b/src/components/CodeInput/README.md @@ -168,26 +168,89 @@ const styles = StyleSheet.create({ ### CodeInputProps -| Prop | Type | Default | Description | -| ---------------------- | ------------------------ | ----------- | --------------------------------------------- | -| `length` | `number` | Theme | Number of input cells (overrides theme) | -| `value` | `string` | `''` | Current input value | -| `onChangeText` | `(code: string) => void` | `undefined` | Callback when code changes | -| `onSubmit` | `(code: string) => void` | `undefined` | Callback when code is complete | -| `onClear` | `() => void` | `undefined` | Callback when code is cleared | -| `cellStyle` | `StyleProp` | Theme | Style for individual cells (overrides theme) | -| `filledCellStyle` | `StyleProp` | Theme | Style for cells with values (overrides theme) | -| `focusCellStyle` | `StyleProp` | Theme | Style for focused cell (overrides theme) | -| `textStyle` | `StyleProp` | Theme | Style for cell text (overrides theme) | -| `focusTextStyle` | `StyleProp` | Theme | Style for focused cell text (overrides theme) | -| `secureTextEntry` | `boolean` | Theme | Enable secure input mode (overrides theme) | -| `keyboardType` | `KeyboardTypeOptions` | Theme | Keyboard type to show (overrides theme) | -| `withCursor` | `boolean` | Theme | Show cursor in focused cell (overrides theme) | -| `placeholder` | `string` | Theme | Placeholder text for empty cells | -| `placeholderTextColor` | `string` | Theme | Color for placeholder text (overrides theme) | -| `placeholderAsDot` | `boolean` | Theme | Render placeholder as dot (overrides theme) | -| `autoFocus` | `boolean` | Theme | Auto focus on mount (overrides theme) | -| `disabled` | `boolean` | Theme | Disable input (overrides theme) | +#### Core Props + +| Prop | Type | Default | Description | +| -------------- | ------------------------ | ----------- | ------------------------------ | +| `length` | `number` | Theme | Number of input cells | +| `value` | `string` | `''` | Current input value | +| `onChangeText` | `(code: string) => void` | `undefined` | Callback when code changes | +| `onSubmit` | `(code: string) => void` | `undefined` | Callback when code is complete | +| `onClear` | `() => void` | `undefined` | Callback when code is cleared | +| `autoFocus` | `boolean` | Theme | Auto focus on mount | +| `disabled` | `boolean` | Theme | Disable input | +| `keyboardType` | `KeyboardTypeOptions` | Theme | Keyboard type to show | +| `testID` | `string` | `undefined` | Test ID for the component | + +#### Styling Props + +| Prop | Type | Default | Description | +| ----------------------- | ---------------------- | ----------- | ------------------------------------- | +| `containerStyle` | `StyleProp` | `undefined` | Style for outer container | +| `cellContainerStyle` | `StyleProp` | `undefined` | Style for container holding all cells | +| `cellStyle` | `StyleProp` | Theme | Style for individual cells | +| `cellWrapperStyle` | `StyleProp` | `undefined` | Style for wrapper around each cell | +| `filledCellStyle` | `StyleProp` | Theme | Style for cells with values | +| `focusCellStyle` | `StyleProp` | Theme | Style for focused cell | +| `focusCellWrapperStyle` | `StyleProp` | `undefined` | Style for wrapper around focused cell | +| `activeCellStyle` | `StyleProp` | `undefined` | Style for cell in active state | +| `errorCellStyle` | `StyleProp` | `undefined` | Style for cell in error state | +| `successCellStyle` | `StyleProp` | `undefined` | Style for cell in success state | +| `disabledCellStyle` | `StyleProp` | `undefined` | Style for cell in disabled state | + +#### Text & Secure Entry Props + +| Prop | Type | Default | Description | +| ---------------------- | ---------------------- | ----------- | -------------------------------- | +| `textStyle` | `StyleProp` | Theme | Style for cell text | +| `focusTextStyle` | `StyleProp` | Theme | Style for focused cell text | +| `secureTextEntry` | `boolean` | Theme | Enable secure input mode | +| `secureViewStyle` | `StyleProp` | `undefined` | Style for secure text entry dots | +| `placeholder` | `string` | Theme | Placeholder text for empty cells | +| `placeholderTextColor` | `string` | Theme | Color for placeholder text | +| `placeholderAsDot` | `boolean` | Theme | Render placeholder as dot | +| `placeholderDotStyle` | `StyleProp` | `undefined` | Style for placeholder dot | + +#### Cursor Props + +| Prop | Type | Default | Description | +| -------------- | ----------------- | ----------- | --------------------------- | +| `withCursor` | `boolean` | Theme | Show cursor in focused cell | +| `customCursor` | `() => ReactNode` | `undefined` | Custom cursor component | + +#### Label Props + +| Prop | Type | Default | Description | +| ---------------- | ---------------------- | ----------- | ----------------------------------------- | +| `label` | `string` | `undefined` | Label text displayed above the code input | +| `labelComponent` | `ReactNode` | `undefined` | Custom label component to replace default | +| `labelStyle` | `StyleProp` | `undefined` | Styling for the label | +| `labelProps` | `TextProps` | `undefined` | Props passed to the label Text component | +| `isRequire` | `boolean` | `undefined` | Show asterisk beside label for required | + +#### Helper & Error Text Props + +| Prop | Type | Default | Description | +| ----------------- | ----------- | ----------- | ------------------------------------------ | +| `helperText` | `string` | `undefined` | Helper text displayed below the code input | +| `helperComponent` | `ReactNode` | `undefined` | Custom helper component to replace default | +| `helperTextProps` | `TextProps` | `undefined` | Props passed to the helper text component | +| `errorText` | `string` | `undefined` | Error text displayed below the code input | +| `errorProps` | `TextProps` | `undefined` | Props passed to the error text component | + +#### State Props + +| Prop | Type | Default | Description | +| --------- | --------- | ----------- | ---------------------------- | +| `error` | `boolean` | `undefined` | Enable error state styling | +| `success` | `boolean` | `undefined` | Enable success state styling | + +#### Component Props + +| Prop | Type | Default | Description | +| ---------------- | ----------- | ----------- | ---------------------------------------------- | +| `leftComponent` | `ReactNode` | `undefined` | React node rendered on the left side of input | +| `rightComponent` | `ReactNode` | `undefined` | React node rendered on the right side of input | ## Usage Patterns @@ -284,6 +347,132 @@ const OTPInput = () => { } ``` +### Error State Handling + +```tsx +const CodeInputWithError = () => { + const [code, setCode] = useState('') + const [error, setError] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const handleCodeChange = (newCode: string) => { + setCode(newCode) + // Clear error when user types + if (error) { + setError(false) + setErrorMessage('') + } + } + + const handleCodeSubmit = async (finalCode: string) => { + try { + await verifyCode(finalCode) + // Success handling + navigation.navigate('Success') + } catch (err) { + setError(true) + setErrorMessage('Invalid code. Please try again.') + setCode('') + } + } + + return ( + + ) +} + +const styles = StyleSheet.create({ + errorCell: { + borderColor: '#FF3B30', + borderWidth: 2, + backgroundColor: '#FFF5F5', + }, +}) +``` + +### Success State Handling + +```tsx +const CodeInputWithSuccess = () => { + const [code, setCode] = useState('') + const [success, setSuccess] = useState(false) + + const handleCodeSubmit = async (finalCode: string) => { + const isValid = await verifyCode(finalCode) + + if (isValid) { + setSuccess(true) + setTimeout(() => { + navigation.navigate('NextScreen') + }, 1000) + } + } + + return ( + + ) +} + +const styles = StyleSheet.create({ + successCell: { + borderColor: '#34C759', + borderWidth: 2, + backgroundColor: '#F0FFF4', + }, +}) +``` + +### With Left & Right Components + +```tsx +import {View, TouchableOpacity} from 'react-native' +import Icon from 'react-native-vector-icons/Ionicons' + +const CodeInputWithComponents = () => { + const [code, setCode] = useState('') + + const handleClear = () => { + setCode('') + } + + return ( + } + rightComponent={ + code.length > 0 && ( + + + + ) + } + /> + ) +} +``` + ### Bank PIN with Secure Display ```tsx @@ -371,9 +560,31 @@ const customTheme = extendTheme({ fontSize: 18, fontWeight: '600', }, + labelStyle: { + // Label text styling + fontSize: 14, + fontWeight: '500', + color: '#333', + }, + errorCellStyle: { + // Style for cells in error state + borderColor: '#FF3B30', + borderWidth: 2, + }, + successCellStyle: { + // Style for cells in success state + borderColor: '#34C759', + borderWidth: 2, + }, + disabledCellStyle: { + // Style for cells in disabled state + opacity: 0.5, + backgroundColor: '#F5F5F5', + }, secureTextEntry: false, // Secure input mode keyboardType: 'number-pad', // Keyboard type autoFocus: false, // Auto focus behavior + disabled: false, // Disabled state placeholderTextColor: '#999999', // Placeholder color }, }, @@ -409,15 +620,40 @@ CodeInputTheme: { borderRadius: 8, // metrics.borderRadius backgroundColor: '#FFFFFF', // base.colors.white }, + filledCellStyle: { + borderColor: '#007AFF', // Highlight when filled + }, + focusCellStyle: { + borderColor: '#007AFF', // Highlight when focused + borderWidth: 2, + }, textStyle: { fontSize: 18, fontWeight: '600', color: '#000000', // base.colors.black }, + labelStyle: { + fontSize: 14, + fontWeight: '500', + color: '#333333', // Label text color + }, + errorCellStyle: { + borderColor: '#FF3B30', // Error state + borderWidth: 2, + }, + successCellStyle: { + borderColor: '#34C759', // Success state + borderWidth: 2, + }, + disabledCellStyle: { + opacity: 0.5, // Disabled state + backgroundColor: '#F5F5F5', + }, secureTextEntry: false, keyboardType: 'number-pad', autoFocus: false, disabled: false, + placeholderTextColor: '#999999', } ``` diff --git a/src/components/CodeInput/components/ErrorText.tsx b/src/components/CodeInput/components/ErrorText.tsx new file mode 100644 index 00000000..391aa622 --- /dev/null +++ b/src/components/CodeInput/components/ErrorText.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import type {TextProps} from 'react-native' +import styled from 'styled-components/native' + +interface ErrorTextProps { + errorText?: string + errorProps?: TextProps +} + +export const ErrorText: React.FC = ({errorText, errorProps}) => ( + {errorText} +) + +const ErrorTextStyled = styled.Text(({theme}) => ({ + fontSize: theme?.fontSizes?.['2xs'], + color: theme?.colors?.errorText, + marginTop: theme?.spacing?.tiny, +})) diff --git a/src/components/CodeInput/components/HelperText.tsx b/src/components/CodeInput/components/HelperText.tsx new file mode 100644 index 00000000..7d739162 --- /dev/null +++ b/src/components/CodeInput/components/HelperText.tsx @@ -0,0 +1,29 @@ +import React, {ReactNode} from 'react' +import type {TextProps} from 'react-native' +import styled from 'styled-components/native' + +interface HelperTextProps { + helperText?: string + helperComponent?: ReactNode + helperTextProps?: TextProps +} + +export const HelperText: React.FC = ({helperText, helperComponent, helperTextProps}) => { + // Prioritize custom component over text + if (helperComponent) { + return {helperComponent} + } + + // Fall back to text rendering + return {helperText} +} + +const HelperContainer = styled.View(({theme}) => ({ + marginTop: theme?.spacing?.tiny, +})) + +const HelperTextStyled = styled.Text(({theme}) => ({ + fontSize: theme?.fontSizes?.['2xs'], + color: theme?.colors?.gray, + marginTop: theme?.spacing?.tiny, +})) diff --git a/src/components/CodeInput/components/index.ts b/src/components/CodeInput/components/index.ts new file mode 100644 index 00000000..6026a2bf --- /dev/null +++ b/src/components/CodeInput/components/index.ts @@ -0,0 +1,2 @@ +export {ErrorText} from './ErrorText' +export {HelperText} from './HelperText' diff --git a/src/theme/components/CodeInput.ts b/src/theme/components/CodeInput.ts index d53a7bef..5c59ba06 100644 --- a/src/theme/components/CodeInput.ts +++ b/src/theme/components/CodeInput.ts @@ -1,12 +1,8 @@ -import type {StyleProp, ViewStyle, TextStyle, KeyboardTypeOptions} from 'react-native' +import type {StyleProp, ViewStyle, TextStyle} from 'react-native' import {metrics} from '../../helpers' import base from '../base' export type CodeInputThemeProps = { - /** - * Number of code input cells - */ - length: number /** * Style for individual cell */ @@ -44,45 +40,40 @@ export type CodeInputThemeProps = { */ focusCellWrapperStyle?: StyleProp /** - * Enable secure text entry mode - */ - secureTextEntry: boolean - /** - * Keyboard type for input + * Color for placeholder text */ - keyboardType: KeyboardTypeOptions + placeholderTextColor: string /** - * Show cursor in focused cell + * Style for placeholder dot */ - withCursor: boolean + placeholderDotStyle?: StyleProp /** - * Placeholder text for empty cells + * Style for outer container */ - placeholder?: string + containerStyle?: StyleProp /** - * Color for placeholder text + * Styling for the label */ - placeholderTextColor: string + labelStyle?: StyleProp /** - * Render placeholder as dot instead of text + * Style for cell in error state */ - placeholderAsDot: boolean + errorCellStyle?: StyleProp /** - * Style for placeholder dot + * Style for cell in success state */ - placeholderDotStyle?: StyleProp + successCellStyle?: StyleProp /** - * Auto focus on mount + * Style for cell in disabled state */ - autoFocus: boolean + disabledCellStyle?: StyleProp /** - * Disable input + * Style for cell in active state (alias for focusCellStyle) */ - disabled: boolean + activeCellStyle?: StyleProp } export const CodeInputTheme: CodeInputThemeProps = { - length: 6, cellStyle: { width: 50, height: 50, @@ -120,18 +111,34 @@ export const CodeInputTheme: CodeInputThemeProps = { }, cellWrapperStyle: undefined, focusCellWrapperStyle: undefined, - secureTextEntry: false, - keyboardType: 'number-pad', - withCursor: true, - placeholder: undefined, placeholderTextColor: base.colors.gray, - placeholderAsDot: false, placeholderDotStyle: { width: 6, height: 6, borderRadius: 3, backgroundColor: base.colors.gray, }, - autoFocus: false, - disabled: false, + containerStyle: undefined, + labelStyle: { + fontSize: 14, + color: base.colors.darkText, + marginBottom: 8, + }, + errorCellStyle: { + borderColor: base.colors.error, + borderWidth: 2, + }, + successCellStyle: { + borderColor: base.colors.success, + borderWidth: 2, + }, + disabledCellStyle: { + backgroundColor: '#f5f5f5', + borderColor: base.colors.primaryBorder, + opacity: 0.5, + }, + activeCellStyle: { + borderColor: base.colors.primary, + borderWidth: 2, + }, }