@opentui-ui/dialog
v0.1.2
Published
A dialog/modal library for terminal UIs built on OpenTUI
Maintainers
Readme
Features
- Lightweight default styling
- Size presets (small, medium, large, full)
- Click-to-close backdrop (opt-in)
- ESC key to close
- Dialog stack support (multiple dialogs)
- Focus management (saves/restores focus on open/close)
- Theme presets (minimal, unstyled)
- React and Solid.js integrations
Table of Contents
Installation
bun add @opentui-ui/dialogCore Usage
Use the core API when working directly with @opentui/core renderables without a framework.
Quick Start
import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { TextRenderable } from "@opentui/core";
// 1. Create the manager and container
const manager = new DialogManager(renderer);
const container = new DialogContainerRenderable(renderer, { manager });
renderer.root.add(container);
// 2. Show dialogs from anywhere!
manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Hello World!" }),
});Quick Reference
// Show dialogs
manager.show({ content: (ctx) => new TextRenderable(ctx, { content: "Hello" }) });
manager.show({ content: fn, size: "large" }); // With size preset
manager.show({ content: fn, id: "my-dialog" }); // With custom ID
// Close dialogs
manager.close(); // Close top-most
manager.close(id); // Close specific
manager.closeAll(); // Close all
manager.replace({...}); // Close all and show new
// Query state
manager.isOpen(); // boolean
manager.getDialogs(); // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefinedChoosing the Right Method
| Method | Best For | dialogId Available | Keyboard Handling |
|--------|----------|---------------------|-------------------|
| confirm() | Yes/No decisions | ✅ via context | ✅ useDialogKeyboard works |
| alert() | Acknowledgments | ✅ via context | ✅ useDialogKeyboard works |
| prompt<T>() | Getting user input | ✅ via context | ✅ useDialogKeyboard works |
| choice<K>() | Multiple options | ✅ via context | ✅ useDialogKeyboard works |
| show() | Non-interactive display | ❌ | ❌ Manual workarounds needed |
Use async methods for any dialog requiring user interaction or keyboard handling. They provide dialogId in the content context, enabling proper keyboard event scoping.
Use show() only for non-interactive dialogs that need external lifecycle control:
// Loading dialog pattern - close from outside
const loadingId = manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Loading..." }),
});
await performOperation();
manager.close(loadingId);Async Prompts
For common dialog patterns, use the built-in async methods that return Promises. Content functions receive both renderCtx and the dialog context, with renderCtx first to match the OpenTUI pattern:
import { BoxRenderable, TextRenderable } from "@opentui/core";
// Confirmation dialog - returns boolean
const confirmed = await manager.confirm({
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
const title = new TextRenderable(renderCtx, { content: "Delete file?" });
box.add(title);
const buttons = new BoxRenderable(renderCtx, { flexDirection: "row" });
const cancelBtn = new TextRenderable(renderCtx, { content: "Cancel" });
cancelBtn.on("mouseUp", dismiss);
const confirmBtn = new TextRenderable(renderCtx, { content: "Confirm" });
confirmBtn.on("mouseUp", () => resolve(true));
buttons.add(cancelBtn);
buttons.add(confirmBtn);
box.add(buttons);
return box;
},
fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});
// Alert dialog - returns void
await manager.alert({
content: (renderCtx, { dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
const text = new TextRenderable(renderCtx, { content: "Operation complete!" });
box.add(text);
const okBtn = new TextRenderable(renderCtx, { content: "OK" });
okBtn.on("mouseUp", dismiss);
box.add(okBtn);
return box;
},
});
// Choice dialog - returns selected key or undefined
const action = await manager.choice<"save" | "discard">({
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
// ... build UI
return box;
},
fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});
// Generic prompt - returns typed value or undefined
const value = await manager.prompt<string>({
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
// ... build UI
return box;
},
fallback: "default", // Optional: value when dismissed via ESC/backdrop
});Context Methods at a Glance
| Method | Returns | Context Properties | Notes |
| ------ | ------- | ------------------ | ----- |
| confirm() | Promise<boolean> | resolve(boolean), dismiss(), dialogId | resolve(true) = confirm, dismiss() = cancel |
| alert() | Promise<void> | dismiss(), dialogId | Just acknowledge and close |
| choice<K>() | Promise<K \| undefined> | resolve(key), dismiss(), dialogId | dismiss() returns undefined |
| prompt<T>() | Promise<T \| undefined> | resolve(value), dismiss(), dialogId | dismiss() returns undefined |
Pattern summary:
resolve(value)— complete the dialog with a valuedismiss()— close without a value (or acknowledge for alerts)dialogId— unique ID for this dialog
Programmatic close: If you need to close an async dialog externally (e.g., from a timer or external event), capture the
dialogIdfrom the context. Callingmanager.close(dialogId)will close the dialog and resolve the promise with the fallback value (undefined,false, etc.) — the promise will not be left pending.
API Reference
DialogManager
const manager = new DialogManager(renderer);
// Show a dialog - returns the dialog ID
const id = manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Hello" }),
size?: "small" | "medium" | "large" | "full",
style?: DialogStyle,
unstyled?: boolean,
backdropColor?: string, // default: "#000000"
backdropOpacity?: number | string, // 0-1 or "50%" (default: 0.35)
closeOnEscape?: boolean, // default: true (per-dialog override)
closeOnClickOutside?: boolean, // default: false
onClose?: () => void,
onOpen?: () => void,
onBackdropClick?: () => void,
id?: string | number, // optional custom ID
});
// Close dialogs
manager.close(); // Close top-most
manager.close(id); // Close specific
manager.closeAll(); // Close all
manager.replace({...}); // Close all and show new
// Query state
manager.isOpen(); // boolean
manager.getDialogs(); // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefined
// Async prompt methods
const confirmed = await manager.confirm({ content: ... });
const value = await manager.prompt<string>({ content: ... });
await manager.alert({ content: ... });
const choice = await manager.choice<"a" | "b">({ content: ... });
// Subscribe to changes
const unsubscribe = manager.subscribe((data) => {
// Called when dialogs change
});
// Cleanup
manager.destroy();DialogContainerRenderable
const container = new DialogContainerRenderable(renderer, {
manager, // Required: DialogManager instance
size: "medium", // Default size preset
dialogOptions: {
// Default style options for all dialogs
style: DialogStyle,
},
sizePresets: {
// Custom size presets (terminal columns)
small: 40,
medium: 60,
large: 80,
},
backdropColor: "#000000", // Default backdrop color
backdropOpacity: 0.35, // 0-1 or "50%" (default: 0.35)
closeOnEscape: true, // ESC key closes top dialog (default: true)
closeOnClickOutside: false, // Backdrop click closes top dialog (default: false)
unstyled: false, // Disable default styles (default: false)
});
// Add to render tree
renderer.root.add(container);DialogStyle
interface DialogStyle {
// Content panel
backgroundColor?: string; // Default: "#262626"
borderColor?: string;
borderStyle?: BorderStyle;
border?: boolean; // Default: false
// Sizing
width?: number | string;
maxWidth?: number;
minWidth?: number;
maxHeight?: number;
// Padding (default: 1 cell all around)
padding?: number;
paddingX?: number;
paddingY?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
}Framework Adapters (React & Solid)
Both frameworks share the same API pattern with DialogProvider, useDialog(), and useDialogState().
Setup
// React
import {
DialogProvider,
useDialog,
useDialogKeyboard,
useDialogState,
} from "@opentui-ui/dialog/react";
// Solid
import {
DialogProvider,
useDialog,
useDialogKeyboard,
useDialogState,
} from "@opentui-ui/dialog/solid";
function App() {
return (
<DialogProvider size="medium">
<MyContent />
</DialogProvider>
);
}Using Themes
import { DialogProvider } from "@opentui-ui/dialog/react";
import { themes } from "@opentui-ui/dialog/themes";
function App() {
return (
<DialogProvider {...themes.minimal}>
<MyContent />
</DialogProvider>
);
}Choosing the Right Method
See the comparison table in Core Usage for when to use show() vs async methods.
TL;DR: Use async methods (confirm, alert, prompt, choice) for interactive dialogs. Use show() only for non-interactive display (loading spinners) that need external lifecycle control:
// Loading dialog - close from outside
const loadingId = dialog.show({ content: () => <LoadingSpinner /> });
await performOperation();
dialog.close(loadingId);Async Prompts (Framework)
Content functions receive only the dialog context (resolve, dismiss, dialogId) — no renderCtx needed:
// Confirmation dialog - returns boolean
const confirmed = await dialog.confirm({
content: (ctx) => (
<box flexDirection="column">
<text>Delete this file?</text>
<box flexDirection="row" gap={1}>
<text onMouseUp={ctx.dismiss}>Cancel</text>
<text onMouseUp={() => ctx.resolve(true)}>Confirm</text>
</box>
</box>
),
fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});
// Alert dialog - returns void (just acknowledgment)
await dialog.alert({
content: (ctx) => (
<box flexDirection="column">
<text>Operation complete!</text>
<text onMouseUp={ctx.dismiss}>OK</text>
</box>
),
});
// Choice dialog - returns selected key or undefined
const action = await dialog.choice<"save" | "discard">({
content: (ctx) => (
<box flexDirection="column">
<text>Unsaved changes</text>
<text onMouseUp={() => ctx.resolve("save")}>Save</text>
<text onMouseUp={() => ctx.resolve("discard")}>Discard</text>
<text onMouseUp={ctx.dismiss}>Cancel</text>
</box>
),
fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});
// Generic prompt - returns typed value or undefined
const name = await dialog.prompt<string>({
content: (ctx) => (
<box flexDirection="column">
<text>Enter your name:</text>
<input onSubmit={(value) => ctx.resolve(value)} />
<text onMouseUp={ctx.dismiss}>Cancel</text>
</box>
),
fallback: "Anonymous", // Optional: value when dismissed via ESC/backdrop
});Building Custom Async Dialogs
The confirm(), alert(), and choice() methods are convenience wrappers. For custom patterns, use prompt<T>() directly:
// Example: A rating dialog that returns 1-5
const rating = await dialog.prompt<number>({
content: (ctx) => (
<box flexDirection="column">
<box flexDirection="row" gap={1}>
{[1, 2, 3, 4, 5].map((n) => (
<text key={n} onMouseUp={() => ctx.resolve(n)}>
{n}
</text>
))}
</box>
<text onMouseUp={ctx.dismiss}>Skip rating</text>
</box>
),
fallback: 0, // Return 0 if dismissed via ESC/backdrop
});useDialog() Hook
Returns dialog actions for imperatively controlling dialogs.
const dialog = useDialog();
// Show a dialog (content must be a function for both React and Solid)
dialog.show({
content: () => <MyContent />,
size: "medium",
style: { backgroundColor: "#1a1a1a" },
unstyled: false,
backdropColor: "#000000",
backdropOpacity: 0.5,
closeOnEscape: true,
closeOnClickOutside: true,
onClose: () => {},
onOpen: () => {},
id: "my-dialog",
});
// Close dialogs
dialog.close(); // Close top-most
dialog.close(id); // Close specific
dialog.closeAll(); // Close all
dialog.replace({...}); // Close all and show new
// Async prompt methods
const confirmed = await dialog.confirm({ content: ... });
const value = await dialog.prompt<string>({ content: ... });
await dialog.alert({ content: ... });
const choice = await dialog.choice<"a" | "b">({ content: ... });useDialogState() Hook
Subscribe to reactive dialog state using a selector.
interface DialogState {
isOpen: boolean; // Whether any dialog is open
count: number; // Number of open dialogs
dialogs: readonly Dialog[]; // All active dialogs (oldest first)
topDialog: Dialog | undefined; // The top-most dialog
}
const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);
const topDialog = useDialogState((s) => s.topDialog);[!WARNING] Always select primitives not new objects.
// Good - selects primitives
const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);
// Bad - creates new object every time, always re-renders
const state = useDialogState((s) => ({ isOpen: s.isOpen, count: s.count }));Key difference: React returns values directly, Solid returns accessors you must call.
// React - values directly
if (isOpen) {
console.log(`${count} dialog(s) open`);
}
// Solid - call accessors
if (isOpen()) {
console.log(`${count()} dialog(s) open`);
}useDialogKeyboard() Hook
When using useKeyboard from @opentui/react or @opentui/solid inside dialog content, keyboard events fire for all stacked dialogs simultaneously. This is because useKeyboard registers global listeners with no focus scoping.
useDialogKeyboard solves this by only firing the handler when the dialog is the topmost in the stack:
import { useDialogKeyboard, type ConfirmContext } from "@opentui-ui/dialog/react";
function DeleteConfirmDialog({ resolve, dialogId }: ConfirmContext) {
const [selected, setSelected] = useState<"cancel" | "delete">("cancel");
// Only fires when THIS dialog is topmost
useDialogKeyboard((key) => {
if (key.name === "tab") {
setSelected((prev) => (prev === "cancel" ? "delete" : "cancel"));
}
if (key.name === "return") {
resolve(selected === "delete");
}
if (key.name === "escape") {
resolve(false);
}
}, dialogId);
return (
<box flexDirection="column">
<text>Delete this file?</text>
<box flexDirection="row" gap={1}>
<text bg={selected === "cancel" ? "#333" : undefined}>Cancel</text>
<text bg={selected === "delete" ? "#c00" : undefined}>Delete</text>
</box>
</box>
);
}Manual Implementation with dialogId
If you need more control, use dialogId with useDialogState and the standard useKeyboard hook:
import { useKeyboard } from "@opentui/react";
import { useDialogState, type ConfirmContext } from "@opentui-ui/dialog/react";
function MyDialog({ resolve, dialogId }: ConfirmContext) {
// Reactively check if this dialog is topmost
const isTopmost = useDialogState((s) => s.topDialog?.id === dialogId);
useKeyboard((key) => {
// Guard: only handle events when topmost
if (!isTopmost) return;
if (key.name === "return") {
resolve(true);
}
});
return <text>Press Enter to confirm</text>;
}Full Example
// React (content must be a function)
function MyContent() {
const dialog = useDialog();
const isOpen = useDialogState((s) => s.isOpen);
return (
<box>
<text>{isOpen ? "Dialog open" : "No dialog"}</text>
<box onMouseUp={() => dialog.show({ content: () => <text>Hello!</text> })}>
<text>Open Dialog</text>
</box>
</box>
);
}
// Solid - note: content is a function, accessors are called
function MyContent() {
const dialog = useDialog();
const isOpen = useDialogState((s) => s.isOpen);
return (
<box>
<text>{isOpen() ? "Dialog open" : "No dialog"}</text>
<box
onMouseUp={() => dialog.show({ content: () => <text>Hello!</text> })}
>
<text>Open Dialog</text>
</box>
</box>
);
}Customization
These options work with both core and framework usage.
Default Styling
Out of the box, dialogs use the minimal theme:
- Lighter backdrop (35% opacity)
- No borders
- Tighter padding (1 cell all around)
This provides a clean, unobtrusive appearance while still being usable immediately.
Themes
Theme presets provide alternative visual styles. Import from @opentui-ui/dialog/themes:
import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { themes } from "@opentui-ui/dialog/themes";
const container = new DialogContainerRenderable(renderer, {
manager,
...themes.unstyled, // Start from scratch with no default styles
});| Theme | Description |
| ----- | ----------- |
| minimal | Lighter backdrop (35%), no borders, tighter padding (default) |
| unstyled | No backdrop, no background, no border, no padding |
Customize a theme:
const container = new DialogContainerRenderable(renderer, {
manager,
...themes.minimal,
size: "large", // Override specific options
});Unstyled Mode
For full control over dialog styling, use unstyled: true. This disables all default styles (backdrop, background, border, padding):
const container = new DialogContainerRenderable(renderer, {
manager,
unstyled: true,
dialogOptions: {
style: {
// Add your own styles
backgroundColor: "#262626",
border: true,
borderColor: "#525252",
},
},
});Size Presets
Default size presets (in terminal columns):
| Size | Width | | ------ | ------------------ | | small | 40 | | medium | 60 | | large | 80 | | full | terminal width - 4 |
Override with sizePresets option:
const container = new DialogContainerRenderable(renderer, {
manager,
sizePresets: {
small: 35,
medium: 55,
large: 75,
},
});TypeScript
Full TypeScript support with exported types:
// Core types
import type {
// Dialog types
Dialog,
DialogContainerOptions,
DialogContentFactory,
DialogId,
DialogShowOptions,
DialogSize,
DialogState,
DialogStyle,
DialogTheme,
DialogToClose,
// Async prompt contexts
AlertContext,
ChoiceContext,
ConfirmContext,
PromptContext,
// Async dialog options (for imperative usage)
AlertOptions,
ChoiceOptions,
ConfirmOptions,
PromptOptions,
// Base types (for building custom adapters)
AsyncDialogOptions,
BaseAlertOptions,
BaseChoiceOptions,
BaseConfirmOptions,
BaseDialogActions,
BasePromptOptions,
} from "@opentui-ui/dialog";
// Type guard for close events
import { isDialogToClose } from "@opentui-ui/dialog";
// Themes and default style constants
import {
DEFAULT_BACKDROP_COLOR,
DEFAULT_BACKDROP_OPACITY,
DEFAULT_PADDING,
DEFAULT_STYLE,
themes,
type DialogTheme,
} from "@opentui-ui/dialog/themes";License
MIT
