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` (rAF-throttled) and exposes the visible height,
  the visual viewport `offsetTop`, and the bottom keyboard inset.
- artifacts/tx-os/src/components/ui/dialog.tsx: `DialogContent` now
  reads the hook and, only when the keyboard inset exceeds ~80px
  (i.e. an actual on-screen keyboard, not browser-chrome jitter),
  pins its center to `offsetTop + height/2` and caps `max-height` to
  the visible height. With the dialog's existing
  `translate(-50%,-50%)`, both top and bottom edges stay inside the
  visible region above the keyboard. Desktop renders unchanged
  (inset is 0 → no inline style applied beyond what the caller passes).

Deviation from plan:
- Dropped the per-textarea `scrollIntoView` on focus (steps 4 of the
  plan). Reviewer flagged it as defensive/AI-shaped and a desktop
  regression risk; with the dialog now correctly capped to the visible
  viewport, the pinned-bottom reply / composer textareas are already
  fully on-screen, so the extra scroll isn't needed.

Verification:
- `tsc --noEmit` clean.
- `notes-thread-dialog` e2e still passes.
This commit is contained in:
riyadhafraa
2026-05-10 15:15:47 +00:00
parent 5dac662421
commit db6e7726eb
2 changed files with 10 additions and 22 deletions
+10 -2
View File
@@ -32,11 +32,19 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, style, ...props }, ref) => {
const { keyboardInset, height } = useVisualViewport()
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
const dynamicStyle: React.CSSProperties = keyboardOpen
? {
top: `${height / 2}px`,
// Center the dialog inside the *visible* visual viewport (above
// the keyboard) rather than the layout viewport. offsetTop
// accounts for the page being scrolled or pinch-zoomed.
top: `${offsetTop + height / 2}px`,
maxHeight: `${height - 16}px`,
...style,
}
-20
View File
@@ -2410,16 +2410,6 @@ 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"
@@ -3024,16 +3014,6 @@ 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"