Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 200 additions & 35 deletions src/components/CodeInput/CodeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,9 +111,64 @@ interface CodeInputProps extends Omit<TextInputProps, 'value' | 'onChangeText' |

/** Test ID for the component */
testID?: string
}

// Default constants moved to theme configuration
/** Style for outer container */
containerStyle?: StyleProp<ViewStyle>

/** 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<TextStyle>

/** 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<ViewStyle>

/** Enable success state styling */
success?: boolean

/** Style for cell in success state */
successCellStyle?: StyleProp<ViewStyle>

/** Style for cell in disabled state */
disabledCellStyle?: StyleProp<ViewStyle>

/** Style for cell in active state */
activeCellStyle?: StyleProp<ViewStyle>

/** 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<CodeInputRef, CodeInputProps>(
Expand Down Expand Up @@ -135,6 +199,25 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
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,
Expand All @@ -145,7 +228,7 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
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
Expand Down Expand Up @@ -295,7 +378,15 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
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]

Expand All @@ -311,7 +402,11 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
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)}
</Cell>
</CellWrapperStyled>
Expand All @@ -324,13 +419,20 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
cellStyle,
filledCellStyle,
focusCellStyle,
activeCellStyle,
cellWrapperStyle,
focusCellWrapperStyle,
handleCellPress,
disabled,
error,
success,
errorCellStyle,
successCellStyle,
disabledCellStyle,
length,
testID,
renderCellContent,
CodeInputTheme,
],
)

Expand All @@ -349,34 +451,74 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
}

return (
<Container testID={testID}>
<HiddenTextInput
testID={`${testID}-hidden-input`}
ref={textInputRef}
value={code}
onChangeText={handleValueChange}
onFocus={handleFocus}
onBlur={handleBlur}
maxLength={actualLength}
keyboardType={keyboardType ?? CodeInputTheme.keyboardType}
textContentType="oneTimeCode"
autoComplete="sms-otp"
autoFocus={autoFocus ?? CodeInputTheme.autoFocus}
editable={!(disabled ?? CodeInputTheme.disabled)}
accessible={true}
accessibilityLabel={`Code input with ${actualLength} digits`}
accessibilityHint={`Enter ${actualLength} digit code`}
accessibilityValue={{
text: `${code.length} of ${actualLength} digits entered`,
}}
{...textInputProps}
/>
<CellContainer
style={cellContainerStyle}
accessible={true}
accessibilityLabel={`Code input cells, ${code.length} of ${actualLength} filled`}>
{cells}
</CellContainer>
<Container testID={testID} style={containerStyle}>
{labelComponent ? (
<LabelContainer testID={`${testID}-label-container`}>
{labelComponent}
{isRequire && <RequiredStar testID={`${testID}-required`}> *</RequiredStar>}
</LabelContainer>
) : (
!!label && (
<LabelText
testID={`${testID}-label`}
style={labelStyle ?? CodeInputTheme.labelStyle}
{...labelProps}>
{label}
{isRequire && <RequiredStar testID={`${testID}-required`}> *</RequiredStar>}
</LabelText>
)
)}
<InputWrapper>
<HiddenTextInput
testID={`${testID}-hidden-input`}
ref={textInputRef}
value={code}
onChangeText={handleValueChange}
onFocus={handleFocus}
onBlur={handleBlur}
maxLength={actualLength}
keyboardType={keyboardType ?? 'number-pad'}
textContentType="oneTimeCode"
autoComplete="sms-otp"
autoFocus={autoFocus}
editable={!disabled}
accessible={true}
accessibilityLabel={`Code input with ${actualLength} digits${error ? ', error' : ''}${
success ? ', success' : ''
}${disabled ? ', disabled' : ''}`}
accessibilityHint={`Enter ${actualLength} digit code`}
accessibilityValue={{
text: `${code.length} of ${actualLength} digits entered`,
}}
accessibilityState={{
disabled: !!disabled,
}}
{...textInputProps}
/>
<ComponentRow>
{!!leftComponent && leftComponent}
<CellContainer
style={cellContainerStyle}
accessible={true}
accessibilityLabel={`Code input cells, ${code.length} of ${actualLength} filled${
error ? ', error' : ''
}${success ? ', success' : ''}${disabled ? ', disabled' : ''}`}
accessibilityState={{
disabled: !!disabled,
}}>
{cells}
</CellContainer>
{!!rightComponent && rightComponent}
</ComponentRow>
</InputWrapper>
{!!errorText && <ErrorText errorText={errorText} errorProps={errorProps} />}
{!errorText && (!!helperText || !!helperComponent) && (
<HelperText
helperText={helperText}
helperComponent={helperComponent}
helperTextProps={helperTextProps}
/>
)}
</Container>
)
},
Expand All @@ -385,10 +527,33 @@ export const CodeInput = forwardRef<CodeInputRef, CodeInputProps>(
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}) => ({
Expand Down
Loading