From d27603e4f1897a60ee5b4040dd1632a3f2a15ad6 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Wed, 2 Sep 2026 21:13:43 +0900 Subject: [PATCH] fix(reports): make drill-down reachable by keyboard and the page fit a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access and layout half of #147. Charts and information design follow separately. Every drill-down target measured `tabIndex: -1` with no role — 18 table rows, 9 legend entries and the Sankey nodes. Drill-down was mouse-only. Table rows, the donut legend and the Sankey rects now take focus, announce what they do, and activate on Enter or Space through a shared `activateOnKey`. A target with nothing to activate stays out of the tab order rather than becoming an empty stop. The donut's own sectors are still mouse-only — Recharts sectors cannot hold focus — but the legend beside them offers the same targets, so nothing is now reachable only by mouse. At 555px the page overflowed to 569 and scrolled sideways. Two causes. The tablist computes `overflow-x: visible` and simply widened the page, leaving Trends and Net Worth off-screen with no scroll affordance; it sits in a scroll container now. And `report-summary-bar` set `gridTemplateColumns` as an inline style, which no breakpoint can reach, so a phone kept the desktop column count and clipped the third tile to its icon; it is grid classes now, one column on a phone, and each tile is `min-w-0` so `1fr` stops yielding to a long label (the tiles measured 173 / 133 / 180 rather than equal thirds). The donut legend painted a stray horizontal scrollbar — `overflow-y-auto` alone makes CSS compute `overflow-x: auto`, and 306 > 302 was enough to show one. Pinned to `overflow-x-hidden`. The AI assistant button is `fixed bottom-6 right-6` and floats over whatever the page ends with, so `
` reserves its height. This clears content at rest; a fixed button still passes over content mid-scroll, which is inherent to the pattern and a design question rather than a layout bug. Not changed: #147 also reports the sidebar avatar overlapping page content. That element is `NEXTJS-PORTAL`, the dev-tools indicator, which ships in no build — there is no product bug there. Verified at 390px and 1440px: page scrollWidth now equals clientWidth, the tablist scrolls, summary tiles are equal width, and focusing a table row, a legend entry or a Sankey node and pressing Enter opens the drill-down. Refs #147 --- src/components/atoms/report-summary-bar.tsx | 22 +++++++--- src/components/atoms/spending-chart.tsx | 11 ++++- .../income-expense-category-table.tsx | 17 +++++++- src/components/organisms/dashboard-shell.tsx | 5 ++- src/components/organisms/report-spending.tsx | 14 ++++++- src/components/organisms/report-tabs.tsx | 40 +++++++++++-------- src/components/organisms/sankey-chart.tsx | 11 ++++- src/lib/a11y.test.ts | 39 ++++++++++++++++++ src/lib/a11y.ts | 21 ++++++++++ 9 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 src/lib/a11y.test.ts create mode 100644 src/lib/a11y.ts diff --git a/src/components/atoms/report-summary-bar.tsx b/src/components/atoms/report-summary-bar.tsx index a9fd002b..d2c41c8d 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 1ad1e235..8b48ba89 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 f7959be9..ec6e5334 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 eb18652d..7f09ccb3 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 32167734..6e78b8b7 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 47f2563e..9c1a2e5c 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 00000000..7a781a04 --- /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 + * `