Add toast notification system with sonner library
- Create Toaster component wrapping sonner at bottom-right position - Add showToast utility with success/error/info methods - Error toasts persist until dismissed, others auto-dismiss after 5s - Migrate error handling to toasts across all pages: - Dashboard (override toggle errors) - Settings (save/load success/error) - Garmin settings (connection success/error) - Calendar (load errors) - Period History (load/delete errors) - Add dark mode support for toast styling - Add Toaster provider to root layout - 27 new tests (23 toaster component + 4 integration) - Total: 977 unit tests passing P5.2 COMPLETE - All P0-P5 items now complete. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
291
src/components/ui/toaster.test.tsx
Normal file
291
src/components/ui/toaster.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
// ABOUTME: Unit tests for the Toaster component and toast utility functions.
|
||||
// ABOUTME: Tests cover rendering, toast types, auto-dismiss behavior, and error persistence.
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AUTO_DISMISS_DURATION, showToast, Toaster } from "./toaster";
|
||||
|
||||
describe("Toaster", () => {
|
||||
// Clear any existing toasts between tests
|
||||
beforeEach(() => {
|
||||
// Render a fresh toaster for each test
|
||||
document.body.innerHTML = "";
|
||||
|
||||
// Mock setPointerCapture/releasePointerCapture for jsdom
|
||||
// Sonner uses these for swipe gestures which jsdom doesn't support
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
Element.prototype.setPointerCapture = vi.fn();
|
||||
}
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
}
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders without crashing", () => {
|
||||
render(<Toaster />);
|
||||
// Toaster renders a portal, so it won't have visible content initially
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders in bottom-right position by default", () => {
|
||||
render(<Toaster />);
|
||||
// Sonner creates an ol element for toasts with data-sonner-toaster attribute
|
||||
const toaster = document.querySelector("[data-sonner-toaster]");
|
||||
expect(toaster).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showToast utility", () => {
|
||||
it("shows success toast", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.success("Operation completed");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Operation completed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Something went wrong");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows info toast", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.info("Here is some information");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Here is some information"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast with custom message from spec examples", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Unable to fetch data. Retry?");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Unable to fetch data. Retry?"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast with Garmin sync message from spec", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Garmin data unavailable. Using last known values.");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Garmin data unavailable. Using last known values."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast with save error message from spec", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Failed to save. Try again.");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Failed to save. Try again."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toast duration configuration", () => {
|
||||
it("exports AUTO_DISMISS_DURATION as 5000ms", () => {
|
||||
expect(AUTO_DISMISS_DURATION).toBe(5000);
|
||||
});
|
||||
|
||||
it("success toasts are configured with auto-dismiss duration", () => {
|
||||
// We verify the implementation by checking the exported constant
|
||||
// and trusting the sonner library to honor the duration
|
||||
expect(AUTO_DISMISS_DURATION).toBe(5000);
|
||||
});
|
||||
|
||||
it("error toasts are configured to persist", async () => {
|
||||
// Error toasts should use Infinity duration
|
||||
// We verify by checking that the error toast API exists
|
||||
expect(showToast.error).toBeDefined();
|
||||
expect(typeof showToast.error).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple toasts", () => {
|
||||
it("can show multiple toasts at once", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.success("First toast");
|
||||
showToast.error("Second toast");
|
||||
showToast.info("Third toast");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First toast")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Second toast")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Third toast")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toast styling", () => {
|
||||
it("applies correct data-type attribute for success toasts", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.success("Styled success");
|
||||
|
||||
await waitFor(() => {
|
||||
const toast = screen
|
||||
.getByText("Styled success")
|
||||
.closest("[data-sonner-toast]");
|
||||
expect(toast).toHaveAttribute("data-type", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies correct data-type attribute for error toasts", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Styled error");
|
||||
|
||||
await waitFor(() => {
|
||||
const toast = screen
|
||||
.getByText("Styled error")
|
||||
.closest("[data-sonner-toast]");
|
||||
expect(toast).toHaveAttribute("data-type", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies correct data-type attribute for info toasts", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.info("Styled info");
|
||||
|
||||
await waitFor(() => {
|
||||
const toast = screen
|
||||
.getByText("Styled info")
|
||||
.closest("[data-sonner-toast]");
|
||||
expect(toast).toHaveAttribute("data-type", "info");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("toast container has aria-live for screen readers", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.success("Accessible toast");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Accessible toast")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Sonner uses aria-live on the section container for announcements
|
||||
const section = document.querySelector("section[aria-live]");
|
||||
expect(section).toHaveAttribute("aria-live", "polite");
|
||||
expect(section).toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("toast container has aria-atomic for complete announcements", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Error for screen reader");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Error for screen reader")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The section should have aria-atomic for screen reader announcements
|
||||
const section = document.querySelector("section[aria-live]");
|
||||
expect(section).toHaveAttribute("aria-atomic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toast dismissal", () => {
|
||||
it("toasts have close button", async () => {
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Dismissible toast");
|
||||
|
||||
await waitFor(() => {
|
||||
const toast = screen
|
||||
.getByText("Dismissible toast")
|
||||
.closest("[data-sonner-toast]");
|
||||
expect(toast).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close button should be rendered for all toasts
|
||||
const closeButton = document.querySelector(
|
||||
"[data-sonner-toast] button[data-close-button]",
|
||||
);
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking close button dismisses toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Toaster />);
|
||||
|
||||
showToast.error("Toast to dismiss");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Toast to dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the close button
|
||||
const closeButton = document.querySelector(
|
||||
"[data-sonner-toast] button[data-close-button]",
|
||||
) as HTMLElement;
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
await user.click(closeButton);
|
||||
|
||||
// Wait for toast to be dismissed
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.queryByText("Toast to dismiss"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API surface", () => {
|
||||
it("exports Toaster component", () => {
|
||||
expect(Toaster).toBeDefined();
|
||||
expect(typeof Toaster).toBe("function");
|
||||
});
|
||||
|
||||
it("exports showToast with success method", () => {
|
||||
expect(showToast.success).toBeDefined();
|
||||
expect(typeof showToast.success).toBe("function");
|
||||
});
|
||||
|
||||
it("exports showToast with error method", () => {
|
||||
expect(showToast.error).toBeDefined();
|
||||
expect(typeof showToast.error).toBe("function");
|
||||
});
|
||||
|
||||
it("exports showToast with info method", () => {
|
||||
expect(showToast.info).toBeDefined();
|
||||
expect(typeof showToast.info).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/components/ui/toaster.tsx
Normal file
76
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// ABOUTME: Toast notification component wrapping sonner for consistent user feedback.
|
||||
// ABOUTME: Exports Toaster component and showToast utility for success/error/info messages.
|
||||
|
||||
"use client";
|
||||
|
||||
import { Toaster as SonnerToaster, toast } from "sonner";
|
||||
|
||||
// Auto-dismiss duration in ms (5 seconds per spec)
|
||||
export const AUTO_DISMISS_DURATION = 5000;
|
||||
|
||||
// Error duration - Infinity means persist until dismissed
|
||||
const ERROR_PERSIST_DURATION = Number.POSITIVE_INFINITY;
|
||||
|
||||
/**
|
||||
* Toaster component - renders in bottom-right position per spec.
|
||||
* Should be placed in the root layout to be available throughout the app.
|
||||
*/
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
// Default duration for non-error toasts
|
||||
duration: AUTO_DISMISS_DURATION,
|
||||
// Add close button for all toasts
|
||||
closeButton: true,
|
||||
// Styling that works with dark mode
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-white group-[.toaster]:text-zinc-950 group-[.toaster]:border-zinc-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-zinc-950 dark:group-[.toaster]:text-zinc-50 dark:group-[.toaster]:border-zinc-800",
|
||||
success:
|
||||
"group-[.toaster]:border-green-500 group-[.toaster]:text-green-700 dark:group-[.toaster]:text-green-400",
|
||||
error:
|
||||
"group-[.toaster]:border-red-500 group-[.toaster]:text-red-700 dark:group-[.toaster]:text-red-400",
|
||||
info: "group-[.toaster]:border-blue-500 group-[.toaster]:text-blue-700 dark:group-[.toaster]:text-blue-400",
|
||||
closeButton:
|
||||
"group-[.toast]:bg-zinc-100 group-[.toast]:text-zinc-500 group-[.toast]:border-zinc-200 dark:group-[.toast]:bg-zinc-800 dark:group-[.toast]:text-zinc-400 dark:group-[.toast]:border-zinc-700",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast utility functions for showing notifications.
|
||||
* Use these instead of calling toast() directly for consistent behavior.
|
||||
*/
|
||||
export const showToast = {
|
||||
/**
|
||||
* Show a success toast that auto-dismisses after 5 seconds.
|
||||
*/
|
||||
success: (message: string) => {
|
||||
toast.success(message, {
|
||||
duration: AUTO_DISMISS_DURATION,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an error toast that persists until manually dismissed.
|
||||
* Per spec: "Errors persist until dismissed"
|
||||
*/
|
||||
error: (message: string) => {
|
||||
toast.error(message, {
|
||||
duration: ERROR_PERSIST_DURATION,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an info toast that auto-dismisses after 5 seconds.
|
||||
*/
|
||||
info: (message: string) => {
|
||||
toast.info(message, {
|
||||
duration: AUTO_DISMISS_DURATION,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user