← All proposals

Chat Attachment

Net-new component: one attached item for chat.

Net-new componentOpen in Storybook

proposals/ChatAttachment/ChatAttachment.tsx159 lines · View on GitHub
import { ComponentPropsWithoutRef } from 'react';

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

import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';

import './chat-attachment.css';

export type ChatAttachmentFileType = 'file' | 'image';

export type ChatAttachmentStatus = 'default' | 'error';

export interface ChatAttachmentProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'children'
> {
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Why the attachment failed, for example "Upload failed". Shown in the
	 * tooltip and read by screen readers. Used with `status='error'`.
	 */
	errorMessage?: string;
	/**
	 * Name of the file. Shown in full in the tooltip and to screen readers;
	 * the visible text is shortened in the middle when it is long.
	 */
	fileName: string;
	/**
	 * What kind of file this is. `image` shows a square thumbnail (or an image
	 * icon when there is no `previewUrl`); `file` shows an icon and the name.
	 */
	fileType?: ChatAttachmentFileType;
	/**
	 * Called when the remove button is pressed. The button is only rendered
	 * when this is set.
	 */
	onRemove?: () => void;
	/**
	 * URL of the image to show as a thumbnail. Only used when `fileType` is
	 * `image`.
	 */
	previewUrl?: string;
	/**
	 * Accessible label of the remove button, for example "Remove report.md".
	 * Required when `onRemove` is set.
	 */
	removeLabel?: string;
	/**
	 * `error` marks the attachment as failed: critical colors and an
	 * exclamation icon, with `errorMessage` as the reason.
	 */
	status?: ChatAttachmentStatus;
}

// 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(' ');

// A name longer than MAX_NAME_LENGTH keeps the start of the name and its
// extension, with an ellipsis between (`longfilen….md`). A name with no
// extension keeps its last TAIL_LENGTH characters instead.
const MAX_NAME_LENGTH = 24;
const TAIL_LENGTH = 6;

const shortenName = (name: string) => {
	if (name.length <= MAX_NAME_LENGTH) return name;
	const extension = name.match(/\.[^.]{1,8}$/)?.[0];
	const tail = extension ?? name.slice(-TAIL_LENGTH);
	return `${name.slice(0, MAX_NAME_LENGTH - tail.length - 1)}…${tail}`;
};

/**
 * ChatAttachment UI component. One attached file or image in a chat, with an
 * optional remove button.
 */
export const ChatAttachment = ({
	className,
	errorMessage,
	fileName,
	fileType = 'file',
	onRemove,
	previewUrl,
	removeLabel,
	status = 'default',
	...props
}: ChatAttachmentProps) => {
	const baseClass = 'pds-chat-attachment';
	const isImage = fileType === 'image';
	const isError = status === 'error';

	return (
		<Tooltip
			className={mergeClasses([baseClass, className])}
			content={
				isError && errorMessage ? `${fileName} — ${errorMessage}` : fileName
			}
			customTrigger={
				<span
					className={mergeClasses([
						`${baseClass}__item`,
						'pds-copy-2xs',
						isImage ? `${baseClass}__item--image` : null,
						isError ? `${baseClass}__item--error` : null,
					])}
				>
					{isError && errorMessage && (
						<span className='visually-hidden'>{errorMessage}</span>
					)}
					{isImage && previewUrl && !isError ? (
						<img
							alt={fileName}
							className={`${baseClass}__image`}
							src={previewUrl}
						/>
					) : (
						<>
							<Icon
								className={`${baseClass}__icon`}
								iconName={
									isImage ? 'image' : isError ? 'diamondExclamation' : 'file'
								}
								size='s'
							/>
							{isImage ? (
								<span className='visually-hidden'>{fileName}</span>
							) : (
								<>
									<span aria-hidden='true' className={`${baseClass}__name`}>
										{shortenName(fileName)}
									</span>
									<span className='visually-hidden'>{fileName}</span>
								</>
							)}
						</>
					)}
					{onRemove && (
						<IconButton
							ariaLabel={removeLabel ?? ''}
							className={`${baseClass}__remove`}
							hasTooltip={false}
							iconName='xmark'
							onClick={onRemove}
							size='xs'
							title={undefined}
						/>
					)}
				</span>
			}
			disableTapTrigger={!isError}
			{...props}
		/>
	);
};