import React, { useEffect, useRef, useCallback } from 'react'; export interface OffcanvasProps { /** Whether the offcanvas is open */ isOpen: boolean; /** Callback when offcanvas should close */ onClose: () => void; /** Placement of the offcanvas */ placement?: 'start' | 'end' | 'top' | 'bottom'; /** Title for the header */ title?: React.ReactNode; /** Show backdrop */ backdrop?: boolean | 'static'; /** Allow scrolling body when open */ scroll?: boolean; /** Enable keyboard (Escape) to close */ keyboard?: boolean; /** Additional CSS classes */ className?: string; /** Header CSS classes */ headerClassName?: string; /** Body CSS classes */ bodyClassName?: string; /** Children content */ children?: React.ReactNode; } export const Offcanvas: React.FC = ({ isOpen, onClose, placement = 'start', title, backdrop = true, scroll = false, keyboard = true, className = '', headerClassName = '', bodyClassName = '', children, }) => { const offcanvasRef = useRef(null); // Handle escape key useEffect(() => { if (!keyboard || !isOpen) return; const handleKeydown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; document.addEventListener('keydown', handleKeydown); return () => document.removeEventListener('keydown', handleKeydown); }, [keyboard, isOpen, onClose]); // Handle body scroll useEffect(() => { if (isOpen && !scroll) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen, scroll]); // Handle backdrop click const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (backdrop === 'static') return; if (e.target === e.currentTarget) { onClose(); } }, [backdrop, onClose]); // Focus trap useEffect(() => { if (!isOpen) return; const offcanvas = offcanvasRef.current; if (!offcanvas) return; const focusableElements = offcanvas.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; const handleTab = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === firstElement) { lastElement?.focus(); e.preventDefault(); } } else { if (document.activeElement === lastElement) { firstElement?.focus(); e.preventDefault(); } } }; firstElement?.focus(); document.addEventListener('keydown', handleTab); return () => document.removeEventListener('keydown', handleTab); }, [isOpen]); const classes = [ 'll-offcanvas', `ll-offcanvas-${placement}`, isOpen && 'll-offcanvas-show', className, ].filter(Boolean).join(' '); if (!isOpen && !backdrop) return null; return ( <> {/* Backdrop */} {backdrop && (
)} {/* Offcanvas */}
{/* Header */} {title && (
{title}
)} {/* Body */}
{children}
); }; export interface OffcanvasHeaderProps { className?: string; children?: React.ReactNode; } export const OffcanvasHeader: React.FC = ({ className = '', children, }) => (
{children}
); export interface OffcanvasTitleProps { as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; className?: string; children?: React.ReactNode; } export const OffcanvasTitle: React.FC = ({ as: Component = 'h5', className = '', children, }) => ( {children} ); export interface OffcanvasBodyProps { className?: string; children?: React.ReactNode; } export const OffcanvasBody: React.FC = ({ className = '', children, }) => (
{children}
);