Lock the key gate while the on-screen keyboard is up
On phones the gate card (447px) is taller than the keyboard-reduced viewport, so the browser panned the page to reveal the field and the user could keep scrolling/dragging while typing the API key. - Pin the gate to the visual viewport (height + offsetTop tracked from visualViewport resize/scroll), so the browser never needs to pan the page to reveal the focused field. - When the keyboard is detected (visual viewport >120px shorter than innerHeight), lock the card: overflow hidden + touch-action none, and keep the field and Continue button in view by scrolling the wrapper programmatically. Re-aligns on resize and when the card grows (error message / busy state). - Corner controls are now absolute within the pinned gate so they stay in the visual viewport. - Don't auto-focus the field on touch devices (no forced keyboard on load); desktop still focuses. Verified by shrinking the viewport to 330px: card locked (wheel/swipe does nothing), field 182-230 and Continue 242-290 both visible, doc scrollHeight == viewport; error state keeps Continue at 270-318. key-gate 49/49, install sheet 64/64, final-ux 12/12, zoom 6/6.
This commit is contained in:
parent
7ab04e9f62
commit
190664c1a5
1 changed files with 79 additions and 5 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
import {
|
||||
|
|
@ -84,12 +84,74 @@ function GateScreen() {
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<GateError>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
// Don't force the on-screen keyboard open on touch devices — let the user
|
||||
// tap the field when they're ready.
|
||||
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Keep the field and the Continue button in view while the on-screen keyboard
|
||||
// is up (the card itself is locked, so this is done by scrolling the wrapper
|
||||
// programmatically rather than by the user).
|
||||
const alignKeyboard = useCallback(() => {
|
||||
const wrap = wrapRef.current;
|
||||
const submit = submitRef.current;
|
||||
if (!wrap || !submit) return;
|
||||
const wrapBox = wrap.getBoundingClientRect();
|
||||
const btnBox = submit.getBoundingClientRect();
|
||||
const below = btnBox.bottom + 12 - wrapBox.bottom;
|
||||
const above = wrapBox.top + 12 - btnBox.top;
|
||||
if (below > 0) wrap.scrollTop += below;
|
||||
else if (above > 0) wrap.scrollTop -= above;
|
||||
}, []);
|
||||
|
||||
// Pin the gate to the *visual* viewport so the browser never has to pan the
|
||||
// page to reveal the focused field. While the on-screen keyboard is up the
|
||||
// card is locked (not user-scrollable).
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
const root = rootRef.current;
|
||||
if (!vv || !root) return;
|
||||
|
||||
const baseline = window.innerHeight;
|
||||
let frame = 0;
|
||||
|
||||
const sync = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
root.style.height = `${vv.height}px`;
|
||||
root.style.top = `${vv.offsetTop}px`;
|
||||
|
||||
const open = baseline - vv.height > 120;
|
||||
setKeyboardOpen(open);
|
||||
if (open) alignKeyboard();
|
||||
});
|
||||
};
|
||||
|
||||
sync();
|
||||
vv.addEventListener("resize", sync);
|
||||
vv.addEventListener("scroll", sync);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
vv.removeEventListener("resize", sync);
|
||||
vv.removeEventListener("scroll", sync);
|
||||
};
|
||||
}, [alignKeyboard]);
|
||||
|
||||
// Re-align when the card grows/shrinks (e.g. an error message appears) while
|
||||
// the keyboard is open.
|
||||
useEffect(() => {
|
||||
if (!keyboardOpen) return;
|
||||
const frame = requestAnimationFrame(alignKeyboard);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [keyboardOpen, error, busy, alignKeyboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!helpOpen) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
|
|
@ -118,10 +180,13 @@ function GateScreen() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="gate-bg fixed inset-0 overflow-hidden overscroll-none">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="gate-bg fixed inset-0 h-dvh overflow-hidden overscroll-none"
|
||||
>
|
||||
{/* corner controls: theme + help ---------------------------------- */}
|
||||
<div
|
||||
className="fixed z-20 flex items-center gap-2"
|
||||
className="absolute z-20 flex items-center gap-2"
|
||||
style={{
|
||||
top: "max(env(safe-area-inset-top), 1.25rem)",
|
||||
right: "max(env(safe-area-inset-right), 1.25rem)",
|
||||
|
|
@ -153,7 +218,15 @@ function GateScreen() {
|
|||
</div>
|
||||
|
||||
{/* card ----------------------------------------------------------- */}
|
||||
<div className="relative z-10 flex h-full items-center justify-center overflow-y-auto overscroll-contain px-5 py-10">
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={cn(
|
||||
"relative z-10 flex h-full justify-center px-5",
|
||||
keyboardOpen
|
||||
? "items-start touch-none overflow-hidden py-3"
|
||||
: "items-center overflow-y-auto overscroll-contain py-10",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -225,6 +298,7 @@ function GateScreen() {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
ref={submitRef}
|
||||
disabled={!draft.trim() || busy}
|
||||
className={cn(
|
||||
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue