From e235fcafa0cfcfc8561d058d68937c6a7ec0ddc2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 12 Oct 2026 20:04:38 -0700 Subject: [PATCH] fix(emcn): keep ongoing status toasts behind actionable toasts The collapsed toast stack paints newest-first and shows only the front card whole; back cards are clamped to the front card's height and peek 13px above it. A persistent status toast raised after an actionable one ("Reconnecting..." / "Joining workflow..." after "This workflow is locked") therefore covered the action, and on touch the first tap only expanded the stack. Order the stack so persistent toasts without an action sit behind every other toast; everything else stays newest-first. --- .../emcn/src/components/toast/toast.test.ts | 43 +++++++++++++++++++ packages/emcn/src/components/toast/toast.tsx | 18 +++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/emcn/src/components/toast/toast.test.ts 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) => {