From ea2c2ec1c819104224e3514f2692ededd6d5e04c Mon Sep 17 00:00:00 2001 From: vicajohn Date: Thu, 27 Aug 2026 06:26:59 +0100 Subject: [PATCH 01/57] feat: implement descriptive empty state UI for dashboard jobs list - Replace hardcoded empty message with EmptyStateCard component - Display briefcase icon for job-related context - Show descriptive title and explanation text - Include role badges (Client, Freelancer, Arbiter) showing available participation options - Add comprehensive test coverage with 16 test cases - Ensure proper accessibility with region landmarks and aria-labels - Validates placeholder display under empty data states --- __tests__/dashboard-list-empty-state.test.tsx | 417 ++++++++++++++++++ app/dashboard/page.tsx | 10 +- 2 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 __tests__/dashboard-list-empty-state.test.tsx diff --git a/__tests__/dashboard-list-empty-state.test.tsx b/__tests__/dashboard-list-empty-state.test.tsx new file mode 100644 index 0000000..5f0b419 --- /dev/null +++ b/__tests__/dashboard-list-empty-state.test.tsx @@ -0,0 +1,417 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Dashboard from "@/app/dashboard/page"; + +const mockUseWallet = vi.fn(); +const mockUseToast = vi.fn(); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/app/context/ToastContext", () => ({ + useToast: () => mockUseToast(), +})); + +vi.mock("@/app/components/Navbar", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/LoadingSkeleton", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/MilestoneCard", () => ({ + default: () =>
, +})); + +describe("Dashboard — empty state placeholder UI", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: "GCLIENT", + signTransaction: vi.fn(), + }); + mockUseToast.mockReturnValue({ + showToast: vi.fn(), + toasts: [], + hideToast: vi.fn(), + }); + }); + + describe("Placeholder elements rendering", () => { + it("displays descriptive empty state card when jobs list is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument(); + }); + }); + + it("renders with proper briefcase icon for job-related context", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + const { container } = render(); + + await waitFor(() => { + const emptyState = screen.getByTestId("dashboard-empty-state"); + expect(emptyState).toBeInTheDocument(); + // Check that SVG icon is rendered + const icons = container.querySelectorAll("svg"); + expect(icons.length).toBeGreaterThan(0); + }); + }); + + it("displays descriptive title 'No jobs found'", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + }); + }); + + it("displays descriptive subtitle explaining the empty state", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText( + /You don't have any jobs yet. Connect your wallet to see jobs you're involved in as a client, freelancer, or arbiter/ + ) + ).toBeInTheDocument(); + }); + }); + }); + + describe("Role badges in empty state", () => { + it("renders role badges showing available participation options", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Client")).toBeInTheDocument(); + expect(screen.getByText("Freelancer")).toBeInTheDocument(); + expect(screen.getByText("Arbiter")).toBeInTheDocument(); + }); + }); + + it("displays all three role badges together in empty state", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const emptyState = screen.getByTestId("dashboard-empty-state"); + const clientBadge = screen.getByText("Client"); + const freelancerBadge = screen.getByText("Freelancer"); + const arbiterBadge = screen.getByText("Arbiter"); + + expect(emptyState).toContainElement(clientBadge); + expect(emptyState).toContainElement(freelancerBadge); + expect(emptyState).toContainElement(arbiterBadge); + }); + }); + }); + + describe("Accessibility and semantic markup", () => { + it("renders empty state as an accessible region landmark", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByRole("region", { name: "No jobs" })).toBeInTheDocument(); + }); + }); + + it("has descriptive aria-label for screen reader context", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const region = screen.getByRole("region", { name: "No jobs" }); + expect(region).toHaveAttribute("aria-label", "No jobs"); + }); + }); + + it("has proper semantic styling with border and rounded container", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const emptyState = screen.getByTestId("dashboard-empty-state"); + expect(emptyState).toHaveClass("border", "rounded-lg", "bg-surface-card"); + }); + }); + }); + + describe("State transitions", () => { + it("transitions from loading skeleton to empty state", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + // Initially shows loading + expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument(); + + // Then transitions to empty state + await waitFor(() => { + expect(screen.queryByTestId("loading-skeleton")).not.toBeInTheDocument(); + expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument(); + }); + }); + + it("hides empty state when jobs load successfully", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId("dashboard-empty-state")).not.toBeInTheDocument(); + }); + }); + }); + + describe("Empty state with different data conditions", () => { + it("shows empty state when API returns empty data array", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument(); + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + }); + }); + + it("shows empty state when filtering results in no matches", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument(); + }); + }); + + it("displays empty state with coherent messaging for disconnected wallet", async () => { + mockUseWallet.mockReturnValue({ + address: null, + signTransaction: vi.fn(), + }); + + render(); + + expect( + screen.getByText(/Connect your wallet to view your jobs/) + ).toBeInTheDocument(); + }); + }); + + describe("Visual hierarchy and structure", () => { + it("centers content within the empty state card", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const emptyState = screen.getByTestId("dashboard-empty-state"); + expect(emptyState).toHaveClass("flex", "flex-col", "items-center", "text-center"); + }); + }); + + it("spaces content elements properly with gap utility", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const emptyState = screen.getByTestId("dashboard-empty-state"); + expect(emptyState).toHaveClass("gap-4"); + }); + }); + }); +}); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 64df656..9840912 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { useWallet } from "@/app/context/WalletContext"; import Navbar from "@/app/components/Navbar"; import MilestoneCard from "@/app/components/MilestoneCard"; import LoadingSkeleton from "@/app/components/LoadingSkeleton"; +import EmptyStateCard from "@/app/components/EmptyStateCard"; import { useActionStates } from "@/app/hooks/useActionStates"; import { useToast } from "@/app/context/ToastContext"; import { @@ -491,7 +492,14 @@ export default function Dashboard() { Error: {error}
) : jobs.length === 0 ? ( -

No jobs found for this wallet

+ ) : (
From b288113cfed75277f22f83d66d3b41d17fe5ad59 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Thu, 27 Aug 2026 06:35:06 +0100 Subject: [PATCH 02/57] feat: implement responsive design for dashboard across mobile, tablet, desktop - Add responsive padding and spacing (px-3 sm:px-6) for mobile-first approach - Implement responsive typography scaling (text-xl sm:text-2xl md:text-3xl) - Stack layout vertically on mobile, horizontal on tablet/desktop - Apply responsive grid layouts (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3) - Make search form full-width on mobile, inline on tablet+ - Add responsive gaps and margins throughout - Implement horizontal overflow handling for pagination on mobile - Reduce button padding and font sizes on mobile viewports - Add comprehensive responsive design test suite with 20 test cases - Validate layout at mobile (< 640px), tablet (640-1024px), desktop (> 1024px) --- __tests__/dashboard-responsive.test.tsx | 652 ++++++++++++++++++++++++ app/dashboard/page.tsx | 68 +-- 2 files changed, 686 insertions(+), 34 deletions(-) create mode 100644 __tests__/dashboard-responsive.test.tsx diff --git a/__tests__/dashboard-responsive.test.tsx b/__tests__/dashboard-responsive.test.tsx new file mode 100644 index 0000000..7019ce1 --- /dev/null +++ b/__tests__/dashboard-responsive.test.tsx @@ -0,0 +1,652 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Dashboard from "@/app/dashboard/page"; + +const mockUseWallet = vi.fn(); +const mockUseToast = vi.fn(); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/app/context/ToastContext", () => ({ + useToast: () => mockUseToast(), +})); + +vi.mock("@/app/components/Navbar", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/LoadingSkeleton", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/MilestoneCard", () => ({ + default: () =>
, +})); + +describe("Dashboard — responsive design", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: "GCLIENT", + signTransaction: vi.fn(), + }); + mockUseToast.mockReturnValue({ + showToast: vi.fn(), + toasts: [], + hideToast: vi.fn(), + }); + }); + + describe("Mobile viewport (< 640px)", () => { + it("renders with proper mobile padding and spacing", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + expect(main).toHaveClass("px-3", "py-6"); + }); + }); + + it("displays mobile-optimized heading size", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const heading = screen.getByRole("heading", { name: "Job Dashboard" }); + expect(heading).toHaveClass("text-xl"); + }); + }); + + it("stacks search form vertically on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const form = screen.getByRole("main").querySelector("form"); + expect(form).toHaveClass("flex-col"); + }); + }); + + it("makes search button full-width on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Search" }); + expect(button).toHaveClass("w-full", "sm:w-auto"); + }); + }); + + it("displays filter buttons with reduced padding on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const filterButtons = screen.getAllByRole("button"); + const roleButton = filterButtons.find((btn) => btn.textContent === "All"); + expect(roleButton).toHaveClass("px-2.5", "sm:px-3"); + }); + }); + + it("stacks job header and badges vertically on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButtons = main.querySelectorAll("button[aria-expanded]"); + expect(jobButtons.length).toBeGreaterThan(0); + const jobButton = jobButtons[0]; + const childDiv = jobButton.querySelector("div"); + expect(childDiv).toHaveClass("flex-col"); + }); + }); + + it("uses responsive font sizes for mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobTitle = main.querySelector("p.font-semibold"); + expect(jobTitle).toHaveClass("text-sm", "sm:text-base"); + }); + }); + }); + + describe("Tablet viewport (640px - 1024px)", () => { + it("displays medium padding on tablet", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + expect(main).toHaveClass("px-3", "sm:px-6"); + }); + }); + + it("shows tablet-optimized heading size", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const heading = screen.getByRole("heading", { name: "Job Dashboard" }); + expect(heading).toHaveClass("sm:text-2xl"); + }); + }); + + it("arranges search form horizontally on tablet", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const form = screen.getByRole("main").querySelector("form"); + expect(form).toHaveClass("sm:gap-3"); + }); + }); + + it("displays expanded content with 2-column grid on tablet", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const gridDiv = main.querySelector(".grid"); + expect(gridDiv).toHaveClass("grid-cols-1"); + expect(gridDiv).toHaveClass("sm:grid-cols-2"); + }); + }); + }); + + describe("Desktop viewport (> 1024px)", () => { + it("displays desktop heading size", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const heading = screen.getByRole("heading", { name: "Job Dashboard" }); + expect(heading).toHaveClass("md:text-3xl"); + }); + }); + + it("shows full 3-column grid for expanded job details on desktop", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const gridDiv = main.querySelector(".grid"); + expect(gridDiv).toHaveClass("grid-cols-1"); + expect(gridDiv).toHaveClass("lg:grid-cols-3"); + }); + }); + + it("displays max-width container properly on desktop", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + expect(main).toHaveClass("max-w-5xl"); + }); + }); + }); + + describe("Typography responsiveness", () => { + it("uses responsive font sizes for job title", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const title = main.querySelector("p.font-semibold"); + expect(title).toHaveClass("text-sm", "sm:text-base"); + }); + }); + + it("scales pagination buttons responsively", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + { + id: "job-2", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 1, + total: 2, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const paginationButton = buttons.find((btn) => btn.textContent === "1"); + expect(paginationButton).toHaveClass("text-xs", "sm:text-sm"); + }); + }); + }); + + describe("Spacing and gaps responsive", () => { + it("applies responsive gaps to main container", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const contentDiv = main.querySelector(".space-y-4"); + expect(contentDiv).toHaveClass("sm:space-y-6"); + }); + }); + + it("uses responsive padding for job buttons", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButton = main.querySelector("button[aria-expanded]"); + expect(jobButton).toHaveClass("px-3", "sm:px-5"); + }); + }); + }); + + describe("Container overflow handling", () => { + it("handles overflow properly for long job IDs on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const title = main.querySelector("p.font-semibold"); + expect(title).toHaveClass("truncate"); + }); + }); + + it("prevents pagination overflow on mobile", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const paginationContainer = main.querySelector(".overflow-x-auto"); + expect(paginationContainer).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 9840912..c3d4a70 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -437,31 +437,31 @@ export default function Dashboard() { return (
-
-

Job Dashboard

+
+

Job Dashboard

{!address ? ( -

Connect your wallet to view your jobs

+

Connect your wallet to view your jobs

) : ( -
-
+
+ setSearchInput(event.target.value)} placeholder="Search by contract/job ID" - className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-sm" + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm" aria-label="Search by contract ID" /> -
+
{roleFilterLabels.map((role) => { const active = roleFilter === role.id; return ( @@ -473,7 +473,7 @@ export default function Dashboard() { setPage(1); }} aria-pressed={active} - className={`px-3 py-1.5 rounded-full text-sm border transition ${ + className={`px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm border transition ${ active ? "bg-indigo-600 border-indigo-500 text-white" : "bg-gray-900 border-gray-700 text-gray-300 hover:text-white" @@ -501,8 +501,8 @@ export default function Dashboard() { badges={["Client", "Freelancer", "Arbiter"]} /> ) : ( -
-
+
+
{jobs.map((job) => { const isExpanded = expandedJobId === job.id; const roleBadges = [ @@ -516,26 +516,26 @@ export default function Dashboard() { {isExpanded && ( -
+
{detailsLoading[job.id] ? ( ) : !expandedJob ? ( -

Unable to load job details.

+

Unable to load job details.

) : ( <> -
-
-

Client

+
+
+

Client

{expandedJob.client}

-
-

Freelancer

+
+

Freelancer

{expandedJob.freelancer}

-
-

Arbiter

+
+

Arbiter

{expandedJob.arbiter}

-
+
{milestoneList.length > 0 ? ( milestoneList.map((m) => ( -
+
-
+
{paginationButtons.map((value) => { const active = value === page; return ( @@ -631,7 +631,7 @@ export default function Dashboard() { type="button" onClick={() => setPage(value)} aria-current={active ? "page" : undefined} - className={`h-8 min-w-8 px-2 rounded-md text-sm border ${ + className={`h-8 min-w-8 px-1.5 sm:px-2 rounded-md text-xs sm:text-sm border whitespace-nowrap ${ active ? "bg-indigo-600 border-indigo-500" : "bg-gray-900 border-gray-700" @@ -647,7 +647,7 @@ export default function Dashboard() { type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages} - className="px-3 py-2 rounded-lg border border-gray-700 bg-gray-900 text-sm disabled:opacity-50" + className="w-full xs:w-auto px-3 py-2 rounded-lg border border-gray-700 bg-gray-900 text-xs sm:text-sm disabled:opacity-50" > Next From bf4743bf9ab4507158adb95bf0df112335b36af8 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Thu, 27 Aug 2026 06:42:24 +0100 Subject: [PATCH 03/57] feat: implement interactive states with hover, focus, and disabled styling - Add focus-visible ring-2 styling on all interactive elements (indigo-500) - Implement hover state transitions with smooth animations (transition-all duration-200) - Add active state styling for button press feedback - Style disabled buttons with opacity-50 and cursor-not-allowed - Prevent hover effects on disabled buttons (disabled:hover:bg-gray-900) - Apply ring-offset for focus states on dark background (ring-offset-gray-950) - Use inset focus ring on job expand buttons for better UX - Add focus-visible:outline-none to remove browser defaults - Implement smooth transitions on all state changes - Add comprehensive test suite with 25 test cases validating: - Search input focus and hover states - Search button focus, hover, and active states - Role filter button states and transitions - Job expand button interactive states - Pagination button states and disabled styling - Accessibility compliance and transitions --- .../dashboard-interactive-states.test.tsx | 809 ++++++++++++++++++ app/dashboard/page.tsx | 22 +- 2 files changed, 820 insertions(+), 11 deletions(-) create mode 100644 __tests__/dashboard-interactive-states.test.tsx diff --git a/__tests__/dashboard-interactive-states.test.tsx b/__tests__/dashboard-interactive-states.test.tsx new file mode 100644 index 0000000..6ac9859 --- /dev/null +++ b/__tests__/dashboard-interactive-states.test.tsx @@ -0,0 +1,809 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Dashboard from "@/app/dashboard/page"; + +const mockUseWallet = vi.fn(); +const mockUseToast = vi.fn(); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/app/context/ToastContext", () => ({ + useToast: () => mockUseToast(), +})); + +vi.mock("@/app/components/Navbar", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/LoadingSkeleton", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/MilestoneCard", () => ({ + default: () =>
, +})); + +describe("Dashboard — interactive states (hover, focus, disabled)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: "GCLIENT", + signTransaction: vi.fn(), + }); + mockUseToast.mockReturnValue({ + showToast: vi.fn(), + toasts: [], + hideToast: vi.fn(), + }); + }); + + describe("Search input focus states", () => { + it("displays focus-visible ring on search input", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveClass("focus-visible:ring-2"); + expect(input).toHaveClass("focus-visible:ring-indigo-500"); + }); + }); + + it("shows hover state on search input", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveClass("hover:border-gray-600"); + }); + }); + + it("has transition animation on search input focus", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveClass("transition-all", "duration-200"); + }); + }); + }); + + describe("Search button states", () => { + it("displays focus-visible ring on search button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Search" }); + expect(button).toHaveClass("focus-visible:ring-2"); + expect(button).toHaveClass("focus-visible:ring-indigo-500"); + }); + }); + + it("shows hover state on search button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Search" }); + expect(button).toHaveClass("hover:bg-indigo-500"); + }); + }); + + it("shows active state on search button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Search" }); + expect(button).toHaveClass("active:bg-indigo-700"); + }); + }); + }); + + describe("Role filter button states", () => { + it("applies active state styling to selected filter", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const allButton = buttons.find((btn) => btn.textContent === "All"); + expect(allButton).toHaveClass("bg-indigo-600"); + expect(allButton).toHaveClass("border-indigo-500"); + }); + }); + + it("shows hover state on inactive filter button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const clientButton = buttons.find((btn) => btn.textContent === "As Client"); + expect(clientButton).toHaveClass("hover:text-white"); + expect(clientButton).toHaveClass("hover:border-gray-600"); + expect(clientButton).toHaveClass("hover:bg-gray-800"); + }); + }); + + it("shows active state on filter button click", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const filterButton = buttons.find((btn) => btn.textContent === "As Client"); + expect(filterButton).toHaveClass("active:bg-gray-700"); + }); + }); + + it("displays focus-visible ring on filter buttons", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const filterButton = buttons.find((btn) => btn.textContent === "As Freelancer"); + expect(filterButton).toHaveClass("focus-visible:ring-2"); + expect(filterButton).toHaveClass("focus-visible:ring-indigo-500"); + }); + }); + + it("has transition animation on filter button interactions", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const filterButton = buttons.find((btn) => btn.textContent === "As Arbiter"); + expect(filterButton).toHaveClass("transition-all", "duration-200"); + }); + }); + }); + + describe("Job expand button states", () => { + it("shows hover state on job expand button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButton = main.querySelector("button[aria-expanded]"); + expect(jobButton).toHaveClass("hover:bg-gray-800/50"); + }); + }); + + it("shows active state on job expand button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButton = main.querySelector("button[aria-expanded]"); + expect(jobButton).toHaveClass("active:bg-gray-800/75"); + }); + }); + + it("displays focus-visible ring on job expand button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButton = main.querySelector("button[aria-expanded]"); + expect(jobButton).toHaveClass("focus-visible:ring-2"); + expect(jobButton).toHaveClass("focus-visible:ring-indigo-500"); + }); + }); + + it("has inset focus ring on job expand button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + const jobButton = main.querySelector("button[aria-expanded]"); + expect(jobButton).toHaveClass("focus-visible:ring-inset"); + }); + }); + }); + + describe("Pagination button states", () => { + it("displays focus-visible ring on pagination buttons", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: "Previous" }); + expect(prevButton).toHaveClass("focus-visible:ring-2"); + expect(prevButton).toHaveClass("focus-visible:ring-indigo-500"); + }); + }); + + it("shows disabled state styling on disabled Previous button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: "Previous" }); + expect(prevButton).toHaveClass("disabled:opacity-50"); + expect(prevButton).toHaveClass("disabled:cursor-not-allowed"); + }); + }); + + it("prevents hover state on disabled pagination button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: "Previous" }); + expect(prevButton).toHaveClass("disabled:hover:bg-gray-900"); + expect(prevButton).toHaveClass("disabled:hover:border-gray-700"); + }); + }); + + it("shows hover state on inactive pagination button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toHaveClass("hover:bg-gray-800"); + expect(nextButton).toHaveClass("hover:border-gray-600"); + }); + }); + + it("shows active state on pagination button", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toHaveClass("active:bg-gray-700"); + }); + }); + + it("has smooth transition on pagination button state changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toHaveClass("transition-all", "duration-200"); + }); + }); + + it("applies ring offset to focus state", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: "Previous" }); + expect(prevButton).toHaveClass("focus-visible:ring-offset-2"); + expect(prevButton).toHaveClass("focus-visible:ring-offset-gray-950"); + }); + }); + }); + + describe("Accessibility compliance", () => { + it("all buttons have visible focus indicators", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + buttons.forEach((button) => { + expect(button).toHaveClass("focus-visible:ring-2"); + }); + }); + }); + + it("disabled buttons have proper cursor styling", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: "Previous" }); + expect(prevButton).toHaveClass("disabled:cursor-not-allowed"); + }); + }); + + it("interactive elements have smooth transitions", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + buttons.forEach((button) => { + expect(button.className).toMatch(/transition-all|transition/); + }); + }); + }); + }); +}); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index c3d4a70..17fa059 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -450,12 +450,12 @@ export default function Dashboard() { value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Search by contract/job ID" - className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm" + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 hover:border-gray-600" aria-label="Search by contract ID" /> @@ -473,10 +473,10 @@ export default function Dashboard() { setPage(1); }} aria-pressed={active} - className={`px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm border transition ${ + className={`px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm border transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 ${ active - ? "bg-indigo-600 border-indigo-500 text-white" - : "bg-gray-900 border-gray-700 text-gray-300 hover:text-white" + ? "bg-indigo-600 border-indigo-500 text-white focus-visible:ring-indigo-400" + : "bg-gray-900 border-gray-700 text-gray-300 hover:text-white hover:border-gray-600 hover:bg-gray-800 active:bg-gray-700 focus-visible:ring-indigo-500" }`} > {role.label} @@ -516,7 +516,7 @@ export default function Dashboard() { @@ -631,10 +631,10 @@ export default function Dashboard() { type="button" onClick={() => setPage(value)} aria-current={active ? "page" : undefined} - className={`h-8 min-w-8 px-1.5 sm:px-2 rounded-md text-xs sm:text-sm border whitespace-nowrap ${ + className={`h-8 min-w-8 px-1.5 sm:px-2 rounded-md text-xs sm:text-sm border whitespace-nowrap transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 ${ active - ? "bg-indigo-600 border-indigo-500" - : "bg-gray-900 border-gray-700" + ? "bg-indigo-600 border-indigo-500 text-white focus-visible:ring-indigo-400" + : "bg-gray-900 border-gray-700 text-gray-200 hover:bg-gray-800 hover:border-gray-600 active:bg-gray-700 focus-visible:ring-indigo-500" }`} > {value} @@ -647,7 +647,7 @@ export default function Dashboard() { type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages} - className="w-full xs:w-auto px-3 py-2 rounded-lg border border-gray-700 bg-gray-900 text-xs sm:text-sm disabled:opacity-50" + className="w-full xs:w-auto px-3 py-2 rounded-lg border border-gray-700 bg-gray-900 text-xs sm:text-sm transition-all duration-200 hover:bg-gray-800 hover:border-gray-600 active:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-gray-900 disabled:hover:border-gray-700" > Next From bbab220e62d7d39982bcbcb13e766b4c33890d95 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Thu, 27 Aug 2026 06:56:46 +0100 Subject: [PATCH 04/57] feat: implement dashboard accessibility compliance (ARIA, keyboard navigation, semantic HTML) --- __tests__/dashboard-accessibility.test.tsx | 1142 ++++++++++++++++++++ app/dashboard/page.tsx | 66 +- 2 files changed, 1188 insertions(+), 20 deletions(-) create mode 100644 __tests__/dashboard-accessibility.test.tsx diff --git a/__tests__/dashboard-accessibility.test.tsx b/__tests__/dashboard-accessibility.test.tsx new file mode 100644 index 0000000..20e4ce9 --- /dev/null +++ b/__tests__/dashboard-accessibility.test.tsx @@ -0,0 +1,1142 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Dashboard from "@/app/dashboard/page"; + +const mockUseWallet = vi.fn(); +const mockUseToast = vi.fn(); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/app/context/ToastContext", () => ({ + useToast: () => mockUseToast(), +})); + +vi.mock("@/app/components/Navbar", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/LoadingSkeleton", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/MilestoneCard", () => ({ + default: () =>
, +})); + +describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: "GCLIENT", + signTransaction: vi.fn(), + }); + mockUseToast.mockReturnValue({ + showToast: vi.fn(), + toasts: [], + hideToast: vi.fn(), + }); + }); + + describe("ARIA attributes — search form", () => { + it("search input has accessible label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveAttribute("id", "search-input"); + expect(screen.getByText("Search by contract or job ID")).toHaveClass("sr-only"); + }); + }); + + it("search input has aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveAttribute("aria-label", "Search by contract ID"); + }); + }); + + it("search input has aria-describedby linking to help text", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveAttribute("aria-describedby", "search-help"); + const helpText = document.getElementById("search-help"); + expect(helpText).toHaveClass("sr-only"); + }); + }); + + it("search button has aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Submit search query" }); + expect(button).toBeInTheDocument(); + }); + }); + }); + + describe("ARIA attributes — filter tabs", () => { + it("filter buttons container has role=tablist", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const tablist = screen.getByRole("tablist", { name: "Filter jobs by role" }); + expect(tablist).toBeInTheDocument(); + }); + }); + + it("filter buttons have role=tab", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const allTab = screen.getByRole("tab", { name: /Filter jobs: All/i }); + const clientTab = screen.getByRole("tab", { name: /Filter jobs: As Client/i }); + expect(allTab).toBeInTheDocument(); + expect(clientTab).toBeInTheDocument(); + }); + }); + + it("active filter tab has aria-selected=true", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const allTab = screen.getByRole("tab", { name: /Filter jobs: All/i }); + expect(allTab).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("inactive filter tabs have aria-selected=false", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const clientTab = screen.getByRole("tab", { name: /Filter jobs: As Client/i }); + expect(clientTab).toHaveAttribute("aria-selected", "false"); + }); + }); + }); + + describe("ARIA attributes — job list", () => { + it("jobs list container has role=region with aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const region = screen.getByRole("region", { name: "Jobs list" }); + expect(region).toBeInTheDocument(); + }); + }); + + it("job expand button has aria-expanded attribute", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expect(expandButton).toHaveAttribute("aria-expanded"); + }); + }); + + it("job expand button has aria-controls referencing job details", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expect(expandButton).toHaveAttribute("aria-controls", "job-details-job-1"); + }); + }); + + it("expanded job details section has matching id from aria-controls", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const detailsSection = document.getElementById("job-details-job-1"); + expect(detailsSection).toBeInTheDocument(); + }); + }); + + it("job details section has role=region with aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expandButton.click(); + + waitFor(() => { + const region = screen.getByRole("region", { name: /Details for job #job-1/i }); + expect(region).toBeInTheDocument(); + }); + }); + }); + + it("role badges have aria-label describing role", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const clientBadge = screen.getByRole("status", { name: "Your role: Client" }); + expect(clientBadge).toBeInTheDocument(); + }); + }); + }); + + describe("ARIA attributes — error states", () => { + it("error message has role=alert", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Failed to fetch jobs", + }), + }) + ); + + render(); + + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveAttribute("aria-live", "assertive"); + }); + }); + + it("error message displays error text", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Connection timeout", + }), + }) + ); + + render(); + + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Connection timeout"); + }); + }); + }); + + describe("ARIA attributes — wallet connection", () => { + it("wallet connection message has role=status and aria-live=polite", () => { + mockUseWallet.mockReturnValue({ + address: null, + signTransaction: vi.fn(), + }); + + render(); + + waitFor(() => { + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent("Connect your wallet"); + }); + }); + }); + + describe("ARIA attributes — pagination", () => { + it("pagination nav has aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nav = screen.getByRole("navigation", { name: "Pagination navigation" }); + expect(nav).toBeInTheDocument(); + }); + }); + + it("pagination page buttons group has role=group", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const group = screen.getByRole("group", { name: "Pagination buttons" }); + expect(group).toBeInTheDocument(); + }); + }); + + it("current pagination button has aria-current=page", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const currentPageButton = screen.getByRole("button", { name: /Current page, page 1/i }); + expect(currentPageButton).toHaveAttribute("aria-current", "page"); + }); + }); + + it("previous button has descriptive aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: /Previous page/i }); + expect(prevButton).toHaveAttribute("aria-label", expect.stringContaining("Previous page")); + }); + }); + + it("next button has descriptive aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nextButton = screen.getByRole("button", { name: /Next page/i }); + expect(nextButton).toHaveAttribute("aria-label", expect.stringContaining("Next page")); + }); + }); + }); + + describe("Semantic HTML structure", () => { + it("uses main landmark for page content", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const main = screen.getByRole("main"); + expect(main).toBeInTheDocument(); + }); + }); + + it("uses h1 for page title", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const heading = screen.getByRole("heading", { level: 1, name: "Job Dashboard" }); + expect(heading).toBeInTheDocument(); + }); + }); + + it("uses form for search", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const form = screen.getByRole("textbox").closest("form"); + expect(form).toBeInTheDocument(); + }); + }); + + it("uses nav for pagination", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const nav = document.querySelector("nav"); + expect(nav).toBeInTheDocument(); + }); + }); + + it("search input has associated label element", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const label = screen.getByLabelText("Search by contract or job ID"); + expect(label).toHaveAttribute("id", "search-input"); + }); + }); + + it("milestons section has role=region with aria-label", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [ + { + index: 0, + amount: "100", + status: "Pending", + }, + ], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expandButton.click(); + + waitFor(() => { + const milestonesRegion = screen.getByRole("region", { name: "Milestones" }); + expect(milestonesRegion).toBeInTheDocument(); + }); + }); + }); + }); + + describe("Keyboard navigation", () => { + it("search input is keyboard focusable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).not.toHaveAttribute("tabindex", "-1"); + }); + }); + + it("search button is keyboard focusable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Submit search query" }); + expect(button).not.toHaveAttribute("tabindex", "-1"); + }); + }); + + it("filter tabs are keyboard focusable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const tabs = screen.getAllByRole("tab"); + tabs.forEach((tab) => { + expect(tab).not.toHaveAttribute("tabindex", "-1"); + }); + }); + }); + + it("job expand buttons are keyboard focusable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expect(expandButton).not.toHaveAttribute("tabindex", "-1"); + }); + }); + + it("pagination buttons are keyboard focusable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: /Previous page/i }); + const nextButton = screen.getByRole("button", { name: /Next page/i }); + expect(prevButton).not.toHaveAttribute("tabindex", "-1"); + expect(nextButton).not.toHaveAttribute("tabindex", "-1"); + }); + }); + }); + + describe("Color contrast and visual clarity", () => { + it("error message has distinct red styling for contrast", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Failed to fetch jobs", + }), + }) + ); + + render(); + + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("bg-red-950/20", "border-red-800", "text-red-400"); + }); + }); + + it("search input has visible border for contrast", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const input = screen.getByPlaceholderText("Search by contract/job ID"); + expect(input).toHaveClass("border", "border-gray-700"); + }); + }); + + it("active tab has sufficient contrast with background", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const activeTab = screen.getByRole("tab", { name: /Filter jobs: All/i }); + expect(activeTab).toHaveClass("bg-indigo-600", "text-white"); + }); + }); + + it("inactive tabs have visible text color for contrast", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const inactiveTab = screen.getByRole("tab", { name: /Filter jobs: As Client/i }); + expect(inactiveTab).toHaveClass("text-gray-300"); + }); + }); + + it("disabled pagination buttons have opacity reduced for visual distinction", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const prevButton = screen.getByRole("button", { name: /Previous page/i }); + expect(prevButton).toHaveClass("disabled:opacity-50"); + }); + }); + }); + + describe("Screen reader announcements", () => { + it("sr-only class hides label text visually but exposes to screen readers", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [], + page: 1, + limit: 5, + total: 0, + }), + }) + ); + + render(); + + await waitFor(() => { + const label = screen.getByText("Search by contract or job ID"); + expect(label).toHaveClass("sr-only"); + }); + }); + + it("aria-hidden hides decorative elements from screen readers", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + data: [ + { + id: "job-1", + client: "GCLIENT", + freelancer: "GFREELANCER", + arbiter: "GARBITER", + funded: true, + milestones: [], + }, + ], + page: 1, + limit: 5, + total: 1, + }), + }) + ); + + render(); + + await waitFor(() => { + const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); + expandButton.click(); + + waitFor(() => { + const decorativeText = screen.queryByText("Collapse", { selector: "[aria-hidden='true']" }); + expect(decorativeText).toBeInTheDocument(); + }); + }); + }); + + it("role=status exposes dynamic wallet connection info to screen readers", () => { + mockUseWallet.mockReturnValue({ + address: null, + signTransaction: vi.fn(), + }); + + render(); + + waitFor(() => { + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + }); + }); + + it("role=alert exposes error messages to screen readers immediately", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Connection failed", + }), + }) + ); + + render(); + + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("aria-live", "assertive"); + }); + }); + }); +}); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 17fa059..484deb1 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -441,27 +441,38 @@ export default function Dashboard() {

Job Dashboard

{!address ? ( -

Connect your wallet to view your jobs

+

+ Connect your wallet to view your jobs +

) : (
+ setSearchInput(event.target.value)} placeholder="Search by contract/job ID" className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 hover:border-gray-600" aria-label="Search by contract ID" + aria-describedby="search-help" /> + + Enter a contract or job ID to search for jobs +
-
+
{roleFilterLabels.map((role) => { const active = roleFilter === role.id; return ( @@ -472,7 +483,9 @@ export default function Dashboard() { setRoleFilter(role.id); setPage(1); }} - aria-pressed={active} + role="tab" + aria-selected={active} + aria-label={`Filter jobs: ${role.label}`} className={`px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm border transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 ${ active ? "bg-indigo-600 border-indigo-500 text-white focus-visible:ring-indigo-400" @@ -488,8 +501,9 @@ export default function Dashboard() { {fetchLoading ? ( ) : error ? ( -
- Error: {error} +
+

Error loading jobs

+

{error}

) : jobs.length === 0 ? ( ) : (
-
- {jobs.map((job) => { +
+ {jobs.map((job, index) => { const isExpanded = expandedJobId === job.id; const roleBadges = [ address === job.client ? "Client" : null, @@ -518,6 +532,8 @@ export default function Dashboard() { onClick={() => setExpandedJobId(isExpanded ? null : job.id)} className="w-full text-left px-3 sm:px-5 py-3 sm:py-4 hover:bg-gray-800/50 transition-all duration-200 active:bg-gray-800/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500" aria-expanded={isExpanded} + aria-label={`Job #${job.id.slice(0, 8)}, ${job.funded ? "Funded" : "Not funded"}, ${isExpanded ? "collapse details" : "expand details"}`} + aria-controls={`job-details-${job.id}`} >
@@ -531,11 +547,13 @@ export default function Dashboard() { {badge} ))} - +
@@ -543,29 +561,34 @@ export default function Dashboard() { {isExpanded && ( -
+
{detailsLoading[job.id] ? ( ) : !expandedJob ? ( -

Unable to load job details.

+

Unable to load job details.

) : ( <>
-

Client

-

{expandedJob.client}

+

Client

+

{expandedJob.client}

-

Freelancer

-

{expandedJob.freelancer}

+

Freelancer

+

{expandedJob.freelancer}

-

Arbiter

-

{expandedJob.arbiter}

+

Arbiter

+

{expandedJob.arbiter}

-
+
{milestoneList.length > 0 ? ( milestoneList.map((m) => ( -
+
)}
From dd99afd58ef9c8292a851cec63acec69c2779258 Mon Sep 17 00:00:00 2001 From: hunter-baddie Date: Thu, 27 Aug 2026 19:57:13 +0100 Subject: [PATCH 05/57] faet:implement CSS micro-animations on dashboard_list elements --- .vscode/settings.json | 1 + __tests__/dashboard-list-animations.test.tsx | 0 app/dashboard/page.tsx | 15 +++++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 __tests__/dashboard-list-animations.test.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41..eabd0c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,3 @@ { + "typescript.autoClosingTags": false } \ No newline at end of file diff --git a/__tests__/dashboard-list-animations.test.tsx b/__tests__/dashboard-list-animations.test.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 64df656..345aeae 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -487,7 +487,7 @@ export default function Dashboard() { {fetchLoading ? ( ) : error ? ( -
+
Error: {error}
) : jobs.length === 0 ? ( @@ -504,11 +504,15 @@ export default function Dashboard() { ].filter(Boolean) as string[]; return ( -
+
{isExpanded && ( -
+
{detailsLoading[job.id] ? ( ) : !expandedJob ? ( From 6a29a1f0afc8e23a690a40d963b70a311fb3b4bd Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:52:22 +0100 Subject: [PATCH 06/57] feat: add accessible DarkModeSwitcher with ARIA compliance (closes #310) --- app/components/DarkModeSwitcher.tsx | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 app/components/DarkModeSwitcher.tsx diff --git a/app/components/DarkModeSwitcher.tsx b/app/components/DarkModeSwitcher.tsx new file mode 100644 index 0000000..65dfc26 --- /dev/null +++ b/app/components/DarkModeSwitcher.tsx @@ -0,0 +1,169 @@ +"use client"; + +import ButtonSpinner from "./ButtonSpinner"; + +export interface DarkModeSwitcherProps { + /** Current theme state: true = dark, false = light, null/undefined = empty/no data */ + isDarkMode?: boolean | null; + /** Toggle handler */ + onToggle?: () => void; + /** Whether the switch is disabled */ + disabled?: boolean; + /** Loading state - shows spinner */ + loading?: boolean; + /** Optional id for the control */ + id?: string; + /** Additional className */ + className?: string; + /** Accessible label override */ + ariaLabel?: string; +} + +/** + * Empty state view for DarkModeSwitcher. + * Displayed when theme data is unavailable (isDarkMode is null/undefined). + * Uses design tokens and is fully accessible. + */ +export function DarkModeSwitcherEmptyState({ + className = "", +}: { + className?: string; +}) { + return ( +
+ +

+ No theme preferences available +

+

+ Theme data is empty. Once theme preferences are configured, the + dark/light toggle will appear here. You can still browse in the default + light theme. +

+ +
+ ); +} + +export default function DarkModeSwitcher({ + isDarkMode, + onToggle, + disabled = false, + loading = false, + id, + className = "", + ariaLabel, +}: DarkModeSwitcherProps) { + const checked = Boolean(isDarkMode); + const isEmpty = isDarkMode === null || isDarkMode === undefined; + const isDisabled = disabled || loading; + + // Empty state - descriptive placeholder when no data + if (isEmpty && !loading) { + return ; + } + + // Loading state + if (loading) { + return ( + + + Loading theme... + + ); + } + + const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode"); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(); + } + }; + + const handleClick = () => { + if (isDisabled) return; + onToggle?.(); + }; + + return ( + + ); +} From 38422cf5c7152157a72e393bc4ad02552a739d99 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:52:28 +0100 Subject: [PATCH 07/57] feat: add DarkModeSwitcher with premium interactive states (closes #311) --- app/components/DarkModeSwitcher.tsx | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 app/components/DarkModeSwitcher.tsx diff --git a/app/components/DarkModeSwitcher.tsx b/app/components/DarkModeSwitcher.tsx new file mode 100644 index 0000000..65dfc26 --- /dev/null +++ b/app/components/DarkModeSwitcher.tsx @@ -0,0 +1,169 @@ +"use client"; + +import ButtonSpinner from "./ButtonSpinner"; + +export interface DarkModeSwitcherProps { + /** Current theme state: true = dark, false = light, null/undefined = empty/no data */ + isDarkMode?: boolean | null; + /** Toggle handler */ + onToggle?: () => void; + /** Whether the switch is disabled */ + disabled?: boolean; + /** Loading state - shows spinner */ + loading?: boolean; + /** Optional id for the control */ + id?: string; + /** Additional className */ + className?: string; + /** Accessible label override */ + ariaLabel?: string; +} + +/** + * Empty state view for DarkModeSwitcher. + * Displayed when theme data is unavailable (isDarkMode is null/undefined). + * Uses design tokens and is fully accessible. + */ +export function DarkModeSwitcherEmptyState({ + className = "", +}: { + className?: string; +}) { + return ( +
+ +

+ No theme preferences available +

+

+ Theme data is empty. Once theme preferences are configured, the + dark/light toggle will appear here. You can still browse in the default + light theme. +

+ +
+ ); +} + +export default function DarkModeSwitcher({ + isDarkMode, + onToggle, + disabled = false, + loading = false, + id, + className = "", + ariaLabel, +}: DarkModeSwitcherProps) { + const checked = Boolean(isDarkMode); + const isEmpty = isDarkMode === null || isDarkMode === undefined; + const isDisabled = disabled || loading; + + // Empty state - descriptive placeholder when no data + if (isEmpty && !loading) { + return ; + } + + // Loading state + if (loading) { + return ( + + + Loading theme... + + ); + } + + const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode"); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(); + } + }; + + const handleClick = () => { + if (isDisabled) return; + onToggle?.(); + }; + + return ( + + ); +} From d7efbe92590211276925623a85a974f06b0cd535 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:52:29 +0100 Subject: [PATCH 08/57] feat: add DarkModeSwitcher with empty state placeholder (closes #313) --- app/components/DarkModeSwitcher.tsx | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 app/components/DarkModeSwitcher.tsx diff --git a/app/components/DarkModeSwitcher.tsx b/app/components/DarkModeSwitcher.tsx new file mode 100644 index 0000000..65dfc26 --- /dev/null +++ b/app/components/DarkModeSwitcher.tsx @@ -0,0 +1,169 @@ +"use client"; + +import ButtonSpinner from "./ButtonSpinner"; + +export interface DarkModeSwitcherProps { + /** Current theme state: true = dark, false = light, null/undefined = empty/no data */ + isDarkMode?: boolean | null; + /** Toggle handler */ + onToggle?: () => void; + /** Whether the switch is disabled */ + disabled?: boolean; + /** Loading state - shows spinner */ + loading?: boolean; + /** Optional id for the control */ + id?: string; + /** Additional className */ + className?: string; + /** Accessible label override */ + ariaLabel?: string; +} + +/** + * Empty state view for DarkModeSwitcher. + * Displayed when theme data is unavailable (isDarkMode is null/undefined). + * Uses design tokens and is fully accessible. + */ +export function DarkModeSwitcherEmptyState({ + className = "", +}: { + className?: string; +}) { + return ( +
+ +

+ No theme preferences available +

+

+ Theme data is empty. Once theme preferences are configured, the + dark/light toggle will appear here. You can still browse in the default + light theme. +

+ +
+ ); +} + +export default function DarkModeSwitcher({ + isDarkMode, + onToggle, + disabled = false, + loading = false, + id, + className = "", + ariaLabel, +}: DarkModeSwitcherProps) { + const checked = Boolean(isDarkMode); + const isEmpty = isDarkMode === null || isDarkMode === undefined; + const isDisabled = disabled || loading; + + // Empty state - descriptive placeholder when no data + if (isEmpty && !loading) { + return ; + } + + // Loading state + if (loading) { + return ( + + + Loading theme... + + ); + } + + const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode"); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(); + } + }; + + const handleClick = () => { + if (isDisabled) return; + onToggle?.(); + }; + + return ( + + ); +} From 92aae616e43390ad09ba374937c6b9cc7e6d1d59 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:52:30 +0100 Subject: [PATCH 09/57] feat: add DarkModeSwitcher base component for Storybook (closes #318) --- app/components/DarkModeSwitcher.tsx | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 app/components/DarkModeSwitcher.tsx diff --git a/app/components/DarkModeSwitcher.tsx b/app/components/DarkModeSwitcher.tsx new file mode 100644 index 0000000..65dfc26 --- /dev/null +++ b/app/components/DarkModeSwitcher.tsx @@ -0,0 +1,169 @@ +"use client"; + +import ButtonSpinner from "./ButtonSpinner"; + +export interface DarkModeSwitcherProps { + /** Current theme state: true = dark, false = light, null/undefined = empty/no data */ + isDarkMode?: boolean | null; + /** Toggle handler */ + onToggle?: () => void; + /** Whether the switch is disabled */ + disabled?: boolean; + /** Loading state - shows spinner */ + loading?: boolean; + /** Optional id for the control */ + id?: string; + /** Additional className */ + className?: string; + /** Accessible label override */ + ariaLabel?: string; +} + +/** + * Empty state view for DarkModeSwitcher. + * Displayed when theme data is unavailable (isDarkMode is null/undefined). + * Uses design tokens and is fully accessible. + */ +export function DarkModeSwitcherEmptyState({ + className = "", +}: { + className?: string; +}) { + return ( +
+ +

+ No theme preferences available +

+

+ Theme data is empty. Once theme preferences are configured, the + dark/light toggle will appear here. You can still browse in the default + light theme. +

+ +
+ ); +} + +export default function DarkModeSwitcher({ + isDarkMode, + onToggle, + disabled = false, + loading = false, + id, + className = "", + ariaLabel, +}: DarkModeSwitcherProps) { + const checked = Boolean(isDarkMode); + const isEmpty = isDarkMode === null || isDarkMode === undefined; + const isDisabled = disabled || loading; + + // Empty state - descriptive placeholder when no data + if (isEmpty && !loading) { + return ; + } + + // Loading state + if (loading) { + return ( + + + Loading theme... + + ); + } + + const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode"); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(); + } + }; + + const handleClick = () => { + if (isDisabled) return; + onToggle?.(); + }; + + return ( + + ); +} From 0db4ad59fcc514f662502eb38acc9496e1e1da9c Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:53:22 +0100 Subject: [PATCH 10/57] test: add a11y tests for DarkModeSwitcher (closes #310) --- __tests__/dark-mode-switcher-a11y.test.tsx | 224 +++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 __tests__/dark-mode-switcher-a11y.test.tsx diff --git a/__tests__/dark-mode-switcher-a11y.test.tsx b/__tests__/dark-mode-switcher-a11y.test.tsx new file mode 100644 index 0000000..c944f39 --- /dev/null +++ b/__tests__/dark-mode-switcher-a11y.test.tsx @@ -0,0 +1,224 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import DarkModeSwitcher, { DarkModeSwitcherEmptyState } from "@/app/components/DarkModeSwitcher"; + +describe("DarkModeSwitcher - a11y ARIA compliance #310", () => { + describe("ARIA role and attributes", () => { + it("renders with role=\"switch\"", () => { + render(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("has aria-checked=false when isDarkMode is false", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + }); + + it("has aria-checked=true when isDarkMode is true", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + }); + + it("has aria-label for light mode", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-label", "Switch to dark mode"); + }); + + it("has aria-label for dark mode", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-label", "Switch to light mode"); + }); + + it("supports custom ariaLabel prop", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-label", "Toggle theme"); + }); + + it("has accessible name via aria-label", () => { + render(); + expect(screen.getByLabelText("Switch to dark mode")).toBeInTheDocument(); + }); + + it("thumb has aria-hidden true", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveAttribute("aria-hidden", "true"); + }); + + it("has data-testid and data-state", () => { + render(); + const el = screen.getByTestId("dark-mode-switcher"); + expect(el).toHaveAttribute("data-state", "dark"); + render(); + // second render adds another but first still exists? Use query + }); + + it("renders dark data-state when dark", () => { + render(); + expect(screen.getAllByTestId("dark-mode-switcher").pop()).toHaveAttribute("data-state", "dark"); + }); + + it("renders light data-state when light", () => { + render(); + expect(screen.getAllByTestId("dark-mode-switcher").pop()).toHaveAttribute("data-state", "light"); + }); + }); + + describe("keyboard navigability", () => { + it("has tabIndex 0 when enabled", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("tabIndex", "0"); + }); + + it("has tabIndex -1 when disabled", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("tabIndex", "-1"); + }); + + it("has tabIndex -1 when loading", () => { + render(); + // loading renders status, not switch - check no switch + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); + + it("calls onToggle on Space key", async () => { + const onToggle = vi.fn(); + render(); + const sw = screen.getByRole("switch"); + sw.focus(); + fireEvent.keyDown(sw, { key: " ", code: "Space" }); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("calls onToggle on Enter key", async () => { + const onToggle = vi.fn(); + render(); + const sw = screen.getByRole("switch"); + fireEvent.keyDown(sw, { key: "Enter", code: "Enter" }); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("does not call onToggle on Space when disabled", () => { + const onToggle = vi.fn(); + render(); + const sw = screen.getByRole("switch"); + fireEvent.keyDown(sw, { key: " ", code: "Space" }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("does not call onToggle on Enter when disabled", () => { + const onToggle = vi.fn(); + render(); + const sw = screen.getByRole("switch"); + fireEvent.keyDown(sw, { key: "Enter" }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("calls onToggle on click when enabled", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + await user.click(screen.getByRole("switch")); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("does not call onToggle on click when disabled", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + await user.click(screen.getByRole("switch")); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("is focusable via keyboard", () => { + render(); + const sw = screen.getByRole("switch"); + sw.focus(); + expect(document.activeElement).toBe(sw); + }); + }); + + describe("disabled and aria-disabled", () => { + it("has disabled attribute when disabled", () => { + render(); + expect(screen.getByRole("switch")).toBeDisabled(); + }); + + it("has aria-disabled true when disabled", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-disabled", "true"); + }); + + it("has aria-disabled false when enabled", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-disabled", "false"); + }); + + it("disabled and aria-disabled are consistent", () => { + render(); + const el = screen.getByRole("switch"); + expect(el).toBeDisabled(); + expect(el).toHaveAttribute("aria-disabled", "true"); + }); + }); + + describe("color contrast compliance - design tokens", () => { + it("uses accessible bg-accent for dark mode (contrast token)", () => { + render(); + expect(screen.getByRole("switch").className).toContain("bg-accent"); + }); + + it("uses bg-surface-field for light mode", () => { + render(); + expect(screen.getByRole("switch").className).toContain("bg-surface-field"); + }); + + it("thumb uses bg-white for high contrast", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-thumb").className).toContain("bg-white"); + }); + + it("uses text-white or text-text-muted with sufficient contrast (token classes)", () => { + const { container } = render(); + // thumb is white, track is accent - ensures contrast + expect(container.innerHTML).toContain("bg-white"); + }); + + it("focus ring uses accent token for visibility", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:ring-accent"); + }); + }); + + describe("loading state a11y", () => { + it("loading renders role status with aria-live", () => { + render(); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveAttribute("aria-label", "Loading theme"); + }); + + it("loading shows spinner and text", () => { + render(); + expect(screen.getByText("Loading theme...")).toBeInTheDocument(); + }); + }); + + describe("empty state a11y", () => { + it("empty state has region role and aria-label", () => { + render(); + expect(screen.getByRole("region", { name: "No theme preferences" })).toBeInTheDocument(); + }); + + it("empty state has descriptive text", () => { + render(); + expect(screen.getByText("No theme preferences available")).toBeInTheDocument(); + }); + + it("EmptyState component directly has a11y attributes", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute("role", "region"); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute("aria-label", "No theme preferences"); + }); + }); +}); From 2cc6b5647fb9e1b9bb1da5c260fff7bd15574947 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:53:23 +0100 Subject: [PATCH 11/57] test: add interactive states tests for DarkModeSwitcher (closes #311) --- .../dark-mode-switcher-interactive.test.tsx | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 __tests__/dark-mode-switcher-interactive.test.tsx diff --git a/__tests__/dark-mode-switcher-interactive.test.tsx b/__tests__/dark-mode-switcher-interactive.test.tsx new file mode 100644 index 0000000..04e562b --- /dev/null +++ b/__tests__/dark-mode-switcher-interactive.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import DarkModeSwitcher from "@/app/components/DarkModeSwitcher"; + +describe("DarkModeSwitcher - premium interactive states #311", () => { + describe("hover states - Tailwind hover: utilities", () => { + it("has hover:bg-accent-hover or hover: opacity for dark mode", () => { + render(); + expect(screen.getByRole("switch").className).toContain("hover:bg-accent-hover"); + }); + + it("has hover:bg-surface-field for light mode", () => { + render(); + expect(screen.getByRole("switch").className).toContain("hover:bg-surface-field"); + }); + + it("has hover:shadow-sm utility", () => { + render(); + expect(screen.getByRole("switch").className).toContain("hover:shadow-sm"); + }); + + it("thumb has hover transition (via parent)", () => { + render(); + expect(screen.getByRole("switch").className).toContain("transition-colors"); + }); + }); + + describe("focus-visible states - ring, outline, shadow", () => { + it("has focus-visible:outline-none", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:outline-none"); + }); + + it("has focus-visible:ring-2", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:ring-2"); + }); + + it("has focus-visible:ring-accent", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:ring-accent"); + }); + + it("has focus-visible:ring-offset-2", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:ring-offset-2"); + }); + + it("has focus-visible:ring-offset-surface-page", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:ring-offset-surface-page"); + }); + + it("has focus-visible:shadow-md", () => { + render(); + expect(screen.getByRole("switch").className).toContain("focus-visible:shadow-md"); + }); + }); + + describe("disabled states - opacity, cursor, disabled: utilities", () => { + it("has disabled:opacity-50", () => { + render(); + expect(screen.getByRole("switch").className).toContain("disabled:opacity-50"); + }); + + it("has disabled:cursor-not-allowed", () => { + render(); + expect(screen.getByRole("switch").className).toContain("disabled:cursor-not-allowed"); + }); + + it("has disabled attribute when disabled", () => { + render(); + expect(screen.getByRole("switch")).toBeDisabled(); + }); + + it("has cursor-pointer when enabled", () => { + render(); + expect(screen.getByRole("switch").className).toContain("cursor-pointer"); + }); + + it("loading state is not a switch (status) and shows disabled appearance", () => { + render(); + expect(screen.getByRole("status").className).toContain("text-text-muted"); + }); + + it("thumb has transition-transform", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-thumb").className).toContain("transition-transform"); + }); + }); + + describe("transition & ring utilities", () => { + it("has transition-colors duration-200", () => { + render(); + const cls = screen.getByRole("switch").className; + expect(cls).toContain("transition-colors"); + expect(cls).toContain("duration-200"); + }); + + it("thumb has duration-200 ease-in-out", () => { + render(); + const cls = screen.getByTestId("dark-mode-switcher-thumb").className; + expect(cls).toContain("duration-200"); + expect(cls).toContain("ease-in-out"); + }); + + it("container has rounded-full for pill shape", () => { + render(); + expect(screen.getByRole("switch").className).toContain("rounded-full"); + }); + + it("thumb has rounded-full and bg-white and shadow-sm", () => { + render(); + const cls = screen.getByTestId("dark-mode-switcher-thumb").className; + expect(cls).toContain("rounded-full"); + expect(cls).toContain("bg-white"); + expect(cls).toContain("shadow-sm"); + }); + }); + + describe("opacity and cursor styles", () => { + it("enabled has opacity via hover (not disabled opacity)", () => { + render(); + expect(screen.getByRole("switch").className).not.toContain("opacity-50"); + // but has disabled:opacity-50 + expect(screen.getByRole("switch").className).toContain("disabled:opacity-50"); + }); + + it("disabled has both cursor-not-allowed and opacity", () => { + render(); + const cls = screen.getByRole("switch").className; + expect(cls).toContain("disabled:opacity-50"); + expect(cls).toContain("disabled:cursor-not-allowed"); + }); + }); + + describe("state-based styling", () => { + it("dark mode has bg-accent", () => { + render(); + expect(screen.getByRole("switch").className).toContain("bg-accent"); + }); + + it("light mode has bg-surface-field", () => { + render(); + expect(screen.getByRole("switch").className).toContain("bg-surface-field"); + }); + + it("dark thumb is translated", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-thumb").className).toContain("translate-x-5"); + }); + + it("light thumb is at origin", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-thumb").className).toContain("translate-x-0"); + }); + }); +}); From a0c22a1b3833b13b3d56117f057513d062b35a90 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:53:24 +0100 Subject: [PATCH 12/57] test: add empty state tests for DarkModeSwitcher (closes #313) --- __tests__/dark-mode-switcher-empty.test.tsx | 125 ++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 __tests__/dark-mode-switcher-empty.test.tsx diff --git a/__tests__/dark-mode-switcher-empty.test.tsx b/__tests__/dark-mode-switcher-empty.test.tsx new file mode 100644 index 0000000..05dd5ea --- /dev/null +++ b/__tests__/dark-mode-switcher-empty.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import DarkModeSwitcher, { DarkModeSwitcherEmptyState } from "@/app/components/DarkModeSwitcher"; + +describe("DarkModeSwitcher - empty list display views #313", () => { + describe("empty state when isDarkMode is null/undefined", () => { + it("renders empty placeholder when isDarkMode is null", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("renders empty placeholder when isDarkMode is undefined", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("does NOT render switch when empty", () => { + render(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); + + it("does not render empty state when isDarkMode is false (valid light mode)", () => { + render(); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("does not render empty state when isDarkMode is true", () => { + render(); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + }); + + it("does not render empty when loading (loading takes precedence)", () => { + render(); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + }); + + describe("empty state UI elements - descriptive placeholder", () => { + it("has region role with aria-label No theme preferences", () => { + render(); + expect(screen.getByRole("region", { name: "No theme preferences" })).toBeInTheDocument(); + }); + + it("shows title No theme preferences available", () => { + render(); + expect(screen.getByText("No theme preferences available")).toBeInTheDocument(); + }); + + it("shows descriptive copy about theme data is empty", () => { + render(); + expect(screen.getByText(/Theme data is empty/)).toBeInTheDocument(); + }); + + it("shows illustrative copy about default light theme", () => { + render(); + expect(screen.getByText(/default light theme/)).toBeInTheDocument(); + }); + + it("shows Waiting for theme data badge", () => { + render(); + expect(screen.getByText("Waiting for theme data")).toBeInTheDocument(); + }); + + it("has decorative icon hidden from AT (aria-hidden)", () => { + const { container } = render(); + const hidden = container.querySelector("[aria-hidden=\"true\"]"); + expect(hidden).toBeInTheDocument(); + }); + + it("uses design tokens: border-border-strong bg-surface-card rounded-xl", () => { + render(); + const el = screen.getByTestId("dark-mode-switcher-empty-state"); + expect(el.className).toContain("border-border-strong"); + expect(el.className).toContain("bg-surface-card"); + expect(el.className).toContain("rounded-xl"); + }); + + it("uses text-text-primary and text-text-muted for contrast", () => { + render(); + expect(screen.getByText("No theme preferences available").className).toContain("text-text-primary"); + expect(screen.getByText(/Theme data is empty/).className).toContain("text-text-muted"); + }); + + it("has centered layout (items-center justify-center text-center)", () => { + render(); + const el = screen.getByTestId("dark-mode-switcher-empty-state"); + expect(el.className).toContain("items-center"); + expect(el.className).toContain("justify-center"); + expect(el.className).toContain("text-center"); + }); + }); + + describe("DarkModeSwitcherEmptyState component directly", () => { + it("renders standalone empty state", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("accepts custom className", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4"); + }); + + it("has correct test id and roles", () => { + render(); + const el = screen.getByTestId("dark-mode-switcher-empty-state"); + expect(el).toHaveAttribute("role", "region"); + expect(el).toHaveAttribute("aria-label", "No theme preferences"); + }); + }); + + describe("not empty - normal rendering", () => { + it("renders switch for light mode with correct aria", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + }); + + it("renders switch for dark mode with correct aria", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + }); + }); +}); From 32be981c5af91d20cbf801110f3aa78c1fecc3f7 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:53:26 +0100 Subject: [PATCH 13/57] feat: add Storybook stories for DarkModeSwitcher (closes #318) --- app/components/DarkModeSwitcher.stories.tsx | 189 ++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 app/components/DarkModeSwitcher.stories.tsx diff --git a/app/components/DarkModeSwitcher.stories.tsx b/app/components/DarkModeSwitcher.stories.tsx new file mode 100644 index 0000000..9634033 --- /dev/null +++ b/app/components/DarkModeSwitcher.stories.tsx @@ -0,0 +1,189 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import DarkModeSwitcher, { DarkModeSwitcherEmptyState } from "./DarkModeSwitcher"; + +const meta = { + title: "Components/DarkModeSwitcher", + component: DarkModeSwitcher, + tags: ["autodocs"], + parameters: { + layout: "centered", + backgrounds: { + default: "dark", + values: [ + { name: "dark", value: "#030712" }, + { name: "light", value: "#ffffff" }, + ], + }, + }, + argTypes: { + isDarkMode: { + control: "select", + options: [true, false, null], + description: "Theme state: true=dark, false=light, null=empty", + }, + disabled: { control: "boolean" }, + loading: { control: "boolean" }, + onToggle: { action: "toggled" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// 1. Light - default light theme +// --------------------------------------------------------------------------- +export const Light: Story = { + name: "Light - default", + args: { + isDarkMode: false, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 2. Dark - dark theme active +// --------------------------------------------------------------------------- +export const Dark: Story = { + name: "Dark - active", + args: { + isDarkMode: true, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 3. Disabled - light +// --------------------------------------------------------------------------- +export const DisabledLight: Story = { + name: "Disabled - light", + args: { + isDarkMode: false, + disabled: true, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 4. Disabled - dark +// --------------------------------------------------------------------------- +export const DisabledDark: Story = { + name: "Disabled - dark", + args: { + isDarkMode: true, + disabled: true, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 5. Loading - spinner state +// --------------------------------------------------------------------------- +export const Loading: Story = { + name: "Loading", + args: { + isDarkMode: false, + loading: true, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 6. Empty - null theme data +// --------------------------------------------------------------------------- +export const Empty: Story = { + name: "Empty - no theme data", + args: { + isDarkMode: null, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 7. Empty - undefined +// --------------------------------------------------------------------------- +export const EmptyUndefined: Story = { + name: "Empty - undefined", + args: { + isDarkMode: undefined, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 8. Dark with custom ariaLabel +// --------------------------------------------------------------------------- +export const CustomAriaLabel: Story = { + name: "Custom ariaLabel", + args: { + isDarkMode: false, + ariaLabel: "Toggle application theme", + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 9. Interactive - hover/focus preview (dark) +// --------------------------------------------------------------------------- +export const InteractiveDark: Story = { + name: "Interactive - dark hover/focus", + args: { + isDarkMode: true, + onToggle: fn(), + }, + parameters: { + pseudo: { hover: true, focus: true }, + }, +}; + +// --------------------------------------------------------------------------- +// 10. Interactive - light hover +// --------------------------------------------------------------------------- +export const InteractiveLight: Story = { + name: "Interactive - light hover", + args: { + isDarkMode: false, + onToggle: fn(), + }, +}; + +// --------------------------------------------------------------------------- +// 11. Empty state component standalone +// --------------------------------------------------------------------------- +export const EmptyStateStandalone: StoryObj = { + name: "EmptyState - standalone", + render: () => , +}; + +// --------------------------------------------------------------------------- +// 12. All states overview +// --------------------------------------------------------------------------- +export const AllStates: Story = { + name: "All states - overview", + render: () => ( +
+
+ Light + +
+
+ Dark + +
+
+ Disabled + +
+
+ Loading + +
+
+ Empty + +
+
+ ), +}; From 0f77e298edefd7dc1c5bfd7c56f2f54cf9282b21 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Fri, 28 Aug 2026 16:53:27 +0100 Subject: [PATCH 14/57] test: add storybook verification tests (closes #318) --- __tests__/dark-mode-switcher-stories.test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 __tests__/dark-mode-switcher-stories.test.tsx diff --git a/__tests__/dark-mode-switcher-stories.test.tsx b/__tests__/dark-mode-switcher-stories.test.tsx new file mode 100644 index 0000000..86383a6 --- /dev/null +++ b/__tests__/dark-mode-switcher-stories.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import DarkModeSwitcher from "@/app/components/DarkModeSwitcher"; +import * as stories from "@/app/components/DarkModeSwitcher.stories"; + +describe("DarkModeSwitcher - Storybook stories #318", () => { + it("exports default meta with title Components/DarkModeSwitcher", () => { + expect(stories.default.title).toBe("Components/DarkModeSwitcher"); + }); + + it("has Light story with isDarkMode false", () => { + expect(stories.Light.args?.isDarkMode).toBe(false); + }); + + it("has Dark story with isDarkMode true", () => { + expect(stories.Dark.args?.isDarkMode).toBe(true); + }); + + it("has DisabledLight story disabled", () => { + expect(stories.DisabledLight.args?.disabled).toBe(true); + expect(stories.DisabledLight.args?.isDarkMode).toBe(false); + }); + + it("has DisabledDark story disabled dark", () => { + expect(stories.DisabledDark.args?.disabled).toBe(true); + expect(stories.DisabledDark.args?.isDarkMode).toBe(true); + }); + + it("has Loading story with loading true", () => { + expect(stories.Loading.args?.loading).toBe(true); + }); + + it("has Empty story with null", () => { + expect(stories.Empty.args?.isDarkMode).toBe(null); + }); + + it("has EmptyUndefined story", () => { + expect(stories.EmptyUndefined.args?.isDarkMode).toBeUndefined(); + }); + + it("has CustomAriaLabel story", () => { + expect(stories.CustomAriaLabel.args?.ariaLabel).toBe("Toggle application theme"); + }); + + it("Light story renders switch with aria-checked false", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + }); + + it("Dark story renders switch with aria-checked true", () => { + render(); + // last switch is dark + const switches = screen.getAllByRole("switch"); + expect(switches[switches.length - 1]).toHaveAttribute("aria-checked", "true"); + }); + + it("Disabled story renders disabled switch", () => { + render(); + const sw = screen.getAllByRole("switch").pop(); + expect(sw).toBeDisabled(); + }); + + it("Loading story renders status", () => { + render(); + expect(screen.getByText("Loading theme...")).toBeInTheDocument(); + }); + + it("Empty story renders empty placeholder", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("EmptyStateStandalone story renders empty state", () => { + const Story = stories.EmptyStateStandalone.render; + expect(Story).toBeDefined(); + if (Story) render(); + // check via previous empty? Need to isolate + }); + + it("AllStates story renders overview", () => { + const Story = stories.AllStates.render; + expect(Story).toBeDefined(); + if (Story) { + const { container } = render(); + expect(container.textContent).toContain("Light"); + expect(container.textContent).toContain("Dark"); + } + }); + + it("stories have mocked onToggle (fn)", () => { + expect(typeof stories.Light.args?.onToggle).toBe("function"); + expect(typeof stories.Dark.args?.onToggle).toBe("function"); + }); + + it("meta has argTypes for controls", () => { + expect(stories.default.argTypes?.isDarkMode).toBeDefined(); + expect(stories.default.argTypes?.disabled).toBeDefined(); + expect(stories.default.argTypes?.loading).toBeDefined(); + }); + + it("meta tags includes autodocs", () => { + expect(stories.default.tags).toContain("autodocs"); + }); +}); From 73596ce8d056daa17b3bc24d52d404afec622f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Sat, 29 Aug 2026 00:33:14 +0100 Subject: [PATCH 15/57] feat: add dark_mode_switcher component with RTL unit tests Implements the app dark/light theme toggle as a keyboard-operable, ARIA-compliant switch (role=switch, aria-checked, aria-label) that persists the chosen theme to localStorage and applies it to the document root. Adds React Testing Library tests verifying node rendering, accessible state, theme application, keyboard operation and persistence. Closes #319 --- __tests__/dark_mode_switcher.test.tsx | 164 ++++++++++++++++++++++++++ app/components/dark_mode_switcher.tsx | 81 +++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 __tests__/dark_mode_switcher.test.tsx create mode 100644 app/components/dark_mode_switcher.tsx diff --git a/__tests__/dark_mode_switcher.test.tsx b/__tests__/dark_mode_switcher.test.tsx new file mode 100644 index 0000000..e80ea2f --- /dev/null +++ b/__tests__/dark_mode_switcher.test.tsx @@ -0,0 +1,164 @@ +/** + * Unit tests for `dark_mode_switcher` (App dark/light theme toggle). + * + * Verifies correct node rendering and behavior: + * - renders as `role="switch"` with an accessible name + * - exposes the current state via `aria-checked` + * - toggles theme (and the document root class) on activation + * - toggles the accessible label to announce the next state + * - keyboard operable (Enter / Space activate the switch) + * - persists the chosen theme to localStorage + * - restores a persisted theme and reflects it in `aria-checked` + * - honors the OS color-scheme preference when nothing is stored + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import DarkModeSwitcher from "@/app/components/dark_mode_switcher"; + +function clearTheme() { + window.localStorage.removeItem("escrow-theme"); +} + +describe("dark_mode_switcher — node rendering", () => { + beforeEach(() => { + clearTheme(); + }); + + it("renders a switch with an accessible name", () => { + render(); + expect( + screen.getByRole("switch", { name: "Switch to dark mode" }) + ).toBeInTheDocument(); + }); + + it("renders a single interactive control", () => { + render(); + expect(screen.getAllByRole("switch")).toHaveLength(1); + }); + + it("defaults to dark when the OS prefers dark and nothing is stored", () => { + const originalMatchMedia = window.matchMedia; + window.matchMedia = vi.fn().mockReturnValue({ matches: true }) as never; + try { + render(); + expect( + screen.getByRole("switch", { name: "Switch to light mode" }) + ).toBeInTheDocument(); + } finally { + window.matchMedia = originalMatchMedia; + } + }); +}); + +describe("dark_mode_switcher — aria state", () => { + beforeEach(() => { + clearTheme(); + }); + + it("exposes the current theme via aria-checked", () => { + render(); + const sw = screen.getByRole("switch"); + expect(sw).toHaveAttribute("aria-checked", "false"); + }); + + it("flips aria-checked when toggled", () => { + render(); + const sw = screen.getByRole("switch"); + fireEvent.click(sw); + expect(sw).toHaveAttribute("aria-checked", "true"); + }); + + it("updates its accessible label to announce the next mode after toggling", () => { + render(); + const sw = screen.getByRole("switch"); + fireEvent.click(sw); + expect( + screen.getByRole("switch", { name: "Switch to light mode" }) + ).toBeInTheDocument(); + }); +}); + +describe("dark_mode_switcher — theme application", () => { + beforeEach(() => { + clearTheme(); + document.documentElement.classList.remove("dark"); + delete document.documentElement.dataset.theme; + }); + + it("applies the 'dark' class to the document root when enabled", async () => { + render(); + fireEvent.click(screen.getByRole("switch")); + await waitFor(() => + expect(document.documentElement.classList.contains("dark")).toBe(true) + ); + }); + + it("removes the 'dark' class when toggled back off", async () => { + render(); + const sw = screen.getByRole("switch"); + fireEvent.click(sw); + await waitFor(() => + expect(document.documentElement.classList.contains("dark")).toBe(true) + ); + fireEvent.click(sw); + await waitFor(() => + expect(document.documentElement.classList.contains("dark")).toBe(false) + ); + }); + + it("sets the data-theme attribute on the root", async () => { + render(); + fireEvent.click(screen.getByRole("switch")); + await waitFor(() => + expect(document.documentElement.dataset.theme).toBe("dark") + ); + }); +}); + +describe("dark_mode_switcher — keyboard operation", () => { + beforeEach(() => { + clearTheme(); + }); + + it("activates the switch with the Enter key", () => { + render(); + const sw = screen.getByRole("switch"); + // A native button activation with Enter dispatches a click event. + fireEvent.keyDown(sw, { key: "Enter" }); + fireEvent.click(sw); + expect(sw).toHaveAttribute("aria-checked", "true"); + }); + + it("activates the switch with the Space key", () => { + render(); + const sw = screen.getByRole("switch"); + // A native button activation with Space dispatches a click event. + fireEvent.keyDown(sw, { key: " " }); + fireEvent.click(sw); + expect(sw).toHaveAttribute("aria-checked", "true"); + }); +}); + +describe("dark_mode_switcher — persistence", () => { + beforeEach(() => { + clearTheme(); + }); + + it("persists the chosen theme to localStorage", async () => { + render(); + fireEvent.click(screen.getByRole("switch")); + await waitFor(() => + expect(window.localStorage.getItem("escrow-theme")).toBe("dark") + ); + }); + + it("restores a persisted theme and reflects it in aria-checked", () => { + window.localStorage.setItem("escrow-theme", "dark"); + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + expect( + screen.getByRole("switch", { name: "Switch to light mode" }) + ).toBeInTheDocument(); + }); +}); diff --git a/app/components/dark_mode_switcher.tsx b/app/components/dark_mode_switcher.tsx new file mode 100644 index 0000000..a56e42e --- /dev/null +++ b/app/components/dark_mode_switcher.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Theme = "dark" | "light"; + +const STORAGE_KEY = "escrow-theme"; + +function readStoredTheme(): Theme | null { + if (typeof window === "undefined") return null; + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === "dark" || stored === "light" ? stored : null; +} + +function systemPrefersDark(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches === true; +} + +function resolveInitialTheme(): Theme { + return readStoredTheme() ?? (systemPrefersDark() ? "dark" : "light"); +} + +function applyTheme(theme: Theme) { + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + root.dataset.theme = theme; +} + +/** + * `dark_mode_switcher` — app dark/light theme toggle. + * + * Rendered as a native ` + ); +} 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 16/57] 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 08940bdff687eb28d1b1d29aaf3145ef6b512c9e Mon Sep 17 00:00:00 2001 From: otsimaofficial Date: Sat, 29 Aug 2026 00:43:19 +0100 Subject: [PATCH 17/57] feat(loading-skeleton): responsive sizing across mobile/tablet/desktop Stack the stat grid and content rows to a single column on mobile, two on tablet, three on desktop, and use responsive padding/spacing so LoadingSkeleton scales cleanly at every breakpoint. Closes #275 --- .../loading-skeleton-responsive.test.tsx | 67 +++++++++++++++++++ app/components/LoadingSkeleton.tsx | 41 +++++++----- 2 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 __tests__/loading-skeleton-responsive.test.tsx diff --git a/__tests__/loading-skeleton-responsive.test.tsx b/__tests__/loading-skeleton-responsive.test.tsx new file mode 100644 index 0000000..8c28522 --- /dev/null +++ b/__tests__/loading-skeleton-responsive.test.tsx @@ -0,0 +1,67 @@ +/** + * Issue #275 – Implement responsive sizing layouts on loading_spinner_skeleton + * + * Verifies that the LoadingSkeleton component resizes and stacks + * responsively across mobile, tablet, and desktop viewports. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import LoadingSkeleton from "@/app/components/LoadingSkeleton"; + +describe("LoadingSkeleton – responsive sizing layout (issue #275)", () => { + it("root wrapper spans the full available width", () => { + render(); + expect(screen.getByTestId("loading-skeleton")).toHaveClass("w-full"); + }); + + it("card wrapper uses responsive padding (p-4 on mobile, sm:p-6 on larger screens)", () => { + render(); + const card = screen.getByTestId("loading-skeleton-card"); + expect(card).toHaveClass("p-4"); + expect(card).toHaveClass("sm:p-6"); + }); + + it("card wrapper uses responsive vertical spacing (space-y-4 / sm:space-y-6)", () => { + render(); + const card = screen.getByTestId("loading-skeleton-card"); + expect(card).toHaveClass("space-y-4"); + expect(card).toHaveClass("sm:space-y-6"); + }); + + it("stats grid stacks to a single column on mobile", () => { + render(); + expect(screen.getByTestId("loading-skeleton-stats")).toHaveClass("grid-cols-1"); + }); + + it("stats grid expands to two columns on tablet (sm:grid-cols-2)", () => { + render(); + expect(screen.getByTestId("loading-skeleton-stats")).toHaveClass("sm:grid-cols-2"); + }); + + it("stats grid expands to three columns on desktop (md:grid-cols-3)", () => { + render(); + expect(screen.getByTestId("loading-skeleton-stats")).toHaveClass("md:grid-cols-3"); + }); + + it("header stacks vertically on mobile and switches to a row on sm+ (flex-col / sm:flex-row)", () => { + render(); + const card = screen.getByTestId("loading-skeleton-card"); + const header = card.firstElementChild as HTMLElement; + expect(header).toHaveClass("flex-col"); + expect(header).toHaveClass("sm:flex-row"); + }); + + it("milestone rows use responsive padding (p-3 on mobile, sm:p-4 on larger screens)", () => { + render(); + const rows = screen.getByTestId("loading-skeleton-rows"); + const firstRow = rows.firstElementChild as HTMLElement; + expect(firstRow).toHaveClass("p-3"); + expect(firstRow).toHaveClass("sm:p-4"); + }); + + it("still renders the accessible loading status region", () => { + render(); + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByText("Loading job data…")).toBeInTheDocument(); + }); +}); diff --git a/app/components/LoadingSkeleton.tsx b/app/components/LoadingSkeleton.tsx index 4b42b2c..2a55934 100644 --- a/app/components/LoadingSkeleton.tsx +++ b/app/components/LoadingSkeleton.tsx @@ -1,36 +1,43 @@ export default function LoadingSkeleton() { return ( -
+
Loading job data… -