diff --git a/packages/emcn/src/components/toast/toast.test.ts b/packages/emcn/src/components/toast/toast.test.ts new file mode 100644 index 00000000000..205e93d7cb3 --- /dev/null +++ b/packages/emcn/src/components/toast/toast.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { orderToastStack } from './toast' + +type StackedToast = Parameters[0][number] + +function stacked(id: string, overrides: Partial = {}): StackedToast { + return { + id, + message: id, + variant: 'default', + duration: 5000, + persistAcrossRoutes: false, + ...overrides, + } +} + +const unlock = stacked('unlock', { + duration: 0, + action: { label: 'Unlock Workflow', onClick: () => {} }, +}) +const reconnecting = stacked('reconnecting', { variant: 'error', duration: 0 }) + +const frontToBack = (toasts: StackedToast[]) => orderToastStack(toasts).map((toast) => toast.id) + +describe('orderToastStack', () => { + it('keeps an actionable toast in front of a status toast raised after it', () => { + expect(frontToBack([unlock, reconnecting])).toEqual(['unlock', 'reconnecting']) + }) + + it('keeps a status toast behind a timed toast raised before it', () => { + expect(frontToBack([stacked('saved'), reconnecting])).toEqual(['saved', 'reconnecting']) + }) + + it('orders everything else newest first', () => { + const joining = stacked('joining', { variant: 'error', duration: 0 }) + expect(frontToBack([unlock, reconnecting, stacked('saved'), joining])).toEqual([ + 'saved', + 'unlock', + 'joining', + 'reconnecting', + ]) + }) +}) diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 618161ce00e..9878bf54d81 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -156,6 +156,22 @@ function toastStateReducer(state: ToastState, action: ToastStateAction): ToastSt } } +/** + * Front-to-back paint order for the stack: newest first, except that ongoing + * status toasts — persistent and without an action, like "Reconnecting..." — + * sit behind every other toast. A collapsed stack shows only the front card + * whole, so a status that arrives later would otherwise hide an earlier + * toast's action until the stack is expanded. + */ +export function orderToastStack(toasts: readonly ToastData[]): ToastData[] { + const isOngoingStatus = (toast: ToastData) => toast.duration <= 0 && !toast.action + const newestFirst = [...toasts].reverse() + return [ + ...newestFirst.filter((toast) => !isOngoingStatus(toast)), + ...newestFirst.filter(isOngoingStatus), + ] +} + type ToastInput = { message: string description?: string @@ -706,7 +722,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { [dismissToast, dismissAllToasts] ) - const ordered = [...toasts].reverse() + const ordered = orderToastStack(toasts) const frontHeight = heights[ordered[0]?.id ?? ''] ?? ESTIMATED_TOAST_HEIGHT let cumulative = 0 const layout = ordered.map((toast, index) => {