Public Access
feat(ui): close 3 P2 audit findings + a11y sweep (Sprint 3)
- lib/toast.tsx (renamed from .ts for JSX): new showToast.undo(message, onUndo, ms=5000) helper. Inline 'Undo' button dismisses the toast and fires onUndo. Note: react-hot-toast 2.6 lacks onClose/onDismiss, so expiry is silent — same effective behavior as confirm() declined. - Dashboard.handleDelete: captures the full MealPlanItem before the DELETE so Undo can re-fire meals.generateItem(planId, dayOfWeek, mealType) and refill the slot (recipe may differ — see plan R4). - Pantry.handleRemove: fully reversible — Undo re-fires pantry.add with the original ingredient_id, quantity, and unit. New removeId state scopes the spinner to the clicked row. - Both confirm() call sites removed. - App.tsx Navigation: whitespace-nowrap + px-2 sm:px-3 so all 4 links fit on one line down to 360 px. aria-current='page' on the active link. <nav aria-label='Primary'>, <main id='main-content'>. - components/ui/Badge: optional icon and aria-label props. Dashboard approval-status Badge passes aria-label='Approval status: approved' (or the current value) so screen readers don't rely on color alone. - ErrorBoundary already mounted at App.tsx:42 — verified, no code change. - Review/sprint3-verification.md (new) + Review/ui-nielsen-audit.md and fix-ui-audit.md updated with Sprint 3 status and deploy steps. Build: npm run build (tsc + vite) green. tsc 0 errors.
This commit is contained in:
@@ -18,20 +18,20 @@ function Navigation() {
|
||||
const isActive = (prefix: string) => path === prefix || path.startsWith(prefix + '/')
|
||||
|
||||
const linkClass = (prefix: string) =>
|
||||
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
`inline-flex items-center px-2 sm:px-3 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
|
||||
isActive(prefix)
|
||||
? 'text-primary-700 bg-primary-50'
|
||||
: 'text-surface-600 hover:bg-surface-100'
|
||||
}`
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-surface-200 sticky top-0 z-50">
|
||||
<nav className="bg-white border-b border-surface-200 sticky top-0 z-50" aria-label="Primary">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-1 min-h-14 py-2">
|
||||
<Link to="/" className={linkClass('/')}>MealPlanner</Link>
|
||||
<Link to="/recipes" className={linkClass('/recipes')}>Recipes</Link>
|
||||
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
||||
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
||||
<Link to="/" className={linkClass('/')} aria-current={isActive('/') ? 'page' : undefined}>MealPlanner</Link>
|
||||
<Link to="/recipes" className={linkClass('/recipes')} aria-current={isActive('/recipes') ? 'page' : undefined}>Recipes</Link>
|
||||
<Link to="/pantry" className={linkClass('/pantry')} aria-current={isActive('/pantry') ? 'page' : undefined}>Pantry</Link>
|
||||
<Link to="/shopping-list" className={linkClass('/shopping-list')} aria-current={isActive('/shopping-list') ? 'page' : undefined}>Shopping List</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -45,7 +45,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-surface-50">
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" id="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/meals/:id" element={<MealDetail />} />
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'neutral', children, className }: BadgeProps) {
|
||||
export function Badge({ variant = 'neutral', children, className, icon, 'aria-label': ariaLabel }: BadgeProps) {
|
||||
const variants = {
|
||||
primary: 'bg-primary-50 text-primary-700 border border-primary-200',
|
||||
success: 'bg-success-50 text-success-700 border border-success-200',
|
||||
@@ -17,7 +20,8 @@ export function Badge({ variant = 'neutral', children, className }: BadgeProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn('badge', variants[variant], className)}>
|
||||
<span className={cn('badge', variants[variant], className)} aria-label={ariaLabel}>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const showToast = {
|
||||
success: (message: string) => toast.success(message),
|
||||
error: (message: string) => toast.error(message),
|
||||
loading: (message: string) => toast.loading(message),
|
||||
dismiss: (toastId: string) => toast.dismiss(toastId),
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
messages: { loading: string; success: string; error: string }
|
||||
) => toast.promise(promise, messages),
|
||||
/**
|
||||
* Show a confirmation toast with an Undo button. The toast stays
|
||||
* visible for `ms` (default 5000) and the user can click Undo to
|
||||
* fire `onUndo`. If the user does nothing, the toast just dismisses.
|
||||
*/
|
||||
undo: (message: string, onUndo: () => void, ms = 5000) => {
|
||||
return toast(
|
||||
(t) => (
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUndo();
|
||||
toast.dismiss(t.id);
|
||||
}}
|
||||
className="font-semibold text-primary-600 hover:text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
),
|
||||
{
|
||||
duration: ms,
|
||||
icon: '\u2715',
|
||||
style: { background: '#fff', color: '#404040' },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GripVertical, X
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { showToast } from '../lib/toast'
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
@@ -93,7 +94,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
||||
{item.recipe?.servings} servings
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
|
||||
<Badge
|
||||
variant={statusVariant}
|
||||
className="text-[10px] px-1.5 py-0.5"
|
||||
aria-label={`Approval status: ${item.approval_status}`}
|
||||
>
|
||||
{item.approval_status}
|
||||
</Badge>
|
||||
{item.estimated_cost && (
|
||||
@@ -345,11 +350,28 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
async function handleDelete(itemId: string) {
|
||||
if (!confirm('Delete this meal from the plan?')) return
|
||||
if (!mealPlan) return
|
||||
const item = mealPlan.items.find(i => i.id === itemId)
|
||||
if (!item) return
|
||||
try {
|
||||
await mealPlannerApi.meals.deleteItem(itemId)
|
||||
toast.success('Meal deleted')
|
||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
||||
showToast.undo(
|
||||
'Meal deleted',
|
||||
async () => {
|
||||
try {
|
||||
await mealPlannerApi.meals.generateItem(
|
||||
mealPlan.id,
|
||||
item.day_of_week,
|
||||
item.meal_type
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
||||
toast.success('Slot filled with a new meal')
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.detail || 'Failed to refill slot')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.detail || 'Failed to delete meal')
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const AISLE_OPTIONS = [
|
||||
export default function Pantry() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [removeId, setRemoveId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
/* ingredient name typed by user */
|
||||
@@ -71,12 +72,46 @@ export default function Pantry() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||
showToast.success('Item removed')
|
||||
setRemoveId(null)
|
||||
},
|
||||
onError: () => {
|
||||
showToast.error('Failed to remove item')
|
||||
setRemoveId(null)
|
||||
},
|
||||
})
|
||||
|
||||
async function handleRemove(item: HomePantryItem) {
|
||||
if (!item.ingredient_id) {
|
||||
showToast.error('Cannot remove: missing ingredient link')
|
||||
return
|
||||
}
|
||||
setRemoveId(item.id)
|
||||
try {
|
||||
await mealPlannerApi.pantry.remove(item.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||
showToast.undo(
|
||||
'Item removed',
|
||||
async () => {
|
||||
try {
|
||||
await mealPlannerApi.pantry.add({
|
||||
ingredient_id: item.ingredient_id!,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||
showToast.success('Item restored')
|
||||
} catch {
|
||||
showToast.error('Failed to restore item')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
showToast.error('Failed to remove item')
|
||||
} finally {
|
||||
setRemoveId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const name = ingredientName.trim()
|
||||
if (!name) {
|
||||
@@ -313,12 +348,8 @@ export default function Pantry() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${item.ingredient?.name || 'this item'} from pantry?`)) {
|
||||
removeMutation.mutate(item.id)
|
||||
}
|
||||
}}
|
||||
loading={removeMutation.isPending}
|
||||
onClick={() => handleRemove(item)}
|
||||
loading={removeMutation.isPending && removeId === item.id}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
Remove
|
||||
|
||||
Reference in New Issue
Block a user