Toast notifications via Reka UI Toast

- AppToast component: top-center blue pill (info), red pill (errors)
- Global error handler catches window.error + unhandledrejection
- Link copied toast on share and copy link button
- use-toast composable: toast.show(message, variant)
This commit is contained in:
Danila Poyarkov 2026-03-01 18:25:04 +03:00
parent f90e1400b7
commit 56aa194785
7 changed files with 85 additions and 0 deletions

View file

@ -19,6 +19,9 @@
### UI
- Toast notifications via Reka UI Toast — top-center blue pill for info, red for errors
- Global error handler (window.error + unhandledrejection) shows errors as toasts
- Link copied toast on share and copy link actions
- HsvColorArea extracted as shared component (ColorPicker + FillPicker)
- Scrollable app menu without visible scrollbar
- Selection broadcasting to remote peers

2
components.d.ts vendored
View file

@ -14,6 +14,7 @@ declare module 'vue' {
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
AppMenu: typeof import('./src/components/AppMenu.vue')['default']
AppToast: typeof import('./src/components/AppToast.vue')['default']
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
@ -31,6 +32,7 @@ declare module 'vue' {
FontPicker: typeof import('./src/components/FontPicker.vue')['default']
HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default']
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default']
IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default']
IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default']

View file

@ -1,3 +1,15 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import AppToast from '@/components/AppToast.vue'
import { toast } from '@/composables/use-toast'
onMounted(() => {
toast.setupGlobalErrorHandler()
})
</script>
<template>
<RouterView />
<AppToast />
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport } from 'reka-ui'
import { toast } from '@/composables/use-toast'
</script>
<template>
<ToastProvider :duration="toast.TOAST_DURATION" swipe-direction="up">
<ToastRoot
v-for="t in toast.toasts.value"
:key="t.id"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
@update:open="
(open) => {
if (!open) toast.remove(t.id)
}
"
>
<icon-lucide-check v-if="t.variant === 'default'" class="size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="size-3 shrink-0" />
<ToastDescription>{{ t.message }}</ToastDescription>
</ToastRoot>
<ToastViewport
class="fixed top-2 left-1/2 z-[9999] flex -translate-x-1/2 flex-col items-center gap-1.5"
/>
</ToastProvider>
</template>

View file

@ -13,6 +13,7 @@ import {
} from 'reka-ui'
import type { CollabState, RemotePeer } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import type { Color } from '@/types'
const props = defineProps<{
@ -45,6 +46,7 @@ const isJoining = computed(() => !!props.pendingRoomId && !props.state.connected
function copyLink() {
if (!shareUrl.value) return
navigator.clipboard.writeText(shareUrl.value)
toast.show('Link copied to clipboard')
copied.value = true
setTimeout(() => {
copied.value = false

View file

@ -0,0 +1,34 @@
import { ref } from 'vue'
export type ToastVariant = 'default' | 'error'
export interface Toast {
id: number
message: string
variant: ToastVariant
}
const TOAST_DURATION = 3000
const toasts = ref<Toast[]>([])
let nextId = 0
function show(message: string, variant: ToastVariant = 'default') {
toasts.value.push({ id: ++nextId, message, variant })
}
function remove(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
function setupGlobalErrorHandler() {
window.addEventListener('error', (e) => {
show(e.message || 'An unexpected error occurred', 'error')
})
window.addEventListener('unhandledrejection', (e) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason)
show(msg || 'An unexpected error occurred', 'error')
})
}
export const toast = { show, remove, toasts, setupGlobalErrorHandler, TOAST_DURATION }

View file

@ -7,6 +7,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import { createDemoShapes } from '@/demo'
import { provideEditorStore } from '@/stores/editor'
@ -47,6 +48,8 @@ const pendingRoomId = (route.params.roomId as string) || null
function onShare() {
const roomId = collab.shareCurrentDoc()
router.push(`/share/${roomId}`)
navigator.clipboard.writeText(`${window.location.origin}/share/${roomId}`)
toast.show('Link copied to clipboard')
}
function onJoin(roomId: string) {