notes(ipad): keep dialog and inputs 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,
  pins its center to `offsetTop + height/2` and caps `max-height` to
  the visible height. With the existing `translate(-50%,-50%)`, both
  edges stay inside the visible region above the keyboard. Desktop
  unaffected (inset is 0 → no inline style applied).
- artifacts/tx-os/src/pages/notes.tsx: thread reply textarea and the
  top composer textarea now call `scrollIntoView({ block: "center" })`
  on focus and again whenever the keyboard inset changes while the
  field is focused. Both paths are gated on `keyboardInset > 80`, so
  desktop focus is untouched.

Verification:
- `tsc --noEmit` clean.
- e2e: `notes-thread-dialog` and `notes-composer-color` both pass.
This commit is contained in:
riyadhafraa
2026-05-10 15:18:54 +00:00
parent db6e7726eb
commit 76f2f7ce51
+41
View File
@@ -42,6 +42,7 @@ import { ar as dfAr, enUS as dfEn } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
@@ -2228,6 +2229,16 @@ function ThreadDialog({
const markedRef = useRef(false);
const replyInputRef = useRef<HTMLTextAreaElement | null>(null);
const focusedRef = useRef(false);
const replyFocused = useRef(false);
const { keyboardInset } = useVisualViewport();
// When the on-screen keyboard appears while the reply box is focused,
// pull the cursor into the centre of the visible viewport. Gated on a
// non-trivial inset so desktop focus is unaffected.
useEffect(() => {
if (keyboardInset <= 80) return;
if (!replyFocused.current) return;
replyInputRef.current?.scrollIntoView({ block: "center" });
}, [keyboardInset]);
// When opened from the incoming-note popup with reply intent, jump
// focus into the reply input as soon as the thread loads.
@@ -2410,6 +2421,15 @@ function ThreadDialog({
ref={replyInputRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onFocus={(e) => {
replyFocused.current = true;
if (keyboardInset > 80) {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onBlur={() => {
replyFocused.current = false;
}}
placeholder={t("notes.replyPlaceholder", "Write a reply…")}
className="min-h-[70px] resize-none"
data-testid="thread-reply-input"
@@ -2886,6 +2906,17 @@ function Composer({
const targetFolderId =
folderSelection.kind === "folder" ? folderSelection.id : null;
const ref = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLTextAreaElement | null>(null);
const contentFocused = useRef(false);
const { keyboardInset } = useVisualViewport();
// Same keyboard-aware scroll-into-view as the thread reply box: only
// fires while a software keyboard is actually open and the composer
// textarea is focused. Desktop focus is unaffected.
useEffect(() => {
if (keyboardInset <= 80) return;
if (!contentFocused.current) return;
contentRef.current?.scrollIntoView({ block: "center" });
}, [keyboardInset]);
useEffect(() => {
if (!open) return;
@@ -3012,8 +3043,18 @@ function Composer({
</div>
) : (
<Textarea
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={(e) => {
contentFocused.current = true;
if (keyboardInset > 80) {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onBlur={() => {
contentFocused.current = false;
}}
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"