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 = {
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({

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { createAccount } from "@/app/actions";
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 [status, setStatus] = useState("");
const [link, setLink] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
// Reset on open.
useEffect(() => {
@ -27,6 +28,15 @@ export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPa
setStatus("");
setLink("");
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]);
@ -65,10 +75,10 @@ export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPa
>
<Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}>
<input
ref={firstFieldRef}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={pending}
autoFocus
autoComplete="off"
spellCheck={false}
placeholder={prefixPattern ? `${prefixPattern}1234` : "username"}

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { createUser } from "@/app/actions";
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 [tUsername, setTUsername] = useState("");
const [tPassword, setTPassword] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
@ -25,6 +26,13 @@ export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
setTUsername("");
setTPassword("");
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]);
@ -63,10 +71,10 @@ export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
>
<Field label="From username" required>
<input
ref={firstFieldRef}
value={fUsername}
onChange={(e) => setFUsername(e.target.value)}
disabled={pending}
autoFocus
autoComplete="off"
spellCheck={false}
className={inputClass}

View File

@ -9,7 +9,16 @@ export default function Nav() {
const isAccounts = !isUsers;
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">
<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">

View File

@ -39,7 +39,13 @@ export default function Toast({
<div
role="status"
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
className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`}