feat(ui): redesign recipient avatar stack hover popover (#3072)

This commit is contained in:
Ephraim Duncan
2026-09-17 13:36:15 +10:00
committed by GitHub
parent cf326f825d
commit 0c6249744a
4 changed files with 254 additions and 222 deletions
@@ -1,66 +0,0 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
@@ -14,9 +15,10 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
}
return (
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -1,16 +1,33 @@
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import {
getExtraRecipientsType,
getRecipientType,
RecipientStatusType,
} from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { useMemo } from 'react';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import {
CheckIcon,
CircleCheckIcon,
CircleDashedIcon,
CircleXIcon,
ClockIcon,
CopyIcon,
MailOpenIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
@@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
const sections = useMemo(() => {
const groups = groupRecipientsByStatus(recipients);
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const rejectedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
];
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
...section,
recipients: groups[section.type],
})).filter((section) => section.recipients.length > 0);
}, [recipients]);
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
trigger={children || <RecipientAvatarStack recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
className:
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
side: position,
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
// Opened via hover, so don't steal focus from wherever the user was.
onOpenAutoFocus: (event) => event.preventDefault(),
}}
>
{completedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
{sections.map((section) => (
<div key={section.type} className="px-3 py-2">
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
<span>{_(section.label)}</span>
<span>{section.recipients.length}</span>
</div>
<div className="mt-1">
{section.recipients.map((recipient) => (
<RecipientRow
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
recipient={recipient}
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
))}
</div>
</div>
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
))}
</PopoverHover>
);
};
type RecipientRowProps = {
recipient: TRecipientLite;
signingToken: string | null;
};
const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
const onCopySigningLink = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
setIsCopied(true);
clearTimeout(copiedTimeoutRef.current ?? undefined);
copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
const content = (
<>
<StackAvatar
first={true}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
className="h-6 w-6 shrink-0 border-0 text-[10px]"
/>
<div className="min-w-0 flex-1 text-xs leading-snug">
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</>
);
if (!signingToken) {
return <div className={recipientRowClassName}>{content}</div>;
}
return (
<button
type="button"
className={cn(
recipientRowClassName,
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
)}
title={_(msg`Click to copy signing link for sending to recipient`)}
onClick={onCopySigningLink}
>
{content}
{isCopied ? (
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
) : (
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
)}
</button>
);
};
const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
const sortedRecipients = useMemo(() => {
const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
return [...rejected.sort(byId), ...others.sort(byId)];
}, [recipients]);
const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
return (
<>
{visibleRecipients.map((recipient, index) => {
const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
const zIndex = String(50 - index * 10);
if (isOverflowSlot) {
return (
<StackAvatar
key="extra-recipients"
first={index === 0}
zIndex={zIndex}
type={getExtraRecipientsType(sortedRecipients.slice(index))}
fallbackText={`+${hiddenRecipients.length + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={index === 0}
zIndex={zIndex}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
})}
</>
);
};
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
[RecipientStatusType.COMPLETED]: [],
[RecipientStatusType.REJECTED]: [],
[RecipientStatusType.WAITING]: [],
[RecipientStatusType.OPENED]: [],
[RecipientStatusType.UNSIGNED]: [],
};
for (const recipient of recipients) {
groups[getRecipientType(recipient)].push(recipient);
}
return groups;
};
const MAX_VISIBLE_AVATARS = 5;
const COPIED_INDICATOR_DURATION_MS = 2000;
const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
type RecipientStatusSection = {
type: RecipientStatusType;
label: MessageDescriptor;
icon: LucideIcon;
className: string;
/** Whether recipients in this section still need to sign, so a signing link can be copied. */
hasSigningLink: boolean;
};
const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
{
type: RecipientStatusType.COMPLETED,
label: msg`Completed`,
icon: CircleCheckIcon,
className: 'text-green-600 dark:text-green-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.REJECTED,
label: msg`Rejected`,
icon: CircleXIcon,
className: 'text-red-600 dark:text-red-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.WAITING,
label: msg`Waiting`,
icon: ClockIcon,
className: 'text-blue-600 dark:text-blue-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.OPENED,
label: msg`Opened`,
icon: MailOpenIcon,
className: 'text-amber-600 dark:text-amber-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.UNSIGNED,
label: msg`Uncompleted`,
icon: CircleDashedIcon,
className: 'text-muted-foreground',
hasSigningLink: true,
},
];
@@ -1,41 +0,0 @@
import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
});
};
return <>{renderStackAvatars(recipients)}</>;
}