From 1fb9af0438359bdd6ea146edb496ac39a28a0136 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 30 May 2026 18:00:42 +0300 Subject: [PATCH] fix(Validator): validate on focus loss so tap-away highlights work (#1459) The 2015 reporter noted that when an invalid value was left in a TextField and the user moved focus by tapping into a different field (rather than the VKB 'next' / Enter button), the leaving field was not highlighted as invalid until the user returned to it. The action-listener path Validator.bindDataListener wired up only fires for VKB Enter / action, so tapping a different field skipped the validation entirely. The class did register a focus listener -- but only when showErrorMessageForFocusedComponent was set, and its focusLost handler was empty. Register an unconditional focus listener at the top of bindDataListener whose only job is to call validate(cmp) on focus loss. The conditional error-popup listener stays as it was; this new listener fires before it and runs regardless of the popup config so every binding path catches the tap-away case. Closes #1459. Adds maven/core-unittests/.../ValidatorFocusLossHighlightTest.java: - focusLossOnAnotherFieldFlagsTheLeftFieldAsInvalid: start with a valid value, clear it to an invalid value, requestFocus on the first field, then requestFocus on the second; the first must now report v.isValid(first) == false. Pre-fix this stayed true until the field regained focus. - focusLossDoesNotFalselyInvalidateAValidField: same sequence with valid content -- focus loss must not flip a valid field to invalid. Full Validator sweep (15 tests) stays green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../codename1/ui/validation/Validator.java | 20 ++++ .../ValidatorFocusLossHighlightTest.java | 93 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/validation/ValidatorFocusLossHighlightTest.java diff --git a/CodenameOne/src/com/codename1/ui/validation/Validator.java b/CodenameOne/src/com/codename1/ui/validation/Validator.java index d70f4fb510..b4c41a46c7 100644 --- a/CodenameOne/src/com/codename1/ui/validation/Validator.java +++ b/CodenameOne/src/com/codename1/ui/validation/Validator.java @@ -530,6 +530,22 @@ protected Object getComponentValue(Component cmp) { /// @deprecated this method was exposed by accident, constraint implicitly calls it and you don't need to /// call it directly. It will be made protected in a future update to Codename One! public void bindDataListener(Component cmp) { + // Re-validate on focus loss in every configuration so a user who + // leaves the field by tapping into another field gets the same + // highlight feedback as a user who hits the VKB 'next' / Enter key. + // The action-listener registration below only catches the VKB Enter + // path; without an explicit focus-lost validate(), the field stays + // un-highlighted after a tap-away. See #1459. + cmp.addFocusListener(new FocusListener() { + @Override + public void focusGained(Component focused) { + } + + @Override + public void focusLost(Component focused) { + validate(focused); + } + }); if (showErrorMessageForFocusedComponent) { if (!(cmp instanceof InputComponent && ((InputComponent) cmp).isOnTopMode())) { cmp.addFocusListener(new FocusListener() { @@ -582,6 +598,10 @@ public void scrollChanged(int scrollX, int scrollY, int oldscrollX, int oldscrol @Override public void focusLost(Component cmp) { + // Validation on focus-loss is handled by the + // unconditional focus listener registered at the top + // of bindDataListener (see #1459); this listener is + // only responsible for the focus-gained error popup. } }); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/validation/ValidatorFocusLossHighlightTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/validation/ValidatorFocusLossHighlightTest.java new file mode 100644 index 0000000000..2f19207260 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/validation/ValidatorFocusLossHighlightTest.java @@ -0,0 +1,93 @@ +package com.codename1.ui.validation; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Regression test for + * https://github.com/codenameone/CodenameOne/issues/1459 + * + * The 2015 reporter said that when a user typed invalid content into a + * validator-bound TextField and then moved focus by tapping into another + * field (rather than the VKB 'next' / Enter button), the previous field was + * not highlighted as invalid until the user returned to it. The + * action-listener path the Validator wires up only fires on VKB Enter / + * action; tapping a different field never went through that path. + * + * The fix registers an unconditional focus listener that re-validates on + * focus loss, so the highlight gets applied as soon as focus moves anywhere + * else. + */ +class ValidatorFocusLossHighlightTest extends UITestBase { + + /// A constraint that simply requires non-empty text. + private static class NonEmptyConstraint implements Constraint { + @Override + public boolean isValid(Object value) { + return value != null && value.toString().length() > 0; + } + + @Override + public String getDefaultFailMessage() { + return "must not be empty"; + } + } + + @FormTest + void focusLossOnAnotherFieldFlagsTheLeftFieldAsInvalid() { + Form f = Display.getInstance().getCurrent(); + f.setLayout(BoxLayout.y()); + TextField first = new TextField("good", "first"); + TextField second = new TextField("", "second"); + f.add(first).add(second); + f.revalidate(); + + Validator v = new Validator(); + v.addConstraint(first, new NonEmptyConstraint()); + + // Initially the field is valid (text is "good"). + assertTrue(v.isValid(first)); + + // Simulate the failure case: clear the field to an invalid value. + first.setText(""); + first.requestFocus(); + assertTrue(first.hasFocus()); + + // User taps into the second field. Before the fix the validator's + // focusLost handler was empty and the action-listener path only + // fired on VKB Enter, so validate(first) was never called and + // first stayed marked valid until it regained focus. + second.requestFocus(); + assertFalse(first.hasFocus()); + + assertFalse(v.isValid(first), + "First field should now be marked invalid because focus left " + + "it without correcting the value. See #1459."); + } + + @FormTest + void focusLossDoesNotFalselyInvalidateAValidField() { + Form f = Display.getInstance().getCurrent(); + f.setLayout(BoxLayout.y()); + TextField first = new TextField("", "first"); + TextField second = new TextField("", "second"); + f.add(first).add(second); + f.revalidate(); + + Validator v = new Validator(); + v.addConstraint(first, new NonEmptyConstraint()); + + first.setText("ok"); + first.requestFocus(); + second.requestFocus(); + + assertTrue(v.isValid(first), + "A field with valid content must stay valid after focus loss."); + } +}