veza/apps/web/src/components/ui/modal.tsx
senke 503e6f00b6 feat(a11y): comprehensive accessibility & view states improvements
Sprint 1 — Quick A11y wins:
- progress.tsx: role=progressbar + aria-value* + aria-label
- switch.tsx: role=switch + aria-checked
- skeleton.tsx: aria-hidden=true
- alert.tsx, Toast.tsx, SelectTrigger.tsx: aria-labels on close buttons
- PostCard.tsx: alt on images + aria-labels on icon buttons
- ProductCard.tsx: aria-labels on play/view buttons
- modal.tsx: role=dialog + aria-modal + aria-labelledby
- input.tsx: error state + aria-invalid + aria-describedby
- FAB.tsx: forward aria-label from label prop

Sprint 2 — Structural A11y + View States:
- tabs/: full ARIA tablist/tab/tabpanel + arrow key navigation
- radio-group.tsx: role=radio + arrow key navigation
- select/: aria-activedescendant + full keyboard navigation
- List.tsx + card.tsx: focus-visible states on interactive elements
- DashboardPage, LibraryPage, LiveView, QueueView: error states
- WishlistView, AdminDashboard, AnalyticsView, SellerDashboard: loading/empty states

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:04:35 +01:00

150 lines
4.2 KiB
TypeScript

import { useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { FocusTrap } from './focus-trap';
import { X } from 'lucide-react';
import { Button } from './button';
import { cn } from '@/lib/utils';
export interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
className?: string;
footer?: React.ReactNode;
}
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-full m-4 h-[calc(100vh-2rem)]',
};
/**
* Composant Modal avec design Kodo
*/
export function Modal({
open,
onClose,
children,
title,
closeOnOverlayClick = true,
closeOnEscape = true,
size = 'md',
className,
footer,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Empêcher le scroll du body quand le modal est ouvert
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
return undefined;
}, [open]);
// Gestion de la touche Escape
useEffect(() => {
if (!closeOnEscape || !open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open, closeOnEscape, onClose]);
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
return createPortal(
<AnimatePresence>
{open && (
<motion.div
key="modal"
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
onClick={handleOverlayClick}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
/>
{/* Modal Content */}
<FocusTrap>
<motion.div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className={cn(
'relative w-full bg-kodo-graphite border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden',
sizeClasses[size],
className,
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{title && (
<div className="p-4 border-b border-border bg-kodo-ink flex justify-between items-center shrink-0">
<h3 id={titleId} className="font-bold text-white text-lg font-display">
{title}
</h3>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="ml-auto"
aria-label="Fermer"
>
<X className="w-5 h-5" />
</Button>
</div>
)}
{/* Body */}
<div className="p-8 overflow-y-auto custom-scrollbar flex-1">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-4 border-t border-border bg-kodo-ink shrink-0 flex justify-end gap-4">
{footer}
</div>
)}
</motion.div>
</FocusTrap>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}