T07a: frontend core SPA — shell + auth + routing + i18n + UI library + a11y
- React 18 + Vite + TypeScript + Tailwind CSS setup - AppShell with Sidebar (plugin menu) + TopBar (tenant switcher, search, notifications, user menu) - Auth pages: Login, PasswordResetRequest, PasswordResetConfirm - Protected routes with auth guard - API client (axios with interceptors: session cookie, 401 redirect, 422 validation) - TanStack Query hooks for auth, users, companies, contacts, notifications - Zustand stores: authStore, uiStore - i18n setup (de/en locales) with react-i18next - UI component library: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog - Accessibility: ARIA labels, 44px touch targets, keyboard nav, reduced-motion, sr-only - Design tokens from prototype as CSS custom properties - 111 tests passing across 20 test files - tsc --noEmit: 0 errors - npm run build: success (471KB JS, 24KB CSS)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopBar } from './TopBar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<main
|
||||
className="flex-1 overflow-y-auto focus:outline-none"
|
||||
tabIndex={-1}
|
||||
role="main"
|
||||
id="main-content"
|
||||
data-testid="content-area"
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const navIcon = (path: string) => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: navIcon('M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6') },
|
||||
{ to: '/companies', labelKey: 'nav.companies', icon: navIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4') },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
|
||||
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
||||
{ to: '/files', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||
{ to: '/email', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||
{ to: '/users', labelKey: 'nav.users', icon: navIcon('M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z') },
|
||||
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
|
||||
{ to: '/settings', labelKey: 'nav.settings', icon: navIcon('M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z') },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-secondary-900/50 z-30 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
data-testid="sidebar-overlay"
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed md:static inset-y-0 left-0 z-40 w-64 bg-secondary-900 text-secondary-300',
|
||||
'flex flex-col transition-transform motion-safe:duration-300',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
|
||||
)}
|
||||
aria-label="Seitenleiste Navigation"
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<div className="h-16 flex items-center px-6 border-b border-secondary-700">
|
||||
<span className="text-xl font-bold text-white">leocrm</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
|
||||
<ul className="space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isActive
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'hover:bg-secondary-800 hover:text-white'
|
||||
)
|
||||
}
|
||||
aria-label={t(item.labelKey)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useTenant } from '@/hooks/useTenant';
|
||||
import { useLogout } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const { toggleSidebar } = useUIStore();
|
||||
const { currentTenant, availableTenants, switchTenant, isSwitching } = useTenant();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const userRef = useRef<HTMLDivElement>(null);
|
||||
const notifRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (tenantRef.current && !tenantRef.current.contains(e.target as Node)) setTenantMenuOpen(false);
|
||||
if (userRef.current && !userRef.current.contains(e.target as Node)) setUserMenuOpen(false);
|
||||
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setNotifOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleTenantSwitch = async (tenantId: string) => {
|
||||
await switchTenant(tenantId);
|
||||
setTenantMenuOpen(false);
|
||||
};
|
||||
|
||||
const fullName = user ? `${user.first_name} ${user.last_name}` : '';
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-16 bg-white border-b border-secondary-200 flex items-center justify-between px-4 sticky top-0 z-20"
|
||||
role="banner"
|
||||
data-testid="topbar"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label="Seitenleiste ein-/ausklappen"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Tenant switcher */}
|
||||
<div ref={tenantRef} className="relative">
|
||||
<button
|
||||
onClick={() => setTenantMenuOpen(!tenantMenuOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-secondary-100 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('topbar.switchTenant')}
|
||||
aria-expanded={tenantMenuOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-secondary-700 max-w-32 truncate">
|
||||
{currentTenant?.name || t('topbar.tenant')}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{tenantMenuOpen && (
|
||||
<ul
|
||||
className="absolute top-full left-0 mt-1 w-56 bg-white rounded-md shadow-lg border border-secondary-200 py-1 z-50"
|
||||
role="listbox"
|
||||
aria-label={t('topbar.switchTenant')}
|
||||
>
|
||||
{availableTenants.map((tenant) => (
|
||||
<li key={tenant.id}>
|
||||
<button
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch flex items-center justify-between focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
tenant.id === currentTenant?.id && 'bg-primary-50 text-primary-700'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{tenant.name}
|
||||
{tenant.id === currentTenant?.id && (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="hidden md:block relative">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={t('topbar.search')}
|
||||
className="w-64 pl-10 pr-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
|
||||
aria-label={t('topbar.search')}
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notifications */}
|
||||
<div ref={notifRef} className="relative">
|
||||
<button
|
||||
onClick={() => setNotifOpen(!notifOpen)}
|
||||
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('topbar.notifications')}
|
||||
aria-expanded={notifOpen}
|
||||
>
|
||||
<svg className="w-5 h-5 text-secondary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<Badge variant="danger" className="absolute -top-1 -right-1 px-1.5 py-0">3</Badge>
|
||||
</button>
|
||||
{notifOpen && (
|
||||
<div className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50">
|
||||
<div className="px-4 py-3 border-b border-secondary-200">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('topbar.notifications')}</h3>
|
||||
</div>
|
||||
<ul className="max-h-64 overflow-y-auto py-1" role="list">
|
||||
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
|
||||
Neuer Kontakt hinzugefügt: Max Mustermann
|
||||
</li>
|
||||
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
|
||||
Firma aktualisiert: TechCorp GmbH
|
||||
</li>
|
||||
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
|
||||
Meeting um 14:00 Uhr mit Müller AG
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div ref={userRef} className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-2 p-1 rounded-md hover:bg-secondary-100 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('topbar.userMenu')}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Avatar name={fullName || 'User'} src={user?.avatar_url} size="sm" />
|
||||
<span className="hidden md:block text-sm font-medium text-secondary-700 max-w-24 truncate">
|
||||
{user?.first_name || ''}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-56 bg-white rounded-md shadow-lg border border-secondary-200 py-1 z-50"
|
||||
role="menu"
|
||||
aria-label={t('topbar.userMenu')}
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-secondary-100">
|
||||
<p className="text-sm font-medium text-secondary-900 truncate">{fullName}</p>
|
||||
<p className="text-xs text-secondary-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('topbar.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 text-sm text-danger-600 hover:bg-danger-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('topbar.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface AvatarProps {
|
||||
name: string;
|
||||
src?: string | null;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-xs',
|
||||
sm: 'w-8 h-8 text-sm',
|
||||
md: 'w-10 h-10 text-base',
|
||||
lg: 'w-14 h-14 text-lg',
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({ name, src, size = 'md', className }: AvatarProps) {
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
const showImage = src && !imgError;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-full bg-primary-100 text-primary-700 font-semibold flex-shrink-0 overflow-hidden',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
role="img"
|
||||
aria-label={`Avatar von ${name}`}
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{getInitials(name)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-secondary-100 text-secondary-700',
|
||||
primary: 'bg-primary-100 text-primary-700',
|
||||
success: 'bg-success-100 text-success-700',
|
||||
warning: 'bg-warning-100 text-warning-700',
|
||||
danger: 'bg-danger-100 text-danger-700',
|
||||
info: 'bg-accent-100 text-accent-700',
|
||||
};
|
||||
|
||||
const dotColors: Record<BadgeVariant, string> = {
|
||||
default: 'bg-secondary-400',
|
||||
primary: 'bg-primary-500',
|
||||
success: 'bg-success-500',
|
||||
warning: 'bg-warning-500',
|
||||
danger: 'bg-danger-500',
|
||||
info: 'bg-accent-500',
|
||||
};
|
||||
|
||||
export function Badge({ variant = 'default', children, className, dot = false }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{dot && <span className={clsx('w-1.5 h-1.5 rounded-full', dotColors[variant])} aria-hidden="true" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500',
|
||||
secondary: 'bg-secondary-200 text-secondary-800 hover:bg-secondary-300 focus-visible:ring-secondary-500',
|
||||
danger: 'bg-danger-600 text-white hover:bg-danger-700 focus-visible:ring-danger-500',
|
||||
ghost: 'bg-transparent text-secondary-700 hover:bg-secondary-100 focus-visible:ring-secondary-500',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'text-sm px-3 py-1.5 rounded-md min-h-touch',
|
||||
md: 'text-base px-4 py-2 rounded-md min-h-touch',
|
||||
lg: 'text-lg px-6 py-3 rounded-lg min-h-touch',
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', size = 'md', isLoading = false, icon, fullWidth = false, className, children, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center font-medium transition-colors motion-safe:duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
aria-busy={isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin motion-reduce:animate-none -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{icon && !isLoading && <span className="mr-2" aria-hidden="true">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface CardProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, description, children, footer, className, actions }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white rounded-lg shadow-sm border border-secondary-200 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="px-6 py-4 border-b border-secondary-200 flex items-center justify-between">
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold text-secondary-900">{title}</h3>}
|
||||
{description && <p className="text-sm text-secondary-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-3 bg-secondary-50 border-t border-secondary-200">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Button } from './Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'primary' | 'danger';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
variant = 'primary',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title || t('confirmDialog.title')} size="sm">
|
||||
<p className="text-sm text-secondary-700">{message}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{cancelLabel || t('confirmDialog.cancel')}
|
||||
</Button>
|
||||
<Button variant={variant === 'danger' ? 'danger' : 'primary'} onClick={onConfirm}>
|
||||
{confirmLabel || t('confirmDialog.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center py-12 px-6 text-center',
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{icon && (
|
||||
<div className="mb-4 text-secondary-300" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{!icon && (
|
||||
<div className="mb-4 w-16 h-16 rounded-full bg-secondary-100 flex items-center justify-center" aria-hidden="true">
|
||||
<svg className="w-8 h-8 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-secondary-900">
|
||||
{title || t('emptyState.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 max-w-sm">
|
||||
{description || t('emptyState.description')}
|
||||
</p>
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useId } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, required, className, id, ...props }, ref) => {
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
const errorId = `${inputId}-error`;
|
||||
const helperId = `${inputId}-helper`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'block w-full rounded-md border px-3 py-2 text-base min-h-touch',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
'motion-safe:transition-colors',
|
||||
error
|
||||
? 'border-danger-500 text-danger-900 placeholder-danger-300'
|
||||
: 'border-secondary-300 text-secondary-900 placeholder-secondary-400',
|
||||
className
|
||||
)}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={clsx(error && errorId, helperText && helperId) || undefined}
|
||||
aria-required={required}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-danger-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-secondary-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement;
|
||||
const focusable = dialogRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
focusable?.focus();
|
||||
} else {
|
||||
previouslyFocused.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, closeOnEscape, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-secondary-900/50 motion-safe:animate-opacity"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
aria-hidden="true"
|
||||
data-testid="modal-backdrop"
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={clsx(
|
||||
'relative bg-white rounded-lg shadow-xl w-full',
|
||||
'motion-safe:animate-scale',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-secondary-200">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, total, pageSize, onPageChange }: PaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const end = Math.min(currentPage * pageSize, total);
|
||||
|
||||
const pages: number[] = [];
|
||||
const maxVisible = 5;
|
||||
const halfVisible = Math.floor(maxVisible / 2);
|
||||
let startPage = Math.max(1, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between px-6 py-3 border-t border-secondary-200" aria-label="Pagination">
|
||||
<div className="text-sm text-secondary-600">
|
||||
<span>{start}–{end}</span> <span>{t('table.of')}</span> <span>{total}</span> <span>{t('table.results')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50 disabled:cursor-not-allowed min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('pagination.previous')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{startPage > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('pagination.first')}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{startPage > 2 && <span className="px-1 text-secondary-400" aria-hidden="true">…</span>}
|
||||
</>
|
||||
)}
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm rounded-md border min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
page === currentPage
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 font-semibold'
|
||||
: 'border-secondary-300 hover:bg-secondary-50'
|
||||
)}
|
||||
aria-label={t('pagination.page', { page })}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && <span className="px-1 text-secondary-400" aria-hidden="true">…</span>}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('pagination.last')}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50 disabled:cursor-not-allowed min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('pagination.next')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useId } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
options: SelectOption[];
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, error, helperText, options, required, placeholder, className, id, ...props }, ref) => {
|
||||
const generatedId = useId();
|
||||
const selectId = id || generatedId;
|
||||
const errorId = `${selectId}-error`;
|
||||
const helperId = `${selectId}-helper`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={clsx(
|
||||
'block w-full rounded-md border px-3 py-2 text-base min-h-touch',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
'motion-safe:transition-colors',
|
||||
error
|
||||
? 'border-danger-500 text-danger-900'
|
||||
: 'border-secondary-300 text-secondary-900',
|
||||
className
|
||||
)}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={clsx(error && errorId, helperText && helperId) || undefined}
|
||||
aria-required={required}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-danger-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-secondary-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'text' | 'rect' | 'circle';
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
|
||||
const variantClass = {
|
||||
text: 'rounded',
|
||||
rect: 'rounded-md',
|
||||
circle: 'rounded-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse motion-reduce:animate-none bg-secondary-200',
|
||||
variantClass[variant],
|
||||
className
|
||||
)}
|
||||
style={{ width, height }}
|
||||
role="status"
|
||||
aria-label="Wird geladen"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)} role="status" aria-label="Wird geladen">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
className={clsx('h-4', i === lines - 1 && 'w-2/3')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
accessor?: (row: T) => string | number;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
emptyMessage = 'Keine Daten vorhanden',
|
||||
loading = false,
|
||||
}: TableProps<T>) {
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortColumn) return data;
|
||||
const column = columns.find((c) => c.key === sortColumn);
|
||||
if (!column?.sortable) return data;
|
||||
|
||||
const accessor = column.accessor || ((row: T) => row[sortColumn]);
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = accessor(a);
|
||||
const bVal = accessor(b);
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [data, sortColumn, sortDirection, columns]);
|
||||
|
||||
const handleSort = (column: TableColumn<T>) => {
|
||||
if (!column.sortable) return;
|
||||
if (sortColumn === column.key) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortColumn(column.key);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto" role="region" aria-label="Data table">
|
||||
<table className="min-w-full divide-y divide-secondary-200">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
className={clsx(
|
||||
'px-6 py-3 text-left text-xs font-semibold text-secondary-600 uppercase tracking-wider',
|
||||
col.sortable && 'cursor-pointer select-none hover:bg-secondary-50 min-h-touch'
|
||||
)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
aria-sort={sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
onClick={() => handleSort(col)}
|
||||
onKeyDown={(e) => {
|
||||
if (col.sortable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
handleSort(col);
|
||||
}
|
||||
}}
|
||||
tabIndex={col.sortable ? 0 : undefined}
|
||||
role={col.sortable ? 'button' : undefined}
|
||||
aria-label={col.sortable ? `${col.header}, sortierbar` : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<span aria-hidden="true">
|
||||
{sortColumn === col.key ? (sortDirection === 'asc' ? '▲' : '▼') : '↕'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="animate-spin motion-reduce:animate-none h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Wird geladen...
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedData.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={clsx(
|
||||
'hover:bg-secondary-50 motion-safe:transition-colors',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={onRowClick ? (e) => {
|
||||
if (e.key === 'Enter') onRowClick(row);
|
||||
} : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-6 py-4 text-sm text-secondary-900">
|
||||
{col.render ? col.render(row) : (row[col.key] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useUIStore, Toast as ToastType } from '@/store/uiStore';
|
||||
|
||||
const toastStyles: Record<ToastType['type'], string> = {
|
||||
success: 'bg-success-50 border-success-500 text-success-800',
|
||||
error: 'bg-danger-50 border-danger-500 text-danger-800',
|
||||
warning: 'bg-warning-50 border-warning-500 text-warning-800',
|
||||
info: 'bg-primary-50 border-primary-500 text-primary-800',
|
||||
};
|
||||
|
||||
const toastIcons: Record<ToastType['type'], string> = {
|
||||
success: 'M5 13l4 4L19 7',
|
||||
error: 'M6 18L18 6M6 6l12 12',
|
||||
warning: 'M12 9v2m0 4h.01M5 19h14a2 2 0 001.684-3.048l-7-12a2 2 0 00-3.368 0l-7 12A2 2 0 005 19z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
};
|
||||
|
||||
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const duration = toast.duration ?? 5000;
|
||||
const timer = setTimeout(() => onRemove(toast.id), duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, toast.duration, onRemove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-start gap-3 p-4 rounded-lg border-l-4 shadow-md min-w-72 max-w-md',
|
||||
'motion-safe:animate-slide-in',
|
||||
toastStyles[toast.type]
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<svg className="h-5 w-5 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={toastIcons[toast.type]} />
|
||||
</svg>
|
||||
<p className="flex-1 text-sm font-medium">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const { toasts, removeToast } = useUIStore();
|
||||
const handleRemove = useCallback((id: string) => removeToast(id), [removeToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-[100] flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
data-testid="toast-container"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={handleRemove} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const { addToast, removeToast } = useUIStore();
|
||||
return {
|
||||
success: (message: string, duration?: number) => addToast({ type: 'success', message, duration }),
|
||||
error: (message: string, duration?: number) => addToast({ type: 'error', message, duration }),
|
||||
warning: (message: string, duration?: number) => addToast({ type: 'warning', message, duration }),
|
||||
info: (message: string, duration?: number) => addToast({ type: 'info', message, duration }),
|
||||
remove: removeToast,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user