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>
);
},
);
Chat Input
A labelled textarea with a toolbar inside the same bordered box:
Net-new componentOpen in Storybook