240 lines
7.3 KiB
TypeScript
240 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import clsx from 'clsx';
|
|
import {
|
|
InformationCircleIcon,
|
|
CheckCircleIcon,
|
|
ExclamationTriangleIcon,
|
|
XCircleIcon,
|
|
XMarkIcon,
|
|
} from '@heroicons/react/20/solid';
|
|
|
|
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
|
|
|
|
export interface AlertProps {
|
|
/** Farbschema / Typ des Alerts */
|
|
tone?: AlertTone;
|
|
|
|
/** Überschrift (z.B. "Attention needed") */
|
|
title?: React.ReactNode;
|
|
|
|
/** Beschreibungstext (als string oder JSX) */
|
|
description?: React.ReactNode;
|
|
|
|
/** Bullet-List-Einträge wie im "With list"-Beispiel */
|
|
listItems?: React.ReactNode[];
|
|
|
|
/** Eigene Icon-Komponente (null = Icon komplett ausblenden) */
|
|
icon?: React.ReactNode | null;
|
|
|
|
/** Bereich für Buttons / Actions unter dem Text */
|
|
actions?: React.ReactNode;
|
|
|
|
/** Inhalt rechts (z.B. ein "Details →"-Link) */
|
|
rightContent?: React.ReactNode;
|
|
|
|
/** Linker Accent-Border (statt Outlines im Dark Mode) */
|
|
accent?: boolean;
|
|
|
|
/** Wenn gesetzt, wird ein Dismiss-X angezeigt und dieser Handler aufgerufen */
|
|
onDismiss?: () => void;
|
|
|
|
className?: string;
|
|
}
|
|
|
|
const toneConfig: Record<
|
|
AlertTone,
|
|
{
|
|
bg: string;
|
|
outline: string;
|
|
accentBorder: string;
|
|
title: string;
|
|
text: string;
|
|
icon: string;
|
|
dismissBtn: string;
|
|
}
|
|
> = {
|
|
info: {
|
|
bg: 'bg-blue-50 dark:bg-blue-500/10',
|
|
outline: 'dark:outline dark:outline-blue-500/20',
|
|
accentBorder: 'border-l-4 border-blue-400 dark:border-blue-500',
|
|
title: 'text-blue-800 dark:text-blue-200',
|
|
text: 'text-blue-700 dark:text-blue-300',
|
|
icon: 'text-blue-400 dark:text-blue-300',
|
|
dismissBtn:
|
|
'inline-flex rounded-md bg-blue-50 p-1.5 text-blue-500 hover:bg-blue-100 ' +
|
|
'focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-50 focus-visible:outline-none ' +
|
|
'dark:bg-transparent dark:text-blue-400 dark:hover:bg-blue-500/10 ' +
|
|
'dark:focus-visible:ring-blue-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-blue-900',
|
|
},
|
|
success: {
|
|
bg: 'bg-green-50 dark:bg-green-500/10',
|
|
outline: 'dark:outline dark:outline-green-500/20',
|
|
accentBorder: 'border-l-4 border-green-400 dark:border-green-500',
|
|
title: 'text-green-800 dark:text-green-200',
|
|
text: 'text-green-700 dark:text-green-200/85',
|
|
icon: 'text-green-400 dark:text-green-300',
|
|
dismissBtn:
|
|
'inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 ' +
|
|
'focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-none ' +
|
|
'dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 ' +
|
|
'dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900',
|
|
},
|
|
warning: {
|
|
bg: 'bg-yellow-50 dark:bg-yellow-500/10',
|
|
outline: 'dark:outline dark:outline-yellow-500/15',
|
|
accentBorder: 'border-l-4 border-yellow-400 dark:border-yellow-500',
|
|
title: 'text-yellow-800 dark:text-yellow-100',
|
|
text: 'text-yellow-700 dark:text-yellow-100/80',
|
|
icon: 'text-yellow-400 dark:text-yellow-300',
|
|
dismissBtn:
|
|
'inline-flex rounded-md bg-yellow-50 p-1.5 text-yellow-500 hover:bg-yellow-100 ' +
|
|
'focus-visible:ring-2 focus-visible:ring-yellow-600 focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-50 focus-visible:outline-none ' +
|
|
'dark:bg-transparent dark:text-yellow-400 dark:hover:bg-yellow-500/10 ' +
|
|
'dark:focus-visible:ring-yellow-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-yellow-900',
|
|
},
|
|
error: {
|
|
bg: 'bg-red-50 dark:bg-red-500/15',
|
|
outline: 'dark:outline dark:outline-red-500/25',
|
|
accentBorder: 'border-l-4 border-red-400 dark:border-red-500',
|
|
title: 'text-red-800 dark:text-red-200',
|
|
text: 'text-red-700 dark:text-red-200/80',
|
|
icon: 'text-red-400 dark:text-red-400',
|
|
dismissBtn:
|
|
'inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 ' +
|
|
'focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2 focus-visible:ring-offset-red-50 focus-visible:outline-none ' +
|
|
'dark:bg-transparent dark:text-red-400 dark:hover:bg-red-500/10 ' +
|
|
'dark:focus-visible:ring-red-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-red-900',
|
|
},
|
|
};
|
|
|
|
function getDefaultIcon(tone: AlertTone) {
|
|
switch (tone) {
|
|
case 'info':
|
|
return (
|
|
<InformationCircleIcon
|
|
aria-hidden="true"
|
|
className="size-5"
|
|
/>
|
|
);
|
|
case 'success':
|
|
return (
|
|
<CheckCircleIcon
|
|
aria-hidden="true"
|
|
className="size-5"
|
|
/>
|
|
);
|
|
case 'warning':
|
|
return (
|
|
<ExclamationTriangleIcon
|
|
aria-hidden="true"
|
|
className="size-5"
|
|
/>
|
|
);
|
|
case 'error':
|
|
return (
|
|
<XCircleIcon
|
|
aria-hidden="true"
|
|
className="size-5"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function Alerts({
|
|
tone = 'info',
|
|
title,
|
|
description,
|
|
listItems,
|
|
icon,
|
|
actions,
|
|
rightContent,
|
|
accent = false,
|
|
onDismiss,
|
|
className,
|
|
}: AlertProps) {
|
|
const cfg = toneConfig[tone];
|
|
const hasList = !!listItems && listItems.length > 0;
|
|
const showIcon = icon !== null;
|
|
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className={clsx(
|
|
'p-4',
|
|
cfg.bg,
|
|
accent ? cfg.accentBorder : 'rounded-md ' + cfg.outline,
|
|
accent && 'rounded-none',
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex">
|
|
{showIcon && (
|
|
<div className="shrink-0">
|
|
<span className={cfg.icon}>
|
|
{icon ?? getDefaultIcon(tone)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={clsx(
|
|
'ml-3 flex-1',
|
|
rightContent || onDismiss
|
|
? 'md:flex md:justify-between md:items-start'
|
|
: undefined,
|
|
)}
|
|
>
|
|
<div className="text-sm">
|
|
{title && (
|
|
<h3 className={clsx('font-medium', cfg.title)}>{title}</h3>
|
|
)}
|
|
|
|
{description && (
|
|
<div className={clsx('mt-2', cfg.text)}>
|
|
{typeof description === 'string' ? (
|
|
<p>{description}</p>
|
|
) : (
|
|
description
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{hasList && (
|
|
<div className={clsx(title || description ? 'mt-2' : undefined, cfg.text)}>
|
|
<ul role="list" className="list-disc space-y-1 pl-5">
|
|
{listItems!.map((item, idx) => (
|
|
<li key={idx}>{item}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{actions && <div className="mt-4">{actions}</div>}
|
|
</div>
|
|
|
|
{(rightContent || onDismiss) && (
|
|
<div className="mt-3 md:mt-0 md:ml-6 flex items-start gap-2">
|
|
{rightContent && <div className="text-sm">{rightContent}</div>}
|
|
|
|
{onDismiss && (
|
|
<button
|
|
type="button"
|
|
onClick={onDismiss}
|
|
className={cfg.dismissBtn}
|
|
>
|
|
<span className="sr-only">Dismiss</span>
|
|
<XMarkIcon aria-hidden="true" className="size-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Alerts;
|