From f5f2f41a5df4d7a148cca2e61a778d29ca15f59c Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sun, 2 Aug 2026 14:42:46 -0300 Subject: [PATCH] fix(a11y): give the view switcher and the exit mode real semantics Closes the two blocked items from #13. The Jornada / Custo da Hora pair swaps the main panel, so it was neither navigation nor a pair of independent toggles. It is now real links carrying the view in the query string, marked with aria-current. That also makes the active tab shareable and bookmarkable - it used to live in useState - and restores Cmd-click and middle-click. Reading the view through useSearchParams forces a Suspense boundary, so the nav and the panel moved into one organism rendered inside it, with the journey view as the fallback. The prerendered HTML still carries the whole shell and / stays static. AUTO/MANUAL was two aria-pressed buttons: assistive tech announced two independent toggles, with no mutual exclusion, two tab stops and no arrow-key navigation. Two radios in a fieldset let the platform supply grouping, aria-checked, arrow keys and the roving tab stop for no JS. The input covers the label rather than sitting in an sr-only corner, so the click target matches what is actually visible. Selectors in both e2e specs follow the new roles. Co-Authored-By: Claude Opus 5 --- __tests__/calculator-views.test.tsx | 78 +++++++++++++++++++++++ __tests__/journey-form.test.tsx | 14 ++-- __tests__/page.test.tsx | 20 ++---- __tests__/work-calculator.test.tsx | 6 +- app/page.tsx | 72 ++------------------- components/atoms/button.tsx | 47 +++++++------- components/organisms/calculator-views.tsx | 71 +++++++++++++++++++++ components/organisms/journey-form.tsx | 42 ++++++------ tests/e2e/salary-calculator.spec.ts | 2 +- tests/e2e/work-calculator.spec.ts | 4 +- 10 files changed, 221 insertions(+), 135 deletions(-) create mode 100644 __tests__/calculator-views.test.tsx create mode 100644 components/organisms/calculator-views.tsx diff --git a/__tests__/calculator-views.test.tsx b/__tests__/calculator-views.test.tsx new file mode 100644 index 0000000..310288e --- /dev/null +++ b/__tests__/calculator-views.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CalculatorViews, CalculatorViewsFromUrl, toCalculatorView } from "@/components/organisms/calculator-views"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +vi.mock("@/components/organisms/work-calculator", () => ({ + WorkCalculator: () =>

Painel da jornada

