feat(web): Base UI toast region + global mutation feedback wiring (#47)

Add a module-scope Base UI toast manager bridged to the QueryClient so
every mutation can give consistent feedback. A MutationCache (extracted
into a makeQueryClient() factory for test reuse) emits a catch-all,
type-aware error toast (unless meta.suppressErrorToast) and an opt-in
success toast (meta.successMessage), reading mutation.meta + i18n.t
outside React. meta is type-checked via a react-query Register
augmentation. ToastRegion is mounted app-wide in main.tsx. Adds a toast
i18n namespace (en/sv parity) and a validated story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:46:26 +02:00
parent 63bfff417b
commit 604d4f6005
8 changed files with 183 additions and 5 deletions
+31
View File
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, within } from 'storybook/test'
import { ToastRegion } from './toast'
import { toastManager } from '../../toast/toast-manager'
const meta = {
component: ToastRegion,
tags: ['ai-generated'],
} satisfies Meta<typeof ToastRegion>
export default meta
type Story = StoryObj<typeof meta>
export const Success: Story = {
args: { children: null },
play: async () => {
toastManager.add({ type: 'success', description: 'Saved' })
await expect(await within(document.body).findByText('Saved')).toBeInTheDocument()
},
}
export const Error: Story = {
args: { children: null },
play: async () => {
toastManager.add({ type: 'error', description: 'Something went wrong' })
await expect(
await within(document.body).findByText('Something went wrong'),
).toBeInTheDocument()
},
}
+55
View File
@@ -0,0 +1,55 @@
import type * as React from "react";
import { Toast as ToastPrimitive } from "@base-ui/react/toast";
import { cn } from "@/lib/utils";
import { toastManager } from "@/toast/toast-manager";
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager();
return toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
toast={toast}
data-slot="toast"
className={cn(
"flex items-start gap-2 rounded-md border bg-white p-3 text-sm shadow-md",
toast.type === "error" && "border-red-300",
toast.type === "success" && "border-green-300",
)}
>
<div className="flex-1">
{toast.title && (
<ToastPrimitive.Title
data-slot="toast-title"
className="font-medium"
/>
)}
<ToastPrimitive.Description
data-slot="toast-description"
className="text-neutral-700"
/>
</div>
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close"
className="text-neutral-400 hover:text-neutral-700"
>
×
</ToastPrimitive.Close>
</ToastPrimitive.Root>
));
}
/** App-wide toast region: provides the external manager + a portaled viewport. */
export function ToastRegion({ children }: { children: React.ReactNode }) {
return (
<ToastPrimitive.Provider toastManager={toastManager}>
{children}
<ToastPrimitive.Portal>
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
<ToastList />
</ToastPrimitive.Viewport>
</ToastPrimitive.Portal>
</ToastPrimitive.Provider>
);
}