diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 770b124..3496df3 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -66,12 +66,47 @@ We create a few examples that you can copy paste and use in your project. ## SlotProps -| Prop | Type | Description | -| ----------------- | -------------- | -------------------------- | -| `char` | string \| null | Character in the slot | -| `isActive` | boolean | Whether the slot is active | -| `hasFakeCaret` | boolean | Whether to show fake caret | -| `placeholderChar` | string \| null | Placeholder character | +| Prop | Type | Description | +| ----------------- | -------------- | ------------------------------------------------------------------------- | +| `char` | string \| null | Character in the slot | +| `isActive` | boolean | Whether the slot is active | +| `hasFakeCaret` | boolean | Whether to show fake caret | +| `placeholderChar` | string \| null | Placeholder character | +| `focus` | () => void | Focuses the input at this slot's position, suppressing iOS clear behavior | + +Each slot exposes a `focus()` method — no ref required. Pass it to `onPress` on a wrapping `Pressable` to let users tap any slot and resume typing from there: + +```tsx + ( + + {slots.map((slot, index) => ( + + + + ))} + + )} +/> +``` + +## OTPInputRef + +Use a `ref` to call imperative methods on the input: + +```tsx +const ref = useRef(null); + +``` + +| Method | Description | +| -------------------------- | --------------------------------------------------------------------------------------------- | +| `focus()` | Focus the input | +| `blur()` | Blur the input | +| `clear()` | Clear all digits | +| `setValue(value: string)` | Set the current value programmatically | +| `focusSlot(index: number)` | Truncate the value to `index` characters and focus — making slot `index` the new active slot | ## Web support diff --git a/example/src/App.tsx b/example/src/App.tsx index e2cf8c4..a6b99d8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -16,6 +16,7 @@ import AppleNativewind from './examples/apple-nativewind'; import DashedNativewind from './examples/dashed-nativewind'; import AnimatedDashedNativewind from './examples/animated-dashed-nativewind'; import AnimatedStripeOTPInput from './examples/animated-stripe-nativewind'; +import FocusSlotExample from './examples/focus-slot'; type TabType = 'regular' | 'nativewind'; @@ -30,6 +31,13 @@ export default function App() { nativewind: StripeNativewind, color: '#6772E5', }, + { + title: 'Animated Stripe', + description: 'Stripe style with slide-in animation', + regular: AnimatedStripeOTPInput, + nativewind: AnimatedStripeOTPInput, + color: '#8B5CF6', + }, { title: 'Apple', description: 'iOS-style with rounded corners and shadows', @@ -59,11 +67,11 @@ export default function App() { color: '#8B5CF6', }, { - title: 'Animated Stripe', - description: 'Stripe style with slide-in animation', - regular: AnimatedStripeOTPInput, - nativewind: AnimatedStripeOTPInput, - color: '#8B5CF6', + title: 'Focus Slot', + description: 'Tap any slot to focus it using slot.focus()', + regular: FocusSlotExample, + nativewind: FocusSlotExample, + color: '#10B981', }, ]; @@ -116,14 +124,14 @@ export default function App() { className="mx-5 mt-5 bg-white rounded-2xl shadow-sm" > - {example.title[0]} - + */} {example.title} diff --git a/example/src/examples/animated-dashed-nativewind.tsx b/example/src/examples/animated-dashed-nativewind.tsx index bd4e70b..46fd67a 100644 --- a/example/src/examples/animated-dashed-nativewind.tsx +++ b/example/src/examples/animated-dashed-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text, Alert } from 'react-native'; +import { View, Text, Alert, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -39,9 +39,12 @@ export default function AnimatedDashedOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && ( - + ); } diff --git a/example/src/examples/animated-stripe-nativewind.tsx b/example/src/examples/animated-stripe-nativewind.tsx index f07cdfa..0bd3452 100644 --- a/example/src/examples/animated-stripe-nativewind.tsx +++ b/example/src/examples/animated-stripe-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text, Alert } from 'react-native'; +import { View, Text, Alert, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -18,32 +18,30 @@ export default function AnimatedStripeOTPInput() { const ref = useRef(null); const onComplete = (code: string) => { Alert.alert('Completed with code:', code); - ref.current?.clear(); + // ref.current?.clear(); }; return ( - - ( - - - {slots.slice(0, 3).map((slot, idx) => ( - - ))} - - - - {slots.slice(3).map((slot, idx) => ( - - ))} - + ( + + + {slots.slice(0, 3).map((slot, idx) => ( + + ))} - )} - /> - + + + {slots.slice(3).map((slot, idx) => ( + + ))} + + + )} + /> ); } @@ -51,13 +49,15 @@ function Slot({ char, isActive, hasFakeCaret, + focus, index, }: SlotProps & { index: number }) { const isFirst = index === 0; const isLast = index === 2 || index === 5; return ( - )} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/apple-nativewind.tsx b/example/src/examples/apple-nativewind.tsx index fc1d325..fca19e0 100644 --- a/example/src/examples/apple-nativewind.tsx +++ b/example/src/examples/apple-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text, Alert } from 'react-native'; +import { View, Text, Alert, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -36,9 +36,10 @@ export default function AppleOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - {char} )} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/apple.tsx b/example/src/examples/apple.tsx index 07b1f0d..0496a0d 100644 --- a/example/src/examples/apple.tsx +++ b/example/src/examples/apple.tsx @@ -1,4 +1,11 @@ -import { View, Text, StyleSheet, type ViewStyle, Alert } from 'react-native'; +import { + View, + Text, + StyleSheet, + type ViewStyle, + Alert, + Pressable, +} from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -38,12 +45,15 @@ export default function AppleOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && {char}} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/dashed-nativewind.tsx b/example/src/examples/dashed-nativewind.tsx index 8372583..cd3b649 100644 --- a/example/src/examples/dashed-nativewind.tsx +++ b/example/src/examples/dashed-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text, Alert } from 'react-native'; +import { View, Text, Alert, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -38,9 +38,12 @@ export default function DashedOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && ( {char} )} @@ -50,7 +53,7 @@ function Slot({ char, isActive, hasFakeCaret }: SlotProps) { 'bg-gray-900 h-0.5': isActive, })} /> - + ); } diff --git a/example/src/examples/dashed.tsx b/example/src/examples/dashed.tsx index 6eb1894..4ddd415 100644 --- a/example/src/examples/dashed.tsx +++ b/example/src/examples/dashed.tsx @@ -1,4 +1,11 @@ -import { View, Text, StyleSheet, type ViewStyle, Alert } from 'react-native'; +import { + View, + Text, + StyleSheet, + type ViewStyle, + Alert, + Pressable, +} from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -38,13 +45,13 @@ export default function DashedOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && {char}} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/focus-slot.tsx b/example/src/examples/focus-slot.tsx new file mode 100644 index 0000000..75d9457 --- /dev/null +++ b/example/src/examples/focus-slot.tsx @@ -0,0 +1,113 @@ +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { OTPInput, type SlotProps } from 'input-otp-native'; + +import Animated, { + useAnimatedStyle, + withRepeat, + withTiming, + withSequence, + useSharedValue, +} from 'react-native-reanimated'; +import { useEffect } from 'react'; + +export default function FocusSlotExample() { + return ( + + ( + + {slots.map((slot, index) => ( + + ))} + + )} + /> + Tap any slot to focus it + + ); +} + +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { + return ( + + {char !== null && {char}} + {hasFakeCaret && } + + ); +} + +function FakeCaret() { + const opacity = useSharedValue(1); + + useEffect(() => { + opacity.value = withRepeat( + withSequence( + withTiming(0, { duration: 500 }), + withTiming(1, { duration: 500 }) + ), + -1, + true + ); + }, [opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + alignItems: 'center', + gap: 10, + }, + slotsRow: { + flexDirection: 'row', + gap: 8, + }, + slot: { + width: 42, + height: 52, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F9FAFB', + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 8, + }, + activeSlot: { + backgroundColor: '#FFF', + borderColor: '#000', + }, + char: { + fontSize: 22, + fontWeight: '500', + color: '#111827', + }, + fakeCaretContainer: { + position: 'absolute', + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + fakeCaret: { + width: 2, + height: 28, + backgroundColor: '#000', + borderRadius: 1, + }, + hint: { + fontSize: 12, + color: '#6B7280', + }, +}); diff --git a/example/src/examples/revolt-nativewind.tsx b/example/src/examples/revolt-nativewind.tsx index db73d3d..f6c2832 100644 --- a/example/src/examples/revolt-nativewind.tsx +++ b/example/src/examples/revolt-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text, Alert } from 'react-native'; +import { View, Text, Alert, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import React, { useRef } from 'react'; @@ -41,9 +41,10 @@ export default function RevoltOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - {char} )} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/revolt.tsx b/example/src/examples/revolt.tsx index afe46ba..ce0e0b2 100644 --- a/example/src/examples/revolt.tsx +++ b/example/src/examples/revolt.tsx @@ -1,4 +1,11 @@ -import { View, Text, StyleSheet, type ViewStyle, Alert } from 'react-native'; +import { + View, + Text, + StyleSheet, + type ViewStyle, + Alert, + Pressable, +} from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import React, { useRef } from 'react'; @@ -41,12 +48,15 @@ export default function RevoltOTPInput() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && {char}} {hasFakeCaret && } - + ); } diff --git a/example/src/examples/stripe-nativewind.tsx b/example/src/examples/stripe-nativewind.tsx index 1361641..4683a90 100644 --- a/example/src/examples/stripe-nativewind.tsx +++ b/example/src/examples/stripe-nativewind.tsx @@ -1,4 +1,4 @@ -import { View, Text } from 'react-native'; +import { View, Text, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -50,6 +50,7 @@ function Slot({ char, isActive, hasFakeCaret, + focus, index, }: SlotProps & { index: number }) { const isFirst = index === 0; @@ -77,7 +78,8 @@ function Slot({ })); return ( - {hasFakeCaret && } - + ); } diff --git a/example/src/examples/stripe.tsx b/example/src/examples/stripe.tsx index 9e28f61..ebed58b 100644 --- a/example/src/examples/stripe.tsx +++ b/example/src/examples/stripe.tsx @@ -1,4 +1,11 @@ -import { View, Text, StyleSheet, type ViewStyle, Alert } from 'react-native'; +import { + View, + Text, + StyleSheet, + type ViewStyle, + Alert, + Pressable, +} from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; import type { OTPInputRef } from 'input-otp-native'; import { useRef } from 'react'; @@ -47,13 +54,15 @@ function Slot({ char, isActive, hasFakeCaret, + focus, index, }: SlotProps & { index: number }) { const isFirst = index === 0; const isLast = index === 2; return ( - {char !== null && {char}} {hasFakeCaret && } - + ); } diff --git a/package.json b/package.json index b3a9beb..aa3cd8d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ], "scripts": { "example": "yarn workspace input-otp-native-example", - "docs": "yarn workspace docs", + "docs": "yarn workspace docs run dev", "test": "jest", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", diff --git a/src/input.test.tsx b/src/input.test.tsx index e04e583..50c11b7 100644 --- a/src/input.test.tsx +++ b/src/input.test.tsx @@ -220,6 +220,173 @@ describe('OTPInput', () => { expect(input.props.value).toBe(''); }); + test('does not clear input on container press when clearTextOnFocus is false', async () => { + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123'); + + const container = await screen.findByTestId('otp-input-container'); + fireEvent.press(container); + + expect(input.props.value).toBe('123'); + }); + + test('cursor is always locked to the end of the value', async () => { + render( + + ); + const input = await screen.findByTestId('otp-input'); + + simulateTyping(input, '12'); + + // selection prop should always be at end + expect(input.props.selection).toEqual({ start: 2, end: 2 }); + }); + + test('cursor resets to end when onSelectionChange fires with wrong position', async () => { + render( + + ); + const input = await screen.findByTestId('otp-input'); + + simulateTyping(input, '12'); + + // Simulate cursor moved to position 0 by arrow key + fireEvent(input, 'selectionChange', { + nativeEvent: { selection: { start: 0, end: 0 } }, + }); + + // After the state reset re-render, selection prop is back at end + expect(input.props.selection).toEqual({ start: 2, end: 2 }); + }); + + describe('slot.focus', () => { + test('each slot has a focus function', async () => { + let capturedSlots: SlotProps[] = []; + const captureRender: InputOTPRenderFn = (props: RenderProps) => { + capturedSlots = props.slots; + return ; + }; + + render( + + ); + + await screen.findByTestId('otp-input'); + expect(capturedSlots).toHaveLength(4); + capturedSlots.forEach((slot) => { + expect(typeof slot.focus).toBe('function'); + }); + }); + + test('slot.focus() truncates value to the slot index', async () => { + let capturedSlots: SlotProps[] = []; + const captureRender: InputOTPRenderFn = (props: RenderProps) => { + capturedSlots = props.slots; + return ; + }; + + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + capturedSlots[3]!.focus(); + }); + + expect(input.props.value).toBe('123'); + expect(onChangeMock).toHaveBeenCalledWith('123'); + }); + + test('slot.focus() suppresses subsequent iOS clearTextOnFocus empty string', async () => { + let capturedSlots: SlotProps[] = []; + const captureRender: InputOTPRenderFn = (props: RenderProps) => { + capturedSlots = props.slots; + return ; + }; + + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + capturedSlots[3]!.focus(); + }); + + // Simulate iOS native clearTextOnFocus firing onChangeText('') + fireEvent.changeText(input, ''); + + // Value should remain at '123', not cleared + expect(input.props.value).toBe('123'); + }); + + test('subsequent onChangeText calls work normally after suppression is consumed', async () => { + let capturedSlots: SlotProps[] = []; + const captureRender: InputOTPRenderFn = (props: RenderProps) => { + capturedSlots = props.slots; + return ; + }; + + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + capturedSlots[3]!.focus(); + }); + + // Consume the suppression with simulated iOS clear + fireEvent.changeText(input, ''); + expect(input.props.value).toBe('123'); + + // Subsequent typing should work normally + fireEvent.changeText(input, '1234'); + expect(input.props.value).toBe('1234'); + expect(onChangeMock).toHaveBeenCalledWith('1234'); + }); + }); + test('handles focus and blur events', async () => { render( ( containerStyle, onComplete, render, + clearTextOnFocus = true, ...props }, ref @@ -53,6 +54,10 @@ export const OTPInput = React.forwardRef( }, blur: () => inputRef.current?.blur(), clear: actions.clear, + focusSlot: (index: number) => { + actions.focusSlot(index); + handlers.onFocus(); + }, })); const renderedChildren = React.useMemo(() => { @@ -64,8 +69,8 @@ export const OTPInput = React.forwardRef( const onPress = React.useCallback(() => { actions.focus(); - actions.clear(); - }, [actions]); + if (clearTextOnFocus) actions.clear(); + }, [actions, clearTextOnFocus]); return ( ( ( caretHidden={Platform.OS === 'ios'} textContentType="oneTimeCode" autoComplete={Platform.OS === 'android' ? 'sms-otp' : 'one-time-code'} - clearTextOnFocus + clearTextOnFocus={clearTextOnFocus} accessible accessibilityRole="text" testID="otp-input" diff --git a/src/types.ts b/src/types.ts index f628f64..9ab8177 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ export interface SlotProps { char: string | null; placeholderChar: string | null; hasFakeCaret: boolean; + /** Focuses the input at this slot's position. Automatically suppresses iOS clearTextOnFocus. */ + focus: () => void; } export interface RenderProps { @@ -38,6 +40,12 @@ export type InputOTPRenderFn = (props: RenderProps) => React.ReactNode; export type OTPInputProps = OTPInputBaseProps & { render?: InputOTPRenderFn; + /** + * Whether to clear the input when it receives focus. + * Not needed when using `slot.focus` — suppression is handled automatically. + * @default true + */ + clearTextOnFocus?: boolean; }; export type OTPInputRef = { @@ -45,4 +53,5 @@ export type OTPInputRef = { focus: () => void; blur: () => void; clear: () => void; + focusSlot: (index: number) => void; }; diff --git a/src/use-input.test.tsx b/src/use-input.test.tsx index 18ac638..9505a20 100644 --- a/src/use-input.test.tsx +++ b/src/use-input.test.tsx @@ -1,6 +1,10 @@ import { act, renderHook } from '@testing-library/react-native'; import { useInput } from './use-input'; -import { TextInput } from 'react-native'; +import { + TextInput, + type NativeSyntheticEvent, + type TextInputSelectionChangeEventData, +} from 'react-native'; describe('useInput', () => { const defaultProps = { @@ -114,6 +118,24 @@ describe('useInput', () => { expect(onChange).toHaveBeenCalledWith('1234'); }); + test('onSelectionChange resets cursor when it moves from end', () => { + const { result } = renderHook(() => useInput({ maxLength: 4 })); + + act(() => { + result.current.handlers.onChangeText('12'); + }); + + // Simulate cursor moving to position 0 (arrow key navigation) + act(() => { + result.current.handlers.onSelectionChange({ + nativeEvent: { selection: { start: 0, end: 0 } }, + } as NativeSyntheticEvent); + }); + + // After re-render, value is unchanged (no data was lost) + expect(result.current.value).toBe('12'); + }); + test('does not apply pasteTransformer on normal typing', () => { const onChange = jest.fn(); const pasteTransformer = jest.fn((text) => text.replace(/\D/g, '')); @@ -162,6 +184,99 @@ describe('useInput', () => { expect(result.current.value).toBe(''); }); + + describe('focusSlot', () => { + test('truncates value to the given index', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useInput({ maxLength: 6, defaultValue: '123456', onChange }) + ); + + act(() => { + result.current.actions.focusSlot(3); + }); + + expect(result.current.value).toBe('123'); + expect(onChange).toHaveBeenCalledWith('123'); + }); + + test('focusSlot(0) clears the value entirely', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useInput({ maxLength: 6, defaultValue: '123456', onChange }) + ); + + act(() => { + result.current.actions.focusSlot(0); + }); + + expect(result.current.value).toBe(''); + expect(onChange).toHaveBeenCalledWith(''); + }); + + test('clamps index above maxLength to maxLength', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useInput({ maxLength: 4, defaultValue: '1234', onChange }) + ); + + act(() => { + result.current.actions.focusSlot(10); + }); + + expect(result.current.value).toBe('1234'); + expect(onChange).toHaveBeenCalledWith('1234'); + }); + + test('clamps negative index to 0', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useInput({ maxLength: 4, defaultValue: '1234', onChange }) + ); + + act(() => { + result.current.actions.focusSlot(-1); + }); + + expect(result.current.value).toBe(''); + expect(onChange).toHaveBeenCalledWith(''); + }); + + test('focuses the input', () => { + const mockFocus = jest.fn(); + const { result } = renderHook(() => + useInput({ maxLength: 4, defaultValue: '1234' }) + ); + + (result.current.inputRef as React.MutableRefObject).current = + { + focus: mockFocus, + clear: jest.fn(), + } as unknown as TextInput; + + act(() => { + result.current.actions.focusSlot(2); + }); + + expect(mockFocus).toHaveBeenCalled(); + }); + + test('marks correct slot as active after focusSlot', () => { + const { result } = renderHook(() => + useInput({ maxLength: 6, defaultValue: '123456' }) + ); + + act(() => { + result.current.actions.focusSlot(3); + result.current.handlers.onFocus(); + }); + + const slots = result.current.contextValue.slots; + expect(slots[3]?.isActive).toBe(true); + expect(slots[2]?.isActive).toBe(false); + expect(slots[4]?.isActive).toBe(false); + }); + }); }); describe('Slots', () => { diff --git a/src/use-input.tsx b/src/use-input.tsx index 934adbe..b1661a6 100644 --- a/src/use-input.tsx +++ b/src/use-input.tsx @@ -1,5 +1,9 @@ import * as React from 'react'; -import { TextInput } from 'react-native'; +import { + TextInput, + type NativeSyntheticEvent, + type TextInputSelectionChangeEventData, +} from 'react-native'; import type { OTPInputProps, RenderProps } from './types'; export function useInput({ @@ -35,11 +39,18 @@ export function useInput({ ); const inputRef = React.useRef(null); + const suppressNextClearOnFocusRef = React.useRef(false); const [isFocused, setIsFocused] = React.useState(false); + const [, setSelectionResetTick] = React.useState(0); + const onChangeText = React.useCallback( (text: string) => { + if (suppressNextClearOnFocusRef.current && text === '') { + suppressNextClearOnFocusRef.current = false; + return; + } // Detect paste operation: if text length increases by more than 1 character // it's likely a paste operation rather than normal typing const isPaste = text.length > value.length + 1; @@ -68,6 +79,17 @@ export function useInput({ setIsFocused(false); }, []); + const onSelectionChange = React.useCallback( + (e: NativeSyntheticEvent) => { + const { start, end } = e.nativeEvent.selection; + if (start !== value.length || end !== value.length) { + // Cursor moved away from end — force re-render to snap it back + setSelectionResetTick((n) => n + 1); + } + }, + [value.length] + ); + const clear = React.useCallback(() => { inputRef.current?.clear(); setValue(''); @@ -77,6 +99,18 @@ export function useInput({ inputRef.current?.focus(); }, []); + const focusSlot = React.useCallback( + (index: number) => { + suppressNextClearOnFocusRef.current = true; + const clampedIndex = Math.max(0, Math.min(index, maxLength)); + const newValue = value.substring(0, clampedIndex); + setValue(newValue); + _onChange?.(newValue); + inputRef.current?.focus(); + }, + [value, maxLength, _onChange] + ); + const contextValue = React.useMemo(() => { return { slots: Array.from({ length: maxLength }).map((_, slotIdx) => { @@ -90,11 +124,12 @@ export function useInput({ placeholderChar, isActive, hasFakeCaret: isActive && char === null, + focus: () => focusSlot(slotIdx), }; }), isFocused, }; - }, [isFocused, maxLength, value, placeholder]); + }, [isFocused, maxLength, value, placeholder, focusSlot]); return { inputRef, @@ -105,10 +140,12 @@ export function useInput({ onChangeText, onFocus, onBlur, + onSelectionChange, }, actions: { clear, focus, + focusSlot, }, }; }