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:
@@ -110,13 +110,13 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
<form onSubmit={onSubmit} className="p-6 space-y-4">
|
||||
<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">Name *</label>
|
||||
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
<label htmlFor="contact-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
|
||||
<input id="contact-name" type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: 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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
||||
<select value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))}
|
||||
<label htmlFor="contact-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
||||
<select id="contact-type" value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_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">
|
||||
{Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
@@ -124,31 +124,31 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
</div>
|
||||
<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">Email</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
<label htmlFor="contact-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
|
||||
<input id="contact-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: 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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
<label htmlFor="contact-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||
<input id="contact-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: 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 transition" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
|
||||
<textarea value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
|
||||
<label htmlFor="contact-details" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
|
||||
<textarea id="contact-details" value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
|
||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||
</div>
|
||||
<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">Group Label</label>
|
||||
<input type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
|
||||
<label htmlFor="contact-group-label" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
|
||||
<input id="contact-group-label" type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
|
||||
placeholder="e.g. Equipment Vendors"
|
||||
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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
|
||||
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
<label htmlFor="contact-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
|
||||
<input id="contact-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
placeholder="laser, cnc, maintenance"
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
@@ -184,6 +184,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search contacts"
|
||||
placeholder="Search contacts..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -220,14 +221,14 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={() => openEdit(contact)}
|
||||
className="text-muted-foreground hover:text-primary transition"
|
||||
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition"
|
||||
aria-label="Edit contact"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(contact.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition"
|
||||
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition"
|
||||
aria-label="Delete contact"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -52,7 +52,7 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
|
||||
<ul className="divide-y divide-border">
|
||||
{[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => (
|
||||
<li key={a.id} className={`py-3 flex items-center gap-3 flex-wrap ${a.is_archived ? 'opacity-50' : ''}`}>
|
||||
<input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||
<input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
|
||||
{a.icon && <span>{a.icon}</span>}
|
||||
<input type="text" defaultValue={a.name} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{a.code}</span>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
|
||||
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
|
||||
<li key={s.id} className="border border-border rounded p-4">
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
|
||||
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function DashboardPage() {
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
|
||||
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition">
|
||||
<IcoPlus className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Quick Task</span>
|
||||
<span className="sm:hidden">Task</span>
|
||||
@@ -211,7 +211,7 @@ export default async function DashboardPage() {
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span>
|
||||
)}
|
||||
<Link href="/tasks" className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition">
|
||||
<Link href="/tasks" className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition">
|
||||
CLAIM
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -101,18 +101,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
|
||||
async function handleApprove(memberId: string) {
|
||||
const result = await approveMember(memberId)
|
||||
if (!result.error) {
|
||||
if (result?.error) { toast.error(result.error); return }
|
||||
setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(memberId: string) {
|
||||
if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return
|
||||
const result = await removeMember(memberId)
|
||||
if (!result.error) {
|
||||
if (result?.error) { toast.error(result.error); return }
|
||||
setMembers(prev => prev.filter(m => m.id !== memberId))
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(m: Member) {
|
||||
setEditMember(m)
|
||||
@@ -133,32 +131,32 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
<>
|
||||
<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">Full Name *</label>
|
||||
<input type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
|
||||
<label htmlFor="member-display-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
|
||||
<input id="member-display-name" type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: 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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
|
||||
<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
<label htmlFor="member-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
|
||||
<input id="member-email" type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: 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 transition" />
|
||||
</div>
|
||||
</div>
|
||||
<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">Phone</label>
|
||||
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
<label htmlFor="member-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||
<input id="member-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: 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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
|
||||
<input type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
|
||||
<label htmlFor="member-handle" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
|
||||
<input id="member-handle" type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
</div>
|
||||
<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">Tier</label>
|
||||
<select value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
|
||||
<label htmlFor="member-tier" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
|
||||
<select id="member-tier" value={form.tier} onChange={e => setForm(f => ({ ...f, tier: 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">
|
||||
<option value="plus">Plus</option>
|
||||
<option value="basic">Basic</option>
|
||||
@@ -167,8 +165,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
|
||||
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
||||
<label htmlFor="member-role" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
|
||||
<select id="member-role" value={form.role} onChange={e => setForm(f => ({ ...f, role: 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">
|
||||
<option value="member">Member</option>
|
||||
<option value="board">Board</option>
|
||||
@@ -179,8 +177,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
</div>
|
||||
<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">Joined At</label>
|
||||
<input type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
|
||||
<label htmlFor="member-joined-at" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
|
||||
<input id="member-joined-at" type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: 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" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
@@ -238,6 +236,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search members"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -326,20 +325,20 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
{m.status === 'unverified' && (
|
||||
<button
|
||||
onClick={() => handleApprove(m.id)}
|
||||
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-2 py-0.5 rounded hover:bg-primary/10 transition"
|
||||
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-3 py-2 min-h-[44px] rounded hover:bg-primary/10 transition"
|
||||
>
|
||||
APPROVE
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(m)}
|
||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||
>
|
||||
EDIT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(m.id)}
|
||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-red-300 hover:text-red-600 transition"
|
||||
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-red-300 hover:text-red-600 transition"
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
@@ -347,7 +346,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }}
|
||||
className="font-mono text-[10px] border border-border px-1.5 py-0.5 rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
|
||||
className="font-mono text-[10px] border border-border px-2 py-2 min-h-[44px] rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
|
||||
title="Assign this member as an area lead"
|
||||
>
|
||||
<option value="">+ area lead</option>
|
||||
|
||||
@@ -397,7 +397,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
||||
{canManageAcl && (
|
||||
<button
|
||||
onClick={() => setShowAcl(v => !v)}
|
||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||
title="Who can access this secret"
|
||||
>
|
||||
Access
|
||||
@@ -406,13 +406,13 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
||||
<button
|
||||
onClick={reveal}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
className="flex items-center gap-1 font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||
title={revealed ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
||||
<button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -497,10 +497,10 @@ function KbEntryRow({
|
||||
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
|
||||
</span>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button onClick={handleEdit} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
|
||||
<button onClick={handleEdit} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition" title="Edit">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
||||
<button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
async function handleLink(paymentId: string, memberId: string) {
|
||||
setLoading(true)
|
||||
const result = await linkPaymentToMember(paymentId, memberId)
|
||||
if (!result.error) {
|
||||
if (result?.error) { toast.error(result.error); setLoading(false); return }
|
||||
const linkedMember = members.find(m => m.id === memberId)
|
||||
setPayments(prev => prev.map(p => p.id === paymentId ? {
|
||||
...p,
|
||||
@@ -110,7 +110,6 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
|
||||
} : p))
|
||||
setLinkingPayment(null)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -273,29 +272,29 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
<form onSubmit={handleLogCash} className="p-6 space-y-4">
|
||||
<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">Amount *</label>
|
||||
<input type="number" step="0.01" min="0" required value={cashForm.amount}
|
||||
<label htmlFor="cash-amount" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
|
||||
<input id="cash-amount" type="number" step="0.01" min="0" required value={cashForm.amount}
|
||||
onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))}
|
||||
placeholder="50.00"
|
||||
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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
|
||||
<input type="date" value={cashForm.date}
|
||||
<label htmlFor="cash-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
|
||||
<input id="cash-date" type="date" value={cashForm.date}
|
||||
onChange={e => setCashForm(f => ({ ...f, date: 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
|
||||
<input type="text" required value={cashForm.from_note}
|
||||
<label htmlFor="cash-from-note" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
|
||||
<input id="cash-from-note" type="text" required value={cashForm.from_note}
|
||||
onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))}
|
||||
placeholder="John Smith — March dues"
|
||||
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 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
|
||||
<select value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))}
|
||||
<label htmlFor="cash-member" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
|
||||
<select id="cash-member" value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: 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">
|
||||
<option value="">— Unlinked —</option>
|
||||
{members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
|
||||
import { toast } from 'sonner'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import type { Tables } from '@/types/database'
|
||||
import { PageTitle } from '@/components/ui/page-title'
|
||||
@@ -55,15 +56,15 @@ export function ProjectsClient({
|
||||
|
||||
async function handleStatusChange(projectId: string, newStatus: string) {
|
||||
const result = await updateProjectStatus(projectId, newStatus)
|
||||
if (!result.error) {
|
||||
if (result?.error) { toast.error(result.error); return }
|
||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(projectId: string) {
|
||||
if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
||||
const result = await deleteProject(projectId)
|
||||
if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId))
|
||||
if (result?.error) { toast.error(result.error); return }
|
||||
setProjects(prev => prev.filter(p => p.id !== projectId))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -89,7 +90,7 @@ export function ProjectsClient({
|
||||
<EmptyContent>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
||||
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> New Project
|
||||
</button>
|
||||
@@ -172,34 +173,34 @@ export function ProjectsClient({
|
||||
</div>
|
||||
<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>
|
||||
<input type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
<label htmlFor="project-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
|
||||
<input id="project-title" type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="CNC Controller Upgrade"
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
||||
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
<label htmlFor="project-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
||||
<textarea id="project-description" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||
</div>
|
||||
<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">Area</label>
|
||||
<select value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
||||
<label htmlFor="project-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
||||
<select id="project-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">
|
||||
<option value="">No area</option>
|
||||
{AREAS.map(a => <option key={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
||||
<input type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
|
||||
<label htmlFor="project-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
||||
<input id="project-due-date" type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
|
||||
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
<label htmlFor="project-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
|
||||
<input id="project-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
placeholder="electronics, hardware, cnc"
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
|
||||
@@ -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,7 +81,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
|
||||
async function handleClaim(taskId: string) {
|
||||
const result = await claimTask(taskId)
|
||||
if (!result.error) {
|
||||
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
|
||||
@@ -88,21 +89,18 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
: t
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(taskId: string) {
|
||||
const result = await completeTask(taskId)
|
||||
if (!result.error) {
|
||||
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) {
|
||||
if (result?.error) { toast.error(result.error); return }
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId))
|
||||
}
|
||||
}
|
||||
|
||||
const tabCounts = {
|
||||
chores: filtered(chores).length,
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -125,3 +125,18 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Keyboard focus visibility (WCAG 2.4.7). Many form controls and links use
|
||||
* `focus:outline-none` with only a 1px border color shift, which is not a
|
||||
* perceivable focus indicator. This rule is intentionally unlayered so it
|
||||
* wins over Tailwind's utilities layer without !important. Scoped to form
|
||||
* controls and links; buttons already get a focus-visible ring from
|
||||
* components/ui/button. :where() keeps specificity at 0 so a component can
|
||||
* still opt out with its own rule.
|
||||
*/
|
||||
:where(input, select, textarea, a[href]):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function OpsAclEditor({ entityType, entityId, options, initial }: Props)
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => toggle(o.value, !on)}
|
||||
className={`font-mono text-[10px] px-2 py-1 rounded border transition ${
|
||||
className={`flex items-center font-mono text-[10px] px-3 py-2 min-h-[44px] rounded border transition ${
|
||||
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user