← All proposals

Avatar Label

The PDS v2 AvatarLabel (src/components/identity/AvatarLabel, branch v2.x) with every Avatar replaced by the Agent Avatar proposal.

Net-new componentOpen in Storybook

proposals/AvatarLabel/AvatarLabel.tsx138 lines · View on GitHub
import {
	cloneElement,
	ComponentPropsWithoutRef,
	isValidElement,
	ReactNode,
} from 'react';

import { AgentAvatar, AgentAvatarProps } from '../AgentAvatar/AgentAvatar';

import './avatar-label.css';

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

/**
 * Prop types for AvatarLabel
 */
export interface AvatarLabelProps extends ComponentPropsWithoutRef<'div'> {
	/**
	 * Aria label for the whole component when used as a link via
	 * `linkContent`. Only needed if the visible `label` text isn't already a
	 * sufficient accessible name.
	 */
	ariaLabel?: string;
	/**
	 * Optional text after the label that says who the agent is acting for (for
	 * example "on behalf of alex@example.com"). Pass the whole phrase,
	 * since the wording and word order change by language. Shown in the
	 * secondary text color and truncates before the label does.
	 */
	attribution?: ReactNode;
	/**
	 * Props to pass to the AgentAvatar component. `label` (the screen-reader text that says it is an agent) is required. `size` is set automatically
	 * based on AvatarLabel's own `size` prop and cannot be overridden here.
	 */
	avatarProps: Omit<AgentAvatarProps, 'ariaLabel' | 'linkContent' | 'size'>;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Show the avatar? Set to `false` to show only the name (and attribution),
	 * for example when the avatar is already shown on a neighboring item. The
	 * avatar's `label` stays in the page as screen-reader-only text, so it is
	 * still announced as an agent.
	 */
	hasAvatar?: boolean;
	/**
	 * Text label displayed next to the avatar.
	 */
	label: ReactNode;
	/**
	 * Optionally makes the whole component a link. Pass a fully-formed,
	 * router-agnostic link element (e.g. an anchor or your router's link
	 * component); the avatar and label render as its children, so clicking
	 * anywhere on either navigates.
	 */
	linkContent?: ReactNode;
	/**
	 * Controls both the avatar size and the label's text size.
	 */
	size?: 'xs' | 's' | 'm';
}

/**
 * AvatarLabel UI component
 */
export const AvatarLabel = ({
	ariaLabel,
	attribution,
	avatarProps,
	className,
	hasAvatar = true,
	label,
	linkContent,
	size = 's',
	...props
}: AvatarLabelProps) => {
	// Set up classes.
	const baseClass = 'pds-avatar-label';
	const sizeClass = `${baseClass}--${size}`;

	// The avatar plus label — rendered directly, or as the children of
	// `linkContent` when a link is provided.
	const content = (
		<>
			{hasAvatar ? (
				<AgentAvatar {...avatarProps} size={size} />
			) : (
				<span className='visually-hidden'>{avatarProps.label}</span>
			)}
			<span className={`${baseClass}__label`}>{label}</span>
			{attribution && (
				<>
					{' '}
					<span
						className={`${baseClass}__attribution`}
						title={typeof attribution === 'string' ? attribution : undefined}
					>
						{attribution}
					</span>
				</>
			)}
		</>
	);

	// When a link element is provided, render the content as its children so
	// the whole component becomes a link.
	const linkMarkup =
		linkContent && isValidElement(linkContent)
			? cloneElement(
					linkContent,
					Object.assign({}, linkContent.props, {
						className: `${baseClass}__link`,
						'aria-label': ariaLabel,
					}),
					content,
				)
			: null;

	// Render the output
	return (
		<div
			className={mergeClasses([
				baseClass,
				sizeClass,
				attribution ? `${baseClass}--has-attribution` : null,
				className ?? '',
			])}
			{...props}
		>
			{linkContent ? linkMarkup : content}
		</div>
	);
};