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 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 ( return (
<> <>
{/* Visually hidden live region for drag-and-drop announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEndWithAnnouncement}
> >
<SortableContext <SortableContext
items={columnIds} items={columnIds}

View File

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

View File

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

View File

@@ -146,6 +146,17 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Satoshi", system-ui, -apple-system, sans-serif; font-family: "Satoshi", system-ui, -apple-system, sans-serif;
} }
:focus-visible {
outline: 2px solid var(--pylon-accent);
outline-offset: 2px;
}
}
@media (prefers-contrast: more) {
:root {
--pylon-text: oklch(10% 0.02 50);
--pylon-text-secondary: oklch(35% 0.01 50);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {