All files / src/components/ui alert.tsx

100% Statements 4/4
100% Branches 13/13
100% Functions 1/1
100% Lines 4/4

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 118 119 120 121 122                                                                                          55x                 55x                                                             166x   166x                                                                    
import type { ReactNode } from "react";
import {
	AlertCircle,
	AlertTriangle,
	CheckCircle2,
	Info,
	X,
} from "lucide-react";
import { cn } from "../../lib/utils";
import {
	STATUS_BAND_TINT,
	STATUS_CODE_TYPE,
	STATUS_INK,
	type StatusTone,
} from "./status-ink";
 
/**
 * Alert — the printed notice band (`.notice`, mockups §7.9).
 *
 * A band, not a rounded box: hairlines top and bottom, the tint behind, the
 * message in page ink. Hairline dividers, not boxes — so it sits inside a card
 * or across a column without inventing a second container.
 *
 * ACCESSIBILITY CONTRACT
 *   - `error` and `warning` get `role="alert"` (assertive: the user is being
 *     interrupted because something is wrong)
 *   - `success`, `info` and `default` get `role="status"` (polite: announced at
 *     the next pause, which is what a confirmation wants)
 *   - `live={false}` drops the live region entirely, for a band that is part of
 *     the page on first paint rather than a response to an action — a live
 *     region that was already there when the page loaded announces nothing
 *     useful and competes with the heading
 *   - the icon is `aria-hidden`; the tone is carried by the `title` text, so
 *     colour is never the only signal (WCAG 1.4.1)
 *   - the dismiss control is a named button, not a bare glyph
 *
 * CONTRAST
 * The body runs in `--foreground-default`, which is 7.80:1 or better on every
 * tint in both palettes and both themes. The title and icon use the printed
 * status ink from `status-ink.ts` (4.66:1 worst case) — see that file for the
 * AA argument and the measured table.
 */
 
type AlertTone = StatusTone;
 
const toneIcon: Record<AlertTone, typeof Info> = {
	success: CheckCircle2,
	warning: AlertTriangle,
	error: AlertCircle,
	info: Info,
	default: Info,
};
 
/** Assertive for the two tones that interrupt; polite for the rest. */
const toneRole: Record<AlertTone, "alert" | "status"> = {
	success: "status",
	warning: "alert",
	error: "alert",
	info: "status",
	default: "status",
};
 
interface AlertProps {
	/** The printed code above the message: "COULD NOT SAVE", "PUBLISHED". */
	title?: string;
	children?: ReactNode;
	variant?: AlertTone;
	/** Pass false for a band that is part of the page, not a response to an action. */
	live?: boolean;
	/** Render the dismiss control. */
	onDismiss?: () => void;
	/** Accessible name of the dismiss control. */
	dismissLabel?: string;
	className?: string;
}
 
export function Alert({
	title,
	children,
	variant = "default",
	live = true,
	onDismiss,
	dismissLabel = "Dismiss",
	className,
}: AlertProps) {
	const Icon = toneIcon[variant];
 
	return (
		<div
			{...(live ? { role: toneRole[variant] } : {})}
			className={cn(
				"flex items-start gap-2 border-y border-border-default px-4 py-2 text-sm text-foreground-default",
				STATUS_BAND_TINT[variant],
				className,
			)}
		>
			<Icon
				aria-hidden="true"
				className={cn("mt-0.5 h-4 w-4 shrink-0", STATUS_INK[variant])}
			/>
			<div className="min-w-0 flex-1">
				{title && (
					<p className={cn(STATUS_CODE_TYPE, STATUS_INK[variant])}>{title}</p>
				)}
				{children && (
					<div className={cn("leading-snug", title && "mt-1")}>{children}</div>
				)}
			</div>
			{onDismiss && (
				<button
					type="button"
					onClick={onDismiss}
					aria-label={dismissLabel}
					className="-mr-1 shrink-0 rounded-sm p-1 text-foreground-muted transition-colors hover:text-foreground-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
				>
					<X className="h-4 w-4" />
				</button>
			)}
		</div>
	);
}