Files
readest/apps/readest-app/src/components/PinInput.tsx
T
Huang Xin 1705006b6b fix(mobile): iOS PIN keyboard UX + Safari font line-height in EPUBs (#4120)
- AppLockScreen: pad the lock screen bottom by the on-screen
  keyboard height tracked via visualViewport, so the flex-centered
  PIN sits above the keyboard on iOS WKWebView where dvh does not
  shrink.
- AppLockScreen: skip stickyFocus on mobile. iOS will not pop the
  keyboard from a programmatic .focus(), so the cursor would blink
  with no input — wait for the user's tap instead.
- PinInput: forward autoFocus to the input when autoFocus or
  stickyFocus is set, for more reliable mount-time focus.
- style.ts: give legacy <p><font>...</font></p> its own block
  context so iOS Safari applies the inherited line-height.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:42:42 +02:00

126 lines
3.5 KiB
TypeScript

'use client';
import clsx from 'clsx';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { PIN_LENGTH } from '@/libs/crypto/applock';
interface PinInputProps {
value: string;
onChange: (next: string) => void;
autoFocus?: boolean;
shake?: boolean;
disabled?: boolean;
ariaLabel: string;
/**
* When true, the component keeps focus pinned to the hidden input —
* used by the full-screen lock screen so the on-screen keyboard
* stays available even after taps elsewhere on the page.
*/
stickyFocus?: boolean;
/** Form-level autocomplete hint. */
autoComplete?: 'one-time-code' | 'current-password' | 'new-password';
}
export interface PinInputHandle {
focus: () => void;
}
const PinDot = ({ filled, active }: { filled: boolean; active: boolean }) => (
<div
className={clsx(
'eink-bordered relative flex h-12 w-10 items-center justify-center rounded-lg border',
'border-base-content/20 bg-base-200/60',
filled && 'border-base-content/40',
active && 'border-base-content/40',
)}
>
<span
className={clsx(
'inline-block h-3 w-3 rounded-full transition-opacity',
filled ? 'bg-base-content opacity-100' : 'opacity-0',
)}
/>
{active && !filled && (
<span
aria-hidden='true'
className='bg-base-content animate-pin-cursor-blink absolute bottom-2 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full'
/>
)}
</div>
);
const PinInput = forwardRef<PinInputHandle, PinInputProps>(function PinInput(
{
value,
onChange,
autoFocus,
shake,
disabled,
ariaLabel,
stickyFocus,
autoComplete = 'one-time-code',
},
ref,
) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [focused, setFocused] = useState(false);
useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus() }));
useEffect(() => {
if (autoFocus) {
const t = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}
return undefined;
}, [autoFocus]);
useEffect(() => {
if (!stickyFocus) return;
const focus = () => inputRef.current?.focus();
focus();
const t = window.setInterval(focus, 200);
return () => window.clearInterval(t);
}, [stickyFocus]);
const handleClick = () => inputRef.current?.focus();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value.replace(/\D/g, '').slice(0, PIN_LENGTH);
onChange(next);
};
return (
<label className='relative block w-fit cursor-pointer' onClick={handleClick}>
<span className='sr-only'>{ariaLabel}</span>
<input
ref={inputRef}
type='password'
inputMode='numeric'
pattern='[0-9]*'
maxLength={PIN_LENGTH}
value={value}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={disabled}
autoComplete={autoComplete}
autoFocus={autoFocus || stickyFocus}
aria-label={ariaLabel}
className='absolute inset-0 z-10 cursor-pointer opacity-0'
/>
<div className={clsx('flex gap-3', shake && 'animate-pin-shake')}>
{Array.from({ length: PIN_LENGTH }).map((_dot, i) => (
<PinDot
key={i}
filled={i < value.length}
active={focused && !disabled && i === value.length}
/>
))}
</div>
</label>
);
});
export default PinInput;