From c736eb9195713b204379c7023c90380b7fe2366a Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:50:57 +0400 Subject: [PATCH] refactor: animate dialog --- components.json | 5 +- src/components/ui/dialog.tsx | 177 ++++++++++++++++++++++------- src/hooks/use-controlled-state.tsx | 33 ++++++ src/lib/get-strict-context.tsx | 36 ++++++ 4 files changed, 211 insertions(+), 40 deletions(-) create mode 100644 src/hooks/use-controlled-state.tsx create mode 100644 src/lib/get-strict-context.tsx diff --git a/components.json b/components.json index 7bae856..ef53d64 100644 --- a/components.json +++ b/components.json @@ -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" + } } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index d1e8394..f7bcd35 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -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) { - return ; +type DialogContextType = { + isOpen: boolean; + setIsOpen: DialogProps["onOpenChange"]; +}; + +const [DialogProvider, useDialog] = + getStrictContext("DialogContext"); + +type DialogProps = React.ComponentProps; + +function Dialog(props: DialogProps) { + const [isOpen, setIsOpen] = useControlledState({ + value: props?.open, + defaultValue: props?.defaultOpen, + onChange: props?.onOpenChange, + }); + + return ( + + + + ); } -function DialogTrigger({ - ...props -}: React.ComponentProps) { +type DialogTriggerProps = React.ComponentProps; + +function DialogTrigger(props: DialogTriggerProps) { return ; } -function DialogPortal({ - ...props -}: React.ComponentProps) { - return ; +type DialogPortalProps = Omit< + React.ComponentProps, + "forceMount" +>; + +function DialogPortal(props: DialogPortalProps) { + const { isOpen } = useDialog(); + + return ( + + {isOpen && ( + + )} + + ); } -function DialogClose({ - ...props -}: React.ComponentProps) { - return ; -} +type DialogOverlayProps = Omit< + React.ComponentProps, + "forceMount" | "asChild" +> & + HTMLMotionProps<"div">; function DialogOverlay({ className, + transition = { duration: 0.2, ease: "easeInOut" }, ...props -}: React.ComponentProps) { +}: DialogOverlayProps) { return ( - - + + + + ); } +type DialogFlipDirection = "top" | "bottom" | "left" | "right"; + +type DialogContentProps = Omit< + React.ComponentProps, + "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) { +}: DialogContentProps) { + const initialRotation = + from === "bottom" || from === "left" ? "20deg" : "-20deg"; + const isVertical = from === "top" || from === "bottom"; + const rotateAxis = isVertical ? "rotateX" : "rotateY"; + return ( { const target = event.target as HTMLElement | null; if (target?.closest('[data-window-drag-area="true"]')) { event.preventDefault(); } + onInteractOutside?.(event); }} - {...props} > - {children} - - - Close - + + {children} + + + Close + + ); } +type DialogCloseProps = React.ComponentProps; + +function DialogClose(props: DialogCloseProps) { + return ; +} + function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
{ + value?: T; + defaultValue?: T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useControlledState( + props: CommonControlledStateProps & { + onChange?: (value: T, ...args: Rest) => void; + }, +): readonly [T, (next: T, ...args: Rest) => void] { + const { value, defaultValue, onChange } = props; + + const [state, setInternalState] = React.useState( + 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; +} diff --git a/src/lib/get-strict-context.tsx b/src/lib/get-strict-context.tsx new file mode 100644 index 0000000..7bbe8d1 --- /dev/null +++ b/src/lib/get-strict-context.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +function getStrictContext( + name?: string, +): readonly [ + ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => React.JSX.Element, + () => T, +] { + const Context = React.createContext(undefined); + + const Provider = ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => {children}; + + 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 };