eletrotupi / tcc/ frontend/components/ui/CodeInput.tsxc901c35af9c84b6a29e5185545ced1129f33b8f4
4.4 KB | 173 lines Raw
1
import React, {
2
  forwardRef,
3
  useImperativeHandle,
4
  useRef,
5
  useState,
6
} from 'react';
7
import { StyleSheet, TextInput, View } from 'react-native';
8
import { Text } from '@/components/ui/Text';
9
import { useThemeColor } from '@/hooks/use-theme-color';
10
import { BorderRadius, Spacing } from '@/constants/theme';
11

12
export interface CodeInputHandle {
13
  focus: () => void;
14
}
15

16
interface CodeInputProps {
17
  value: string;
18
  onChange: (value: string) => void;
19
  length?: number;
20
  error?: string;
21
  editable?: boolean;
22
  testIdPrefix?: string;
23
  accessibilityLabel?: string;
24
  onComplete?: (value: string) => void;
25
}
26

27
// A single hidden TextInput backs the six boxes: it keeps paste and the
28
// platform one-time-code autofill working, and exposes the code as one
29
// accessible control instead of six unrelated fields.
30
export const CodeInput = forwardRef<CodeInputHandle, CodeInputProps>(
31
  (
32
    {
33
      value,
34
      onChange,
35
      length = 6,
36
      error,
37
      editable = true,
38
      testIdPrefix = 'code-input',
39
      accessibilityLabel,
40
      onComplete,
41
    },
42
    ref,
43
  ) => {
44
    const inputRef = useRef<TextInput>(null);
45
    const [focused, setFocused] = useState(false);
46
    const [caret, setCaret] = useState(0);
47

48
    const textColor = useThemeColor({}, 'text');
49
    const backgroundColor = useThemeColor({}, 'inputBackgroundColor');
50
    const borderColor = useThemeColor({}, 'inputBorderColor');
51
    const focusBorderColor = useThemeColor({}, 'inputFocusBorderColor');
52
    const errorColor = useThemeColor({}, 'danger');
53

54
    useImperativeHandle(ref, () => ({
55
      focus: () => inputRef.current?.focus(),
56
    }));
57

58
    const digits = Array.from({ length }, (_, index) => value[index] ?? '');
59
    const cursor = Math.min(caret, value.length);
60
    const activeIndex = focused && cursor < length ? cursor : -1;
61

62
    const handleChangeText = (text: string) => {
63
      const sanitized = text.replace(/\D/g, '').slice(0, length);
64

65
      if (sanitized === value) return;
66

67
      onChange(sanitized);
68
      setCaret(sanitized.length);
69

70
      if (sanitized.length === length) {
71
        onComplete?.(sanitized);
72
      }
73
    };
74

75
    return (
76
      <View style={styles.container}>
77
        <View style={styles.boxes}>
78
          {digits.map((digit, index) => {
79
            const isActive = index === activeIndex;
80
            const boxBorderColor = error
81
              ? errorColor
82
              : isActive
83
                ? focusBorderColor
84
                : borderColor;
85

86
            return (
87
              <View
88
                key={index}
89
                accessible={false}
90
                testID={`${testIdPrefix}-box-${index}`}
91
                style={[
92
                  styles.box,
93
                  {
94
                    backgroundColor,
95
                    borderColor: boxBorderColor,
96
                    borderWidth: isActive || error ? 2 : 1,
97
                  },
98
                ]}
99
              >
100
                <Text style={[styles.digit, { color: textColor }]}>{digit}</Text>
101
              </View>
102
            );
103
          })}
104

105
          <TextInput
106
            ref={inputRef}
107
            testID={`${testIdPrefix}-input`}
108
            value={value}
109
            onChangeText={handleChangeText}
110
            onFocus={() => setFocused(true)}
111
            onBlur={() => setFocused(false)}
112
            onSelectionChange={(event) =>
113
              setCaret(event.nativeEvent.selection.end)
114
            }
115
            keyboardType="number-pad"
116
            inputMode="numeric"
117
            textContentType="oneTimeCode"
118
            autoComplete="one-time-code"
119
            editable={editable}
120
            caretHidden
121
            accessibilityLabel={accessibilityLabel}
122
            style={styles.hiddenInput}
123
          />
124
        </View>
125

126
        {error ? (
127
          <Text
128
            accessibilityLiveRegion="polite"
129
            style={[styles.errorText, { color: errorColor }]}
130
          >
131
            {error}
132
          </Text>
133
        ) : null}
134
      </View>
135
    );
136
  },
137
);
138

139
CodeInput.displayName = 'CodeInput';
140

141
const styles = StyleSheet.create({
142
  container: {
143
    marginBottom: 16,
144
  },
145
  boxes: {
146
    flexDirection: 'row',
147
    gap: Spacing.inlineGapSm,
148
    height: 48,
149
  },
150
  box: {
151
    flex: 1,
152
    alignItems: 'center',
153
    justifyContent: 'center',
154
    borderRadius: BorderRadius.md,
155
  },
156
  digit: {
157
    fontSize: 24,
158
    fontWeight: '600',
159
  },
160
  hiddenInput: {
161
    position: 'absolute',
162
    top: 0,
163
    left: 0,
164
    right: 0,
165
    bottom: 0,
166
    opacity: 0.01,
167
    color: 'transparent',
168
  },
169
  errorText: {
170
    fontSize: 12,
171
    marginTop: 6,
172
  },
173
});