Skip to content

🚸(frontend) improve thread navigation a11y and multiselect ux#708

Merged
jbpenrath merged 1 commit into
mainfrom
ux/multiselect-improvments
Jun 15, 2026
Merged

🚸(frontend) improve thread navigation a11y and multiselect ux#708
jbpenrath merged 1 commit into
mainfrom
ux/multiselect-improvments

Conversation

@jbpenrath

@jbpenrath jbpenrath commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Purpose

Declare the thread list as a listbox with multiselectable elements. Now when multiselect is enable, clicking on a thread add it to the current selection, it does not reset the selection. Furthermore, the keyboard navigation has been improved

Summary by CodeRabbit

  • New Features

    • Keyboard navigation for thread lists (ArrowUp/Down, Home/End), Shift+arrow range selection, and Space to toggle selection.
  • Improvements

    • Better keyboard focus management and accessibility for thread items; selection behavior refined; updated thread-related localization copy.
  • Style

    • Improved focus-visible styling for keyboard-only focus and checkbox layout stability.
  • Tests

    • Updated/added unit and end-to-end tests to reflect listbox role and new navigation behaviors.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements keyboard-accessible multi-select for the thread listbox by extracting pure selection logic, adding navigation utilities, refactoring the selection provider, updating thread items for option semantics, integrating a listbox focus hook, persisting focus across remounts, styling focus, updating locales, and adjusting E2E tests to use role="option".

Changes

Keyboard-Accessible Listbox Selection

