Skip to content

Commit e2c749b

Browse files
committed
Implement Advanced Mobile Responsive Components
1 parent 9e7feac commit e2c749b

6 files changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React, { ReactNode, useEffect, useState } from 'react';
2+
import { isMobileDevice } from '../../utils/mobileUtils';
3+
4+
interface AdaptiveLayoutProps {
5+
mobileView: ReactNode;
6+
desktopView: ReactNode;
7+
breakpoint?: number;
8+
}
9+
10+
export const AdaptiveLayout: React.FC<AdaptiveLayoutProps> = ({
11+
mobileView,
12+
desktopView,
13+
breakpoint = 768
14+
}) => {
15+
const [isMobile, setIsMobile] = useState<boolean>(true); // Default to mobile for mobile-first approach
16+
17+
useEffect(() => {
18+
const checkIsMobile = () => {
19+
// Use both utility and explicit window width for precise responsive switching
20+
const mobileByWidth = window.innerWidth <= breakpoint;
21+
setIsMobile(mobileByWidth || isMobileDevice());
22+
};
23+
24+
checkIsMobile(); // Initial check
25+
26+
// Performance optimization: debounce resize handler
27+
let timeoutId: ReturnType<typeof setTimeout>;
28+
const handleResize = () => {
29+
clearTimeout(timeoutId);
30+
timeoutId = setTimeout(checkIsMobile, 150);
31+
};
32+
33+
window.addEventListener('resize', handleResize);
34+
return () => {
35+
window.removeEventListener('resize', handleResize);
36+
clearTimeout(timeoutId);
37+
};
38+
}, [breakpoint]);
39+
40+
return <>{isMobile ? mobileView : desktopView}</>;
41+
};
42+
43+
// Also export a container that changes layout direction, padding, etc., based on sizing
44+
export const AdaptiveContainer: React.FC<{children: React.ReactNode; className?: string}> = ({
45+
children,
46+
className = ''
47+
}) => {
48+
// Mobile-first container: stacked by default, becomes flex-row on md screens
49+
return (
50+
<div className={`flex flex-col md:flex-row w-full p-4 md:p-8 gap-4 md:gap-8 ${className}`}>
51+
{children}
52+
</div>
53+
);
54+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React, { HTMLAttributes } from 'react';
2+
import { useMobileGestures } from '../../hooks/useMobileGestures';
3+
4+
interface GestureHandlerProps extends HTMLAttributes<HTMLDivElement> {
5+
onSwipeLeft?: () => void;
6+
onSwipeRight?: () => void;
7+
onSwipeUp?: () => void;
8+
onSwipeDown?: () => void;
9+
onPinchIn?: () => void;
10+
onPinchOut?: () => void;
11+
onTap?: () => void;
12+
swipeThreshold?: number;
13+
children: React.ReactNode;
14+
}
15+
16+
export const GestureHandler: React.FC<GestureHandlerProps> = ({
17+
onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown,
18+
onPinchIn, onPinchOut, onTap, swipeThreshold,
19+
children, ...props
20+
}) => {
21+
const gestureProps = useMobileGestures({
22+
onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown,
23+
onPinchIn, onPinchOut, onTap, swipeThreshold
24+
});
25+
26+
return (
27+
<div {...gestureProps} {...props} style={{ touchAction: 'pan-y', ...props.style }}>
28+
{children}
29+
</div>
30+
);
31+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React, { useState } from 'react';
2+
import { Home, Search, BookOpen, User } from 'lucide-react';
3+
4+
interface NavItem {
5+
id: string;
6+
label: string;
7+
icon: React.ReactNode;
8+
onClick?: () => void;
9+
}
10+
11+
export const MobileNavigation: React.FC<{
12+
initialActive?: string;
13+
onNavChange?: (id: string) => void;
14+
}> = ({ initialActive = 'home', onNavChange }) => {
15+
const [activeTab, setActiveTab] = useState(initialActive);
16+
17+
const navItems: NavItem[] = [
18+
{ id: 'home', label: 'Home', icon: <Home size={24} /> },
19+
{ id: 'search', label: 'Search', icon: <Search size={24} /> },
20+
{ id: 'courses', label: 'Courses', icon: <BookOpen size={24} /> },
21+
{ id: 'profile', label: 'Profile', icon: <User size={24} /> },
22+
];
23+
24+
const handleTabClick = (id: string) => {
25+
setActiveTab(id);
26+
if (onNavChange) onNavChange(id);
27+
};
28+
29+
return (
30+
<nav className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 z-50 md:hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
31+
<ul className="flex justify-around items-center h-16 px-2">
32+
{navItems.map((item) => {
33+
const isActive = activeTab === item.id;
34+
return (
35+
<li key={item.id} className="flex-1">
36+
<button
37+
onClick={() => handleTabClick(item.id)}
38+
className={`w-full flex flex-col items-center justify-center py-2 space-y-1 transition-colors duration-200
39+
${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'}
40+
`}
41+
aria-label={item.label}
42+
>
43+
<div className={`${isActive ? 'scale-110' : 'scale-100'} transition-transform duration-200`}>
44+
{item.icon}
45+
</div>
46+
<span className="text-[10px] font-medium leading-none">{item.label}</span>
47+
</button>
48+
</li>
49+
);
50+
})}
51+
</ul>
52+
</nav>
53+
);
54+
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React, { ButtonHTMLAttributes, ReactNode, useState } from 'react';
2+
import { GestureHandler } from './GestureHandler';
3+
4+
// Touch-Optimized Button with larger hit area and tap feedback
5+
interface TouchButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
6+
children: ReactNode;
7+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
8+
fullWidth?: boolean;
9+
}
10+
11+
export const TouchButton: React.FC<TouchButtonProps> = ({
12+
children,
13+
variant = 'primary',
14+
fullWidth = true,
15+
className = '',
16+
...props
17+
}) => {
18+
const [isTouched, setIsTouched] = useState(false);
19+
20+
const baseClasses = "relative overflow-hidden rounded-xl font-medium transition-all duration-200 active:scale-95 flex items-center justify-center";
21+
const sizeClasses = "min-h-[48px] px-6 py-3 text-base"; // Minimum 48px height for touch targets
22+
const widthClasses = fullWidth ? "w-full" : "";
23+
24+
const variantClasses = {
25+
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
26+
secondary: "bg-gray-200 text-gray-900 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 active:bg-gray-300 dark:active:bg-gray-600",
27+
outline: "border-2 border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 bg-transparent active:bg-blue-50 dark:active:bg-gray-800",
28+
ghost: "bg-transparent text-gray-700 dark:text-gray-300 active:bg-gray-100 dark:active:bg-gray-800"
29+
};
30+
31+
return (
32+
<button
33+
className={`${baseClasses} ${sizeClasses} ${widthClasses} ${variantClasses[variant]} ${isTouched ? 'opacity-80' : 'opacity-100'} ${className}`}
34+
onTouchStart={() => setIsTouched(true)}
35+
onTouchEnd={() => setIsTouched(false)}
36+
onTouchCancel={() => setIsTouched(false)}
37+
{...props}
38+
>
39+
{children}
40+
</button>
41+
);
42+
};
43+
44+
// Swipeable Card Component
45+
interface SwipeableCardProps {
46+
children: ReactNode;
47+
onSwipeLeft?: () => void;
48+
onSwipeRight?: () => void;
49+
onSwipeUp?: () => void;
50+
onSwipeDown?: () => void;
51+
className?: string;
52+
}
53+
54+
export const SwipeableCard: React.FC<SwipeableCardProps> = ({
55+
children,
56+
onSwipeLeft,
57+
onSwipeRight,
58+
onSwipeUp,
59+
onSwipeDown,
60+
className = ''
61+
}) => {
62+
return (
63+
<GestureHandler
64+
onSwipeLeft={onSwipeLeft}
65+
onSwipeRight={onSwipeRight}
66+
onSwipeUp={onSwipeUp}
67+
onSwipeDown={onSwipeDown}
68+
className={`bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm border border-gray-100 dark:border-gray-700 transition-transform active:scale-[0.98] ${className}`}
69+
>
70+
{children}
71+
</GestureHandler>
72+
);
73+
};
74+
75+
// Bottom Sheet Modal (Mobile Optimized)
76+
interface BottomSheetProps {
77+
isOpen: boolean;
78+
onClose: () => void;
79+
children: ReactNode;
80+
title?: string;
81+
}
82+
83+
export const BottomSheet: React.FC<BottomSheetProps> = ({
84+
isOpen,
85+
onClose,
86+
children,
87+
title
88+
}) => {
89+
if (!isOpen) return null;
90+
91+
return (
92+
<div className="fixed inset-0 z-50 flex flex-col justify-end">
93+
{/* Backdrop */}
94+
<div
95+
className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
96+
onClick={onClose}
97+
/>
98+
99+
{/* Sheet Content */}
100+
<div className="relative bg-white dark:bg-gray-900 w-full rounded-t-3xl p-6 shadow-xl animate-in slide-in-from-bottom-full duration-300 pb-safe">
101+
<GestureHandler
102+
onSwipeDown={onClose}
103+
swipeThreshold={40}
104+
>
105+
{/* Handle bar for swiping */}
106+
<div className="w-12 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-6 cursor-pointer" />
107+
</GestureHandler>
108+
109+
{title && (
110+
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
111+
{title}
112+
</h2>
113+
)}
114+
115+
<div className="max-h-[70vh] overflow-y-auto">
116+
{children}
117+
</div>
118+
</div>
119+
</div>
120+
);
121+
};

src/hooks/useMobileGestures.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useState, TouchEvent, useCallback } from 'react';
2+
import { calculateSwipeDirection, calculateDistance } from '../utils/mobileUtils';
3+
4+
interface GestureHandlers {
5+
onSwipeLeft?: () => void;
6+
onSwipeRight?: () => void;
7+
onSwipeUp?: () => void;
8+
onSwipeDown?: () => void;
9+
onPinchIn?: () => void;
10+
onPinchOut?: () => void;
11+
onTap?: () => void;
12+
swipeThreshold?: number;
13+
}
14+
15+
export const useMobileGestures = (handlers: GestureHandlers) => {
16+
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
17+
const [initialPinchDistance, setInitialPinchDistance] = useState<number | null>(null);
18+
19+
const handleTouchStart = useCallback((e: TouchEvent) => {
20+
if (e.touches.length === 1) {
21+
setTouchStart({ x: e.touches[0].clientX, y: e.touches[0].clientY });
22+
} else if (e.touches.length === 2) {
23+
const dist = calculateDistance(
24+
e.touches[0].clientX, e.touches[0].clientY,
25+
e.touches[1].clientX, e.touches[1].clientY
26+
);
27+
setInitialPinchDistance(dist);
28+
}
29+
}, []);
30+
31+
const handleTouchEnd = useCallback((e: TouchEvent) => {
32+
if (e.changedTouches.length === 1 && touchStart) {
33+
const touchEnd = { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
34+
const isTap = Math.abs(touchEnd.x - touchStart.x) < 10 && Math.abs(touchEnd.y - touchStart.y) < 10;
35+
36+
if (isTap && handlers.onTap) {
37+
handlers.onTap();
38+
} else {
39+
const direction = calculateSwipeDirection(
40+
touchStart.x, touchStart.y,
41+
touchEnd.x, touchEnd.y,
42+
handlers.swipeThreshold || 50
43+
);
44+
45+
if (direction === 'LEFT' && handlers.onSwipeLeft) handlers.onSwipeLeft();
46+
if (direction === 'RIGHT' && handlers.onSwipeRight) handlers.onSwipeRight();
47+
if (direction === 'UP' && handlers.onSwipeUp) handlers.onSwipeUp();
48+
if (direction === 'DOWN' && handlers.onSwipeDown) handlers.onSwipeDown();
49+
}
50+
}
51+
setTouchStart(null);
52+
setInitialPinchDistance(null);
53+
}, [touchStart, handlers]);
54+
55+
const handleTouchMove = useCallback((e: TouchEvent) => {
56+
if (e.touches.length === 2 && initialPinchDistance !== null) {
57+
const currentDistance = calculateDistance(
58+
e.touches[0].clientX, e.touches[0].clientY,
59+
e.touches[1].clientX, e.touches[1].clientY
60+
);
61+
62+
const pinchThreshold = 20;
63+
if (currentDistance - initialPinchDistance > pinchThreshold && handlers.onPinchOut) {
64+
handlers.onPinchOut();
65+
setInitialPinchDistance(currentDistance); // Reset to detect continuous pinch
66+
} else if (initialPinchDistance - currentDistance > pinchThreshold && handlers.onPinchIn) {
67+
handlers.onPinchIn();
68+
setInitialPinchDistance(currentDistance);
69+
}
70+
}
71+
}, [initialPinchDistance, handlers]);
72+
73+
return {
74+
onTouchStart: handleTouchStart,
75+
onTouchMove: handleTouchMove,
76+
onTouchEnd: handleTouchEnd,
77+
};
78+
};

src/utils/mobileUtils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export const isMobileDevice = (): boolean => {
2+
if (typeof window === 'undefined') return false;
3+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
4+
};
5+
6+
export const calculateSwipeDirection = (
7+
startX: number,
8+
startY: number,
9+
endX: number,
10+
endY: number,
11+
threshold = 50
12+
): 'LEFT' | 'RIGHT' | 'UP' | 'DOWN' | null => {
13+
const diffX = endX - startX;
14+
const diffY = endY - startY;
15+
16+
if (Math.abs(diffX) > Math.abs(diffY)) {
17+
// Horizontal swipe
18+
if (Math.abs(diffX) > threshold) {
19+
return diffX > 0 ? 'RIGHT' : 'LEFT';
20+
}
21+
} else {
22+
// Vertical swipe
23+
if (Math.abs(diffY) > threshold) {
24+
return diffY > 0 ? 'DOWN' : 'UP';
25+
}
26+
}
27+
return null;
28+
};
29+
30+
export const calculateDistance = (x1: number, y1: number, x2: number, y2: number): number => {
31+
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
32+
};

0 commit comments

Comments
 (0)