376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
/**
|
|
* EN: Native App-style Mobile Layout Component
|
|
* VI: Component Layout Mobile theo phong cách Native App
|
|
*
|
|
* A mobile-first layout that mimics native app UX with iOS/Android design patterns.
|
|
* Layout mobile-first mô phỏng UX native app với design patterns của iOS/Android.
|
|
*
|
|
* Features:
|
|
* - iOS/Android safe areas support
|
|
* - Native-like bottom navigation
|
|
* - Pull-to-refresh capability
|
|
* - Touch-optimized interactions
|
|
* - App-like scrolling behavior
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <MobileLayout showBottomNav>
|
|
* <YourContent />
|
|
* </MobileLayout>
|
|
* ```
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { cn } from '@/shared/lib/utils';
|
|
|
|
export interface MobileLayoutProps {
|
|
/**
|
|
* Content to render inside the layout
|
|
*/
|
|
children: React.ReactNode;
|
|
|
|
/**
|
|
* Custom className for the container
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Header content (optional)
|
|
*/
|
|
header?: React.ReactNode;
|
|
|
|
/**
|
|
* Footer content (optional)
|
|
*/
|
|
footer?: React.ReactNode;
|
|
|
|
/**
|
|
* Bottom navigation (optional)
|
|
*/
|
|
bottomNav?: React.ReactNode;
|
|
|
|
/**
|
|
* Whether to show the header
|
|
*/
|
|
showHeader?: boolean;
|
|
|
|
/**
|
|
* Whether to show the footer
|
|
*/
|
|
showFooter?: boolean;
|
|
|
|
/**
|
|
* Enable pull-to-refresh functionality
|
|
*/
|
|
enablePullToRefresh?: boolean;
|
|
|
|
/**
|
|
* Callback for pull-to-refresh
|
|
*/
|
|
onRefresh?: () => Promise<void>;
|
|
|
|
/**
|
|
* Show native-style bottom navigation bar
|
|
*/
|
|
showBottomNav?: boolean;
|
|
|
|
/**
|
|
* Bottom navigation items for native-style nav
|
|
*/
|
|
bottomNavItems?: BottomNavItem[];
|
|
|
|
/**
|
|
* Active bottom nav item
|
|
*/
|
|
activeNavItem?: string;
|
|
|
|
/**
|
|
* Callback when bottom nav item is pressed
|
|
*/
|
|
onNavItemPress?: (itemId: string) => void;
|
|
}
|
|
|
|
export interface BottomNavItem {
|
|
id: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
badge?: number;
|
|
}
|
|
|
|
/**
|
|
* Native App-style Mobile Layout Component
|
|
*
|
|
* Features:
|
|
* - Full-screen native app experience
|
|
* - iOS/Android safe areas
|
|
* - Pull-to-refresh with native feel
|
|
* - Native-style bottom navigation
|
|
* - Touch-optimized interactions
|
|
* - App-like scrolling physics
|
|
*
|
|
* Layout structure:
|
|
* - Header: iOS/Android style with safe areas
|
|
* - Main: Native scrolling with pull-to-refresh
|
|
* - BottomNav: Native-style tab bar
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <MobileLayout
|
|
* showBottomNav
|
|
* bottomNavItems={navItems}
|
|
* activeNavItem="chat"
|
|
* onNavItemPress={handleNavPress}
|
|
* enablePullToRefresh
|
|
* onRefresh={handleRefresh}
|
|
* >
|
|
* <ChatInterface />
|
|
* </MobileLayout>
|
|
* ```
|
|
*/
|
|
export function MobileLayout({
|
|
children,
|
|
className,
|
|
header,
|
|
footer,
|
|
bottomNav,
|
|
showHeader = true,
|
|
showFooter = false,
|
|
enablePullToRefresh = false,
|
|
onRefresh,
|
|
showBottomNav = false,
|
|
bottomNavItems = [],
|
|
activeNavItem,
|
|
onNavItemPress,
|
|
}: MobileLayoutProps) {
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [pullDistance, setPullDistance] = useState(0);
|
|
const [isPulling, setIsPulling] = useState(false);
|
|
const mainRef = useRef<HTMLDivElement>(null);
|
|
const startYRef = useRef<number>(0);
|
|
const isDraggingRef = useRef(false);
|
|
|
|
// Pull-to-refresh logic
|
|
useEffect(() => {
|
|
if (!enablePullToRefresh || !mainRef.current) return;
|
|
|
|
const handleTouchStart = (e: TouchEvent) => {
|
|
if (mainRef.current!.scrollTop === 0) {
|
|
startYRef.current = e.touches[0].clientY;
|
|
isDraggingRef.current = true;
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
if (!isDraggingRef.current || mainRef.current!.scrollTop > 0) return;
|
|
|
|
const currentY = e.touches[0].clientY;
|
|
const diff = currentY - startYRef.current;
|
|
|
|
if (diff > 0) {
|
|
e.preventDefault();
|
|
setPullDistance(Math.min(diff * 0.5, 80)); // Max pull distance
|
|
setIsPulling(diff > 30); // Threshold for refresh trigger
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = async () => {
|
|
if (!isDraggingRef.current) return;
|
|
|
|
isDraggingRef.current = false;
|
|
|
|
if (isPulling && onRefresh) {
|
|
setIsRefreshing(true);
|
|
setPullDistance(0);
|
|
setIsPulling(false);
|
|
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
} else {
|
|
setPullDistance(0);
|
|
setIsPulling(false);
|
|
}
|
|
};
|
|
|
|
const element = mainRef.current;
|
|
element.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
element.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
element.addEventListener('touchend', handleTouchEnd);
|
|
|
|
return () => {
|
|
element.removeEventListener('touchstart', handleTouchStart);
|
|
element.removeEventListener('touchmove', handleTouchMove);
|
|
element.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}, [enablePullToRefresh, isPulling, onRefresh]);
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col min-h-screen',
|
|
'mobile-layout',
|
|
'overflow-hidden', // Prevent double scrollbars
|
|
'bg-black text-white', // Native app dark theme
|
|
className
|
|
)}
|
|
>
|
|
{/* Mobile Header - iOS/Android style */}
|
|
{showHeader && header && (
|
|
<header
|
|
className={cn(
|
|
'bg-black/80 backdrop-blur-xl',
|
|
'sticky top-0 z-50',
|
|
'h-14 min-h-[56px]', // Touch-friendly height
|
|
'flex items-center px-4',
|
|
'border-b border-white/10',
|
|
'safe-area-inset-top' // iOS notch support
|
|
)}
|
|
>
|
|
{header}
|
|
</header>
|
|
)}
|
|
|
|
{/* Main Content - Native scrolling with pull-to-refresh */}
|
|
<main
|
|
ref={mainRef}
|
|
className={cn(
|
|
'flex-1',
|
|
'overflow-y-auto overflow-x-hidden',
|
|
'relative',
|
|
showBottomNav ? 'pb-20' : '', // Space for native bottom nav
|
|
enablePullToRefresh ? 'overscroll-none' : 'overscroll-behavior-contain'
|
|
)}
|
|
style={{
|
|
transform: pullDistance > 0 ? `translateY(${pullDistance}px)` : undefined,
|
|
transition: isDraggingRef.current ? 'none' : 'transform 0.3s ease-out'
|
|
}}
|
|
>
|
|
{/* Pull-to-refresh indicator */}
|
|
{enablePullToRefresh && (
|
|
<div
|
|
className={cn(
|
|
'absolute top-0 left-0 right-0 z-10',
|
|
'flex items-center justify-center',
|
|
'h-16 bg-black/50 backdrop-blur-sm',
|
|
'transform transition-transform duration-200',
|
|
pullDistance > 0 ? 'translate-y-0' : '-translate-y-full'
|
|
)}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
{isRefreshing ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
<span className="text-sm text-white/70">Refreshing...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'w-4 h-4 rounded-full border-2 border-white/30 flex items-center justify-center transition-all',
|
|
isPulling ? 'border-white scale-110' : ''
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-1 h-1 bg-white rounded-full transition-opacity',
|
|
isPulling ? 'opacity-100' : 'opacity-0'
|
|
)} />
|
|
</div>
|
|
<span className={cn(
|
|
'text-sm transition-colors',
|
|
isPulling ? 'text-white' : 'text-white/50'
|
|
)}>
|
|
{isPulling ? 'Release to refresh' : 'Pull to refresh'}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content with top padding for pull indicator */}
|
|
<div className={enablePullToRefresh ? 'pt-4' : ''}>
|
|
{children}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Footer (optional) */}
|
|
{showFooter && footer && (
|
|
<footer className="safe-area-inset-bottom">
|
|
{footer}
|
|
</footer>
|
|
)}
|
|
|
|
{/* Native-style Bottom Navigation */}
|
|
{showBottomNav && bottomNavItems.length > 0 && (
|
|
<nav
|
|
className={cn(
|
|
'fixed bottom-0 left-0 right-0 z-50',
|
|
'bg-black/90 backdrop-blur-xl',
|
|
'h-16 min-h-[64px]', // iOS/Android standard height
|
|
'flex items-center justify-around px-2',
|
|
'border-t border-white/10',
|
|
'safe-area-inset-bottom', // iOS home indicator support
|
|
'pb-safe-bottom' // Additional safe area padding
|
|
)}
|
|
>
|
|
{bottomNavItems.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => onNavItemPress?.(item.id)}
|
|
className={cn(
|
|
'flex flex-col items-center justify-center',
|
|
'min-w-[60px] h-12 rounded-lg',
|
|
'transition-all duration-200',
|
|
'relative',
|
|
activeNavItem === item.id
|
|
? 'text-white'
|
|
: 'text-white/50 hover:text-white/80'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'relative mb-1',
|
|
activeNavItem === item.id ? 'scale-110' : ''
|
|
)}>
|
|
{item.icon}
|
|
{item.badge && item.badge > 0 && (
|
|
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
|
{item.badge > 99 ? '99+' : item.badge}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className={cn(
|
|
'text-xs font-medium transition-all',
|
|
activeNavItem === item.id ? 'opacity-100' : 'opacity-70'
|
|
)}>
|
|
{item.label}
|
|
</span>
|
|
{activeNavItem === item.id && (
|
|
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-white rounded-full" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
{/* Custom Bottom Navigation (legacy support) */}
|
|
{bottomNav && !showBottomNav && (
|
|
<nav
|
|
className={cn(
|
|
'bg-black/80 backdrop-blur-xl',
|
|
'sticky bottom-0 z-50',
|
|
'h-16 min-h-[64px]',
|
|
'flex items-center justify-around px-4',
|
|
'border-t border-white/10',
|
|
'safe-area-inset-bottom'
|
|
)}
|
|
>
|
|
{bottomNav}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|