-
Notifications
You must be signed in to change notification settings - Fork 0
Add 'Move to' menu as keyboard alternative to drag-and-drop #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -51,6 +56,7 @@ type TaskRowProps = { | |
| task: Task; | ||
| isBusy: boolean; | ||
| isEditing: boolean; | ||
| currentSection: TaskSection; | ||
| editState: { | ||
| title: string; | ||
| assignee: TaskAssignee; | ||
|
|
@@ -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]); | ||
|
|
||
| const moveTargets = taskSectionOrder.filter((s) => s !== currentSection); | ||
|
|
||
| if (isEditing) { | ||
| return ( | ||
|
Comment on lines
+94
to
118
|
||
|
|
@@ -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
|
||
| 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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -247,10 +247,51 @@ | |||||||
| flex-shrink: 0; | ||||||||
| } | ||||||||
|
|
||||||||
| .taskRow:hover .taskActions { | ||||||||
| .taskRow:hover .taskActions, | ||||||||
| .taskRow:focus-within .taskActions { | ||||||||
|
||||||||
| .taskRow:focus-within .taskActions { | |
| .taskRow:focus-within .taskActions, | |
| .taskRow:has(.moveMenu) .taskActions { |
There was a problem hiding this comment.
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
documentlisteners. 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.