-
+ // Pin the back button to the top so it stays reachable while the content
+ // scrolls or the on-screen keyboard is open (matches NewLayout's header).
+ // Use a small FIXED top pad, NOT env(safe-area-inset-top): in a browser tab
+ // (not a standalone PWA) the top inset tracks the address bar showing/hiding,
+ // which produced a large, fluctuating gap above the back arrow on both iOS
+ // Safari and Android Chrome. The content already sits below the browser chrome,
+ // so a constant pad gives one small, stable offset on both platforms. (This
+ // layout scrolls its own inner container, not , so the back button does
+ // not need position:fixed the way NewLayout's header does.)
+
}
+ {/* Bottom clearance for the pinned actions. env(safe-area-inset-bottom) covers the
+ iOS home indicator and the Android gesture pill, but Android 3-button navigation
+ reports a 0 inset while its ~48dp opaque bar still overlaps the viewport bottom —
+ so the inset alone left the actions clipped there. Reserve a base that clears the
+ 3-button bar (3rem ≈ 48dp) OR the safe-area inset plus the original comfortable
+ spacing, whichever is larger. Desktop/gesture keep a normal footer margin. */}
+ {footer && (
+
+ {/* Pin the top nav so its controls (back button on sub-screens; Connect-to-dApp,
+ wallet switcher and settings on the dashboard) stay reachable while the page
+ scrolls, the on-screen keyboard is open, or a bottom sheet is open over a
+ scrolled page. PinnedHeader is `fixed` on mobile so it survives vaul's iOS
+ body scroll-lock (a sticky header would drop out under it); see its doc. */}
+ {header && {header}}
+ {children}
+
-
-);
+ );
+};
diff --git a/apps/demo-wallet/src/core/components/shared/pinned-header/index.ts b/apps/demo-wallet/src/core/components/shared/pinned-header/index.ts
new file mode 100644
index 000000000..3f7d2210e
--- /dev/null
+++ b/apps/demo-wallet/src/core/components/shared/pinned-header/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export * from './pinned-header';
diff --git a/apps/demo-wallet/src/core/components/shared/pinned-header/pinned-header.tsx b/apps/demo-wallet/src/core/components/shared/pinned-header/pinned-header.tsx
new file mode 100644
index 000000000..b2b599907
--- /dev/null
+++ b/apps/demo-wallet/src/core/components/shared/pinned-header/pinned-header.tsx
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import React, { useLayoutEffect, useRef, useState } from 'react';
+
+import { cn } from '@/core/lib/utils';
+
+interface PinnedHeaderProps {
+ children: React.ReactNode;
+ /** Extra classes for the centered inner bar (e.g. flex layout for the header row). */
+ className?: string;
+}
+
+/**
+ * Top app bar that stays visible above the page content on every screen.
+ *
+ * Positioning is split by breakpoint:
+ * - Mobile (`< md`): `position: fixed`, viewport-relative. This is deliberate.
+ * When a vaul bottom sheet opens on iOS Safari it pins the document with
+ * `body { position: fixed; top: -scrollY }` (vaul's use-position-fixed scroll
+ * lock). A `position: sticky` header sticks to its scroll container — but a
+ * `position: fixed` is no longer the scroll container, so a sticky
+ * header detaches and drops out for the sheet's whole lifetime, then snaps
+ * back late after vaul's async scroll restore. A `position: fixed` header is
+ * anchored to the viewport (initial containing block), so it is immune to the
+ * body being pulled to `top:-scrollY` and stays painted in place — dimmed
+ * under the sheet's overlay — regardless of scroll position, on open and on
+ * close. A flow spacer of the measured header height keeps the page content
+ * from jumping up under the fixed bar.
+ * - Desktop (`>= md`): `position: sticky`. Radix Dialog (the desktop modal) locks
+ * scroll via `overflow: hidden` (react-remove-scroll), NOT `position: fixed`,
+ * so sticky is unaffected there. Keeping desktop on sticky avoids introducing a
+ * fixed-vs-flow reflow on the wider layout, where the sheet problem never occurs.
+ *
+ * The bar spans the viewport width so the fixed layer isn't clipped; the inner
+ * element re-applies the `max-w-md` centered column so the controls line up with
+ * the page content on tablet/desktop.
+ *
+ * Top padding is a small FIXED value (not `env(safe-area-inset-top)`). In a
+ * browser tab (not a standalone PWA) the top inset tracks the address bar
+ * showing/hiding, which produced a large, fluctuating gap above the bar on both
+ * iOS Safari and Android Chrome. The in-app bar already sits below the browser
+ * chrome, so a constant pad gives one small, stable offset on both platforms.
+ */
+export const PinnedHeader: React.FC = ({ children, className }) => {
+ const barRef = useRef(null);
+ const [height, setHeight] = useState(0);
+
+ // Track the bar's rendered height so the flow spacer reserves exactly that
+ // much space under the fixed bar (mobile only). Height is content-driven
+ // (wallet name length, screen title), so measure it rather than hardcode.
+ useLayoutEffect(() => {
+ const node = barRef.current;
+ if (!node) return;
+ const update = () => setHeight(node.getBoundingClientRect().height);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+ <>
+
+
{children}
+
+ {/* Flow spacer: reserve the bar's height so content starts below it.
+ Only needed while the bar is `fixed` (mobile); on `md:` the bar is
+ sticky and already occupies flow, so collapse the spacer. */}
+
+ >
+ );
+};
diff --git a/apps/demo-wallet/src/core/components/ui/modal/modal.tsx b/apps/demo-wallet/src/core/components/ui/modal/modal.tsx
index 3a791db62..f8a3ac12a 100644
--- a/apps/demo-wallet/src/core/components/ui/modal/modal.tsx
+++ b/apps/demo-wallet/src/core/components/ui/modal/modal.tsx
@@ -6,7 +6,7 @@
*
*/
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import type { ComponentProps } from 'react';
import { ChevronLeft, X } from 'lucide-react';
@@ -14,6 +14,7 @@ import { Dialog, DialogContent, DialogTitle } from '../dialog';
import { Drawer, DrawerContent, DrawerTitle } from '../drawer';
import { cn } from '@/core/lib/utils';
+import { isIOS } from '@/core/lib/is-ios';
import { useIsMobile } from '@/core/hooks/use-media-query';
export interface ModalContainerProps extends ComponentProps<'div'> {
@@ -21,22 +22,155 @@ export interface ModalContainerProps extends ComponentProps<'div'> {
onOpenChange: (value: boolean) => void;
/** When false, the modal can't be dismissed by backdrop / Esc / swipe — only via its own actions. */
dismissible?: boolean;
+ /**
+ * Set for drawers that contain a focusable input (textarea / text field).
+ * Keeps the mobile bottom-sheet path as a compact, content-height sheet
+ * (rounded top, bottom-anchored — the default sheet look) but drives it with
+ * a manual, `visualViewport`-based keyboard lift: while the software keyboard
+ * is open the sheet's `bottom` offset is raised by the keyboard height so the
+ * whole panel — input AND action button — sits directly above the keyboard.
+ * vaul's own `repositionInputs` is disabled (it mutates the sheet height/bottom
+ * from the same resize events and misfires: fly-off on Android, off-screen on
+ * the first iOS focus). On iOS the body scroll-lock is kept so focusing the
+ * input can't scroll the document and drag the fixed sheet away. No effect on
+ * desktop (Dialog).
+ */
+ keyboardSafe?: boolean;
}
+/**
+ * Height (in CSS px) of the on-screen keyboard as it overlaps the layout
+ * viewport, derived from `window.visualViewport`. With the default
+ * `interactive-widget=resizes-visual` (both iOS Safari and Android Chrome), the
+ * keyboard shrinks only the *visual* viewport; the layout viewport — and thus
+ * any `position: fixed` element — is unaffected. So the overlap is
+ * `innerHeight − visualViewport.height − visualViewport.offsetTop`, and adding
+ * that as a `bottom` offset lifts a bottom-anchored fixed sheet to rest right on
+ * top of the keyboard. Returns 0 when the keyboard is closed (or when
+ * `visualViewport` is unavailable). Updates are rAF-throttled and only run while
+ * `enabled` (the keyboardSafe sheet is open on mobile), so no other sheet or the
+ * desktop Dialog is affected.
+ */
+const useKeyboardInset = (enabled: boolean): number => {
+ const [inset, setInset] = useState(0);
+
+ useEffect(() => {
+ const vv = typeof window !== 'undefined' ? window.visualViewport : null;
+ if (!enabled || !vv) {
+ setInset(0);
+ return;
+ }
+
+ let frame = 0;
+ const measure = () => {
+ frame = 0;
+ // Clamp ≥ 0: during the open/close animation the terms can cross by a
+ // sub-pixel and go slightly negative. Sub-2px results are treated as
+ // "keyboard closed" to avoid a 1px jitter when it's not really open.
+ const raw = window.innerHeight - vv.height - vv.offsetTop;
+ setInset(raw > 2 ? Math.round(raw) : 0);
+ };
+ const onChange = () => {
+ if (frame) return;
+ frame = window.requestAnimationFrame(measure);
+ };
+
+ // resize fires as the keyboard animates in/out; scroll fires when the
+ // visual viewport pans (offsetTop changes) with the keyboard open.
+ vv.addEventListener('resize', onChange);
+ vv.addEventListener('scroll', onChange);
+ measure();
+
+ return () => {
+ if (frame) window.cancelAnimationFrame(frame);
+ vv.removeEventListener('resize', onChange);
+ vv.removeEventListener('scroll', onChange);
+ setInset(0);
+ };
+ }, [enabled]);
+
+ return inset;
+};
+
export const ModalContainer: React.FC = ({
isOpened,
onOpenChange,
dismissible = true,
+ keyboardSafe = false,
children,
className,
...props
}) => {
const isMobile = useIsMobile();
+ // Track the keyboard height only while the keyboardSafe sheet is open on mobile;
+ // drives the manual lift below. No-op (0) for every other sheet, on desktop, and
+ // while this sheet is closed (no visualViewport listeners attached then).
+ const keyboardInset = useKeyboardInset(isMobile && keyboardSafe && isOpened);
if (isMobile) {
+ // The only sheet with a focusable text input is the Connect-to-dApp paste
+ // sheet (keyboardSafe). Its keyboard handling is tuned per platform below;
+ // isIOS() gates only the iOS-specific body scroll-lock, not the shared
+ // repositionInputs / lift handling.
+ const iosKeyboardWorkaround = keyboardSafe && isIOS();
+
return (
-
-
+ , iOS Safari only — Android is never affected as vaul gates it on
+ // isSafari()).
+ //
+ // - Input-less sheets (!keyboardSafe): suppress it. The lock breaks a
+ // position:sticky header, which is why we now pin the header with
+ // position:fixed (PinnedHeader). With that in place the header no longer
+ // needs the lock, and suppressing it also avoids vaul's async scroll-restore
+ // jump on close (the wallet-switcher / settings sheets have no manual restore).
+ // - Input sheet on iOS (iosKeyboardWorkaround): KEEP the lock (noBodyStyles=false).
+ // With repositionInputs off, the lock is what stops iOS from scrolling the
+ // document when the textarea is focused, which would otherwise drag the fixed
+ // sheet off-screen. The connect sheet restores scroll manually on close.
+ // - Input sheet on Android: noBodyStyles=true, but it's a no-op there anyway.
+ noBodyStyles={!iosKeyboardWorkaround}
+ >
+ 0 ? { ...props.style, bottom: keyboardInset } : props.style}
+ >
{children}
diff --git a/apps/demo-wallet/src/core/lib/is-ios.ts b/apps/demo-wallet/src/core/lib/is-ios.ts
new file mode 100644
index 000000000..513c6f74d
--- /dev/null
+++ b/apps/demo-wallet/src/core/lib/is-ios.ts
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+/**
+ * Detects iOS Safari / iPadOS Safari, where the software keyboard resizes the
+ * *visual* viewport (not the layout viewport) and where `position: fixed` +
+ * body scroll-lock interacts badly with focus. Used to opt drawers that contain
+ * a focusable input out of vaul's keyboard-repositioning / background-scaling,
+ * which otherwise shove the panel off-screen on the first focus.
+ *
+ * iPadOS 13+ reports a desktop UA ("Macintosh") but has touch points, so we
+ * also treat a touch-capable "Mac" as iOS for the purposes of this workaround.
+ */
+export function isIOS(): boolean {
+ if (typeof navigator === 'undefined') return false;
+ const ua = navigator.userAgent;
+ if (/iPad|iPhone|iPod/.test(ua)) return true;
+ // iPadOS masquerading as macOS.
+ return ua.includes('Mac') && typeof document !== 'undefined' && 'ontouchend' in document;
+}
diff --git a/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx b/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx
index 830641048..7212335a2 100644
--- a/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx
+++ b/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx
@@ -30,6 +30,7 @@ export const SetupPasswordScreen: React.FC = () => {
const location = useLocation();
const { setPassword: setStorePassword } = useAuth();
const inputRef = useRef(null);
+ const confirmRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
@@ -55,6 +56,13 @@ export const SetupPasswordScreen: React.FC = () => {
}
};
+ // Fired by the on-screen keyboard's Return/Go key (and the hidden submit button).
+ // Mirrors the Continue button; the canSubmit guard makes it a no-op when invalid.
+ const handleFormSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ void handleSubmit();
+ };
+
const footer = (
diff --git a/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx b/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx
index 2b354975c..fd56cf745 100644
--- a/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx
+++ b/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx
@@ -6,11 +6,12 @@
*
*/
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTonConnect } from '@demo/wallet-core';
import { Button } from '@/core/components/ui/button';
import { Modal } from '@/core/components/ui/modal';
+import { isIOS } from '@/core/lib/is-ios';
interface ConnectDappModalProps {
isOpen: boolean;
@@ -21,6 +22,27 @@ export const ConnectDappModal: React.FC = ({ isOpen, onCl
const { handleTonConnectUrl } = useTonConnect();
const [url, setUrl] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
+ // iOS-only safety net: the software keyboard mutates the visual viewport
+ // while the sheet's body scroll-lock is active, so the page scroll offset
+ // can be restored to the wrong place on close. Snapshot it on open and
+ // restore it after the close animation settles so the dashboard doesn't jump.
+ const scrollYRef = useRef(0);
+
+ useEffect(() => {
+ if (!isIOS()) return;
+ if (isOpen) {
+ scrollYRef.current = window.scrollY;
+ return;
+ }
+ const savedY = scrollYRef.current;
+ // Run after vaul finishes its own teardown / restore.
+ const timer = window.setTimeout(() => {
+ if (Math.abs(window.scrollY - savedY) > 1) {
+ window.scrollTo(0, savedY);
+ }
+ }, 350);
+ return () => window.clearTimeout(timer);
+ }, [isOpen]);
const handleConnect = useCallback(async () => {
const trimmed = url.trim();
@@ -39,7 +61,7 @@ export const ConnectDappModal: React.FC = ({ isOpen, onCl
}, [url, handleTonConnectUrl, onClose]);
return (
- !open && onClose()} className="px-2">
+ !open && onClose()} keyboardSafe className="px-2">
Connect to dApp