diff --git a/apps/readest-app/src/__tests__/components/PinInput.test.tsx b/apps/readest-app/src/__tests__/components/PinInput.test.tsx new file mode 100644 index 00000000..e029c2a2 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/PinInput.test.tsx @@ -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( {}} 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( {}} 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( {}} ariaLabel='PIN code' />); + + const input = screen.getByLabelText('PIN code'); + expect(document.activeElement).not.toBe(input); + }); +}); diff --git a/apps/readest-app/src/components/AppLockScreen.tsx b/apps/readest-app/src/components/AppLockScreen.tsx index dbada91d..0674b5a8 100644 --- a/apps/readest-app/src/components/AppLockScreen.tsx +++ b/apps/readest-app/src/components/AppLockScreen.tsx @@ -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 (
diff --git a/apps/readest-app/src/components/PinInput.tsx b/apps/readest-app/src/components/PinInput.tsx index 4f133aa9..f76e2f8f 100644 --- a/apps/readest-app/src/components/PinInput.tsx +++ b/apps/readest-app/src/components/PinInput.tsx @@ -105,6 +105,7 @@ const PinInput = forwardRef(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' /> diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 5dc479fa..2e1cdf18 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -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;