Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/components/TaskRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect, useRef, useState } from "react";
import {
ArrowUpDown,
CalendarDays,
Check,
Flag,
Expand All @@ -23,10 +25,13 @@ import {
taskPriorityOrder,
taskRecurrenceMeta,
taskRecurrenceOrder,
taskSectionMeta,
taskSectionOrder,
type TaskAssignee,
type TaskBucket,
type TaskPriority,
type TaskRecurrence,
type TaskSection,
} from "@/lib/tasks";
import type { Task } from "./TasksWidget";

Expand All @@ -51,6 +56,7 @@ type TaskRowProps = {
task: Task;
isBusy: boolean;
isEditing: boolean;
currentSection: TaskSection;
editState: {
title: string;
assignee: TaskAssignee;
Expand All @@ -66,22 +72,47 @@ type TaskRowProps = {
onSaveEdit: (taskId: string) => void;
onDelete: (taskId: string) => void;
onEditChange: (field: string, value: string | boolean) => void;
onMove: (task: Task, section: TaskSection) => void;
};

export default function TaskRow({
task,
isBusy,
isEditing,
currentSection,
editState,
onToggle,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
onEditChange,
onMove,
}: TaskRowProps) {
const dueInfo = getDueLabel(task.dueDate);
const resetLabel = task.completed ? formatResetLabel(task.nextResetAt) : null;
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!moveMenuOpen) return;
function handleClickOutside(e: MouseEvent) {
if (moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) {
setMoveMenuOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") setMoveMenuOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [moveMenuOpen]);
Comment on lines +97 to +113

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each open move menu instance attaches global document listeners. Since multiple task rows can potentially have menus open at once, this can lead to multiple global listeners and duplicated work. Consider centralizing the open-menu state (only one menu open at a time) and/or using a shared click-outside handler to keep listener count constant.

Copilot uses AI. Check for mistakes.

const moveTargets = taskSectionOrder.filter((s) => s !== currentSection);

if (isEditing) {
return (
Comment on lines +94 to 118

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moveMenuOpen state can survive transitions into/out of edit mode because isEditing short-circuits the render but doesn’t reset the state. If the menu was open before editing starts, it will reopen automatically when editing ends. Close the menu when isEditing becomes true (and/or when task.id changes).

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -208,6 +239,39 @@ export default function TaskRow({
</div>

<div className={styles.taskActions}>
<div className={styles.moveMenuWrapper} ref={moveMenuRef}>
<button
type="button"
className="btn-icon"
onClick={() => setMoveMenuOpen((v) => !v)}
disabled={isBusy}
aria-haspopup="true"
aria-expanded={moveMenuOpen}
aria-label="Move task to another section"
title="Move to…"
>
<ArrowUpDown size={14} />
</button>
{moveMenuOpen && (
<ul className={styles.moveMenu} role="menu">
{moveTargets.map((section) => (
<li key={section} role="none">
<button
type="button"
role="menuitem"
className={styles.moveMenuItem}
Comment on lines +243 to +262

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dropdown uses role="menu"/role="menuitem", but focus is not moved into the menu and items remain in the normal Tab order (no roving tabindex / arrow-key handling). This can cause screen readers to announce a menu while keyboard interaction behaves like standard buttons. Either implement the full ARIA menu keyboard pattern (focus management + arrow keys) or remove the menu roles and rely on native button/list semantics; also consider using aria-haspopup="menu" instead of "true" and associating the trigger with the menu via aria-controls/id.

Copilot uses AI. Check for mistakes.
onClick={() => {
setMoveMenuOpen(false);
onMove(task, section);
}}
>
{taskSectionMeta[section].label}
</button>
</li>
))}
</ul>
)}
</div>
<button type="button" className="btn-icon" onClick={() => onStartEdit(task)} disabled={isBusy}>
<Pencil size={14} />
</button>
Expand Down
43 changes: 42 additions & 1 deletion src/components/TasksWidget.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,51 @@
flex-shrink: 0;
}

.taskRow:hover .taskActions {
.taskRow:hover .taskActions,
.taskRow:focus-within .taskActions {

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the move menu is rendered inside .taskActions (which is hidden via opacity: 0 unless the row is hovered/focused), an open menu can become invisible while still being present in the DOM (e.g., if focus moves outside the row without closing the menu). Consider keeping actions/menu visible while the menu is open (e.g., an additional class) and/or closing the menu on focusout/blur so aria-expanded can’t remain true while the UI is visually hidden.

Suggested change
.taskRow:focus-within .taskActions {
.taskRow:focus-within .taskActions,
.taskRow:has(.moveMenu) .taskActions {

Copilot uses AI. Check for mistakes.
opacity: 1;
}

/* ── Move menu ── */

.moveMenuWrapper {
position: relative;
}

.moveMenu {
position: absolute;
right: 0;
top: 100%;
z-index: 20;
list-style: none;
margin: 4px 0 0;
padding: 4px 0;
min-width: 140px;
background: var(--surface-elevated, #2a2d35);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.moveMenuItem {
display: block;
width: 100%;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.82rem;
padding: 7px 14px;
text-align: left;
cursor: pointer;
transition: all 0.12s ease;
}

.moveMenuItem:hover,
.moveMenuItem:focus-visible {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
}

/* ── Edit card ── */

.editCard {
Expand Down
2 changes: 2 additions & 0 deletions src/components/TasksWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export default function TasksWidget({ initialTasks }: { initialTasks: Task[] })
task={task}
isBusy={loadingIds.has(task.id)}
isEditing={editingTaskId === task.id}
currentSection={section}
editState={{
title: editingTitle,
assignee: editingAssignee,
Expand All @@ -248,6 +249,7 @@ export default function TasksWidget({ initialTasks }: { initialTasks: Task[] })
onSaveEdit={handleUpdate}
onDelete={setDeleteTarget}
onEditChange={handleEditChange}
onMove={handleMove}
/>
))}
</ul>
Expand Down
Loading