317 lines
7.5 KiB
TypeScript
317 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useContext, createContext } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { cn } from '@/editor/lib/utils';
|
|
|
|
interface SheetContextType {
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
side: 'top' | 'right' | 'bottom' | 'left';
|
|
}
|
|
|
|
const SheetContext = createContext<SheetContextType | undefined>(undefined);
|
|
|
|
function useSheet() {
|
|
const context = useContext(SheetContext);
|
|
if (!context) {
|
|
throw new Error('Sheet components must be used within a Sheet');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
interface SheetProps {
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
children: React.ReactNode;
|
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
}
|
|
|
|
function Sheet({
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
children,
|
|
side = 'right',
|
|
}: SheetProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const isControlled = controlledOpen !== undefined;
|
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
|
|
const setOpen = useCallback(
|
|
(newOpen: boolean) => {
|
|
if (!isControlled) {
|
|
setInternalOpen(newOpen);
|
|
}
|
|
onOpenChange?.(newOpen);
|
|
},
|
|
[isControlled, onOpenChange]
|
|
);
|
|
|
|
return (
|
|
<SheetContext.Provider value={{ open, setOpen, side }}>
|
|
{children}
|
|
</SheetContext.Provider>
|
|
);
|
|
}
|
|
|
|
function SheetTrigger(
|
|
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
|
|
) {
|
|
const { setOpen } = useSheet();
|
|
const { children, asChild, ...rest } = props;
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement, {
|
|
...rest,
|
|
onClick: (e: React.MouseEvent) => {
|
|
setOpen(true);
|
|
children.props.onClick?.(e);
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
data-slot="sheet-trigger"
|
|
{...rest}
|
|
onClick={(e) => {
|
|
setOpen(true);
|
|
props.onClick?.(e);
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SheetPortal({ children }: { children: React.ReactNode }) {
|
|
if (typeof document === 'undefined') return null;
|
|
return createPortal(children, document.body);
|
|
}
|
|
|
|
function SheetOverlay({
|
|
className,
|
|
onClick,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
const { setOpen } = useSheet();
|
|
|
|
return (
|
|
<motion.div
|
|
data-slot="sheet-overlay"
|
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
onClick={(e) => {
|
|
setOpen(false);
|
|
onClick?.(e);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
showCloseButton?: boolean;
|
|
}
|
|
|
|
function SheetContent({
|
|
className,
|
|
children,
|
|
showCloseButton = true,
|
|
...props
|
|
}: SheetContentProps) {
|
|
const { open, setOpen, side } = useSheet();
|
|
|
|
const sideClasses = {
|
|
right: 'inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border-l',
|
|
left: 'inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border-r',
|
|
top: 'inset-x-0 top-0 h-auto border-b',
|
|
bottom: 'inset-x-0 bottom-0 h-auto border-t',
|
|
};
|
|
|
|
const slideVariants = {
|
|
right: {
|
|
initial: { x: 400, opacity: 0 },
|
|
animate: { x: 0, opacity: 1 },
|
|
exit: { x: 400, opacity: 0 },
|
|
},
|
|
left: {
|
|
initial: { x: -400, opacity: 0 },
|
|
animate: { x: 0, opacity: 1 },
|
|
exit: { x: -400, opacity: 0 },
|
|
},
|
|
top: {
|
|
initial: { y: -400, opacity: 0 },
|
|
animate: { y: 0, opacity: 1 },
|
|
exit: { y: -400, opacity: 0 },
|
|
},
|
|
bottom: {
|
|
initial: { y: 400, opacity: 0 },
|
|
animate: { y: 0, opacity: 1 },
|
|
exit: { y: 400, opacity: 0 },
|
|
},
|
|
};
|
|
|
|
return (
|
|
<SheetPortal>
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<>
|
|
<SheetOverlay />
|
|
<motion.div
|
|
data-slot="sheet-content"
|
|
className={cn(
|
|
'bg-background fixed z-50 flex flex-col gap-0 shadow-lg',
|
|
sideClasses[side],
|
|
className,
|
|
)}
|
|
variants={slideVariants[side]}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
{...props}
|
|
>
|
|
{children}
|
|
{showCloseButton && (
|
|
<button
|
|
data-slot="sheet-close"
|
|
onClick={() => setOpen(false)}
|
|
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
aria-label="Close"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="size-4"
|
|
>
|
|
<path d="M18 6l-12 12M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</motion.div>
|
|
</>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</SheetPortal>
|
|
);
|
|
}
|
|
|
|
function SheetClose(
|
|
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
|
|
) {
|
|
const { setOpen } = useSheet();
|
|
const { children, asChild, ...rest } = props;
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement, {
|
|
...rest,
|
|
onClick: (e: React.MouseEvent) => {
|
|
setOpen(false);
|
|
children.props.onClick?.(e);
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
data-slot="sheet-close"
|
|
{...rest}
|
|
onClick={(e) => {
|
|
setOpen(false);
|
|
props.onClick?.(e);
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sheet-header"
|
|
className={cn(
|
|
'flex flex-col gap-1.5 p-6 border-b border-border',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sheet-footer"
|
|
className={cn(
|
|
'flex flex-col-reverse gap-2 p-6 border-t border-border sm:flex-row sm:justify-end',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SheetTitle({ className, ...props }: React.ComponentProps<'h2'>) {
|
|
return (
|
|
<h2
|
|
data-slot="sheet-title"
|
|
className={cn('text-lg leading-none font-semibold', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SheetDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|
return (
|
|
<p
|
|
data-slot="sheet-description"
|
|
className={cn('text-muted-foreground text-sm', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SheetBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function SheetBody({ className, children, ...props }: SheetBodyProps) {
|
|
return (
|
|
<div
|
|
data-slot="sheet-body"
|
|
className={cn('flex-1 overflow-y-auto px-6 py-4', className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export {
|
|
Sheet,
|
|
SheetTrigger,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetFooter,
|
|
SheetTitle,
|
|
SheetDescription,
|
|
SheetBody,
|
|
SheetPortal,
|
|
SheetOverlay,
|
|
AnimatePresence,
|
|
};
|