Skip to content

Fix keyboard reordering by restoring focus on sortable wrapper#85474

Open
MobileMage wants to merge 9 commits intoExpensify:mainfrom
MobileMage:fix/83903-keyboard-reorder-single-tabstop-v2
Open

Fix keyboard reordering by restoring focus on sortable wrapper#85474
MobileMage wants to merge 9 commits intoExpensify:mainfrom
MobileMage:fix/83903-keyboard-reorder-single-tabstop-v2

Conversation

@MobileMage
Copy link
Copy Markdown
Contributor

@MobileMage MobileMage commented Mar 16, 2026

Explanation of Change

Removes the tabIndex={-1} override on the SortableItem wrapper that was preventing dnd-kit's KeyboardSensor from activating. Adds an onFocus handler to maintain WCAG single-tab-stop behavior by making nested interactive children non-tabbable and redirecting stray focus to the wrapper. Extends handleKeyDown to forward Enter to inner buttons since they are no longer directly tabbable.

Fixed Issues

$ #79247
PROPOSAL: #79247 (comment)

Tests

  1. Navigate to a workspace with Track Distance enabled (Settings > Workspaces > [workspace] > Distance > Track Distance)
  2. Open the Track Distance page so you see the list of waypoints (start, stop, etc.)
  3. Keyboard reordering: Tab to a waypoint row, press Space to grab it, use Arrow Up/Down to move it, press Space to drop it -- verify the item is reordered correctly
  4. Single tab stop (WCAG): Tab through the waypoint list and verify each row only receives one tab stop (the wrapper), not two (wrapper + inner button)
  5. Enter to navigate: Tab to a waypoint row and press Enter -- verify it opens the waypoint editor (address picker)
  6. Enter blocked during drag: Tab to a waypoint, press Space to grab, press Enter, then press Space to drop -- verify Enter does NOT navigate away while dragging
  7. Ghost drag cleanup: Start a keyboard drag (Space), navigate away (e.g. browser back), return to the page -- verify there is no lingering ghost drag state
  8. Mouse drag after keyboard: Do a keyboard reorder, then click-and-drag a different waypoint -- verify both input methods work independently
  9. Pointer drag still works: Click and drag a waypoint row to reorder -- verify pointer-based drag works
  10. Verify that no errors appear in the JS console
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline (disconnect network)
  2. Navigate to Track Distance page
  3. Attempt keyboard reordering (Space to grab, arrows to move, Space to drop)
  4. Verify the reorder UI works (optimistic update)
  5. Go back online and verify the change persists

QA Steps

  1. Navigate to a workspace > Distance > Track Distance
  2. Tab to a waypoint and press Space, then Arrow Down, then Space -- verify the item moved down
  3. Tab through waypoints and confirm only one tab stop per row
  4. Press Enter on a focused waypoint -- verify it opens the editor
  5. Space to grab, Enter (should be blocked), Space to drop -- verify no navigation during drag
  6. Click-and-drag a waypoint to reorder -- verify mouse drag still works
  7. Verify no console errors
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
Kapture.2026-03-27.at.22.38.02.mp4
iOS: Native
iOS: mWeb Safari
Kapture.2026-03-27.at.22.38.46.mp4
MacOS: Chrome / Safari
Kapture.2026-03-09.at.17.31.10.mp4

Removes the tabIndex={-1} override that prevented dnd-kit's KeyboardSensor
from activating. Adds an onFocus handler to maintain WCAG single tab stop
by redirecting stray focus and making nested elements non-tabbable. Extends
handleKeyDown to forward Enter to inner buttons.
@MobileMage MobileMage marked this pull request as ready for review March 16, 2026 21:53
@MobileMage MobileMage requested review from a team as code owners March 16, 2026 21:53
@melvin-bot melvin-bot bot requested review from eVoloshchak and heyjennahay and removed request for a team March 16, 2026 21:53
@melvin-bot
Copy link
Copy Markdown

melvin-bot bot commented Mar 16, 2026

