From 037c46f96153f799088897e1484ff223d74a5e3c Mon Sep 17 00:00:00 2001 From: "hang.nguyen" Date: Thu, 23 Oct 2025 15:30:51 +0700 Subject: [PATCH 1/3] feat: add props for code input --- src/components/CodeInput/CodeInput.tsx | 231 +++++++++++++++--- .../CodeInput/components/ErrorText.tsx | 18 ++ .../CodeInput/components/HelperText.tsx | 29 +++ src/components/CodeInput/components/index.ts | 2 + src/theme/components/CodeInput.ts | 47 ++++ 5 files changed, 295 insertions(+), 32 deletions(-) create mode 100644 src/components/CodeInput/components/ErrorText.tsx create mode 100644 src/components/CodeInput/components/HelperText.tsx create mode 100644 src/components/CodeInput/components/index.ts diff --git a/src/components/CodeInput/CodeInput.tsx b/src/components/CodeInput/CodeInput.tsx index 85d268aa..ec9b932d 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,6 +111,63 @@ 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 } // Default constants moved to theme configuration @@ -135,6 +201,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, @@ -295,7 +380,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 +404,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 +421,20 @@ export const CodeInput = forwardRef( cellStyle, filledCellStyle, focusCellStyle, + activeCellStyle, cellWrapperStyle, focusCellWrapperStyle, handleCellPress, disabled, + error, + success, + errorCellStyle, + successCellStyle, + disabledCellStyle, length, testID, renderCellContent, + CodeInputTheme, ], ) @@ -349,34 +453,74 @@ export const CodeInput = forwardRef( } return ( - - - - {cells} - + + {labelComponent ? ( + + {labelComponent} + {isRequire && *} + + ) : ( + label && ( + + {label} + {isRequire && *} + + ) + )} + + + + {!!leftComponent && leftComponent} + + {cells} + + {!!rightComponent && rightComponent} + + + {!!errorText && } + {!errorText && (!!helperText || !!helperComponent) && ( + + )} ) }, @@ -385,10 +529,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/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..bf53643f 100644 --- a/src/theme/components/CodeInput.ts +++ b/src/theme/components/CodeInput.ts @@ -79,6 +79,30 @@ export type CodeInputThemeProps = { * Disable input */ disabled: boolean + /** + * Style for outer container + */ + containerStyle?: StyleProp + /** + * Styling for the label + */ + labelStyle?: StyleProp + /** + * Style for cell in error state + */ + errorCellStyle?: StyleProp + /** + * Style for cell in success state + */ + successCellStyle?: StyleProp + /** + * Style for cell in disabled state + */ + disabledCellStyle?: StyleProp + /** + * Style for cell in active state (alias for focusCellStyle) + */ + activeCellStyle?: StyleProp } export const CodeInputTheme: CodeInputThemeProps = { @@ -134,4 +158,27 @@ export const CodeInputTheme: CodeInputThemeProps = { }, 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, + }, } From 9462fc666ce2fab8e1cacdfe9a95a15326e061a8 Mon Sep 17 00:00:00 2001 From: "hang.nguyen" Date: Thu, 23 Oct 2025 16:07:45 +0700 Subject: [PATCH 2/3] feat: update CodeInput readme --- src/components/CodeInput/CodeInput.tsx | 2 +- src/components/CodeInput/README.md | 276 +++++++++++++++++++++++-- 2 files changed, 257 insertions(+), 21 deletions(-) diff --git a/src/components/CodeInput/CodeInput.tsx b/src/components/CodeInput/CodeInput.tsx index ec9b932d..ef42a23b 100644 --- a/src/components/CodeInput/CodeInput.tsx +++ b/src/components/CodeInput/CodeInput.tsx @@ -460,7 +460,7 @@ export const CodeInput = forwardRef( {isRequire && *} ) : ( - label && ( + !!label && ( 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', } ``` From 216ab1b1b0d6aff1880d80c72bc31b8f92c61682 Mon Sep 17 00:00:00 2001 From: "hang.nguyen" Date: Tue, 28 Oct 2025 09:29:13 +0700 Subject: [PATCH 3/3] fix: address feedback to remove incorrect theme props --- src/components/CodeInput/CodeInput.tsx | 10 +++--- src/theme/components/CodeInput.ts | 42 +------------------------- 2 files changed, 5 insertions(+), 47 deletions(-) diff --git a/src/components/CodeInput/CodeInput.tsx b/src/components/CodeInput/CodeInput.tsx index ef42a23b..f45694b4 100644 --- a/src/components/CodeInput/CodeInput.tsx +++ b/src/components/CodeInput/CodeInput.tsx @@ -170,8 +170,6 @@ interface CodeInputProps extends Omit( ( @@ -230,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 @@ -479,11 +477,11 @@ export const CodeInput = forwardRef( onFocus={handleFocus} onBlur={handleBlur} maxLength={actualLength} - keyboardType={keyboardType ?? CodeInputTheme.keyboardType} + keyboardType={keyboardType ?? 'number-pad'} textContentType="oneTimeCode" autoComplete="sms-otp" - autoFocus={autoFocus ?? CodeInputTheme.autoFocus} - editable={!(disabled ?? CodeInputTheme.disabled)} + autoFocus={autoFocus} + editable={!disabled} accessible={true} accessibilityLabel={`Code input with ${actualLength} digits${error ? ', error' : ''}${ success ? ', success' : '' diff --git a/src/theme/components/CodeInput.ts b/src/theme/components/CodeInput.ts index bf53643f..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 */ @@ -43,42 +39,14 @@ export type CodeInputThemeProps = { * Style for wrapper around focused cell */ focusCellWrapperStyle?: StyleProp - /** - * Enable secure text entry mode - */ - secureTextEntry: boolean - /** - * Keyboard type for input - */ - keyboardType: KeyboardTypeOptions - /** - * Show cursor in focused cell - */ - withCursor: boolean - /** - * Placeholder text for empty cells - */ - placeholder?: string /** * Color for placeholder text */ placeholderTextColor: string - /** - * Render placeholder as dot instead of text - */ - placeholderAsDot: boolean /** * Style for placeholder dot */ placeholderDotStyle?: StyleProp - /** - * Auto focus on mount - */ - autoFocus: boolean - /** - * Disable input - */ - disabled: boolean /** * Style for outer container */ @@ -106,7 +74,6 @@ export type CodeInputThemeProps = { } export const CodeInputTheme: CodeInputThemeProps = { - length: 6, cellStyle: { width: 50, height: 50, @@ -144,20 +111,13 @@ 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,