diff --git a/.changeset/toast-priority-system.md b/.changeset/toast-priority-system.md new file mode 100644 index 0000000000..1e3608a9bc --- /dev/null +++ b/.changeset/toast-priority-system.md @@ -0,0 +1,18 @@ +--- +"@zag-js/toast": minor +--- + +Add priority-based queue system for toasts inspired by Adobe Spectrum's design guidelines. Toasts now support priority +levels (1-8, where 1 is highest priority) with automatic priority assignment: + +- **Error toasts**: Priority 1 (actionable) or 2 (non-actionable) - always shown first +- **Warning toasts**: Priority 2 (actionable) or 6 (non-actionable) +- **Loading toasts**: Priority 3 (actionable) or 4 (non-actionable) +- **Success toasts**: Priority 4 (actionable) or 7 (non-actionable) +- **Info toasts**: Priority 5 (actionable) or 8 (non-actionable) + +When the maximum number of toasts is reached, new high-priority toasts are queued and displayed as soon as space becomes +available, ensuring critical notifications are never missed. Custom priorities can be set using the `priority` option. + +Additionally, when using `toast.promise` to track a promise, the `success` options can now specify `type` as either +`success` or `warning` for more flexible promise result handling. diff --git a/packages/machines/toast/src/index.ts b/packages/machines/toast/src/index.ts index b824658726..f8f2d19da4 100644 --- a/packages/machines/toast/src/index.ts +++ b/packages/machines/toast/src/index.ts @@ -22,6 +22,7 @@ export type { StatusChangeDetails, ToastStore as Store, ToastStoreProps as StoreProps, + ToastQueuePriority, Type, } from "./toast.types" diff --git a/packages/machines/toast/src/toast.store.ts b/packages/machines/toast/src/toast.store.ts index 88128ebe54..7aa815354c 100644 --- a/packages/machines/toast/src/toast.store.ts +++ b/packages/machines/toast/src/toast.store.ts @@ -1,11 +1,42 @@ import type { Required } from "@zag-js/types" import { compact, runIfFn, uuid, warn } from "@zag-js/utils" -import type { Options, PromiseOptions, ToastProps, ToastStore, ToastStoreProps } from "./toast.types" +import type { + Options, + ToastQueuePriority, + PromiseOptions, + ToastProps, + ToastStore, + ToastStoreProps, + Type, +} from "./toast.types" const withDefaults = >(options: T, defaults: D): T & Required => { return { ...defaults, ...compact(options as any) } } +const priorities: Record = { + error: [1, 2], + warning: [2, 6], + loading: [3, 4], + success: [4, 7], + info: [5, 8], +} + +const DEFAULT_TYPE: Type = "info" + +const getPriorityForType = (type?: Type, hasAction?: boolean): ToastQueuePriority => { + const [actionable, nonActionable] = priorities[type ?? DEFAULT_TYPE] ?? [5, 8] + return hasAction ? actionable : nonActionable +} + +const sortToastsByPriority = (toastArray: Partial>[]): Partial>[] => { + return toastArray.sort((a, b) => { + const priorityA = a.priority ?? getPriorityForType(a.type, !!a.action) + const priorityB = b.priority ?? getPriorityForType(b.type, !!b.action) + return priorityA - priorityB + }) +} + export function createToastStore(props: ToastStoreProps): ToastStore { const attrs = withDefaults(props, { placement: "bottom", @@ -47,6 +78,7 @@ export function createToastStore(props: ToastStoreProps): ToastStore const processQueue = () => { while (toastQueue.length > 0 && toasts.length < attrs.max) { + toastQueue = sortToastsByPriority(toastQueue) const nextToast = toastQueue.shift() if (nextToast) { publish(nextToast) @@ -70,15 +102,17 @@ export function createToastStore(props: ToastStoreProps): ToastStore return toast }) } else { - addToast({ + const newToast = { id, duration: attrs.duration, removeDelay: attrs.removeDelay, - type: "info", + type: DEFAULT_TYPE, ...data, stacked: !attrs.overlap, gap: attrs.gap, - }) + } + const priority = newToast.priority ?? getPriorityForType(newToast.type, !!newToast.action) + addToast({ ...newToast, priority }) } return id @@ -102,23 +136,28 @@ export function createToastStore(props: ToastStoreProps): ToastStore } const error = (data?: Omit, "type">) => { - return create({ ...data, type: "error" }) + const priority = data?.priority ?? getPriorityForType("error", !!data?.action) + return create({ ...data, type: "error", priority }) } const success = (data?: Omit, "type">) => { - return create({ ...data, type: "success" }) + const priority = data?.priority ?? getPriorityForType("success", !!data?.action) + return create({ ...data, type: "success", priority }) } const info = (data?: Omit, "type">) => { - return create({ ...data, type: "info" }) + const priority = data?.priority ?? getPriorityForType("info", !!data?.action) + return create({ ...data, type: "info", priority }) } const warning = (data?: Omit, "type">) => { - return create({ ...data, type: "warning" }) + const priority = data?.priority ?? getPriorityForType("warning", !!data?.action) + return create({ ...data, type: "warning", priority }) } const loading = (data?: Omit, "type">) => { - return create({ ...data, type: "loading" }) + const priority = data?.priority ?? getPriorityForType("loading", !!data?.action) + return create({ ...data, type: "loading", priority }) } const getVisibleToasts = () => { @@ -161,7 +200,7 @@ export function createToastStore(props: ToastStoreProps): ToastStore } else if (options.success !== undefined) { removable = false const successOptions = runIfFn(options.success, response) - create({ ...shared, ...successOptions, id, type: "success" }) + create({ ...shared, ...successOptions, id, type: successOptions.type ?? "success" }) } }) .catch(async (error) => { diff --git a/packages/machines/toast/src/toast.types.ts b/packages/machines/toast/src/toast.types.ts index eb236e67e1..b54f1a7410 100644 --- a/packages/machines/toast/src/toast.types.ts +++ b/packages/machines/toast/src/toast.types.ts @@ -5,7 +5,9 @@ import type { EventObject, Machine, Service } from "@zag-js/core" * Base types * -----------------------------------------------------------------------------*/ -export type Type = "success" | "error" | "loading" | "info" | (string & {}) +export type Type = "success" | "error" | "loading" | "info" | "warning" | (string & {}) + +export type ToastQueuePriority = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 export type Placement = "top-start" | "top" | "top-end" | "bottom-start" | "bottom" | "bottom-end" @@ -74,6 +76,10 @@ export interface Options { * The type of the toast */ type?: Type | undefined + /** + * The priority of the toast (1 = highest, 8 = lowest) + */ + priority?: ToastQueuePriority | undefined /** * Function called when the toast is visible */ @@ -358,7 +364,7 @@ type MaybeFunction = Value | ((arg: Args) => Value) export interface PromiseOptions { loading: Omit, "type"> - success?: MaybeFunction, "type">, V> | undefined + success?: MaybeFunction, "type"> & { type?: "success" | "warning" }, V> | undefined error?: MaybeFunction, "type">, unknown> | undefined finally?: (() => void | Promise) | undefined } diff --git a/packages/machines/toast/src/toast.utils.ts b/packages/machines/toast/src/toast.utils.ts index ed311abe31..7a782fbfe9 100644 --- a/packages/machines/toast/src/toast.utils.ts +++ b/packages/machines/toast/src/toast.utils.ts @@ -8,6 +8,7 @@ export const defaultTimeouts: Record = { error: 5000, success: 2000, loading: Infinity, + warning: 5000, DEFAULT: 5000, }