@eVoloshchak Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot bot removed the request for review from a team March 16, 2026 21:53
onFocus={(e) => {
for (const element of e.currentTarget.querySelectorAll<HTMLElement>('button, [tabindex]:not([tabindex="-1"])')) {
element.tabIndex = -1;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-2 (docs)

The CSS selector string 'button, [tabindex]:not([tabindex="-1"])' is hardcoded inline. The file already establishes a pattern of extracting selectors to named constants (PRESSABLE_SELECTOR on line 7). This new selector should follow the same convention for consistency and readability.

Extract the selector to a named constant at the top of the file:

const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [tabindex]:not([tabindex="-1"])';

Then reference it in the onFocus handler:

for (const element of e.currentTarget.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR)) {

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 16, 2026

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
...ponents/FocusTrap/FocusTrapForScreen/index.web.tsx 0.00% <ø> (ø)
.../pages/iou/request/step/IOURequestStepDistance.tsx 59.52% <100.00%> (+0.19%) ⬆️
src/components/DraggableList/index.tsx 0.00% <0.00%> (ø)
...eyboardShortcut/bindHandlerToKeydownEvent/index.ts 0.00% <0.00%> (ø)
...ges/iou/request/step/IOURequestStepDistanceMap.tsx 0.00% <0.00%> (ø)
src/components/DraggableList/index.native.tsx 82.14% <81.48%> (-17.86%) ⬇️
src/components/DraggableList/SortableItem.tsx 0.00% <0.00%> (ø)
src/libs/cancelDndKeyboardDrag.ts 0.00% <0.00%> (ø)
... and 63 files with indirect coverage changes

@eVoloshchak
Copy link
Copy Markdown
Contributor

Bug:

  1. Press Space to grab a waypoint
  2. Use the tab to select the map
  3. Use arrows to manipulate the map
  4. Notice the items are reordered simultaneously with the map

Basically, at this point focused utem and the item controlled with arrow keys become detached. I suspect we need to unsubscribe from arrow navigation if the item is unfocused

Screen.Recording.2026-03-17.at.23.21.03.mov

Same issue on mWeb (please include recordings for all platforms)

Screen.Recording.2026-03-17.at.23.28.10.mov

cc: @MobileMage

@rushatgabhane
Copy link
Copy Markdown
Member

@MobileMage can you merge main and address feedback ^

@rushatgabhane
Copy link
Copy Markdown
Member

@MobileMage do you have an ETA for this PR

@MobileMage
Copy link
Copy Markdown
Contributor Author

Should de done today @eVoloshchak

@MobileMage
Copy link
Copy Markdown
Contributor Author

Kapture.2026-03-19.at.17.07.13.mp4

@eVoloshchak I've fixed it by adding onBlur to the sortable items, one catch is that you'd have to hit tab twice (first one cancels the current drag), is that acceptable?

The onBlur handler in SortableItem dispatches a synthetic Escape keydown
to cancel dnd-kit keyboard drags when focus leaves the wrapper. This
event was also caught by the app's global keyboard shortcut system
(react-native-key-command captures on document), triggering EscapeHandler
and closing the sidebar/modal.

Extract the synthetic dispatch into cancelDndKeyboardDrag with a
synchronous module-level flag. Guard bindHandlerToKeydownEvent (the
entry point for all KeyboardShortcut subscribers) so the entire shortcut
system is skipped during the synthetic dispatch.

Also handle Tab in onKeyDownCapture during an active drag so the drag is
cancelled before the browser moves focus, allowing both cancel and focus
move in a single Tab press.
@eVoloshchak
Copy link
Copy Markdown
Contributor

one catch is that you'd have to hit tab twice (first one cancels the current drag), is that acceptable?

I think yes, but I'd like to test how intuitive that is in practice. Could you push the changes please?

@MobileMage
Copy link
Copy Markdown
Contributor Author

Pushed @eVoloshchak

// Cancel drag on Tab but let default Tab behavior move focus naturally.
// This must happen in capture phase (before blur) so the drag ends
// before the browser moves focus, avoiding a render that eats the Tab.
if (isDragging && e.key === 'Tab') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (isDragging && e.key === 'Tab') {
if (isDragging && e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) {

@eVoloshchak
Copy link
Copy Markdown
Contributor

Bug:

  1. Press 'Space' to grab a waypoint
  2. Press 'Tab'
  3. Keep pressing 'Tab'

Expected result: as the last element is focused, pressing Tab will focus the first element again (back button at the top)
Actual result: focus becomes "out of bounds", browser UI if focused, after that, the backdrop elements receive focus

Screen.Recording.2026-03-20.at.13.23.49.mov

@rushatgabhane
Copy link
Copy Markdown
Member

@MobileMage bump ^^

The cancelDndKeyboardDrag helper dispatches a synthetic Escape event
that reaches focus-trap's bubble-phase listener, deactivating the trap
and breaking Tab cycling. Add escapeDeactivates guard so focus-trap
ignores the synthetic event while the dnd cancellation flag is set.

Also add CONST.KEYBOARD_SHORTCUTS.TAB and use it in SortableItem.
Comment thread src/CONST/index.ts Outdated
DEFAULT: {input: keyInputSpace},
},
},
TAB: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This isn't needed, TAB key already exists (line 963)

@eVoloshchak
Copy link
Copy Markdown
Contributor

Desktop web is looking good!

@MobileMage, I noticed the screenshots/videos section is missing videos for native and mWeb. Please test every update for the PR, running all of the test cases on all platforms that are in the checklist.
This works properly on mWeb

Screen.Recording.2026-03-25.at.16.31.17.mov

But the same scenario on native fails, you're navigated away after pressing Space

Screen.Recording.2026-03-25.at.16.35.16.mov

@MobileMage
Copy link
Copy Markdown
Contributor Author

Will prioritize this tomorrow

Copy link
Copy Markdown
Contributor

@heyjennahay heyjennahay left a comment

Choose a reason for hiding this comment

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

Product review not required

@MobileMage
Copy link
Copy Markdown
Contributor Author

Thanks!

@eVoloshchak I looked into this and the issue is that dnd-kit's KeyboardSensor only works on web. On native, we use react-native-draggable-flatlist which is touch-only, so when you hit Space it just falls through to the regular onPress and navigates away.

I'm thinking I could build a simple keyboard reorder mode for native separately from dnd-kit. Basically Space puts the item into a "moving" state, arrow keys swap its position in the list, and Space/Escape exits. No drag animations tho.

Also included videos for android and ios mweb testing.

Worth doing?

@eVoloshchak
Copy link
Copy Markdown
Contributor

I'm thinking I could build a simple keyboard reorder mode for native separately from dnd-kit. Basically Space puts the item into a "moving" state, arrow keys swap its position in the list, and Space/Escape exits. No drag animations tho.

Yeah, that seems fine to me, this is a pretty rare edge case

On native, dnd-kit's KeyboardSensor doesn't work because we use
react-native-draggable-flatlist which is touch-only. This adds a simple
keyboard reorder mode:
- Arrow keys navigate focus between items (via useArrowKeyFocusManager)
- Space enters moving mode for the focused item
- Arrow Up/Down swap the item position while in moving mode
- Space confirms the reorder, Escape cancels
- Touch drag cancels keyboard moving mode

Also passes isKeyboardMoving through renderItem params so consumers
can disable navigation (onPress) during keyboard reorder.
@eVoloshchak
Copy link
Copy Markdown
Contributor

@MobileMage, is this ready for review?

Screen.Recording.2026-04-07.at.08.41.29.mov

@MobileMage
Copy link
Copy Markdown
Contributor Author

Nope, this is proving really difficult. @eVoloshchak

@MobileMage
Copy link
Copy Markdown
Contributor Author

I dug into the native Space behavior I can't intercept it on iOS without native code @eVoloshchak

@eVoloshchak
Copy link
Copy Markdown
Contributor

@MobileMage, would using react-native-key-command work?

@MobileMage
Copy link
Copy Markdown
Contributor Author

Yeah, that's what I used when testing, when Full Keyboard Access is enabled, iOS consumes Space at the system level to trigger accessibility activation on the focused view (calling accessibilityActivate) before the event ever enters the responder chain. So UIKeyCommand never sees it (confirmed via logs where arrow keys reach JS fine but Space never does).

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.

4 participants