Scrum4Me/components/todos/todo-list.tsx
Janpeter Visser 04181e54cb
Merge pull request #87 from madhura68/feat/a11y-audit-fixes
fix(a11y): static accessibility fixes (v1-readiness #4 — code-side)
2026-05-04 14:08:58 +02:00

621 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
type PaginationState,
} from '@tanstack/react-table'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
createTodoAction,
updateTodoAction,
archiveSelectedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
} from '@/actions/todos'
interface Todo {
id: string
title: string
description: string | null
done: boolean
created_at: string
product_id: string | null
product_name: string | null
}
interface Pbi {
id: string
title: string
}
interface Product {
id: string
name: string
pbis: Pbi[]
}
interface TodoListProps {
todos: Todo[]
products: Product[]
isDemo: boolean
}
// Checkbox with indeterminate support for TanStack row selection
function IndeterminateCheckbox({
indeterminate,
className,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate ?? false
}, [indeterminate])
return (
<input
ref={ref}
type="checkbox"
className={cn('size-4 cursor-pointer accent-primary', className)}
{...props}
/>
)
}
function SaveButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" disabled={pending}>
{pending ? '…' : 'Opslaan'}
</Button>
)
}
// --- Promote to PBI dialog ---
function PromotePbiDialog({
todo,
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToPbiAction(_prev, fd)
if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() }
return result
},
undefined
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar PBI</h2>
<p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p>
<form action={formAction} className="space-y-3">
<input type="hidden" name="todoId" value={todo.id} />
<div className="space-y-1.5">
<label htmlFor="promote-pbi-title" className="text-sm font-medium">Titel</label>
<Input id="promote-pbi-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label htmlFor="promote-pbi-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-pbi-product"
name="productId"
required
defaultValue={todo.product_id ?? products[0]?.id}
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-pbi-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-pbi-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>
<option value="4">Laag</option>
</select>
</div>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
<Button type="submit" disabled={products.length === 0}>Promoveren</Button>
</div>
</form>
</div>
</div>
)
}
// --- Promote to Story dialog ---
function PromoteStoryDialog({
todo,
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [selectedProductId, setSelectedProductId] = useState(todo.product_id ?? products[0]?.id ?? '')
const selectedProduct = products.find(p => p.id === selectedProductId)
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToStoryAction(_prev, fd)
if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() }
return result
},
undefined
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar Story</h2>
<p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p>
<form action={formAction} className="space-y-3">
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="productId" value={selectedProductId} />
<div className="space-y-1.5">
<label htmlFor="promote-story-title" className="text-sm font-medium">Titel</label>
<Input id="promote-story-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-story-product"
value={selectedProductId}
onChange={e => setSelectedProductId(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-pbi" className="text-sm font-medium">PBI</label>
{!selectedProduct?.pbis.length ? (
<p className="text-sm text-muted-foreground">Maak eerst een PBI aan in dit product.</p>
) : (
<select id="promote-story-pbi" name="pbiId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
{selectedProduct.pbis.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-story-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>
<option value="4">Laag</option>
</select>
</div>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
<Button type="submit" disabled={!selectedProduct?.pbis.length}>Promoveren</Button>
</div>
</form>
</div>
</div>
)
}
// --- Detail card ---
function TodoCard({
mode,
activeTodo,
products,
isDemo,
defaultProductId,
onSuccess,
onPromotePbi,
onPromoteStory,
}: {
mode: 'idle' | 'create' | 'edit'
activeTodo: Todo | null
products: Product[]
isDemo: boolean
defaultProductId: string
onSuccess: () => void
onPromotePbi: (todo: Todo) => void
onPromoteStory: (todo: Todo) => void
}) {
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
useEffect(() => {
if (createState && 'success' in createState && createState.success) onSuccess()
}, [createState, onSuccess])
useEffect(() => {
if (editState && 'success' in editState && editState.success) onSuccess()
}, [editState, onSuccess])
if (mode === 'idle') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">Selecteer een rij of klik op + om te beginnen.</p>
</div>
)
}
if (mode === 'create') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Nieuwe todo</p>
<form action={createFormAction} className="space-y-3">
<div className="flex gap-3">
<select
name="productId"
defaultValue={defaultProductId}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
placeholder="Titel…"
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
<Textarea
name="description"
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
{typeof createState?.error === 'string' && (
<p className="text-xs text-error">{createState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// Edit mode
if (!activeTodo) return null
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Todo bewerken</p>
<form action={editFormAction} className="space-y-3">
<input type="hidden" name="id" value={activeTodo.id} />
<div className="flex gap-3">
<select
name="productId"
defaultValue={activeTodo.product_id ?? ''}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
defaultValue={activeTodo.title}
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
<Textarea
name="description"
defaultValue={activeTodo.description ?? ''}
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none">
<input
type="checkbox"
name="done"
defaultChecked={activeTodo.done}
disabled={isDemo}
className="size-4 accent-primary cursor-pointer"
/>
Afgerond
</label>
{typeof editState?.error === 'string' && (
<p className="text-xs text-error">{editState.error}</p>
)}
<div className="flex items-center gap-2">
{!isDemo && (
<>
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
PBI
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteStory(activeTodo)}>
Story
</Button>
</>
)}
<div className="flex-1" />
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// --- Main component ---
export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [isPending, startTransition] = useTransition()
const [selectedProductId, setSelectedProductId] = useState('all')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [mode, setMode] = useState<'idle' | 'create'>('idle')
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
const filtered = useMemo(() => {
if (selectedProductId === 'all') return todos
if (selectedProductId === '') return todos.filter(t => t.product_id === null)
return todos.filter(t => t.product_id === selectedProductId)
}, [todos, selectedProductId])
useEffect(() => {
setPagination(p => ({ ...p, pageIndex: 0 }))
setRowSelection({})
}, [selectedProductId])
const columns = useMemo<ColumnDef<Todo>[]>(() => [
{
id: 'select',
header: ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onChange={e => table.toggleAllPageRowsSelected(e.target.checked)}
/>
),
cell: ({ row }) => (
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={e => row.toggleSelected(e.target.checked)}
onClick={e => e.stopPropagation()}
/>
),
},
{
accessorKey: 'title',
header: 'Titel',
cell: ({ row }) => (
<p className={cn(
'line-clamp-2 text-sm leading-snug',
row.original.done && 'line-through text-muted-foreground'
)}>
{row.original.title}
</p>
),
},
{
accessorKey: 'product_name',
header: 'Product',
cell: ({ row }) => row.original.product_name ? (
<Badge variant="outline" className="text-xs font-normal">{row.original.product_name}</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'created_at',
header: 'Datum',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(row.original.created_at).toLocaleDateString('nl-NL')}
</span>
),
},
], [])
const table = useReactTable({
data: filtered,
columns,
state: { rowSelection, pagination },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
const selectedCount = selectedRows.length
const { pageIndex, pageSize } = table.getState().pagination
const totalRows = filtered.length
const start = totalRows === 0 ? 0 : pageIndex * pageSize + 1
const end = Math.min((pageIndex + 1) * pageSize, totalRows)
const activeTodo = todos.find(t => t.id === activeRowId) ?? null
const cardMode = mode === 'create' ? 'create' : activeTodo ? 'edit' : 'idle'
const defaultProductId = selectedProductId !== 'all' ? selectedProductId : ''
const handleCancel = useCallback(() => {
setActiveRowId(null)
setMode('idle')
}, [])
function handleRowClick(todo: Todo) {
setActiveRowId(prev => prev === todo.id ? null : todo.id)
setMode('idle')
}
function handleNew() {
setActiveRowId(null)
setRowSelection({})
setMode('create')
}
function handleBulkArchive() {
const ids = selectedRows.map(r => r.original.id)
startTransition(async () => {
const result = await archiveSelectedTodosAction(ids)
if (result?.error) {
toast.error(typeof result.error === 'string' ? result.error : 'Archiveren mislukt')
} else {
const n = ids.length
toast.success(`${n} todo${n === 1 ? '' : "'s"} gearchiveerd`)
setRowSelection({})
setActiveRowId(null)
setMode('idle')
}
})
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={selectedProductId}
onChange={e => setSelectedProductId(e.target.value)}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
<option value="all">Alles</option>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div className="flex-1" />
{selectedCount > 0 && !isDemo && (
<Button variant="outline" size="sm" onClick={handleBulkArchive} disabled={isPending}>
Archiveer geselecteerde ({selectedCount})
</Button>
)}
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={handleNew} disabled={isDemo}>+</Button>
</DemoTooltip>
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id} className="hover:bg-transparent">
{hg.headers.map(header => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className={cn(
'cursor-pointer hover:bg-surface-container-low',
activeRowId === row.original.id
? 'bg-primary/5'
: row.getIsSelected()
? 'bg-primary/10'
: ''
)}
onClick={() => handleRowClick(row.original)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center text-sm text-muted-foreground">
{todos.length === 0
? "Nog geen todo's. Gebruik + om er een aan te maken."
: "Geen todo's voor deze selectie."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalRows > pageSize && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{start}{end} van {totalRows}</span>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}></Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}></Button>
</div>
</div>
)}
{/* Detail card */}
<TodoCard
key={activeRowId ?? (mode === 'create' ? 'create' : 'idle')}
mode={cardMode}
activeTodo={activeTodo}
products={products}
isDemo={isDemo}
defaultProductId={defaultProductId}
onSuccess={handleCancel}
onPromotePbi={setPromotePbi}
onPromoteStory={setPromoteStory}
/>
{promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
)}
{promoteStory && (
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
)}
</div>
)
}