mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
3 Commits
favicon-up
...
feat/snack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d71b012e4 | ||
|
|
a9695a7e1a | ||
|
|
719de7c828 |
115
src/components/toast/SnackbarToast.stories.ts
Normal file
115
src/components/toast/SnackbarToast.stories.ts
Normal 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>`
|
||||
}
|
||||
77
src/components/toast/SnackbarToast.vue
Normal file
77
src/components/toast/SnackbarToast.vue
Normal 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>
|
||||
165
src/components/toast/SnackbarToastProvider.test.ts
Normal file
165
src/components/toast/SnackbarToastProvider.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
50
src/components/toast/SnackbarToastProvider.vue
Normal file
50
src/components/toast/SnackbarToastProvider.vue
Normal 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>
|
||||
43
src/composables/useSnackbarToast.test.ts
Normal file
43
src/composables/useSnackbarToast.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
32
src/composables/useSnackbarToast.ts
Normal file
32
src/composables/useSnackbarToast.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user