150 lines
3.8 KiB
TypeScript
150 lines
3.8 KiB
TypeScript
import { AlertTriangle, ArrowUpRight, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import {
|
|
StatusIcon as SemanticStatusIcon,
|
|
type StatusTone
|
|
} from "@/shared/ui/status";
|
|
import {
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
MAP_READABLE_RADIUS_CLASS_NAME
|
|
} from "./map-control-styles";
|
|
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
|
|
|
const noticeTones = {
|
|
info: "info",
|
|
success: "success",
|
|
warning: "warning",
|
|
error: "danger",
|
|
loading: "info"
|
|
} satisfies Record<MapNoticeTone, StatusTone>;
|
|
|
|
export function MapNoticeContent({
|
|
title,
|
|
message,
|
|
tone = "info",
|
|
actionLabel,
|
|
onAction
|
|
}: MapNoticeOptions) {
|
|
const Icon = getNoticeIcon(tone);
|
|
|
|
return (
|
|
<MapNoticeFrame tone={tone}>
|
|
<div data-slot="map-notice-summary" className="flex items-start gap-3 py-3.5 pl-4 pr-12">
|
|
<MapNoticeIcon tone={tone} icon={Icon} />
|
|
<MapNoticeBody title={title} message={message} />
|
|
</div>
|
|
{actionLabel && onAction ? (
|
|
<div data-slot="map-notice-actions" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-3 pl-14 pr-3">
|
|
<span className="text-xs leading-5 text-[var(--ink-muted)]">保留至手动关闭</span>
|
|
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
|
</div>
|
|
) : null}
|
|
</MapNoticeFrame>
|
|
);
|
|
}
|
|
|
|
export function MapNoticeCloseIcon() {
|
|
return (
|
|
<>
|
|
<span className="sr-only">关闭通知</span>
|
|
<X size={15} aria-hidden="true" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
|
// Sonner restores its saved focus on blur. Persistent action buttons must
|
|
// let the user move directly to another workbench control instead.
|
|
onFocus={(event) => event.stopPropagation()}
|
|
className={cn(
|
|
"glass-transient relative w-full overflow-hidden border text-sm text-slate-900",
|
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapNoticeIcon({
|
|
tone,
|
|
icon: Icon
|
|
}: {
|
|
tone: MapNoticeTone;
|
|
icon: ReturnType<typeof getNoticeIcon>;
|
|
}) {
|
|
return (
|
|
<SemanticStatusIcon
|
|
tone={noticeTones[tone]}
|
|
activity={tone === "loading" ? "loading" : "static"}
|
|
size="md"
|
|
className={cn("mt-0.5", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
|
>
|
|
<Icon size={17} />
|
|
</SemanticStatusIcon>
|
|
);
|
|
}
|
|
|
|
function MapNoticeBody({
|
|
title,
|
|
message
|
|
}: {
|
|
title?: string;
|
|
message: string;
|
|
}) {
|
|
return (
|
|
<div className="min-w-0 flex-1">
|
|
{title ? (
|
|
<span className="block break-words text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
|
) : null}
|
|
<span className={cn("block break-words text-sm leading-6 text-slate-600", title && "mt-1")}>
|
|
{message}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
onClick={onClick}
|
|
size="sm"
|
|
variant="outline"
|
|
className={cn(
|
|
"pointer-events-auto h-9 max-w-full gap-1.5 whitespace-normal border-[var(--surface-border)] bg-[var(--action-soft)] px-2.5 text-xs text-[var(--action-blue-hover)]",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME
|
|
)}
|
|
>
|
|
{label}
|
|
<ArrowUpRight size={14} aria-hidden="true" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function getNoticeIcon(tone: MapNoticeTone) {
|
|
if (tone === "success") {
|
|
return CheckCircle2;
|
|
}
|
|
|
|
if (tone === "warning") {
|
|
return AlertTriangle;
|
|
}
|
|
|
|
if (tone === "error") {
|
|
return XCircle;
|
|
}
|
|
|
|
if (tone === "loading") {
|
|
return Loader2;
|
|
}
|
|
|
|
return Info;
|
|
}
|