Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ResponsiveModal for improved mobile experience #57

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
14 changes: 13 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vaul": "^1.1.2",
"vite": "^6.0"
},
"optionalDependencies": {
Expand Down
58 changes: 34 additions & 24 deletions resources/js/components/delete-user.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import { FormEventHandler, useCallback, useRef, useState } from 'react';

import InputError from '@/components/input-error';
import { ResponsiveModal } from '@/components/responsive-modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

import HeadingSmall from '@/components/heading-small';

import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';

export default function DeleteUser() {
const passwordInput = useRef<HTMLInputElement>(null);

const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm<Required<{ password: string }>>({ password: '' });
const [isOpen, setIsOpen] = useState<boolean>(false);

const handleModalClose = useCallback(() => {
setIsOpen(false);
clearErrors();
reset();
}, [clearErrors, reset]);

const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) handleModalClose();
else setIsOpen(true);
},
[handleModalClose],
);

const deleteUser: FormEventHandler = (e) => {
e.preventDefault();

destroy(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onSuccess: () => handleModalClose(),
onError: () => passwordInput.current?.focus(),
onFinish: () => reset(),
});
};

const closeModal = () => {
clearErrors();
reset();
};

return (
<div className="space-y-6">
<HeadingSmall title="Delete account" description="Delete your account and all of its resources" />
Expand All @@ -39,13 +50,14 @@ export default function DeleteUser() {
<p className="text-sm">Please proceed with caution, this cannot be undone.</p>
</div>

<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</DialogTrigger>
<DialogContent>
<Button variant="destructive" onClick={() => setIsOpen(true)}>
Delete account
</Button>

<ResponsiveModal open={isOpen} onOpenChange={handleOpenChange}>
<div className="p-4 sm:p-6">
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
<DialogDescription className="mt-2 mb-4">
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password
to confirm you would like to permanently delete your account.
</DialogDescription>
Expand All @@ -70,19 +82,17 @@ export default function DeleteUser() {
</div>

<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary" onClick={closeModal}>
Cancel
</Button>
</DialogClose>
<Button variant="secondary" onClick={handleModalClose} type="button">
Cancel
</Button>

<Button variant="destructive" disabled={processing} asChild>
<button type="submit">Delete account</button>
<Button variant="destructive" disabled={processing} type="submit">
Delete account
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</ResponsiveModal>
</div>
</div>
);
Expand Down
31 changes: 31 additions & 0 deletions resources/js/components/responsive-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useIsMobile } from '@/hooks/use-mobile';

import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Drawer, DrawerContent } from '@/components/ui/drawer';
import React from 'react';

interface ResponsiveModalProps {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}

export const ResponsiveModal = ({ children, onOpenChange, open }: ResponsiveModalProps) => {
const isMobile = useIsMobile();

if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<div className="hide-scrollbar max-h-[85vh] overflow-y-auto">{children}</div>
</DrawerContent>
</Drawer>
);
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="hide-scrollbar max-h-[85vh] w-full overflow-y-auto border-none sm:max-w-lg">{children}</DialogContent>
</Dialog>
);
};
130 changes: 130 additions & 0 deletions resources/js/components/ui/drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"

import { cn } from "@/lib/utils"

function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}

function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}

function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}

function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}

function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-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-50 bg-black/80",
className
)}
{...props}
/>
)
}

function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}

function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}

function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}

function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}

export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}