# Prompt Dialog
*URL: https://www.grettech.com/components/prompt-dialog*

A dialog that asks for a single value, with async submit and inline errors.

- Async submit with a loading state
- Shows the error and stays open if onConfirm throws
- Use any element as the trigger
- Three size variants

## Install with the shadcn CLI
```bash
npx shadcn@latest add https://www.grettech.com/api/registry/prompt-dialog
```
With its demo: `npx shadcn@latest add https://www.grettech.com/api/registry/prompt-dialog-demo`

**Dependencies:** npm: clsx, tailwind-merge, lucide-react, class-variance-authority; shadcn/ui: button, input, dialog

## Manual installation
**Step 1: Install dependencies**
Install the packages this component uses and the shadcn/ui components it builds on. It also needs the `cn` helper in `lib/utils.ts`, which `npx shadcn@latest init` adds.

```bash
npm install clsx tailwind-merge lucide-react class-variance-authority
npx shadcn@latest add button input dialog
```

**Step 2: Copy the source code**
Add these files to your project components directory.

File: `components/ui/prompt-dialog.tsx`
```tsx
"use client"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Loader2 } from "lucide-react"
import * as React from "react"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"

const promptDialogVariants = cva("sm:max-w-[425px]", {
  variants: {
    size: {
      default: "",
      sm: "sm:max-w-[300px]",
      lg: "sm:max-w-[600px]",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

type TriggerProps = {
  onClick?: React.MouseEventHandler<HTMLElement>
  "aria-haspopup"?: React.AriaAttributes["aria-haspopup"]
  "aria-expanded"?: boolean
}

/**
 * Opens the dialog from whatever trigger element is passed in. Done by hand
 * instead of `<DialogTrigger asChild>` so it works with both the Radix and
 * the Base UI versions of shadcn/ui's Dialog.
 */
function renderTrigger(
  trigger: React.ReactElement<TriggerProps>,
  open: boolean,
  onOpen: () => void
) {
  return React.cloneElement(trigger, {
    onClick: (e: React.MouseEvent<HTMLElement>) => {
      trigger.props.onClick?.(e)
      if (!e.defaultPrevented) onOpen()
    },
    "aria-haspopup": "dialog",
    "aria-expanded": open,
  })
}

export interface PromptDialogProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof promptDialogVariants> {
  title?: string
  description?: string
  placeholder?: string
  submitText?: string
  onConfirm?: (value: string) => void | Promise<void>
  children?: React.ReactNode
}

export const PromptDialog = React.forwardRef<HTMLDivElement, PromptDialogProps>(
  (
    {
      title = "Enter Value",
      description = "Please provide the requested information below.",
      placeholder = "Enter value...",
      submitText = "Submit",
      onConfirm = () => {},
      children = <Button variant="outline">Open Prompt</Button>,
      size,
      className,
      ...props
    },
    ref
  ) => {
    const [open, setOpen] = useState(false)
    const [value, setValue] = useState("")
    const [pending, setPending] = useState(false)
    const [error, setError] = useState<string | null>(null)
    const inputId = React.useId()

    const handleOpenChange = (next: boolean) => {
      if (pending) return // don't close mid-submit
      setOpen(next)
      if (!next) {
        setValue("")
        setError(null)
      }
    }

    const handleSubmit = async (e: React.FormEvent) => {
      e.preventDefault()
      if (!value.trim() || pending) return
      setPending(true)
      setError(null)
      try {
        await onConfirm(value.trim())
        setOpen(false)
        setValue("")
      } catch (err) {
        // Keep the dialog open so the value can be fixed and retried
        setError(err instanceof Error ? err.message : "Something went wrong.")
      } finally {
        setPending(false)
      }
    }

    return (
      <Dialog open={open} onOpenChange={handleOpenChange}>
        {renderTrigger(
          React.isValidElement<TriggerProps>(children) ? (
            children
          ) : (
            <Button variant="outline">{children}</Button>
          ),
          open,
          () => setOpen(true)
        )}
        <DialogContent
          ref={ref}
          className={cn(promptDialogVariants({ size, className }))}
          {...props}
        >
          <DialogHeader>
            <DialogTitle>{title}</DialogTitle>
            {description && (
              <DialogDescription>{description}</DialogDescription>
            )}
          </DialogHeader>
          <form onSubmit={handleSubmit} className="space-y-4">
            <div className="space-y-2">
              <Input
                id={inputId}
                value={value}
                onChange={(e) => setValue(e.target.value)}
                placeholder={placeholder}
                aria-label={title}
                aria-invalid={error ? true : undefined}
                aria-describedby={error ? `${inputId}-error` : undefined}
                disabled={pending}
                autoFocus
              />
              {error && (
                <p
                  id={`${inputId}-error`}
                  role="alert"
                  className="text-sm text-destructive"
                >
                  {error}
                </p>
              )}
            </div>
            <DialogFooter>
              <Button
                type="button"
                variant="outline"
                onClick={() => handleOpenChange(false)}
                disabled={pending}
              >
                Cancel
              </Button>
              <Button type="submit" disabled={!value.trim() || pending}>
                {pending && <Loader2 className="animate-spin" />}
                {submitText}
              </Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </Dialog>
    )
  }
)
PromptDialog.displayName = "PromptDialog"

```

**Step 3: Framework Adjustments (Optional)**
If you are manually copying this code into a Vite or Remix project (without using the CLI), make sure to remove the `"use client";` directive from the very top of the file.

## Props
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `title` | `string` | `"Enter Value"` | No | Dialog title, also used as the input's accessible label. |
| `description` | `string` | `"Please provide the requested information below."` | No | Text under the title. Pass an empty string to hide it. |
| `placeholder` | `string` | `"Enter value..."` | No | Placeholder of the input. |
| `submitText` | `string` | `"Submit"` | No | Label of the submit button. |
| `onConfirm` | `(value: string) => void \| Promise<void>` | `() => {}` | No | Called with the trimmed value on submit. While a returned promise is pending the dialog shows a loading state; if it throws, the error message is shown and the dialog stays open. |
| `children` | `React.ReactNode` | `<Button variant="outline">Open Prompt</Button>` | No | Trigger element. It receives onClick, so pass a button or a component that forwards onClick. Plain text is wrapped in an outline Button. |
| `size` | `"default" \| "sm" \| "lg"` | `"default"` | No | Maximum width of the dialog. |
| `className` | `string` | - | No | Extra classes for the dialog content. |
| `...props` | `React.HTMLAttributes<HTMLDivElement>` | - | No | Any other attributes (id, style, aria-*, etc.), passed to the dialog content. |

## Usage
```tsx
"use client"

import { Button } from "@/components/ui/button"
import { PromptDialog } from "@/components/ui/prompt-dialog"

export default function PromptDialogDemo() {
  return (
    <PromptDialog
      title="Create New Project"
      description="Enter a name for your new project to get started."
      placeholder="Project name..."
      submitText="Create"
      onConfirm={(val) => console.log("Created:", val)}
    >
      <Button>New Project</Button>
    </PromptDialog>
  )
}

```
