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:
parent
eebbcb3db2
commit
3bfd35ef8d
@ -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({
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}`}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user