Skip to content

Commit 34ae636

Browse files
authored
Add Code Input component (47) (#716)
* Add CodeInput components * Added example + final tweaks * Added tests for CodeInput * Max size to default cell * Trigger rebuild * Revert "Trigger rebuild" This reverts commit ce1b941. * v47.6.0 * Revert "v47.6.0" This reverts commit e5f3b48. * Fix version
1 parent ba40500 commit 34ae636

File tree

12 files changed

+388
-1
lines changed

12 files changed

+388
-1
lines changed

example/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import LinearProgressExample from "./LinearProgressExample";
6363
import CircularProgressExample from "./CircularProgressExample";
6464
import SectionListExample from "./SectionListExample";
6565
import VideoPlayerExample from "./VideoPlayerExample";
66+
import CodeInputExample from "./CodeInputExample";
6667

6768
const ROUTES = {
6869
AudioPlayer: AudioPlayerExample,
@@ -101,6 +102,7 @@ const ROUTES = {
101102
CircularProgress: CircularProgressExample,
102103
SectionList: SectionListExample,
103104
VideoPlayer: VideoPlayerExample,
105+
CodeInput: CodeInputExample,
104106
};
105107

106108
let customFonts = {

example/src/CodeInputExample.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react";
2+
import Section, { Container } from "./Section";
3+
import { CodeInput, CodeInputCell, CodeInputText } from "@draftbit/ui";
4+
5+
const CodeInputExample: React.FC = () => {
6+
const [value1, setValue1] = React.useState("");
7+
const [value2, setValue2] = React.useState("");
8+
9+
return (
10+
<Container style={{}}>
11+
<Section title="CodeInput (default cell)" style={{}}>
12+
<CodeInput value={value1} onChangeText={setValue1} />
13+
</Section>
14+
<Section title="CodeInput (custom cell)" style={{}}>
15+
<CodeInput
16+
value={value1}
17+
onChangeText={setValue1}
18+
onInputFull={(value) => console.log("Input full", value)}
19+
renderItem={({ cellValue, isFocused }) => {
20+
return (
21+
<CodeInputCell
22+
style={{
23+
width: 70,
24+
height: 70,
25+
shadowColor: "#000",
26+
backgroundColor: "white",
27+
shadowOffset: {
28+
width: 0,
29+
height: 4,
30+
},
31+
shadowOpacity: 0.32,
32+
shadowRadius: 5.46,
33+
elevation: 9,
34+
alignItems: "center",
35+
justifyContent: "center",
36+
}}
37+
>
38+
<CodeInputText
39+
style={{ color: isFocused ? "green" : "black", fontSize: 20 }}
40+
isFocused={isFocused}
41+
>
42+
{cellValue}
43+
</CodeInputText>
44+
</CodeInputCell>
45+
);
46+
}}
47+
/>
48+
</Section>
49+
<Section title="CodeInput (more cells)" style={{}}>
50+
<CodeInput cellCount={7} value={value2} onChangeText={setValue2} />
51+
</Section>
52+
</Container>
53+
);
54+
};
55+
56+
export default CodeInputExample;

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"lodash.isnumber": "^3.0.3",
5656
"lodash.omit": "^4.5.0",
5757
"lodash.tonumber": "^4.0.3",
58+
"react-native-confirmation-code-field": "^7.3.1",
5859
"react-native-deck-swiper": "^2.0.12",
5960
"react-native-gesture-handler": "~2.8.0",
6061
"react-native-markdown-display": "^7.0.0-alpha.2",

packages/core/src/__tests__/Debouncing.test.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react-native";
33
import TextInput from "../components/TextInput";
44
import TextField from "../components/TextField";
55
import NumberInput from "../components/NumberInput";
6+
import { CodeInput } from "../components/CodeInput";
67
import DefaultTheme, { Theme } from "../styles/DefaultTheme";
78
import { IconI } from "../interfaces/Icon";
89

@@ -39,8 +40,8 @@ describe("Text Input debouncing test", () => {
3940
const Wrapper: React.FC = () => {
4041
const [value, setValue] = React.useState("");
4142
return (
43+
//@ts-ignore
4244
<TextField
43-
//@ts-ignore
4445
theme={DefaultTheme as Theme}
4546
Icon={React.Fragment as IconI}
4647
value={value}
@@ -75,6 +76,26 @@ describe("Text Input debouncing test", () => {
7576
testDebounce(1, 23, 0, delay);
7677
}
7778
);
79+
80+
test.each([200, 500, 1000])(
81+
"should onChangeTextDelayed be called once with %s delay in CodeInput",
82+
(delay) => {
83+
const Wrapper: React.FC = () => {
84+
const [value, setValue] = React.useState("");
85+
return (
86+
<CodeInput
87+
value={value}
88+
onChangeText={(text) => setValue(text)}
89+
changeTextDelay={delay}
90+
onChangeTextDelayed={onChangeTextDelayed}
91+
/>
92+
);
93+
};
94+
95+
render(<Wrapper />);
96+
testDebounce("first", "second", "", delay);
97+
}
98+
);
7899
});
79100

80101
function testDebounce(
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from "react";
2+
import { render, screen, fireEvent, act } from "@testing-library/react-native";
3+
import { View } from "react-native";
4+
import { CodeInput, CodeInputText } from "../../components/CodeInput";
5+
import { Cursor } from "react-native-confirmation-code-field";
6+
7+
describe("CodeInput tests", () => {
8+
test("should onInputFull be called when input is full", () => {
9+
const cellCount = 6;
10+
const text = "0".repeat(cellCount);
11+
const onInputFull = jest.fn();
12+
13+
const Wrapper: React.FC = () => {
14+
const [value, setValue] = React.useState("");
15+
return (
16+
<CodeInput
17+
value={value}
18+
onChangeText={(text) => setValue(text)}
19+
cellCount={cellCount}
20+
onInputFull={onInputFull}
21+
/>
22+
);
23+
};
24+
25+
render(<Wrapper />);
26+
27+
const textInput = screen.getByTestId("native-text-input");
28+
act(() => fireEvent.changeText(textInput, text));
29+
30+
expect(onInputFull).toHaveBeenCalledTimes(1);
31+
expect(onInputFull).toHaveBeenCalledWith(text);
32+
});
33+
34+
test.each([2, 4, 6, 7, 8])(
35+
"should render %s custom input cells",
36+
(cellCount) => {
37+
render(
38+
<CodeInput
39+
renderItem={() => <View testID="test-input-cell" />}
40+
cellCount={cellCount}
41+
/>
42+
);
43+
44+
const cells = screen.queryAllByTestId("test-input-cell");
45+
expect(cells).toHaveLength(cellCount);
46+
}
47+
);
48+
49+
test.each([2, 4, 6, 7, 8])(
50+
"should render %s default input cells when renderItem not provided",
51+
(cellCount) => {
52+
render(<CodeInput cellCount={cellCount} />);
53+
54+
const cells = screen.queryAllByTestId("default-code-input-cell");
55+
expect(cells).toHaveLength(cellCount);
56+
}
57+
);
58+
59+
describe("CodeInputText tests", () => {
60+
test("should render cursor when focused and does not have a value", () => {
61+
render(<CodeInputText isFocused />);
62+
63+
const cursor = screen.UNSAFE_queryByType(Cursor);
64+
expect(cursor).toBeTruthy();
65+
});
66+
67+
test("should render text value when focused and has a value", () => {
68+
const text = "sample text";
69+
render(<CodeInputText isFocused>{text}</CodeInputText>);
70+
71+
const cursor = screen.UNSAFE_queryByType(Cursor);
72+
const componentWithText = screen.queryByText(text);
73+
expect(componentWithText).toBeTruthy();
74+
expect(cursor).toBeFalsy();
75+
});
76+
77+
test("should render text value when not focused and has a value", () => {
78+
const text = "sample text";
79+
render(<CodeInputText isFocused={false}>{text}</CodeInputText>);
80+
81+
const cursor = screen.UNSAFE_queryByType(Cursor);
82+
const componentWithText = screen.queryByText(text);
83+
expect(componentWithText).toBeTruthy();
84+
expect(cursor).toBeFalsy();
85+
});
86+
});
87+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from "react";
2+
import {
3+
StyleProp,
4+
ViewStyle,
5+
TextInput as NativeTextInput,
6+
View,
7+
} from "react-native";
8+
import TextInput, { TextInputProps } from "../TextInput";
9+
import {
10+
CodeField,
11+
useClearByFocusCell,
12+
} from "react-native-confirmation-code-field";
13+
import { DefaultCodeInputCell } from "./CodeInputCell";
14+
15+
interface CellItem {
16+
cellValue: string;
17+
index: number;
18+
isFocused: boolean;
19+
}
20+
21+
interface CodeInputProps extends TextInputProps {
22+
onInputFull?: (value: string) => void;
23+
cellCount?: number;
24+
clearOnCellFocus?: boolean;
25+
blurOnFull?: boolean;
26+
renderItem?: ({ cellValue, index, isFocused }: CellItem) => JSX.Element;
27+
style?: StyleProp<ViewStyle>;
28+
}
29+
30+
const CodeInput = React.forwardRef<NativeTextInput, CodeInputProps>(
31+
(
32+
{
33+
onInputFull,
34+
cellCount = 4,
35+
clearOnCellFocus = true,
36+
blurOnFull = true,
37+
renderItem,
38+
value,
39+
onChangeText,
40+
style,
41+
...rest
42+
},
43+
ref
44+
) => {
45+
const newCodeInputRef = React.useRef<NativeTextInput>(null);
46+
47+
// Use the provided ref or default to new ref when not provided
48+
const codeInputRef = ref
49+
? (ref as React.RefObject<NativeTextInput>)
50+
: newCodeInputRef;
51+
52+
// Clears input of a cell when focused, configured as explained here (https://github.com/retyui/react-native-confirmation-code-field/blob/master/API.md#useclearbyfocuscellvalue-string-setvalue-text-string--void)
53+
const [codeFieldProps, getCellOnLayout] = useClearByFocusCell({
54+
value,
55+
setValue: (text) => onChangeText?.(text),
56+
});
57+
58+
React.useEffect(() => {
59+
if (value?.length === cellCount) {
60+
if (blurOnFull) {
61+
codeInputRef.current?.blur();
62+
}
63+
onInputFull?.(value);
64+
}
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
}, [value, cellCount, blurOnFull, codeInputRef]);
67+
68+
return (
69+
<CodeField
70+
ref={codeInputRef}
71+
{...(clearOnCellFocus ? codeFieldProps : {})}
72+
value={value}
73+
onChangeText={onChangeText}
74+
rootStyle={style}
75+
InputComponent={TextInput}
76+
cellCount={cellCount}
77+
renderCell={({ symbol, index, isFocused }) => (
78+
<View
79+
key={index}
80+
onLayout={clearOnCellFocus ? getCellOnLayout(index) : undefined}
81+
style={{ flex: 1 }}
82+
>
83+
{renderItem?.({ cellValue: symbol, index, isFocused }) || (
84+
<DefaultCodeInputCell cellValue={symbol} isFocused={isFocused} />
85+
)}
86+
</View>
87+
)}
88+
{...rest}
89+
/>
90+
);
91+
}
92+
);
93+
94+
export default CodeInput;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from "react";
2+
import { StyleProp, ViewStyle, View, StyleSheet } from "react-native";
3+
import type { Theme } from "../../styles/DefaultTheme";
4+
import { withTheme } from "../../theming";
5+
import CodeInputText from "./CodeInputText";
6+
7+
interface CodeInputCellProps {
8+
style?: StyleProp<ViewStyle>;
9+
}
10+
11+
const CodeInputCell: React.FC<React.PropsWithChildren<CodeInputCellProps>> = ({
12+
style,
13+
children,
14+
}) => {
15+
return <View style={[styles.cell, style]} children={children} />;
16+
};
17+
18+
interface DefaultCodeInputCellProps {
19+
cellValue: string;
20+
isFocused: boolean;
21+
theme: Theme;
22+
}
23+
24+
export const DefaultCodeInputCell = withTheme(
25+
({ cellValue, isFocused, theme }: DefaultCodeInputCellProps) => {
26+
return (
27+
<View
28+
testID="default-code-input-cell"
29+
style={[
30+
styles.cell,
31+
styles.defaultCellContainer,
32+
{
33+
borderWidth: isFocused ? 2 : 1,
34+
borderColor: isFocused
35+
? theme.colors.primary
36+
: theme.colors.disabled,
37+
},
38+
]}
39+
>
40+
<CodeInputText
41+
style={[
42+
styles.defaultCellText,
43+
{
44+
color: theme.colors.strong,
45+
},
46+
]}
47+
isFocused={isFocused}
48+
>
49+
{cellValue}
50+
</CodeInputText>
51+
</View>
52+
);
53+
}
54+
);
55+
56+
const styles = StyleSheet.create({
57+
cell: { marginStart: 5, marginEnd: 5 },
58+
defaultCellContainer: {
59+
padding: 5,
60+
backgroundColor: "transparent",
61+
borderRadius: 5,
62+
justifyContent: "center",
63+
alignItems: "center",
64+
aspectRatio: 1,
65+
maxWidth: 70,
66+
maxHeight: 70,
67+
},
68+
defaultCellText: {
69+
fontSize: 25,
70+
},
71+
});
72+
73+
export default CodeInputCell;

0 commit comments

Comments
 (0)