← All proposals

Chat Input

A labelled textarea with a toolbar inside the same bordered box:

Net-new componentOpen in Storybook

proposals/ChatInput/ChatInput.tsx282 lines · View on GitHub
import {
	ChangeEvent,
	ComponentPropsWithoutRef,
	DragEvent,
	forwardRef,
	ReactNode,
	Ref,
	useRef,
	useState,
} from 'react';

import './chat-input.css';

export type ChatInputSize = 's' | 'm';

export type ChatInputVariant = 'default' | 'compact';

export interface ChatInputProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'defaultValue' | 'onChange' | 'placeholder'
> {
	/**
	 * Controls at the end of the toolbar, inside the field (for example a
	 * model selector, a voice button and the `SendButton`).
	 */
	actionsEnd?: ReactNode;
	/**
	 * Controls at the start of the toolbar, inside the field (for example
	 * add and attach buttons).
	 */
	actionsStart?: ReactNode;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Initial value. Makes the field uncontrolled; cannot be used with `value`.
	 */
	defaultValue?: string;
	/**
	 * Text shown over the field while files are dragged onto it. Without it,
	 * the field only changes its border and background.
	 */
	dropLabel?: string;
	/**
	 * Is the field disabled?
	 */
	disabled?: boolean;
	/**
	 * ID of the textarea.
	 */
	id: string;
	/**
	 * Can the user resize the field by dragging its corner?
	 */
	isResizable?: boolean;
	/**
	 * Text label associated with the field.
	 */
	label: string;
	/**
	 * Message or description used to help clarify the usage of the field.
	 */
	message?: ReactNode;
	/**
	 * Change event handler for the textarea.
	 */
	onChange?: (event: ChangeEvent<HTMLTextAreaElement>) => void;
	/**
	 * Called with the dropped files. Setting it turns on drag and drop: files
	 * dragged onto the field are handed to this handler. Dragged text still
	 * drops into the textarea as usual.
	 */
	onFilesDrop?: (files: File[]) => void;
	/**
	 * Placeholder text shown when the field is empty.
	 */
	placeholder?: string;
	/**
	 * Number of visible text rows above the toolbar.
	 */
	rows?: number;
	/**
	 * Should the label be visible? Defaults to `false`: the placeholder says
	 * what the field is for, and the label still renders for screen readers.
	 */
	showLabel?: boolean;
	/**
	 * Field size. Sets the padding.
	 */
	size?: ChatInputSize;
	/**
	 * Additional props for the `<textarea>` element.
	 */
	textareaProps?: ComponentPropsWithoutRef<'textarea'>;
	/**
	 * Which variant to render. `default` is a tall field with the toolbar
	 * underneath. `compact` is a single-line bar with the controls on the same
	 * row, and is not resizable.
	 */
	variant?: ChatInputVariant;
	/**
	 * Value of the textarea. Makes the field controlled with `onChange`; cannot
	 * be used with `defaultValue`.
	 */
	value?: string;
}

// Stand-in for the v2 `mergeClasses` util, which the published package does
// not export. The port uses the real one.
const mergeClasses = (classes: Array<string | null | undefined>) =>
	classes.filter(Boolean).join(' ');

/**
 * ChatInput UI component. A `Textarea` with a toolbar inside the field, for
 * composing a prompt.
 */
export const ChatInput = forwardRef(
	(
		{
			actionsEnd,
			actionsStart,
			className,
			defaultValue,
			disabled = false,
			dropLabel,
			id,
			isResizable = true,
			label,
			message,
			onChange,
			onFilesDrop,
			placeholder,
			rows = 3,
			showLabel = false,
			size = 's',
			textareaProps,
			value,
			variant = 'default',
			...props
		}: ChatInputProps,
		ref: Ref<HTMLTextAreaElement>,
	) => {
		const baseClass = 'pds-chat-input';
		const isCompact = variant === 'compact';
		const canDrop = Boolean(onFilesDrop) && !disabled;

		// dragenter and dragleave also fire for every child the pointer crosses,
		// so count them to know when the pointer has really left the field.
		const dragDepth = useRef(0);
		const [isDragOver, setIsDragOver] = useState(false);

		const hasFiles = (event: DragEvent) =>
			Array.from(event.dataTransfer.types).includes('Files');

		const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
			if (!canDrop || !hasFiles(event)) return;
			dragDepth.current += 1;
			setIsDragOver(true);
		};

		const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
			if (!canDrop || !hasFiles(event)) return;
			// Required to make the field a valid drop target.
			event.preventDefault();
			event.dataTransfer.dropEffect = 'copy';
		};

		const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
			if (!canDrop || !hasFiles(event)) return;
			dragDepth.current = Math.max(0, dragDepth.current - 1);
			if (dragDepth.current === 0) setIsDragOver(false);
		};

		const handleDrop = (event: DragEvent<HTMLDivElement>) => {
			if (!canDrop || !hasFiles(event)) return;
			event.preventDefault();
			dragDepth.current = 0;
			setIsDragOver(false);
			const files = Array.from(event.dataTransfer.files);
			if (files.length) onFilesDrop?.(files);
		};

		const textarea = (
			<textarea
				ref={ref}
				aria-describedby={message ? `${id}__message` : undefined}
				className={`${baseClass}__textarea`}
				defaultValue={defaultValue}
				disabled={disabled}
				id={id}
				name={id}
				onChange={onChange}
				placeholder={placeholder}
				rows={isCompact ? 1 : rows}
				value={value}
				{...textareaProps}
			/>
		);

		// The package CSS styles `pds-input`, `pds-input-label` and
		// `pds-input-message` (shared by every PDS input), so the markup reuses
		// those classes; `InputLabel` and `InputMessage` themselves are not
		// exported.
		return (
			<div
				className={mergeClasses([
					'pds-input',
					baseClass,
					isCompact ? `${baseClass}--compact` : null,
					size !== 's' ? `${baseClass}--${size}` : null,
					disabled ? 'pds-is-disabled' : null,
					className,
				])}
				{...props}
			>
				<label
					className={mergeClasses([
						'pds-input-label',
						!showLabel ? 'visually-hidden' : null,
					])}
					htmlFor={id}
					id={`${id}-label`}
				>
					{label}
				</label>

				<div
					className={mergeClasses([
						`${baseClass}__field`,
						isResizable && !isCompact ? 'pds-is-resizable' : null,
						isDragOver ? 'pds-is-drag-over' : null,
					])}
					onDragEnter={handleDragEnter}
					onDragLeave={handleDragLeave}
					onDragOver={handleDragOver}
					onDrop={handleDrop}
				>
					{isDragOver && dropLabel && (
						<div aria-hidden='true' className={`${baseClass}__drop-label`}>
							<span>{dropLabel}</span>
						</div>
					)}
					{isCompact ? (
						<>
							{actionsStart && (
								<div className={`${baseClass}__actions-start`}>
									{actionsStart}
								</div>
							)}
							{textarea}
							{actionsEnd && (
								<div className={`${baseClass}__actions-end`}>{actionsEnd}</div>
							)}
						</>
					) : (
						<>
							{textarea}
							{(actionsStart || actionsEnd) && (
								<div className={`${baseClass}__actions`}>
									<div className={`${baseClass}__actions-start`}>
										{actionsStart}
									</div>
									<div className={`${baseClass}__actions-end`}>
										{actionsEnd}
									</div>
								</div>
							)}
						</>
					)}
				</div>

				{message && (
					<div className='pds-input-message' id={`${id}__message`}>
						<div className='pds-input-message__text'>{message}</div>
					</div>
				)}
			</div>
		);
	},
);