🚸(frontend) improve thread navigation a11y and multiselect ux#708
Conversation
📝 WalkthroughWalkthroughThis 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". ChangesKeyboard-Accessible Listbox Selection
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
src/frontend/public/locales/common/en-US.jsonsrc/frontend/public/locales/common/fr-FR.jsonsrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scsssrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsxsrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.tssrc/frontend/src/features/layouts/components/thread-panel/index.tsxsrc/frontend/src/features/providers/thread-selection-core.test.tssrc/frontend/src/features/providers/thread-selection-core.tssrc/frontend/src/features/providers/thread-selection.tsx
af3b2a7 to
0b06e89
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (14)
src/frontend/public/locales/common/en-US.jsonsrc/frontend/public/locales/common/fr-FR.jsonsrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scsssrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsxsrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.tssrc/frontend/src/features/layouts/components/thread-panel/index.tsxsrc/frontend/src/features/providers/thread-listbox-focus.tsxsrc/frontend/src/features/providers/thread-selection-core.test.tssrc/frontend/src/features/providers/thread-selection-core.tssrc/frontend/src/features/providers/thread-selection.tsxsrc/frontend/src/routes/mailbox/$mailboxId/route.tsxsrc/frontend/src/styles/globals.scss
💤 Files with no reviewable changes (1)
- src/frontend/src/styles/globals.scss
0b06e89 to
03cdadd
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (20)
src/e2e/src/__tests__/attachment-preview.spec.tssrc/e2e/src/__tests__/message-import.spec.tssrc/e2e/src/__tests__/message-inline-image.spec.tssrc/e2e/src/__tests__/message-send.spec.tssrc/e2e/src/__tests__/outbox.spec.tssrc/e2e/src/__tests__/thread-event.spec.tssrc/e2e/src/__tests__/thread-starred-read.spec.tssrc/frontend/public/locales/common/en-US.jsonsrc/frontend/public/locales/common/fr-FR.jsonsrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scsssrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsxsrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.tssrc/frontend/src/features/layouts/components/thread-panel/index.tsxsrc/frontend/src/features/providers/thread-listbox-focus.tsxsrc/frontend/src/features/providers/thread-selection-core.test.tssrc/frontend/src/features/providers/thread-selection-core.tssrc/frontend/src/features/providers/thread-selection.tsxsrc/frontend/src/routes/mailbox/$mailboxId/route.tsx
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.
03cdadd to
3719014
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/frontend/src/features/providers/thread-selection.tsx (1)
48-52:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDo 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
📒 Files selected for processing (21)
src/e2e/src/__tests__/attachment-preview.spec.tssrc/e2e/src/__tests__/mailbox-settings.spec.tssrc/e2e/src/__tests__/message-import.spec.tssrc/e2e/src/__tests__/message-inline-image.spec.tssrc/e2e/src/__tests__/message-send.spec.tssrc/e2e/src/__tests__/outbox.spec.tssrc/e2e/src/__tests__/thread-event.spec.tssrc/e2e/src/__tests__/thread-starred-read.spec.tssrc/frontend/public/locales/common/en-US.jsonsrc/frontend/public/locales/common/fr-FR.jsonsrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scsssrc/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsxsrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.test.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/listbox-navigation.tssrc/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-listbox.tssrc/frontend/src/features/layouts/components/thread-panel/index.tsxsrc/frontend/src/features/providers/thread-listbox-focus.tsxsrc/frontend/src/features/providers/thread-selection-core.test.tssrc/frontend/src/features/providers/thread-selection-core.tssrc/frontend/src/features/providers/thread-selection.tsxsrc/frontend/src/routes/mailbox/$mailboxId/route.tsx
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
Improvements
Style
Tests