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:
leocrm-bot
2026-06-29 07:55:47 +02:00
parent f8193a6ab5
commit 22976abe92
66 changed files with 8598 additions and 0 deletions
+51
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+56
View File
@@ -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';
+36
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+57
View File
@@ -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';
+106
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+69
View File
@@ -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';
+44
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+80
View File
@@ -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,
};
}