notes(ipad): keep dialog above the on-screen keyboard

Task #476. On iPad Safari, opening the keyboard while writing a new
note or replying inside the note thread dialog covered the bottom of
the dialog (textarea + send button). The dialog used
`top:50%; translate(-50%,-50%)` against the layout viewport and never
reacted to the visual viewport shrinking when the keyboard appeared.

Changes:
- artifacts/tx-os/index.html: added `interactive-widget=resizes-content`
  to the viewport meta so iOS 17+ resizes the layout viewport when the
  software keyboard opens.
- artifacts/tx-os/src/hooks/use-visual-viewport.ts: new hook that
  subscribes to `window.visualViewport` `resize` / `scroll` /
  `orientationchange` events (rAF-throttled) and exposes the current
  visible height plus the bottom keyboard inset.
- artifacts/tx-os/src/components/ui/dialog.tsx: `DialogContent` now
  reads the hook and, when the keyboard inset is non-trivial (> 80px),
  pins its center to the visible viewport mid-line and caps
  `max-height` to the visible height. Desktop renders unchanged
  (inset is 0 → no inline style applied beyond what the caller passes).
- artifacts/tx-os/src/pages/notes.tsx: the thread dialog reply
  textarea and the top composer textarea now `scrollIntoView({ block:
  "center" })` on focus (after a 250ms delay so the keyboard animation
  settles), so the cursor lands inside the visible region on touch
  devices. Desktop focus is unaffected — `scrollIntoView` is a no-op
  when the element is already in the viewport.

Verification:
- `tsc --noEmit` clean.
- Existing `notes-thread-dialog` e2e still passes (dialog stays
  capped at 85% viewport height, scroller still overflows, recipient
  chips and composer remain in viewport).
This commit is contained in:
riyadhafraa
2026-05-10 15:13:21 +00:00
parent 23da13be79
commit 5dac662421
4 changed files with 106 additions and 20 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>Tx OS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!--
+32 -19
View File
@@ -3,6 +3,7 @@ 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
@@ -30,25 +31,37 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}
>
{children}
<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>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, style, ...props }, ref) => {
const { keyboardInset, height } = useVisualViewport()
const keyboardOpen = keyboardInset > 80
const dynamicStyle: React.CSSProperties = keyboardOpen
? {
top: `${height / 2}px`,
maxHeight: `${height - 16}px`,
...style,
}
: (style ?? {})
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
style={dynamicStyle}
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
)}
{...props}
>
{children}
<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>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
export interface VisualViewportState {
height: number;
keyboardInset: number;
offsetTop: number;
}
function readState(): VisualViewportState {
if (typeof window === "undefined") {
return { height: 0, keyboardInset: 0, offsetTop: 0 };
}
const vv = window.visualViewport;
if (!vv) {
return {
height: window.innerHeight,
keyboardInset: 0,
offsetTop: 0,
};
}
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
return {
height: vv.height,
keyboardInset: inset,
offsetTop: vv.offsetTop,
};
}
export function useVisualViewport(): VisualViewportState {
const [state, setState] = useState<VisualViewportState>(() => readState());
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
let raf = 0;
const update = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => setState(readState()));
};
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
window.addEventListener("orientationchange", update);
update();
return () => {
cancelAnimationFrame(raf);
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return state;
}
+20
View File
@@ -2410,6 +2410,16 @@ function ThreadDialog({
ref={replyInputRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onFocus={(e) => {
const el = e.currentTarget;
setTimeout(() => {
try {
el.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
el.scrollIntoView();
}
}, 250);
}}
placeholder={t("notes.replyPlaceholder", "Write a reply…")}
className="min-h-[70px] resize-none"
data-testid="thread-reply-input"
@@ -3014,6 +3024,16 @@ function Composer({
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={(e) => {
const el = e.currentTarget;
setTimeout(() => {
try {
el.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
el.scrollIntoView();
}
}, 250);
}}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
data-testid="notes-composer-content"