Skip to content

Commit a8387a6

Browse files
authored
fix: iOS paste not working (#7) + Migrate example to expo 53
* fix: iOS paste not working * feat: bump example to expo 53 * feat: add defaultPasteTransformer and missing params in useInput * fix: tests * fix: drop backgroundColor * feat: better handling of paste * feat: use HOF for pasteTransformer function * fix: tests
1 parent a1c6694 commit a8387a6

File tree

5 files changed

+859
-837
lines changed

5 files changed

+859
-837
lines changed

example/package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@
99
"web": "expo start --web"
1010
},
1111
"dependencies": {
12-
"@expo/metro-runtime": "~4.0.0",
12+
"@expo/metro-runtime": "~5.0.4",
1313
"clsx": "^2.1.1",
14-
"expo": "~52.0.20",
15-
"expo-status-bar": "~2.0.0",
14+
"expo": "^53.0.0",
15+
"expo-status-bar": "~2.2.3",
1616
"nativewind": "^4.1.23",
17-
"react": "18.3.1",
18-
"react-dom": "18.3.1",
19-
"react-native": "0.76.5",
20-
"react-native-reanimated": "^3.16.6",
21-
"react-native-web": "~0.19.13",
17+
"react": "19.0.0",
18+
"react-dom": "19.0.0",
19+
"react-native": "0.79.2",
20+
"react-native-reanimated": "~3.17.4",
21+
"react-native-web": "^0.20.0",
2222
"tailwind-merge": "^2.6.0",
2323
"tailwindcss": "^3.4.17"
2424
},

src/input.test.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { View, Text, Platform } from 'react-native';
1+
import { View, Text, Platform, TextInput } from 'react-native';
22
import * as React from 'react';
33
import {
44
cleanup,
@@ -40,6 +40,22 @@ const defaultRender: InputOTPRenderFn = (props: RenderProps) => (
4040
</View>
4141
);
4242

43+
/**
44+
* Simulate typing on the input like a user would do by typing one character at a time.
45+
*
46+
* @param input - The input element to simulate typing on.
47+
* @param text - The text to simulate typing.
48+
*/
49+
const simulateTyping = (input: TextInput, text: string) => {
50+
let accumulated = '';
51+
52+
for (const char of text) {
53+
accumulated += char;
54+
fireEvent.changeText(input, accumulated);
55+
fireEvent(input, 'keyPress', { nativeEvent: { key: char } });
56+
}
57+
};
58+
4359
// Mock Platform.OS
4460
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
4561
OS: 'ios',
@@ -63,7 +79,6 @@ describe('OTPInput', () => {
6379

6480
const input = await screen.findByTestId('otp-input');
6581
expect(input).toBeTruthy();
66-
expect(input.props.maxLength).toBe(6);
6782
expect(input.props.inputMode).toBe('numeric');
6883
expect(input.props.autoComplete).toBe('one-time-code');
6984
});
@@ -128,7 +143,6 @@ describe('OTPInput', () => {
128143
);
129144

130145
const input = await screen.findByTestId('otp-input');
131-
expect(input.props.maxLength).toBe(4);
132146
expect(input.props.placeholder).toBe(placeholder);
133147
expect(input.props.inputMode).toBe('text');
134148
});
@@ -162,7 +176,7 @@ describe('OTPInput', () => {
162176
);
163177

164178
const input = await screen.findByTestId('otp-input');
165-
fireEvent.changeText(input, '123456');
179+
simulateTyping(input, '123456');
166180

167181
expect(onChangeMock).toHaveBeenCalledWith('123456');
168182
expect(onCompleteMock).toHaveBeenCalledWith('123456');
@@ -179,11 +193,11 @@ describe('OTPInput', () => {
179193
);
180194

181195
const input = await screen.findByTestId('otp-input');
182-
fireEvent.changeText(input, '12345');
196+
simulateTyping(input, '12345');
183197
expect(onChangeMock).toHaveBeenCalledWith('12345');
184198
expect(onCompleteMock).not.toHaveBeenCalled();
185199

186-
fireEvent.changeText(input, '123456');
200+
simulateTyping(input, '123456');
187201
expect(onChangeMock).toHaveBeenCalledWith('123456');
188202
expect(onCompleteMock).toHaveBeenCalledWith('123456');
189203
});
@@ -198,7 +212,7 @@ describe('OTPInput', () => {
198212
);
199213

200214
const input = await screen.findByTestId('otp-input');
201-
fireEvent.changeText(input, '123');
215+
simulateTyping(input, '123');
202216

203217
const container = await screen.findByTestId('otp-input-container');
204218
fireEvent.press(container);
@@ -240,12 +254,13 @@ describe('OTPInput', () => {
240254
const input = await screen.findByTestId('otp-input');
241255

242256
// Test invalid input
243-
fireEvent.changeText(input, 'abc');
244-
expect(onChangeMock).not.toHaveBeenCalled();
257+
simulateTyping(input, 'abc');
258+
expect(onChangeMock).toHaveBeenCalledTimes(2);
259+
expect(onChangeMock).toHaveBeenCalledWith('');
245260
expect(input.props.value).toBe('');
246261

247262
// Test valid input
248-
fireEvent.changeText(input, '123');
263+
simulateTyping(input, '123');
249264
expect(onChangeMock).toHaveBeenCalledWith('123');
250265
expect(input.props.value).toBe('123');
251266
});
@@ -260,7 +275,7 @@ describe('OTPInput', () => {
260275
);
261276

262277
const input = await screen.findByTestId('otp-input');
263-
fireEvent.changeText(input, '123456');
278+
simulateTyping(input, '123456');
264279

265280
expect(input.props.value.length).toBeLessThanOrEqual(4);
266281
});
@@ -280,11 +295,11 @@ describe('OTPInput', () => {
280295

281296
const input = await screen.findByTestId('otp-input');
282297
await act(async () => {
283-
ref.current?.setValue('123');
298+
ref.current?.setValue('1');
284299
});
285300

286-
expect(input.props.value).toBe('123');
287-
expect(onChangeMock).toHaveBeenCalledWith('123');
301+
expect(input.props.value).toBe('1');
302+
expect(onChangeMock).toHaveBeenCalledWith('1');
288303
});
289304

