From 719de7c828b2d511e48478bc0a52bf8960e27cde Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 29 Apr 2026 08:17:35 +0900 Subject: [PATCH] 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 0000000000..ae6d294eb1 --- /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 0000000000..a2ff46c5d0 --- /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 0000000000..bb5994bb1b --- /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 + } +}