From 67f1bec76e382130bcb9d2fb20da5c14be4d9530 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Tue, 3 Mar 2026 15:18:06 +0000 Subject: [PATCH 1/5] feat: introduce `focusSlot` API to `OTPInputRef` for programmatic slot focusing --- src/input.test.tsx | 87 +++++++++++++++++++++++++++++++++++++++ src/input.tsx | 4 ++ src/types.ts | 1 + src/use-input.test.tsx | 93 ++++++++++++++++++++++++++++++++++++++++++ src/use-input.tsx | 12 ++++++ 5 files changed, 197 insertions(+) diff --git a/src/input.test.tsx b/src/input.test.tsx index e04e583..ea329a2 100644 --- a/src/input.test.tsx +++ b/src/input.test.tsx @@ -359,6 +359,93 @@ describe('OTPInput', () => { expect(cells.props['data-focused']).toBe(false); }); + describe('focusSlot', () => { + test('truncates value to the given index and focuses', async () => { + const ref = React.createRef(); + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + ref.current?.focusSlot(3); + }); + + expect(input.props.value).toBe('123'); + expect(onChangeMock).toHaveBeenCalledWith('123'); + }); + + test('focusSlot(0) clears all slots', async () => { + const ref = React.createRef(); + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + ref.current?.focusSlot(0); + }); + + expect(input.props.value).toBe(''); + expect(onChangeMock).toHaveBeenCalledWith(''); + }); + + test('focusSlot beyond maxLength leaves value unchanged', async () => { + const ref = React.createRef(); + render( + + ); + + const input = await screen.findByTestId('otp-input'); + simulateTyping(input, '123456'); + + await act(async () => { + ref.current?.focusSlot(10); + }); + + expect(input.props.value).toBe('123456'); + }); + + test('focusSlot focuses the input', async () => { + const ref = React.createRef(); + render( + + ); + + const cells = await screen.findByTestId('otp-cells'); + + await act(async () => { + ref.current?.focusSlot(2); + }); + + expect(cells.props['data-focused']).toBe(true); + }); + }); + test('clear method clears the input through ref', async () => { const ref = React.createRef(); render( diff --git a/src/input.tsx b/src/input.tsx index 49329b9..98051b8 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -53,6 +53,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(() => { diff --git a/src/types.ts b/src/types.ts index f628f64..4505fa9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,4 +45,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..65bf999 100644 --- a/src/use-input.test.tsx +++ b/src/use-input.test.tsx @@ -162,6 +162,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..12ac3f1 100644 --- a/src/use-input.tsx +++ b/src/use-input.tsx @@ -77,6 +77,17 @@ export function useInput({ inputRef.current?.focus(); }, []); + const focusSlot = React.useCallback( + (index: number) => { + 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) => { @@ -109,6 +120,7 @@ export function useInput({ actions: { clear, focus, + focusSlot, }, }; } From f3a7ea6b551b47081375af3d0e0e62eb755263e6 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Tue, 3 Mar 2026 15:18:31 +0000 Subject: [PATCH 2/5] docs: focusSlot docs --- docs/astro.config.mjs | 4 + docs/src/content/docs/getting-started.mdx | 45 +++++++ docs/src/content/docs/guides/focus-slot.mdx | 35 +++++ example/src/App.tsx | 22 +++- .../examples/animated-stripe-nativewind.tsx | 42 +++--- example/src/examples/focus-slot.tsx | 120 ++++++++++++++++++ package.json | 2 +- 7 files changed, 240 insertions(+), 30 deletions(-) create mode 100644 docs/src/content/docs/guides/focus-slot.mdx create mode 100644 example/src/examples/focus-slot.tsx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index a54b889..85deaf4 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -29,6 +29,10 @@ export default defineConfig({ label: 'Examples', autogenerate: { directory: 'examples' }, }, + { + label: 'Guides', + autogenerate: { directory: 'guides' }, + }, ], customCss: [ './src/styles/global.css', diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 770b124..cfec88c 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -73,6 +73,51 @@ We create a few examples that you can copy paste and use in your project. | `hasFakeCaret` | boolean | Whether to show fake caret | | `placeholderChar` | string \| null | Placeholder character | +## 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 | + +### focusSlot + +`focusSlot(index)` lets users jump to any slot by tapping it. Wrap each slot in a `Pressable` and call `ref.current?.focusSlot(index)` in its `onPress`: + +```tsx +const ref = useRef(null); + + ( + + {slots.map((slot, index) => ( + ref.current?.focusSlot(index)}> + + + ))} + + )} +/> +``` + +Tapping slot `3` on a fully filled `"123456"` → value becomes `"123"`, slot `3` is now active. + +- `focusSlot(0)` — equivalent to `clear()`, resets everything +- `focusSlot(maxLength)` — moves focus to the end without clearing +- Indexes outside `[0, maxLength]` are clamped automatically + ## Web support The library is mainly inspired by [otp-input](https://github.com/guilhermerodz/input-otp) and has a similar API, so we recommend using it on the web. diff --git a/docs/src/content/docs/guides/focus-slot.mdx b/docs/src/content/docs/guides/focus-slot.mdx new file mode 100644 index 0000000..06adde5 --- /dev/null +++ b/docs/src/content/docs/guides/focus-slot.mdx @@ -0,0 +1,35 @@ +--- +title: Focus Slot +description: Tap any OTP slot to jump focus to it using focusSlot(index) +head: + - tag: title + content: Focus Slot | input-otp-native imperative ref API +--- + +import { Code } from '@astrojs/starlight/components'; +import focusSlotCode from '@example/src/examples/focus-slot.tsx?raw'; + +`focusSlot(index)` is an imperative method on `OTPInputRef` that truncates the +value to `index` characters and focuses the input — making slot `index` the new +active slot. This lets users tap any slot directly to resume typing from there. + +## How it works + +Wrap every slot in a `Pressable` and call `ref.current?.focusSlot(index)` in +its `onPress`. The library handles the rest: + +- Truncates the current value to `index` characters +- Focuses the underlying `TextInput` +- The standard sequential-input logic takes over from that slot + +## Example + + + +## Edge cases + +| Call | Result | +| ---------------------------------- | --------------------------------------------- | +| `focusSlot(0)` | Clears all digits (equivalent to `clear()`) | +| `focusSlot(maxLength)` | Focuses the end without clearing any digits | +| `focusSlot(-1)` or `focusSlot(99)` | Clamped to `[0, maxLength]` automatically | diff --git a/example/src/App.tsx b/example/src/App.tsx index e2cf8c4..6ab9041 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 focusSlot(index)', + 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-stripe-nativewind.tsx b/example/src/examples/animated-stripe-nativewind.tsx index f07cdfa..e5191a7 100644 --- a/example/src/examples/animated-stripe-nativewind.tsx +++ b/example/src/examples/animated-stripe-nativewind.tsx @@ -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) => ( + + ))} + + + )} + /> ); } diff --git a/example/src/examples/focus-slot.tsx b/example/src/examples/focus-slot.tsx new file mode 100644 index 0000000..1b8ad01 --- /dev/null +++ b/example/src/examples/focus-slot.tsx @@ -0,0 +1,120 @@ +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { OTPInput, type SlotProps } from 'input-otp-native'; +import type { OTPInputRef } from 'input-otp-native'; +import { useRef } from 'react'; + +import Animated, { + useAnimatedStyle, + withRepeat, + withTiming, + withSequence, + useSharedValue, +} from 'react-native-reanimated'; +import { useEffect } from 'react'; + +export default function FocusSlotExample() { + const ref = useRef(null); + + return ( + + ( + + {slots.map((slot, index) => ( + ref.current?.focusSlot(index)} + > + + + ))} + + )} + /> + Tap any slot to focus it + + ); +} + +function Slot({ char, isActive, hasFakeCaret }: 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/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}\"", From b7dcde81748865ccb711a0071495440d6522bc78 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Tue, 3 Mar 2026 15:50:05 +0000 Subject: [PATCH 3/5] fix: lock cursor to end to prevent arrow key navigation issues When users navigate the hidden TextInput with arrow keys, the cursor could move backwards causing Backspace to delete the wrong character or do nothing. Lock the cursor at the end at all times using the `selection` prop and an `onSelectionChange` handler that forces a re-render to snap it back if it drifts. --- src/input.test.tsx | 37 +++++++++++++++++++++++++++++++++++++ src/input.tsx | 2 ++ src/use-input.test.tsx | 24 +++++++++++++++++++++++- src/use-input.tsx | 20 +++++++++++++++++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/input.test.tsx b/src/input.test.tsx index ea329a2..ae4834b 100644 --- a/src/input.test.tsx +++ b/src/input.test.tsx @@ -220,6 +220,43 @@ describe('OTPInput', () => { expect(input.props.value).toBe(''); }); + 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 }); + }); + test('handles focus and blur events', async () => { render( ( onChangeText={handlers.onChangeText} onFocus={handlers.onFocus} onBlur={handlers.onBlur} + selection={{ start: value.length, end: value.length }} + onSelectionChange={handlers.onSelectionChange} placeholder={placeholder} inputMode={inputMode} /** diff --git a/src/use-input.test.tsx b/src/use-input.test.tsx index 65bf999..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, '')); diff --git a/src/use-input.tsx b/src/use-input.tsx index 12ac3f1..57506d3 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({ @@ -38,6 +42,8 @@ export function useInput({ const [isFocused, setIsFocused] = React.useState(false); + const [, setSelectionResetTick] = React.useState(0); + const onChangeText = React.useCallback( (text: string) => { // Detect paste operation: if text length increases by more than 1 character @@ -68,6 +74,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(''); @@ -116,6 +133,7 @@ export function useInput({ onChangeText, onFocus, onBlur, + onSelectionChange, }, actions: { clear, From 3b2ec815659e9d89140ddd9b1f0e6e07ed2cde56 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Tue, 3 Mar 2026 17:38:00 +0000 Subject: [PATCH 4/5] feat: expose slot.focus() directly on SlotProps Each slot now carries a `focus()` method that calls focusSlot internally, eliminating the need for an OTPInputRef just to support tappable slots. Also adds a `clearTextOnFocus` prop (default true) and automatically suppresses the iOS native clearTextOnFocus empty-string event when slot.focus() is used, so the truncated value is preserved correctly. Docs consolidate the focusSlot guide into getting-started and remove the separate guides/ section. --- docs/astro.config.mjs | 4 - docs/src/content/docs/getting-started.mdx | 58 +++--- docs/src/content/docs/guides/focus-slot.mdx | 35 ---- example/src/App.tsx | 2 +- example/src/examples/focus-slot.tsx | 10 +- src/input.test.tsx | 217 ++++++++++++-------- src/input.tsx | 8 +- src/types.ts | 8 + src/use-input.tsx | 9 +- 9 files changed, 177 insertions(+), 174 deletions(-) delete mode 100644 docs/src/content/docs/guides/focus-slot.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 85deaf4..a54b889 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -29,10 +29,6 @@ export default defineConfig({ label: 'Examples', autogenerate: { directory: 'examples' }, }, - { - label: 'Guides', - autogenerate: { directory: 'guides' }, - }, ], customCss: [ './src/styles/global.css', diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index cfec88c..3496df3 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -66,44 +66,23 @@ 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 | -## 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 | - -### focusSlot - -`focusSlot(index)` lets users jump to any slot by tapping it. Wrap each slot in a `Pressable` and call `ref.current?.focusSlot(index)` in its `onPress`: +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 -const ref = useRef(null); - ( {slots.map((slot, index) => ( - ref.current?.focusSlot(index)}> + ))} @@ -112,11 +91,22 @@ const ref = useRef(null); /> ``` -Tapping slot `3` on a fully filled `"123456"` → value becomes `"123"`, slot `3` is now active. +## OTPInputRef + +Use a `ref` to call imperative methods on the input: + +```tsx +const ref = useRef(null); + +``` -- `focusSlot(0)` — equivalent to `clear()`, resets everything -- `focusSlot(maxLength)` — moves focus to the end without clearing -- Indexes outside `[0, maxLength]` are clamped automatically +| 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/docs/src/content/docs/guides/focus-slot.mdx b/docs/src/content/docs/guides/focus-slot.mdx deleted file mode 100644 index 06adde5..0000000 --- a/docs/src/content/docs/guides/focus-slot.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Focus Slot -description: Tap any OTP slot to jump focus to it using focusSlot(index) -head: - - tag: title - content: Focus Slot | input-otp-native imperative ref API ---- - -import { Code } from '@astrojs/starlight/components'; -import focusSlotCode from '@example/src/examples/focus-slot.tsx?raw'; - -`focusSlot(index)` is an imperative method on `OTPInputRef` that truncates the -value to `index` characters and focuses the input — making slot `index` the new -active slot. This lets users tap any slot directly to resume typing from there. - -## How it works - -Wrap every slot in a `Pressable` and call `ref.current?.focusSlot(index)` in -its `onPress`. The library handles the rest: - -- Truncates the current value to `index` characters -- Focuses the underlying `TextInput` -- The standard sequential-input logic takes over from that slot - -## Example - - - -## Edge cases - -| Call | Result | -| ---------------------------------- | --------------------------------------------- | -| `focusSlot(0)` | Clears all digits (equivalent to `clear()`) | -| `focusSlot(maxLength)` | Focuses the end without clearing any digits | -| `focusSlot(-1)` or `focusSlot(99)` | Clamped to `[0, maxLength]` automatically | diff --git a/example/src/App.tsx b/example/src/App.tsx index 6ab9041..a6b99d8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -68,7 +68,7 @@ export default function App() { }, { title: 'Focus Slot', - description: 'Tap any slot to focus it using focusSlot(index)', + description: 'Tap any slot to focus it using slot.focus()', regular: FocusSlotExample, nativewind: FocusSlotExample, color: '#10B981', diff --git a/example/src/examples/focus-slot.tsx b/example/src/examples/focus-slot.tsx index 1b8ad01..3b10113 100644 --- a/example/src/examples/focus-slot.tsx +++ b/example/src/examples/focus-slot.tsx @@ -1,7 +1,5 @@ import { View, Text, StyleSheet, Pressable } from 'react-native'; import { OTPInput, type SlotProps } from 'input-otp-native'; -import type { OTPInputRef } from 'input-otp-native'; -import { useRef } from 'react'; import Animated, { useAnimatedStyle, @@ -13,20 +11,14 @@ import Animated, { import { useEffect } from 'react'; export default function FocusSlotExample() { - const ref = useRef(null); - return ( ( {slots.map((slot, index) => ( - ref.current?.focusSlot(index)} - > + ))} diff --git a/src/input.test.tsx b/src/input.test.tsx index ae4834b..50c11b7 100644 --- a/src/input.test.tsx +++ b/src/input.test.tsx @@ -220,6 +220,25 @@ 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( { 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( { expect(cells.props['data-focused']).toBe(false); }); - describe('focusSlot', () => { - test('truncates value to the given index and focuses', async () => { - const ref = React.createRef(); - render( - - ); - - const input = await screen.findByTestId('otp-input'); - simulateTyping(input, '123456'); - - await act(async () => { - ref.current?.focusSlot(3); - }); - - expect(input.props.value).toBe('123'); - expect(onChangeMock).toHaveBeenCalledWith('123'); - }); - - test('focusSlot(0) clears all slots', async () => { - const ref = React.createRef(); - render( - - ); - - const input = await screen.findByTestId('otp-input'); - simulateTyping(input, '123456'); - - await act(async () => { - ref.current?.focusSlot(0); - }); - - expect(input.props.value).toBe(''); - expect(onChangeMock).toHaveBeenCalledWith(''); - }); - - test('focusSlot beyond maxLength leaves value unchanged', async () => { - const ref = React.createRef(); - render( - - ); - - const input = await screen.findByTestId('otp-input'); - simulateTyping(input, '123456'); - - await act(async () => { - ref.current?.focusSlot(10); - }); - - expect(input.props.value).toBe('123456'); - }); - - test('focusSlot focuses the input', async () => { - const ref = React.createRef(); - render( - - ); - - const cells = await screen.findByTestId('otp-cells'); - - await act(async () => { - ref.current?.focusSlot(2); - }); - - expect(cells.props['data-focused']).toBe(true); - }); - }); - test('clear method clears the input through ref', async () => { const ref = React.createRef(); render( diff --git a/src/input.tsx b/src/input.tsx index b82540c..4873e1d 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -27,6 +27,7 @@ export const OTPInput = React.forwardRef( containerStyle, onComplete, render, + clearTextOnFocus = true, ...props }, ref @@ -68,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 4505fa9..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 = { diff --git a/src/use-input.tsx b/src/use-input.tsx index 57506d3..b1661a6 100644 --- a/src/use-input.tsx +++ b/src/use-input.tsx @@ -39,6 +39,7 @@ export function useInput({ ); const inputRef = React.useRef(null); + const suppressNextClearOnFocusRef = React.useRef(false); const [isFocused, setIsFocused] = React.useState(false); @@ -46,6 +47,10 @@ export function useInput({ 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; @@ -96,6 +101,7 @@ export function useInput({ 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); @@ -118,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, From f1efed8c5031da9922447ad3ddcc3535a21e4b04 Mon Sep 17 00:00:00 2001 From: Youssouf EL Azizi Date: Tue, 3 Mar 2026 17:58:09 +0000 Subject: [PATCH 5/5] feat(examples): make Slot itself a Pressable using slot.focus --- .../examples/animated-dashed-nativewind.tsx | 11 +++++++---- .../examples/animated-stripe-nativewind.tsx | 8 +++++--- example/src/examples/apple-nativewind.tsx | 9 +++++---- example/src/examples/apple.tsx | 18 ++++++++++++++---- example/src/examples/dashed-nativewind.tsx | 11 +++++++---- example/src/examples/dashed.tsx | 15 +++++++++++---- example/src/examples/focus-slot.tsx | 13 +++++++------ example/src/examples/revolt-nativewind.tsx | 9 +++++---- example/src/examples/revolt.tsx | 18 ++++++++++++++---- example/src/examples/stripe-nativewind.tsx | 8 +++++--- example/src/examples/stripe.tsx | 15 ++++++++++++--- 11 files changed, 92 insertions(+), 43 deletions(-) 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 e5191a7..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'; @@ -49,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 index 3b10113..75d9457 100644 --- a/example/src/examples/focus-slot.tsx +++ b/example/src/examples/focus-slot.tsx @@ -18,9 +18,7 @@ export default function FocusSlotExample() { render={({ slots }) => ( {slots.map((slot, index) => ( - - - + ))} )} @@ -30,12 +28,15 @@ export default function FocusSlotExample() { ); } -function Slot({ char, isActive, hasFakeCaret }: SlotProps) { +function Slot({ char, isActive, hasFakeCaret, focus }: SlotProps) { return ( - + {char !== null && {char}} {hasFakeCaret && } - + ); } 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 && } - + ); }