package webui import "kjol/vdom" // Port of web/kit/Toast.tsx. // // NOTE: Solid's context API (useToast + addToast/success/error/warning/info/ // generic) is dropped — there is no context or portal in the neutral runtime. // Callers own the []Toast list and its removal instead: build the toasts, pass // them to ToastProvider, and handle OnDismiss to drop one by ID. The // auto-generated toast IDs (generateId) become the caller's responsibility. // NOTE: auto-dismiss timers, the requestAnimationFrame progress countdown, and // the exit (fade/slide) animation are dropped (no timers/rAF here). The progress // bar, when shown, renders full-width and static; the dismiss button removes the // toast immediately. // NOTE: the "circle-xmark" and "circle-info" icons are not in the default icon // registry and render as empty boxes until an app registers them. // ToastType selects a toast's accent color and leading icon. type ToastType string const ( ToastSuccess ToastType = "success" ToastError ToastType = "error" ToastWarning ToastType = "warning" ToastInfo ToastType = "info" ToastGeneric ToastType = "generic" ) // ToastPosition is where the container is anchored on screen. type ToastPosition string const ( ToastTopRight ToastPosition = "top-right" ToastTopLeft ToastPosition = "top-left" ToastBottomRight ToastPosition = "bottom-right" ToastBottomLeft ToastPosition = "bottom-left" ToastTopCenter ToastPosition = "top-center" ToastBottomCenter ToastPosition = "bottom-center" ) // Toast is one notification. Duration is in ms (0 = no auto-dismiss and no // progress bar). Dismissible shows the close button; ShowProgress shows the // (static) progress bar when Duration > 0. Type defaults to ToastInfo. type Toast struct { ID string Message string Type ToastType Duration int Dismissible bool ShowProgress bool } var toastTypeIcons = map[ToastType]string{ ToastSuccess: "circle-check", ToastError: "circle-xmark", ToastWarning: "triangle-exclamation", ToastInfo: "circle-info", ToastGeneric: "", } const toastContainerBase = "fixed z-[200] flex flex-col gap-2" var toastContainerPositions = map[ToastPosition]string{ ToastTopRight: "top-4 right-4", ToastTopLeft: "top-4 left-4", ToastBottomRight: "bottom-4 right-4 flex-col-reverse", ToastBottomLeft: "bottom-4 left-4 flex-col-reverse", ToastTopCenter: "top-4 left-1/2 -translate-x-1/2", ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse", } const toastBase = "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" var toastTypeBorder = map[ToastType]string{ ToastSuccess: "border-l-green-700", ToastError: "border-l-red-700", ToastWarning: "border-l-yellow-500", ToastInfo: "border-l-sky-800", ToastGeneric: "border-l-neutral-400", } var toastIconColor = map[ToastType]string{ ToastSuccess: "text-green-600", ToastError: "text-red-600", ToastWarning: "text-yellow-600", ToastInfo: "text-sky-700", ToastGeneric: "", } // ToastItem renders a single toast. onDismiss receives the toast's ID when the // close button is pressed. func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode { typ := t.Type if typ == "" { typ = ToastInfo } icon := toastTypeIcons[typ] showProgress := t.ShowProgress && t.Duration > 0 row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")} if icon != "" { row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ]))) } row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message))) if t.Dismissible { dismiss := []vdom.Mod{ vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"), vdom.Attr("aria-label", "Dismiss"), } if onDismiss != nil { id := t.ID dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) })) } dismiss = append(dismiss, Icon("xmark", 16, "")) row = append(row, vdom.El("button", dismiss...)) } mods := []vdom.Mod{ vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])), vdom.Attr("role", "alert"), vdom.El("div", row...), } if showProgress { mods = append(mods, vdom.El("div", vdom.Attr("class", "h-1 w-full bg-neutral-100"), vdom.El("div", vdom.Attr("class", "h-full bg-neutral-300"), vdom.Attr("style", "width:100%")), )) } return vdom.El("div", mods...) } // ToastProviderProps configures ToastProvider. Position defaults to // ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept). type ToastProviderProps struct { Position ToastPosition MaxToasts int Toasts []Toast OnDismiss func(string) } // ToastProvider renders its children followed by the fixed toast container. It // replaces the TSX ToastContext.Provider; the wrapper uses display:contents so // it introduces no layout box of its own. func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode { position := p.Position if position == "" { position = ToastBottomRight } max := p.MaxToasts if max <= 0 { max = 5 } toasts := p.Toasts if len(toasts) > max { toasts = toasts[len(toasts)-max:] } container := []vdom.Mod{ vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])), vdom.Attr("aria-live", "polite"), vdom.Attr("aria-label", "Notifications"), } for _, t := range toasts { container = append(container, ToastItem(t, p.OnDismiss)) } mods := []vdom.Mod{vdom.Attr("class", "contents")} mods = kids(mods, children) mods = append(mods, vdom.El("div", container...)) return vdom.El("div", mods...) }