2b26fe1efa
A failed or stopped clone also raised a toast on the same corner, a layer above the dock, so it covered the chip's x / Retry / Stop and repeated the same long message; the chip now says it alone, and every toast stacks above the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH) wraps inside the chip and inside a toast instead of running off the screen. Open Settings -> Git is a router link; a plain <a href> reloaded the app.
66 lines
2.5 KiB
TypeScript
66 lines
2.5 KiB
TypeScript
/**
|
|
* @file Toaster.tsx
|
|
* @description Renders the app-wide toast queue from `lib/toast.ts`, mounted
|
|
* once in `App.tsx`. Two live regions — `status` for success/info, `alert` for
|
|
* errors — so a screen reader announces each message at the right urgency.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertCircle, Check, Info, X } from "lucide-react";
|
|
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
|
|
|
const TONE: Record<ToastItem["kind"], string> = {
|
|
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
|
info: "border-border bg-surface-2 text-fg-primary",
|
|
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
|
};
|
|
|
|
function Item({ item }: { item: ToastItem }) {
|
|
const { t } = useTranslation("common");
|
|
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
|
return (
|
|
<div
|
|
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
|
>
|
|
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
|
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
|
|
{item.message}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => dismissToast(item.id)}
|
|
className="opacity-70 hover:opacity-100 p-1 -m-0.5"
|
|
aria-label={t("dismiss")}
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The single toast renderer. */
|
|
export function Toaster() {
|
|
const [items, setItems] = useState<ToastItem[]>([]);
|
|
useEffect(() => subscribeToasts(setItems), []);
|
|
const errors = items.filter((i) => i.kind === "error");
|
|
const rest = items.filter((i) => i.kind !== "error");
|
|
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
|
|
// to its height), so a toast never covers a chip's buttons.
|
|
return (
|
|
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
|
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
|
{rest.map((i) => (
|
|
<Item key={i.id} item={i} />
|
|
))}
|
|
</div>
|
|
<div role="alert" aria-live="assertive" className="flex flex-col gap-2">
|
|
{errors.map((i) => (
|
|
<Item key={i.id} item={i} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|