Skip to content

feat(ui): keyboard-accessible move-download-between-packages (#134)#137

Merged
mpiton merged 5 commits into
mainfrom
feat/issue-134-keyboard-move-download
May 1, 2026
Merged

feat(ui): keyboard-accessible move-download-between-packages (#134)#137
mpiton merged 5 commits into
mainfrom
feat/issue-134-keyboard-move-download

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented Apr 30, 2026

Summary

Closes #134.

Adds a keyboard-driven and screen-reader-friendly alternative to the pointer-only HTML5 drag-and-drop introduced in PR #133 for moving downloads between packages. The drag-and-drop path is unchanged.

  • PackageDownloadRow — new Move… button (toggles to Cancel move once a selection exists) + aria-grabbed on the row.
  • PackageRowMove selected here target action surfaces on every non-source package while a move is pending; the dropzone wrapper exposes aria-dropeffect="move".
  • PackagesView — shared moveDownloadBetweenPackages helper (factor of the existing dropDownload IPC sequence so rollback lives in one place), pendingMove state, and a visually-hidden role="status" aria-live="polite" live region (packages-move-live-region) for selected / started / success / cancelled / error announcements.
  • i18n — new EN + FR keys under packages.move.*.

Acceptance criteria

  • Keyboard users can initiate a "move download" action without a pointer device.
  • Appropriate ARIA attributes are applied during the move flow (aria-grabbed on source, aria-dropeffect on targets).
  • A live region announces move start, success, cancel, and error outcomes.
  • Existing pointer-based drag-and-drop continues to work unchanged (existing test still green).
  • New keyboard flow is covered by Vitest tests (5 new tests).

Test plan

  • npx vitest run src/views/PackagesView — 21/21 passing
  • npm run typecheck — clean
  • npm run lint — 0 warnings, 0 errors
  • Manual: tab to a download row, press the Move button, tab to a target package, activate "Move selected here" — verify focus, screen reader announcements, no regression on pointer drag.

Summary by CodeRabbit

  • New Features

    • Keyboard-driven workflow to move downloads between packages with screen-reader-friendly ARIA/button states, start/cancel/complete CTAs, and live-region announcements; pointer drag-and-drop remains unchanged
  • Localization

    • New English and French strings for move actions and live-region messages
  • Tests

    • Added interaction tests for keyboard initiation, ARIA/live-region wording, success/cancel/failure flows, and concurrency/race handling
  • Documentation

    • Changelog entry documenting the new move flow and behavior (toasts, rollback on failure)

Add a keyboard-driven and screen-reader-friendly alternative to the
pointer-only HTML5 drag-and-drop introduced for moving downloads
between packages in PR #133.

- PackageDownloadRow: new "Move…" / "Cancel move" toggle button +
  aria-grabbed on the row.
- PackageRow: "Move selected here" target action + aria-dropeffect
  on the dropzone (source package excluded).
- PackagesView: shared moveDownloadBetweenPackages helper, pendingMove
  state, and a visually-hidden role="status" aria-live="polite" live
  region for selected/started/success/cancelled/error announcements.
- Pointer drag-and-drop path unchanged.
- New i18n keys (EN + FR) under packages.move.*.
- Five new Vitest tests cover all four AC from #134.
@github-actions github-actions Bot added documentation Improvements or additions to documentation frontend labels Apr 30, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03301c38-081b-4e00-ba94-7ca3e5589a58

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a keyboard-driven "move downloads between packages" flow: selectable source rows, per-package "Move selected here" targets, aria-pressed toggle on source, a role="status" aria-live="polite" live region for announcements, a moveDownloadBetweenPackages helper with rollback and race-guarding, new EN/FR i18n keys, and Vitest coverage.

Changes

Cohort / File(s) Summary
Changelog & Locales
CHANGELOG.md, src/i18n/locales/en.json, src/i18n/locales/fr.json
Adds Unreleased changelog entry and new i18n keys for move actions, CTA labels, and live-region messages (EN + FR).
Package Download Row
src/views/PackagesView/PackageDownloadRow.tsx
Adds isPendingMove, onSelectForMove, onCancelMove props; renders Move/Cancel button with aria-pressed state and toggled label/icon; click routes to select or cancel handlers.
Package Row & Types
src/views/PackagesView/PackageRow.tsx
Adds exported PendingMove interface and extends PackageRowActions with selectForMove, cancelMove, executeMove; computes isMoveTarget and conditionally renders "Move here" CTA wired to executeMove.
Package Tree
src/views/PackagesView/PackageTree.tsx
Adds pendingMove prop (typed `PendingMove
Packages View Logic
src/views/PackagesView/PackagesView.tsx
Introduces moveDownloadBetweenPackages helper (remove → add with rollback), refactors dropDownload to use it, adds pendingMove + moveAnnouncement state, implements selectForMove/cancelMove/executeMove (with start/success/cancel/error transitions and an executeMove race guard), and renders a role="status" aria-live="polite" live region.
Tests
src/views/PackagesView/__tests__/PackagesView.test.tsx
Adds Vitest cases for keyboard move initiation, aria-pressed/target CTA visibility, live-region wording for selection/start/success/error/cancel, rollback on add failure, and stale-move concurrency handling.

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as PackageDownloadRow
    participant State as PackagesView<br/>(state)
    participant Tree as PackageRow<br/>(targets)
    participant IPC as IPC<br/>(remove/add)

    User->>UI: Activate Move button (keyboard)
    UI->>State: onSelectForMove(download, fromPackageId)
    State->>State: set pendingMove, set moveAnnouncement (selected)
    State->>Tree: provide pendingMove + actions
    Tree->>Tree: show Move-here on non-source packages

    User->>Tree: Activate Move-here
    Tree->>State: executeMove(toPackage)
    State->>State: update moveAnnouncement (started)
    State->>IPC: package_remove_download (source)
    IPC-->>State: remove OK
    State->>IPC: package_add_download (target)
    alt add succeeds
        IPC-->>State: add OK
        State->>State: update moveAnnouncement (success), clear pendingMove
    else add fails
        IPC-->>State: error
        State->>IPC: package_add_download (rollback to source)
        IPC-->>State: rollback OK
        State->>State: update moveAnnouncement (error), clear pendingMove
    end
    State->>Tree: refresh/invalidate packages
    State->>UI: live region announces result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

ui

Poem

🐰 I press the Move and tap my paw with care,

A little light shows targets waiting there.
The live region hums the start and then the end,
If add should fail, rollback mends the bend.
Hooray — I hopped your file across with flair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: introducing keyboard-accessible move-download-between-packages functionality.
Linked Issues check ✅ Passed All acceptance criteria from issue #134 are met: keyboard initiation via Move button, ARIA semantics, live region announcements, drag-and-drop unchanged, and Vitest test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the keyboard-accessible move interaction and supporting infrastructure; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-134-keyboard-move-download

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fea69fb654

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/views/PackagesView/PackagesView.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/views/PackagesView/PackagesView.tsx (1)

353-377: ⚡ Quick win

Align keyboard move feedback with pointer move toasts.

executeMove currently updates only the live region on success/error, while pointer drag-drop also shows visual toasts. Adding matching success/error toasts here would keep feedback consistent for non-screen-reader users.

♻️ Suggested patch
       try {
         const result = await moveDownloadBetweenPackages(
           move.downloadId,
           move.fromPackageId,
           toPackage.id,
         );
         if (!result.ok) {
           setMoveAnnouncement(
             t("packages.move.announce.error", { name: move.fileName }),
           );
           toast.error(t("packages.toast.moveDownloadRollbackError"));
           invalidatePackages();
           return;
         }
         setMoveAnnouncement(
           t("packages.move.announce.success", {
             name: move.fileName,
             package: toPackage.name,
           }),
         );
+        toast.success(t("packages.toast.moveDownloadSuccess"));
         invalidatePackages();
       } catch {
         setMoveAnnouncement(
           t("packages.move.announce.error", { name: move.fileName }),
         );
+        toast.error(t("packages.toast.moveDownloadError"));
       } finally {
         setPendingMove(null);
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/views/PackagesView/PackagesView.tsx` around lines 353 - 377, executeMove
updates the ARIA live region via setMoveAnnouncement but doesn't show the same
visual toasts that the pointer drag-drop path does; update executeMove (where
moveDownloadBetweenPackages is called) to call toast.success on successful move
(using the same i18n message used in setMoveAnnouncement for success) and
toast.error on failure (including in the catch block) so keyboard-initiated
moves show the same visual feedback; keep existing invalidatePackages(),
setMoveAnnouncement(...) and use move.fileName and toPackage.name as in the
current success path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/views/PackagesView/PackagesView.tsx`:
- Around line 353-377: executeMove updates the ARIA live region via
setMoveAnnouncement but doesn't show the same visual toasts that the pointer
drag-drop path does; update executeMove (where moveDownloadBetweenPackages is
called) to call toast.success on successful move (using the same i18n message
used in setMoveAnnouncement for success) and toast.error on failure (including
in the catch block) so keyboard-initiated moves show the same visual feedback;
keep existing invalidatePackages(), setMoveAnnouncement(...) and use
move.fileName and toPackage.name as in the current success path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 85143c7b-990a-419f-a38e-40c65baa94ea

📥 Commits

Reviewing files that changed from the base of the PR and between c894580 and fea69fb.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/i18n/locales/en.json
  • src/i18n/locales/fr.json
  • src/views/PackagesView/PackageDownloadRow.tsx
  • src/views/PackagesView/PackageRow.tsx
  • src/views/PackagesView/PackageTree.tsx
  • src/views/PackagesView/PackagesView.tsx
  • src/views/PackagesView/__tests__/PackagesView.test.tsx

Match the pointer drag-and-drop path: surface toast.success on
successful keyboard move and toast.error on rollback / catch so
sighted keyboard users see a visible notification instead of only
the sr-only live region. Live region announcements are kept as-is
for screen readers.
@mpiton
Copy link
Copy Markdown
Owner Author

mpiton commented Apr 30, 2026

@coderabbitai resolve

Align keyboard move feedback with pointer move toasts.
executeMove currently updates only the live region on success/error, while pointer drag-drop also shows visual toasts.

Fixed in 1b83303. executeMove now calls toast.success(t("packages.toast.moveDownloadSuccess")) on the success branch and toast.error(t("packages.toast.moveDownloadError")) in the catch (src/views/PackagesView/PackagesView.tsx:373,378); the rollback branch already raised toast.error(t("packages.toast.moveDownloadRollbackError")). Live region announcements untouched so screen-reader behaviour does not regress. PackagesView.test.tsx assertions added on the keyboard success and error cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Tip

For best results, initiate chat on the files or code changes.

@mpiton Thanks for the detailed confirmation! The fix looks solid — toast.success / toast.error on the success and catch branches of executeMove brings keyboard parity with the pointer drag-drop toasts, and keeping the live region announcements intact ensures screen-reader users aren't affected. The added test assertions on both keyboard success and error cases are a nice touch.

[resolve]


🧠 Learnings used
Learnt from: mpiton
Repo: mpiton/vortex PR: 133
File: src/views/PackagesView/PackageDownloadRow.tsx:16-24
Timestamp: 2026-04-30T08:30:56.541Z
Learning: In mpiton/vortex, the drag-and-drop move interaction in `src/views/PackagesView/PackageDownloadRow.tsx` is intentionally pointer-only for now. A keyboard alternative (select source row → "Move to package…" menu on target rows + ARIA semantics) is deferred as a follow-up task. Do not flag the missing keyboard drag alternative as a blocking issue in this file.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/views/PackagesView/PackagesView.tsx (1)

78-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Invalidate the download cache after moving a download.

invalidatePackages() only refreshes packageQueries.all(). The expanded usePackageDownloadsQuery(expandedId) result is left stale, so a moved row can still appear under the source package until the user refreshes or re-expands.

♻️ Proposed fix
 const invalidatePackages = useCallback(() => {
   queryClient.invalidateQueries({ queryKey: packageQueries.all() });
+  queryClient.invalidateQueries({ queryKey: downloadQueries.all() });
 }, [queryClient]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/views/PackagesView/PackagesView.tsx` around lines 78 - 80,
invalidatePackages currently only invalidates packageQueries.all(), leaving the
expanded usePackageDownloadsQuery(expandedId) stale after a move; update
invalidatePackages to also invalidate the downloads query so moved rows
disappear immediately — call queryClient.invalidateQueries for the
packageDownloads key (e.g., packageQueries.downloads(expandedId) when expandedId
is available, or the broader packageQueries.downloads() / non-exact prefix if
you want to clear all package download caches) in the same useCallback that
defines invalidatePackages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/views/PackagesView/__tests__/PackagesView.test.tsx`:
- Around line 523-545: The test currently makes package_add_download always
throw, which exercises rollback failure instead of the intended "target add
fails but rollback succeeds" path; update the mockInvoke implementation used in
this test so that package_add_download throws only for the target-add call and
returns successfully for the rollback add (e.g., use a counter or inspect the
call args to only throw on the first package_add_download call), ensuring the
moveDownloadBetweenPackages branch where rollback succeeds is exercised and the
assertion expecting an error announcement still holds.

---

Outside diff comments:
In `@src/views/PackagesView/PackagesView.tsx`:
- Around line 78-80: invalidatePackages currently only invalidates
packageQueries.all(), leaving the expanded usePackageDownloadsQuery(expandedId)
stale after a move; update invalidatePackages to also invalidate the downloads
query so moved rows disappear immediately — call queryClient.invalidateQueries
for the packageDownloads key (e.g., packageQueries.downloads(expandedId) when
expandedId is available, or the broader packageQueries.downloads() / non-exact
prefix if you want to clear all package download caches) in the same useCallback
that defines invalidatePackages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b85dc45f-e93b-45d6-b7cd-3b4a17c36c3a

📥 Commits

Reviewing files that changed from the base of the PR and between fea69fb and 1b83303.

📒 Files selected for processing (2)
  • src/views/PackagesView/PackagesView.tsx
  • src/views/PackagesView/__tests__/PackagesView.test.tsx

Comment thread src/views/PackagesView/__tests__/PackagesView.test.tsx Outdated
Per CodeRabbit nitpick: the previous mock made `package_add_download`
throw on every call, which exercised the rollback-failure branch of
`moveDownloadBetweenPackages` instead of the intended "target add fails,
rollback succeeds" path. Throw only when `packageId === "pkg-2"` so the
rollback `package_add_download` to `pkg-1` returns null, the inner
`throw addError` propagates, and `executeMove`'s outer catch surfaces
`packages.move.announce.error` + `toast.error(moveDownloadError)`. Two
new ordering assertions on `package_add_download` calls pin the
contract.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/views/PackagesView/PackagesView.tsx">

<violation number="1" location="src/views/PackagesView/PackagesView.tsx:341">
P2: `executeMove` clears `pendingMove` unconditionally after async work, which can erase a newer selection made while the move request is still in flight.</violation>
</file>

<file name="src/views/PackagesView/PackageRow.tsx">

<violation number="1" location="src/views/PackagesView/PackageRow.tsx:91">
P2: `aria-dropeffect` is deprecated ARIA and should not be used for new accessibility behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src/views/PackagesView/PackagesView.tsx
Comment thread src/views/PackagesView/PackageRow.tsx Outdated
Address two cubic-dev-ai findings:

1. `aria-grabbed` and `aria-dropeffect` are deprecated in WAI-ARIA 1.1.
   Replace `aria-grabbed` on the download row with `aria-pressed` on a
   single `PackageDownloadRow` toggle button (Move ↔ Cancel-move),
   which is the natural toggle-button semantic for a two-state action.
   Drop `aria-dropeffect` from the package dropzone wrapper: the
   `Move selected here` button already announces the affordance through
   its `aria-label`, and the `aria-live="polite"` region announces
   start/success/cancel/error transitions.

2. `executeMove` cleared `pendingMove` unconditionally in its `finally`
   block, which could erase a fresher selection made by the user while
   the move's async work was still in flight. Wrap the clear in a
   functional `setPendingMove` that only nulls when the current value
   still matches the snapshot taken at the start of `executeMove`.

A new vitest case pins the race guard via a deferred `package_remove_download`
mock; the existing aria-attribute assertions are migrated to the new
`aria-pressed` shape. CHANGELOG updated to reflect the modern ARIA
choice.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4e49a0f9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 322 to 323
toast.success(t("packages.toast.moveDownloadSuccess"));
invalidatePackages();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale pending move after pointer drop succeeds

If a user marks a download with Move… and then moves that same item via pointer drag-and-drop, the success path here never clears pendingMove. The UI keeps showing keyboard move targets based on the old source package, and the next executeMove can attempt package_remove_download from the stale source, producing a false error even though the drag move already succeeded. This cross-flow state leak is introduced by the new keyboard-move state and should be reset when a drag move completes.

Useful? React with 👍 / 👎.

cargo-audit started flagging wasmtime 43.0.1 on 2026-04-30 with
RUSTSEC-2026-0114 ("Panic when allocating a table exceeding the size
of the host's address space", medium severity, fixed in 43.0.2). The
patch bump is constrained by the wasi-common 43.0.1 → 43.0.2 pin, so
both crates and their internal helpers (wasmtime-environ, wiggle,
winch-codegen, every wasmtime-internal-* shim, etc.) move to 43.0.2
in lockstep. No source change required — the extism git dep at
mpiton/extism@f14e56d still resolves cleanly against the new patch.

cargo audit --file src-tauri/Cargo.lock: 0 vulnerabilities.
cargo clippy --workspace --all-targets -- -D warnings: clean.
cargo test --workspace: 1330 passing, 7 ignored.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a1efe653d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +79 to +80
const isMoveTarget =
pendingMove !== null && pendingMove.fromPackageId !== pkg.id;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate pending move source before offering targets

isMoveTarget only checks that pendingMove exists and the current row is not the source, so if the source package disappears (for example, the user selects a move and then deletes or filters out that package), every remaining package still shows Move selected here. Clicking it then runs executeMove with a stale fromPackageId, causing package_remove_download to fail and surfacing a misleading move error for an action the UI still advertised as valid. Clear or revalidate pendingMove when its source package is no longer present before rendering targets.

Useful? React with 👍 / 👎.

@mpiton mpiton merged commit 1c6c8a6 into main May 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation frontend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y: keyboard-accessible move-download-between-packages interaction

1 participant