From b1b4f56b47ed37cb0576237e509d464966cdc22b Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 6 Nov 2025 10:14:26 +0700 Subject: [PATCH] feat: Add responsive Dialog/Drawer pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created responsive dialog pattern for better mobile UX: Components Added: 1. drawer.tsx - Vaul-based drawer component (bottom sheet) 2. responsive-dialog.tsx - Smart wrapper that switches based on screen size 3. use-media-query.ts - Hook to detect screen size Pattern: - Desktop (≥768px): Use Dialog (modal overlay) - Mobile (<768px): Use Drawer (bottom sheet) - Provides consistent API for both Usage Example: Save} > Benefits: - Better mobile UX with native-feeling bottom sheet - Easier to reach buttons on mobile - Consistent desktop experience - Single component API Dependencies: - npm install vaul (drawer library) - @radix-ui/react-dialog (already installed) Next Steps: - Convert payment gateway modal to use ResponsiveDialog - Use AlertDialog for confirmations - Apply pattern to other modals in project Note: Payment gateway modal needs custom implementation due to complex layout (scrollable body + sticky footer) --- admin-spa/package-lock.json | 14 +++ admin-spa/package.json | 1 + admin-spa/src/components/ui/drawer.tsx | 116 ++++++++++++++++++ .../src/components/ui/responsive-dialog.tsx | 72 +++++++++++ admin-spa/src/hooks/use-media-query.ts | 19 +++ 5 files changed, 222 insertions(+) create mode 100644 admin-spa/src/components/ui/drawer.tsx create mode 100644 admin-spa/src/components/ui/responsive-dialog.tsx create mode 100644 admin-spa/src/hooks/use-media-query.ts diff --git a/admin-spa/package-lock.json b/admin-spa/package-lock.json index df989c8..cc5e675 100644 --- a/admin-spa/package-lock.json +++ b/admin-spa/package-lock.json @@ -35,6 +35,7 @@ "recharts": "^3.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "vaul": "^1.1.2", "zustand": "^5.0.8" }, "devDependencies": { @@ -7947,6 +7948,19 @@ "dev": true, "license": "MIT" }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/admin-spa/package.json b/admin-spa/package.json index 6640648..de92d7a 100644 --- a/admin-spa/package.json +++ b/admin-spa/package.json @@ -37,6 +37,7 @@ "recharts": "^3.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "vaul": "^1.1.2", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/admin-spa/src/components/ui/drawer.tsx b/admin-spa/src/components/ui/drawer.tsx new file mode 100644 index 0000000..c17b0cc --- /dev/null +++ b/admin-spa/src/components/ui/drawer.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/admin-spa/src/components/ui/responsive-dialog.tsx b/admin-spa/src/components/ui/responsive-dialog.tsx new file mode 100644 index 0000000..d9be0e4 --- /dev/null +++ b/admin-spa/src/components/ui/responsive-dialog.tsx @@ -0,0 +1,72 @@ +import * as React from "react" +import { useMediaQuery } from "@/hooks/use-media-query" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" + +interface ResponsiveDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + children: React.ReactNode + title?: string + description?: string + footer?: React.ReactNode + className?: string +} + +export function ResponsiveDialog({ + open, + onOpenChange, + children, + title, + description, + footer, + className, +}: ResponsiveDialogProps) { + const isDesktop = useMediaQuery("(min-width: 768px)") + + if (isDesktop) { + return ( + + + {(title || description) && ( + + {title && {title}} + {description && {description}} + + )} + {children} + {footer} + + + ) + } + + return ( + + + {(title || description) && ( + + {title && {title}} + {description && {description}} + + )} +
{children}
+ {footer && {footer}} +
+
+ ) +} diff --git a/admin-spa/src/hooks/use-media-query.ts b/admin-spa/src/hooks/use-media-query.ts new file mode 100644 index 0000000..95e552c --- /dev/null +++ b/admin-spa/src/hooks/use-media-query.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +export function useMediaQuery(query: string) { + const [value, setValue] = React.useState(false) + + React.useEffect(() => { + function onChange(event: MediaQueryListEvent) { + setValue(event.matches) + } + + const result = matchMedia(query) + result.addEventListener("change", onChange) + setValue(result.matches) + + return () => result.removeEventListener("change", onChange) + }, [query]) + + return value +}