diff --git a/packages/webkit/src/components/feedback/toast/toast.test.ts b/packages/webkit/src/components/feedback/toast/toast.test.ts index 7d68534f1..89a78e481 100644 --- a/packages/webkit/src/components/feedback/toast/toast.test.ts +++ b/packages/webkit/src/components/feedback/toast/toast.test.ts @@ -219,6 +219,40 @@ describe('Toast (composition + imperative store)', () => { expect(within(region!).queryByTestId('feedback-toast__close')).toBeNull() }) + + it('honours a per-toast closable over a Toaster that closes nothing', async () => { + // Source: `item.entry.closable ?? closable` — the per-toast option wins. + // It only can if the region's projection of the store entry carries + // `closable`; when it did not, this option was silently inert. + await mountToaster({ position: 'bottom-right', duration: 0, closable: false }) + + toast.error('Deployment failed', { closable: true }) + const region = await regionFor('bottom-right') + const scope = within(region!) + + expect(scope.getByText('Deployment failed')).toBeTruthy() + expect(scope.getByTestId('feedback-toast__close')).toBeTruthy() + }) + + it('keeps a per-toast closable through an in-place update', async () => { + // The async pattern: a `loading` toast is raised first and only becomes + // closable when it settles into its error state under the SAME id. + await mountToaster({ position: 'bottom-right', duration: 0, closable: false }) + + const id = toast.loading('Deploying…') + let region = await regionFor('bottom-right') + expect(within(region!).queryByTestId('feedback-toast__close')).toBeNull() + + useToastStore().update(id, { + type: 'error', + message: 'Deployment failed', + duration: 0, + closable: true + }) + region = await regionFor('bottom-right') + + expect(within(region!).getByTestId('feedback-toast__close')).toBeTruthy() + }) }) describe('per-toast position overrides the Toaster default', () => { diff --git a/packages/webkit/src/components/feedback/toast/toaster.vue b/packages/webkit/src/components/feedback/toast/toaster.vue index 47bfaf834..86b7e77a2 100644 --- a/packages/webkit/src/components/feedback/toast/toaster.vue +++ b/packages/webkit/src/components/feedback/toast/toaster.vue @@ -118,6 +118,11 @@ if (changed) heights.value = nextHeights } + // This projection IS the entry the region renders (`item.entry` below), not + // just a change-detection key — so every field the template or the default + // slot reads has to be carried here. `closable` in particular: without it the + // per-toast override is dropped and every toast falls back to the Toaster's + // prop. (`onClose` is deliberately absent: the store owns invoking it.) watch( () => store.toasts.map((t) => ({ @@ -127,7 +132,8 @@ description: t.description, action: t.action, duration: t.duration, - position: t.position + position: t.position, + closable: t.closable })), (next) => { const nextIds = new Set(next.map((t) => t.id))