All files / src/components/ui avatar.tsx

100% Statements 11/11
94.11% Branches 16/17
100% Functions 5/5
100% Lines 11/11

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117                                                                      1x                                                         22x   37x   31x                               19x 19x   19x             19x 5x       2x           14x                      
import { useState } from "react";
import { cn } from "../../lib/utils";
 
/**
 * Avatar — the job book's monogram tile (`.mono`, mockups §7.3).
 *
 * A studio with a logo shows the logo; a studio without one gets a tile in the
 * committed-field colour with its initials printed on it. Square by default
 * (2px radius, like every other printed thing); `shape="round"` is for the one
 * place roundness means something — a person, in the top bar.
 *
 * COLOUR: `--primary-600` with `--foreground-inverse`, not the mockup's
 * `--action`/`--on-action`. The tile was written while the legacy palette
 * override was still in place, and that override themed `primary-600` but not
 * `--action`, so `--action` would have painted one laminate-teal tile into an
 * otherwise un-reskinned portal. The override is gone and both tokens are now
 * the same laminate teal (`primary-600` is #255D52), so this is a free choice
 * today rather than a forced one.
 *
 * Ratios for the pair, text on fill: 6.63:1 light, 7.39:1 dark — AA with no
 * `dark:` override. (`primary-500`, which `button.tsx` uses, was only 3.53:1
 * on the legacy palette and would not have been.)
 *
 * ACCESSIBILITY CONTRACT
 *   - with a `src`: a real `<img>` with `alt={name}`
 *   - without one (or after the image fails): the tile is `role="img"` with
 *     `aria-label={name}`, because "AS" read out letter by letter is not the
 *     name of anything
 *   - `decorative` drops the name entirely for a tile that sits next to the
 *     same text, so a screen reader does not say it twice
 */
 
type AvatarSize = "sm" | "md" | "lg";
 
/** 1.75 / 2.25 / 3rem — `.mono-sm`, `.mono`, `.mono-lg`. */
const sizeClasses: Record<AvatarSize, string> = {
	sm: "size-7 text-xs",
	md: "size-9 text-sm",
	lg: "size-12 text-base",
};
 
interface AvatarProps {
	/** The name the tile stands for: the label, and the source of the initials. */
	name: string;
	/** Logo or photo. Falls back to the monogram if it fails to load. */
	src?: string | null;
	size?: AvatarSize;
	shape?: "square" | "round";
	/**
	 * True when the same name is already printed beside the tile. Removes it
	 * from the accessibility tree rather than repeating it.
	 */
	decorative?: boolean;
	className?: string;
}
 
/**
 * First letters of the first two words, capitalised — "Asha Interiors" → "AI".
 *
 * Falls back to the first character for a single word, and to nothing at all
 * for an empty or punctuation-only name (the tile then prints blank rather
 * than a stray glyph).
 */
export function initialsFor(name: string): string {
	return name
		.split(/\s+/)
		.filter((word) => /\p{L}|\p{N}/u.test(word))
		.slice(0, 2)
		.map((word) => (word.match(/\p{L}|\p{N}/u) ?? [""])[0])
		.join("")
		.toUpperCase();
}
 
export function Avatar({
	name,
	src,
	size = "md",
	shape = "square",
	decorative = false,
	className,
}: AvatarProps) {
	// The src that failed, not a boolean: a new src is a new chance, and
	// storing the URL derives that in render instead of resetting a flag in an
	// effect (which would paint the broken image for one frame first).
	const [failedSrc, setFailedSrc] = useState<string | null>(null);
	const failed = src != null && failedSrc === src;
 
	const shell = cn(
		"inline-grid place-items-center overflow-hidden font-semibold tracking-tight select-none bg-primary-600 text-foreground-inverse",
		shape === "round" ? "rounded-full" : "rounded-sm",
		sizeClasses[size],
		className,
	);
 
	if (src && !failed) {
		return (
			<img
				src={src}
				alt={decorative ? "" : name}
				onError={() => setFailedSrc(src)}
				className={cn(shell, "object-cover")}
			/>
		);
	}
 
	return (
		<span
			{...(decorative
				? { "aria-hidden": true }
				: { role: "img", "aria-label": name })}
			className={shell}
		>
			{initialsFor(name)}
		</span>
	);
}