From 719de7c828b2d511e48478bc0a52bf8960e27cde Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 29 Apr 2026 08:17:35 +0900 Subject: [PATCH 1/3] feat: add SnackbarToast component for canvas feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a singleton snackbar toast component (bottom-center, Teleport to body) intended for non-blocking canvas feedback. Surfaces an optional keybinding badge or an action button (e.g. Undo) and supports auto- dismiss with hover-pause. Component-only — no app integration in this PR. Wiring to specific canvas commands (link visibility, focus mode, subgraph unpack) will land in follow-up PRs once the UX direction is settled (#11718 thread). Visuals match Figma node 6826:77784 in the Comfy Design System. Refs FE-484 --- src/components/graph/SnackbarToast.stories.ts | 112 ++++++++++++++++++ src/components/graph/SnackbarToast.vue | 74 ++++++++++++ src/composables/useSnackbarToast.ts | 58 +++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/components/graph/SnackbarToast.stories.ts create mode 100644 src/components/graph/SnackbarToast.vue create mode 100644 src/composables/useSnackbarToast.ts diff --git a/src/components/graph/SnackbarToast.stories.ts b/src/components/graph/SnackbarToast.stories.ts new file mode 100644 index 00000000000..ae6d294eb13 --- /dev/null +++ b/src/components/graph/SnackbarToast.stories.ts @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '@/components/ui/button/Button.vue' +import { useSnackbarToast } from '@/composables/useSnackbarToast' + +import SnackbarToast from './SnackbarToast.vue' + +const meta: Meta = { + title: 'Components/Graph/SnackbarToast', + component: SnackbarToast, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen' + }, + decorators: [ + () => ({ + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { SnackbarToast, Button }, + setup() { + const toast = useSnackbarToast() + function trigger() { + toast.show('Toast message') + } + return { trigger } + }, + template: ` +
+

Auto-dismiss after 2s. Hover to pause.

+ + +
+ ` + }) +} + +export const WithShortcut: Story = { + render: () => ({ + components: { SnackbarToast, Button }, + setup() { + const toast = useSnackbarToast() + function trigger() { + toast.show('Links hidden', { shortcut: 'Ctrl+A' }) + } + return { trigger } + }, + template: ` +
+

Toast with assigned keybinding badge.

+ + +
+ ` + }) +} + +export const WithUndoAction: Story = { + render: () => ({ + components: { SnackbarToast, Button }, + setup() { + const toast = useSnackbarToast() + function trigger() { + toast.show('Subgraph unpacked', { + actionLabel: 'Undo', + onAction: () => { + toast.show('Subgraph repacked') + } + }) + } + return { trigger } + }, + template: ` +
+

No assigned shortcut: shows an Undo action button.

+ + +
+ ` + }) +} + +export const Persistent: Story = { + render: () => ({ + components: { SnackbarToast, Button }, + setup() { + const toast = useSnackbarToast() + function trigger() { + toast.show('Stays open until dismissed', { duration: 60_000 }) + } + return { trigger, dismiss: toast.dismiss } + }, + template: ` +
+

Long duration so close-button behavior is testable.

+
+ + +
+ +
+ ` + }) +} diff --git a/src/components/graph/SnackbarToast.vue b/src/components/graph/SnackbarToast.vue new file mode 100644 index 00000000000..a2ff46c5d09 --- /dev/null +++ b/src/components/graph/SnackbarToast.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/composables/useSnackbarToast.ts b/src/composables/useSnackbarToast.ts new file mode 100644 index 00000000000..bb5994bb1b4 --- /dev/null +++ b/src/composables/useSnackbarToast.ts @@ -0,0 +1,58 @@ +import { ref } from 'vue' + +const message = ref('') +const shortcut = ref('') +const visible = ref(false) +const actionLabel = ref('') +const onAction = ref<(() => void) | null>(null) +let timeout: ReturnType | null = null +let duration = 2000 + +export function useSnackbarToast() { + function show( + msg: string, + options?: { + shortcut?: string + duration?: number + actionLabel?: string + onAction?: () => void + } + ) { + if (timeout) clearTimeout(timeout) + message.value = msg + shortcut.value = options?.shortcut ?? '' + actionLabel.value = options?.actionLabel ?? '' + onAction.value = options?.onAction ?? null + duration = options?.duration ?? 2000 + visible.value = true + startTimer() + } + + function startTimer() { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + visible.value = false + }, duration) + } + + function pause() { + if (timeout) clearTimeout(timeout) + } + + function dismiss() { + if (timeout) clearTimeout(timeout) + visible.value = false + } + + return { + message, + shortcut, + visible, + actionLabel, + onAction, + show, + dismiss, + pause, + startTimer + } +} From a9695a7e1ac151f7bc9fc31cc500e56fe4b5ef89 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 29 Apr 2026 08:39:38 +0900 Subject: [PATCH 2/3] refactor: move SnackbarToast under components/toast Belongs with the existing toast components (`GlobalToast`, `ProgressToastItem`, `RerouteMigrationToast`), not under `components/graph/`. Story title updated to `Components/Toast/SnackbarToast`. --- src/components/{graph => toast}/SnackbarToast.stories.ts | 2 +- src/components/{graph => toast}/SnackbarToast.vue | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/components/{graph => toast}/SnackbarToast.stories.ts (98%) rename src/components/{graph => toast}/SnackbarToast.vue (100%) diff --git a/src/components/graph/SnackbarToast.stories.ts b/src/components/toast/SnackbarToast.stories.ts similarity index 98% rename from src/components/graph/SnackbarToast.stories.ts rename to src/components/toast/SnackbarToast.stories.ts index ae6d294eb13..606bd555b97 100644 --- a/src/components/graph/SnackbarToast.stories.ts +++ b/src/components/toast/SnackbarToast.stories.ts @@ -6,7 +6,7 @@ import { useSnackbarToast } from '@/composables/useSnackbarToast' import SnackbarToast from './SnackbarToast.vue' const meta: Meta = { - title: 'Components/Graph/SnackbarToast', + title: 'Components/Toast/SnackbarToast', component: SnackbarToast, tags: ['autodocs'], parameters: { diff --git a/src/components/graph/SnackbarToast.vue b/src/components/toast/SnackbarToast.vue similarity index 100% rename from src/components/graph/SnackbarToast.vue rename to src/components/toast/SnackbarToast.vue From 8d71b012e43d6a155cddcffff35c608e78d8e842 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 29 Apr 2026 09:44:55 +0900 Subject: [PATCH 3/3] refactor(SnackbarToast): replace module singleton with Reka ToastProvider Codex adversarial review (PR #11731) flagged the original `useSnackbarToast` for owning UI state as raw module-level refs and a manual `setTimeout`, which conflicts with the existing toast stack (PrimeVue/GlobalToast/HoneyToast) and is unsafe under HMR, multiple hosts, rapid back-to-back show() calls, and throwing action callbacks. This rewrites the component on top of Reka's `ToastProvider` / `ToastRoot` / `ToastAction` / `ToastClose` / `ToastViewport` primitives, which already handle the queue, duration, hover/focus pause, swipe dismiss, and SR announcement. State now lives in a `` component scope, not at module load. Changes: - `useSnackbarToast()` is now an inject-based hook returning `{ show, dismiss }`. Throws when no Provider is in scope. - `SnackbarToastProvider.vue` owns the toasts array, provides the API, and replaces the previous toast on rapid show() (singleton policy preserved). Renders `` at the same bottom-center position as before. - `SnackbarToast.vue` is now a single `` item renderer, driven by a typed `toast` prop. Action handler is wrapped in try/finally so a throwing callback still dismisses and is logged. - Stories wrap each variant in `` with simple trigger components. - Visuals match Figma node 6826:77784 (verified via Figma MCP). Tests: - `useSnackbarToast.test.ts` covers no-provider throw and inject contract. - `SnackbarToastProvider.test.ts` covers initial empty state, show rendering, singleton replace, shortcut badge vs action exclusivity, action click + dismiss, throwing action still dismisses, dismiss(id) targeting, and unique-id guarantee. Refs FE-484 --- src/components/toast/SnackbarToast.stories.ts | 129 +++++++------- src/components/toast/SnackbarToast.vue | 115 ++++++------ .../toast/SnackbarToastProvider.test.ts | 165 ++++++++++++++++++ .../toast/SnackbarToastProvider.vue | 50 ++++++ src/composables/useSnackbarToast.test.ts | 43 +++++ src/composables/useSnackbarToast.ts | 76 +++----- 6 files changed, 408 insertions(+), 170 deletions(-) create mode 100644 src/components/toast/SnackbarToastProvider.test.ts create mode 100644 src/components/toast/SnackbarToastProvider.vue create mode 100644 src/composables/useSnackbarToast.test.ts diff --git a/src/components/toast/SnackbarToast.stories.ts b/src/components/toast/SnackbarToast.stories.ts index 606bd555b97..6b3453004f4 100644 --- a/src/components/toast/SnackbarToast.stories.ts +++ b/src/components/toast/SnackbarToast.stories.ts @@ -3,11 +3,11 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import Button from '@/components/ui/button/Button.vue' import { useSnackbarToast } from '@/composables/useSnackbarToast' -import SnackbarToast from './SnackbarToast.vue' +import SnackbarToastProvider from './SnackbarToastProvider.vue' -const meta: Meta = { +const meta: Meta = { title: 'Components/Toast/SnackbarToast', - component: SnackbarToast, + component: SnackbarToastProvider, tags: ['autodocs'], parameters: { layout: 'fullscreen' @@ -25,88 +25,91 @@ type Story = StoryObj export const Default: Story = { render: () => ({ - components: { SnackbarToast, Button }, - setup() { - const toast = useSnackbarToast() - function trigger() { - toast.show('Toast message') - } - return { trigger } - }, + components: { SnackbarToastProvider, Button, Trigger }, template: ` -
-

Auto-dismiss after 2s. Hover to pause.

- - -
+ + + ` }) } export const WithShortcut: Story = { render: () => ({ - components: { SnackbarToast, Button }, - setup() { - const toast = useSnackbarToast() - function trigger() { - toast.show('Links hidden', { shortcut: 'Ctrl+A' }) - } - return { trigger } - }, + components: { SnackbarToastProvider, Button, TriggerWithShortcut }, template: ` -
-

Toast with assigned keybinding badge.

- - -
+ + + ` }) } export const WithUndoAction: Story = { render: () => ({ - components: { SnackbarToast, Button }, - setup() { - const toast = useSnackbarToast() - function trigger() { - toast.show('Subgraph unpacked', { - actionLabel: 'Undo', - onAction: () => { - toast.show('Subgraph repacked') - } - }) - } - return { trigger } - }, + components: { SnackbarToastProvider, Button, TriggerWithUndo }, template: ` -
-

No assigned shortcut: shows an Undo action button.

- - -
+ + + ` }) } export const Persistent: Story = { render: () => ({ - components: { SnackbarToast, Button }, - setup() { - const toast = useSnackbarToast() - function trigger() { - toast.show('Stays open until dismissed', { duration: 60_000 }) - } - return { trigger, dismiss: toast.dismiss } - }, + components: { SnackbarToastProvider, Button, TriggerPersistent }, template: ` -
-

Long duration so close-button behavior is testable.

-
- - -
- -
+ + + ` }) } + +const Trigger = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { trigger: () => toast.show('Toast message') } + }, + template: `` +} + +const TriggerWithShortcut = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => toast.show('Links hidden', { shortcut: 'Ctrl+A' }) + } + }, + template: `` +} + +const TriggerWithUndo = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => + toast.show('Subgraph unpacked', { + actionLabel: 'Undo', + onAction: () => toast.show('Subgraph repacked') + }) + } + }, + template: `` +} + +const TriggerPersistent = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => + toast.show('Stays open until dismissed', { duration: 60_000 }) + } + }, + template: `` +} diff --git a/src/components/toast/SnackbarToast.vue b/src/components/toast/SnackbarToast.vue index a2ff46c5d09..4f98790623e 100644 --- a/src/components/toast/SnackbarToast.vue +++ b/src/components/toast/SnackbarToast.vue @@ -1,74 +1,77 @@ diff --git a/src/components/toast/SnackbarToastProvider.test.ts b/src/components/toast/SnackbarToastProvider.test.ts new file mode 100644 index 00000000000..a12205e12f3 --- /dev/null +++ b/src/components/toast/SnackbarToastProvider.test.ts @@ -0,0 +1,165 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import type { SnackbarToastApi } from '@/composables/useSnackbarToast' +import { useSnackbarToast } from '@/composables/useSnackbarToast' + +import SnackbarToastProvider from './SnackbarToastProvider.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { dismiss: 'Dismiss' } } } +}) + +let capturedApi: SnackbarToastApi | null = null + +const Harness = defineComponent({ + setup() { + capturedApi = useSnackbarToast() + return () => h('div', { 'data-testid': 'harness' }) + } +}) + +function setup(): { + user: ReturnType + api: SnackbarToastApi + unmount: () => void +} { + capturedApi = null + const user = userEvent.setup() + const { unmount } = render(SnackbarToastProvider, { + slots: { default: () => h(Harness) }, + global: { plugins: [i18n] } + }) + const api = capturedApi + if (!api) throw new Error('Harness did not capture api') + return { user, api, unmount } +} + +describe('SnackbarToastProvider', () => { + beforeEach(() => { + document.body.innerHTML = '' + // happy-dom doesn't implement these; reka-ui ToastClose/ToastAction call them + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false + Element.prototype.releasePointerCapture = () => {} + Element.prototype.setPointerCapture = () => {} + } + }) + + afterEach(() => { + capturedApi = null + }) + + it('renders no toast initially', () => { + setup() + expect(screen.getByTestId('harness')).toBeInTheDocument() + expect(screen.queryAllByRole('status')).toHaveLength(0) + }) + + it('renders a toast after show()', async () => { + const { api } = setup() + api.show('Hello world') + await nextTick() + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('replaces an existing toast on rapid show() (singleton)', async () => { + const { api } = setup() + api.show('first') + api.show('second') + await nextTick() + expect(screen.queryByText('first')).not.toBeInTheDocument() + expect(screen.getByText('second')).toBeInTheDocument() + }) + + it('renders a shortcut badge when shortcut is provided', async () => { + const { api } = setup() + api.show('Links hidden', { shortcut: 'Ctrl+A' }) + await nextTick() + const badge = screen.getByText('Ctrl+A') + expect(badge).toBeInTheDocument() + // when shortcut is set, the action button must NOT render + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull() + }) + + it('renders an action button when actionLabel is provided without shortcut', async () => { + const { api } = setup() + const onAction = vi.fn() + api.show('Subgraph unpacked', { actionLabel: 'Undo', onAction }) + await nextTick() + expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument() + }) + + it('does not render action button when shortcut is also set', async () => { + const { api } = setup() + api.show('msg', { + shortcut: 'Ctrl+A', + actionLabel: 'Undo', + onAction: vi.fn() + }) + await nextTick() + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull() + }) + + it('action click invokes the callback and dismisses the toast', async () => { + const { user, api } = setup() + const onAction = vi.fn() + api.show('msg', { actionLabel: 'Undo', onAction }) + await nextTick() + + await user.click(screen.getByRole('button', { name: 'Undo' })) + await nextTick() + + expect(onAction).toHaveBeenCalledTimes(1) + expect(screen.queryByText('msg')).not.toBeInTheDocument() + }) + + it('dismisses the toast even when the action callback throws', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { user, api } = setup() + const onAction = vi.fn(() => { + throw new Error('boom') + }) + api.show('msg', { actionLabel: 'Undo', onAction }) + await nextTick() + + await user.click(screen.getByRole('button', { name: 'Undo' })) + await nextTick() + + expect(onAction).toHaveBeenCalledTimes(1) + expect(screen.queryByText('msg')).not.toBeInTheDocument() + expect(errSpy).toHaveBeenCalled() + errSpy.mockRestore() + }) + + it('dismiss(id) removes the targeted toast', async () => { + const { api } = setup() + const id = api.show('first') + await nextTick() + expect(screen.getByText('first')).toBeInTheDocument() + api.dismiss(id) + await nextTick() + expect(screen.queryByText('first')).not.toBeInTheDocument() + }) + + it('dismiss(id) for an unknown id is a no-op', async () => { + const { api } = setup() + api.show('first') + await nextTick() + api.dismiss('non-existent') + await nextTick() + expect(screen.getByText('first')).toBeInTheDocument() + }) + + it('show() returns a unique id per call', () => { + const { api } = setup() + const a = api.show('a') + const b = api.show('b') + expect(a).not.toEqual(b) + }) +}) diff --git a/src/components/toast/SnackbarToastProvider.vue b/src/components/toast/SnackbarToastProvider.vue new file mode 100644 index 00000000000..f3d5a393cc9 --- /dev/null +++ b/src/components/toast/SnackbarToastProvider.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/composables/useSnackbarToast.test.ts b/src/composables/useSnackbarToast.test.ts new file mode 100644 index 00000000000..b5297ebec45 --- /dev/null +++ b/src/composables/useSnackbarToast.test.ts @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h, provide } from 'vue' + +import type { SnackbarToastApi } from './useSnackbarToast' +import { SnackbarToastKey, useSnackbarToast } from './useSnackbarToast' + +const Consumer = defineComponent({ + setup() { + const api = useSnackbarToast() + return () => + h('div', { 'data-testid': 'consumer' }, [ + h('span', { 'data-testid': 'has-show' }, String(typeof api.show)), + h('span', { 'data-testid': 'has-dismiss' }, String(typeof api.dismiss)) + ]) + } +}) + +describe('useSnackbarToast', () => { + it('throws when no SnackbarToastProvider is in scope', () => { + expect(() => render(Consumer)).toThrow(/SnackbarToastProvider/) + }) + + it('returns the injected api', () => { + const api: SnackbarToastApi = { + show: vi.fn(() => 'id-1'), + dismiss: vi.fn() + } + const Provider = defineComponent({ + setup(_, { slots }) { + provide(SnackbarToastKey, api) + return () => slots.default?.() + } + }) + + render(Provider, { + slots: { default: () => h(Consumer) } + }) + + expect(screen.getByTestId('has-show').textContent).toBe('function') + expect(screen.getByTestId('has-dismiss').textContent).toBe('function') + }) +}) diff --git a/src/composables/useSnackbarToast.ts b/src/composables/useSnackbarToast.ts index bb5994bb1b4..9694e0ee630 100644 --- a/src/composables/useSnackbarToast.ts +++ b/src/composables/useSnackbarToast.ts @@ -1,58 +1,32 @@ -import { ref } from 'vue' +import type { InjectionKey } from 'vue' +import { inject } from 'vue' -const message = ref('') -const shortcut = ref('') -const visible = ref(false) -const actionLabel = ref('') -const onAction = ref<(() => void) | null>(null) -let timeout: ReturnType | null = null -let duration = 2000 - -export function useSnackbarToast() { - function show( - msg: string, - options?: { - shortcut?: string - duration?: number - actionLabel?: string - onAction?: () => void - } - ) { - if (timeout) clearTimeout(timeout) - message.value = msg - shortcut.value = options?.shortcut ?? '' - actionLabel.value = options?.actionLabel ?? '' - onAction.value = options?.onAction ?? null - duration = options?.duration ?? 2000 - visible.value = true - startTimer() - } +export interface ShowSnackbarOptions { + shortcut?: string + duration?: number + actionLabel?: string + onAction?: () => void +} - function startTimer() { - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - visible.value = false - }, duration) - } +export interface SnackbarToastItem extends ShowSnackbarOptions { + id: string + message: string +} - function pause() { - if (timeout) clearTimeout(timeout) - } +export interface SnackbarToastApi { + show(message: string, options?: ShowSnackbarOptions): string + dismiss(id: string): void +} - function dismiss() { - if (timeout) clearTimeout(timeout) - visible.value = false - } +export const SnackbarToastKey: InjectionKey = + Symbol('SnackbarToastApi') - return { - message, - shortcut, - visible, - actionLabel, - onAction, - show, - dismiss, - pause, - startTimer +export function useSnackbarToast(): SnackbarToastApi { + const api = inject(SnackbarToastKey, null) + if (!api) { + throw new Error( + 'useSnackbarToast() must be called within .' + ) } + return api }