Created
October 9, 2025 20:01
-
-
Save marcospgp/8402e48c8a888891a2e27c41888d3d49 to your computer and use it in GitHub Desktop.
react baseUI dialog manager
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 ComponentType, type ReactNode, useEffect, useState } from "react"; | |
import { Button } from "./Button"; | |
import { Form, Input } from "./forms"; | |
export type DialogAction = { | |
label: string; | |
action?: () => void | Promise<void>; | |
danger?: boolean; | |
}; | |
function DialogTitle(props: Readonly<{ title: string }>) { | |
return <D.Title render={(ps) => <h1 {...ps}>{props.title}</h1>} />; | |
} | |
function DialogDescription(props: Readonly<{ description: string }>) { | |
return ( | |
<D.Description className="text-text-muted col-span-2" render={<div>{props.description}</div>} /> | |
); | |
} | |
function DialogButtons(props: Readonly<{ actions: DialogAction[]; onClose: () => void }>) { | |
return ( | |
<div className="mt-md gap-xs col-span-2 flex justify-end"> | |
{props.actions.map((action, index) => ( | |
<Button | |
key={index} | |
danger={action.danger} | |
onClick={() => { | |
void (async () => { | |
if (action.action) await action.action(); | |
props.onClose(); | |
})(); | |
}} | |
> | |
{action.label} | |
</Button> | |
))} | |
</div> | |
); | |
} | |
type BaseDialogOptions = { | |
dismissible?: boolean; | |
fitContent?: boolean; | |
}; | |
type DialogState = { | |
id: number; | |
open: boolean; | |
hasBeenOpened: boolean; | |
isClosing: boolean; | |
element: ReactNode; | |
onOpenChange: (open: boolean) => void; | |
onRemove: () => void | Promise<void>; | |
} & BaseDialogOptions; | |
const dialogStore = { | |
dialogs: [] as DialogState[], | |
nextId: 0, | |
listener: null as (() => void) | null, | |
add( | |
id: number, | |
element: ReactNode, | |
onRemove: () => void | Promise<void>, | |
options?: { dismissible?: boolean; fitContent?: boolean }, | |
) { | |
const dialog: DialogState = { | |
id, | |
open: false, | |
hasBeenOpened: false, | |
isClosing: false, | |
element, | |
onRemove, | |
dismissible: options?.dismissible, | |
fitContent: options?.fitContent, | |
// Handles all close triggers (escape, backdrop, button clicks). | |
// Sets isClosing to immediately exclude dialog from position | |
// calculations, so parent dialogs transition back to full size without | |
// delay. | |
onOpenChange: (open) => { | |
this.dialogs = this.dialogs.map((d) => | |
d.id === id ? { ...d, open, isClosing: !open ? true : d.isClosing } : d, | |
); | |
this.listener?.(); | |
}, | |
}; | |
this.dialogs = [...this.dialogs, dialog]; | |
this.listener?.(); | |
}, | |
remove(id: number) { | |
const dialog = this.dialogs.find((d) => d.id === id); | |
if (dialog) { | |
dialog.onOpenChange(false); | |
} | |
}, | |
removeComplete(id: number) { | |
this.dialogs = this.dialogs.filter((d) => d.id !== id); | |
this.listener?.(); | |
}, | |
}; | |
type PromptDialogContentProps = Readonly<{ | |
title: string; | |
onSubmit: (value: string) => void | Promise<void>; | |
description?: string; | |
label?: string; | |
placeholder?: string; | |
cancelLabel?: string; | |
submitLabel?: string; | |
}>; | |
function PromptDialogContent(props: PromptDialogContentProps & { close: () => void }) { | |
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null); | |
return ( | |
<> | |
<DialogTitle title={props.title} /> | |
{props.description && <DialogDescription description={props.description} />} | |
<Form.Root | |
onSubmit={async () => { | |
await props.onSubmit(inputRef?.value ?? ""); | |
props.close(); | |
}} | |
> | |
<Input | |
name="input" | |
label={props.label} | |
ref={setInputRef} | |
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"; | |
}} | |
validationMode="onChange" | |
/> | |
</Form.Root> | |
<DialogButtons | |
actions={[ | |
{ label: props.cancelLabel ?? "Cancel" }, | |
{ | |
label: props.submitLabel ?? "Submit", | |
action: async () => { | |
await props.onSubmit(inputRef?.value ?? ""); | |
props.close(); | |
}, | |
}, | |
]} | |
onClose={props.close} | |
/> | |
</> | |
); | |
} | |
type ConfirmDialogContentProps = Readonly<{ | |
title: string; | |
description?: string; | |
onConfirm?: () => void | Promise<void>; | |
confirmLabel?: string; | |
cancelLabel?: string; | |
danger?: boolean; | |
}>; | |
function ConfirmDialogContent(props: ConfirmDialogContentProps & { close: () => void }) { | |
const actions: DialogAction[] = [ | |
{ label: props.cancelLabel ?? "Cancel" }, | |
{ | |
label: props.confirmLabel ?? "Ok", | |
action: props.onConfirm, | |
danger: props.danger, | |
}, | |
]; | |
return ( | |
<> | |
<DialogTitle title={props.title} /> | |
{props.description && <DialogDescription description={props.description} />} | |
<DialogButtons actions={actions} onClose={props.close} /> | |
</> | |
); | |
} | |
type AlertDialogContentProps = Readonly<{ | |
title: string; | |
description?: string; | |
label?: string; | |
}>; | |
function AlertDialogContent(props: AlertDialogContentProps & { close: () => void }) { | |
const label = props.label ?? "Got it"; | |
return ( | |
<> | |
<DialogTitle title={props.title} /> | |
{props.description && <DialogDescription description={props.description} />} | |
<DialogButtons actions={[{ label }]} onClose={props.close} /> | |
</> | |
); | |
} | |
type FormDialogContentProps = Readonly<{ | |
title: string; | |
formChildren: ReactNode; | |
onSubmit: () => void | Promise<void>; | |
description?: string; | |
cancelLabel?: string; | |
submitLabel?: string; | |
}>; | |
function FormDialogContent(props: FormDialogContentProps & { close: () => void }) { | |
return ( | |
<> | |
<DialogTitle title={props.title} /> | |
{props.description && <DialogDescription description={props.description} />} | |
<Form.Root | |
onSubmit={async () => { | |
await props.onSubmit(); | |
props.close(); | |
}} | |
> | |
{props.formChildren} | |
</Form.Root> | |
<DialogButtons | |
actions={[ | |
{ label: props.cancelLabel ?? "Cancel" }, | |
{ | |
label: props.submitLabel ?? "Submit", | |
action: async () => { | |
await props.onSubmit(); | |
props.close(); | |
}, | |
}, | |
]} | |
onClose={props.close} | |
/> | |
</> | |
); | |
} | |
type CustomDialogContentProps = Readonly<{ | |
content: ReactNode | ComponentType<{ close: () => void }>; | |
title?: string | null; | |
actions?: DialogAction[]; | |
}>; | |
function CustomDialogContent(props: CustomDialogContentProps & { close: () => void }) { | |
let contentElement: ReactNode; | |
if (typeof props.content === "function") { | |
const ContentComponent = props.content; | |
contentElement = <ContentComponent close={props.close} />; | |
} else { | |
contentElement = props.content; | |
} | |
return ( | |
<> | |
{props.title !== undefined && props.title !== null && <DialogTitle title={props.title} />} | |
{contentElement} | |
{props.actions && props.actions.length > 0 && ( | |
<DialogButtons actions={props.actions} onClose={props.close} /> | |
)} | |
</> | |
); | |
} | |
type AlertDialogProps = AlertDialogContentProps & Pick<BaseDialogOptions, "dismissible">; | |
type CustomDialogProps = CustomDialogContentProps & | |
BaseDialogOptions & { onClose?: () => void | Promise<void> }; | |
/** | |
* Imperative API allows auto state reset on dialog close via key-based component | |
* remounting and centralizes complex rendering logic (child dialog transitions, | |
* stacking, backdrop) in DialogProvider. Declarative would require manual state | |
* management and scattered render logic. | |
*/ | |
export const dialogManager = { | |
prompt: (props: PromptDialogContentProps) => { | |
const id = dialogStore.nextId++; | |
const close = () => { | |
dialogStore.remove(id); | |
}; | |
dialogStore.add(id, <PromptDialogContent {...props} close={close} />, () => { | |
dialogStore.removeComplete(id); | |
}); | |
}, | |
confirm: (props: ConfirmDialogContentProps) => { | |
const id = dialogStore.nextId++; | |
const close = () => { | |
dialogStore.remove(id); | |
}; | |
dialogStore.add(id, <ConfirmDialogContent {...props} close={close} />, () => { | |
dialogStore.removeComplete(id); | |
}); | |
}, | |
alert: (props: AlertDialogProps) => { | |
const id = dialogStore.nextId++; | |
const close = () => { | |
dialogStore.remove(id); | |
}; | |
const { dismissible, ...contentProps } = props; | |
dialogStore.add( | |
id, | |
<AlertDialogContent {...contentProps} close={close} />, | |
() => { | |
dialogStore.removeComplete(id); | |
}, | |
{ dismissible }, | |
); | |
}, | |
form: (props: FormDialogContentProps) => { | |
const id = dialogStore.nextId++; | |
const close = () => { | |
dialogStore.remove(id); | |
}; | |
dialogStore.add(id, <FormDialogContent {...props} close={close} />, () => { | |
dialogStore.removeComplete(id); | |
}); | |
}, | |
custom: (props: CustomDialogProps) => { | |
const id = dialogStore.nextId++; | |
const close = () => { | |
dialogStore.remove(id); | |
}; | |
const { onClose, fitContent, dismissible, ...contentProps } = props; | |
dialogStore.add( | |
id, | |
<CustomDialogContent {...contentProps} close={close} />, | |
async () => { | |
await onClose?.(); | |
dialogStore.removeComplete(id); | |
}, | |
{ fitContent, dismissible }, | |
); | |
}, | |
}; | |
/** | |
* Should be included once in app layout. | |
* | |
* Per React docs: "Components should only be used in JSX. Don't call them as | |
* regular functions." Calling component functions directly violates Rules of | |
* Hooks - hooks attach to the caller's chain instead of the component's own | |
* boundary, causing hook order violations when dialogs open/close. | |
*/ | |
export function DialogProvider() { | |
const [_renderCount, setRenderCount] = useState(0); | |
useEffect(() => { | |
dialogStore.listener = () => { | |
setRenderCount((prev) => prev + 1); | |
}; | |
return () => { | |
dialogStore.listener = null; | |
}; | |
}, []); | |
// Open dialogs after they're added to DOM to trigger CSS transitions | |
useEffect(() => { | |
dialogStore.dialogs.forEach((dialog) => { | |
if (!dialog.hasBeenOpened) { | |
dialogStore.dialogs = dialogStore.dialogs.map((d) => | |
d.id === dialog.id ? { ...d, open: true, hasBeenOpened: true } : d, | |
); | |
dialogStore.listener?.(); | |
} | |
}); | |
}); | |
// BaseUI expects nested dialogs for proper backdrop/styling handling, but | |
// nesting causes all parent dialogs to re-render when children open/close, | |
// replaying transitions. | |
// We render as siblings and manually handle backdrop + stacking styles to | |
// avoid this. | |
const openDialogCount = dialogStore.dialogs.filter((d) => d.open).length; | |
// isClosing flag excludes dialogs from positioning calculations while they | |
// animate out, so parent dialogs instantly scale back up when children close. | |
const activeDialogs = dialogStore.dialogs.filter((d) => !d.isClosing); | |
return ( | |
<> | |
{openDialogCount > 0 && ( | |
<div className="fixed inset-0 cursor-pointer bg-black opacity-50 transition-opacity" /> | |
)} | |
{dialogStore.dialogs.map((dialog) => { | |
const activeIndex = activeDialogs.indexOf(dialog); | |
const reverseIndex = activeIndex >= 0 ? activeDialogs.length - 1 - activeIndex : 0; | |
return ( | |
<D.Root | |
key={dialog.id} | |
open={dialog.open} | |
onOpenChange={dialog.onOpenChange} | |
onOpenChangeComplete={(isOpen) => { | |
if (isOpen) return; | |
void dialog.onRemove(); | |
}} | |
dismissible={dialog.dismissible} | |
> | |
<D.Portal> | |
<div className="fixed inset-0 flex items-center justify-center"> | |
<D.Popup | |
className={`${!dialog.fitContent && "w-lg"} bg-bg px-md py-sm bevel relative 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`} | |
style={{ | |
transform: `translateY(${reverseIndex * 32}px) scale(${Math.max(0, 1 - reverseIndex * 0.1)})`, | |
}} | |
> | |
{dialog.element} | |
</D.Popup> | |
</div> | |
</D.Portal> | |
</D.Root> | |
); | |
})} | |
</> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment