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