feat(mobile): PinInput component — 6-digit numeric pad
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user