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..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