diff --git a/src/components/atoms/report-summary-bar.tsx b/src/components/atoms/report-summary-bar.tsx index a9fd002..d2c41c8 100644 --- a/src/components/atoms/report-summary-bar.tsx +++ b/src/components/atoms/report-summary-bar.tsx @@ -55,19 +55,31 @@ interface ReportSummaryBarProps { items: SummaryItem[]; } +/** + * An inline `gridTemplateColumns` is unreachable by any breakpoint, so the bar + * kept its desktop column count on a phone and pushed the page sideways — + * squeezing the third tile down to its icon. Classes instead, so the count can + * fall to one column on a narrow screen. + */ +const COLUMNS_AT_WIDE: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", + 5: "lg:grid-cols-5", +}; + export function ReportSummaryBar({ items }: ReportSummaryBarProps) { + const columns = COLUMNS_AT_WIDE[Math.min(items.length, 5)] ?? "lg:grid-cols-5"; return ( -
+
{items.map((item) => { const tone = resolveTone(item); const Icon = item.icon; return (
{Icon && (
-
+
{chartData.map((row, i) => ( void; }) { return ( + // Recharts' sectors cannot take focus, so this legend is the keyboard route + // into the donut. An inert row (no onClick) stays out of the tab order.
{name} diff --git a/src/components/molecules/income-expense-category-table.tsx b/src/components/molecules/income-expense-category-table.tsx index 1ad1e23..8b48ba8 100644 --- a/src/components/molecules/income-expense-category-table.tsx +++ b/src/components/molecules/income-expense-category-table.tsx @@ -1,6 +1,7 @@ "use client"; import { centsToDisplay } from "@/lib/money"; +import { activateOnKey } from "@/lib/a11y"; import type { IncomeExpenseCategoryRow } from "@/queries/reports"; import { ChevronRight } from "lucide-react"; import { CategoryIconTile } from "@/components/atoms/category-icon"; @@ -52,10 +53,12 @@ function Section({ ); } - const rowClass = onCategoryClick ? "cursor-pointer group" : "hover:bg-transparent"; + const rowClass = onCategoryClick + ? "cursor-pointer group focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" + : "hover:bg-transparent"; return ( -
+
{label}
@@ -72,8 +75,18 @@ function Section({ {rows.map((row) => ( onCategoryClick?.(row.categoryId, row.isIncome)} + onKeyDown={activateOnKey( + onCategoryClick ? () => onCategoryClick(row.categoryId, row.isIncome) : undefined, + )} > diff --git a/src/components/organisms/dashboard-shell.tsx b/src/components/organisms/dashboard-shell.tsx index f7959be..ec6e533 100644 --- a/src/components/organisms/dashboard-shell.tsx +++ b/src/components/organisms/dashboard-shell.tsx @@ -19,7 +19,10 @@ export function DashboardShell({ userName, userEmail, defaultOpen = true, childr Ledgr -
+ {/* The AI assistant button is `fixed bottom-6 right-6`, so it floats over + whatever the page ends with. Reserve its height (56px + 24px inset) + plus a gap, so content can always be scrolled clear of it. */} +
{children}
diff --git a/src/components/organisms/report-spending.tsx b/src/components/organisms/report-spending.tsx index eb18652..7f09ccb 100644 --- a/src/components/organisms/report-spending.tsx +++ b/src/components/organisms/report-spending.tsx @@ -17,6 +17,7 @@ import { TableRow, } from "@/components/ui/table"; import { centsToDisplay } from "@/lib/money"; +import { activateOnKey } from "@/lib/a11y"; import { CHART_COLORS } from "@/lib/chart-colors"; import type { SpendingRow } from "@/queries/reports"; @@ -76,7 +77,7 @@ export function ReportSpending({
-
+
@@ -89,8 +90,17 @@ export function ReportSpending({ {data.map((row, i) => ( handleDrillDown({ id: row.categoryId, name: row.categoryName })} + onKeyDown={activateOnKey(() => + handleDrillDown({ id: row.categoryId, name: row.categoryName }), + )} >
diff --git a/src/components/organisms/report-tabs.tsx b/src/components/organisms/report-tabs.tsx index 3216773..6e78b8b 100644 --- a/src/components/organisms/report-tabs.tsx +++ b/src/components/organisms/report-tabs.tsx @@ -80,23 +80,29 @@ export function ReportTabs({ value={activeTab} onValueChange={(tab) => updateFilter("tab", tab === "spending" ? null : tab)} > - - - Spending - - - Income vs Expense - - - Cash Flow - - - Trends - - - Net Worth - - + {/* Five tabs do not fit a phone. Without a scroll container the list + just widened the page — Trends and Net Worth sat off-screen with no + way to reach them, because the list itself computes + `overflow-x: visible`. */} +
+ + + Spending + + + Income vs Expense + + + Cash Flow + + + Trends + + + Net Worth + + +
{spendingData && ( diff --git a/src/components/organisms/sankey-chart.tsx b/src/components/organisms/sankey-chart.tsx index 47f2563..9c1a2e5 100644 --- a/src/components/organisms/sankey-chart.tsx +++ b/src/components/organisms/sankey-chart.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { sankey, sankeyLinkHorizontal } from "d3-sankey"; import { centsToDisplay } from "@/lib/money"; import { INCOME_COLOR, CHART_COLORS } from "@/lib/chart-colors"; +import { activateOnKey } from "@/lib/a11y"; export interface SankeyNode { id: string; @@ -155,12 +156,20 @@ export function SankeyChart({ nodes, links, onNodeClick, height = 400 }: SankeyC const clickable = onNodeClick && node.type !== "savings"; return ( + {/* An SVG rect takes focus only with an explicit tabIndex and a + role — without both, the Sankey was mouse-only. */} clickable && onNodeClick(node.id, node.type)} + onKeyDown={activateOnKey( + clickable ? () => onNodeClick(node.id, node.type) : undefined, + )} /> {nodeHeight > 12 && ( }; +} + +describe("activateOnKey", () => { + test("returns nothing when there is no action, so the element stays untabbable", () => { + expect(activateOnKey(undefined)).toBeUndefined(); + }); + + test("Enter activates", () => { + const onActivate = vi.fn(); + const e = keyEvent("Enter"); + activateOnKey(onActivate)!(e); + expect(onActivate).toHaveBeenCalledOnce(); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("Space activates, and does not also scroll the page", () => { + const onActivate = vi.fn(); + const e = keyEvent(" "); + activateOnKey(onActivate)!(e); + expect(onActivate).toHaveBeenCalledOnce(); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("any other key is left alone", () => { + const onActivate = vi.fn(); + for (const key of ["Tab", "a", "Escape", "ArrowDown", "Spacebar"]) { + const e = keyEvent(key); + activateOnKey(onActivate)!(e); + expect(onActivate).not.toHaveBeenCalled(); + expect(e.preventDefault).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/lib/a11y.ts b/src/lib/a11y.ts new file mode 100644 index 0000000..7a781a0 --- /dev/null +++ b/src/lib/a11y.ts @@ -0,0 +1,21 @@ +import type { KeyboardEvent } from "react"; + +/** + * Enter/Space activation for an element that is clickable but is not a + * `