← All proposals

Chat Panel

A layout shell for the content of an AI chat drawer or docked side panel.

Net-new componentOpen in Storybook

proposals/ChatPanel/ChatPanel.tsx345 lines · View on GitHub
import {
	ComponentPropsWithoutRef,
	DragEvent,
	ReactNode,
	useEffect,
	useRef,
	useState,
} from 'react';

import { CloseButton } from '@pantheon-systems/pds-toolkit-react';

import { mergeClasses } from '../_base/Button/utils';
import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';

import './chat-panel.css';

export type ChatPanelInset = 'default' | 'none';
export type ChatPanelSize = 'l' | 'm';

export interface ChatPanelProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'title'
> {
	/**
	 * The content area. It scrolls; the title row, header and footer stay
	 * pinned. Pass the empty state or the conversation.
	 */
	children?: ReactNode;
	/**
	 * Additional class names.
	 */
	className?: string;
	/**
	 * Screen-reader text for the close button. Required when `hasCloseButton`
	 * is on.
	 */
	closeButtonLabel?: string;
	/**
	 * Text shown over the content area while files are dragged over the panel,
	 * and read to screen readers when it appears.
	 */
	dropLabel?: string;
	/**
	 * Inline padding of the content area. `none` bleeds to the edges; the child
	 * supplies its own padding.
	 */
	contentInset?: ChatPanelInset;
	/**
	 * Pinned below the content (for example the composer).
	 */
	footer?: ReactNode;
	/**
	 * Inline padding of the footer. `none` bleeds to the edges.
	 */
	footerInset?: ChatPanelInset;
	/**
	 * Shows the built-in close button in the title row.
	 */
	hasCloseButton?: boolean;
	/**
	 * Draws a rule under the header.
	 */
	hasHeaderDivider?: boolean;
	/**
	 * Fades the bottom edge of the content area into the footer, so text
	 * dissolves as it scrolls under it. The last line stays clear when the
	 * content is scrolled to the end.
	 */
	hasContentFade?: boolean;
	/**
	 * Draws a rule above the footer.
	 */
	hasFooterDivider?: boolean;
	/**
	 * Shows a floating button over the bottom of the content area while it is
	 * scrolled away from the end, and keeps the view at the end as the content
	 * grows, but only while the person is already at the end. Pass
	 * `scrollToLatestLabel` with it.
	 */
	hasScrollToLatest?: boolean;
	/**
	 * Pinned below the title row (for example a summary strip, context rows or
	 * tabs).
	 */
	header?: ReactNode;
	/**
	 * Inline padding of the header. `none` bleeds to the edges (for example for
	 * a tab bar).
	 */
	headerInset?: ChatPanelInset;
	/**
	 * Called with the dropped files. Setting it turns on drag and drop for the
	 * whole panel: files dragged anywhere over it show an overlay on the content
	 * area, and are handed to this handler on drop. Dragged text and links are
	 * ignored. A drop that a child (such as Chat Input) already handled is not
	 * handed over again.
	 */
	onFilesDrop?: (files: File[]) => void;
	/**
	 * Called when the close button is clicked.
	 */
	onClose?: () => void;
	/**
	 * Screen-reader text and tooltip for the scroll to latest button (for
	 * example "Scroll to latest"). Needed when `hasScrollToLatest` is on.
	 */
	scrollToLatestLabel?: string;
	/**
	 * Preset width, the same as the PDS Drawer sizes: `m` is 25rem and `l` is
	 * 37.5rem.
	 */
	size?: ChatPanelSize;
	/**
	 * The title row, beside the close button (for example an Identity Block).
	 */
	title?: ReactNode;
}

/**
 * ChatPanel UI component. The frame of an AI chat drawer or docked panel: a
 * title row, a pinned header, a scrolling content area and a pinned footer.
 * Every region is a slot.
 */
