Task #630: keep recipients list visible above iPad keyboard in Send Note

Problem: opening Send Note on iPad, tapping the search field opened the
soft keyboard which covered the recipients list and the Send button —
the dialog stayed centered on the full layout viewport.

Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):

Two-tier keyboard detection:
  1. Primary — `vv.keyboardInset > 0` from useVisualViewport. Works on
     desktop touch laptops, iPad Safari (regular tab), Android Chrome.
     When true, override DialogContent style with
       top = vv.offsetTop + vv.height/2 − 48 (QuickType inset)
       transform: translate(-50%, -50%)
     so the dialog recenters around the visible viewport region.
  2. Fallback — `searchFocused && matchMedia('(pointer: coarse)')`.
     Only used when visualViewport does NOT report an inset, which is
     the iPad PWA (Add to Home Screen) case explicitly called out in
     components/ui/dialog.tsx comment. Top-anchors at 6vh with
     maxHeight 50dvh so the dialog never crosses the screen midpoint.
  3. Neither — pass NO style at all so Radix's default centered layout
     is fully restored (no leftover transition or top).

Touch-only fallback gating (matchMedia pointer:coarse) prevents the
earlier desktop-regression flagged by code review — focusing the
search on a desktop never repositions the dialog.

Other changes:
- onFocus/onBlur on the search input feed the fallback signal.
- Blur is debounced 150ms so tapping a recipient row mid-blur doesn't
  recenter the dialog out from under the finger.
- useEffect cleanup clears pending blur timeout on unmount.

Verification: tsc clean. Architect approved the touch-gated fallback;
follow-up review reconciled the diff with the spec's primary
visualViewport-driven path.
This commit is contained in:
Riyadh
2026-05-24 09:48:10 +00:00
parent cd7e4cfad9
commit 2cc955ed65
@@ -120,14 +120,22 @@ export function SendNoteDialog({
}, 150);
};
// Primary signal: visualViewport actually reports a keyboard inset.
// Used on desktop touch laptops, iPad Safari (regular tab), and
// Android Chrome where vv updates correctly.
const keyboardActive = vv.keyboardInset > 0;
// Fallback signal: iPad PWA (Add to Home Screen) doesn't reliably
// update visualViewport when the keyboard opens (see comment in
// components/ui/dialog.tsx). On touch-primary devices, treat focus
// on the search input as evidence the keyboard is up so the dialog
// still gets repositioned. This is a fallback only — when vv DOES
// update, the primary branch above takes over.
const keyboardFallback = !keyboardActive && searchFocused && isTouchPrimary;
const KEYBOARD_PREDICTION_INSET =
vv.keyboardInset > 0 || keyboardLikely ? 48 : 0;
keyboardActive || keyboardFallback ? 48 : 0;
const listMaxHeight = (() => {
// Only assume the ~50% usable bound on touch devices where a
// soft keyboard is actually about to appear. Desktops keep using
// the real viewport height (or visualViewport when it updates).
const baseHeight = vv.height || window.innerHeight;
const assumedUsable = keyboardLikely
const assumedUsable = keyboardFallback
? Math.min(baseHeight, window.innerHeight * 0.5)
: baseHeight;
const usable = assumedUsable - KEYBOARD_PREDICTION_INSET;
@@ -139,20 +147,32 @@ export function SendNoteDialog({
return forList;
})();
// Override DialogContent's centered positioning while typing on
// touch devices: anchor to top with `translate(-50%, 0)` so the
// dialog grows downward from a fixed top edge and never extends
// past the midpoint of the screen (where the soft keyboard sits).
// On desktop and other non-touch contexts, leave Radix's default
// centered layout alone.
const dialogStyle: React.CSSProperties = keyboardLikely
// Dialog positioning override:
// • Primary path (`keyboardActive`): compute the new vertical
// centre from the *visual* viewport — anchored at
// `offsetTop + height/2`, shifted up by the QuickType strip
// allowance so the Send button isn't hidden under predictions.
// Keep Radix's `translate(-50%, -50%)` so the dialog stays
// centered around the new anchor.
// • Fallback path (`keyboardFallback`): top-anchor at 6vh with
// `translate(-50%, 0)` and cap `maxHeight: 50dvh` so the dialog
// never crosses the screen midpoint where the keyboard sits.
// • Otherwise: pass NO style so Radix's default centered layout
// is fully restored (no leftover transition, no leftover top).
const dialogStyle: React.CSSProperties | undefined = keyboardActive
? {
top: vv.offsetTop + vv.height / 2 - KEYBOARD_PREDICTION_INSET,
transform: "translate(-50%, -50%)",
transition: "top 120ms ease",
}
: keyboardFallback
? {
top: "6vh",
transform: "translate(-50%, 0)",
maxHeight: "50dvh",
transition: "top 120ms ease, transform 120ms ease",
}
: { transition: "top 120ms ease, transform 120ms ease" };
: undefined;
const candidates = useMemo(() => {
const me = user?.id;
@@ -217,7 +237,7 @@ export function SendNoteDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent
className="max-w-md"
style={dialogStyle}
{...(dialogStyle ? { style: dialogStyle } : {})}
data-testid="send-note-dialog"
>
<DialogHeader className="pr-8">