, +})); + +vi.mock("@/components/organisms/salary-calculator", () => ({ + SalaryCalculator: () =>

Painel do custo da hora

, +})); + +const searchParams = { current: new URLSearchParams() }; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParams.current, +})); + +describe("toCalculatorView", () => { + it("only accepts the salary view, falling back to the journey", () => { + expect(toCalculatorView("salary")).toBe("salary"); + expect(toCalculatorView("work")).toBe("work"); + expect(toCalculatorView("anything-else")).toBe("work"); + expect(toCalculatorView(null)).toBe("work"); + }); +}); + +describe("CalculatorViews", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParams.current = new URLSearchParams(); + }); + + it("marks the active tab and links both views by URL", () => { + render(); + + expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("href", "/?view=work"); + expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("href", "/?view=salary"); + expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Jornada" })).not.toHaveAttribute("aria-current"); + }); + + it("renders the panel matching the active view", async () => { + const { rerender } = render(); + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + + rerender(); + expect(await screen.findByText("Painel do custo da hora")).toBeInTheDocument(); + }); + + it("tracks the tab the visitor moves to", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("link", { name: "Custo da Hora" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); + }); + + it("reads the active view from the query string", () => { + searchParams.current = new URLSearchParams("view=salary"); + render(); + + expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument(); + }); + + it("falls back to the journey when the query string has no view", () => { + render(); + + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx index 21fe7a8..f11b4dc 100644 --- a/__tests__/journey-form.test.tsx +++ b/__tests__/journey-form.test.tsx @@ -125,17 +125,17 @@ describe("JourneyForm", () => { const user = userEvent.setup(); render(); - const autoButton = screen.getByRole("button", { name: "AUTO" }); - const manualButton = screen.getByRole("button", { name: "MANUAL" }); - expect(autoButton).toHaveAttribute("aria-pressed", "true"); - expect(manualButton).toHaveAttribute("aria-pressed", "false"); + const autoOption = screen.getByRole("radio", { name: "AUTO" }); + const manualOption = screen.getByRole("radio", { name: "MANUAL" }); + expect(autoOption).toBeChecked(); + expect(manualOption).not.toBeChecked(); - await user.click(manualButton); + await user.click(manualOption); expect(onManualExitChange).toHaveBeenCalledWith(true); - expect(manualButton).toHaveAttribute("aria-pressed", "true"); + expect(manualOption).toBeChecked(); - await user.click(autoButton); + await user.click(autoOption); expect(onManualExitChange).toHaveBeenLastCalledWith(false); }); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx index 419752c..df5b83b 100644 --- a/__tests__/page.test.tsx +++ b/__tests__/page.test.tsx @@ -26,6 +26,10 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + describe("Home", () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,21 +72,7 @@ describe("Home", () => { render(); expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Jornada" })).toHaveAttribute("aria-pressed", "true"); - }); - - it("switches to the hourly cost view and tracks it", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "Custo da Hora" })); - - expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); - expect(await screen.findByText("Painel do custo da hora")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Jornada" })); - - expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "work" }); + expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("aria-current", "page"); }); it("offers the dark theme while the light one is active", async () => { diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx index c662526..f196440 100644 --- a/__tests__/work-calculator.test.tsx +++ b/__tests__/work-calculator.test.tsx @@ -215,7 +215,7 @@ describe("WorkCalculator", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: "MANUAL" })); + await user.click(screen.getByRole("radio", { name: "MANUAL" })); expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { value: "manual", @@ -223,7 +223,7 @@ describe("WorkCalculator", () => { expect(screen.getByText("BALANÇO FINAL")).toBeInTheDocument(); expect(screen.getAllByText("Saída Real").length).toBeGreaterThan(0); - await user.click(screen.getByRole("button", { name: "AUTO" })); + await user.click(screen.getByRole("radio", { name: "AUTO" })); expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { value: "auto", @@ -238,7 +238,7 @@ describe("WorkCalculator", () => { await user.clear(exitTimeField); await user.type(exitTimeField, "1900"); - expect(screen.getByRole("button", { name: "MANUAL" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("radio", { name: "MANUAL" })).toBeChecked(); }); it("restores the defaults and tracks the reset", async () => { diff --git a/app/page.tsx b/app/page.tsx index 67c83e9..bbad6b5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,22 +1,17 @@ "use client"; import { format } from "date-fns"; -import { Clock, DollarSign, Moon, Sun, Wallet } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; +import { Clock, Moon, Sun, Wallet } from "lucide-react"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect } from "react"; import { Button } from "@/components/atoms/button"; -import { SalaryCalculator } from "@/components/organisms/salary-calculator"; -import { WorkCalculator } from "@/components/organisms/work-calculator"; +import { CalculatorViews, CalculatorViewsFromUrl } from "@/components/organisms/calculator-views"; import { useCurrentTime } from "@/hooks/use-current-time"; import { safeGAEvent } from "@/lib/analytics"; -type View = "work" | "salary"; - const PLACEHOLDER_CLOCK = "--:--:--"; export default function Home() { - const [activeView, setActiveView] = useState("work"); const currentTime = useCurrentTime(); const { setTheme, resolvedTheme } = useTheme(); @@ -99,64 +94,9 @@ export default function Home() { - - -
- - {activeView === "work" ? ( - - - - ) : ( - - - - )} - -
+ }> + +
diff --git a/components/atoms/button.tsx b/components/atoms/button.tsx index ce2125d..96e1e52 100644 --- a/components/atoms/button.tsx +++ b/components/atoms/button.tsx @@ -1,35 +1,36 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +export type ButtonVariant = "default" | "outline" | "ghost" | "danger"; +export type ButtonSize = "default" | "sm" | "lg" | "icon"; + export interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "default" | "outline" | "ghost" | "danger"; - size?: "default" | "sm" | "lg" | "icon"; + variant?: ButtonVariant; + size?: ButtonSize; +} + +export function buttonClasses(variant: ButtonVariant = "default", size: ButtonSize = "default", className?: string) { + return cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-xl font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950 disabled:pointer-events-none disabled:opacity-50", + { + "bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-500/20 active:scale-95": variant === "default", + "border border-neutral-200 dark:border-neutral-800 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white": + variant === "outline", + "hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white": variant === "ghost", + "bg-red-500/10 text-red-600 hover:bg-red-500/20": variant === "danger", + "h-12 px-6 py-2 text-base": size === "default", + "h-11 rounded-md px-3 text-sm": size === "sm", + "h-14 rounded-2xl px-8 text-lg": size === "lg", + "h-11 w-11": size === "icon", + }, + className, + ); } const Button = React.forwardRef( ({ className, variant = "default", size = "default", ...props }, ref) => { return ( -
-
- - -
+
+ Modo de cálculo da saída + {EXIT_MODES.map(({ label, isManual }) => ( + + ))} +
diff --git a/tests/e2e/salary-calculator.spec.ts b/tests/e2e/salary-calculator.spec.ts index 4006c70..653de76 100644 --- a/tests/e2e/salary-calculator.spec.ts +++ b/tests/e2e/salary-calculator.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Salary Calculator (Custo da Hora)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - await page.click('button:has-text("Custo da Hora")'); + await page.getByRole("link", { name: "Custo da Hora" }).click(); }); test("should display default salary elements", async ({ page }) => { diff --git a/tests/e2e/work-calculator.spec.ts b/tests/e2e/work-calculator.spec.ts index 1942526..5cd340d 100644 --- a/tests/e2e/work-calculator.spec.ts +++ b/tests/e2e/work-calculator.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Work Calculator (Jornada)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - await page.click('button:has-text("Jornada")'); + await page.getByRole("link", { name: "Jornada" }).click(); }); test("should display default values correctly", async ({ page }) => { @@ -14,7 +14,7 @@ test.describe("Work Calculator (Jornada)", () => { }); test("should allow manual exit input", async ({ page }) => { - await page.click('button:has-text("MANUAL")'); + await page.getByRole("radio", { name: "MANUAL" }).check(); await expect(page.locator('text="Saída Real"').first()).toBeVisible(); });