feat: accessibility pass - semantic HTML, ARIA, focus indicators, high contrast
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 (
|
||||
<li key={cardId}>
|
||||
<CardThumbnail
|
||||
key={cardId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@
|
||||
@apply bg-background text-foreground;
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user