Files
pos-system/apps/web-client/src/features/shared/components/layout/mobile-layout/mobile-layout.tsx

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>
);
}