290305
test('focus method focuses the input through ref', async () => {
@@ -359,9 +374,9 @@ describe('OTPInput', () => {
359374

360375
// First set a value
361376
await act(async () => {
362-
ref.current?.setValue('123');
377+
ref.current?.setValue('1');
363378
});
364-
expect(input.props.value).toBe('123');
379+
expect(input.props.value).toBe('1');
365380

366381
// Then clear it
367382
await act(async () => {

src/input.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,21 @@ import { TextInput, StyleSheet, Pressable, Platform } from 'react-native';
44
import type { OTPInputProps, OTPInputRef } from './types';
55
import { useInput } from './use-input';
66

7+
/**
8+
* Default paste transformer that removes all non-numeric characters.
9+
*/
10+
const defaultPasteTransformer = (maxLength: number) => (pasted: string) => {
11+
// match exactly maxLength digits, not preceded or followed by another digit
12+
const otpRegex = new RegExp(`(?<!\\d)(\\d{${maxLength}})(?!\\d)`);
13+
const match = pasted.match(otpRegex);
14+
15+
return match?.[1] ?? '';
16+
};
17+
718
export const OTPInput = React.forwardRef<OTPInputRef, OTPInputProps>(
819
(
920
{
21+
style,
1022
onChange,
1123
maxLength,
1224
pattern,
@@ -25,6 +37,8 @@ export const OTPInput = React.forwardRef<OTPInputRef, OTPInputProps>(
2537
pattern,
2638
placeholder,
2739
defaultValue: props.defaultValue,
40+
pasteTransformer:
41+
props.pasteTransformer ?? defaultPasteTransformer(maxLength),
2842
onComplete,
2943
});
3044

@@ -62,14 +76,18 @@ export const OTPInput = React.forwardRef<OTPInputRef, OTPInputProps>(
6276
{renderedChildren}
6377
<TextInput
6478
ref={inputRef}
65-
style={styles.input}
66-
maxLength={maxLength}
79+
style={[styles.input, style]}
6780
value={value}
6881
onChangeText={handlers.onChangeText}
6982
onFocus={handlers.onFocus}
7083
onBlur={handlers.onBlur}
7184
placeholder={placeholder}
7285
inputMode={inputMode}
86+
/**
87+
* On iOS if the input has an opacity of 0, we can't paste text into it.
88+
* As we're setting the opacity to 0.02, we need to hide the caret.
89+
*/
90+
caretHidden={Platform.OS === 'ios'}
7391
autoComplete={Platform.OS === 'android' ? 'sms-otp' : 'one-time-code'}
7492
clearTextOnFocus
7593
accessible
@@ -90,7 +108,18 @@ const styles = StyleSheet.create({
90108
},
91109
input: {
92110
...StyleSheet.absoluteFillObject,
93-
opacity: 0,
94-
backgroundColor: 'red',
111+
/**
112+
* On iOS if the input has an opacity of 0, we can't paste text into it.
113+
* This is a workaround to allow pasting text into the input.
114+
*/
115+
...Platform.select({
116+
ios: {
117+
opacity: 0.02,
118+
color: 'transparent',
119+
},
120+
android: {
121+
opacity: 0,
122+
},
123+
}),
95124
},
96125
});

src/use-input.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function useInput({
4545
const isPaste = text.length > value.length + 1;
4646
const transformedText =
4747
isPaste && pasteTransformer ? pasteTransformer(text) : text;
48+
// Slice the text to the maxLength as we're not limiting the input length to handle paste properly
4849
const newValue = transformedText.slice(0, maxLength);
4950

5051
if (newValue.length > 0 && regexp && !regexp.test(newValue)) {

0 commit comments

Comments
 (0)