feat: accessibility pass - semantic HTML, ARIA, focus indicators, high contrast

This commit is contained in:
Your Name
2026-02-15 19:19:34 +02:00
parent e2ce484955
commit 4638ce046c
4 changed files with 64 additions and 12 deletions

View File

@@ -248,14 +248,49 @@ export function BoardView() {
const columnIds = board.columns.map((c) => c.id);
const [announcement, setAnnouncement] = useState("");
const handleDragEndWithAnnouncement = useCallback(
(event: DragEndEvent) => {
handleDragEnd(event);
const { active, over } = event;
if (over && board) {
const type = active.data.current?.type;
if (type === "card") {
const card = board.cards[active.id as string];
const targetCol = over.data.current?.type === "column"
? board.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
: findColumnByCardId(board, over.id as string);
if (card && targetCol) {
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
}
} else if (type === "column") {
const col = board.columns.find((c) => c.id === (active.id as string));
if (col) {
setAnnouncement(`Reordered column "${col.title}"`);
}
}
}
},
[handleDragEnd, board]
);
return (
<>
{/* Visually hidden live region for drag-and-drop announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragEnd={handleDragEndWithAnnouncement}
>
<SortableContext
items={columnIds}

View File

@@ -53,6 +53,8 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
transition={{ type: "spring", stiffness: 300, damping: 25 }}
{...attributes}
{...listeners}
role="article"
aria-label={card.title}
>
{/* Label dots */}
{card.labels.length > 0 && (

View File

@@ -60,11 +60,14 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
width,
};
const cardCount = column.cardIds.length;
return (
<motion.div
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
initial={prefersReducedMotion ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
@@ -81,21 +84,22 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
strategy={verticalListSortingStrategy}
>
<ScrollArea className="flex-1 overflow-y-auto">
<div ref={setDroppableNodeRef} className="flex min-h-[40px] flex-col gap-2 p-2">
<ul ref={setDroppableNodeRef} className="flex min-h-[40px] list-none flex-col gap-2 p-2">
{column.cardIds.map((cardId) => {
const card = board?.cards[cardId];
if (!card) return null;
return (
<CardThumbnail
key={cardId}
card={card}
boardLabels={board?.labels ?? []}
columnId={column.id}
onCardClick={onCardClick}
/>
<li key={cardId}>
<CardThumbnail
card={card}
boardLabels={board?.labels ?? []}
columnId={column.id}
onCardClick={onCardClick}
/>
</li>
);
})}
</div>
</ul>
</ScrollArea>
</SortableContext>
@@ -118,6 +122,6 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</Button>
</div>
)}
</motion.div>
</motion.section>
);
}