feat(mobile): PinInput component — 6-digit numeric pad

This commit is contained in:
byGalax
2026-05-16 17:04:25 +02:00
parent 898b7469bb
commit 18a6365586
7 changed files with 688 additions and 4 deletions
+31
View File
@@ -0,0 +1,31 @@
import { fireEvent, render } from '@testing-library/react-native';
import { describe, expect, it, vi } from 'vitest';
import { PinInput } from './PinInput';
describe('<PinInput>', () => {
it('appends digits to the underlying value and stops at length', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
expect(onChange).toHaveBeenCalledWith('123456');
});
it('strips non-digits', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
expect(onChange).toHaveBeenCalledWith('123');
});
it('renders one bullet per filled slot', () => {
const { getAllByText } = render(
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
);
expect(getAllByText('•').length).toBe(3);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInput as TextInputType,
} from 'react-native';
import { colors } from '../theme/colors';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
// that captures the numeric keyboard; visible slots render bullets when
// filled. Tapping anywhere on the row re-focuses the input.
export function PinInput({
value,
onChange,
length = 6,
autoFocus,
disabled,
ariaLabel,
onSubmit,
}: Props) {
const ref = useRef<TextInputType | null>(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
return (
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
<TextInput
ref={ref}
testID="pin-input"
accessibilityLabel={ariaLabel}
keyboardType="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
editable={!disabled}
value={value}
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
onSubmitEditing={() => {
if (value.length === length) onSubmit?.();
}}
style={styles.hidden}
/>
<View style={styles.slots}>
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
return (
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
{filled && <Text style={styles.bullet}></Text>}
</View>
);
})}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { alignItems: 'center' },
hidden: {
position: 'absolute',
width: 1,
height: 1,
opacity: 0,
},
slots: { flexDirection: 'row', gap: 8 },
slot: {
width: 40,
height: 48,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
},
slotFilled: {
borderColor: colors.accent,
backgroundColor: colors.bg,
},
bullet: { color: colors.text, fontSize: 22 },
});
+4 -1
View File
@@ -49,6 +49,9 @@
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.12"
"@testing-library/react-native": "^12.9.0",
"@types/react": "~18.3.12",
"jsdom": "^29.1.1",
"react-test-renderer": "18.3.1"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
const shimPath = fileURLToPath(new URL('./vitest.rn-shim.ts', import.meta.url));
// Vitest cannot transform React Native's Flow-typed source. We redirect
// every `react-native` reference to a minimal host-component shim
// (`vitest.rn-shim.ts`) so @testing-library/react-native v12 + jsdom can
// render the few primitives PinInput uses.
//
// Two interception layers are needed:
// 1. resolve.alias — covers ESM imports vite sees during transformation
// of inlined dependencies (e.g. when @testing-library/react-native
// goes through vite's pipeline because of `server.deps.inline`).
// 2. setupFiles patches Module._resolveFilename so any pure-CJS
// `require("react-native")` that escapes vite also lands on the shim.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
server: {
deps: {
inline: [/@testing-library\/react-native/],
},
},
},
resolve: {
alias: [
{ find: /^react-native$/, replacement: shimPath },
{ find: /^react-native\/(.*)$/, replacement: shimPath },
],
},
});
+84
View File
@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Minimal `react-native` surface used by vitest tests. Each visible
// primitive is a forwardRef component that renders a lower-case React
// element so react-test-renderer treats it as a regular host node, and
// @testing-library/react-native's queries can traverse the tree.
// The shim is loaded by Module._resolveFilename in vitest.setup.ts.
import * as React from 'react';
function makeHost(name: string) {
const C: any = React.forwardRef(function HostComponent(
props: Record<string, unknown>,
ref: unknown,
) {
return React.createElement(name, { ...props, ref });
});
C.displayName = name;
return C;
}
export const View = makeHost('View');
export const Text = makeHost('Text');
export const TextInput = makeHost('TextInput');
export const Pressable = makeHost('Pressable');
export const Image = makeHost('Image');
export const ScrollView = makeHost('ScrollView');
export const Modal = makeHost('Modal');
export const Switch = makeHost('Switch');
export const Platform = { OS: 'test', select: (m: any) => m.default ?? m.test ?? null };
export const StyleSheet = {
create<T extends Record<string, unknown>>(s: T): T {
return s;
},
flatten(s: unknown): unknown {
if (Array.isArray(s)) {
const flat: Record<string, unknown> = {};
for (const part of s) Object.assign(flat, part ?? {});
return flat;
}
return s ?? {};
},
hairlineWidth: 1,
absoluteFill: {},
absoluteFillObject: {},
};
export const Dimensions = { get: () => ({ width: 360, height: 640 }) };
export const NativeModules: Record<string, unknown> = {};
export const Animated = { View, Text, Image, createAnimatedComponent: (c: unknown) => c };
export const Linking = { openURL: async () => undefined };
export const Alert = { alert: () => undefined };
export const Appearance = { getColorScheme: () => 'dark' };
export const AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => undefined }),
};
export const PixelRatio = { get: () => 2 };
export const Keyboard = { dismiss: () => undefined };
export const useWindowDimensions = () => ({ width: 360, height: 640 });
const defaultExport = {
View,
Text,
TextInput,
Pressable,
Image,
ScrollView,
Modal,
Switch,
Platform,
StyleSheet,
Dimensions,
NativeModules,
Animated,
Linking,
Alert,
Appearance,
AccessibilityInfo,
PixelRatio,
Keyboard,
useWindowDimensions,
};
export default defaultExport;
+25
View File
@@ -0,0 +1,25 @@
// Vitest cannot transform React Native's Flow-typed source. We monkey-patch
// Node's CommonJS resolver so every bare `react-native` request returns our
// minimal host-component shim. This file is referenced by `setupFiles` in
// vitest.config.ts and runs before any test file imports execute.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Module = require('node:module');
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const shimPath = path.join(here, 'vitest.rn-shim.ts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalResolve = (Module as any)._resolveFilename;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Module as any)._resolveFilename = function patched(
request: string,
parent: unknown,
...rest: unknown[]
): string {
if (request === 'react-native' || request.startsWith('react-native/')) {
return shimPath;
}
return originalResolve.call(this, request, parent, ...rest);
};