turash/bugulma/frontend/components/ui/Sheet.tsx
2025-12-15 10:06:41 +01:00

198 lines
4.7 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import { clsx } from 'clsx';
import { X } from 'lucide-react';
import Button from './Button';
export interface SheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
side?: 'left' | 'right' | 'top' | 'bottom';
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
className?: string;
}
const sideClasses = {
left: 'left-0 top-0 bottom-0',
right: 'right-0 top-0 bottom-0',
top: 'top-0 left-0 right-0',
bottom: 'bottom-0 left-0 right-0',
};
const sizeClasses = {
sm: 'w-80',
md: 'w-96',
lg: 'w-[32rem]',
xl: 'w-[42rem]',
full: 'w-full',
};
const slideAnimations = {
left: 'animate-in slide-in-from-left duration-300',
right: 'animate-in slide-in-from-right duration-300',
top: 'animate-in slide-in-from-top duration-300',
bottom: 'animate-in slide-in-from-bottom duration-300',
};
/**
* Sheet/Drawer component - side panel
*/
export const Sheet = ({
open,
onOpenChange,
children,
side = 'right',
size = 'md',
className,
}: SheetProps) => {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onOpenChange(false);
}
};
if (open) {
document.addEventListener('keydown', handleEscape);
// Focus trap
const focusableElements = sheetRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements?.[0] as HTMLElement;
firstElement?.focus();
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [open, onOpenChange]);
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Sheet */}
<div
ref={sheetRef}
className={clsx(
'fixed z-50',
'bg-background border shadow-lg',
'flex flex-col',
sideClasses[side],
size === 'full' ? 'h-full' : sizeClasses[size],
slideAnimations[side],
className
)}
role="dialog"
aria-modal="true"
>
{children}
</div>
</>
);
};
export interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const SheetHeader = ({ children, className, ...props }: SheetHeaderProps) => {
return (
<div className={clsx('flex flex-col space-y-1.5 p-6 pb-4 border-b', className)} {...props}>
{children}
</div>
);
};
export interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
}
export const SheetTitle = ({ children, className, ...props }: SheetTitleProps) => {
return (
<h2 className={clsx('text-lg font-semibold leading-none tracking-tight', className)} {...props}>
{children}
</h2>
);
};
export interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
export const SheetDescription = ({ children, className, ...props }: SheetDescriptionProps) => {
return (
<p className={clsx('text-sm text-muted-foreground', className)} {...props}>
{children}
</p>
);
};
export interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const SheetContent = ({ children, className, ...props }: SheetContentProps) => {
return (
<div className={clsx('flex-1 overflow-y-auto p-6', className)} {...props}>
{children}
</div>
);
};
export interface SheetFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const SheetFooter = ({ children, className, ...props }: SheetFooterProps) => {
return (
<div
className={clsx('flex items-center justify-end gap-3 p-6 pt-4 border-t', className)}
{...props}
>
{children}
</div>
);
};
export interface SheetCloseProps {
onClose: () => void;
className?: string;
}
export const SheetClose = ({ onClose, className }: SheetCloseProps) => {
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
className={clsx('absolute right-4 top-4', className)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
);
};