From ded9e6cc0d7955e47298394ee3b5be4a2aa83107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Sat, 29 Aug 2026 00:36:38 +0100 Subject: [PATCH 1/2] feat: add notification_bell with a11y compliance and validation alerts Implements the navbar alert bell badge as a keyboard-operable (role=button) disclosure with ARIA compliance (#320): accessible name, aria-haspopup/aria-expanded/aria-controls, aria-live announcement regions, aria-hidden on decorative glyphs, focus-visible rings and design-token contrast. Adds field error indicators and alerts (#324): validation field configs render role=alert error text that toggles as validation triggers, wired via aria-describedby and counted toward the unread badge. Adds React Testing Library tests covering both requirements. Closes #320 Closes #324 --- __tests__/notification_bell.test.tsx | 257 +++++++++++++++++++++++++++ app/components/notification_bell.tsx | 184 +++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 __tests__/notification_bell.test.tsx create mode 100644 app/components/notification_bell.tsx diff --git a/__tests__/notification_bell.test.tsx b/__tests__/notification_bell.test.tsx new file mode 100644 index 0000000..9b3ec60 --- /dev/null +++ b/__tests__/notification_bell.test.tsx @@ -0,0 +1,257 @@ +/** + * Test suite for `notification_bell` (Navbar alert bell badge). + * + * Covers: + * - #320 a11y compliance: keyboard operability, ARIA roles/attributes, + * aria-live regions, aria-hidden on decorative glyphs, focus-visible + * styling, and accessible labels / badge counts. + * - #324 validation alerts: error text elements that toggle when + * validation triggers, role="alert" announcement, aria-invalid + + * aria-describedby wiring, and badge counts driven by errors. + */ + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import NotificationBell from "@/app/components/notification_bell"; + +const renderBell = (props = {}) => render(); + +// =========================================================================== +// #320 — a11y: keyboard operability & ARIA roles +// =========================================================================== + +describe("notification_bell — a11y (keyboard & ARIA)", () => { + it("renders a native button trigger with an accessible name", () => { + renderBell(); + expect(screen.getByRole("button", { name: /Notifications/ })).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInstanceOf(HTMLButtonElement); + }); + + it("exposes the disclosure state via aria-expanded", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("declares the panel with aria-haspopup and links it via aria-controls", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + const panelId = trigger.getAttribute("aria-controls"); + expect(panelId).toBeTruthy(); + expect(document.getElementById(panelId as string)?.id).toBe(panelId); + }); + + it("marks decorative bell glyph as aria-hidden", () => { + renderBell(); + const glyph = screen.getByText("🔔"); + expect(glyph).toHaveAttribute("aria-hidden", "true"); + }); + + it("marks the visible badge count as aria-hidden and duplicates it in sr-only text", () => { + renderBell({ + notifications: [{ id: "n1", type: "info", title: "Hi" }], + }); + const hiddenCount = screen.getAllByText("1").find((el) => + el.hasAttribute("aria-hidden") + ); + expect(hiddenCount).toBeTruthy(); + expect( + screen.getByText("1 unread notification") + ).toBeInTheDocument(); + }); + + it("announces the panel via an aria-live region once opened", () => { + renderBell(); + fireEvent.click(screen.getByRole("button")); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-live", "polite"); + }); + + it("provides a focus-visible ring class on the trigger", () => { + renderBell(); + expect(screen.getByRole("button").className).toMatch(/focus-visible:ring/); + }); + + it("operates from the keyboard (Enter/Space activate the native button)", () => { + renderBell(); + const trigger = screen.getByRole("button"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + fireEvent.keyDown(trigger, { key: " " }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); +}); + +// =========================================================================== +// #320 — a11y: accessible names & landmark context +// =========================================================================== + +describe("notification_bell — a11y (labels & landmarks)", () => { + it("supports a custom accessible-name label on the trigger", () => { + renderBell({ label: "Alerts" }); + expect(screen.getByRole("button", { name: /Alerts/ })).toBeInTheDocument(); + }); + + it("names the dialog panel after the label", () => { + renderBell({ label: "Alerts" }); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("dialog", { name: "Alerts panel" })).toBeInTheDocument(); + }); + + it("groups validation fields inside a labelled region", () => { + renderBell({ fields: [{ name: "amount", label: "Amount" }] }); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("group", { name: "Validation errors" }) + ).toBeInTheDocument(); + }); + + it("shows a 'caught up' message when there is nothing to show", () => { + renderBell(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("You're all caught up.")).toBeInTheDocument(); + }); +}); + +// =========================================================================== +// #324 — validation alerts toggle with validation triggers +// =========================================================================== + +describe("notification_bell — validation alerts (#324)", () => { + it("renders an error message when a field is invalid", () => { + renderBell({ + fields: [ + { name: "amount", label: "Milestone amount", error: "Amount is required." }, + ], + }); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("alert", { name: "" }) + ).toBeInTheDocument(); + expect(screen.getByText("Amount is required.")).toBeInTheDocument(); + expect(screen.getByText("Invalid")).toBeInTheDocument(); + }); + + it("hides the error text when the field becomes valid", () => { + const { rerender } = render( + + ); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("Amount is required.")).toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText("Amount is required.")).not.toBeInTheDocument(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + }); + + it("marks valid fields as clean with no alert role", () => { + renderBell({ fields: [{ name: "amount", label: "Milestone amount" }] }); + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + }); + + it("announces field errors with role=alert and assertive aria-live", () => { + renderBell({ + fields: [ + { name: "deadline", label: "Deadline", error: "Deadline is in the past." }, + ], + }); + fireEvent.click(screen.getByRole("button")); + const alertEl = screen.getByRole("alert"); + expect(alertEl).toHaveAttribute("aria-live", "assertive"); + expect(alertEl).toHaveTextContent("Deadline is in the past."); + }); + + it("counts invalid fields toward the badge", () => { + renderBell({ + fields: [ + { name: "a", label: "A", error: "bad" }, + { name: "b", label: "B", error: "bad" }, + ], + }); + expect(screen.getAllByText("2")).toHaveLength(1); + expect(screen.getByText("2 unread notifications")).toBeInTheDocument(); + }); + + it("clears the badge when all fields validate", () => { + const { rerender } = render( + + ); + expect(screen.getByText("1 unread notification")).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/unread notification/)).not.toBeInTheDocument(); + }); + + it("renders a per-field indicator inside the validation group", () => { + renderBell({ + fields: [ + { name: "amount", label: "Milestone amount", error: "Amount is required." }, + { name: "token", label: "Token" }, + ], + }); + fireEvent.click(screen.getByRole("button")); + const group = screen.getByRole("group", { name: "Validation errors" }); + expect(within(group).getByText("Amount is required.")).toBeInTheDocument(); + expect(within(group).getByText("Milestone amount")).toBeInTheDocument(); + expect(within(group).getByText("Token")).toBeInTheDocument(); + }); +}); + +// =========================================================================== +// #324 — notification panels & alert roles +// =========================================================================== + +describe("notification_bell — notifications & alert roles", () => { + it("renders each notification in the panel", () => { + renderBell({ + notifications: [ + { id: "n1", type: "info", title: "New milestone" }, + { id: "n2", type: "warning", title: "Low balance" }, + ], + }); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("New milestone")).toBeInTheDocument(); + expect(screen.getByText("Low balance")).toBeInTheDocument(); + }); + + it("uses role=alert with assertive live for error notifications", () => { + renderBell({ + notifications: [{ id: "err", type: "error", title: "Signature failed" }], + }); + fireEvent.click(screen.getByRole("button")); + const alertEl = screen.getByRole("alert"); + expect(alertEl).toHaveAttribute("aria-live", "assertive"); + expect(alertEl).toHaveTextContent("Signature failed"); + }); + + it("uses role=status for non-error notifications", () => { + renderBell({ + notifications: [{ id: "ok", type: "success", title: "Released" }], + }); + fireEvent.click(screen.getByRole("button")); + const statusEl = screen.getAllByRole("status").find((el) => + el.textContent?.includes("Released") + ); + expect(statusEl).toBeTruthy(); + }); + + it("marks the panel hidden until opened", () => { + renderBell({ notifications: [{ id: "n1", type: "info", title: "Hi" }] }); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveProperty("hidden", true); + fireEvent.click(screen.getByRole("button")); + expect(dialog).toHaveProperty("hidden", false); + }); +}); diff --git a/app/components/notification_bell.tsx b/app/components/notification_bell.tsx new file mode 100644 index 0000000..4b6dc17 --- /dev/null +++ b/app/components/notification_bell.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useId, useState } from "react"; + +export type NotificationType = "error" | "warning" | "success" | "info"; + +export interface NotificationItem { + id: string; + type: NotificationType; + title: string; + message?: string; +} + +export interface NotificationField { + name: string; + label: string; + error?: string | null; +} + +export interface NotificationBellProps { + /** Notifications to surface in the panel. */ + notifications?: NotificationItem[]; + /** Validation field configurations; entries with an `error` render an alert. */ + fields?: NotificationField[]; + /** Label used for the trigger button (defaults to "Notifications"). */ + label?: string; +} + +const TYPE_STYLES: Record = { + error: "border-danger bg-danger/40 text-danger-soft", + warning: "border-warning bg-warning/40 text-warning-soft", + success: "border-success bg-success/40 text-success-soft", + info: "border-accent bg-accent/40 text-accent-soft", +}; + +const TYPE_ICON: Record = { + error: "✕", + warning: "⚠", + success: "✓", + info: "ℹ", +}; + +function computeBadgeCount(notifications: NotificationItem[], fields: NotificationField[]) { + return notifications.length + fields.filter((f) => f.error).length; +} + +/** + * `notification_bell` — navbar alert bell badge. + * + * Accessibility (a11y): + * - Native ` + + + + ); +} From a9573a64bdf8b93086b0da8ce3d56faad2347163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Sat, 29 Aug 2026 17:41:50 +0100 Subject: [PATCH 2/2] feat: add Storybook interface mocks for notification_bell [closes #328] --- app/components/notification_bell.stories.tsx | 179 +++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 app/components/notification_bell.stories.tsx diff --git a/app/components/notification_bell.stories.tsx b/app/components/notification_bell.stories.tsx new file mode 100644 index 0000000..ed2c6ba --- /dev/null +++ b/app/components/notification_bell.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import NotificationBell from "./notification_bell"; + +const meta = { + title: "Components/NotificationBell", + component: NotificationBell, + tags: ["autodocs"], + parameters: { + layout: "padded", + backgrounds: { + default: "dark", + values: [ + { name: "dark", value: "#0f1117" }, + { name: "light", value: "#ffffff" }, + ], + }, + }, + argTypes: { + label: { control: "text" }, + }, + args: { + label: "Notifications", + }, +} satisfies Meta; +type Story = StoryObj; + +export default meta; + +// --------------------------------------------------------------------------- +// 1. Default — no notifications, no validation fields +// --------------------------------------------------------------------------- + +export const Default: Story = { + name: "Default — no notifications", + args: { + notifications: [], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 2. With notifications +// --------------------------------------------------------------------------- + +export const WithNotifications: Story = { + name: "With notifications", + args: { + notifications: [ + { id: "1", type: "success", title: "Payment received", message: "10 USDC sent to your wallet" }, + { id: "2", type: "info", title: "New message", message: "You have a new message from buyer" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 3. With error notification +// --------------------------------------------------------------------------- + +export const WithError: Story = { + name: "With error notification", + args: { + notifications: [ + { id: "1", type: "error", title: "Payment failed", message: "Insufficient balance" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 4. With warning notification +// --------------------------------------------------------------------------- + +export const WithWarning: Story = { + name: "With warning notification", + args: { + notifications: [ + { id: "1", type: "warning", title: "Action required", message: "Please verify your email" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 5. With success notification +// --------------------------------------------------------------------------- + +export const WithSuccess: Story = { + name: "With success notification", + args: { + notifications: [ + { id: "1", type: "success", title: "Deposit completed", message: "Your deposit of 50 USDC was successful" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 6. With info notification +// --------------------------------------------------------------------------- + +export const WithInfo: Story = { + name: "With info notification", + args: { + notifications: [ + { id: "1", type: "info", title: "System update", message: "Maintenance scheduled for tonight" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 7. With validation fields having errors +// --------------------------------------------------------------------------- + +export const WithValidationErrors: Story = { + name: "With validation field errors", + args: { + notifications: [], + fields: [ + { + name: "amount", + label: "Amount", + error: "Amount must be greater than 0", + }, + { + name: "recipient", + label: "Recipient", + error: "Invalid recipient address", + }, + ], + }, +}; + +// --------------------------------------------------------------------------- +// 8. With both notifications and validation fields +// --------------------------------------------------------------------------- + +export const WithNotificationsAndErrors: Story = { + name: "With notifications and validation errors", + args: { + notifications: [ + { id: "1", type: "error", title: "Payment failed", message: "Insufficient balance" }, + ], + fields: [ + { + name: "amount", + label: "Amount", + error: "Amount must be greater than 0", + }, + ], + }, +}; + +// --------------------------------------------------------------------------- +// 9. Custom label +// --------------------------------------------------------------------------- + +export const CustomLabel: Story = { + name: "Custom label", + args: { + notifications: [], + fields: [], + label: "My Notifications", + }, +}; + +// --------------------------------------------------------------------------- +// 10. Empty state — all caught up +// --------------------------------------------------------------------------- + +export const EmptyState: Story = { + name: "Empty — all caught up", + args: { + notifications: [], + fields: [], + }, +}; \ No newline at end of file