fix(web): PWA notch safe-area + skip autoFocus on touch devices

Adds viewportFit: 'cover' so the PWA can draw under the notch /
Dynamic Island when installed. Nav and Toast read env(safe-area-inset-*)
to keep their content out of the hardware cutouts (no-op on browsers
without a notch — env() resolves to 0).

Replaces autoFocus on the first field of CreateAccountDialog and
CreateUserDialog with a useEffect that only focuses on pointer devices
(matchMedia '(hover: hover) and (pointer: fine)'). Phones no longer
get the soft keyboard popping the instant a dialog opens.
This commit is contained in:
yiekheng 2026-05-02 21:26:42 +08:00
parent eebbcb3db2
commit 3bfd35ef8d
5 changed files with 44 additions and 6 deletions

View File

@ -10,6 +10,11 @@ export const metadata: Metadata = {
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#18181b", themeColor: "#18181b",
// Lets the page draw under the iPhone notch / Dynamic Island when the
// PWA runs in standalone mode. Components that pin to the edges (Nav,
// Toast) read env(safe-area-inset-*) to keep their content out of the
// hardware cutouts.
viewportFit: "cover",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { createAccount } from "@/app/actions"; import { createAccount } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell"; import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
@ -18,6 +18,7 @@ export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPa
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [link, setLink] = useState(""); const [link, setLink] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
// Reset on open. // Reset on open.
useEffect(() => { useEffect(() => {
@ -27,6 +28,15 @@ export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPa
setStatus(""); setStatus("");
setLink(""); setLink("");
setError(null); setError(null);
// Autofocus the first field only on devices with a fine pointer
// (desktop). Phones skip this — the soft keyboard popping the
// moment a dialog opens is jarring and reflows the page.
if (typeof window !== "undefined") {
const isPointerDevice = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (isPointerDevice) {
requestAnimationFrame(() => firstFieldRef.current?.focus());
}
}
} }
}, [open]); }, [open]);
@ -65,10 +75,10 @@ export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPa
> >
<Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}> <Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}>
<input <input
ref={firstFieldRef}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
disabled={pending} disabled={pending}
autoFocus
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
placeholder={prefixPattern ? `${prefixPattern}1234` : "username"} placeholder={prefixPattern ? `${prefixPattern}1234` : "username"}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { createUser } from "@/app/actions"; import { createUser } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell"; import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
@ -17,6 +17,7 @@ export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
const [fPassword, setFPassword] = useState(""); const [fPassword, setFPassword] = useState("");
const [tUsername, setTUsername] = useState(""); const [tUsername, setTUsername] = useState("");
const [tPassword, setTPassword] = useState(""); const [tPassword, setTPassword] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -25,6 +26,13 @@ export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
setTUsername(""); setTUsername("");
setTPassword(""); setTPassword("");
setError(null); setError(null);
// Autofocus first field only on pointer devices — see CreateAccountDialog.
if (typeof window !== "undefined") {
const isPointerDevice = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (isPointerDevice) {
requestAnimationFrame(() => firstFieldRef.current?.focus());
}
}
} }
}, [open]); }, [open]);
@ -63,10 +71,10 @@ export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
> >
<Field label="From username" required> <Field label="From username" required>
<input <input
ref={firstFieldRef}
value={fUsername} value={fUsername}
onChange={(e) => setFUsername(e.target.value)} onChange={(e) => setFUsername(e.target.value)}
disabled={pending} disabled={pending}
autoFocus
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
className={inputClass} className={inputClass}

View File

@ -9,7 +9,16 @@ export default function Nav() {
const isAccounts = !isUsers; const isAccounts = !isUsers;
return ( return (
<header className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/80 backdrop-blur-md"> <header
className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/80 backdrop-blur-md"
style={{
// Push content below the iPhone notch when the PWA is installed.
// No-op on browsers without a notch (env() resolves to 0).
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
}}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6"> <div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<Link href="/" className="group flex items-center gap-3"> <Link href="/" className="group flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white"> <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">

View File

@ -39,7 +39,13 @@ export default function Toast({
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 transform px-4" className="fixed left-1/2 z-50 -translate-x-1/2 transform px-4"
style={{
// Stay below the notch when running as an installed PWA.
// calc(safe-area + 1rem) keeps the toast 1rem below the safe-area
// edge — and 1rem below the top in browsers without a notch.
top: "calc(env(safe-area-inset-top) + 1rem)",
}}
> >
<div <div
className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`} className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`}