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,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