Skip to content

Instantly share code, notes, and snippets.

@alexanderson1993
Created April 2, 2023 19:07
Show Gist options
  • Save alexanderson1993/623bf0324f740ec4e33f33b59487dda7 to your computer and use it in GitHub Desktop.
Save alexanderson1993/623bf0324f740ec4e33f33b59487dda7 to your computer and use it in GitHub Desktop.
A multi-purpose alert/confirm/prompt replacement built with shadcn/ui AlertDialog components.
"use client";
import * as React from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
} from "@/components/ui/AlertDialog";
export const AlertDialogContext = React.createContext<
<T extends AlertAction>(
params: T
) => Promise<T["type"] extends "alert" | "confirm" ? boolean : null | string>
>(() => null!);
export type AlertAction =
| { type: "alert"; title: string; body?: string; cancelButton?: string }
| {
type: "confirm";
title: string;
body?: string;
cancelButton?: string;
actionButton?: string;
}
| {
type: "prompt";
title: string;
body?: string;
cancelButton?: string;
actionButton?: string;
defaultValue?: string;
inputProps?: React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
}
| { type: "close" };
interface AlertDialogState {
open: boolean;
title: string;
body: string;
type: "alert" | "confirm" | "prompt";
cancelButton: string;
actionButton: string;
defaultValue?: string;
inputProps?: React.PropsWithoutRef<
React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>
>;
}
export function alertDialogReducer(
state: AlertDialogState,
action: AlertAction
): AlertDialogState {
switch (action.type) {
case "close":
return { ...state, open: false };
case "alert":
case "confirm":
case "prompt":
return {
...state,
open: true,
...action,
cancelButton:
action.cancelButton || (action.type === "alert" ? "Okay" : "Cancel"),
actionButton:
("actionButton" in action && action.actionButton) || "Okay",
};
default:
return state;
}
}
export function AlertDialogProvider({
children,
}: {
children: React.ReactNode;
}) {
const [state, dispatch] = React.useReducer(alertDialogReducer, {
open: false,
title: "",
body: "",
type: "alert",
cancelButton: "Cancel",
actionButton: "Okay",
});
const resolveRef = React.useRef<(tf: any) => void>();
function close() {
dispatch({ type: "close" });
resolveRef.current?.(false);
}
function confirm(value?: string) {
dispatch({ type: "close" });
resolveRef.current?.(value ?? true);
}
const dialog = React.useCallback(async <T extends AlertAction>(params: T) => {
dispatch(params);
return new Promise<
T["type"] extends "alert" | "confirm" ? boolean : null | string
>((resolve) => {
resolveRef.current = resolve;
});
}, []);
return (
<AlertDialogContext.Provider value={dialog}>
{children}
<AlertDialog
open={state.open}
onOpenChange={(open) => {
if (!open) close();
return;
}}
>
<AlertDialogContent asChild>
<form
onSubmit={(event) => {
event.preventDefault();
confirm(event.currentTarget.prompt?.value);
}}
>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
{state.body ? (
<AlertDialogDescription>{state.body}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
{state.type === "prompt" && (
<Input
name="prompt"
defaultValue={state.defaultValue}
{...state.inputProps}
/>
)}
<AlertDialogFooter>
<Button type="button" onClick={close}>
{state.cancelButton}
</Button>
{state.type === "alert" ? null : (
<Button type="submit">{state.actionButton}</Button>
)}
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
</AlertDialogContext.Provider>
);
}
type Params<T extends "alert" | "confirm" | "prompt"> =
| Omit<Extract<AlertAction, { type: T }>, "type">
| string;
export function useConfirm() {
const dialog = React.useContext(AlertDialogContext);
return React.useCallback(
(params: Params<"confirm">) => {
return dialog({
...(typeof params === "string" ? { title: params } : params),
type: "confirm",
});
},
[dialog]
);
}
export function usePrompt() {
const dialog = React.useContext(AlertDialogContext);
return (params: Params<"prompt">) =>
dialog({
...(typeof params === "string" ? { title: params } : params),
type: "prompt",
});
}
export function useAlert() {
const dialog = React.useContext(AlertDialogContext);
return (params: Params<"alert">) =>
dialog({
...(typeof params === "string" ? { title: params } : params),
type: "alert",
});
}
import App from './app'
import { hydrateRoot, createRoot } from "react-dom/client";
import App from "./App";
import AlertDialogProvider from "@/components/ui/AlertDialogProvider";
createRoot(document.getElementById("root")).render(
<AlertDialogProvider>{children}</AlertDialogProvider>
);
import {
useAlert,
useConfirm,
usePrompt,
} from "@/components/ui/AlertDialogProvider";
import { Button } from "@/components/ui/Button";
export default function Test() {
const alert = useAlert();
const confirm = useConfirm();
const prompt = usePrompt();
return (
<>
<Button
onClick={async () => {
console.log(
await alert({
title: "Test",
body: "Just wanted to say you're cool.",
cancelButton: "Heyo!",
}) // false
);
}}
type="button"
>
Test Alert
</Button>
<Button
onClick={async () => {
console.log(
await confirm({
title: "Test",
body: "Are you sure you want to do that?",
cancelButton: "On second thought...",
}) // true | false
);
}}
type="button"
>
Test Confirm
</Button>
<Button
onClick={async () => {
console.log(
await prompt({
title: "Test",
body: "Hey there! This is a test.",
defaultValue: "Something something" + Math.random().toString(),
}) // string | false
);
}}
type="button"
>
Test Prompt
</Button>
</>
);
}
@monsieurpigeon
Copy link

very good, thanks

@shinusuresh
Copy link

That is a very nice approach. Thank you

@hitecSmartHome
Copy link

When you are triggering it from a shadcn context menu, you must specify modal={false} on the context menu. Otherwise it will freeze the dom

@hitecSmartHome
Copy link

I have made some changes so you can specify the cancelButtonVariant and the actionButtonVariant when calling any of them.
I have also moved the default action and cancel text to a central location at the top.
This also includes the fix from @Nishchit14
The default variants are "default"

"use client";

import * as React from "react";
import {
  AlertDialog,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogFooter,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

export const AlertDialogContext = React.createContext<(
  params: AlertAction
) => Promise<AlertAction["type"] extends "alert" | "confirm" ? boolean : null | string>>(() => null!);

type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";

const defaultCancelButtonText:string = "Cancel";
const defaultActionButtonText:string = "Okay";

export type AlertAction =
  | { 
      type: "alert"; 
      title: string; 
      body?: string; 
      cancelButton?: string;
      cancelButtonVariant?: ButtonVariant;
    }
  | {
      type: "confirm";
      title: string;
      body?: string;
      cancelButton?: string;
      actionButton?: string;
      cancelButtonVariant?: ButtonVariant;
      actionButtonVariant?: ButtonVariant;
    }
  | {
      type: "prompt";
      title: string;
      body?: string;
      cancelButton?: string;
      actionButton?: string;
      defaultValue?: string;
      cancelButtonVariant?: ButtonVariant;
      actionButtonVariant?: ButtonVariant;
      inputProps?: React.DetailedHTMLProps<
        React.InputHTMLAttributes<HTMLInputElement>,
        HTMLInputElement
      >;
    }
  | { type: "close" };

interface AlertDialogState {
  open: boolean;
  title: string;
  body: string;
  type: "alert" | "confirm" | "prompt";
  cancelButton: string;
  actionButton: string;
  cancelButtonVariant: ButtonVariant;
  actionButtonVariant: ButtonVariant;
  defaultValue?: string;
  inputProps?: React.PropsWithoutRef<
    React.DetailedHTMLProps<
      React.InputHTMLAttributes<HTMLInputElement>,
      HTMLInputElement
    >
  >;
}

export function alertDialogReducer(
  state: AlertDialogState,
  action: AlertAction
): AlertDialogState {
  switch (action.type) {
    case "close":
      return { ...state, open: false };
    case "alert":
    case "confirm":
    case "prompt":
      return {
        ...state,
        open: true,
        ...action,
        cancelButton: action.cancelButton || (action.type === "alert" ? defaultActionButtonText : defaultCancelButtonText),
        actionButton: ("actionButton" in action && action.actionButton) || defaultActionButtonText,
        cancelButtonVariant: action.cancelButtonVariant || "default",
        actionButtonVariant: ("actionButtonVariant" in action && action.actionButtonVariant) || "default",
      };
    default:
      return state;
  }
}

export function AlertDialogProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [state, dispatch] = React.useReducer(alertDialogReducer, {
    open: false,
    title: "",
    body: "",
    type: "alert",
    cancelButton: defaultCancelButtonText,
    actionButton: defaultActionButtonText,
    cancelButtonVariant: "default",
    actionButtonVariant: "default",
  });

  const resolveRef = React.useRef<(tf: any) => void>();

  function close() {
    dispatch({ type: "close" });
    resolveRef.current?.(false);
  }

  function confirm(value?: string) {
    dispatch({ type: "close" });
    resolveRef.current?.(value ?? true);
  }

  const dialog = React.useCallback(async <T extends AlertAction>(params: T) => {
    dispatch(params);

    return new Promise<
      T["type"] extends "alert" | "confirm" ? boolean : null | string
    >((resolve) => {
      resolveRef.current = resolve;
    });
  }, []);

  return (
    <AlertDialogContext.Provider value={dialog}>
      {children}
      <AlertDialog
        open={state.open}
        onOpenChange={(open) => {
          if (!open) close();
          return;
        }}
      >
        <AlertDialogContent asChild>
          <form
            onSubmit={(event) => {
              event.preventDefault();
              confirm(event.currentTarget.prompt?.value);
            }}
          >
            <AlertDialogHeader>
              <AlertDialogTitle>{state.title}</AlertDialogTitle>
              {state.body ? (
                <AlertDialogDescription>{state.body}</AlertDialogDescription>
              ) : null}
            </AlertDialogHeader>
            {state.type === "prompt" && (
              <Input
                name="prompt"
                defaultValue={state.defaultValue}
                {...state.inputProps}
              />
            )}
            <AlertDialogFooter>
              <Button type="button" onClick={close} variant={state.cancelButtonVariant}>
                {state.cancelButton}
              </Button>
              {state.type === "alert" ? null : (
                <Button type="submit" variant={state.actionButtonVariant}>{state.actionButton}</Button>
              )}
            </AlertDialogFooter>
          </form>
        </AlertDialogContent>
      </AlertDialog>
    </AlertDialogContext.Provider>
  );
}

type Params<T extends "alert" | "confirm" | "prompt"> =
  | Omit<Extract<AlertAction, { type: T }>, "type">
  | string;

export function useConfirm() {
  const dialog = React.useContext(AlertDialogContext);

  return React.useCallback(
    (params: Params<"confirm">) => {
      return dialog({
        ...(typeof params === "string" ? { title: params } : params),
        type: "confirm",
      });
    },
    [dialog]
  );
}

export function usePrompt() {
  const dialog = React.useContext(AlertDialogContext);

  return (params: Params<"prompt">) =>
    dialog({
      ...(typeof params === "string" ? { title: params } : params),
      type: "prompt",
    });
}

export function useAlert() {
  const dialog = React.useContext(AlertDialogContext);
  return (params: Params<"alert">) =>
    dialog({
      ...(typeof params === "string" ? { title: params } : params),
      type: "alert",
    });
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment