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 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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user