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>
This commit is contained in:
Huang Xin
2026-05-11 02:42:42 +08:00
committed by GitHub
parent 952e436514
commit 1705006b6b
4 changed files with 61 additions and 2 deletions
@@ -0,0 +1,33 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import PinInput from '@/components/PinInput';
vi.mock('@/libs/crypto/applock', () => ({
PIN_LENGTH: 4,
}));
afterEach(cleanup);
describe('PinInput', () => {
it('focuses the input on mount when autoFocus is true', async () => {
render(<PinInput value='' onChange={() => {}} ariaLabel='PIN code' autoFocus />);
const input = screen.getByLabelText('PIN code');
await vi.waitFor(() => expect(document.activeElement).toBe(input));
});
it('focuses the input on mount when stickyFocus is true', async () => {
render(<PinInput value='' onChange={() => {}} ariaLabel='PIN code' stickyFocus />);
const input = screen.getByLabelText('PIN code');
await vi.waitFor(() => expect(document.activeElement).toBe(input));
});
it('does not focus the input when neither autoFocus nor stickyFocus is set', () => {
render(<PinInput value='' onChange={() => {}} ariaLabel='PIN code' />);
const input = screen.getByLabelText('PIN code');
expect(document.activeElement).not.toBe(input);
});
});
@@ -1,19 +1,40 @@
'use client';
import clsx from 'clsx';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import PinInput from '@/components/PinInput';
import { useEnv } from '@/context/EnvContext';
import { PIN_LENGTH, verifyPin } from '@/libs/crypto/applock';
import { useAppLockStore } from '@/store/appLockStore';
import { useTranslation } from '@/hooks/useTranslation';
export default function AppLockScreen() {
const _ = useTranslation();
const { appService } = useEnv();
const { pinHash, pinSalt, unlock } = useAppLockStore();
const autoFocusEnabled = !appService?.isMobile;
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [shaking, setShaking] = useState(false);
const [kbInset, setKbInset] = useState(0);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const layoutH = document.documentElement.clientHeight;
const offset = layoutH - vv.height - vv.offsetTop;
setKbInset(offset > 1 ? offset : 0);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
update();
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
// Avoid React state for the in-flight guard — `setVerifying(true)`
// would re-trigger the effect, the cleanup would set `cancelled=true`,
// and the resolve handler would short-circuit before clearing the
@@ -50,6 +71,7 @@ export default function AppLockScreen() {
return (
<div
className='bg-base-100 full-height inset-0 z-[200] flex flex-col items-center justify-center px-6'
style={{ paddingBottom: kbInset || undefined }}
role='dialog'
aria-modal='true'
aria-label={_('App locked')}
@@ -66,7 +88,7 @@ export default function AppLockScreen() {
value={pin}
onChange={handleChange}
ariaLabel={_('PIN code')}
stickyFocus
stickyFocus={autoFocusEnabled}
shake={shaking}
/>
@@ -105,6 +105,7 @@ const PinInput = forwardRef<PinInputHandle, PinInputProps>(function PinInput(
onBlur={() => setFocused(false)}
disabled={disabled}
autoComplete={autoComplete}
autoFocus={autoFocus || stickyFocus}
aria-label={ariaLabel}
className='absolute inset-0 z-10 cursor-pointer opacity-0'
/>
+3
View File
@@ -460,6 +460,9 @@ const getParagraphLayoutStyles = (
${!vertical && overrideLayout ? `margin-top: ${paragraphMargin}em !important;` : ''}
${!vertical && overrideLayout ? `margin-bottom: ${paragraphMargin}em !important;` : ''}
}
p > font:only-child {
display: flow-root;
}
:lang(zh), :lang(ja), :lang(ko) {
widows: 1;