291 lines
6.9 KiB
TypeScript
291 lines
6.9 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|