Conversation
Each task row now has a move button (ArrowUpDown icon) that opens a dropdown menu listing all sections except the current one. The menu is fully keyboard accessible: Tab to the button, Enter/Space to open, Tab through menu items, Enter to select, Escape to dismiss. Also adds :focus-within to taskActions so buttons are visible when any child receives keyboard focus (not just on hover). Closes IRL-25
There was a problem hiding this comment.
Pull request overview
Adds a keyboard-accessible “Move to…” menu to each task row in TasksWidget, providing a non-drag-and-drop way to move tasks between sections.
Changes:
- Passes
currentSectionand anonMovecallback fromTasksWidgetinto eachTaskRow. - Reveals task action buttons on keyboard focus (
:focus-within) in addition to hover. - Introduces a per-row “Move to…” dropdown menu UI with section targets and click-outside/Escape dismissal.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/components/TasksWidget.tsx | Wires currentSection + onMove into TaskRow to support keyboard-driven moves. |
| src/components/TasksWidget.module.css | Makes actions visible on focus and adds styling for the new move menu. |
| src/components/TaskRow.tsx | Implements the “Move to…” button + dropdown menu and hooks it up to onMove. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| .taskRow:hover .taskActions { | ||
| .taskRow:hover .taskActions, | ||
| .taskRow:focus-within .taskActions { |
There was a problem hiding this comment.
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.
| .taskRow:focus-within .taskActions { | |
| .taskRow:focus-within .taskActions, | |
| .taskRow:has(.moveMenu) .taskActions { |
| 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]); |
There was a problem hiding this comment.
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.
| 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 ( |
There was a problem hiding this comment.
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).
| <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} |
There was a problem hiding this comment.
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.
Assignee: @alecvdp (alecvdpoel)
Summary
Adds a keyboard/non-pointer alternative to drag-and-drop for moving tasks between sections in the TasksWidget.
:focus-within) in addition to hover, ensuring keyboard-only users can discover and use themaria-haspopup,aria-expanded,role="menu",role="menuitem") for screen reader compatibilityTesting
Closes IRL-25