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}
/>
);
};
Chat Attachment
Net-new component: one attached item for chat.
Net-new componentOpen in Storybook