Files
xo-cli/src/tui/components/Dialog.tsx
2026-03-23 03:51:51 +00:00

291 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dialog components for modals, confirmations, and input dialogs.
*/
import React, { useId, useRef, useState } from 'react';
import { Box, Text, measureElement } from 'ink';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
/**
* Base dialog wrapper props.
*/
interface DialogWrapperProps {
/** Dialog title */
title: string;
/** Border color */
borderColor?: string;
/** Dialog content */
children: React.ReactNode;
/** Dialog width */
width?: number;
/** Dialog Background Color */
backgroundColor?: string;
}
export function DialogWrapper({
title,
borderColor = colors.primary,
children,
width = 60,
backgroundColor = colors.bg,
}: DialogWrapperProps): React.ReactElement {
const ref = useRef<any>(null);
const [height, setHeight] = useState<number | null>(null);
// measure after render
React.useLayoutEffect(() => {
if (ref.current) {
const { height } = measureElement(ref.current);
setHeight(height);
}
}, [children, title, width]);
return (
<Box flexDirection="column">
{/* Opaque backing layer */}
{height !== null && (
<Box
position="absolute"
flexDirection="column"
width={width}
height={height}
backgroundColor={backgroundColor}
>
{Array.from({ length: height }).map((_, i) => (
<Text key={i} backgroundColor={backgroundColor}>
{' '.repeat(width)}
</Text>
))}
</Box>
)}
{/* Actual dialog */}
<Box
ref={ref}
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={width}
backgroundColor={backgroundColor}
>
<Text color={borderColor} bold>
{title}
</Text>
<Box marginY={1} flexDirection="column">
{children}
</Box>
</Box>
</Box>
);
}
/**
* Props for InputDialog component.
*/
interface InputDialogProps {
/** Dialog title */
title: string;
/** Input prompt/label */
prompt: string;
/** Initial value */
initialValue?: string;
/** Placeholder text */
placeholder?: string;
/** Submit handler */
onSubmit: (value: string) => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Input dialog for getting text input from user.
*/
export function InputDialog({
title,
prompt,
initialValue = '',
placeholder,
onSubmit,
onCancel,
isActive = true,
}: InputDialogProps): React.ReactElement {
const layerId = useId();
const [value, setValue] = useState(initialValue);
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.escape) {
onCancel();
}
});
const handleSubmit = (val: string) => {
onSubmit(val);
};
return (
<DialogWrapper title={title} borderColor={colors.primary}>
<Text>{prompt}</Text>
<Box marginTop={1} borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={isActive}
/>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter to submit Esc to cancel</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for ConfirmDialog component.
*/
interface ConfirmDialogProps {
/** Dialog title */
title: string;
/** Confirmation message */
message: string;
/** Confirm handler */
onConfirm: () => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
}
/**
* Confirmation dialog with Yes/No options.
*/
export function ConfirmDialog({
title,
message,
onConfirm,
onCancel,
isActive = true,
confirmLabel = 'Yes',
cancelLabel = 'No',
}: ConfirmDialogProps): React.ReactElement {
const layerId = useId();
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (input, key) => {
if (key.leftArrow || key.rightArrow || key.tab) {
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
} else if (key.return) {
if (selected === 'confirm') {
onConfirm();
} else {
onCancel();
}
} else if (key.escape || input === 'n' || input === 'N') {
onCancel();
} else if (input === 'y' || input === 'Y') {
onConfirm();
}
});
return (
<DialogWrapper title={title} borderColor={colors.warning}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1} gap={2}>
<Text
backgroundColor={selected === 'confirm' ? colors.focus : colors.secondary}
color={selected === 'confirm' ? colors.bg : colors.text}
bold={selected === 'confirm'}
>
{` ${confirmLabel} `}
</Text>
<Text
backgroundColor={selected === 'cancel' ? colors.focus : colors.secondary}
color={selected === 'cancel' ? colors.bg : colors.text}
bold={selected === 'cancel'}
>
{` ${cancelLabel} `}
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Y/N or Tab to switch Enter to select</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for MessageDialog component.
*/
interface MessageDialogProps {
/** Dialog title */
title: string;
/** Message content */
message: string;
/** Close handler */
onClose: () => void;
/** Dialog type for styling */
type?: 'info' | 'error' | 'success';
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Simple message dialog (info, error, success).
*/
export function MessageDialog({
title,
message,
onClose,
type = 'info',
isActive = true,
}: MessageDialogProps): React.ReactElement {
const layerId = useId();
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.return || key.escape) {
onClose();
}
});
const borderColor = type === 'error' ? colors.error :
type === 'success' ? colors.success :
colors.info;
const icon = type === 'error' ? '✗' :
type === 'success' ? '✓' :
'';
return (
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</DialogWrapper>
);
}