diff --git a/__tests__/freighter_connector.component.test.tsx b/__tests__/freighter_connector.component.test.tsx index 5b0fdf4..ae075c8 100644 --- a/__tests__/freighter_connector.component.test.tsx +++ b/__tests__/freighter_connector.component.test.tsx @@ -8,6 +8,39 @@ import { HIGH_FEE_THRESHOLD_STROOPS, } from "@/app/lib/freighter_connector"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. This component suite +// renders FreighterGasWarningBanner inside FreighterConnector via +// GasEstimationWarningBanner, which only reads `gasWarning` from the wallet +// context — stubbing `useWallet` with the real provider's default shape keeps +// the real freighter_connector logic under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "freighter", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + // --------------------------------------------------------------------------- // #112 — React Testing Library assertions for freighter_connector // --------------------------------------------------------------------------- diff --git a/__tests__/freighter_multisig_hook.test.ts b/__tests__/freighter_multisig_hook.test.ts index f4f82bb..608b42c 100644 --- a/__tests__/freighter_multisig_hook.test.ts +++ b/__tests__/freighter_multisig_hook.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { useFreighterMultiSigAssembly } from "@/app/hooks/useFreighterMultiSigAssembly"; import { WalletMultiSigStructureError } from "@/app/lib/wallet_state_context"; import { @@ -13,13 +14,31 @@ function toBase64(text: string): string { const TEST_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +// The hook validates XDR against a real @stellar/stellar-sdk parser, so the +// fixtures must be genuine signed transaction envelopes rather than arbitrary +// bytes. Building a real envelope (and signing it so `signatures` is +// populated) exercises the parser instead of failing it. +function buildSignedEnvelope(): string { + const keypair = Keypair.random(); + const account = new Account(keypair.publicKey(), "0"); + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: "0" })) + .setTimeout(30) + .build(); + transaction.sign(keypair); + return transaction.toEnvelope().toXDR("base64"); +} + describe("useFreighterMultiSigAssembly hook (#109)", () => { it("parseStructure parses a well-formed envelope without errors", () => { const { result } = renderHook(() => useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const shape = result.current.parseStructure(xdr); expect(shape.baseXdr).toBe(xdr); @@ -42,7 +61,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); expect(tx).toBeDefined(); }); @@ -62,7 +81,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("b".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); const serialized = result.current.serializeTransaction(tx); expect(typeof serialized).toBe("string"); @@ -74,7 +93,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("c".repeat(200)); + const xdr = buildSignedEnvelope(); const signFn = vi.fn(async () => "signed-xdr"); const signed = await result.current.signTransaction(xdr, signFn); 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/__tests__/notification_bell_interactive_states.test.tsx b/__tests__/notification_bell_interactive_states.test.tsx new file mode 100644 index 0000000..f5443e6 --- /dev/null +++ b/__tests__/notification_bell_interactive_states.test.tsx @@ -0,0 +1,107 @@ +/** + * Test suite for `notification_bell` premium interactive states (#321). + * + * Covers: + * - Visible hover affordances (background + pointer affordances). + * - Active/pressed feedback on the trigger. + * - Focus-visible ring for keyboard users. + * - Disabled trigger: native + ARIA disabled, no toggle, styled fallback, + * keyboard inertness while the unread badge stays visible. + */ + +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import NotificationBell from "@/app/components/notification_bell"; + +const renderBell = (props = {}) => render(); + +describe("notification_bell — interactive states (#321)", () => { + let trigger: HTMLElement; + + beforeEach(() => { + renderBell({ notifications: [{ id: "n1", type: "info", title: "Hi" }] }); + trigger = screen.getByRole("button"); + }); + + it("restyles the trigger on hover", () => { + expect(trigger.className).toMatch(/hover:bg-gray-700/); + }); + + it("provides pressed/active feedback on the trigger", () => { + expect(trigger.className).toMatch(/active:bg-gray-600/); + expect(trigger.className).toMatch(/active:scale-95/); + }); + + it("keeps the focus-visible keyboard ring on the trigger", () => { + expect(trigger.className).toMatch(/focus-visible:ring-2/); + expect(trigger.className).toMatch(/focus-visible:ring-indigo-400/); + expect(trigger.className).toMatch(/focus-visible:ring-offset-2/); + }); + + it("is still keyboard-operable and toggles the panel on activation", () => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); +}); + +describe("notification_bell — disabled trigger (#321)", () => { + it("reflects the disabled state on native and ARIA attributes", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + expect(trigger).toBeDisabled(); + expect(trigger).toHaveAttribute("aria-disabled", "true"); + }); + + it("applies disabled affordances (muted, inert hover/press/focus)", () => { + renderBell({ disabled: true, notifications: [{ id: "n", type: "info", title: "Hi" }] }); + const trigger = screen.getByRole("button"); + expect(trigger.className).toMatch(/disabled:cursor-not-allowed/); + expect(trigger.className).toMatch(/disabled:opacity-60/); + expect(trigger.className).toMatch(/disabled:hover:bg-gray-800/); + expect(trigger.className).toMatch(/disabled:active:scale-100/); + expect(trigger.className).toMatch(/disabled:focus-visible:ring-0/); + }); + + it("does not open the panel when clicked while disabled", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveProperty("hidden", true); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(dialog).toHaveProperty("hidden", true); + }); + + it("ignores keyboard activation while disabled", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("dialog", { hidden: true })).toHaveProperty( + "hidden", + true + ); + }); + + it("retains the unread badge and accessible name while disabled", () => { + const label = "Alerts"; + renderBell({ + disabled: true, + label, + notifications: [{ id: "n", type: "error", title: "Boom" }], + }); + expect(screen.getByRole("button", { name: /Alerts/ })).toBeInTheDocument(); + expect(screen.getByText("1 unread notification")).toBeInTheDocument(); + }); + + it("does not expose disabled when the prop is absent", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).not.toBeDisabled(); + expect(trigger).not.toHaveAttribute("aria-disabled"); + }); +}); \ No newline at end of file diff --git a/__tests__/signature_timeout_alert.test.tsx b/__tests__/signature_timeout_alert.test.tsx index d356886..f3c8a79 100644 --- a/__tests__/signature_timeout_alert.test.tsx +++ b/__tests__/signature_timeout_alert.test.tsx @@ -1,9 +1,43 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import SignatureTimeoutAlert from "@/app/components/SignatureTimeoutAlert"; import WalletLoaderOverlay from "@/app/components/WalletLoaderOverlay"; import { endWalletOperation, startWalletOperation } from "@/app/lib/wallet_state_context"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. These suites never +// exercise provider-driven wallet state (they render the components with bare +// props), so `useWallet` is stubbed with the same defaults the real +// WalletContext provides via `createContext`. The real wallet library modules +// (wallet_state_context, freighter_connector, albedo_connector, +// ledger_usb_bridge) stay under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "albedo", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + describe("SignatureTimeoutAlert", () => { it("renders timeout details and logs a formatted stack trace", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -54,9 +88,13 @@ describe("SignatureTimeoutAlert", () => { it("keeps the loader counter balanced when an external operation is active", async () => { render(); - startWalletOperation(); + act(() => { + startWalletOperation(); + }); expect(screen.getByTestId("wallet-loader-overlay")).toBeInTheDocument(); - endWalletOperation(); + act(() => { + endWalletOperation(); + }); await waitFor(() => expect(screen.queryByTestId("wallet-loader-overlay")).not.toBeInTheDocument()); }); }); diff --git a/__tests__/signature_timeout_alert_timeout.test.ts b/__tests__/signature_timeout_alert_timeout.test.ts index dd38e25..f0c0599 100644 --- a/__tests__/signature_timeout_alert_timeout.test.ts +++ b/__tests__/signature_timeout_alert_timeout.test.ts @@ -107,8 +107,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 1_000); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(1_000); - await promise.catch(() => {}); + await onRejected; expect(request.payload).toBeNull(); expect(payload.every((b) => b === 0)).toBe(true); @@ -131,8 +132,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 500); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(500); - await promise.catch(() => {}); + await onRejected; // Memory cleared — operation cannot leak sensitive bytes post-abort. expect(request.payload).toBeNull(); @@ -210,8 +212,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { }); const promise = runSignatureWithTimeout(request, signFn, 5_000); + const onRejected = promise.catch(() => {}); await vi.runAllTimersAsync(); - await promise.catch(() => {}); + await onRejected; // Memory should NOT be cleared for non-timeout errors (unmodified request) // The timeout path specifically clears; other errors pass through as-is. diff --git a/app/components/SignatureTimeoutAlert.tsx b/app/components/SignatureTimeoutAlert.tsx index eec9aaf..668468f 100644 --- a/app/components/SignatureTimeoutAlert.tsx +++ b/app/components/SignatureTimeoutAlert.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useWallet } from "@/app/context/WalletContext"; import { NETWORK_PASSPHRASE } from "@/app/lib/contract"; import { @@ -50,7 +50,6 @@ export default function SignatureTimeoutAlert({ const albedoAssembly = useAlbedoMultiSigAssembly(NETWORK_PASSPHRASE); const ledgerAssembly = useLedgerMultiSigAssembly(NETWORK_PASSPHRASE); const [isRetrying, setIsRetrying] = useState(false); - const [parseMessage, setParseMessage] = useState(null); const activeError = error ?? signatureTimeoutError; const activeTransactionXdr = transactionXdr ?? signatureTimeoutXdr ?? undefined; @@ -69,21 +68,18 @@ export default function SignatureTimeoutAlert({ }); }, [activeError, hasTimeout, networkMismatchMessage, transactionId]); - useEffect(() => { - if (!activeTransactionXdr) { - setParseMessage(null); - return; - } + // Derived state: parse failures are computed on render instead of being + // written to state from an effect (avoids cascading renders). + const parseMessage = useMemo(() => { + if (!activeTransactionXdr) return null; try { parseMultiSigEnvelope(activeTransactionXdr, { parseEnvelopeXdr: createStellarEnvelopeParser(NETWORK_PASSPHRASE), }); - setParseMessage(null); + return null; } catch (parseError) { - setParseMessage( - parseError instanceof Error ? parseError.message : String(parseError) - ); + return parseError instanceof Error ? parseError.message : String(parseError); } }, [activeTransactionXdr]); 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 diff --git a/app/components/notification_bell.tsx b/app/components/notification_bell.tsx new file mode 100644 index 0000000..655badd --- /dev/null +++ b/app/components/notification_bell.tsx @@ -0,0 +1,194 @@ +"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; + /** Disables the trigger and its panel while retaining the unread badge. */ + disabled?: boolean; +} + +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 ` + + + + ); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index f149f27..3bca877 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,40 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom environment: normalize the web-storage globals across Node versions. +// Newer Node releases ship an experimental `localStorage` global that is +// `undefined` unless `--localstorage-file` is passed; that value shadows the +// jsdom implementation in vitest, crashing tests that read bare +// `localStorage`. When the jsdom-provided global did not install, fall back +// to an in-memory Storage so the API stays available and testable. +if (typeof globalThis.localStorage === "undefined") { + function createMemoryStorage(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + const value = store.get(key); + return value === undefined ? null : value; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; + } + + Object.defineProperty(globalThis, "localStorage", { + value: createMemoryStorage(), + configurable: true, + writable: true, + }); +}