feat(tracker): a11y live regions + keyboard-shortcut hint (plan D/C5)

- aria-label on the pencil "Edit bill" buttons (were title-only), matching the
  autopay buttons' title+aria-label pattern.
- role="status" aria-live on the filter result count so filtering announces
  "N of M shown"; a visually-hidden live region announces drag/keyboard reorder
  ("Moved <bill> to position N of M").
- Header "Keyboard shortcuts" dropdown documenting the (previously invisible)
  j/k/arrows/Enter/p/Esc row shortcuts + ⌘K palette.

Deferred as low-value/optional: extra display toggles (C4) and a due-this-week
chip (C5) — avoided header/option clutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 15:00:16 -05:00
parent 0abe861928
commit 21651b3f67
3 changed files with 47 additions and 2 deletions

View File

@ -273,6 +273,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
size="icon" variant="ghost" size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100" className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill" title="Edit bill"
aria-label={`Edit ${row.name}`}
onClick={() => onEditBill?.(row)} onClick={() => onEditBill?.(row)}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />

View File

@ -438,6 +438,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
size="icon" variant="ghost" size="icon" variant="ghost"
className="h-6 w-6 shrink-0 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100" className="h-6 w-6 shrink-0 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill" title="Edit bill"
aria-label={`Edit ${row.name}`}
onClick={() => onEditBill?.(row)} onClick={() => onEditBill?.(row)}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react'; import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays } from 'lucide-react'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays, Keyboard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries'; import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries';
@ -319,6 +319,8 @@ export default function TrackerPage() {
// Multi-select: bill ids checked for a bulk pay/skip action. // Multi-select: bill ids checked for a bulk pay/skip action.
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
// Screen-reader announcement for drag/keyboard reorder (visually hidden).
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
// Use React Query for data fetching // Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
@ -775,12 +777,19 @@ export default function TrackerPage() {
if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!; if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
} }
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id)); const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
if (moved) {
const pos = orderedBucketRows.findIndex(r => r.id === moved.id) + 1;
setReorderAnnouncement(`Moved ${moved.name} to position ${pos} of ${orderedBucketRows.length}.`);
}
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null); persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
} }
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Visually-hidden live region: announces drag/keyboard reorder to screen readers */}
<div role="status" aria-live="polite" className="sr-only">{reorderAnnouncement}</div>
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
@ -873,6 +882,40 @@ export default function TrackerPage() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 tracker-print-hide"
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts"
>
<Keyboard className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Keyboard shortcuts</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="space-y-1.5 px-2 py-1.5 text-xs text-muted-foreground">
{[
['Move between bills', 'j / k · ↑ / ↓'],
['Edit focused bill', 'Enter'],
['Toggle paid', 'p'],
['Blur / deselect row', 'Esc'],
['Command palette', '⌘K / Ctrl+K'],
].map(([label, keys]) => (
<div key={label} className="flex items-center justify-between gap-4">
<span>{label}</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">{keys}</kbd>
</div>
))}
</div>
<DropdownMenuSeparator />
<p className="px-2 py-1 text-[11px] text-muted-foreground">Focus any bill row (Tab or click), then use the keys above.</p>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1"> <div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
@ -1000,7 +1043,7 @@ export default function TrackerPage() {
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip> <FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip> <FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip> <FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> <span role="status" aria-live="polite" className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown {filteredRows.length} of {rows.length} shown
{hasSort && ( {hasSort && (
<span className="ml-2 hidden sm:inline"> <span className="ml-2 hidden sm:inline">