Skip to content

fix(TokenCompleteTextView): let the tab key move focus out of the field - #11413

Merged
rafaeltonholo merged 1 commit into
thunderbird:mainfrom
snowykte0426:fix/8231-tab-moves-focus-from-recipient-fields
Aug 20, 2026
Merged

fix(TokenCompleteTextView): let the tab key move focus out of the field#11413
rafaeltonholo merged 1 commit into
thunderbird:mainfrom
snowykte0426:fix/8231-tab-moves-focus-from-recipient-fields

Conversation

@snowykte0426

@snowykte0426 snowykte0426 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Contribution Summary

Linked Issue/Ticket: Fixes #8231
RFC / Technical Design (if applicable): n/a, behaviour agreed in this comment

Description

With a hardware keyboard the focus could not leave To, Cc or Bcc.

TokenCompleteTextView.onKeyDown consumed the tab key along with enter and dpad centre. ViewRootImpl only moves focus for a key that comes back unhandled, so consuming it trapped the focus. Shift+tab kept working, because hasNoModifiers() is false for it. That is the "only way out is backwards" from the first comment on the issue.

AutoCompleteTextView already gets tab right. It consumes the key while the completion list is open and leaves it alone otherwise, and TextView deliberately returns it unhandled so that focus can move. So tab is no longer touched in onKeyDown.

It is handled in onKeyUp instead. Left alone, AutoCompleteTextView completes on tab immediately, which picks a suggestion for the user as soon as there is more than one to pick from. Now: with the list closed the key falls through and the framework moves the focus on. With a single suggestion tab completes it and the focus stays in the field. With several, tab selects into the list, and you walk it with tab or the arrow keys and confirm with enter.

Enter and dpad centre behave as they did.

Screen Shots

Verified on an Android 16 emulator with a real tab key (adb shell input keyevent 61), and two contacts so the number of suggestions could be controlled. Before, tab did nothing at all. After, it walks To, the Cc/Bcc expander, Subject and then Message text; a single suggestion gets completed; and tab, tab, enter commits the second of two.

Tests are in TokenCompleteTextViewBehaviorTest, on the Robolectric harness that was already there. Five of them fail against the current code.

Two things worth knowing. Tab no longer hides the soft keyboard, which used to happen through handleDone(). With an external keyboard there is nothing to hide, and once tab lands on Subject you want the keyboard up anyway, so I left it that way; say the word if you would rather have the old behaviour back. The other is that the selected suggestion still is not highlighted. That turned out to be a separate problem in the drop down's styling, and #11416 fixes it.

AI Disclosure

Select one of the following (mandatory)

  • This contribution does not include any changes created or assisted by AI.
  • This contribution includes changes assisted by AI.
  • This contribution includes changes created by AI.

Contribution Checklist

  • I have read and affirm that my contribution adheres to Mozilla’s Community Participation Guidelines
  • This contribution is in Kotlin where possible
  • This contribution does not use merge commits
  • This contribution adheres to the existing codestyle (run gradlew spotlessCheck to check and gradlew spotlessApply to format your source code; will be checked by CI).
  • This contribution does not break existing unit tests (run gradlew testDebugUnitTest; will be checked by CI).
  • This contribution includes tests for any new functionality, and maintains tests for any updated functionality.
  • This contribution adheres to our Engineering process (RFC/Technical Design/ADR)
  • This PR has a descriptive title and body that accurately outlines all changes made, and contains a reference to any issues that it fixes (e.g. Closes #XXX or Fixes #XXX).

The Kotlin box is unticked because this changes the vendored token-auto-complete widget, which is Java, and its tests sit alongside it.

@snowykte0426

snowykte0426 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

The require-report-label check needs a maintainer to apply one of the report labels, since I don't have permission to set labels here. report: include looks right for a user-visible fix.

Everything else is waiting on workflow approval. Locally the module's tests, lint, spotless and detekt pass, along with :legacy:ui:legacy:testDebugUnitTest and :quality:konsist:test.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Validation Passed: All report and feature-flag labels are correctly set.

onKeyDown consumed the tab key together with enter and dpad centre, so the
event never reached the framework's focus navigation and the focus could not
leave a recipient field with a hardware keyboard. Shift+tab kept working
because hasNoModifiers() is false for it, which is why the field could only
be left backwards.

AutoCompleteTextView already handles tab the way it should: it consumes the
key while the completion list is open and leaves it alone otherwise, and
TextView returns it unhandled precisely so that focus can move. So stop
handling tab in onKeyDown and let the superclass decide.

That leaves AutoCompleteTextView's habit of completing immediately on tab,
which picks a suggestion for the user as soon as there are several. Handle
the key in onKeyUp instead: a single suggestion is completed and the focus
stays put, while several suggestions move the selection into the list so it
can be walked with tab or the arrow keys and confirmed with enter. The drop
down hides its selection, and ignores keys, until something is selected, so
selecting the first suggestion is what makes it navigable at all.

Enter and dpad centre keep the behaviour they had. The field shouldFocusNext
gated is renamed to completeOnKeyUp, since completing is what it actually
does, and it is now drained at the top of onKeyUp so that a tab key up
arriving while enter is still held cannot leave it armed to fire on a later,
unrelated key up.

Fixes thunderbird#8231
@snowykte0426
snowykte0426 force-pushed the fix/8231-tab-moves-focus-from-recipient-fields branch from 634166b to b95022c Compare August 18, 2026 06:19
@snowykte0426

snowykte0426 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two follow-ups to this branch.

The first is a real bug. onKeyUp returned early for tab while the completion list was open, before the block that drains completeOnKeyUp. So if a tab key up arrived while enter was still held, the flag stayed armed and went off later on some unrelated key up. The drain now happens at the top of onKeyUp, before tab is looked at, and the isPopupShowing() check moved into handleTabInCompletionList() so that it is tested after the drain instead of before it. There is a test that fails without the fix.

The second is a test for the real key sequence. The tab tests called onKeyDown or onKeyUp in isolation, which is not what a keypress does. On a device the down leg reaches mPopup.onKeyDown and can move the selection before onKeyUp runs, so if (getListSelection() == INVALID_POSITION) setListSelection(0) may already be dead by the first press. Two tests now dispatch down and then up.

I should be straight about those two. They pass against the unpatched widget as well, so they are guards, not proof. I could not get Robolectric to reproduce the device path that tells them apart, because the drop down there does not end up in the same state. The discriminating coverage is still the four original tab tests plus the new drain test, and the device check below.

I re-verified on an Android 16 emulator, since the reordering changes when super.onKeyUp runs relative to handleDone(). With no completion list open, tab moves focus from id/to to id/recipient_expander. With one suggestion, tab completes it, and a further tab carries on to Subject. With several, tab walks the selection down the list without completing, and enter commits the selected one.

@rafaeltonholo

Copy link
Copy Markdown
Member

@snowykte0426, could you please create a more concise summary? There is too much information there that makes it hard to follow.

@snowykte0426

snowykte0426 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Fair, it had grown well past useful. Rewritten to the problem, the three tab behaviours in a table, and how it was verified. The framework archaeology is gone, and the drop-down highlight discussion has moved to #11416 where it belongs. I've trimmed #11410 and #11416 the same way.

@rafaeltonholo rafaeltonholo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@rafaeltonholo
rafaeltonholo merged commit a59bd67 into thunderbird:main Aug 20, 2026
19 of 20 checks passed
@thunderbird-botmobile

Copy link
Copy Markdown
Contributor

Thanks for your contribution! Your pull request has been merged and will be part of Thunderbird 24. We appreciate the time and effort you put into improving Thunderbird. If you haven’t already, you’re welcome to join our Matrix chat for contributors. It’s where we discuss development and help each other out. https://matrix.to/#/#tb-android-dev:mozilla.org
Hope to see you there! 🚀📱🐦

@thunderbird-botmobile thunderbird-botmobile Bot added this to the Thunderbird 24 milestone Aug 20, 2026
snowykte0426 added a commit to snowykte0426/thunderbird-android that referenced this pull request Aug 20, 2026
…ected

Moving through the recipient completion list with a keyboard left no trace on
screen, so there was no way to tell which suggestion was about to be picked.

The list selector cannot do this reliably. `setListSelection()` reaches
`ListView.layoutChildren`, which positions the selector with
`positionSelector(INVALID_POSITION, sel)`; `AbsListView.positionSelector` then
computes `positionChanged = position != mSelectorPosition` and skips the block
that refreshes the selector's drawable state, because both sides are -1. The
selector is therefore painted with an empty state set on the first selection.
Its state is the list's own state as well, and `DropDownListView` reports
itself as focused for as long as the popup exists, so a stateful selector
cannot tell a selected row from a cleared one either.

The row does not have that problem: `ListView.setupChild` calls
`child.setSelected(selected && shouldShowSelector())`, which is correct from
the first selection onwards. So give the row a background that draws the
selection, and leave the list selector alone so touch feedback is unchanged.

`colorSecondaryContainer`, the Material token for a selected list item, is not
usable here: the rows take their text colour from
`textColorPrimaryRecipientDropdown`, which is `@android:color/primary_text_light`
and pinned to black in the light theme, and that only reaches 3.3:1 on
`lightSecondaryContainer`. `colorSurfaceContainerHighest` keeps the text at
16:1 but is itself only 1.22:1 against the popup background, so the selected
row also gets a `colorPrimary` outline: 7.7:1 in the light theme and 14.1:1 in
the dark one, which meets WCAG 1.4.11 for a state indicator.

This has no visible effect on its own. The completion list ignores the arrow
keys until something is selected, so thunderbird#11413, which makes the tab key select the
first suggestion, is what reaches this code path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

report: include Include changes in user-facing reports.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

External keyboard gets stuck on the address fields on Compose screen

2 participants