Files
awesome-app/components/ui/shadcn-io/comparison/index.tsx
valknarness b63592f153 a new start
2025-10-25 16:09:02 +02:00

210 lines
5.1 KiB
TypeScript

'use client';
import { GripVerticalIcon } from 'lucide-react';
import {
type MotionValue,
motion,
useMotionValue,
useSpring,
useTransform,
} from 'motion/react';
import {
type ComponentProps,
createContext,
type HTMLAttributes,
type MouseEventHandler,
type ReactNode,
type TouchEventHandler,
useContext,
useState,
} from 'react';
import { cn } from '@/lib/utils';
type ImageComparisonContextType = {
sliderPosition: number;
setSliderPosition: (pos: number) => void;
motionSliderPosition: MotionValue<number>;
mode: 'hover' | 'drag';
};
const ImageComparisonContext = createContext<
ImageComparisonContextType | undefined
>(undefined);
const useImageComparisonContext = () => {
const context = useContext(ImageComparisonContext);
if (!context) {
throw new Error(
'useImageComparisonContext must be used within a ImageComparison'
);
}
return context;
};
export type ComparisonProps = HTMLAttributes<HTMLDivElement> & {
mode?: 'hover' | 'drag';
onDragStart?: () => void;
onDragEnd?: () => void;
};
export const Comparison = ({
className,
mode = 'drag',
onDragStart,
onDragEnd,
...props
}: ComparisonProps) => {
const [isDragging, setIsDragging] = useState(false);
const motionValue = useMotionValue(50);
const motionSliderPosition = useSpring(motionValue, {
bounce: 0,
duration: 0,
});
const [sliderPosition, setSliderPosition] = useState(50);
const handleDrag = (domRect: DOMRect, clientX: number) => {
if (!isDragging && mode === 'drag') {
return;
}
const x = clientX - domRect.left;
const percentage = Math.min(Math.max((x / domRect.width) * 100, 0), 100);
motionValue.set(percentage);
setSliderPosition(percentage);
};
const handleMouseDrag: MouseEventHandler<HTMLDivElement> = (event) => {
if (!event) {
return;
}
const containerRect = event.currentTarget.getBoundingClientRect();
handleDrag(containerRect, event.clientX);
};
const handleTouchDrag: TouchEventHandler<HTMLDivElement> = (event) => {
if (!event) {
return;
}
const containerRect = event.currentTarget.getBoundingClientRect();
const touches = Array.from(event.touches);
handleDrag(containerRect, touches.at(0)?.clientX ?? 0);
};
const handleDragStart = () => {
if (mode === 'drag') {
setIsDragging(true);
onDragStart?.();
}
};
const handleDragEnd = () => {
if (mode === 'drag') {
setIsDragging(false);
onDragEnd?.();
}
};
return (
<ImageComparisonContext.Provider
value={{ sliderPosition, setSliderPosition, motionSliderPosition, mode }}
>
<div
aria-label="Comparison slider"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={sliderPosition}
className={cn(
'relative isolate w-full select-none overflow-hidden',
className
)}
onMouseDown={handleDragStart}
onMouseLeave={handleDragEnd}
onMouseMove={handleMouseDrag}
onMouseUp={handleDragEnd}
onTouchEnd={handleDragEnd}
onTouchMove={handleTouchDrag}
onTouchStart={handleDragStart}
role="slider"
tabIndex={0}
{...props}
/>
</ImageComparisonContext.Provider>
);
};
export type ComparisonItemProps = ComponentProps<typeof motion.div> & {
position: 'left' | 'right';
};
export const ComparisonItem = ({
className,
position,
...props
}: ComparisonItemProps) => {
const { motionSliderPosition } = useImageComparisonContext();
const leftClipPath = useTransform(
motionSliderPosition,
(value) => `inset(0 0 0 ${value}%)`
);
const rightClipPath = useTransform(
motionSliderPosition,
(value) => `inset(0 ${100 - value}% 0 0)`
);
return (
<motion.div
aria-hidden="true"
className={cn('absolute inset-0 h-full w-full object-cover', className)}
role="img"
style={{
clipPath: position === 'left' ? leftClipPath : rightClipPath,
}}
{...props}
/>
);
};
export type ComparisonHandleProps = ComponentProps<typeof motion.div> & {
children?: ReactNode;
};
export const ComparisonHandle = ({
className,
children,
...props
}: ComparisonHandleProps) => {
const { motionSliderPosition, mode } = useImageComparisonContext();
const left = useTransform(motionSliderPosition, (value) => `${value}%`);
return (
<motion.div
aria-hidden="true"
className={cn(
'-translate-x-1/2 absolute top-0 z-50 flex h-full w-10 items-center justify-center',
mode === 'drag' && 'cursor-grab active:cursor-grabbing',
className
)}
role="presentation"
style={{ left }}
{...props}
>
{children ?? (
<>
<div className="-translate-x-1/2 absolute left-1/2 h-full w-1 bg-background" />
{mode === 'drag' && (
<div className="z-50 flex items-center justify-center rounded-sm bg-background px-0.5 py-1">
<GripVerticalIcon className="h-4 w-4 select-none text-muted-foreground" />
</div>
)}
</>
)}
</motion.div>
);
};