mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 22:58:08 +00:00
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:
112
src/components/graph/SnackbarToast.stories.ts
Normal file
112
src/components/graph/SnackbarToast.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
74
src/components/graph/SnackbarToast.vue
Normal file
74
src/components/graph/SnackbarToast.vue
Normal 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>
|
||||
58
src/composables/useSnackbarToast.ts
Normal file
58
src/composables/useSnackbarToast.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user