refactor(tracker): remove dead code and unreferenced files (plan A1)
- Delete unreachable in-file dialogs: MonthlyStateDialog block (setShowMbs never called) and inline PaymentModal in TrackerRow + MobileTrackerRow (setEditPayment only ever null; editing works via PaymentLedgerDialog). - Delete fully-unreferenced files: tracker/EditableCell.tsx, ui/confirm-dialog.tsx, ui/separator.tsx (+ orphaned @radix-ui/react-separator). - Simplify OverdueCommandCenter dead branch (early-returns null when empty). - Correct stale roadmap/FUTURE docs: tracker keyboard nav is implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
db1be4b0bd
commit
3973560e92
10
FUTURE.md
10
FUTURE.md
|
|
@ -143,16 +143,16 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu
|
|||
### 🔵 No Keyboard Navigation or Shortcuts — LOW
|
||||
**Added:** 2026-05-11 by Ripley
|
||||
|
||||
**Status:** Partial — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ❌
|
||||
**Status:** Done — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ✅
|
||||
|
||||
**Description:**
|
||||
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid.
|
||||
Skip link, command palette, and tracker-row keyboard navigation are all implemented.
|
||||
|
||||
**Implementation Notes:**
|
||||
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`)
|
||||
- ❌ Arrow keys navigate tracker rows when grid is focused
|
||||
- Remaining effort: 1-2 hours
|
||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`)
|
||||
- ✅ Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`)
|
||||
- Nice-to-have: a `?`-triggered shortcut legend for discoverability
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Dollars } from '@/lib/money';
|
||||
import type { TrackerRow } from '@/types';
|
||||
|
||||
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
||||
interface EditableCellProps {
|
||||
row: TrackerRow;
|
||||
field: 'amount' | 'date';
|
||||
threshold: Dollars;
|
||||
defaultPaymentDate: string;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }: EditableCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const displayVal = field === 'amount'
|
||||
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
||||
: (row.last_paid_date ? fmtDate(row.last_paid_date) : '—');
|
||||
|
||||
const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
|
||||
// Mismatch when paid amount differs from the effective threshold for this month
|
||||
const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold;
|
||||
|
||||
function startEdit() {
|
||||
if (editing) return;
|
||||
setValue(field === 'amount'
|
||||
? (row.total_paid > 0 ? String(row.total_paid) : '')
|
||||
: (row.last_paid_date || ''));
|
||||
setEditing(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
setEditing(false);
|
||||
const val = value.trim();
|
||||
if (!val) return;
|
||||
try {
|
||||
if (row.payments && row.payments.length > 0) {
|
||||
const update: { amount?: number; paid_date?: string } = {};
|
||||
if (field === 'amount') update.amount = parseFloat(val);
|
||||
if (field === 'date') update.paid_date = val;
|
||||
await api.updatePayment(row.payments[0]!.id, update);
|
||||
toast.success('Saved');
|
||||
refresh();
|
||||
} else {
|
||||
await createPaymentOrConfirm(
|
||||
{
|
||||
bill_id: row.id,
|
||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||
},
|
||||
() => { toast.success('Saved'); refresh(); },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') inputRef.current?.blur();
|
||||
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={field === 'date' ? 'date' : 'number'}
|
||||
step={field === 'amount' ? '0.01' : undefined}
|
||||
min={field === 'amount' ? '0' : undefined}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEdit}
|
||||
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
|
||||
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
|
||||
isEmpty && 'text-muted-foreground',
|
||||
mismatch && 'text-amber-500',
|
||||
!isEmpty && !mismatch && 'text-emerald-500',
|
||||
)}
|
||||
>
|
||||
{displayVal}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
|
|
@ -20,7 +19,7 @@ import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'
|
|||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||
import type { Payment, TrackerRow } from '@/types';
|
||||
import type { TrackerRow } from '@/types';
|
||||
|
||||
function MiniSparkline({ values }: { values?: number[] | null }) {
|
||||
if (!values || values.length < 2) return null;
|
||||
|
|
@ -78,7 +77,6 @@ interface MobileTrackerRowProps {
|
|||
|
||||
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) {
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
|
|
@ -378,15 +376,6 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
|||
</div>
|
||||
</motion.div>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
autopayEnabled={!!row.autopay_enabled}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentLedgerOpen && (
|
||||
<PaymentLedgerDialog
|
||||
row={row}
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { fmt, errMessage } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { TrackerRow } from '@/types';
|
||||
|
||||
const MONTHS = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
|
||||
interface MonthlyStateDialogProps {
|
||||
row: TrackerRow;
|
||||
year: number;
|
||||
month: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) {
|
||||
const [actualAmount, setActualAmount] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSkipped, setIsSkipped] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Populate from current row state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
|
||||
setNotes(row.monthly_notes || '');
|
||||
setIsSkipped(!!row.is_skipped);
|
||||
}
|
||||
}, [open, row]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
|
||||
if (amt !== null && (isNaN(amt) || amt < 0)) {
|
||||
toast.error('Amount must be a positive number or empty');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveBillMonthlyState(row.id, {
|
||||
year,
|
||||
month,
|
||||
actual_amount: amt,
|
||||
notes: notes.trim() || null,
|
||||
is_skipped: isSkipped,
|
||||
});
|
||||
toast.success(`${MONTHS[month - 1]} state saved`);
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold tracking-tight">
|
||||
{row.name}
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
{MONTHS[month - 1]} {year}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Monthly overrides — changes only affect {MONTHS[month - 1]}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
|
||||
{/* Actual amount this month */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Actual Amount ($)
|
||||
</Label>
|
||||
<Input
|
||||
id="mbs-amount"
|
||||
type="number" min="0" step="0.01"
|
||||
placeholder={String(row.expected_amount)}
|
||||
value={actualAmount}
|
||||
onChange={e => setActualAmount(e.target.value)}
|
||||
className="font-mono bg-background/50 border-border/60"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave blank to use the template default ({fmt(row.expected_amount)}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Monthly notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Notes (this month only)
|
||||
</Label>
|
||||
<Input
|
||||
id="mbs-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="e.g. higher than usual, double-billed…"
|
||||
className="bg-background/50 border-border/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skip this month */}
|
||||
<label className="flex items-start gap-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSkipped}
|
||||
onChange={e => setIsSkipped(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">Skip this month</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonthlyStateDialog;
|
||||
|
|
@ -193,15 +193,11 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
|||
<div className="flex items-center gap-2.5">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-rose-400" />
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{overdueRows.length === 0
|
||||
? 'No active overdue bills'
|
||||
: `${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
||||
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-rose-400 dark:text-rose-300">
|
||||
{fmt(asDollars(totalOverdue))}
|
||||
</span>
|
||||
{overdueRows.length > 0 && (
|
||||
<span className="font-mono text-sm text-rose-400 dark:text-rose-300">
|
||||
{fmt(asDollars(totalOverdue))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{snoozedRows.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import {
|
|||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||
|
|
@ -24,7 +22,7 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
|||
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
||||
import { NotesCell } from '@/components/tracker/NotesCell';
|
||||
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
||||
import type { Payment, TrackerRow as TrackerRowData } from '@/types';
|
||||
import type { TrackerRow as TrackerRowData } from '@/types';
|
||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||
|
||||
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
|
||||
|
|
@ -46,9 +44,7 @@ interface TrackerRowProps {
|
|||
|
||||
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) {
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const [optimisticActual, setOptimisticActual] = useState<Dollars | undefined>(undefined);
|
||||
|
|
@ -690,15 +686,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
)}
|
||||
</TableRow>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
autopayEnabled={!!row.autopay_enabled}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentLedgerOpen && (
|
||||
<PaymentLedgerDialog
|
||||
row={row}
|
||||
|
|
@ -711,17 +698,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
/>
|
||||
)}
|
||||
|
||||
{showMbs && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
open={showMbs}
|
||||
onOpenChange={setShowMbs}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type ConfirmVariant = 'default' | 'destructive';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title?: string;
|
||||
description?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: ConfirmVariant;
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/* ── ConfirmDialog ──────────────────────────────────────────────
|
||||
Reusable confirmation dialog that replaces window.confirm. */
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm?.();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!loading) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={loading ? undefined : onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className={cn(loading && 'cursor-not-allowed')}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── useConfirm hook ────────────────────────────────────────────
|
||||
Imperative API for ConfirmDialog.
|
||||
|
||||
Usage:
|
||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||
const ok = await confirm({ title, description, confirmLabel, variant });
|
||||
// Render <ConfirmDialogComponent /> anywhere in the tree. */
|
||||
|
||||
interface ConfirmState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
variant: ConfirmVariant;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
description?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: ConfirmVariant;
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const [state, setState] = React.useState<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
description: '',
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'default',
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Keep a stable ref to the resolve callback so it survives re-renders
|
||||
const resolveRef = React.useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = React.useCallback(
|
||||
({
|
||||
title = 'Are you sure?',
|
||||
description = '',
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
}: ConfirmOptions = {}): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setState({
|
||||
open: true,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
variant,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleConfirm = React.useCallback(async () => {
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
resolveRef.current?.(true);
|
||||
resolveRef.current = null;
|
||||
setState((s) => ({ ...s, open: false, loading: false }));
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resolveRef.current?.(false);
|
||||
resolveRef.current = null;
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ConfirmDialogComponent = React.useCallback(
|
||||
() => (
|
||||
<ConfirmDialog
|
||||
open={state.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={state.title}
|
||||
description={state.description}
|
||||
confirmLabel={state.confirmLabel}
|
||||
cancelLabel={state.cancelLabel}
|
||||
variant={state.variant}
|
||||
loading={state.loading}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
),
|
||||
[state, handleConfirm, handleOpenChange]
|
||||
);
|
||||
|
||||
return { confirm, ConfirmDialogComponent };
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }: ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
|
|
@ -4512,52 +4511,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
|
|
|
|||
13
roadmap.md
13
roadmap.md
|
|
@ -107,20 +107,19 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu
|
|||
|
||||
---
|
||||
|
||||
### 🔵 No Keyboard Navigation or Shortcuts — LOW
|
||||
**Status:** Partially implemented
|
||||
### 🔵 No Keyboard Navigation or Shortcuts — DONE
|
||||
**Status:** Implemented
|
||||
|
||||
**Description:**
|
||||
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid.
|
||||
A skip link, command palette, and tracker-row keyboard navigation all exist.
|
||||
|
||||
**Implementation Status:**
|
||||
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
|
||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`)
|
||||
- ❌ Arrow keys navigate tracker rows when grid is focused
|
||||
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`)
|
||||
- ✅ Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`)
|
||||
|
||||
**Remaining Work:**
|
||||
- Implement arrow key navigation for tracker rows
|
||||
- Estimated effort: 1-2 hours
|
||||
- None. A `?`-triggered shortcut legend for discoverability is a possible nice-to-have.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue