Files
fusion/packages/dashboard/app/components/OAuthManualCodeForm.tsx
gsxdsm d0ce7829c0 FN-7953: fix mobile OAuth code submit taps
Submit Anthropic OAuth manual codes on the first mobile tap instead of requiring keyboard dismissal first.

- Add a reusable touch action gesture hook that handles touch/pointer activation before synthetic clicks.
- Wire the OAuth manual code Submit button to invoke submission on the first touch while preventing duplicate click handling.
- Cover the mobile double-tap regression and document the UI bug pattern for future fixes.

Files changed:
 .../oauth-manual-code-mobile-double-tap-submit.md  |  60 +++++++++++
 .../app/components/OAuthManualCodeForm.tsx         |  31 +++++-
 .../__tests__/OAuthManualCodeForm.test.tsx         | 110 +++++++++++++++++++++
 .../hooks/__tests__/useTouchActionGesture.test.ts  | 110 +++++++++++++++++++++
 .../dashboard/app/hooks/useTouchActionGesture.ts   |  89 +++++++++++++++++
 5 files changed, 399 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7953

Fusion-Task-Lineage: d387cdbd-25a7-4b7d-add6-27a1ded5cbea

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-14 17:18:06 -07:00

158 lines
5.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTouchActionGesture } from "../hooks/useTouchActionGesture";
import "./OAuthManualCodeForm.css";
interface OAuthManualCodeFormProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
prompt: string;
placeholder?: string;
helpText?: string;
disabled?: boolean;
submitLabel?: string;
"data-testid"?: string;
}
export function OAuthManualCodeForm({
value,
onChange,
onSubmit,
prompt,
placeholder,
helpText,
disabled = false,
submitLabel = "Submit code",
"data-testid": testId,
}: OAuthManualCodeFormProps) {
const formRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const [inputFocused, setInputFocused] = useState(false);
// FNXC:OAuthManualCodeForm 2026-07-14-00:00: on mobile, tapping "Submit code"
// while the textarea above still has focus can cause the browser to consume
// that first tap solely to blur the textarea and dismiss the on-screen
// keyboard, suppressing the resulting click — requiring a second, separate
// tap to actually submit (FN-7953). Reuse the same gesture handling already
// proven for the chat Send/Stop button (`useStandardChatActionGesture` in
// StandardChatSurface.tsx) via the generic, non-chat-coupled
// useTouchActionGesture() hook so a single tap submits immediately.
const { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch } = useTouchActionGesture();
const shouldUseMobileScrollAssist = useCallback(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
const compactLayout = window.matchMedia("(max-width: 768px)").matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
return compactLayout || coarsePointer;
}, []);
const getScrollBehavior = useCallback((): ScrollBehavior => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "auto";
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
}, []);
const scrollInputIntoView = useCallback(() => {
if (!shouldUseMobileScrollAssist()) {
return;
}
const target = inputRef.current ?? formRef.current;
if (!target || typeof target.scrollIntoView !== "function") {
return;
}
const behavior = getScrollBehavior();
requestAnimationFrame(() => {
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
// Mobile keyboards can shift viewport after focus; a short follow-up call
// keeps the textarea visible when that deferred viewport resize completes.
window.setTimeout(() => {
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
}, 120);
});
}, [getScrollBehavior, shouldUseMobileScrollAssist]);
useEffect(() => {
if (!inputFocused || !shouldUseMobileScrollAssist()) {
return;
}
scrollInputIntoView();
const viewport = window.visualViewport;
if (!viewport) {
return;
}
const handleViewportShift = () => {
if (document.activeElement === inputRef.current) {
scrollInputIntoView();
}
};
viewport.addEventListener("resize", handleViewportShift);
viewport.addEventListener("scroll", handleViewportShift);
return () => {
viewport.removeEventListener("resize", handleViewportShift);
viewport.removeEventListener("scroll", handleViewportShift);
};
}, [inputFocused, scrollInputIntoView, shouldUseMobileScrollAssist]);
return (
<div ref={formRef} className="oauth-manual-code" data-testid={testId}>
<p className="oauth-manual-code__prompt">{prompt}</p>
<textarea
ref={inputRef}
className="form-input oauth-manual-code__input"
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={() => {
setInputFocused(true);
scrollInputIntoView();
}}
onBlur={() => setInputFocused(false)}
placeholder={placeholder}
rows={3}
spellCheck={false}
disabled={disabled}
/>
<div className="oauth-manual-code__actions">
<button
type="button"
className="btn btn-sm"
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
if (!beginTouchActionGesture()) return;
markHandledSendTouch();
if (!disabled) onSubmit();
}
}}
onTouchStart={(event) => {
event.preventDefault();
if (!beginTouchActionGesture()) return;
markHandledSendTouch();
if (!disabled) onSubmit();
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (consumeHandledSendTouch()) return;
if (!disabled) onSubmit();
}}
disabled={disabled}
style={{ touchAction: "manipulation" }}
>
{submitLabel}
</button>
</div>
{helpText && <p className="oauth-manual-code__help">{helpText}</p>}
</div>
);
}