diff --git a/src/components/toast/SnackbarToast.stories.ts b/src/components/toast/SnackbarToast.stories.ts new file mode 100644 index 00000000000..6b3453004f4 --- /dev/null +++ b/src/components/toast/SnackbarToast.stories.ts @@ -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 = { + title: 'Components/Toast/SnackbarToast', + component: SnackbarToastProvider, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen' + }, + decorators: [ + () => ({ + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { SnackbarToastProvider, Button, Trigger }, + template: ` + + + + ` + }) +} + +export const WithShortcut: Story = { + render: () => ({ + components: { SnackbarToastProvider, Button, TriggerWithShortcut }, + template: ` + + + + ` + }) +} + +export const WithUndoAction: Story = { + render: () => ({ + components: { SnackbarToastProvider, Button, TriggerWithUndo }, + template: ` + + + + ` + }) +} + +export const Persistent: Story = { + render: () => ({ + components: { SnackbarToastProvider, Button, TriggerPersistent }, + template: ` + + + + ` + }) +} + +const Trigger = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { trigger: () => toast.show('Toast message') } + }, + template: `` +} + +const TriggerWithShortcut = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => toast.show('Links hidden', { shortcut: 'Ctrl+A' }) + } + }, + template: `` +} + +const TriggerWithUndo = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => + toast.show('Subgraph unpacked', { + actionLabel: 'Undo', + onAction: () => toast.show('Subgraph repacked') + }) + } + }, + template: `` +} + +const TriggerPersistent = { + components: { Button }, + setup() { + const toast = useSnackbarToast() + return { + trigger: () => + toast.show('Stays open until dismissed', { duration: 60_000 }) + } + }, + template: `` +} diff --git a/src/components/toast/SnackbarToast.vue b/src/components/toast/SnackbarToast.vue new file mode 100644 index 00000000000..4f98790623e --- /dev/null +++ b/src/components/toast/SnackbarToast.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/toast/SnackbarToastProvider.test.ts b/src/components/toast/SnackbarToastProvider.test.ts new file mode 100644 index 00000000000..a12205e12f3 --- /dev/null +++ b/src/components/toast/SnackbarToastProvider.test.ts @@ -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 + 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) + }) +}) diff --git a/src/components/toast/SnackbarToastProvider.vue b/src/components/toast/SnackbarToastProvider.vue new file mode 100644 index 00000000000..f3d5a393cc9 --- /dev/null +++ b/src/components/toast/SnackbarToastProvider.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/composables/useSnackbarToast.test.ts b/src/composables/useSnackbarToast.test.ts new file mode 100644 index 00000000000..b5297ebec45 --- /dev/null +++ b/src/composables/useSnackbarToast.test.ts @@ -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') + }) +}) diff --git a/src/composables/useSnackbarToast.ts b/src/composables/useSnackbarToast.ts new file mode 100644 index 00000000000..9694e0ee630 --- /dev/null +++ b/src/composables/useSnackbarToast.ts @@ -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 = + Symbol('SnackbarToastApi') + +export function useSnackbarToast(): SnackbarToastApi { + const api = inject(SnackbarToastKey, null) + if (!api) { + throw new Error( + 'useSnackbarToast() must be called within .' + ) + } + return api +}