From f12e8658dd20d3b33d5e8d258fb1774bbf175a88 Mon Sep 17 00:00:00 2001 From: eliyahu-kriel <44751346+elik56a@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:34:38 +0200 Subject: [PATCH 1/2] feat(otp): add OtpField, a verification code input with SMS autofill, plus migration guides OtpField takes a one-time code in a box per character. One hidden TextInput lies over the boxes, so typing, long-press paste, screen readers and the code the OS offers from a text message all work (oneTimeCode on iOS and the web, sms-otp on Android). A pasted message keeps only the code's characters. It takes length, numeric or text input, secure dots for a PIN, autoFocus and disabled; onChange fires on user changes and onComplete once the last box is filled. The boxes are drawn like each design's text fields, with a blinking caret in the next one. New migration guides map @react-native-community/slider, @ptomasroos/react-native-multi-slider, react-native-otp-entry, react-native-confirmation-code-field and react-native-ui-datepicker to the library. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- context7.json | 2 +- package.json | 5 +- src/index.ts | 1 + src/otp/OtpField.test.tsx | 51 +++++++ src/otp/OtpField.tsx | 70 +++++++++ src/otp/components/OtpCell.tsx | 42 ++++++ src/otp/hooks/useCaretBlink.test.ts | 31 ++++ src/otp/hooks/useCaretBlink.ts | 27 ++++ src/otp/hooks/useOtpField.ts | 43 ++++++ src/otp/index.ts | 2 + src/otp/otp.constants.ts | 31 ++++ src/otp/otp.styles.ts | 11 ++ src/otp/otp.types.ts | 54 +++++++ src/otp/otp.utils.test.ts | 34 +++++ src/otp/otp.utils.ts | 22 +++ website/docs/api/reference.mdx | 2 + website/docs/components/otp-field.mdx | 134 ++++++++++++++++++ website/docs/guides/designs.mdx | 1 + website/docs/guides/styling.mdx | 1 + website/docs/guides/typescript.mdx | 2 + website/docs/intro.mdx | 3 +- website/docs/resources/ai.mdx | 3 + .../migration/confirmation-code-field.mdx | 50 +++++++ website/docs/resources/migration/index.mdx | 7 +- .../docs/resources/migration/multi-slider.mdx | 52 +++++++ .../docs/resources/migration/otp-entry.mdx | 51 +++++++ website/docs/resources/migration/slider.mdx | 53 +++++++ .../resources/migration/ui-datepicker.mdx | 56 ++++++++ website/docusaurus.config.ts | 6 +- website/sidebars.ts | 5 + website/src/catalog/catalog.constants.ts | 2 + website/src/catalog/playgrounds.tsx | 13 ++ website/src/home/components/Hero.tsx | 6 +- website/src/home/home.constants.ts | 3 +- website/src/live/live.constants.ts | 4 +- 36 files changed, 881 insertions(+), 13 deletions(-) create mode 100644 src/otp/OtpField.test.tsx create mode 100644 src/otp/OtpField.tsx create mode 100644 src/otp/components/OtpCell.tsx create mode 100644 src/otp/hooks/useCaretBlink.test.ts create mode 100644 src/otp/hooks/useCaretBlink.ts create mode 100644 src/otp/hooks/useOtpField.ts create mode 100644 src/otp/index.ts create mode 100644 src/otp/otp.constants.ts create mode 100644 src/otp/otp.styles.ts create mode 100644 src/otp/otp.types.ts create mode 100644 src/otp/otp.utils.test.ts create mode 100644 src/otp/otp.utils.ts create mode 100644 website/docs/components/otp-field.mdx create mode 100644 website/docs/resources/migration/confirmation-code-field.mdx create mode 100644 website/docs/resources/migration/multi-slider.mdx create mode 100644 website/docs/resources/migration/otp-entry.mdx create mode 100644 website/docs/resources/migration/slider.mdx create mode 100644 website/docs/resources/migration/ui-datepicker.mdx diff --git a/README.md b/README.md index 04f585e..81d6e79 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm](https://img.shields.io/npm/v/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![downloads](https://img.shields.io/npm/dw/@elik56a/react-native-pickers)](https://www.npmjs.com/package/@elik56a/react-native-pickers) [![bundle size](https://img.shields.io/bundlephobia/minzip/@elik56a/react-native-pickers)](https://bundlephobia.com/package/@elik56a/react-native-pickers) [![CI](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml/badge.svg)](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/@elik56a/react-native-pickers)](LICENSE) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/elik56a/react-native-pickers/badge)](https://scorecard.dev/viewer/?uri=github.com/elik56a/react-native-pickers) -**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers and a slider, plus the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize. +**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, a slider and an OTP code input, plus the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize.

React Native Pickers on an iPhone and an Android phone side by side: a date range picker with prices, wheel and clock-dial time pickers, a searchable select, an emoji picker, a phone number field, a color picker, a duration picker, and light and dark mode @@ -106,6 +106,7 @@ Each field opens its picker in a bottom sheet on iOS and Android and in a dropdo | [`HorizontalList`](https://react-native-pickers.dev/docs/components/horizontal-list) | Segmented control or chips that scroll sideways and reveal the next item | | [`ColorPicker`](https://react-native-pickers.dev/docs/components/color-picker) | iOS's Grid / Spectrum / Sliders, Google's swatch circles, or the browser's spectrum; hex with opacity and a copy button | | [`Slider`](https://react-native-pickers.dev/docs/components/slider) | iOS's knob, Material 3's handle with a value bubble, or a range input; one thumb or a range, steps, icons | +| [`OtpField`](https://react-native-pickers.dev/docs/components/otp-field) | A verification code in a box per character: SMS autofill on iOS, Android and the web, paste, a PIN mode | | [`EmojiPicker`](https://react-native-pickers.dev/docs/components/emoji-picker) | 1,923 emojis: categories, search, skin tones (long-press for one emoji's), recents, version filtering | | [`BottomSheet`](https://react-native-pickers.dev/docs/components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content | | [Picker sheets](https://react-native-pickers.dev/docs/components/picker-sheets) | `DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`: a picker in a sheet with Cancel / Done | @@ -304,6 +305,17 @@ The [full API reference](https://react-native-pickers.dev/docs/api/reference) li | `valueLabel` | `'never' \| 'drag' \| 'always'`: the value bubble | `'drag'` in Material | | `minimumIcon` / `maximumIcon` | `ReactNode` beside the track | | +### OtpField + +| Prop | Type | Default | +|---|---|---| +| `value` | `string`: the code so far | `''` | +| `onChange` | `(code) => void`: as the user types, deletes or pastes | | +| `onComplete` | `(code) => void`: once the last cell is filled | | +| `length` | `number` | `6` | +| `inputMode` | `'numeric' \| 'text'` | `'numeric'` | +| `secureTextEntry` | `boolean`: dots, for a PIN | `false` | + ### DurationPicker A length of time, such as a timer or a workout, rather than a time of day. The value is a number of seconds. diff --git a/context7.json b/context7.json index 00f6c48..f9f31d4 100644 --- a/context7.json +++ b/context7.json @@ -3,7 +3,7 @@ "url": "https://context7.com/elik56a/react-native-pickers", "public_key": "pk_EfgECmIIv32CQlZpPe9GW", "projectTitle": "React Native Pickers", - "description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider plus bottom sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).", + "description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider, an OTP code input plus bottom sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).", "folders": ["website/docs"], "rules": [ "Install @elik56a/react-native-pickers. It has zero native code (pure JavaScript), so it works in Expo Go without a rebuild.", diff --git a/package.json b/package.json index 878d86b..7b636a1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@elik56a/react-native-pickers", "version": "0.26.0", - "description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), plus bottom sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.", + "description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), an OTP code input, plus bottom sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.", "license": "MIT", "author": "Eliyahu Kriel ", "homepage": "https://react-native-pickers.dev/", @@ -74,6 +74,9 @@ "color-picker", "slider", "range-slider", + "otp-input", + "otp", + "verification-code", "colour-picker", "hex-color-picker", "duration-picker", diff --git a/src/index.ts b/src/index.ts index e686acb..1496544 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from './duration'; export * from './emoji'; export * from './list'; export * from './month'; +export * from './otp'; export * from './select'; export * from './sheet'; export * from './slider'; diff --git a/src/otp/OtpField.test.tsx b/src/otp/OtpField.test.tsx new file mode 100644 index 0000000..9ebf16d --- /dev/null +++ b/src/otp/OtpField.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { OtpField } from './OtpField'; + +const input = () => screen.getByLabelText('Verification code'); +const cellText = (index: number) => screen.getByTestId(`otp-cell-${index}`, { includeHiddenElements: true }); + +describe('OtpField', () => { + it('reports what is typed and completes on the last character', async () => { + const onChange = jest.fn(); + const onComplete = jest.fn(); + await render(); + await fireEvent.changeText(input(), '12'); + expect(onChange).toHaveBeenLastCalledWith('12'); + expect(onComplete).not.toHaveBeenCalled(); + await fireEvent.changeText(input(), '1234'); + expect(onComplete).toHaveBeenCalledWith('1234'); + expect(cellText(3)).toHaveTextContent('4'); + }); + + it('takes the code out of a pasted message and ignores letters on a numeric field', async () => { + const onChange = jest.fn(); + await render(); + await fireEvent.changeText(input(), 'Your code is 482-913'); + expect(onChange).toHaveBeenLastCalledWith('482913'); + onChange.mockClear(); + await fireEvent.changeText(input(), '482913x'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('shows dots when secure', async () => { + await render(); + expect(cellText(0)).toHaveTextContent('•'); + expect(cellText(1)).toHaveTextContent('•'); + }); + + it('follows a controlled value without reporting it back', async () => { + const onChange = jest.fn(); + const onComplete = jest.fn(); + const { rerender } = await render(); + await rerender(); + expect(input().props.value).toBe('5678'); + expect(onChange).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('offers the code from a text message on a number pad', async () => { + await render(); + expect(input().props).toMatchObject({ textContentType: 'oneTimeCode', keyboardType: 'number-pad', maxLength: 6 }); + }); +}); diff --git a/src/otp/OtpField.tsx b/src/otp/OtpField.tsx new file mode 100644 index 0000000..451ad73 --- /dev/null +++ b/src/otp/OtpField.tsx @@ -0,0 +1,70 @@ +import { TextInput, View } from 'react-native'; + +import { usePickerTheme } from '../core'; +import { coreStyles } from '../core/core.styles'; +import { useDesignOverride } from '../core/theme/ThemeProvider'; +import { DISABLED_TRIGGER_OPACITY, FIELD_DESIGN } from '../sheet/sheet.constants'; +import { OtpCell } from './components/OtpCell'; +import { useOtpField } from './hooks/useOtpField'; +import { DEFAULT_OTP_LABEL, OTP_AUTO_COMPLETE, OTP_INPUT_MODE } from './otp.constants'; +import { styles } from './otp.styles'; +import type { OtpFieldProps } from './otp.types'; + +/** A one-time code in a box per character. One text field underneath takes typing, pasting and the code the OS reads from a text message. */ +export const OtpField = (props: OtpFieldProps) => { + const { + inputMode = OTP_INPUT_MODE.numeric, + secureTextEntry = false, + autoFocus, + disabled = false, + accessibilityLabel = DEFAULT_OTP_LABEL, + style, + styles: slots, + theme: themeProp, + design, + testID, + } = props; + const theme = usePickerTheme(useDesignOverride(themeProp, design)); + const otp = useOtpField(props); + const look = FIELD_DESIGN[theme.design]; + + return ( + + + {otp.cells.map((cell) => ( + + ))} + + + + ); +}; diff --git a/src/otp/components/OtpCell.tsx b/src/otp/components/OtpCell.tsx new file mode 100644 index 0000000..393bd71 --- /dev/null +++ b/src/otp/components/OtpCell.tsx @@ -0,0 +1,42 @@ +import { Animated, Text, View } from 'react-native'; + +import { textStyle } from '../../core'; +import { FOCUS_RING_WIDTH } from '../../sheet/sheet.constants'; +import { CARET_HEIGHT_RATIO } from '../otp.constants'; +import { styles } from '../otp.styles'; +import type { OtpCellProps } from '../otp.types'; + +/** One character of the code in a box drawn like the design's text fields, with the caret while it's next to fill. */ +export const OtpCell = ({ cell, caretOpacity, look, theme, slots, testID }: OtpCellProps) => { + const c = theme.colors; + const borderWidth = cell.focused ? look.openBorderWidth : 1; + const fontSize = theme.typography.sizes.xl; + + return ( + + {cell.caret ? ( + + ) : ( + {cell.char} + )} + + ); +}; diff --git a/src/otp/hooks/useCaretBlink.test.ts b/src/otp/hooks/useCaretBlink.test.ts new file mode 100644 index 0000000..3300017 --- /dev/null +++ b/src/otp/hooks/useCaretBlink.test.ts @@ -0,0 +1,31 @@ +import { Animated } from 'react-native'; + +import { renderHook } from '@testing-library/react-native'; + +import { useCaretBlink } from './useCaretBlink'; + +describe('useCaretBlink', () => { + afterEach(() => jest.restoreAllMocks()); + + it('blinks while visible, restarts when the caret moves, and stops when hidden', async () => { + const stop = jest.fn(); + const loop = jest.spyOn(Animated, 'loop').mockReturnValue({ + start: jest.fn(), + stop, + reset: jest.fn(), + }); + const { rerender } = await renderHook( + ({ visible, position }: { visible: boolean; position: number }) => useCaretBlink(visible, position), + { + initialProps: { visible: true, position: 0 }, + } + ); + expect(loop).toHaveBeenCalledTimes(1); + await rerender({ visible: true, position: 1 }); + expect(stop).toHaveBeenCalledTimes(1); + expect(loop).toHaveBeenCalledTimes(2); + await rerender({ visible: false, position: 1 }); + expect(stop).toHaveBeenCalledTimes(2); + expect(loop).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/otp/hooks/useCaretBlink.ts b/src/otp/hooks/useCaretBlink.ts new file mode 100644 index 0000000..47a4891 --- /dev/null +++ b/src/otp/hooks/useCaretBlink.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { Animated } from 'react-native'; + +import { useAnimatedValue } from '../../core/hooks/useAnimatedValue'; +import { timing } from '../../core/utils/animation.utils'; +import { CARET_BLINK_MS } from '../otp.constants'; + +/** The caret's opacity: blinking while `visible`, and shown solid again each time it moves to `position`. */ +export const useCaretBlink = (visible: boolean, position: number) => { + const opacity = useAnimatedValue(1); + + // biome-ignore lint/correctness/useExhaustiveDependencies: restarts when the caret moves, so it shows as soon as a character is typed + useEffect(() => { + opacity.setValue(1); + if (!visible) return; + const blink = Animated.loop( + Animated.sequence([ + timing(opacity, 0, { duration: 0, delay: CARET_BLINK_MS }), + timing(opacity, 1, { duration: 0, delay: CARET_BLINK_MS }), + ]) + ); + blink.start(); + return () => blink.stop(); + }, [visible, position]); + + return opacity; +}; diff --git a/src/otp/hooks/useOtpField.ts b/src/otp/hooks/useOtpField.ts new file mode 100644 index 0000000..e975a38 --- /dev/null +++ b/src/otp/hooks/useOtpField.ts @@ -0,0 +1,43 @@ +import { useControllableState } from '../../core/hooks/useControllableState'; +import { useFocus } from '../../core/hooks/useFocus'; +import { DEFAULT_OTP_LENGTH, OTP_INPUT_MODE } from '../otp.constants'; +import type { OtpFieldProps } from '../otp.types'; +import { getCells, sanitizeCode } from '../otp.utils'; +import { useCaretBlink } from './useCaretBlink'; + +/** The code the cells show, and the handler that turns typing and pasting into it. `onComplete` fires when the user fills the last cell. */ +export const useOtpField = ({ + length = DEFAULT_OTP_LENGTH, + value, + defaultValue = '', + onChange, + onComplete, + inputMode = OTP_INPUT_MODE.numeric, + secureTextEntry = false, +}: OtpFieldProps) => { + const [raw, setCode] = useControllableState(value, defaultValue, onChange); + const code = sanitizeCode(raw, length, inputMode); + const focus = useFocus(); + const cells = getCells(code, length, focus.focused, secureTextEntry); + const caretOpacity = useCaretBlink( + cells.some((cell) => cell.caret), + code.length + ); + + const changeText = (text: string) => { + const next = sanitizeCode(text, length, inputMode); + if (next === code) return; + setCode(next); + if (next.length === length) onComplete?.(next); + }; + + return { + code, + length, + cells, + caretOpacity, + changeText, + onFocus: focus.onFocus, + onBlur: focus.onBlur, + }; +}; diff --git a/src/otp/index.ts b/src/otp/index.ts new file mode 100644 index 0000000..3aaaa0d --- /dev/null +++ b/src/otp/index.ts @@ -0,0 +1,2 @@ +export { OtpField } from './OtpField'; +export type { OtpFieldProps, OtpFieldSlot, OtpInputMode } from './otp.types'; diff --git a/src/otp/otp.constants.ts b/src/otp/otp.constants.ts new file mode 100644 index 0000000..9020977 --- /dev/null +++ b/src/otp/otp.constants.ts @@ -0,0 +1,31 @@ +import { Platform, type TextInputProps } from 'react-native'; + +export const OTP_INPUT_MODE = { + /** Digits only, on a number pad */ + numeric: 'numeric', + /** Letters and digits */ + text: 'text', +} as const; + +/** Characters the field keeps from what's typed or pasted, so "123-456" or "Your code is 123456" become the code. */ +export const OTP_DROPPED_CHARS = { + numeric: /\D/g, + text: /[^0-9a-z]/gi, +} as const; + +export const DEFAULT_OTP_LENGTH = 6; +export const DEFAULT_OTP_LABEL = 'Verification code'; +/** What a `secureTextEntry` cell shows for each character. */ +export const SECURE_DOT = '•'; + +/** The OS offers the code from a text message: iOS and the web by `one-time-code`, Android's autofill by `sms-otp`. */ +export const OTP_AUTO_COMPLETE: TextInputProps['autoComplete'] = Platform.select({ android: 'sms-otp', default: 'one-time-code' }); + +export const OTP_CELL_MAX_WIDTH = 56; +export const CARET_WIDTH = 2; +/** The caret's height, as a multiple of the cell's font size. */ +export const CARET_HEIGHT_RATIO = 1.2; +/** The caret shows and hides this long each, as a text field's does. */ +export const CARET_BLINK_MS = 500; +/** A mobile browser zooms into a focused input with smaller text, so the hidden input keeps this size. */ +export const NO_ZOOM_FONT_SIZE = 16; diff --git a/src/otp/otp.styles.ts b/src/otp/otp.styles.ts new file mode 100644 index 0000000..fe6f746 --- /dev/null +++ b/src/otp/otp.styles.ts @@ -0,0 +1,11 @@ +import { StyleSheet } from 'react-native'; + +import { CARET_WIDTH, NO_ZOOM_FONT_SIZE, OTP_CELL_MAX_WIDTH } from './otp.constants'; + +export const styles = StyleSheet.create({ + row: { flexDirection: 'row', justifyContent: 'center' }, + cell: { flex: 1, maxWidth: OTP_CELL_MAX_WIDTH, alignItems: 'center', justifyContent: 'center' }, + caret: { width: CARET_WIDTH }, + // Over the cells, so a tap anywhere focuses it and a long press offers to paste; the cells draw what it holds. + input: { ...StyleSheet.absoluteFill, opacity: 0, fontSize: NO_ZOOM_FONT_SIZE }, +}); diff --git a/src/otp/otp.types.ts b/src/otp/otp.types.ts new file mode 100644 index 0000000..f4e1c86 --- /dev/null +++ b/src/otp/otp.types.ts @@ -0,0 +1,54 @@ +import type { Animated, StyleProp, ViewStyle } from 'react-native'; + +import type { PickerTheme, SlotStyles, ThemableProps } from '../core'; +import type { ValueOf } from '../core/core.types'; +import type { FieldDesign } from '../sheet/sheet.types'; +import type { OTP_INPUT_MODE } from './otp.constants'; + +/** 'numeric': digits on a number pad. 'text': letters and digits. */ +export type OtpInputMode = ValueOf; + +export type OtpFieldSlot = 'container' | 'cell' | 'focusedCell' | 'filledCell' | 'cellText' | 'caret'; + +export interface OtpFieldProps extends ThemableProps { + /** How many characters the code has. Default: 6 */ + length?: number; + value?: string; + defaultValue?: string; + /** As the user types, deletes or pastes, with the code so far */ + onChange?: (code: string) => void; + /** Once the user fills the last cell, with the whole code */ + onComplete?: (code: string) => void; + /** 'numeric': digits only, on a number pad. 'text': letters and digits. Default: 'numeric' */ + inputMode?: OtpInputMode; + /** Each character shows as a dot, for a PIN. Default: false */ + secureTextEntry?: boolean; + autoFocus?: boolean; + disabled?: boolean; + /** What screen readers call the field. Default: 'Verification code' */ + accessibilityLabel?: string; + style?: StyleProp; + styles?: SlotStyles; + /** On the text input; cell `i` gets `${testID}-cell-${i}` */ + testID?: string; +} + +export interface OtpCellState { + key: string; + /** The character, or a dot with `secureTextEntry`; '' while empty */ + char: string; + /** The field is focused and this cell takes the next character */ + focused: boolean; + filled: boolean; + /** The blinking caret: in the focused cell while it's empty */ + caret: boolean; +} + +export interface OtpCellProps { + cell: OtpCellState; + caretOpacity: Animated.Value; + look: FieldDesign; + theme: PickerTheme; + slots?: SlotStyles; + testID?: string; +} diff --git a/src/otp/otp.utils.test.ts b/src/otp/otp.utils.test.ts new file mode 100644 index 0000000..3354b32 --- /dev/null +++ b/src/otp/otp.utils.test.ts @@ -0,0 +1,34 @@ +import { getCells, sanitizeCode } from './otp.utils'; + +describe('sanitizeCode', () => { + it('keeps the digits of a pasted message, up to the length', () => { + expect(sanitizeCode('Your code is 123-456', 6, 'numeric')).toBe('123456'); + expect(sanitizeCode('1234567', 6, 'numeric')).toBe('123456'); + expect(sanitizeCode('ab', 6, 'numeric')).toBe(''); + }); + + it('keeps letters and digits in text mode, as typed', () => { + expect(sanitizeCode('a1-B2 c3!', 6, 'text')).toBe('a1B2c3'); + }); +}); + +describe('getCells', () => { + it('focuses the next empty cell and draws the caret there', () => { + const cells = getCells('12', 4, true, false); + expect(cells.map((c) => c.char)).toEqual(['1', '2', '', '']); + expect(cells.map((c) => c.focused)).toEqual([false, false, true, false]); + expect(cells.map((c) => c.caret)).toEqual([false, false, true, false]); + expect(cells.map((c) => c.filled)).toEqual([true, true, false, false]); + }); + + it('keeps the last cell focused without a caret once the code is complete', () => { + const cells = getCells('1234', 4, true, false); + expect(cells[3]).toMatchObject({ focused: true, caret: false, filled: true }); + }); + + it('focuses nothing while the field is blurred, and hides characters when secure', () => { + const cells = getCells('12', 4, false, true); + expect(cells.some((c) => c.focused || c.caret)).toBe(false); + expect(cells.map((c) => c.char)).toEqual(['•', '•', '', '']); + }); +}); diff --git a/src/otp/otp.utils.ts b/src/otp/otp.utils.ts new file mode 100644 index 0000000..d3a3ad1 --- /dev/null +++ b/src/otp/otp.utils.ts @@ -0,0 +1,22 @@ +import { OTP_DROPPED_CHARS, SECURE_DOT } from './otp.constants'; +import type { OtpCellState, OtpInputMode } from './otp.types'; + +/** The code in what was typed or pasted: only the characters the mode allows, at most `length` of them. */ +export const sanitizeCode = (text: string, length: number, mode: OtpInputMode) => + text.replace(OTP_DROPPED_CHARS[mode], '').slice(0, length); + +/** One cell per character. The focused one is the next to fill, or the last once the code is complete. */ +export const getCells = (code: string, length: number, focused: boolean, secure: boolean): OtpCellState[] => { + const next = Math.min(code.length, length - 1); + return Array.from({ length }, (_, i) => { + const char = code[i] ?? ''; + const isFocused = focused && i === next; + return { + key: String(i), + char: char && secure ? SECURE_DOT : char, + focused: isFocused, + filled: char !== '', + caret: isFocused && char === '', + }; + }); +}; diff --git a/website/docs/api/reference.mdx b/website/docs/api/reference.mdx index 653bc37..81ceb21 100644 --- a/website/docs/api/reference.mdx +++ b/website/docs/api/reference.mdx @@ -30,6 +30,7 @@ Every export from `@elik56a/react-native-pickers`, grouped. Each name links to i | [`EmojiPicker`](../components/emoji-picker) | Emoji keyboard | | [`ColorPicker`](../components/color-picker) | iOS's tabs, Google's swatch circles or the browser's spectrum; hex with opacity | | [`Slider`](../components/slider) | iOS's knob, Material 3's handle or a range input; one value or a range | +| [`OtpField`](../components/otp-field) | One-time code in a box per character, with SMS autofill and paste | | [`BottomSheet`](../components/bottom-sheet) | Animated sheet | ## Sheets @@ -177,6 +178,7 @@ interface ThemableProps { theme?: DeepPartial } | `display` | `'tabs'` iOS · `'swatches'` Material · `'spectrum'` web | ColorPicker | | `min` / `max` / `step` | `0` / `100` / `1` | Slider | | `valueLabel` | `'drag'` Material · `'never'` iOS, web | Slider | +| `length` / `inputMode` | `6` / `'numeric'` | OtpField | | `display` / `timeDisplay` | `'wheel'` / `'wheel'` iOS · `'calendar'` / `'input'` Material · `'calendar'` / `'list'` web | DateTimePicker | | `timePosition` | `'beside'` web · `'below'` other | DateTimePicker | | `dayHeight` | `44` | Calendar | diff --git a/website/docs/components/otp-field.mdx b/website/docs/components/otp-field.mdx new file mode 100644 index 0000000..9b896c3 --- /dev/null +++ b/website/docs/components/otp-field.mdx @@ -0,0 +1,134 @@ +--- +title: React Native OTP Input +description: "React Native OTP input for verification codes and PINs: a box per character with a blinking caret, SMS code autofill on iOS, Android and the web, paste from a message, numeric or letters, secure dots. iOS, Material and web designs, zero native code." +--- + +import PropPlayground from '@site/src/components/PropPlayground'; +import { PLAYGROUNDS } from '@site/src/catalog/playgrounds'; + +# OtpField + +`OtpField` is a React Native OTP input: the one-time code from a text message or an authenticator app, a box per character. It's the step after [`PhoneField`](./phone-field) in a phone sign-in. + +One text field sits under the boxes, so everything a text field does works: the keyboard offers the code from the latest text message (iOS, Android and Safari), long-press pastes, and screen readers hear one field rather than six. The boxes look like the [design's](../guides/designs) text fields. + +```tsx +import { OtpField } from '@elik56a/react-native-pickers'; +``` + +```tsx live +import { useState } from 'react'; +import { Text, View } from 'react-native'; +import { OtpField } from '@elik56a/react-native-pickers'; + +export default function Example() { + const [code, setCode] = useState(''); + const [sent, setSent] = useState(null); + return ( + + + {sent ? `Checking ${sent}…` : 'Enter the 6-digit code'} + + ); +} +``` + +Paste *Your code is 482-913* and the boxes fill with `482913`: the field keeps only the characters a code can have. + +## Try the props + + + +## Checking the code + +`onComplete` fires once the user fills the last box, with the whole code: send it to your server there. `onChange` fires on every key, delete and paste, for a controlled field. Neither fires when you change `value` yourself. + +For a wrong code, set `value` to `''`: the boxes empty and the field stays focused for another try. To hide the keyboard while you check, call `Keyboard.dismiss()` in `onComplete`. + +```tsx live +import { useState } from 'react'; +import { Keyboard, Text, View } from 'react-native'; +import { OtpField } from '@elik56a/react-native-pickers'; + +const CORRECT = '1234'; + +export default function Example() { + const [code, setCode] = useState(''); + const [error, setError] = useState(false); + return ( + + { + setCode(next); + setError(false); + }} + onComplete={(full) => { + if (full === CORRECT) Keyboard.dismiss(); + else { + setError(true); + setCode(''); + } + }} + styles={error ? { cell: { borderColor: '#D93025' } } : undefined} + /> + {error ? 'Wrong code, try again' : 'Hint: 1234'} + + ); +} +``` + +## Letters, and PINs + +`inputMode="text"` takes letters and digits on the full keyboard, for codes like `A7K2Q9`. `secureTextEntry` shows each character as a dot, for a PIN. + +```tsx live +import { View } from 'react-native'; +import { OtpField } from '@elik56a/react-native-pickers'; + +export default function Example() { + return ( + + + + + ); +} +``` + +## Designs + +| iOS | Material | Web | +| --- | --- | --- | +| bordered boxes, the focused one in the primary colour | outlined boxes, the focused one with a 2dp primary outline | compact outlined boxes, the focused one with a focus ring | + +Each box is up to 56 wide and as tall as the design's fields, sharing the row's width, so six fit on the narrowest phone. Restyle any part with `styles`. + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `string` | — | The code so far (controlled). | +| `defaultValue` | `string` | `''` | Starting code when `value` isn't set. | +| `onChange` | `(code: string) => void` | — | As the user types, deletes or pastes. | +| `onComplete` | `(code: string) => void` | — | Once the user fills the last box. | +| `length` | `number` | `6` | How many characters the code has. | +| `inputMode` | `'numeric' \| 'text'` | `'numeric'` | Digits on a number pad, or letters and digits. | +| `secureTextEntry` | `boolean` | `false` | Dots instead of characters. | +| `autoFocus` | `boolean` | `false` | Focus the field when it mounts. | +| `disabled` | `boolean` | `false` | Dims the boxes and ignores typing. | +| `accessibilityLabel` | `string` | `'Verification code'` | What screen readers call the field. | +| `theme` / `design` | | | See [Theming](../guides/theming) and [Designs](../guides/designs). | +| `style` | `ViewStyle` | — | The field's container. | +| `styles` | `{ container, cell, focusedCell, filledCell, cellText, caret }` | — | [Slot styles](../guides/styling). `container` is the row of boxes. | +| `testID` | `string` | — | On the text input; box `i` gets `${testID}-cell-${i}`. | + +## Accessibility + +Screen readers see one text field, named by `accessibilityLabel`, with the code as its value; the boxes are hidden from them. Typing, deleting and pasting work as in any field, with a hardware keyboard too. + +## Related + +- [PhoneField](./phone-field): the number to send the code to +- [Migrate from react-native-otp-entry](../resources/migration/otp-entry) or [react-native-confirmation-code-field](../resources/migration/confirmation-code-field) diff --git a/website/docs/guides/designs.mdx b/website/docs/guides/designs.mdx index c4b46dc..da6ef43 100644 --- a/website/docs/guides/designs.mdx +++ b/website/docs/guides/designs.mdx @@ -71,6 +71,7 @@ export default function Example() { | WeekStrip | chosen day tinted, SUN MON | chosen day filled, S M T | chosen day a filled square, Sun Mon | | Colour | Grid, Spectrum and Sliders tabs, swatches in a row to scroll | swatch circles, then **Custom** for a saturation square and hue slider | a saturation square, eyedropper, hue slider and hex code, then swatches | | Slider | a white knob on a thin track | a slim handle with gaps on a thick track, stop dots, a value bubble while dragging | a solid dot on a medium track, a halo on hover and press | +| OtpField | bordered boxes, the focused one in the primary colour | outlined boxes, the focused one with a 2dp primary outline | compact outlined boxes, the focused one with a focus ring | | HorizontalList | a sliding raised thumb; filled chips | outlined segments and chips, the chosen one filled with a ✓ | a sliding raised thumb; filled chips | | Search fields | a short gray search bar | a tall pill | an outlined input with a focus ring | | EmojiPicker | categories at the bottom, the current one on a gray fill, UPPERCASE titles | categories at the bottom, the current one in a tinted pill | categories on top, underlined | diff --git a/website/docs/guides/styling.mdx b/website/docs/guides/styling.mdx index e50d027..b8c5dd4 100644 --- a/website/docs/guides/styling.mdx +++ b/website/docs/guides/styling.mdx @@ -55,6 +55,7 @@ type SlotStyles = Partial>>; | [WeekStrip](../components/week-strip#props) | the Calendar slots | | [ColorPicker](../components/color-picker#props) | `container` `swatch` `pad` `slider` `sliderThumb` `sliderLabel` `hexInput` `preview` | | [Slider](../components/slider#styling) | `container` `track` `activeTrack` `thumb` `mark` `markLabel` `valueLabel` `valueLabelText` | +| [OtpField](../components/otp-field#props) | `container` `cell` `focusedCell` `filledCell` `cellText` `caret` | | [SelectList](../components/select-list#slots) | `container` `search` `searchInput` `list` `groupHeader` `groupHeaderText` `option` `selectedOption` `optionLabel` `selectedOptionLabel` `optionDescription` `check` `empty` `emptyText` `loading` `loadingText` | | [SelectPicker](../components/select-picker#slots) | all SelectList slots + `trigger` `triggerText` `placeholder` `label` `chip` `chipText` `chevron` `clear` | | [HorizontalList](../components/horizontal-list#slots) | `container` `content` `item` `selectedItem` `itemLabel` `selectedItemLabel` `thumb` `arrow` `arrowText` | diff --git a/website/docs/guides/typescript.mdx b/website/docs/guides/typescript.mdx index e1dc0d3..6f444af 100644 --- a/website/docs/guides/typescript.mdx +++ b/website/docs/guides/typescript.mdx @@ -63,6 +63,8 @@ import type { ColorPickerProps, ColorPickerSlot, ColorOption, ColorDisplay, ColorLabels, // slider SliderProps, SingleSliderProps, RangeSliderProps, SliderRange, SliderSlot, SliderMarkInput, SliderValueLabel, + // otp + OtpFieldProps, OtpFieldSlot, OtpInputMode, // select SelectPickerProps, SelectListProps, SelectListBaseProps, SelectValueProps, SelectOption, SelectSlot, SelectTriggerSlot, SelectTriggerInfo, SelectRenderOptionInfo, diff --git a/website/docs/intro.mdx b/website/docs/intro.mdx index 1b4cabd..4446804 100644 --- a/website/docs/intro.mdx +++ b/website/docs/intro.mdx @@ -2,7 +2,7 @@ title: React Native Pickers, zero native code sidebar_label: Introduction slug: /intro -description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable." +description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider and an OTP code input, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable." --- # React Native Pickers @@ -92,6 +92,7 @@ Edit the code and the result updates instantly. Each example is a complete file | [`EmojiPicker`](./components/emoji-picker) | 1,923 emojis, categories, search, skin tones, recents, version filtering | | [`ColorPicker`](./components/color-picker) | iOS's Grid / Spectrum / Sliders, Google's swatch circles, or the browser's spectrum; hex with opacity and a copy button | | [`Slider`](./components/slider) | iOS's knob, Material 3's handle with a value bubble, or a range input; one thumb or a range, steps, icons | +| [`OtpField`](./components/otp-field) | A verification code in a box per character: SMS autofill, paste, a PIN mode | | [`BottomSheet`](./components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content | | [`DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`](./components/picker-sheets) | Ready-made pickers in a sheet with Cancel / Done and draft state | | [`DatePickerField`, `TimePickerField`, `DateTimePickerField`, `DurationPickerField`, `MonthPickerField`, `ColorPickerField`, `WheelPickerField`, `EmojiPickerField`, `PickerField`](./components/picker-fields) | Form fields that open a picker sheet and show the picked value, styled like `SelectPicker` | diff --git a/website/docs/resources/ai.mdx b/website/docs/resources/ai.mdx index cf69e02..b88f978 100644 --- a/website/docs/resources/ai.mdx +++ b/website/docs/resources/ai.mdx @@ -87,6 +87,9 @@ Slider value: number onChange(value) (on release, or a key/screen-r range → value [start,end], onChange([start,end]), thumbLabels=['Minimum','Maximum']; a touch grabs the nearest thumb, thumbs never cross showSteps (dots per step) marks (number | {value,label})[] (dots + labels under the track) snapToMarks (only the marks) valueLabel 'never'|'drag'|'always' (default 'drag' Material, else 'never') formatValue(v) (bubble + screen readers) minimumIcon maximumIcon (nodes beside the track) disabled looks: ios white knob on a 4pt track | material M3 bar handle, 16dp track, gaps, stop dots | web 18px dot on a 6px track; web: pointer cursor + a halo on hover/focus/press; slots container track activeTrack thumb mark markLabel valueLabel valueLabelText +OtpField value: string (the code so far) onChange(code) (typing, deleting, pasting) onComplete(code) (last cell filled) length=6 inputMode 'numeric'|'text' (digits on a number pad | letters and digits) secureTextEntry (dots) autoFocus disabled accessibilityLabel='Verification code' + one hidden TextInput over the cells: paste and SMS autofill work (oneTimeCode on iOS/web, sms-otp on Android); a pasted message keeps only the code chars; no ref: clear with value='', blur with Keyboard.dismiss() in onComplete + cells look like the design's text fields; slots container cell focusedCell filledCell cellText caret BottomSheet visible onClose title headerLeft headerRight footer height(<=1 fraction | px) enableSwipeToClose draggable(header drags too; native:true web:false; handle always drags) expandable expandedHeight Sheets (draft; commit on Done): DatePickerSheet visible onClose value onConfirm + all calendar props + mode + display + confirmOnSelect diff --git a/website/docs/resources/migration/confirmation-code-field.mdx b/website/docs/resources/migration/confirmation-code-field.mdx new file mode 100644 index 0000000..6990507 --- /dev/null +++ b/website/docs/resources/migration/confirmation-code-field.mdx @@ -0,0 +1,50 @@ +--- +title: Migrate from react-native-confirmation-code-field +sidebar_label: 'confirmation-code-field' +description: "Switch from react-native-confirmation-code-field to React Native Pickers: OtpField draws the cells, caret and focus for you, reads the code from a text message on iOS, Android and the web, and follows iOS, Material and web designs." +--- + +# Migrate from `react-native-confirmation-code-field` + +`react-native-confirmation-code-field` gives you the hidden input and leaves the cells to you. [`OtpField`](../../components/otp-field) works the same way underneath and draws the cells, the caret and the focus for you. + +```diff +- import { CodeField, Cursor, useBlurOnFulfill, useClearByFocusCell } from 'react-native-confirmation-code-field'; ++ import { OtpField } from '@elik56a/react-native-pickers'; + +- const ref = useBlurOnFulfill({ value, cellCount: 6 }); +- const [props, getCellOnLayoutHandler] = useClearByFocusCell({ value, setValue }); +- +- ( +- +- {symbol || (isFocused && )} +- +- )} +- /> ++ +``` + +| confirmation-code-field | React Native Pickers | +| --- | --- | +| `cellCount` | `length` (default `6`) | +| `value` / `onChangeText` | `value` / `onChange`, or leave them out and use `onComplete` | +| `keyboardType`, `textContentType`, `autoComplete` | set for you: a number pad, and the code from a text message on iOS, Android and the web | +| `renderCell` + `Cursor` | drawn for you, with a blinking caret; restyle with `styles={{ cell, focusedCell, filledCell, cellText, caret }}` | +| `rootStyle` | `styles={{ container }}` or `style` | +| `MaskSymbol` | `secureTextEntry` | +| `useBlurOnFulfill` | `onComplete={(code) => { Keyboard.dismiss(); verify(code); }}` | +| `useClearByFocusCell` | set `value` to `''` to start over | +| — | `inputMode="text"` for letters and digits, pasted messages reduced to the code, `disabled` | + +Typing or pasting keeps only the characters the code can have, so pasting *Your code is 482-913* fills `482913`. + +See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice. diff --git a/website/docs/resources/migration/index.mdx b/website/docs/resources/migration/index.mdx index ff779db..6518140 100644 --- a/website/docs/resources/migration/index.mdx +++ b/website/docs/resources/migration/index.mdx @@ -1,7 +1,7 @@ --- title: Migrate to React Native Pickers sidebar_label: Overview -description: "Prop-by-prop guides to move from datetimepicker, react-native-date-picker, react-native-calendars, dropdown and select libraries, the country and phone pickers and rn-emoji-keyboard to React Native Pickers." +description: "Prop-by-prop guides to move from datetimepicker, react-native-date-picker, react-native-calendars, dropdown and select libraries, the country and phone pickers, rn-emoji-keyboard, sliders and OTP inputs to React Native Pickers." --- # Migration @@ -16,6 +16,7 @@ Compared with native pickers, your users keep a native look: the iOS design on i | [`react-native-date-picker`](./react-native-date-picker.mdx) | `DateWheelPicker`, `TimePicker`, `DatePickerSheet` | | [`react-native-modal-datetime-picker`](./modal-datetime-picker.mdx) | `DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet` | | [`react-native-calendars`](./react-native-calendars.mdx) | `DatePicker` | +| [`react-native-ui-datepicker`](./ui-datepicker.mdx) | `DatePicker`, `DateTimePicker` | | [`react-native-picker-select`](./picker-select.mdx) | `SelectPicker` | | [`react-native-dropdown-picker`](./dropdown-picker.mdx) | `SelectPicker` | | [`react-native-element-dropdown`](./element-dropdown.mdx) | `SelectPicker` | @@ -24,5 +25,9 @@ Compared with native pickers, your users keep a native look: the iOS design on i | [`react-native-country-picker-modal`](./country-picker-modal.mdx) | `CountryPicker` | | [`react-native-phone-number-input`](./phone-number-input.mdx) | `PhoneField` | | [`rn-emoji-keyboard`](./emoji-keyboard.mdx) | `EmojiPickerSheet` | +| [`@react-native-community/slider`](./slider.mdx) | `Slider` | +| [`@ptomasroos/react-native-multi-slider`](./multi-slider.mdx) | `Slider` with `range` | +| [`react-native-otp-entry`](./otp-entry.mdx) | `OtpField` | +| [`react-native-confirmation-code-field`](./confirmation-code-field.mdx) | `OtpField` | Not sure it's the right fit? [Comparison](../comparison.mdx) shows when another library is the better choice. diff --git a/website/docs/resources/migration/multi-slider.mdx b/website/docs/resources/migration/multi-slider.mdx new file mode 100644 index 0000000..978ea4a --- /dev/null +++ b/website/docs/resources/migration/multi-slider.mdx @@ -0,0 +1,52 @@ +--- +title: Migrate from @ptomasroos/react-native-multi-slider +sidebar_label: 'multi-slider' +description: "Switch from @ptomasroos/react-native-multi-slider to React Native Pickers: a range Slider with two thumbs that fills its width, labelled marks, a value bubble and screen-reader support, in iOS, Material and web designs." +--- + +# Migrate from `@ptomasroos/react-native-multi-slider` + +```diff +- import MultiSlider from '@ptomasroos/react-native-multi-slider'; ++ import { Slider } from '@elik56a/react-native-pickers'; + +- ++ +``` + +| multi-slider | React Native Pickers | +| --- | --- | +| `values` (`[a]` or `[a, b]`) | `value`: a number, or `[start, end]` with `range` | +| `min` / `max` / `step` | `min` / `max` / `step` | +| `onValuesChange` | `onValueChanging` | +| `onValuesChangeFinish` | `onChange` | +| `onValuesChangeStart` | — | +| `sliderLength` | not needed: the slider fills its container, or set `style={{ width }}` | +| `optionsArray` / `snapped` | `marks={[…]}` with `snapToMarks` | +| `enableLabel` / `customLabel` | `valueLabel="always"` or `"drag"`, with `formatValue` | +| `valuePrefix` / `valueSuffix` | ``formatValue={(v) => `$${v}`}`` | +| `allowOverlap` / `minMarkerOverlapDistance` | the thumbs can meet but not cross | +| `selectedStyle` / `unselectedStyle` | `styles={{ activeTrack, track }}` | +| `markerStyle` / `customMarker` | `styles={{ thumb }}` | +| `enabledOne` / `enabledTwo` | `disabled` for the whole slider | +| `vertical` | — | + +[`Slider`](../../components/slider) has no native code or Reanimated, gives each thumb a screen-reader label (`thumbLabels`), and moves with arrow keys on the web. + +See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice. diff --git a/website/docs/resources/migration/otp-entry.mdx b/website/docs/resources/migration/otp-entry.mdx new file mode 100644 index 0000000..0fd2e2d --- /dev/null +++ b/website/docs/resources/migration/otp-entry.mdx @@ -0,0 +1,51 @@ +--- +title: Migrate from react-native-otp-entry +sidebar_label: 'otp-entry' +description: "Switch from react-native-otp-entry to React Native Pickers: OtpField with the same cells and blinking caret, SMS code autofill, paste, a PIN mode, and iOS, Material and web designs from your theme." +--- + +# Migrate from `react-native-otp-entry` + +```diff +- import { OtpInput } from 'react-native-otp-entry'; ++ import { OtpField } from '@elik56a/react-native-pickers'; + +- ++ +``` + +| otp-entry | React Native Pickers | +| --- | --- | +| `numberOfDigits` | `length` (default `6`) | +| `onTextChange` | `onChange` | +| `onFilled` | `onComplete` | +| `autoFocus` (default `true`) | `autoFocus` (default `false`) | +| `focusColor` | `theme={{ colors: { primary } }}` | +| `type="numeric"` / `"alphanumeric"` | `inputMode="numeric"` / `"text"` | +| `secureTextEntry` / `disabled` | `secureTextEntry` / `disabled` | +| `blurOnFilled` | `onComplete={(code) => { Keyboard.dismiss(); verify(code); }}` | +| `hideStick` | `styles={{ caret: { opacity: 0 } }}` | +| `theme.containerStyle` | `styles.container` | +| `theme.pinCodeContainerStyle` | `styles.cell` | +| `theme.focusedPinCodeContainerStyle` | `styles.focusedCell` | +| `theme.filledPinCodeContainerStyle` | `styles.filledCell` | +| `theme.pinCodeTextStyle` | `styles.cellText` | +| `theme.focusStickStyle` | `styles.caret` | +| `ref.clear()` / `ref.setValue(code)` | a controlled `value` | +| `textInputProps.accessibilityLabel` | `accessibilityLabel` (default *Verification code*) | + +Without a `styles` override, [`OtpField`](../../components/otp-field)'s cells look like the [design's](../../guides/designs.mdx) text fields: iOS on iPhone, Material on Android and outlined boxes with a focus ring on the web. + +See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice. diff --git a/website/docs/resources/migration/slider.mdx b/website/docs/resources/migration/slider.mdx new file mode 100644 index 0000000..8e2dcf3 --- /dev/null +++ b/website/docs/resources/migration/slider.mdx @@ -0,0 +1,53 @@ +--- +title: Migrate from @react-native-community/slider +sidebar_label: 'slider' +description: "Switch from @react-native-community/slider to React Native Pickers: Slider with no native code, a range with two thumbs, step dots, labelled marks and a value bubble, in iOS, Material and web designs. Works in Expo Go." +--- + +# Migrate from `@react-native-community/slider` + +```diff +- import Slider from '@react-native-community/slider'; ++ import { Slider } from '@elik56a/react-native-pickers'; + +- ++ +``` + +| @react-native-community/slider | React Native Pickers | +| --- | --- | +| `minimumValue` / `maximumValue` (default `0` / `1`) | `min` / `max` (default `0` / `100`) | +| `step` (default `0`, continuous) | `step` (default `1`; `0` for continuous) | +| `value` (write-only, read once) | `value` (controlled) or `defaultValue` | +| `onValueChange` | `onValueChanging`, on every step of a drag | +| `onSlidingComplete` | `onChange`, once the finger lifts, or on a key or screen-reader step | +| `onSlidingStart` | — | +| `lowerLimit` / `upperLimit` | `min` / `max` | +| `minimumTrackTintColor` / `thumbTintColor` | `theme={{ colors: { primary } }}`, or `styles={{ activeTrack, thumb }}` | +| `maximumTrackTintColor` | `styles={{ track }}` | +| `thumbSize` / `thumbImage` | `styles={{ thumb }}` | +| `StepMarker` / `renderStepNumber` | `showSteps`, or `marks` with labels | +| `inverted` | follows the layout direction: right to left in RTL | +| `tapToSeek` | always on | +| — | `range` for two thumbs, `valueLabel` bubble, `snapToMarks`, `minimumIcon` / `maximumIcon`, keyboard steps on the web | + +Two defaults differ: `max` is `100` rather than `1`, and `step` is `1` rather than continuous. Pass `max={1} step={0}` to keep a 0–1 slider. + +[`Slider`](../../components/slider) has no native code, so it runs in Expo Go, ships in OTA updates and draws the same on the web. + +See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice. diff --git a/website/docs/resources/migration/ui-datepicker.mdx b/website/docs/resources/migration/ui-datepicker.mdx new file mode 100644 index 0000000..3739b7f --- /dev/null +++ b/website/docs/resources/migration/ui-datepicker.mdx @@ -0,0 +1,56 @@ +--- +title: Migrate from react-native-ui-datepicker +sidebar_label: 'ui-datepicker' +description: "Switch from react-native-ui-datepicker to React Native Pickers: DatePicker with plain Date values instead of dayjs, single, range and multiple modes, marked dates, a bottom sheet and a form field, in iOS, Material and web designs." +--- + +# Migrate from `react-native-ui-datepicker` + +```diff +- import DateTimePicker from 'react-native-ui-datepicker'; ++ import { DatePicker } from '@elik56a/react-native-pickers'; + +- setRange({ startDate, endDate })} +- minDate={new Date()} +- min={2} +- /> ++ +``` + +Values are plain `Date`s in local time, not dayjs objects, and the callbacks pass the value itself rather than an object to unpack. + +| ui-datepicker | React Native Pickers | +| --- | --- | +| `mode="single"` + `date` + `onChange({ date })` | `value` + `onChange(date)` | +| `mode="range"` + `startDate` / `endDate` | `value: { start, end }` + `onChange(range)` | +| `mode="multiple"` + `dates` + `onChange({ dates })` | `value: Date[]` + `onChange(dates)` | +| `min` / `max` (nights, range) | `minRangeDays` / `maxRangeDays` (nights) | +| `max` (multiple) | `maxSelections` | +| `minDate` / `maxDate` | `minDate` / `maxDate` (`Date`) | +| `disabledDates` (array or function) | `isDateDisabled(date)`, or `markedDates[key].disabled` | +| `enabledDates` | `isDateDisabled={(d) => !isAllowed(d)}` | +| `firstDayOfWeek` | `firstDayOfWeek` | +| `month` / `year` / `onMonthChange` / `onYearChange` | `initialMonth` (`Date`) / `onMonthChange(month: Date)` | +| `initialView="month"` / `"year"` | `enableQuickJump`: tap the title for months and years | +| `timePicker` / `use12Hours` | [`DateTimePicker`](../../components/date-time-picker) with `format="12h"` | +| `showOutsideDays` | `showOutsideDays` | +| `hideWeekdays` / `weekdaysFormat` | `showWeekdays={false}` / `weekdayFormat` | +| `locale` | `locale` (names from `Intl`, no dayjs locale imports) | +| `timeZone` | — (dates are the device's local time) | +| `calendar="jalali"` | — | +| `styles` / `classNames` / `components` | `theme`, `styles` slots, `renderDay`, `renderArrow` | +| — | `mode="ranges"`, `markedDates` with dots and labels (prices), `DatePickerSheet` and `DatePickerField` | + +[`DatePicker`](../../components/date-picker) has no dependency, so dayjs isn't needed. See [ranges and multiple dates](../../components/date-range-picker) for the range rules. + +See [all migration guides](./index.mdx) for other libraries, and [Comparison](../comparison.mdx) for when another library is the better choice. diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 5c668b9..1927b73 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -38,7 +38,7 @@ const FOOTER_BRAND = ` ${socialLink('LinkedIn', LINKEDIN, `${AUTHOR} on LinkedIn`)} `; const DESCRIPTION = - 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.'; + 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider and an OTP code input for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.'; const local = (pkg: string) => path.dirname(require.resolve(`${pkg}/package.json`)); const { version } = require('../package.json'); @@ -70,7 +70,7 @@ const structuredData = { version, author: { '@type': 'Person', name: AUTHOR, url: 'https://github.com/elik56a', sameAs: [LINKEDIN] }, keywords: - 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, slider, range slider, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible', + 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, slider, range slider, otp input, verification code input, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible', }, ], }; @@ -95,7 +95,7 @@ const config: Config = { attributes: { name: 'keywords', content: - 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, react native slider, range slider, bottom sheet, material design date picker, material time picker clock, ios style picker, expo, react native web', + 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, react native slider, range slider, react native otp input, otp verification code input, sms code autofill, bottom sheet, material design date picker, material time picker clock, ios style picker, expo, react native web', }, }, { tagName: 'link', attributes: { rel: 'alternate', type: 'text/plain', title: 'LLM docs', href: `${BASE_URL}llms.txt` } }, diff --git a/website/sidebars.ts b/website/sidebars.ts index 56bcde9..81a8059 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -99,6 +99,7 @@ const sidebars: SidebarsConfig = { 'resources/migration/react-native-date-picker', 'resources/migration/modal-datetime-picker', 'resources/migration/react-native-calendars', + 'resources/migration/ui-datepicker', 'resources/migration/picker-select', 'resources/migration/dropdown-picker', 'resources/migration/element-dropdown', @@ -107,6 +108,10 @@ const sidebars: SidebarsConfig = { 'resources/migration/country-picker-modal', 'resources/migration/phone-number-input', 'resources/migration/emoji-keyboard', + 'resources/migration/slider', + 'resources/migration/multi-slider', + 'resources/migration/otp-entry', + 'resources/migration/confirmation-code-field', ], }, 'resources/faq', diff --git a/website/src/catalog/catalog.constants.ts b/website/src/catalog/catalog.constants.ts index a222398..2cb3eb9 100644 --- a/website/src/catalog/catalog.constants.ts +++ b/website/src/catalog/catalog.constants.ts @@ -31,6 +31,7 @@ export const PLAYGROUND = { colorPicker: 'colorPicker', colorPickerField: 'colorPickerField', slider: 'slider', + otpField: 'otpField', pickerField: 'pickerField', pickerSheet: 'pickerSheet', bottomSheet: 'bottomSheet', @@ -98,6 +99,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { doc: 'components/emoji-picker', label: 'EmojiPicker', playgrounds: [PLAYGROUND.emojiPicker, PLAYGROUND.emojiPickerField] }, { doc: 'components/color-picker', label: 'ColorPicker', playgrounds: [PLAYGROUND.colorPicker, PLAYGROUND.colorPickerField] }, { doc: 'components/slider', label: 'Slider', playgrounds: [PLAYGROUND.slider] }, + { doc: 'components/otp-field', label: 'OtpField', playgrounds: [PLAYGROUND.otpField] }, { label: 'Fields & sheets', pages: [ diff --git a/website/src/catalog/playgrounds.tsx b/website/src/catalog/playgrounds.tsx index fb42c4d..4b4d433 100644 --- a/website/src/catalog/playgrounds.tsx +++ b/website/src/catalog/playgrounds.tsx @@ -19,6 +19,7 @@ import { MonthPicker, MonthPickerField, MultiWheelPicker, + OtpField, PhoneField, SelectList, SelectPicker, @@ -452,6 +453,18 @@ export const PLAYGROUNDS: Record = { { prop: 'disabled', options: [false, true] }, ], }, + otpField: { + name: 'OtpField', + component: OtpField, + value: '', + code: 'value={code} onChange={setCode} onComplete={verify}', + controls: [ + { prop: 'length', options: [6, 4, 8], default: 6 }, + { prop: 'inputMode', options: ['numeric', 'text'], default: 'numeric' }, + { prop: 'secureTextEntry', options: [false, true] }, + { prop: 'disabled', options: [false, true] }, + ], + }, colorPickerField: { name: 'ColorPickerField', component: ColorPickerField, diff --git a/website/src/home/components/Hero.tsx b/website/src/home/components/Hero.tsx index dde832c..a7a44c1 100644 --- a/website/src/home/components/Hero.tsx +++ b/website/src/home/components/Hero.tsx @@ -29,9 +29,9 @@ export const Hero = () => { React Native app needs.

- Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, a slider, plus the sheets - and fields to put them in. Native on iOS, Android and the web, yours to customize, and 100% JavaScript with - zero native code. + Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, a slider, an OTP input, + plus the sheets and fields to put them in. Native on iOS, Android and the web, yours to customize, and{' '} + 100% JavaScript with zero native code.

diff --git a/website/src/home/home.constants.ts b/website/src/home/home.constants.ts index 79a23c9..1d4a151 100644 --- a/website/src/home/home.constants.ts +++ b/website/src/home/home.constants.ts @@ -13,7 +13,7 @@ export const NPM_DOWNLOADS_STATUS = { export const PAGE_TITLE = 'Date, Time, Select & Emoji Pickers with Zero Native Code'; export const PAGE_DESCRIPTION = - 'Wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.'; + 'Wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers, a slider and an OTP code input for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.'; export const COUNT_UP_MS = 1400; export const COPIED_MS = 1500; @@ -69,6 +69,7 @@ export const COMPONENTS = [ { doc: 'components/emoji-picker', name: 'EmojiPicker', body: '1,923 emojis, skin tones, recents, search' }, { doc: 'components/color-picker', name: 'ColorPicker', body: "iOS's grid, Google's circles, a web spectrum" }, { doc: 'components/slider', name: 'Slider', body: 'One value or a range, with steps and icons' }, + { doc: 'components/otp-field', name: 'OtpField', body: 'Verification codes, SMS autofill, paste' }, { doc: 'components/multi-wheel-picker', name: 'MultiWheelPicker', body: 'Units, compound values, dependent columns' }, { doc: 'components/picker-sheets', name: 'Sheets', body: 'Every picker in a bottom sheet or modal' }, { doc: 'components/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet, a dropdown or inline' }, diff --git a/website/src/live/live.constants.ts b/website/src/live/live.constants.ts index 0435433..267da44 100644 --- a/website/src/live/live.constants.ts +++ b/website/src/live/live.constants.ts @@ -34,6 +34,7 @@ const DURATION = ['DurationPicker', 'DurationPickerField', 'DurationPickerSheet' const EMOJI = ['EmojiPicker', 'EmojiPickerField', 'EmojiPickerSheet']; const SELECT = ['SelectPicker', 'CountryPicker']; const SLIDER = ['Slider']; +const OTP = ['OtpField']; /** * The side panel's props, offered for every live example that renders one of their components. @@ -76,7 +77,8 @@ export const LIVE_CONTROLS: readonly LiveControl[] = [ { prop: 'showDialCode', components: ['CountryPicker'], options: BOOLEAN, default: false }, { prop: 'clearable', components: [...FIELDS, ...SELECT], options: BOOLEAN, default: false, requires: /\bonClear=/ }, { prop: 'showSteps', components: SLIDER, options: BOOLEAN, default: false }, - { prop: 'disabled', components: [...FIELDS, ...SELECT, ...SLIDER], options: BOOLEAN, default: false }, + { prop: 'secureTextEntry', components: OTP, options: BOOLEAN, default: false }, + { prop: 'disabled', components: [...FIELDS, ...SELECT, ...SLIDER, ...OTP], options: BOOLEAN, default: false }, ]; export const SNACK_URL = 'https://snack.expo.dev/'; From cf8540f37a27b16957cfd7ee48017bc9628bf176 Mon Sep 17 00:00:00 2001 From: eliyahu-kriel <44751346+elik56a@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:37:16 +0200 Subject: [PATCH 2/2] fix(otp): name the OTP hooks' return types so the build can emit them useOtpField's inferred return held React Native's internal AnimatedValue, which tsc can't name in the declarations (TS2883). It now returns OtpFieldState, and useCaretBlink an Animated.Value. Co-Authored-By: Claude Opus 5.5 --- src/otp/hooks/useCaretBlink.ts | 2 +- src/otp/hooks/useOtpField.ts | 4 ++-- src/otp/otp.types.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/otp/hooks/useCaretBlink.ts b/src/otp/hooks/useCaretBlink.ts index 47a4891..0a99415 100644 --- a/src/otp/hooks/useCaretBlink.ts +++ b/src/otp/hooks/useCaretBlink.ts @@ -6,7 +6,7 @@ import { timing } from '../../core/utils/animation.utils'; import { CARET_BLINK_MS } from '../otp.constants'; /** The caret's opacity: blinking while `visible`, and shown solid again each time it moves to `position`. */ -export const useCaretBlink = (visible: boolean, position: number) => { +export const useCaretBlink = (visible: boolean, position: number): Animated.Value => { const opacity = useAnimatedValue(1); // biome-ignore lint/correctness/useExhaustiveDependencies: restarts when the caret moves, so it shows as soon as a character is typed diff --git a/src/otp/hooks/useOtpField.ts b/src/otp/hooks/useOtpField.ts index e975a38..378cee3 100644 --- a/src/otp/hooks/useOtpField.ts +++ b/src/otp/hooks/useOtpField.ts @@ -1,7 +1,7 @@ import { useControllableState } from '../../core/hooks/useControllableState'; import { useFocus } from '../../core/hooks/useFocus'; import { DEFAULT_OTP_LENGTH, OTP_INPUT_MODE } from '../otp.constants'; -import type { OtpFieldProps } from '../otp.types'; +import type { OtpFieldProps, OtpFieldState } from '../otp.types'; import { getCells, sanitizeCode } from '../otp.utils'; import { useCaretBlink } from './useCaretBlink'; @@ -14,7 +14,7 @@ export const useOtpField = ({ onComplete, inputMode = OTP_INPUT_MODE.numeric, secureTextEntry = false, -}: OtpFieldProps) => { +}: OtpFieldProps): OtpFieldState => { const [raw, setCode] = useControllableState(value, defaultValue, onChange); const code = sanitizeCode(raw, length, inputMode); const focus = useFocus(); diff --git a/src/otp/otp.types.ts b/src/otp/otp.types.ts index f4e1c86..986e1d0 100644 --- a/src/otp/otp.types.ts +++ b/src/otp/otp.types.ts @@ -44,6 +44,17 @@ export interface OtpCellState { caret: boolean; } +/** What `useOtpField` hands the field. Named so the build can emit the hook returning it. */ +export interface OtpFieldState { + code: string; + length: number; + cells: OtpCellState[]; + caretOpacity: Animated.Value; + changeText: (text: string) => void; + onFocus: () => void; + onBlur: () => void; +} + export interface OtpCellProps { cell: OtpCellState; caretOpacity: Animated.Value;