Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
## [Unreleased]

### Added
- **Confirmation Dialog System:** Replaced browser's native `alert`/`confirm` with a custom, accessible, and themed modal system.
- **Features:**
- Dual-theme support (Glassmorphism & Neobrutalism).
- Asynchronous `useConfirm` hook returning a Promise.
- Specialized variants (danger, warning, info) with appropriate styling and icons.
- Fully accessible `Modal` component (added `role="dialog"`, `aria-labelledby`, `aria-modal`).
- **Technical:** Created `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`. Updated `web/pages/GroupDetails.tsx` to use the new system.

- **Error Boundary System:** Implemented a global React Error Boundary to catch render errors gracefully.
- **Features:**
- Dual-theme support (Glassmorphism & Neobrutalism) for the error fallback UI.
Expand All @@ -22,6 +30,8 @@
- Keyboard navigation support for Groups page, enabling accessibility for power users.

### Changed
- **Web App:** Refactored `GroupDetails` destructive actions (Delete Group, Delete Expense, Leave Group, Remove Member) to use the new `ConfirmDialog` instead of `window.confirm`.
- **Accessibility:** Updated `Modal` component to include proper ARIA roles and labels, fixing a long-standing accessibility gap.
- Updated JULES_PROMPT.md based on review of successful PRs:
- Emphasized complete system implementation over piecemeal changes
- Added best practices from successful PRs (Toast system, keyboard navigation iteration)
Expand Down
59 changes: 59 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,39 @@ colors: {

## Component Patterns

### Confirmation Dialog System

**Date:** 2026-01-21
**Context:** Replacing window.confirm with accessible modal

To handle destructive actions asynchronously while maintaining UI consistency:

```tsx
// 1. Setup in App
<ConfirmProvider>
<App />
</ConfirmProvider>

// 2. Usage in Component
const { confirm } = useConfirm();

const handleDelete = async () => {
const result = await confirm({
title: 'Delete Item',
description: 'Are you sure?',
variant: 'danger', // danger | warning | info
confirmText: 'Delete'
});

if (result) {
await deleteItem();
}
}
```

**Key Implementation Detail:**
Uses a promise-based approach (`new Promise(resolve => ...)`) stored in state/ref to bridge the imperative `confirm()` call with the declarative React UI rendering.

### Error Boundary Pattern

**Date:** 2026-01-14
Expand Down Expand Up @@ -190,6 +223,9 @@ When making a div clickable (like a card), you must ensure it's accessible:
</Modal>
```

**Accessibility Update (2026-01-21):**
Modals must include `role="dialog"`, `aria-modal="true"`, and `aria-labelledby="title-id"` on the overlay container to be properly detected by screen readers (and testing tools).

### Toast Notification Pattern

**Date:** 2026-01-01
Expand Down Expand Up @@ -486,6 +522,29 @@ _Document errors and their solutions here as you encounter them._

## Recent Implementation Reviews

### ✅ Successful PR Pattern: Confirmation Dialog System (#255)

**Date:** 2026-01-21
**Context:** Replacing native confirm dialogs with custom UI

**What was implemented:**
1. Created `ConfirmContext` using Promise pattern for async/await usage
2. Created `ConfirmDialog` component wrapping existing `Modal`
3. Enhanced `Modal` with proper ARIA attributes (`role="dialog"`, `aria-labelledby`)
4. Replaced native `window.confirm` in `GroupDetails.tsx`

**Why it succeeded:**
- ✅ Improved UX (no more native browser alerts)
- ✅ Improved Accessibility (Modal roles + keyboard support)
- ✅ Maintained dual-theme support (Glass/Neo)
- ✅ Clean integration via Context API (easy to use `const { confirm } = useConfirm()`)

**Key learnings:**
- Modals need explicit ARIA roles to be testable/accessible.
- Promise-based context API allows keeping the call site logic simple (`if (await confirm()) ...`).

---

### ✅ Successful PR Pattern: Error Boundary (#240)

**Date:** 2026-01-14
Expand Down
14 changes: 7 additions & 7 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
- Impact: App doesn't crash, users can recover
- Size: ~80 lines

- [x] **[ux]** Confirmation dialog for destructive actions
- Completed: 2026-01-21
- Files: Created `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/components/ui/Modal.tsx`
- Context: Replaced window.confirm with custom accessible modal system
- Impact: Prevents accidental data loss, matches app theme, improves accessibility
- Size: ~100 lines

### Mobile

- [ ] **[ux]** Pull-to-refresh with haptic feedback on all list screens
Expand Down Expand Up @@ -77,13 +84,6 @@
- Size: ~35 lines
- Added: 2026-01-01

- [ ] **[ux]** Confirmation dialog for destructive actions
- Files: Create `web/components/ui/ConfirmDialog.tsx`, integrate
- Context: Confirm before deleting groups/expenses
- Impact: Prevents accidental data loss
- Size: ~70 lines
- Added: 2026-01-01

### Mobile

- [ ] **[ux]** Swipe-to-delete for expenses with undo option
Expand Down
19 changes: 11 additions & 8 deletions web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ThemeWrapper } from './components/layout/ThemeWrapper';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ConfirmProvider } from './contexts/ConfirmContext';
import { ToastContainer } from './components/ui/Toast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { Auth } from './pages/Auth';
Expand Down Expand Up @@ -50,14 +51,16 @@ const App = () => {
return (
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
<ConfirmProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</ThemeProvider>
);
Expand Down
94 changes: 94 additions & 0 deletions web/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { AlertTriangle, Info } from 'lucide-react';
import React from 'react';
import { THEMES } from '../../constants';
import { useTheme } from '../../contexts/ThemeContext';
import { Button } from './Button';
import { Modal } from './Modal';

export type ConfirmVariant = 'danger' | 'warning' | 'info';

export interface ConfirmDialogProps {
isOpen: boolean;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: ConfirmVariant;
onConfirm: () => void;
onCancel: () => void;
}

export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
description,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
onConfirm,
onCancel,
}) => {
const { style, mode } = useTheme();
const isNeo = style === THEMES.NEOBRUTALISM;

// Determine styles based on variant
const getIcon = () => {
switch (variant) {
case 'danger':
return <AlertTriangle size={32} className={isNeo ? 'text-black' : 'text-red-500'} />;
case 'warning':
return <AlertTriangle size={32} className={isNeo ? 'text-black' : 'text-yellow-500'} />;
case 'info':
return <Info size={32} className={isNeo ? 'text-black' : 'text-blue-500'} />;
}
};

const getIconBg = () => {
switch (variant) {
case 'danger':
return isNeo ? 'bg-red-400 border-2 border-black rounded-none' : 'bg-red-500/20 rounded-full';
case 'warning':
return isNeo ? 'bg-yellow-400 border-2 border-black rounded-none' : 'bg-yellow-500/20 rounded-full';
case 'info':
return isNeo ? 'bg-blue-400 border-2 border-black rounded-none' : 'bg-blue-500/20 rounded-full';
}
};

const getButtonVariant = () => {
switch (variant) {
case 'danger': return 'danger';
case 'warning': return 'primary';
case 'info': return 'primary';
default: return 'primary';
}
};

return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="ghost" onClick={onCancel}>
{cancelText}
</Button>
<Button variant={getButtonVariant()} onClick={onConfirm} autoFocus>
{confirmText}
</Button>
</>
}
>
<div className="flex flex-col items-center text-center sm:flex-row sm:text-left sm:items-start gap-4">
<div className={`p-3 shrink-0 ${getIconBg()}`}>
{getIcon()}
</div>
<div>
<p className={`text-base leading-relaxed ${isNeo ? 'text-black' : 'text-white/80'}`}>
{description}
</p>
</div>
</div>
</Modal>
);
};
6 changes: 3 additions & 3 deletions web/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<motion.div
variants={overlayVariants}
initial="hidden"
Expand All @@ -64,8 +64,8 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
>
{/* Header */}
<div className={`p-6 flex justify-between items-center ${style === THEMES.NEOBRUTALISM ? 'border-b-2 border-black bg-neo-main text-white' : 'border-b border-white/10 bg-white/5'}`}>
<h3 className={`text-2xl font-bold ${style === THEMES.NEOBRUTALISM ? 'uppercase font-mono tracking-tighter' : ''}`}>{title}</h3>
<button onClick={onClose} className="hover:rotate-90 transition-transform duration-200">
<h3 id="modal-title" className={`text-2xl font-bold ${style === THEMES.NEOBRUTALISM ? 'uppercase font-mono tracking-tighter' : ''}`}>{title}</h3>
<button onClick={onClose} className="hover:rotate-90 transition-transform duration-200" aria-label="Close modal">
<X size={24} />
</button>
</div>
Expand Down
73 changes: 73 additions & 0 deletions web/contexts/ConfirmContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { createContext, useCallback, useContext, useState } from 'react';
import { ConfirmDialog, ConfirmVariant } from '../components/ui/ConfirmDialog';

interface ConfirmOptions {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: ConfirmVariant;
}

interface ConfirmContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}

const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);

export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
description: '',
});
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);

const confirm = useCallback((options: ConfirmOptions) => {
setOptions(options);
setIsOpen(true);
return new Promise<boolean>((resolve) => {
setResolveRef(() => resolve);
});
}, []);

const handleConfirm = useCallback(() => {
setIsOpen(false);
if (resolveRef) {
resolveRef(true);
setResolveRef(null);
}
}, [resolveRef]);

const handleCancel = useCallback(() => {
setIsOpen(false);
if (resolveRef) {
resolveRef(false);
setResolveRef(null);
}
}, [resolveRef]);

return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<ConfirmDialog
isOpen={isOpen}
title={options.title}
description={options.description}
confirmText={options.confirmText}
cancelText={options.cancelText}
variant={options.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
);
};

export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (context === undefined) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
Loading
Loading