Created
September 22, 2025 13:07
-
-
Save marcospgp/9ae67de36dcc212a76ec1755b3c97c72 to your computer and use it in GitHub Desktop.
BaseUI dialog manager implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Dialog as D } from "@base-ui-components/react/dialog"; | |
import { | |
type ComponentProps, | |
type ReactNode, | |
createContext, | |
useEffect, | |
useRef, | |
useState, | |
} from "react"; | |
import { Button } from "./Button"; | |
import { Field } from "./Field"; | |
import { Form } from "./Form"; | |
type DialogProps = { | |
title: string; | |
renderTrigger?: ComponentProps<typeof D.Trigger>["render"]; | |
render: (close: () => void) => ReactNode; | |
/** Used to modify or reset state after CSS transitions have completed. */ | |
onOpenChangeComplete?: (open: boolean) => void; | |
fitContent?: boolean; | |
}; | |
/** | |
* Internal dialog component that can optionally render without a trigger. | |
* When renderTrigger is not provided, the dialog opens immediately. | |
* This is used internally by imperative dialog functions. | |
*/ | |
function InternalDialog(props: Readonly<DialogProps>) { | |
const [isOpen, setIsOpen] = useState(!props.renderTrigger); | |
const [key, setKey] = useState(0); | |
return ( | |
<D.Root | |
key={key} | |
open={isOpen} | |
onOpenChange={setIsOpen} | |
onOpenChangeComplete={(open) => { | |
if (!open) setKey((prev) => prev + 1); // Force remount. | |
props.onOpenChangeComplete?.(open); | |
}} | |
> | |
{props.renderTrigger && <D.Trigger render={props.renderTrigger} />} | |
<D.Portal> | |
<div className="fixed inset-0 z-10 flex items-center justify-center"> | |
{/* Backdrop must not be covered so it remains clickable. */} | |
<D.Backdrop className="absolute inset-0 z-20 cursor-pointer bg-black opacity-50 transition data-[ending-style]:opacity-0 data-[starting-style]:opacity-0" /> | |
{/* Dialog */} | |
<D.Popup | |
className={`${!props.fitContent && "w-lg"} bg-bg px-md py-sm bevel relative z-30 flex max-w-[min(var(--container-lg),80vw)] flex-col transition data-[ending-style]:scale-90 data-[starting-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0`} | |
> | |
<D.Title render={(ps) => <h1 {...ps}>{props.title}</h1>} /> | |
{props.render(() => { | |
setIsOpen(false); | |
})} | |
</D.Popup> | |
</div> | |
</D.Portal> | |
</D.Root> | |
); | |
} | |
type PromptConfig = { | |
title: string; | |
description?: string; | |
label?: string; | |
placeholder?: string; | |
positiveAction?: string; | |
negativeAction?: string; | |
danger?: boolean; | |
onSubmit: (value: string) => void | Promise<void>; | |
onCancel?: () => void | Promise<void>; | |
}; | |
type ConfirmConfig = { | |
title: string; | |
description?: string; | |
positiveAction?: string; | |
negativeAction?: string; | |
danger?: boolean; | |
onConfirm: () => void | Promise<void>; | |
onCancel?: () => void | Promise<void>; | |
}; | |
type AlertConfig = { | |
title: string; | |
description?: string; | |
positiveAction?: string; | |
onClose: () => void | Promise<void>; | |
}; | |
type CustomDialogConfig = { | |
title: string; | |
children: ReactNode | ((close: () => void) => ReactNode); | |
onClose?: () => void | Promise<void>; | |
fitContent?: boolean; | |
}; | |
// Component-specific prop types (config + component handlers) | |
type PromptProps = PromptConfig & { | |
renderTrigger?: ComponentProps<typeof D.Trigger>["render"]; | |
}; | |
type DialogVariant = "prompt" | "confirm" | "alert"; | |
function InternalPromptDialog( | |
props: { | |
variant: DialogVariant; | |
onOpenChangeComplete?: (open: boolean) => void; | |
onCancel?: () => void; // Add cancel handler | |
} & PromptProps, | |
) { | |
const inputRef = useRef<HTMLInputElement>(null); | |
const isAlert = props.variant === "alert"; | |
const isPrompt = props.variant === "prompt"; | |
const positiveAction = props.positiveAction ?? (isAlert ? "Got it" : "Ok"); | |
const negativeAction = isAlert ? undefined : (props.negativeAction ?? "Cancel"); | |
return ( | |
<InternalDialog | |
title={props.title} | |
renderTrigger={props.renderTrigger} | |
onOpenChangeComplete={(open) => { | |
props.onOpenChangeComplete?.(open); | |
// Handle cancellation when dialog closes without submission | |
if (!open && !isAlert && props.onCancel) { | |
props.onCancel(); | |
} | |
}} | |
render={(close) => ( | |
<Form.Root | |
onSubmit={async () => { | |
if (isAlert) { | |
close(); | |
return; | |
} | |
await props.onSubmit(inputRef.current?.value ?? ""); | |
close(); | |
}} | |
> | |
<div className="mb-sm gap-md flex flex-col"> | |
{props.description && ( | |
<D.Description className="text-text-muted" render={<div>{props.description}</div>} /> | |
)} | |
{isPrompt && ( | |
<Field | |
name="input" | |
label={props.label} | |
ref={inputRef} | |
placeholder={props.placeholder} | |
autoComplete="off" | |
autoFocus | |
grow | |
validate={(value: unknown) => { | |
const valid = typeof value === "string" && value.trim().length > 0; | |
return valid ? null : "Please enter a value"; | |
}} | |
/** Temporary fix to avoid flash of error message when | |
* dismissing dialog with empty input, which triggers onBlur. | |
* https://github.com/mui/base-ui/issues/2803 | |
*/ | |
validationMode="onChange" | |
/> | |
)} | |
</div> | |
<div className="mt-md gap-xs flex justify-end"> | |
{!isAlert && negativeAction && ( | |
<D.Close | |
onClick={() => { | |
if (props.onCancel) props.onCancel(); | |
close(); | |
}} | |
render={<Button />} | |
className="" | |
> | |
{negativeAction} | |
</D.Close> | |
)} | |
<Form.Submit autoFocus={!isPrompt} danger={!isAlert && props.danger}> | |
{positiveAction} | |
</Form.Submit> | |
</div> | |
</Form.Root> | |
)} | |
/> | |
); | |
} | |
function PromptDialog(props: PromptProps) { | |
const [key, setKey] = useState(0); | |
return ( | |
<InternalPromptDialog | |
key={key} | |
variant="prompt" | |
onOpenChangeComplete={(open) => { | |
if (!open) { | |
// Force remount. | |
setKey((x) => x + 1); | |
} | |
}} | |
{...props} | |
/> | |
); | |
} | |
function ConfirmDialog( | |
props: ConfirmConfig & { onClose: (confirmed: boolean) => void | Promise<void> }, | |
) { | |
const { onClose, onConfirm, onCancel, ...restProps } = props; | |
return ( | |
<InternalPromptDialog | |
variant="confirm" | |
onSubmit={() => onClose(true)} | |
onCancel={() => onClose(false)} | |
{...restProps} | |
/> | |
); | |
} | |
function AlertDialog(props: AlertConfig & { onOpenChangeComplete?: (open: boolean) => void }) { | |
return ( | |
<InternalPromptDialog | |
variant="alert" | |
onSubmit={() => {}} | |
onOpenChangeComplete={props.onOpenChangeComplete} | |
{...props} | |
/> | |
); | |
} | |
function CustomDialog( | |
props: CustomDialogConfig & { onOpenChangeComplete?: (open: boolean) => void }, | |
) { | |
const [key, setKey] = useState(0); | |
return ( | |
<InternalDialog | |
key={key} | |
title={props.title} | |
fitContent={props.fitContent} | |
onOpenChangeComplete={(open) => { | |
if (!open) { | |
// Force remount to reset custom component state. | |
setKey((prev) => prev + 1); | |
if (props.onClose) { | |
void Promise.resolve(props.onClose()); | |
} | |
} | |
props.onOpenChangeComplete?.(open); | |
}} | |
render={(close) => | |
typeof props.children === "function" ? props.children(close) : props.children | |
} | |
/> | |
); | |
} | |
// Imperative dialog state management | |
type DialogState = | |
| { id: number; type: "prompt"; props: PromptProps } | |
| { | |
id: number; | |
type: "confirm"; | |
props: ConfirmConfig & { onClose: (confirmed: boolean) => void | Promise<void> }; | |
} | |
| { | |
id: number; | |
type: "alert"; | |
props: AlertConfig & { onOpenChangeComplete?: (open: boolean) => void }; | |
} | |
| { | |
id: number; | |
type: "custom"; | |
props: CustomDialogConfig & { onOpenChangeComplete?: (open: boolean) => void }; | |
}; | |
const dialogs = new Map<number, DialogState>(); | |
let listener: ((dialogs: DialogState[]) => void) | null = null; | |
let dialogId = 0; | |
const notify = () => { | |
if (listener) { | |
const dialogArray = Array.from(dialogs.values()); | |
listener(dialogArray); | |
} | |
}; | |
const subscribe = (newListener: (dialogs: DialogState[]) => void) => { | |
listener = newListener; | |
return () => { | |
listener = null; | |
}; | |
}; | |
const removeDialog = (id: number) => { | |
dialogs.delete(id); | |
notify(); | |
}; | |
// Helper function to get next dialog ID and prevent infinite growth | |
const getNextDialogId = () => { | |
const id = ++dialogId; | |
// Reset ID counter to prevent infinite growth (simple approach) | |
if (dialogId > 1000) dialogId = 0; | |
return id; | |
}; | |
/** | |
* Imperative dialog manager for programmatically controlling dialogs. | |
* | |
* Why imperative API (dialogManager.prompt()) over declarative | |
* (<PromptDialog />): | |
* | |
* - auto state reset on dialog close | |
* - can trigger dialog without rendering a button | |
*/ | |
export const dialogManager = { | |
prompt: (config: PromptConfig) => { | |
const id = getNextDialogId(); | |
let isResolved = false; | |
dialogs.set(id, { | |
id, | |
type: "prompt", | |
props: { | |
...config, | |
onSubmit: (result: string) => { | |
if (!isResolved) { | |
isResolved = true; | |
removeDialog(id); | |
void Promise.resolve(config.onSubmit(result)); | |
} | |
}, | |
onCancel: () => { | |
if (!isResolved) { | |
isResolved = true; | |
removeDialog(id); | |
if (config.onCancel) { | |
void Promise.resolve(config.onCancel()); | |
} | |
} | |
}, | |
}, | |
}); | |
notify(); | |
}, | |
confirm: (config: ConfirmConfig) => { | |
const id = getNextDialogId(); | |
dialogs.set(id, { | |
id, | |
type: "confirm", | |
props: { | |
...config, | |
onClose: (confirmed: boolean) => { | |
removeDialog(id); | |
if (confirmed) { | |
void Promise.resolve(config.onConfirm()); | |
} else if (config.onCancel) { | |
void Promise.resolve(config.onCancel()); | |
} | |
}, | |
}, | |
}); | |
notify(); | |
}, | |
alert: (config: AlertConfig) => { | |
const id = getNextDialogId(); | |
dialogs.set(id, { | |
id, | |
type: "alert", | |
props: { | |
...config, | |
onOpenChangeComplete: (open: boolean) => { | |
if (!open) { | |
removeDialog(id); | |
void Promise.resolve(config.onClose()); | |
} | |
}, | |
}, | |
}); | |
notify(); | |
}, | |
custom: (config: CustomDialogConfig) => { | |
const id = getNextDialogId(); | |
dialogs.set(id, { | |
id, | |
type: "custom", | |
props: { | |
...config, | |
onOpenChangeComplete: (open: boolean) => { | |
if (!open) { | |
removeDialog(id); | |
if (config.onClose) { | |
void Promise.resolve(config.onClose()); | |
} | |
} | |
}, | |
}, | |
}); | |
notify(); | |
}, | |
}; | |
// Context for provider | |
const DialogContext = createContext(null); | |
// Provider component | |
export function DialogProvider(props: Readonly<{ children: ReactNode }>) { | |
const [dialogList, setDialogList] = useState<DialogState[]>([]); | |
useEffect(() => { | |
const unsubscribe = subscribe(setDialogList); | |
return unsubscribe; | |
}, []); | |
return ( | |
<DialogContext value={null}> | |
{props.children} | |
{/* Render active dialogs */} | |
{dialogList.map((dialog) => { | |
if (dialog.type === "prompt") { | |
return <PromptDialog key={dialog.id} {...dialog.props} />; | |
} | |
if (dialog.type === "confirm") { | |
return <ConfirmDialog key={dialog.id} {...dialog.props} />; | |
} | |
if (dialog.type === "custom") { | |
return <CustomDialog key={dialog.id} {...dialog.props} />; | |
} | |
return <AlertDialog key={dialog.id} {...dialog.props} />; | |
})} | |
</DialogContext> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment