From 0d88d43bcd7d1486c34ceddf4d6e4ba0af2571c4 Mon Sep 17 00:00:00 2001 From: Pavel Lesyuk Date: Sat, 4 Jul 2026 23:33:52 +0800 Subject: [PATCH 1/2] fix(demo-wallet): mobile layout, safe-area, viewport height, bottom-sheet keyboard/scroll A set of linked mobile-web (iOS Safari + Android Chrome) layout and bottom-sheet fixes for the wallet demo. Desktop and the extension popup are unchanged. Safe-area & viewport: - Add viewport-fit=cover so env(safe-area-inset-*) resolves to real values on mobile (0 on desktop, no visual change). - Reserve bottom clearance for the pinned actions in the onboarding/auth layout (CenteredScreen): max(3rem, 1.5rem + env(safe-area-inset-bottom)). The inset alone is 0 under Android 3-button navigation while its ~48dp bar still overlaps the viewport, which clipped the Continue / Reset Wallet / terms line; the base clears that bar while gesture/iOS/desktop keep a normal margin. - Use min-h-dvh (was 100vh) for the dashboard layout so it fills the viewport on iOS Safari, whose dynamic toolbars are excluded from 100vh. dvh degrades to vh where unsupported, so desktop/Android are unchanged. Pinned top bar (new PinnedHeader): - Pin the top nav so its controls (back button on sub-screens; Connect-to-dApp, wallet switcher, settings on the dashboard) stay reachable while the page scrolls or the keyboard is open. Also pin the onboarding back button. - On mobile the header is position:fixed (not sticky) with a measured flow spacer: when a vaul bottom sheet opens on iOS Safari it locks the body with position:fixed; top:-scrollY, which detaches a sticky header (it drops out for the sheet's lifetime and snaps back late). A viewport-anchored fixed header is immune and stays painted, dimmed under the overlay. Desktop keeps sticky (Radix Dialog locks scroll via overflow:hidden, so sticky is fine and avoids a reflow). - Top padding is a small fixed value, not env(safe-area-inset-top): in a browser tab that inset tracks the address bar showing/hiding, producing a large fluctuating gap above the bar on both platforms. On-screen Return key (setup-password / unlock): - Wrap the password inputs in a
whose onSubmit mirrors the Continue/Unlock button (guarded by the same validity condition, so invalid input is a no-op) and set enterKeyHint for the right on-screen key label. A visually-hidden submit button is what makes iOS Safari's Return key fire submission; the visible buttons keep their existing handlers, no submit logic duplicated. Connect-to-dApp sheet keyboard handling (the only sheet with a text input): - vaul's repositionInputs mutates the sheet height/offset from visualViewport resize events and misfired: fly-off on the first iOS focus, shove-up on Android. Disable it for this sheet on both platforms and drive the keyboard lift manually via a visualViewport-based bottom offset (useKeyboardInset), so the compact, content-height sheet -- input and Connect button -- rests directly above the keyboard. bottom is orthogonal to vaul's transform, so there is no fight. - Standard (input-less) sheets set noBodyStyles so vaul's iOS body scroll-lock doesn't break the now-fixed header; the iOS input sheet keeps the lock (so focusing the textarea can't scroll the document and drag the fixed sheet away) and restores the page scroll offset on close. Scoped via a keyboardSafe opt-in on the input sheet; other sheets and the desktop Dialog are unaffected. --- apps/demo-wallet/index.html | 2 +- .../centered-screen/centered-screen.tsx | 41 +++-- .../shared/new-layout/new-layout.tsx | 23 ++- .../components/shared/pinned-header/index.ts | 9 ++ .../shared/pinned-header/pinned-header.tsx | 84 ++++++++++ .../src/core/components/ui/modal/modal.tsx | 144 +++++++++++++++++- apps/demo-wallet/src/core/lib/is-ios.ts | 25 +++ .../setup-password-screen.tsx | 28 +++- .../unlock-screen/unlock-screen.tsx | 19 ++- .../connect-dapp-modal/connect-dapp-modal.tsx | 31 +++- 10 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 apps/demo-wallet/src/core/components/shared/pinned-header/index.ts create mode 100644 apps/demo-wallet/src/core/components/shared/pinned-header/pinned-header.tsx create mode 100644 apps/demo-wallet/src/core/lib/is-ios.ts diff --git a/apps/demo-wallet/index.html b/apps/demo-wallet/index.html index cadf47e20..e1d9bb0b7 100644 --- a/apps/demo-wallet/index.html +++ b/apps/demo-wallet/index.html @@ -8,7 +8,7 @@ - + TON Wallet Demo diff --git a/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx b/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx index 9bd097bd7..9ed6735c0 100644 --- a/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx +++ b/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx @@ -25,15 +25,26 @@ export const CenteredScreen: React.FC = ({ onBack, footer,
{onBack && ( -
- + // 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.) +
+
+ +
)} @@ -41,7 +52,17 @@ export const CenteredScreen: React.FC = ({ onBack, footer,
{children}
- {footer &&
{footer}
} + {/* 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 && ( +
+ {footer} +
+ )}
); diff --git a/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx b/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx index e14762828..d74d5ee2b 100644 --- a/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx +++ b/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx @@ -8,16 +8,25 @@ import React from 'react'; +import { PinnedHeader } from '../pinned-header'; + interface NewLayoutProps { header?: React.ReactNode; children: React.ReactNode; } -export const NewLayout: React.FC = ({ header, children }) => ( -
-
- {header} -
{children}
+export const NewLayout: React.FC = ({ header, children }) => { + return ( +
+
+ {/* 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..ce8b3bd6b 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,159 @@ 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..c997e2c7f 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,12 @@ 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 From 0bbcb396251decc65af6b09ae30e5d3a3400220e Mon Sep 17 00:00:00 2001 From: Pavel Lesyuk Date: Sun, 5 Jul 2026 00:29:27 +0800 Subject: [PATCH 2/2] style(demo-wallet): apply prettier formatting prettier/prettier fixes flagged by CI lint (formatting only, no behavior change): modal.tsx keyboard-inset ternary and connect-dapp-modal Modal.Container props. --- apps/demo-wallet/src/core/components/ui/modal/modal.tsx | 6 +----- .../components/connect-dapp-modal/connect-dapp-modal.tsx | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) 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 ce8b3bd6b..f8a3ac12a 100644 --- a/apps/demo-wallet/src/core/components/ui/modal/modal.tsx +++ b/apps/demo-wallet/src/core/components/ui/modal/modal.tsx @@ -169,11 +169,7 @@ export const ModalContainer: React.FC = ({ // wins over any caller style but still preserves the caller's other props. aria-describedby={undefined} {...props} - style={ - keyboardSafe && keyboardInset > 0 - ? { ...props.style, bottom: keyboardInset } - : props.style - } + style={keyboardSafe && keyboardInset > 0 ? { ...props.style, bottom: keyboardInset } : props.style} > {children} 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 c997e2c7f..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 @@ -61,12 +61,7 @@ export const ConnectDappModal: React.FC = ({ isOpen, onCl }, [url, handleTonConnectUrl, onClose]); return ( - !open && onClose()} - keyboardSafe - className="px-2" - > + !open && onClose()} keyboardSafe className="px-2"> Connect to dApp