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>
);
};
Chat Panel
A layout shell for the content of an AI chat drawer or docked side panel.
Net-new componentOpen in Storybook