Layer / File(s) Summary
Pure selection helpers and tests
src/frontend/src/features/providers/thread-selection-core.ts, src/frontend/src/features/providers/thread-selection-core.test.ts
computeToggle, resolveAnchorIndex, computeRange, and pruneSelection provide framework-free multi-select operations with comprehensive tests covering edge cases and immutability semantics.
Listbox navigation utilities
src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts, listbox-navigation.test.ts
Exports ListboxNavKey type, isListboxNavKey guard, and getNextFocusId function for keyboard navigation (Arrow/Home/End keys) with boundary clamping and fallback behavior; tests verify traversal and key filtering.
ThreadSelectionProvider refactor
src/frontend/src/features/providers/thread-selection.tsx
Provider exports toggleThread and selectRange actions instead of the prior toggleThreadSelection; uses anchorThreadIdRef to track selection anchor and delegates range/prune logic to selection-core helpers; updates clear/prune behavior and exposed API.
ThreadItem accessibility and wiring
src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx, _index.scss
Item adopts role="option" with aria-selected, roving tabindex via getItemProps, and click handlers routing to onFocusItem then onToggle/onSelectRange; styles add :focus-visible outline and preserve checkbox wrapper layout.
useThreadListbox hook and ThreadPanel integration
src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts, src/frontend/src/features/layouts/components/thread-panel/index.tsx
Hook manages roving tabindex, keyboard navigation (Arrow/Home/End, Space toggle, Shift+Arrow range expansion), focus restoration on remount, and consistency across list changes; ThreadPanel wires role="listbox", list-level handlers, and per-item props.
Focus persistence and route wiring
src/frontend/src/features/providers/thread-listbox-focus.tsx, src/frontend/src/routes/mailbox/$mailboxId/route.tsx
New context provider persists focusedThreadId across ThreadPanel remounts using React state and refs; route wraps outlet to maintain focus state during navigation.
Localization and styling
src/frontend/public/locales/common/en-US.json, common/fr-FR.json
Adds "Thread list" entry and adjusts select/deselect keys in English and French to match multi-select semantics.
E2E test updates
src/e2e/src/__tests__/*
Updates Playwright selectors across multiple e2e test files to locate thread items using getByRole("option") instead of getByRole("link"); scopes one mailbox switcher locator to the menu popover.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • suitenumerique/messages#447: Both PRs target the same thread-panel multi-selection feature—main PR refactors the selection mechanics into thread-selection-core/toggleThread+selectRange and wires them through ThreadPanel/ThreadItem.
  • suitenumerique/messages#621: Related through mention/unread badge flows and mailbox/thread event handling touched by this PR.
  • suitenumerique/messages#576: Overlaps on ThreadSelectionProvider/useThreadSelection refactors and selection-state surface area.

Suggested reviewers

  • sylvinus
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: improving thread navigation accessibility (a11y) and multiselect user experience (ux) through listbox role introduction and keyboard navigation enhancements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx`:
- Around line 79-83: The click handlers (e.g., handleCheckboxClick and the
option click handler around lines 90-95) only call onFocusItem to update
internal focus state but do not set actual DOM focus on the option anchor, which
breaks keyboard navigation in Safari; update both handlers to find the option's
anchor element (via the existing anchor ref or from the event.currentTarget) and
call .focus() on it for pointer clicks (after stopPropagation/preventDefault and
onFocusItem) so the DOM focus matches the internal focus state.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: a344d0ee-b477-433e-b560-4757c5393c75

📥 Commits

Reviewing files that changed from the base of the PR and between 5d12e61 and af3b2a7.

📒 Files selected for processing (11)
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/providers/thread-selection-core.test.ts
  • src/frontend/src/features/providers/thread-selection-core.ts
  • src/frontend/src/features/providers/thread-selection.tsx

@jbpenrath jbpenrath force-pushed the ux/multiselect-improvments branch from af3b2a7 to 0b06e89 Compare June 11, 2026 23:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts`:
- Around line 102-104: Change the focus/scroll sequence so native focus doesn't
auto-scroll: when moving focus via itemRefs in use-thread-listbox, call
node?.focus({ preventScroll: true }) and then call node?.scrollIntoView({ block:
'nearest' }) so scrollIntoView is the single source of scrolling; update
references to node?.focus() in that hook accordingly.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: a252230e-ed8c-4701-84ac-1ad6e725cf19

📥 Commits

Reviewing files that changed from the base of the PR and between af3b2a7 and 0b06e89.

📒 Files selected for processing (14)
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/providers/thread-listbox-focus.tsx
  • src/frontend/src/features/providers/thread-selection-core.test.ts
  • src/frontend/src/features/providers/thread-selection-core.ts
  • src/frontend/src/features/providers/thread-selection.tsx
  • src/frontend/src/routes/mailbox/$mailboxId/route.tsx
  • src/frontend/src/styles/globals.scss
💤 Files with no reviewable changes (1)
  • src/frontend/src/styles/globals.scss

@jbpenrath jbpenrath force-pushed the ux/multiselect-improvments branch from 0b06e89 to 03cdadd Compare June 12, 2026 00:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss`:
- Around line 89-93: The wrapper span with class thread-item__checkbox-wrapper
should not use display: contents because index.tsx sets aria-hidden="true" on it
and that flattens the accessibility boundary; change
.thread-item__checkbox-wrapper to use a real layout container (e.g., display:
flex or inline-flex) and update any CSS rules that currently target the nested
Checkbox element to target the wrapper instead so layout remains identical while
preserving the aria-hidden boundary; confirm index.tsx still applies
aria-hidden="true" to the same span and that role="option" is unaffected by the
Checkbox semantics.

In `@src/frontend/src/features/providers/thread-selection.tsx`:
- Around line 48-75: toggleThread currently sets anchorThreadIdRef.current =
threadId even when computeToggle removed the last selection, leaving a stale
anchor for subsequent selectRange calls; change toggleThread to capture the
result of computeToggle (e.g., newSelected), call
setSelectedThreadIds(newSelected), and if newSelected.size === 0 then clear
anchorThreadIdRef.current (set to undefined/null) and setIsSelectionMode(false),
otherwise set anchorThreadIdRef.current = threadId and setIsSelectionMode(true);
this ensures selectRange (which uses anchorThreadIdRef.current and computeRange)
won't pivot from a cleared anchor.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: b6ce9ab4-89ad-4ff0-8b85-a0687ca4ce54

📥 Commits

Reviewing files that changed from the base of the PR and between 0b06e89 and 03cdadd.

📒 Files selected for processing (20)
  • src/e2e/src/__tests__/attachment-preview.spec.ts
  • src/e2e/src/__tests__/message-import.spec.ts
  • src/e2e/src/__tests__/message-inline-image.spec.ts
  • src/e2e/src/__tests__/message-send.spec.ts
  • src/e2e/src/__tests__/outbox.spec.ts
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/e2e/src/__tests__/thread-starred-read.spec.ts
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/providers/thread-listbox-focus.tsx
  • src/frontend/src/features/providers/thread-selection-core.test.ts
  • src/frontend/src/features/providers/thread-selection-core.ts
  • src/frontend/src/features/providers/thread-selection.tsx
  • src/frontend/src/routes/mailbox/$mailboxId/route.tsx

Comment thread src/frontend/src/features/providers/thread-selection.tsx Outdated
Declare the thread list as a listbox with multiselectable elements.
Now when multiselect is enable, clicking on a thread add it to the
current selection, it does not reset the selection.
Furthermore, the keyboard navigation has been improved.
@jbpenrath jbpenrath force-pushed the ux/multiselect-improvments branch from 03cdadd to 3719014 Compare June 12, 2026 06:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/frontend/src/features/providers/thread-selection.tsx (1)

48-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not keep a deselected thread as the range anchor.

Line 50 always overwrites anchorThreadIdRef.current, and Lines 65-73 will reuse that anchor as long as any selection remains. If the user deselects the current anchor while other threads stay selected, the next Shift+Arrow / Shift+click expands from that deselected row and can re-add it unexpectedly.

Possible fix
 const toggleThread = useCallback((threadId: string) => {
-    setSelectedThreadIds((prev) => computeToggle(prev, threadId));
-    anchorThreadIdRef.current = threadId;
+    setSelectedThreadIds((prev) => {
+        const next = computeToggle(prev, threadId);
+
+        if (next.has(threadId)) {
+            anchorThreadIdRef.current = threadId;
+        } else if (anchorThreadIdRef.current === threadId) {
+            anchorThreadIdRef.current = null;
+        }
+
+        return next;
+    });
     setIsSelectionMode(true);
 }, []);

Also applies to: 65-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/providers/thread-selection.tsx` around lines 48 -
52, toggleThread currently always sets anchorThreadIdRef.current to the toggled
id even when that id becomes deselected, causing future range expansions to use
an anchor that is not selected; change toggleThread to compute the new selection
first (call computeToggle and store the result), call setSelectedThreadIds with
that result, then only set anchorThreadIdRef.current = threadId if the new
selection contains threadId (i.e., it was selected); if the new selection does
not contain threadId and anchorThreadIdRef.current === threadId, clear the
anchor (set to null/undefined); apply the same guard/clearing logic to the
range-selection logic referenced in the review (the block around lines 65-73) so
you never set or retain an anchor that is not currently selected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/frontend/src/features/providers/thread-selection.tsx`:
- Around line 48-52: toggleThread currently always sets
anchorThreadIdRef.current to the toggled id even when that id becomes
deselected, causing future range expansions to use an anchor that is not
selected; change toggleThread to compute the new selection first (call
computeToggle and store the result), call setSelectedThreadIds with that result,
then only set anchorThreadIdRef.current = threadId if the new selection contains
threadId (i.e., it was selected); if the new selection does not contain threadId
and anchorThreadIdRef.current === threadId, clear the anchor (set to
null/undefined); apply the same guard/clearing logic to the range-selection
logic referenced in the review (the block around lines 65-73) so you never set
or retain an anchor that is not currently selected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6dbf7327-ff43-4088-9030-5eecc334bca1

📥 Commits

Reviewing files that changed from the base of the PR and between 03cdadd and 3719014.

📒 Files selected for processing (21)
  • src/e2e/src/__tests__/attachment-preview.spec.ts
  • src/e2e/src/__tests__/mailbox-settings.spec.ts
  • src/e2e/src/__tests__/message-import.spec.ts
  • src/e2e/src/__tests__/message-inline-image.spec.ts
  • src/e2e/src/__tests__/message-send.spec.ts
  • src/e2e/src/__tests__/outbox.spec.ts
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/e2e/src/__tests__/thread-starred-read.spec.ts
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.ts
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/providers/thread-listbox-focus.tsx
  • src/frontend/src/features/providers/thread-selection-core.test.ts
  • src/frontend/src/features/providers/thread-selection-core.ts
  • src/frontend/src/features/providers/thread-selection.tsx
  • src/frontend/src/routes/mailbox/$mailboxId/route.tsx

@jbpenrath jbpenrath merged commit e0cb1f4 into main Jun 15, 2026
15 checks passed
@jbpenrath jbpenrath deleted the ux/multiselect-improvments branch June 15, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant