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:
parent
0abe861928
commit
21651b3f67
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue