Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img alt="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" src="https://raw.githubusercontent.com/elik56a/react-native-pickers/master/website/static/img/demo.gif" width="900">
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <elik56a@gmail.com>",
"homepage": "https://react-native-pickers.dev/",
Expand Down Expand Up @@ -74,6 +74,9 @@
"color-picker",
"slider",
"range-slider",
"otp-input",
"otp",
"verification-code",
"colour-picker",
"hex-color-picker",
"duration-picker",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
51 changes: 51 additions & 0 deletions src/otp/OtpField.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<OtpField length={4} onChange={onChange} onComplete={onComplete} testID="otp" />);
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(<OtpField onChange={onChange} />);
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(<OtpField length={4} defaultValue="12" secureTextEntry testID="otp" />);
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(<OtpField length={4} value="" onChange={onChange} onComplete={onComplete} testID="otp" />);
await rerender(<OtpField length={4} value="5678" onChange={onChange} onComplete={onComplete} testID="otp" />);
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(<OtpField />);
expect(input().props).toMatchObject({ textContentType: 'oneTimeCode', keyboardType: 'number-pad', maxLength: 6 });
});
});
70 changes: 70 additions & 0 deletions src/otp/OtpField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[{ opacity: disabled ? DISABLED_TRIGGER_OPACITY : 1 }, style]}>
<View
style={[styles.row, { gap: theme.spacing.sm }, slots?.container]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{otp.cells.map((cell) => (
<OtpCell
key={cell.key}
cell={cell}
caretOpacity={otp.caretOpacity}
look={look}
theme={theme}
slots={slots}
testID={testID ? `${testID}-cell-${cell.key}` : undefined}
/>
))}
</View>
<TextInput
value={otp.code}
onChangeText={otp.changeText}
onFocus={otp.onFocus}
onBlur={otp.onBlur}
maxLength={otp.length}
keyboardType={inputMode === OTP_INPUT_MODE.numeric ? 'number-pad' : 'default'}
autoCapitalize="none"
autoCorrect={false}
textContentType="oneTimeCode"
autoComplete={OTP_AUTO_COMPLETE}
secureTextEntry={secureTextEntry}
autoFocus={autoFocus}
editable={!disabled}
accessibilityLabel={accessibilityLabel}
style={[styles.input, coreStyles.noOutline]}
testID={testID}
/>
</View>
);
};
42 changes: 42 additions & 0 deletions src/otp/components/OtpCell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
style={[
styles.cell,
{
height: look.minHeight,
backgroundColor: c.background,
borderWidth,
borderColor: cell.focused ? c.primary : c[look.outline],
borderRadius: theme.radius.md,
},
cell.focused && look.focusRing && { boxShadow: `0px 0px 0px ${FOCUS_RING_WIDTH}px ${c.primaryMuted}` },
slots?.cell,
cell.filled && slots?.filledCell,
cell.focused && slots?.focusedCell,
]}
testID={testID}
>
{cell.caret ? (
<Animated.View
style={[styles.caret, { height: fontSize * CARET_HEIGHT_RATIO, backgroundColor: c.primary, opacity: caretOpacity }, slots?.caret]}
/>
) : (
<Text style={[textStyle(theme, 'xl', 'medium'), slots?.cellText]}>{cell.char}</Text>
)}
</View>
);
};
31 changes: 31 additions & 0 deletions src/otp/hooks/useCaretBlink.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions src/otp/hooks/useCaretBlink.ts
Original file line number Diff line number Diff line change
@@ -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): 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
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;
};
43 changes: 43 additions & 0 deletions src/otp/hooks/useOtpField.ts
Original file line number Diff line number Diff line change
@@ -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, OtpFieldState } 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): OtpFieldState => {
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,
};
};
2 changes: 2 additions & 0 deletions src/otp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OtpField } from './OtpField';
export type { OtpFieldProps, OtpFieldSlot, OtpInputMode } from './otp.types';
31 changes: 31 additions & 0 deletions src/otp/otp.constants.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions src/otp/otp.styles.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
Loading