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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user