Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/components/atoms/report-summary-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
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 (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${Math.min(items.length, 5)}, 1fr)` }}
>
<div className={cn("grid gap-4 grid-cols-1 sm:grid-cols-2", columns)}>
{items.map((item) => {
const tone = resolveTone(item);
const Icon = item.icon;
return (
<div
key={item.label}
className="flex items-center gap-3 rounded-lg border p-3"
className="flex min-w-0 items-center gap-3 rounded-lg border p-3"
>
{Icon && (
<div
Expand Down
11 changes: 9 additions & 2 deletions src/components/atoms/spending-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { centsToDisplay } from "@/lib/money";
import { activateOnKey } from "@/lib/a11y";
import { CHART_COLORS } from "@/lib/chart-colors";

export interface SpendingChartItem {
Expand Down Expand Up @@ -78,7 +79,7 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
</PieChart>
</ResponsiveContainer>
</div>
<div className="w-3/5 overflow-y-auto">
<div className="w-3/5 overflow-y-auto overflow-x-hidden">
{chartData.map((row, i) => (
<SpendingLegendRow
key={row.name}
Expand Down Expand Up @@ -132,9 +133,15 @@ function SpendingLegendRow({
onClick?: () => 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.
<div
className={`flex items-center gap-2 py-1 text-sm ${onClick ? "cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1" : ""}`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? `Show ${name} transactions` : undefined}
className={`flex items-center gap-2 py-1 text-sm ${onClick ? "cursor-pointer hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring rounded px-1 -mx-1" : ""}`}
onClick={onClick}
onKeyDown={activateOnKey(onClick)}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="truncate flex-1">{name}</span>
Expand Down
17 changes: 15 additions & 2 deletions src/components/molecules/income-expense-category-table.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div>
<div className="overflow-x-auto">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider bg-muted/30">
{label}
</div>
Expand All @@ -72,8 +75,18 @@ function Section({
{rows.map((row) => (
<TableRow
key={`${row.categoryId ?? "uncategorized"}-${row.isIncome}`}
tabIndex={onCategoryClick ? 0 : undefined}
role={onCategoryClick ? "button" : undefined}
aria-label={
onCategoryClick
? `Show ${row.categoryName} transactions, ${centsToDisplay(row.total)}`
: undefined
}
className={rowClass}
onClick={() => onCategoryClick?.(row.categoryId, row.isIncome)}
onKeyDown={activateOnKey(
onCategoryClick ? () => onCategoryClick(row.categoryId, row.isIncome) : undefined,
)}
>
<TableCell className="px-3 py-2 flex items-center gap-2">
<CategoryIconTile name={row.categoryIcon} iconSize={14} className="size-6" />
Expand Down
5 changes: 4 additions & 1 deletion src/components/organisms/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export function DashboardShell({ userName, userEmail, defaultOpen = true, childr
<SidebarTrigger className="h-11 w-11" />
<span className="text-sm font-semibold">Ledgr</span>
</header>
<main className="flex-1 px-4 py-4 md:px-6 md:py-6 lg:px-8">
{/* 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. */}
<main className="flex-1 px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-24 lg:px-8">
{children}
</main>
</SidebarInset>
Expand Down
14 changes: 12 additions & 2 deletions src/components/organisms/report-spending.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -76,7 +77,7 @@ export function ReportSpending({
<SpendingChart data={chartData} viewMode={view} onItemClick={handleDrillDown} />
</div>

<div className="border rounded-lg">
<div className="border rounded-lg overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow className="hover:bg-transparent text-muted-foreground">
Expand All @@ -89,8 +90,17 @@ export function ReportSpending({
{data.map((row, i) => (
<TableRow
key={row.categoryId ?? "uncategorized"}
className="cursor-pointer"
// The row is the click target for a mouse, and a focus stop for
// a keyboard. Without the latter, drill-down was mouse-only:
// every row measured tabIndex -1 with no role.
tabIndex={0}
role="button"
aria-label={`Show ${row.categoryName} transactions, ${centsToDisplay(row.total)}`}
className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => handleDrillDown({ id: row.categoryId, name: row.categoryName })}
onKeyDown={activateOnKey(() =>
handleDrillDown({ id: row.categoryId, name: row.categoryName }),
)}
>
<TableCell className="px-3 py-2">
<div className="flex items-center gap-3">
Expand Down
40 changes: 23 additions & 17 deletions src/components/organisms/report-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,29 @@ export function ReportTabs({
value={activeTab}
onValueChange={(tab) => updateFilter("tab", tab === "spending" ? null : tab)}
>
<TabsList className="h-9">
<TabsTrigger value="spending">
<PieChart /> Spending
</TabsTrigger>
<TabsTrigger value="income-expense">
<ArrowLeftRight /> Income vs Expense
</TabsTrigger>
<TabsTrigger value="cash-flow">
<Waypoints /> Cash Flow
</TabsTrigger>
<TabsTrigger value="trends">
<TrendingUp /> Trends
</TabsTrigger>
<TabsTrigger value="net-worth">
<LineChart /> Net Worth
</TabsTrigger>
</TabsList>
{/* 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`. */}
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-9">
<TabsTrigger value="spending">
<PieChart /> Spending
</TabsTrigger>
<TabsTrigger value="income-expense">
<ArrowLeftRight /> Income vs Expense
</TabsTrigger>
<TabsTrigger value="cash-flow">
<Waypoints /> Cash Flow
</TabsTrigger>
<TabsTrigger value="trends">
<TrendingUp /> Trends
</TabsTrigger>
<TabsTrigger value="net-worth">
<LineChart /> Net Worth
</TabsTrigger>
</TabsList>
</div>

