move tsx kit -> uikit

This commit is contained in:
2026-07-13 09:17:11 -04:00
parent 98978e4930
commit 9243d142e9
37 changed files with 20 additions and 19 deletions

216
web/uikit/Toast.tsx Normal file
View 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>
);
}