210 lines
6.0 KiB
TypeScript
210 lines
6.0 KiB
TypeScript
import {
|
||
forwardRef,
|
||
useImperativeHandle,
|
||
useRef,
|
||
type CSSProperties,
|
||
type FormEvent,
|
||
type KeyboardEvent,
|
||
type ReactNode,
|
||
} from 'react';
|
||
|
||
export interface PromptComposerProps {
|
||
/** Controlled input value. */
|
||
value: string;
|
||
/** Standard onChange — accepts the Vercel-AI-SDK-shaped event. */
|
||
onChange: (e: { target: { value: string } }) => void;
|
||
/** Called on Enter (without Shift) or send-button click. */
|
||
onSubmit: (e?: { preventDefault?: () => void }) => void | Promise<void>;
|
||
/** Disable input + submit (e.g. while streaming). */
|
||
disabled?: boolean;
|
||
/** Placeholder when empty. */
|
||
placeholder?: string;
|
||
/** Render a Stop button instead of Send (e.g. during streaming). */
|
||
showStop?: boolean;
|
||
/** Called when the Stop button is clicked. */
|
||
onStop?: () => void;
|
||
/** Auto-grow up to this many lines before scrolling. Default 8. */
|
||
maxLines?: number;
|
||
/** Extra controls rendered to the left of the send button. */
|
||
leadingActions?: ReactNode;
|
||
/** Tailwind escape hatch. */
|
||
className?: string;
|
||
/** Inline style escape hatch. */
|
||
style?: CSSProperties;
|
||
/** ARIA label for the textarea. */
|
||
ariaLabel?: string;
|
||
}
|
||
|
||
export interface PromptComposerHandle {
|
||
focus: () => void;
|
||
clear: () => void;
|
||
}
|
||
|
||
/**
|
||
* Multi-line prompt input with submit-on-Enter + Shift-Enter newline,
|
||
* auto-growing textarea, optional Stop affordance during streaming, and
|
||
* slots for slash-commands or other leading actions (Wave 2 extension).
|
||
*
|
||
* Keyboard:
|
||
* Enter — submit
|
||
* Shift+Enter — newline
|
||
* Cmd/Ctrl+Enter — submit (alternate; useful when textarea grows)
|
||
* Escape — blur
|
||
*/
|
||
export const PromptComposer = forwardRef<PromptComposerHandle, PromptComposerProps>(
|
||
function PromptComposer(
|
||
{
|
||
value,
|
||
onChange,
|
||
onSubmit,
|
||
disabled = false,
|
||
placeholder = 'Send a message…',
|
||
showStop = false,
|
||
onStop,
|
||
maxLines = 8,
|
||
leadingActions,
|
||
className,
|
||
style,
|
||
ariaLabel = 'Message',
|
||
},
|
||
ref,
|
||
) {
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
focus: () => textareaRef.current?.focus(),
|
||
clear: () => {
|
||
onChange({ target: { value: '' } });
|
||
textareaRef.current?.focus();
|
||
},
|
||
}),
|
||
[onChange],
|
||
);
|
||
|
||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Escape') {
|
||
e.currentTarget.blur();
|
||
return;
|
||
}
|
||
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter';
|
||
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
|
||
if ((isPlainEnter || isCmdEnter) && !disabled && value.trim()) {
|
||
e.preventDefault();
|
||
void onSubmit({ preventDefault: () => undefined });
|
||
}
|
||
};
|
||
|
||
const handleFormSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
if (!disabled && value.trim()) void onSubmit(e);
|
||
};
|
||
|
||
// Compute auto-grow height — line-height (1.5) × maxLines, capped.
|
||
const maxHeight = `calc(1.5em * ${maxLines} + var(--bl-space-3, 12px) * 2)`;
|
||
|
||
return (
|
||
<form
|
||
data-testid="bl-prompt-composer"
|
||
onSubmit={handleFormSubmit}
|
||
className={className}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-end',
|
||
gap: 'var(--bl-space-2, 8px)',
|
||
padding: 'var(--bl-space-2, 8px)',
|
||
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||
borderRadius: 'var(--bl-radius-card, 12px)',
|
||
background: 'var(--bl-surface-card, #fff)',
|
||
...style,
|
||
}}
|
||
>
|
||
{leadingActions && (
|
||
<div
|
||
data-testid="bl-prompt-leading"
|
||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--bl-space-1, 4px)' }}
|
||
>
|
||
{leadingActions}
|
||
</div>
|
||
)}
|
||
|
||
<textarea
|
||
ref={textareaRef}
|
||
data-testid="bl-prompt-textarea"
|
||
aria-label={ariaLabel}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
rows={1}
|
||
onChange={onChange}
|
||
onKeyDown={handleKeyDown}
|
||
// Auto-grow: reset, then expand to scrollHeight on each keystroke.
|
||
onInput={e => {
|
||
const ta = e.currentTarget;
|
||
ta.style.height = 'auto';
|
||
ta.style.height = `${ta.scrollHeight}px`;
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
resize: 'none',
|
||
border: 'none',
|
||
outline: 'none',
|
||
background: 'transparent',
|
||
color: 'var(--bl-text-primary, inherit)',
|
||
fontFamily: 'inherit',
|
||
fontSize: '0.95rem',
|
||
lineHeight: 1.5,
|
||
padding: 'var(--bl-space-2, 8px)',
|
||
maxHeight,
|
||
overflowY: 'auto',
|
||
}}
|
||
/>
|
||
|
||
{showStop ? (
|
||
<button
|
||
type="button"
|
||
data-testid="bl-prompt-stop"
|
||
onClick={() => onStop?.()}
|
||
aria-label="Stop generating"
|
||
style={sendButtonStyle('var(--bl-danger, #ef4444)')}
|
||
>
|
||
◼
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="submit"
|
||
data-testid="bl-prompt-send"
|
||
disabled={disabled || !value.trim()}
|
||
aria-label="Send message"
|
||
style={{
|
||
...sendButtonStyle('var(--bl-accent, #6366f1)'),
|
||
opacity: disabled || !value.trim() ? 0.4 : 1,
|
||
cursor: disabled || !value.trim() ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
↑
|
||
</button>
|
||
)}
|
||
</form>
|
||
);
|
||
},
|
||
);
|
||
|
||
function sendButtonStyle(bg: string): CSSProperties {
|
||
return {
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
width: 36,
|
||
height: 36,
|
||
border: 'none',
|
||
borderRadius: 'var(--bl-radius-pill, 999px)',
|
||
background: bg,
|
||
color: 'var(--bl-accent-foreground, #fff)',
|
||
fontSize: 16,
|
||
cursor: 'pointer',
|
||
transition: 'transform 80ms ease-out, opacity 120ms ease-out',
|
||
};
|
||
}
|