move tsx kit -> uikit
This commit is contained in:
216
web/uikit/Toast.tsx
Normal file
216
web/uikit/Toast.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { createContext, useContext, createSignal, createEffect, onCleanup, For, Show, JSXElement } from "solid-js";
|
||||
import { Icon } from "./Icons.tsx";
|
||||
|
||||
export type ToastType = "success" | "error" | "warning" | "info" | "generic";
|
||||
export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
|
||||
|
||||
export interface ToastConfig {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
duration?: number | null;
|
||||
dismissible?: boolean;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
interface ToastInstance extends ToastConfig {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (config: ToastConfig) => string;
|
||||
removeToast: (id: string) => void;
|
||||
success: (message: string, options?: Partial<ToastConfig>) => string;
|
||||
error: (message: string, options?: Partial<ToastConfig>) => string;
|
||||
warning: (message: string, options?: Partial<ToastConfig>) => string;
|
||||
info: (message: string, options?: Partial<ToastConfig>) => string;
|
||||
generic: (message: string, options?: Partial<ToastConfig>) => string;
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
position?: ToastPosition;
|
||||
maxToasts?: number;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const TOAST_TYPE_ICONS: Record<ToastType, string | null> = {
|
||||
success: "circle-check",
|
||||
error: "circle-xmark",
|
||||
warning: "triangle-exclamation",
|
||||
info: "circle-info",
|
||||
generic: null,
|
||||
};
|
||||
|
||||
const CONTAINER_BASE = "fixed z-[200] flex flex-col gap-2";
|
||||
const CONTAINER_POSITIONS: Record<ToastPosition, string> = {
|
||||
"top-right": "top-4 right-4",
|
||||
"top-left": "top-4 left-4",
|
||||
"bottom-right": "bottom-4 right-4 flex-col-reverse",
|
||||
"bottom-left": "bottom-4 left-4 flex-col-reverse",
|
||||
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
||||
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
|
||||
};
|
||||
|
||||
const TOAST_BASE = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out";
|
||||
const TOAST_TYPE_BORDER: Record<ToastType, string> = {
|
||||
success: "border-l-green-700",
|
||||
error: "border-l-red-700",
|
||||
warning: "border-l-yellow-500",
|
||||
info: "border-l-sky-800",
|
||||
generic: "border-l-neutral-400",
|
||||
};
|
||||
const TOAST_ICON_COLOR: Record<ToastType, string> = {
|
||||
success: "text-green-600",
|
||||
error: "text-red-600",
|
||||
warning: "text-yellow-600",
|
||||
info: "text-sky-700",
|
||||
generic: "",
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 5000;
|
||||
|
||||
let toastCounter = 0;
|
||||
function generateId(): string {
|
||||
return "toast-" + (++toastCounter) + "-" + Date.now();
|
||||
}
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: ToastInstance;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem(props: ToastItemProps) {
|
||||
const type = () => props.toast.type ?? "info";
|
||||
const duration = () => props.toast.duration ?? DEFAULT_DURATION;
|
||||
const dismissible = () => props.toast.dismissible !== false;
|
||||
const showProgress = () => props.toast.showProgress !== false;
|
||||
|
||||
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||
let animationFrameRef: number | null = null;
|
||||
let startTime = Date.now();
|
||||
|
||||
const [isExiting, setIsExiting] = createSignal(false);
|
||||
const [progress, setProgress] = createSignal(100);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => props.onDismiss(props.toast.id), 150);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const dur = duration();
|
||||
if (dur !== null && dur > 0) {
|
||||
timeoutRef = setTimeout(handleDismiss, dur);
|
||||
startTime = Date.now();
|
||||
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 100 - (elapsed / dur) * 100);
|
||||
setProgress(remaining);
|
||||
if (remaining > 0) {
|
||||
animationFrameRef = requestAnimationFrame(updateProgress);
|
||||
}
|
||||
};
|
||||
animationFrameRef = requestAnimationFrame(updateProgress);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (timeoutRef) {
|
||||
clearTimeout(timeoutRef);
|
||||
}
|
||||
if (animationFrameRef) {
|
||||
cancelAnimationFrame(animationFrameRef);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const icon = () => TOAST_TYPE_ICONS[type()];
|
||||
const shouldShowProgress = () => showProgress() && duration() !== null && duration()! > 0;
|
||||
|
||||
const toastClass = () => {
|
||||
let c = TOAST_BASE + " " + TOAST_TYPE_BORDER[type()];
|
||||
if (isExiting()) c += " opacity-0 translate-x-2";
|
||||
return c;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={toastClass()} role="alert">
|
||||
<div class="flex items-start gap-3 p-4">
|
||||
<Show when={icon()}>
|
||||
<Icon icon={icon()} size={20} class={"shrink-0 mt-0.5 " + TOAST_ICON_COLOR[type()]}/>
|
||||
</Show>
|
||||
<div class="flex-1 text-sm text-neutral-800">{props.toast.message}</div>
|
||||
<Show when={dismissible()}>
|
||||
<button onclick={handleDismiss} class="shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors" aria-label="Dismiss">
|
||||
<Icon icon="xmark" size={16}/>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={shouldShowProgress()}>
|
||||
<div class="h-1 w-full bg-neutral-100">
|
||||
<div class="h-full bg-neutral-300" style={{ width: progress() + "%" }}/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastProvider(props: ToastProviderProps) {
|
||||
const [toasts, setToasts] = createSignal<ToastInstance[]>([]);
|
||||
|
||||
const position = () => props.position ?? "bottom-right";
|
||||
const maxToasts = () => props.maxToasts ?? 5;
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const addToast = (config: ToastConfig): string => {
|
||||
const id = generateId();
|
||||
const newToast: ToastInstance = { ...config, id };
|
||||
setToasts((prev) => {
|
||||
const updated = [...prev, newToast];
|
||||
if (updated.length > maxToasts()) {
|
||||
return updated.slice(-maxToasts());
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
const success = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "success", ...options });
|
||||
const error = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "error", ...options });
|
||||
const warning = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "warning", ...options });
|
||||
const info = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "info", ...options });
|
||||
const generic = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "generic", ...options });
|
||||
|
||||
const value: ToastContextValue = {
|
||||
addToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
generic,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{props.children}
|
||||
<div class={CONTAINER_BASE + " " + CONTAINER_POSITIONS[position()]} aria-live="polite" aria-label="Notifications">
|
||||
<For each={toasts()}>{(toast) => (
|
||||
<ToastItem toast={toast} onDismiss={removeToast}/>
|
||||
)}</For>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user