Compare commits

...

3 Commits

Author SHA1 Message Date
dante01yoon
8d71b012e4 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 `<SnackbarToastProvider>`
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 `<ToastViewport>` at the same bottom-center
  position as before.
- `SnackbarToast.vue` is now a single `<ToastRoot>` 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 `<SnackbarToastProvider>` 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
2026-04-29 09:44:55 +09:00
dante01yoon
a9695a7e1a 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`.
2026-04-29 08:39:38 +09:00
dante01yoon
719de7c828 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
2026-04-29 08:17:35 +09:00
6 changed files with 482 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import { useSnackbarToast } from '@/composables/useSnackbarToast'
import SnackbarToastProvider from './SnackbarToastProvider.vue'
const meta: Meta<typeof SnackbarToastProvider> = {
title: 'Components/Toast/SnackbarToast',
component: SnackbarToastProvider,
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: { SnackbarToastProvider, Button, Trigger },
template: `
<SnackbarToastProvider>
<Trigger label="Show toast" message="Toast message" />
</SnackbarToastProvider>
`
})
}
export const WithShortcut: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerWithShortcut },
template: `
<SnackbarToastProvider>
<TriggerWithShortcut />
</SnackbarToastProvider>
`
})
}
export const WithUndoAction: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerWithUndo },
template: `
<SnackbarToastProvider>
<TriggerWithUndo />
</SnackbarToastProvider>
`
})
}
export const Persistent: Story = {
render: () => ({
components: { SnackbarToastProvider, Button, TriggerPersistent },
template: `
<SnackbarToastProvider>
<TriggerPersistent />
</SnackbarToastProvider>
`
})
}
const Trigger = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return { trigger: () => toast.show('Toast message') }
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerWithShortcut = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () => toast.show('Links hidden', { shortcut: 'Ctrl+A' })
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerWithUndo = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () =>
toast.show('Subgraph unpacked', {
actionLabel: 'Undo',
onAction: () => toast.show('Subgraph repacked')
})
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}
const TriggerPersistent = {
components: { Button },
setup() {
const toast = useSnackbarToast()
return {
trigger: () =>
toast.show('Stays open until dismissed', { duration: 60_000 })
}
},
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
}

View File

@@ -0,0 +1,77 @@
<template>
<ToastRoot
:duration="toast.duration ?? DEFAULT_DURATION"
type="foreground"
class="flex 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)] outline-none data-[state=closed]:opacity-0 data-[state=closed]:transition-opacity data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=end]:transition-transform data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y)"
@update:open="handleOpenChange"
>
<ToastTitle class="truncate">
{{ toast.message }}
</ToastTitle>
<kbd
v-if="toast.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"
>
{{ toast.shortcut }}
</kbd>
<div class="flex items-center pl-2">
<ToastAction
v-if="hasAction"
as-child
:alt-text="toast.actionLabel ?? ''"
@click.prevent="handleAction"
>
<Button
variant="inverted"
size="md"
class="text-sm hover:bg-base-foreground/80"
>
{{ toast.actionLabel }}
</Button>
</ToastAction>
<ToastClose as-child :aria-label="t('g.dismiss')">
<Button
variant="inverted"
size="md"
class="hover:bg-base-foreground/80"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</ToastClose>
</div>
</ToastRoot>
</template>
<script setup lang="ts">
import { ToastAction, ToastClose, ToastRoot, ToastTitle } from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SnackbarToastItem } from '@/composables/useSnackbarToast'
const DEFAULT_DURATION = 2000
const { toast } = defineProps<{ toast: SnackbarToastItem }>()
const emit = defineEmits<{
dismiss: []
}>()
const { t } = useI18n()
const hasAction = computed(() => !!toast.onAction && !toast.shortcut)
function handleOpenChange(open: boolean) {
if (!open) emit('dismiss')
}
function handleAction() {
try {
toast.onAction?.()
} catch (err) {
console.error('SnackbarToast action handler threw:', err)
} finally {
emit('dismiss')
}
}
</script>

View File

@@ -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<typeof userEvent.setup>
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)
})
})

View File

@@ -0,0 +1,50 @@
<template>
<ToastProvider swipe-direction="down" :duration="DEFAULT_DURATION">
<slot />
<SnackbarToast
v-for="item in toasts"
:key="item.id"
:toast="item"
@dismiss="dismiss(item.id)"
/>
<ToastViewport
class="fixed bottom-16 left-1/2 z-1000 m-0 flex -translate-x-1/2 list-none flex-col items-center gap-2 p-0 outline-none"
/>
</ToastProvider>
</template>
<script setup lang="ts">
import { ToastProvider, ToastViewport } from 'reka-ui'
import { provide, ref } from 'vue'
import type {
ShowSnackbarOptions,
SnackbarToastApi,
SnackbarToastItem
} from '@/composables/useSnackbarToast'
import { SnackbarToastKey } from '@/composables/useSnackbarToast'
import SnackbarToast from './SnackbarToast.vue'
const DEFAULT_DURATION = 2000
const toasts = ref<SnackbarToastItem[]>([])
function createId(): string {
return `snackbar-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function show(message: string, options: ShowSnackbarOptions = {}): string {
const item: SnackbarToastItem = { id: createId(), message, ...options }
toasts.value = [item]
return item.id
}
function dismiss(id: string): void {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
const api: SnackbarToastApi = { show, dismiss }
provide(SnackbarToastKey, api)
defineExpose(api)
</script>

View File

@@ -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')
})
})

View File

@@ -0,0 +1,32 @@
import type { InjectionKey } from 'vue'
import { inject } from 'vue'
export interface ShowSnackbarOptions {
shortcut?: string
duration?: number
actionLabel?: string
onAction?: () => void
}
export interface SnackbarToastItem extends ShowSnackbarOptions {
id: string
message: string
}
export interface SnackbarToastApi {
show(message: string, options?: ShowSnackbarOptions): string
dismiss(id: string): void
}
export const SnackbarToastKey: InjectionKey<SnackbarToastApi> =
Symbol('SnackbarToastApi')
export function useSnackbarToast(): SnackbarToastApi {
const api = inject(SnackbarToastKey, null)
if (!api) {
throw new Error(
'useSnackbarToast() must be called within <SnackbarToastProvider>.'
)
}
return api
}