Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/demo-wallet/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="WalletKit" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>TON Wallet Demo</title>
</head>
<body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,44 @@ export const CenteredScreen: React.FC<CenteredScreenProps> = ({ onBack, footer,
<div className="h-dvh bg-white select-none flex flex-col">
<div className="w-full max-w-md mx-auto flex flex-col flex-1 min-h-0">
{onBack && (
<div className="flex-shrink-0 px-4 pt-3">
<button
type="button"
onClick={onBack}
aria-label="Back"
className="w-9 h-9 rounded-full bg-gray-100 flex items-center justify-center text-gray-700 hover:bg-gray-200 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
// 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 <body>, so the back button does
// not need position:fixed the way NewLayout's header does.)
<div className="sticky top-0 z-20 flex-shrink-0 bg-white pt-2">
<div className="px-4 pt-1 pb-1">
<button
type="button"
onClick={onBack}
aria-label="Back"
className="w-9 h-9 rounded-full bg-gray-100 flex items-center justify-center text-gray-700 hover:bg-gray-200 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
</div>
</div>
)}

<div className="flex-1 min-h-0 overflow-y-auto">
<div className="min-h-full flex flex-col justify-center py-6">{children}</div>
</div>

{footer && <div className="flex-shrink-0 px-4 pb-6 pt-2">{footer}</div>}
{/* 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 && (
<div className="flex-shrink-0 px-4 pt-2 pb-[max(3rem,calc(1.5rem+env(safe-area-inset-bottom)))]">
{footer}
</div>
)}
</div>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<NewLayoutProps> = ({ header, children }) => (
<div className="min-h-screen bg-white select-none">
<div className="max-w-md mx-auto">
{header}
<main className="px-4 pb-6">{children}</main>
export const NewLayout: React.FC<NewLayoutProps> = ({ header, children }) => {
return (
<div className="min-h-dvh bg-white select-none">
<div className="max-w-md mx-auto">
{/* 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 && <PinnedHeader>{header}</PinnedHeader>}
<main className="px-4 pb-6">{children}</main>
</div>
</div>
</div>
);
);
};
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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` <body> 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<PinnedHeaderProps> = ({ children, className }) => {
const barRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={barRef}
// Mobile: fixed + small stable top pad. Desktop: sticky, no extra pad
// (matches the prior env()-inset-0 behaviour on desktop; inset-x-0 is inert
// under position:sticky, so the bar keeps its normal in-flow column width).
className="fixed inset-x-0 top-0 z-20 bg-white pt-2 md:sticky md:pt-0"
>
<div className={cn('max-w-md mx-auto', className)}>{children}</div>
</div>
{/* 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. */}
<div className="md:hidden" style={{ height }} aria-hidden />
</>
);
};
140 changes: 137 additions & 3 deletions apps/demo-wallet/src/core/components/ui/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,171 @@
*
*/

import React from 'react';
import React, { useEffect, useState } from 'react';
import type { ComponentProps } from 'react';
import { ChevronLeft, X } from 'lucide-react';

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'> {
isOpened: boolean;
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<ModalContainerProps> = ({
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 (
<Drawer open={isOpened} onOpenChange={onOpenChange} dismissible={dismissible}>
<DrawerContent className={cn('max-w-md mx-auto', className)} aria-describedby={undefined} {...props}>
<Drawer
open={isOpened}
onOpenChange={onOpenChange}
dismissible={dismissible}
// vaul's repositionInputs listens to visualViewport resize and mutates the
// sheet's height/bottom when the keyboard opens. On iOS it misfires on the
// first focus and throws a fixed sheet off-screen; on Android it shoves the
// sheet up as the keyboard opens (the "flies off" regression). Disable it for
// the input sheet on BOTH platforms and drive the keyboard lift ourselves via
// useKeyboardInset (bottom offset). With repositionInputs off vaul writes
// neither `height` nor `bottom` on the drawer root (both live inside its
// onVisualViewportChange handler, which early-returns), so our `bottom` lift is
// uncontested. Input-less sheets keep the default (undefined ⇒ true), harmless
// as they never focus an input.
repositionInputs={keyboardSafe ? false : undefined}
shouldScaleBackground={false}
// noBodyStyles controls vaul's iOS body scroll-lock (position:fixed + top:-scrollY
// on <body>, 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}
>
<DrawerContent
className={cn(
'max-w-md mx-auto',
// Input sheet stays a compact, content-height bottom sheet (the default
// rounded-top, bottom-anchored look). Cap it to the visual viewport with
// internal scroll so an unusually tall sheet can't overflow above the
// keyboard. The lift itself is the `bottom` offset below.
keyboardSafe && 'max-h-[85dvh] overflow-y-auto overscroll-contain',
className,
)}
// Manual keyboard lift: raise the whole (fixed, bottom-anchored) panel by the
// keyboard height so the input and the Connect button sit right above the
// keyboard. Driven by the visualViewport resize stream (rAF-throttled), which
// supplies intermediate heights as the keyboard animates — so the sheet tracks
// the keyboard smoothly without needing a CSS transition (vaul re-inlines
// `transition: transform` on the root, which would otherwise drop a `bottom`
// transition). The overlay is `fixed inset-0`, so the dashboard stays fully
// dimmed behind the sheet at any offset (no bleed-through). 0 ⇒ resting at the
// bottom edge, identical to the default sheet. Merged after {...props} so it
// 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}
>
{children}
</DrawerContent>
</Drawer>
Expand Down
25 changes: 25 additions & 0 deletions apps/demo-wallet/src/core/lib/is-ios.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading