shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
59.61k stars 3.3k forks source link

feat: function to trigger open/close dialog #386

Closed madatbay closed 1 year ago

madatbay commented 1 year ago

Tried to search for it but didn't find a way to close the dialog with a method or function. This is useful after processing data like - making a fetch request, mutation, or something that is awaitable, or can change dialog state from outside In the example below, let's assume when the user clicks to "save changes", I want to handle some submit function as a result close dialog from that function.

... await updateData(data).then(res=>closeDialog()).catch(err=>setError(error))

Currently, the way I see is used after exporting Close from radix primitive. But this is a JSX element, cant be used as function to change dialog state

<DialogClose>Close<<DialogClose/>
Screenshot 2023-05-20 at 09 37 14

There is 2nd way to close Dialog by creating a state and binding it to open={open} prop in the Dialog component. We can open or close Dialog by changing that state. But when do so, the "X" button and clicking outside of the dialog to close it stops working

Why we need that feature?

I think this will solve the issue we have in Note section which asserts that we have to encase the trigger button to Dialog itself. I take this as a big problem because if I have a Dialog component, it's not possible to trigger it from multiple places. If I need to have that dialog in multiple places I need to copy/paste the same dialog code which is a duplication issue.

Sample use case: I have "Newsletter dialog" and I need to trigger this dialog with the button in the navbar, footer, or inside the main content. Or if the user stays for 5 mins on the website, I want to trigger that newsletter dialog

chungweileong94 commented 1 year ago

If you want control the opening state by yourself, the open prop is the way to go, however, you also need to set the onOpenChange, so that the close button wiil able to trigger the onOpenChange event and change the state that you created.

Example:

const [open, setOpen] = useState(false);

return <Dialog open={open} onOpenChange={setOpen} />

Reference: https://www.radix-ui.com/docs/primitives/components/dialog#root

OsamaQureshi147 commented 8 months ago

I am using Next13.4 app router in which all the components and pages are RSC and SSR respectively by default. I am rendering the Dialog in the custom component lets say AddToCartDialog.tsx in server component and want to close it programmatically something like handleCloseDialog(). I can't pass the prop state and setter function as React hooks do not work inside React Server Components. Is there clean way to do it? Or should I add a redundant client component wrapper for the dialogs?

chungweileong94 commented 8 months ago

@OsamaQureshi147 It seems like you need JS on client side to me tho, this is the time where you have to use client component instead.

wobsoriano commented 8 months ago

@OsamaQureshi147 For a basic requirement, you can probably use the searchParams and do a condition like:

/some-page?productId=69&modal=true

export default function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  const { productId, modal } = searchParams

  return (
    <div>
      <h1>Some Page</h1>
      <YourDialog open={modal === "true"} productId={productId} />
    </div>
  )
}

Then use <Link href="/some-page" /> to dismiss the modal.

Other than that, client component.

Demo:

https://github.com/shadcn-ui/ui/assets/13049130/d107ec0a-e6a4-45eb-a1be-839b74a5efc4

msbeeman commented 8 months ago

@wobsoriano The problem is you can't use this approach if you're trying to open a modal from any components that generally live in an applications root layout such as a Navbar since search params are not available in that context, but I think it's good approach for subpages.

Source: https://nextjs.org/docs/app/api-reference/file-conventions/layout#layouts-do-not-receive-searchparams

wobsoriano commented 8 months ago

Right @msbeeman, I guess you can still use useSearchParams in the layout.

@OsamaQureshi147 this might be worth checking as well - Intercepting Routes

Example - https://nextjs-app-route-interception.vercel.app/

Steveb599 commented 7 months ago

@OsamaQureshi147 For a basic requirement, you can probably use the searchParams and do a condition like:

/some-page?productId=69&modal=true

export default function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  const { productId, modal } = searchParams

  return (
    <div>
      <h1>Some Page</h1>
      <YourDialog open={modal === "true"} productId={productId} />
    </div>
  )
}

Then use <Link href="/some-page" /> to dismiss the modal.

Other than that, client component.

Demo:

Screen.Recording.2023-09-29.at.4.14.55.PM.mov

How can I make a required fields in the modal?

zacBkh commented 5 months ago

If you want control the opening state by yourself, the open prop is the way to go, however, you also need to set the onOpenChange, so that the close button wiil able to trigger the onOpenChange event and change the state that you created.

Example:

const [open, setOpen] = useState(false);

return <Dialog open={open} onOpenChange={setOpen} />

Reference: https://www.radix-ui.com/docs/primitives/components/dialog#root

thanks, not so clear in the doc!

quyettranvu commented 4 months ago

If you want control the opening state by yourself, the open prop is the way to go, however, you also need to set the onOpenChange, so that the close button wiil able to trigger the onOpenChange event and change the state that you created.

Example:

const [open, setOpen] = useState(false);

return <Dialog open={open} onOpenChange={setOpen} />

Reference: https://www.radix-ui.com/docs/primitives/components/dialog#root

Thank you very much, after searching and trying for couples of hours, I finally could reach it!

wobsoriano commented 4 months ago

Might help some - adapting shadcn/ui dialog for parallel and intercepting routes

chungweileong94 commented 4 months ago

I'm actually trying a different approach (https://github.com/chungweileong94/nextjs-parallel-route-dialog) by using the NextJS parallel route to control the dialog open state. The thing that I focus in my code example is to preserve the dialog closing animation.

sobitp59 commented 4 months ago

This is how we can do, hope it helps

"use client";
// Imports here

export function AddProductModal() {
  const router = useRouter();
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button
          className="bg-red-50 text-red-600"
        >
          Add Product
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
         // ADD PRODUCT FORM HERE
        <DialogFooter>
          <Button
            onClick={() => {
              addProduct(data).then(() => setOpen(false));
              router.refresh();
            }}
            className="bg-red-50 text-red-600 hover:bg-red-100"
            type="submit"
          >
            Add
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
jsaunders92 commented 4 months ago

I'm actually trying a different approach (https://github.com/chungweileong94/nextjs-parallel-route-dialog) by using the NextJS parallel route to control the dialog open state. The thing that I focus in my code example is to preserve the dialog closing animation.

I was doing it like you, but slightly differently. I changed my code to match your approach though and I think it's more manageable/closer to what I want. The thing is, the closing animation does work, but sometimes. I wonder if there is some sort of race condition that is causing it to sometimes not work?

chungweileong94 commented 4 months ago

I wonder if there is some sort of race condition that is causing it to sometimes not work?

Well, it works fine to fit my use-case. But I don't expect the close animation to work in cases like hard navigation. I do notice some limitation to the close animation, where the content of the dialog actually close instantly as soon as it triggers, but the animation is fast enough that you wouldn't notice it😬

You could try to use the browser history API with NextJS 14.1, to see if that helps, https://nextjs.org/blog/next-14-1#windowhistorypushstate-and-windowhistoryreplacestate, haven't try it myself personally.

But I will say if you really want to get the animation right, move things to client-side is the way to go.

jsaunders92 commented 4 months ago

You could try to use the browser history API with NextJS 14.1, to see if that helps, https://nextjs.org/blog/next-14-1#windowhistorypushstate-and-windowhistoryreplacestate, haven't try it myself personally.

I'll give this a go, I'll report back! I've already been messing around with the router.

But I will say if you really want to get the animation right, move things to client-side is the way to go. I think I much rather not. To the point, I'm willing to sacrifice the exit animation. Although I personally think it's easily doable. I mean it's like 90% there anyway already. I just need to work out what causes it to sometimes not work.

Either way, my code and then the changes based on your reorganisation of the folder structure based on your example has been really useful. So has Ariakit's Dialog with App Router documentation. I'll let you know if I get further with the exit animations.

nishaaanth2 commented 3 months ago

If you want control the opening state by yourself, the open prop is the way to go, however, you also need to set the onOpenChange, so that the close button wiil able to trigger the onOpenChange event and change the state that you created.

Example:

const [open, setOpen] = useState(false);

return <Dialog open={open} onOpenChange={setOpen} />

Reference: https://www.radix-ui.com/docs/primitives/components/dialog#root

bruh, i suck i was try digging in wrong "dialogtrigger" wasted 3 days because of this. open dialog

harsh7800 commented 3 months ago

`{!form.formState.isValid ? (

        ) : (
          <DialogClose asChild>
            <Button type="submit" className="w-full">
              Save changes
            </Button>
          </DialogClose>
        )}`

this worked for me :)

saifurrahmantanvir commented 2 months ago
const [open, setOpen] = React.useState(false)

  const { reset } = form;
  const { isSubmitting, isSubmitSuccessful } = form.formState;

  React.useEffect(() => {
    isSubmitSuccessful && reset()

  }, [isSubmitSuccessful, reset])
<Dialog open={open} onOpenChange={setOpen}>

Dialog closing and form reset (react-hook-form) working properly with this. Didn't add anything like value={field.value} in the <Select onValueChange={field.onChange} defaultValue={field.value}> tag or anything in <SelectValue placeholder="Select priority" /> this tag.

romhenri commented 2 months ago

Very useful!

lvbn commented 2 months ago

This is how we can do, hope it helps

"use client";
// Imports here

export function AddProductModal() {
  const router = useRouter();
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button
          className="bg-red-50 text-red-600"
        >
          Add Product
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
         // ADD PRODUCT FORM HERE
        <DialogFooter>
          <Button
            onClick={() => {
              addProduct(data).then(() => setOpen(false));
              router.refresh();
            }}
            className="bg-red-50 text-red-600 hover:bg-red-100"
            type="submit"
          >
            Add
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Does this only work on submit? I have been trying it in other parts of the code but it never works...

SehajBindra commented 1 month ago

This worked for me !!

"use client";
import React, { useEffect, useState } from "react";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/Components/ui/dialog";
import { Button } from "./ui/button";
import Image from "next/image";
import Search from "./Search";
import { usePathname } from "next/navigation";

function SearchDialog() {
  const [open, setOpen] = useState(false);
  const pathname = usePathname();
  useEffect(() => {
    setOpen(false);
  }, [pathname]);
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger className="z-10 cursor-pointer" asChild>
        <Image src="/search.svg" width={24} height={24} alt="search" />
      </DialogTrigger>
      <DialogContent className="rounded-xl">
        <DialogHeader>
          <DialogTitle>Share link</DialogTitle>
          <DialogDescription>
            Anyone who has this link will be able to view this.
            <DialogClose asChild>
              <Search />
            </DialogClose>
          </DialogDescription>
        </DialogHeader>
        <div className="flex items-center space-x-2"></div>
        <DialogFooter className="sm:justify-start">
          <DialogClose asChild>
            <Button type="button" variant="outline">
              Close
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export default SearchDialog;import React, { useEffect, useState } from "react";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/Components/ui/dialog";
import { Button } from "./ui/button";
import Image from "next/image";
import Search from "./Search";
import { usePathname } from "next/navigation";

function SearchDialog() {
  const [open, setOpen] = useState(false);
  const pathname = usePathname();
  useEffect(() => {
    setOpen(false);
  }, [pathname]);
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger className="z-10 cursor-pointer" asChild>
        <Image src="/search.svg" width={24} height={24} alt="search" />
      </DialogTrigger>
      <DialogContent className="rounded-xl">
        <DialogHeader>
          <DialogTitle>Share link</DialogTitle>
          <DialogDescription>
            Anyone who has this link will be able to view this.
            <DialogClose asChild>
              <Search />
            </DialogClose>
          </DialogDescription>
        </DialogHeader>
        <div className="flex items-center space-x-2"></div>
        <DialogFooter className="sm:justify-start">
          <DialogClose asChild>
            <Button type="button" variant="outline">
              Close
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export default SearchDialog;
pnavk commented 1 month ago

@OsamaQureshi147 For a basic requirement, you can probably use the searchParams and do a condition like:

/some-page?productId=69&modal=true

export default function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  const { productId, modal } = searchParams

  return (
    <div>
      <h1>Some Page</h1>
      <YourDialog open={modal === "true"} productId={productId} />
    </div>
  )
}

Then use <Link href="/some-page" /> to dismiss the modal.

Other than that, client component.

Demo:

Screen.Recording.2023-09-29.at.4.14.55.PM.mov

I had a slightly different requirement to be able to open a modal using the normal DialogTrigger but also from my navigation bar component which was in my root layout. I was able use this approach with some slight adjustments to get it to work. This preserves all the nice animations for opening and closing the modal while allowing the modal to be opened or closed from anywhere in the component tree.

Code is here in-case anyone is curious: https://github.com/pnavk/nextjs-rsc-modal-dialog-example

thebadking commented 5 days ago

this issue should be re-opened, I have tried the approach of having a state variable with the [open, setOpen], when pressing cancel we need to reset the state to the state previous to opening the dialog, this state is being shared across dialog instanciations somehow, even setting a key prop didn't help, the behaviour of the cancel button is not the same as the "X" close button defaulted by the Dialog component but still both actions are buggy


'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger
} from '@/components/ui/dialog';
import { Textarea } from '../ui/textarea';

type InputDialogConfig = {
  triggerElement?: React.ReactNode;
  title?: string;
  description?: string;
  deleteLabel?: string;
  inputValue: string;
  onSave: (value: string) => void;
  onRemove?: () => void;
};

const CreateInputDialog = (config: InputDialogConfig) => {
  const {
    triggerElement = <Button variant="outline">Edit</Button>,
    title = 'Editing',
    description = null,
    deleteLabel = null,
    inputValue = '',
    onSave = () => {},
    onRemove = () => {}
  } = config;

  const [input, setInput] = useState(inputValue);
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>{triggerElement}</DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          {description && <DialogDescription>{description}</DialogDescription>}
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              className="col-span-4 h-[200px]"
            />
          </div>
        </div>
        <DialogFooter>
          {deleteLabel && (
            <Button
              type="button"
              variant="destructive"
              className="absolute left-6"
              onClick={(e) => {
                e.stopPropagation();
                onRemove();
                setOpen(false);
              }}
            >
              {deleteLabel}
            </Button>
          )}

          <Button
            type="button"
            variant={'outline'}
            onClick={(e) => {
              e.stopPropagation();
              setInput(inputValue);
              setOpen(false);
            }}
          >
            Cancel
          </Button>

          <Button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onSave(input);
              setOpen(false);
            }}
          >
            Save
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

export default CreateInputDialog;

please use this as an example and instantiate it twice on a higher order component, pass it a function to save, no need to test the delete as this has no possible issues, and after setting the two vars to different values and switching from one to other instanciation you will see the value being brought from one instantiation to another.

https://www.loom.com/share/2989c845f661479e9d6e1d7bd1072437?sid=fb91d584-3929-4796-8ebb-725114b9748a

chungweileong94 commented 5 days ago

@thebadking It would be good if you could provide a small repro, it seems to me it's some state logic problem from upstream. By creating a small repro, you might somehow figure out what actually went wrong when the repro codebase is relatively small🙂

thebadking commented 4 days ago

@chungweileong94 will do, just finishing some refactoring that hopefully will fix it