feat: add SnackbarToast component for canvas feedback

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
This commit is contained in:
dante01yoon
2026-04-29 08:17:35 +09:00
parent e7640d414b
commit 719de7c828
3 changed files with 244 additions and 0 deletions

View File

@@ -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<typeof SnackbarToast> = {
title: 'Components/Graph/SnackbarToast',
component: SnackbarToast,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template:
'<div class="relative h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { SnackbarToast, Button },
setup() {
const toast = useSnackbarToast()
function trigger() {
toast.show('Toast message')
}
return { trigger }
},
template: `
<div class="flex flex-col gap-2">
<p class="text-base-foreground">Auto-dismiss after 2s. Hover to pause.</p>
<Button class="w-fit" @click="trigger">Show toast</Button>
<SnackbarToast />
</div>
`
})
}
export const WithShortcut: Story = {
render: () => ({
components: { SnackbarToast, Button },
setup() {
const toast = useSnackbarToast()
function trigger() {
toast.show('Links hidden', { shortcut: 'Ctrl+A' })
}
return { trigger }
},
template: `
<div class="flex flex-col gap-2">
<p class="text-base-foreground">Toast with assigned keybinding badge.</p>
<Button class="w-fit" @click="trigger">Show toast</Button>
<SnackbarToast />
</div>
`
})
}
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: `
<div class="flex flex-col gap-2">
<p class="text-base-foreground">No assigned shortcut: shows an Undo action button.</p>
<Button class="w-fit" @click="trigger">Show toast</Button>
<SnackbarToast />
</div>
`
})
}
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: `
<div class="flex flex-col gap-2">
<p class="text-base-foreground">Long duration so close-button behavior is testable.</p>
<div class="flex gap-2">
<Button class="w-fit" @click="trigger">Show toast</Button>
<Button class="w-fit" variant="muted-textonly" @click="dismiss">Dismiss</Button>
</div>
<SnackbarToast />
</div>
`
})
}

View File

@@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed bottom-16 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-4 rounded-lg bg-base-foreground py-1 pr-2 pl-3 text-sm text-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
@mouseenter="pause"
@mouseleave="startTimer"
>
<span class="truncate">
{{ message }}
</span>
<kbd
v-if="shortcut"
class="flex h-4 min-w-3.5 items-center justify-center rounded-sm bg-base-background/70 px-1 text-xs font-normal text-base-foreground"
>
{{ shortcut }}
</kbd>
<ToolbarRoot
orientation="horizontal"
:aria-label="t('g.dismiss')"
class="flex items-center pl-2"
>
<ToolbarButton v-if="hasAction" as-child @click="handleAction">
<Button
variant="inverted"
size="md"
class="text-sm hover:bg-base-foreground/80"
>
{{ actionLabel }}
</Button>
</ToolbarButton>
<ToolbarButton as-child @click="dismiss">
<Button
variant="inverted"
size="md"
:aria-label="t('g.dismiss')"
class="hover:bg-base-foreground/80"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</ToolbarButton>
</ToolbarRoot>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ToolbarButton, ToolbarRoot } from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSnackbarToast } from '@/composables/useSnackbarToast'
const { t } = useI18n()
const {
message,
shortcut,
visible,
actionLabel,
onAction,
dismiss,
pause,
startTimer
} = useSnackbarToast()
const hasAction = computed(() => !!onAction.value && !shortcut.value)
function handleAction() {
onAction.value?.()
}
</script>

View File

@@ -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<typeof setTimeout> | 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
}
}