<TabsContent value="spending" className="mt-4">
{spendingData && (
Expand Down
11 changes: 10 additions & 1 deletion src/components/organisms/sankey-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,12 +156,20 @@ export function SankeyChart({ nodes, links, onNodeClick, height = 400 }: SankeyC
const clickable = onNodeClick && node.type !== "savings";
return (
<g key={node.id}>
{/* An SVG rect takes focus only with an explicit tabIndex and a
role — without both, the Sankey was mouse-only. */}
<rect
x={x0} y={y0}
width={x1 - x0} height={nodeHeight}
fill={color} rx={2}
className={clickable ? "cursor-pointer" : ""}
tabIndex={clickable ? 0 : undefined}
role={clickable ? "button" : undefined}
aria-label={clickable ? `Show ${node.name} transactions` : undefined}
className={clickable ? "cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" : ""}
onClick={() => clickable && onNodeClick(node.id, node.type)}
onKeyDown={activateOnKey(
clickable ? () => onNodeClick(node.id, node.type) : undefined,
)}
/>
{nodeHeight > 12 && (
<text
Expand Down
39 changes: 39 additions & 0 deletions src/lib/a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, test, expect, vi } from "vitest";
import type { KeyboardEvent } from "react";
import { activateOnKey } from "./a11y";

function keyEvent(key: string) {
return { key, preventDefault: vi.fn() } as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> };
}

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();
}
});
});
21 changes: 21 additions & 0 deletions src/lib/a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { KeyboardEvent } from "react";

/**
* Enter/Space activation for an element that is clickable but is not a
* `<button>` — a table row, a chart legend entry.
*
* Returning `undefined` when there is nothing to activate lets a caller spread
* the result unconditionally: an inert row then gets no handler, and so keeps
* its `tabIndex` off and stays out of the tab order.
*/
export function activateOnKey(
onActivate: (() => void) | undefined,
): ((e: KeyboardEvent) => void) | undefined {
if (!onActivate) return undefined;
return (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
// Space scrolls the page by default, which is not what pressing a control does.
e.preventDefault();
onActivate();
};
}