mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 07:03:52 +02:00
refactor: animate dialog
This commit is contained in:
+4
-1
@@ -10,6 +10,7 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "react-icons",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +18,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
+138
-39
@@ -1,86 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/lib/get-strict-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WindowDragArea } from "../window-drag-area";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps["onOpenChange"];
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] =
|
||||
getStrictContext<DialogContextType>("DialogContext");
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
type DialogPortalProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
"forceMount"
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
type DialogOverlayProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Overlay>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
transition = { duration: 0.2, ease: "easeInOut" },
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
}: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-background/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<WindowDragArea />
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={transition}
|
||||
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
|
||||
{...props}
|
||||
>
|
||||
<WindowDragArea />
|
||||
</motion.div>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
|
||||
|
||||
type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
from?: DialogFlipDirection;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
from = "top",
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
}: DialogContentProps) {
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[10000] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-window-drag-area="true"]')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
onInteractOutside?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useControlledState<T, Rest extends any[] = []>(
|
||||
props: CommonControlledStateProps<T> & {
|
||||
onChange?: (value: T, ...args: Rest) => void;
|
||||
},
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
|
||||
const [state, setInternalState] = React.useState<T>(
|
||||
value !== undefined ? value : (defaultValue as T),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setInternalState(value);
|
||||
}, [value]);
|
||||
|
||||
const setState = React.useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
setInternalState(next);
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
|
||||
function getStrictContext<T>(
|
||||
name?: string,
|
||||
): readonly [
|
||||
({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => React.JSX.Element,
|
||||
() => T,
|
||||
] {
|
||||
const Context = React.createContext<T | undefined>(undefined);
|
||||
|
||||
const Provider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
|
||||
const useSafeContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
if (ctx === undefined) {
|
||||
throw new Error(`useContext must be used within ${name ?? "a Provider"}`);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
return [Provider, useSafeContext] as const;
|
||||
}
|
||||
|
||||
export { getStrictContext };
|
||||
Reference in New Issue
Block a user