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>
);
}
|