feat: Complete mobile UX/UI overhaul

Major improvements:
- New CSS design system with custom properties (tokens)
- Consistent spacing scale (4px base)
- Touch-friendly sizing (44px min targets)
- Improved Carousel with better touch handling and rubber-band effect
- Enhanced FilterControls (auto-collapse on mobile)
- Better stat card styling with change indicators
- Refined chart cards and toggle switches
- Smoother transitions and micro-interactions
- Better RTL support
- Print styles
- Responsive breakpoints for tablet (1024px), mobile (768px), and small mobile (375px)
- Cleaner typography hierarchy
- Subtle shadows and depth

Components updated:
- App.css: Complete rewrite with design tokens
- Carousel.jsx: Better touch gestures with velocity detection
- StatCard.jsx: Improved change indicator styling
- FilterControls.jsx: Auto-collapse on mobile
- EmptyState.jsx: Better accessibility
- ChartExport.js: Cleaned up unused imports
This commit is contained in:
fahed
2026-02-03 15:02:29 +03:00
parent f17e19f3f8
commit 0e5d285680
18 changed files with 2610 additions and 1610 deletions
+76 -12
View File
@@ -1,4 +1,4 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useState } from 'react';
function Carousel({
children,
@@ -8,25 +8,66 @@ function Carousel({
showLabels = true,
className = ''
}) {
const touchStart = useRef(null);
const touchStartX = useRef(null);
const touchStartY = useRef(null);
const trackRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const itemCount = React.Children.count(children);
// Threshold for swipe detection
const SWIPE_THRESHOLD = 50;
const VELOCITY_THRESHOLD = 0.3;
const handleTouchStart = useCallback((e) => {
touchStart.current = e.touches[0].clientX;
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
setIsDragging(true);
setDragOffset(0);
}, []);
const handleTouchMove = useCallback((e) => {
if (!touchStartX.current || !isDragging) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = currentX - touchStartX.current;
const diffY = currentY - touchStartY.current;
// Only handle horizontal swipes
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault();
// Add resistance at edges
let offset = diffX;
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
offset = diffX * 0.3; // Rubber band effect
}
setDragOffset(offset);
}
}, [isDragging, activeIndex, itemCount]);
const handleTouchEnd = useCallback((e) => {
if (!touchStart.current) return;
const diff = touchStart.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (!touchStartX.current || !isDragging) return;
const endX = e.changedTouches[0].clientX;
const diff = touchStartX.current - endX;
const velocity = Math.abs(diff) / 200; // Rough velocity calc
// Determine if we should change slide
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
if (diff > 0 && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1);
} else if (diff < 0 && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
}
}
touchStart.current = null;
}, [activeIndex, setActiveIndex, itemCount]);
// Reset
touchStartX.current = null;
touchStartY.current = null;
setIsDragging(false);
setDragOffset(0);
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft' && activeIndex > 0) {
@@ -36,18 +77,40 @@ function Carousel({
}
}, [activeIndex, setActiveIndex, itemCount]);
// Calculate transform
const baseTransform = -(activeIndex * 100);
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
const transform = baseTransform + dragPercentage;
return (
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
<div
className={`carousel ${className}`}
onKeyDown={handleKeyDown}
tabIndex={0}
role="region"
aria-label="Carousel"
>
<div className="carousel-container">
<div className="carousel-viewport">
<div
ref={trackRef}
className="carousel-track"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
style={{
transform: `translateX(${transform}%)`,
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{React.Children.map(children, (child, i) => (
<div className="carousel-slide" key={i}>
<div
className="carousel-slide"
key={i}
role="tabpanel"
aria-hidden={activeIndex !== i}
aria-label={labels[i] || `Slide ${i + 1}`}
>
{child}
</div>
))}
@@ -64,6 +127,7 @@ function Carousel({
role="tab"
aria-selected={activeIndex === i}
aria-label={labels[i] || `Slide ${i + 1}`}
aria-controls={`slide-${i}`}
>
{showLabels && labels[i] && (
<span className="dot-label">{labels[i]}</span>
+20 -9
View File
@@ -2,18 +2,29 @@ import React from 'react';
function EmptyState({
icon = '📊',
title = 'No data found',
message = 'Try adjusting your filters',
action,
actionLabel = 'Reset Filters'
title,
message,
action = null,
actionLabel = 'Try Again',
className = ''
}) {
return (
<div className="empty-state">
<div className="empty-state-icon">{icon}</div>
<h3 className="empty-state-title">{title}</h3>
<p className="empty-state-message">{message}</p>
<div className={`empty-state ${className}`}>
<div className="empty-state-icon" role="img" aria-hidden="true">
{icon}
</div>
{title && (
<h3 className="empty-state-title">{title}</h3>
)}
{message && (
<p className="empty-state-message">{message}</p>
)}
{action && (
<button className="empty-state-action" onClick={action}>
<button
className="empty-state-action"
onClick={action}
type="button"
>
{actionLabel}
</button>
)}
+64 -11
View File
@@ -1,33 +1,86 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
function FilterControls({
children,
title = 'Filters',
title,
defaultExpanded = true,
onReset = null,
className = ''
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const { t } = useLanguage();
const displayTitle = title || t('filters.title');
// Start collapsed on mobile
const [expanded, setExpanded] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth > 768 ? defaultExpanded : false;
}
return defaultExpanded;
});
// Handle resize
useEffect(() => {
const handleResize = () => {
// Auto-expand on desktop, keep user preference on mobile
if (window.innerWidth > 768) {
setExpanded(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const toggleExpanded = () => {
setExpanded(!expanded);
};
return (
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
<div className="controls-header" onClick={() => setExpanded(!expanded)}>
<h3>{title}</h3>
<div
className="controls-header"
onClick={toggleExpanded}
role="button"
aria-expanded={expanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpanded();
}
}}
>
<h3>{displayTitle}</h3>
<div className="controls-header-actions">
{onReset && expanded && (
<button
className="controls-reset"
onClick={(e) => { e.stopPropagation(); onReset(); }}
onClick={(e) => {
e.stopPropagation();
onReset();
}}
aria-label={t('filters.reset') || 'Reset filters'}
>
Reset
{t('filters.reset') || 'Reset'}
</button>
)}
<button className="controls-toggle">
{expanded ? '▲ Hide' : '▼ Show'}
<button
className="controls-toggle"
aria-hidden="true"
>
{expanded ? '▲' : '▼'}
</button>
</div>
</div>
<div className="controls-body">
<div
className="controls-body"
style={{
display: expanded ? 'block' : 'none',
animation: expanded ? 'fadeIn 200ms ease' : 'none'
}}
>
{children}
</div>
</div>
@@ -37,7 +90,7 @@ function FilterControls({
function FilterGroup({ label, children }) {
return (
<div className="control-group">
<label>{label}</label>
{label && <label>{label}</label>}
{children}
</div>
);
+7 -2
View File
@@ -1,15 +1,20 @@
import React from 'react';
function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }) {
const isPositive = change !== null && change >= 0;
return (
<div className="stat-card">
<h3>{title}</h3>
<div className="stat-value">{value}</div>
{subtitle && (
<div className="stat-subtitle">{subtitle}</div>
)}
{change !== null && (
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
{isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
<span className="stat-change-label">{changeLabel}</span>
</div>
)}
</div>