Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/components/toast/SnackbarToast.stories.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 77 additions & 0 deletions src/components/toast/SnackbarToast.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 165 additions & 0 deletions src/components/toast/SnackbarToastProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading