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";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
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 { useTheme } from "@/components/ThemeToggle";
|
||||||
import {
|
import {
|
||||||
|
|
@ -84,12 +84,74 @@ function GateScreen() {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<GateError>(null);
|
const [error, setError] = useState<GateError>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const submitRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!helpOpen) return;
|
if (!helpOpen) return;
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
|
@ -118,10 +180,13 @@ function GateScreen() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ---------------------------------- */}
|
{/* corner controls: theme + help ---------------------------------- */}
|
||||||
<div
|
<div
|
||||||
className="fixed z-20 flex items-center gap-2"
|
className="absolute z-20 flex items-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
top: "max(env(safe-area-inset-top), 1.25rem)",
|
top: "max(env(safe-area-inset-top), 1.25rem)",
|
||||||
right: "max(env(safe-area-inset-right), 1.25rem)",
|
right: "max(env(safe-area-inset-right), 1.25rem)",
|
||||||
|
|
@ -153,7 +218,15 @@ function GateScreen() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* card ----------------------------------------------------------- */}
|
{/* 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
|
<motion.div
|
||||||
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
|
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -225,6 +298,7 @@ function GateScreen() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
ref={submitRef}
|
||||||
disabled={!draft.trim() || busy}
|
disabled={!draft.trim() || busy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",
|
"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