fix(ux): focus visibility, tap targets, form labels, surfaced errors

- S6: unlayered :focus-visible ring for form controls and links so
  keyboard focus is perceivable (was focus:outline-none + 1px border)
- S5: >=44px touch targets at the named hotspots (dashboard actions,
  list-row buttons, ops pills/icons, customize number inputs); dense
  rows use a 44px hit area without growing row height
- S8: 28 form fields associated via id/htmlFor; search inputs get
  aria-label
- S7: claim/complete/delete/link handlers now surface result.error via
  toast and stop instead of failing silently
This commit is contained in:
Moheeb Zara
2026-05-16 00:00:23 -07:00
parent ea49e2129d
commit 075b274204
11 changed files with 125 additions and 106 deletions
+27 -23
View File
@@ -3,6 +3,7 @@
import { useState, useTransition } from 'react'
import { Plus, X, CheckCircle2 } from 'lucide-react'
import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions'
import { toast } from 'sonner'
import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
@@ -80,28 +81,25 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
async function handleClaim(taskId: string) {
const result = await claimTask(taskId)
if (!result.error) {
const member = members.find(m => m.user_id === currentUserId)
setTasks(prev => prev.map(t =>
t.id === taskId
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
: t
))
}
if (result?.error) { toast.error(result.error); return }
const member = members.find(m => m.user_id === currentUserId)
setTasks(prev => prev.map(t =>
t.id === taskId
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
: t
))
}
async function handleComplete(taskId: string) {
const result = await completeTask(taskId)
if (!result.error) {
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
}
if (result?.error) { toast.error(result.error); return }
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
}
async function handleDelete(taskId: string) {
const result = await deleteTask(taskId)
if (!result.error) {
setTasks(prev => prev.filter(t => t.id !== taskId))
}
if (result?.error) { toast.error(result.error); return }
setTasks(prev => prev.filter(t => t.id !== taskId))
}
const tabCounts = {
@@ -201,7 +199,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
{task.status === 'open' && (
<button
onClick={() => handleClaim(task.id)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
CLAIM
</button>
@@ -210,7 +208,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
{(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && (
<button
onClick={() => handleComplete(task.id)}
className="font-mono text-[10px] border border-green-200 text-green-700 px-2 py-0.5 rounded hover:bg-green-50 transition whitespace-nowrap"
className="font-mono text-[10px] border border-green-200 text-green-700 px-3 py-2 min-h-[44px] rounded hover:bg-green-50 transition whitespace-nowrap"
>
DONE
</button>
@@ -218,7 +216,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<button
onClick={() => handleDelete(task.id)}
className="text-muted-foreground hover:text-destructive transition flex-shrink-0"
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition flex-shrink-0"
aria-label="Delete task"
>
<X className="w-3.5 h-3.5" />
@@ -258,8 +256,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<form onSubmit={handleCreate} className="p-6 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<label htmlFor="task-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
<input
id="task-title"
type="text"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
@@ -271,8 +270,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<label htmlFor="task-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
<textarea
id="task-description"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Optional details..."
@@ -283,8 +283,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<label htmlFor="task-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
<select
id="task-type"
value={form.type}
onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -294,8 +295,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<label htmlFor="task-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
<select
id="task-area"
value={form.area}
onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -308,8 +310,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
<label htmlFor="task-recurrence" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
<select
id="task-recurrence"
value={form.recurrence}
onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
@@ -318,8 +321,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<label htmlFor="task-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
<input
id="task-due-date"
type="date"
value={form.due_date}
onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}