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:
@@ -120,14 +120,22 @@ export function SendNoteDialog({
|
|||||||
}, 150);
|
}, 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 =
|
const KEYBOARD_PREDICTION_INSET =
|
||||||
vv.keyboardInset > 0 || keyboardLikely ? 48 : 0;
|
keyboardActive || keyboardFallback ? 48 : 0;
|
||||||
const listMaxHeight = (() => {
|
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 baseHeight = vv.height || window.innerHeight;
|
||||||
const assumedUsable = keyboardLikely
|
const assumedUsable = keyboardFallback
|
||||||
? Math.min(baseHeight, window.innerHeight * 0.5)
|
? Math.min(baseHeight, window.innerHeight * 0.5)
|
||||||
: baseHeight;
|
: baseHeight;
|
||||||
const usable = assumedUsable - KEYBOARD_PREDICTION_INSET;
|
const usable = assumedUsable - KEYBOARD_PREDICTION_INSET;
|
||||||
@@ -139,20 +147,32 @@ export function SendNoteDialog({
|
|||||||
return forList;
|
return forList;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Override DialogContent's centered positioning while typing on
|
// Dialog positioning override:
|
||||||
// touch devices: anchor to top with `translate(-50%, 0)` so the
|
// • Primary path (`keyboardActive`): compute the new vertical
|
||||||
// dialog grows downward from a fixed top edge and never extends
|
// centre from the *visual* viewport — anchored at
|
||||||
// past the midpoint of the screen (where the soft keyboard sits).
|
// `offsetTop + height/2`, shifted up by the QuickType strip
|
||||||
// On desktop and other non-touch contexts, leave Radix's default
|
// allowance so the Send button isn't hidden under predictions.
|
||||||
// centered layout alone.
|
// Keep Radix's `translate(-50%, -50%)` so the dialog stays
|
||||||
const dialogStyle: React.CSSProperties = keyboardLikely
|
// 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: "6vh",
|
top: vv.offsetTop + vv.height / 2 - KEYBOARD_PREDICTION_INSET,
|
||||||
transform: "translate(-50%, 0)",
|
transform: "translate(-50%, -50%)",
|
||||||
maxHeight: "50dvh",
|
transition: "top 120ms ease",
|
||||||
transition: "top 120ms ease, transform 120ms ease",
|
|
||||||
}
|
}
|
||||||
: { transition: "top 120ms ease, transform 120ms ease" };
|
: keyboardFallback
|
||||||
|
? {
|
||||||
|
top: "6vh",
|
||||||
|
transform: "translate(-50%, 0)",
|
||||||
|
maxHeight: "50dvh",
|
||||||
|
transition: "top 120ms ease, transform 120ms ease",
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const me = user?.id;
|
const me = user?.id;
|
||||||
@@ -217,7 +237,7 @@ export function SendNoteDialog({
|
|||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
style={dialogStyle}
|
{...(dialogStyle ? { style: dialogStyle } : {})}
|
||||||
data-testid="send-note-dialog"
|
data-testid="send-note-dialog"
|
||||||
>
|
>
|
||||||
<DialogHeader className="pr-8">
|
<DialogHeader className="pr-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user