forked from akai/readest
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:
@@ -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'
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user