Improve dialog behavior when virtual keyboard is open

Update dialog component to use CSS :has(:focus) for keyboard detection, removing reliance on visual viewport for better cross-platform compatibility.
This commit is contained in:
Riyadh
2026-05-21 08:45:43 +00:00
parent 210afc7d44
commit 65c5335eb2
2 changed files with 26 additions and 127 deletions
+7 -115
View File
@@ -3,7 +3,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useVisualViewport } from "@/hooks/use-visual-viewport"
const Dialog = DialogPrimitive.Root
@@ -32,104 +31,17 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, style, ...props }, ref) => {
const { keyboardInset, height, offsetTop } = useVisualViewport()
// Only react when an on-screen keyboard is actually present. iOS Safari
// and Android Chrome both report a non-trivial bottom inset (>= ~150px)
// when their software keyboard opens; tiny insets from browser chrome
// changes are ignored so desktop and normal mobile scrolling stay
// untouched.
const keyboardOpen = keyboardInset > 80
// CSS vars consumed by the global `[data-dialog-keyboard-open="true"]`
// rule in index.css. We pass position/size through CSS variables and
// let `!important` rules win the cascade against arbitrary consumer
// Tailwind classes (`w-[calc(100vw-1rem)]`, `max-w-3xl`, `max-h-[92vh]`,
// entrance-animation transforms, etc.) which previously beat inline
// `style` on iPad PWA and left the sheet pinned as a narrow strip in
// the top-left corner.
const kbVars = keyboardOpen
? ({
["--dialog-top" as never]: `${offsetTop}px`,
["--dialog-max-h" as never]: `${height - 8}px`,
} as React.CSSProperties)
: undefined
const innerRef = React.useRef<HTMLDivElement | null>(null)
// Merge the consumer-provided forwardRef with our own ref so we can
// attach focus listeners without breaking Radix's portal/animation
// wiring (which relies on receiving a working ref).
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref],
)
// iOS PWA + Android Chrome both mis-handle focus inside a position:fixed
// dialog when the soft keyboard opens:
// * iOS scrolls the *layout* viewport upward trying to reveal the
// focused input, which slides our fixed-positioned dialog off the
// top of the screen. The text the user is typing disappears.
// * Neither browser scrolls the focused field into view inside the
// dialog's own scroll container, so tall forms (saved notes,
// user profile, executive meetings) lose the focused field below
// the keyboard or above the visible band.
// The fix is dialog-wide so every form benefits without page edits:
// 1. Undo iOS's layout-viewport scroll (`window.scrollTo(0, 0)`).
// 2. After the keyboard animation settles, scrollIntoView({block:
// 'center'}) on the focused field — this scrolls the dialog's
// own overflow container, since the dialog is the nearest
// scrollable ancestor when keyboardOpen flips on overflow-y.
React.useEffect(() => {
const node = innerRef.current
if (!node) return
const onFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null
if (!target) return
const tag = target.tagName
if (
tag !== "INPUT" &&
tag !== "TEXTAREA" &&
tag !== "SELECT" &&
!target.isContentEditable
) {
return
}
// Undo iOS layout-viewport push-up immediately, then again after
// the keyboard finishes animating (~250ms on iPad).
const reset = () => {
try {
window.scrollTo(0, 0)
} catch {}
try {
target.scrollIntoView({ block: "center", behavior: "smooth" })
} catch {
target.scrollIntoView()
}
}
requestAnimationFrame(reset)
const t = window.setTimeout(reset, 300)
const t2 = window.setTimeout(reset, 600)
target.addEventListener(
"blur",
() => {
clearTimeout(t)
clearTimeout(t2)
},
{ once: true },
)
}
node.addEventListener("focusin", onFocusIn)
return () => node.removeEventListener("focusin", onFocusIn)
}, [])
const mergedStyle: React.CSSProperties = { ...(kbVars ?? {}), ...(style ?? {}) }
// Keyboard-aware sheet behaviour is driven entirely by the
// `[role="dialog"]:has(:focus)` rule in index.css — see that file for
// the rationale. We no longer depend on `visualViewport` because iPad
// PWAs don't reliably resize it when the soft keyboard opens, which
// left the previous JS-driven override silently dead on this device.
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={setRefs}
data-dialog-keyboard-open={keyboardOpen ? "true" : undefined}
style={mergedStyle}
ref={ref}
style={style}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
@@ -137,26 +49,6 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
{/* TEMP DEBUG: shows the live visualViewport readings so we can
tell on-device whether the keyboard-open branch is actually
firing. Remove once iPad PWA layout is confirmed. */}
<div
style={{
position: "absolute",
top: 2,
left: 2,
zIndex: 9999,
fontSize: 10,
background: keyboardOpen ? "#16a34a" : "#dc2626",
color: "white",
padding: "2px 6px",
borderRadius: 4,
pointerEvents: "none",
fontFamily: "monospace",
}}
>
kb:{keyboardInset.toFixed(0)} h:{height.toFixed(0)} top:{offsetTop.toFixed(0)} {keyboardOpen ? "OPEN" : "closed"}
</div>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
+19 -12
View File
@@ -472,24 +472,31 @@ html[dir="rtl"] .truncate {
/*
* Dialog keyboard-aware sheet override.
*
* On iPad/iPhone PWAs with the soft keyboard open, consumer
* DialogContent classes like `w-[calc(100vw-1rem)] sm:w-full
* max-w-3xl max-h-[92vh]` plus the Radix entrance-animation transforms
* (slide-in-from-left-1/2, zoom-in-95) were beating inline `style` and
* pinning the dialog as a narrow strip in the top-left corner. CSS
* variables (`--dialog-top`, `--dialog-max-h`) are set inline by
* `DialogContent` from `useVisualViewport`; the `!important` rules
* here guarantee they win the cascade no matter what classes the
* consumer passes in.
* iPad/iPhone PWAs don't reliably resize `window.visualViewport` when
* the soft keyboard opens, so a JS-driven override based on it was
* silently dead on Tahani's device — the dialog stayed centred (with
* its consumer-provided `max-w-3xl translate(-50%, -50%)` classes) and
* iOS scrolled the *layout* viewport upward to reveal the focused
* input, leaving the dialog as a narrow strip pinned in the top-left
* corner.
*
* Instead we drive everything from CSS. `:has(:focus)` matches whenever
* any focusable descendant inside a `[role="dialog"]` (input, textarea,
* select, contenteditable) holds focus — and on iOS that's exactly when
* the soft keyboard is showing. We then pin the dialog as a
* full-width sheet at the top of the dynamic viewport using `100dvh`,
* which Safari shrinks automatically to exclude the keyboard. No JS
* required, !important wins the cascade against any consumer Tailwind
* classes, and the layout snaps back the moment focus leaves.
*/
[data-dialog-keyboard-open="true"] {
top: var(--dialog-top, 0px) !important;
[role="dialog"]:has(:is(input, textarea, select, [contenteditable="true"]):focus) {
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: auto !important;
width: 100vw !important;
max-width: none !important;
max-height: var(--dialog-max-h, 100vh) !important;
max-height: 100dvh !important;
transform: none !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;