export const ChatPanel = ({
	children,
	className,
	closeButtonLabel,
	contentInset = 'default',
	dropLabel,
	footer,
	footerInset = 'default',
	hasCloseButton = true,
	hasContentFade = false,
	hasFooterDivider = false,
	hasHeaderDivider = false,
	hasScrollToLatest = false,
	header,
	headerInset = 'default',
	onClose,
	onFilesDrop,
	scrollToLatestLabel,
	size = 'm',
	title,
	...props
}: ChatPanelProps) => {
	const baseClass = 'pds-chat-panel';
	const hasTitleRow = Boolean(title) || hasCloseButton;

	// Scroll to latest. A marker at the end of the content tells whether the
	// person is at the end. A scroll event (which content growth does not
	// cause) tells whether they are following it, so growth pushes the marker
	// out of view only while they are, and then we scroll to the end.
	const contentRef = useRef<HTMLDivElement>(null);
	const endRef = useRef<HTMLDivElement>(null);
	const isFollowing = useRef(true);
	const [isAtEnd, setIsAtEnd] = useState(true);

	useEffect(() => {
		const content = contentRef.current;
		const end = endRef.current;
		if (!hasScrollToLatest || !content || !end) return;

		const slack = 24;
		const scrollToEnd = () => content.scrollTo({ top: content.scrollHeight });

		const observer = new IntersectionObserver(
			([entry]) => {
				setIsAtEnd(entry.isIntersecting || isFollowing.current);
				if (!entry.isIntersecting && isFollowing.current) scrollToEnd();
			},
			{ root: content, rootMargin: `0px 0px ${slack}px 0px` },
		);
		observer.observe(end);

		const onScroll = () => {
			isFollowing.current =
				content.scrollHeight - content.scrollTop - content.clientHeight <=
				slack;
		};
		content.addEventListener('scroll', onScroll, { passive: true });

		return () => {
			observer.disconnect();
			content.removeEventListener('scroll', onScroll);
		};
	}, [hasScrollToLatest]);

	const handleScrollToLatest = () => {
		const content = contentRef.current;
		if (!content) return;
		const reduceMotion = window.matchMedia(
			'(prefers-reduced-motion: reduce)',
		).matches;
		isFollowing.current = true;
		content.scrollTo({
			behavior: reduceMotion ? 'auto' : 'smooth',
			top: content.scrollHeight,
		});
		// The button goes away once the end is reached; keep focus in the stream.
		content.focus({ preventScroll: true });
	};

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

	const canDrop = Boolean(onFilesDrop);
	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 panel 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;
		dragDepth.current = 0;
		setIsDragOver(false);
		// dragover already cancelled the default, so a child that took the drop
		// (Chat Input) is told apart by it calling preventDefault on this drop
		// event before it bubbles here.
		if (event.defaultPrevented) return;
		event.preventDefault();
		const files = Array.from(event.dataTransfer.files);
		if (files.length) onFilesDrop?.(files);
	};

	return (
		<div
			className={mergeClasses([baseClass, `${baseClass}--${size}`, className])}
			onDragEnter={handleDragEnter}
			onDragLeave={handleDragLeave}
			onDragOver={handleDragOver}
			onDrop={handleDrop}
			{...props}
		>
			{hasTitleRow && (
				<div className={`${baseClass}__title-row`}>
					{title && <div className={`${baseClass}__title`}>{title}</div>}
					{hasCloseButton && (
						<CloseButton
							ariaLabel={closeButtonLabel}
							className={`${baseClass}__close`}
							onClick={onClose}
							size='m'
						/>
					)}
				</div>
			)}
			{header && (
				<div
					className={mergeClasses([
						`${baseClass}__header`,
						`${baseClass}__header--inset-${headerInset}`,
						hasHeaderDivider ? `${baseClass}__header--divider` : null,
					])}
				>
					{header}
				</div>
			)}
			<div className={`${baseClass}__body`}>
				<div
					ref={contentRef}
					className={mergeClasses([
						`${baseClass}__content`,
						`${baseClass}__content--inset-${contentInset}`,
						hasContentFade ? `${baseClass}__content--fade` : null,
					])}
					tabIndex={hasScrollToLatest ? -1 : undefined}
				>
					{children}
					{hasScrollToLatest && (
						<div
							ref={endRef}
							aria-hidden='true'
							className={`${baseClass}__end`}
						/>
					)}
				</div>
				{hasScrollToLatest && !isAtEnd && (
					<div className={`${baseClass}__scroll`}>
						<IconButton
							ariaLabel={scrollToLatestLabel ?? ''}
							className={`${baseClass}__scroll-button`}
							hasBorder
							iconName='anglesDown'
							onClick={handleScrollToLatest}
							size='s'
						/>
					</div>
				)}
				{canDrop && (
					<>
						{isDragOver && (
							<div aria-hidden='true' className={`${baseClass}__drop`}>
								<Icon
									className={`${baseClass}__drop-icon`}
									iconName='file'
									size='4xl'
								/>
								{dropLabel && (
									<span className={`${baseClass}__drop-label pds-heading-l`}>
										{dropLabel}
									</span>
								)}
							</div>
						)}
						<span aria-live='polite' className='visually-hidden' role='status'>
							{isDragOver ? dropLabel : null}
						</span>
					</>
				)}
			</div>
			{footer && (
				<div
					className={mergeClasses([
						`${baseClass}__footer`,
						`${baseClass}__footer--inset-${footerInset}`,
						hasFooterDivider ? `${baseClass}__footer--divider` : null,
					])}
				>
					{footer}
				</div>
			)}
		</div>
	);
};