Skip to content

feat: Add blur simulation and implicit focus tracking (#164) (CP: 25.3) - #287

Merged
mcollovati merged 2 commits into
25.3from
cherry-pick-164-to-25.3
Oct 14, 2026
Merged

mcollovati merged 2 commits into
25.3from
cherry-pick-164-to-25.3

Conversation

@mcollovati

Copy link
Copy Markdown
Contributor

This PR cherry-picks changes from the original PR #164 to branch 25.3.

Original PR description

Summary

Browserless tests now track which component has keyboard focus, so focus and blur listeners fire on their own when a tester clicks a component or sets its value. ComponentTester also gets explicit focus(), blur() and isFocused() methods, and server-side Focusable.focus() / blur() calls are simulated too.

Fixes #163

What changed

Behavior change: interactions made through testers now fire focus and blur events. This affects all users, but only tests whose components have focus or blur listeners:

  • click() (the shared Clickable implementation) and setValueAsUser(...) first move focus to the component, which blurs the previously focused one. Blur runs before the click is handled, like in a browser.
  • Existing tests that count events, or that expect no focus/blur, may see extra events now.
  • If the application has a Focusable.focus() or blur() call pending, the simulation reads the pending JavaScript queue, which consumes it — any other JavaScript queued at the same time is dropped. When no focus or blur call is pending the queue is left untouched, so JavaScript scheduled by the application can still be asserted on. Everything consumed is listed in a debug log under com.vaadin.browserless.FocusTracker. Handling the queue centrally is tracked in #221.

New behaviour:

  • New package-private FocusTracker keeps the focused component per UI. It fires focus / blur DOM events as if they came from the client, and mirrors Flow's markers so that a server-initiated focus or blur reports isFromClient() == false.
  • Server-side Focusable.focus() / blur() calls are applied at the end of a focus-tracked interaction, on roundTrip(), and when isFocused() is called.
  • focus() and blur() fail fast: IllegalArgumentException if the component is not Focusable, IllegalStateException if it is disabled or not attached. Read-only fields can still be focused.
  • Only Focusable components take focus. Interacting with anything else blurs the previous component and leaves nothing focused, like focus falling back to the document body.
  • Focus is per UI, so it does not survive a page reload, not even in a @PreserveOnRefresh view.
  • Testers that fire their events directly (radio button, menu bar, context menu item clicks) do not take part in focus tracking. This is documented in the README together with the other limits.

Use case

A form focuses the next field from a value change listener, and a "save" button validates the amount in a blur listener. The developer wants a test that proves both happen in the right order, without any browser.

TextField amount = new TextField("Amount");
TextField note = new TextField("Note");
amount.addValueChangeListener(e -> note.focus()); // application logic
amount.addBlurListener(e -> validate(amount.getValue()));

// test
test(amount).setValue("100");      // focuses the field, then fires the value change
assertTrue(test(note).isFocused()); // server-side focus() was applied

test(save).click();                 // blurs "note" first, then handles the click
assertFalse(test(note).isFocused());

// explicit control when nothing else is interacted with afterwards
test(amount).focus();
test(amount).blur();

API Changes

com.vaadin.browserless.ComponentTester

// Added
public void focus() // moves simulated keyboard focus to the wrapped component
public void blur() // fires a blur event; no-op if the component is not focused
public boolean isFocused() // true if the component has focus, server-side focus() included

Test summary

Implicit focus on interactions:

  • Field focused when a value is set through the tester
  • Previously focused field blurred by a click or by setting another field
  • Blur listener runs before the click listener
  • No blur or re-focus on consecutive edits of the same field
  • Click on a non-focusable component blurs the field and focuses nothing

Explicit focus API:

  • Blur through the tester fires the blur event as from the client
  • Blur on a non-focused component is a no-op
  • focus() / blur() fail with a readable message when the component is not Focusable
  • Read-only field focusable, disabled or detached field fails

Server-side Focusable calls:

  • focus() from a value change listener moves focus and blurs the previous field, not from client
  • focus(FocusOption...) detected as well
  • blur() fires the blur event and clears focus, not from client
  • Focus scheduled outside an interaction applied on roundTrip()
  • Field focused in a dialog opened by a click listener

Page reload:

  • Focus cleared after a reload, also in a @PreserveOnRefresh view

This PR cherry-picks changes from the original PR #164 to branch 25.3.
---
#### Original PR description
> ## Summary
> Browserless tests now track which component has keyboard focus, so focus and blur listeners fire on their own when a tester clicks a component or sets its value. `ComponentTester` also gets explicit `focus()`, `blur()` and `isFocused()` methods, and server-side `Focusable.focus()` / `blur()` calls are simulated too.
>
> Fixes #163
>
> ## What changed
>
> **Behavior change:** interactions made through testers now fire focus and blur events. This affects all users, but only tests whose components have focus or blur listeners:
>
> - `click()` (the shared `Clickable` implementation) and `setValueAsUser(...)` first move focus to the component, which blurs the previously focused one. Blur runs before the click is handled, like in a browser.
> - Existing tests that count events, or that expect no focus/blur, may see extra events now.
> - If the application has a `Focusable.focus()` or `blur()` call pending, the simulation reads the pending JavaScript queue, which consumes it — any other JavaScript queued at the same time is dropped. When no focus or blur call is pending the queue is left untouched, so JavaScript scheduled by the application can still be asserted on. Everything consumed is listed in a debug log under `com.vaadin.browserless.FocusTracker`. Handling the queue centrally is tracked in [#221](#221).
>
> New behaviour:
>
> - New package-private `FocusTracker` keeps the focused component per UI. It fires `focus` / `blur` DOM events as if they came from the client, and mirrors Flow's markers so that a server-initiated focus or blur reports `isFromClient() == false`.
> - Server-side `Focusable.focus()` / `blur()` calls are applied at the end of a focus-tracked interaction, on `roundTrip()`, and when `isFocused()` is called.
> - `focus()` and `blur()` fail fast: `IllegalArgumentException` if the component is not `Focusable`, `IllegalStateException` if it is disabled or not attached. Read-only fields can still be focused.
> - Only `Focusable` components take focus. Interacting with anything else blurs the previous component and leaves nothing focused, like focus falling back to the document body.
> - Focus is per UI, so it does not survive a page reload, not even in a `@PreserveOnRefresh` view.
> - Testers that fire their events directly (radio button, menu bar, context menu item clicks) do not take part in focus tracking. This is documented in the README together with the other limits.
>
> ## Use case
> A form focuses the next field from a value change listener, and a "save" button validates the amount in a blur listener. The developer wants a test that proves both happen in the right order, without any browser.
>
> ```java
> TextField amount = new TextField("Amount");
> TextField note = new TextField("Note");
> amount.addValueChangeListener(e -> note.focus()); // application logic
> amount.addBlurListener(e -> validate(amount.getValue()));
>
> // test
> test(amount).setValue("100");      // focuses the field, then fires the value change
> assertTrue(test(note).isFocused()); // server-side focus() was applied
>
> test(save).click();                 // blurs "note" first, then handles the click
> assertFalse(test(note).isFocused());
>
> // explicit control when nothing else is interacted with afterwards
> test(amount).focus();
> test(amount).blur();
> ```
>
> ## API Changes
>
> ### com.vaadin.browserless.ComponentTester
>
> ```java
> // Added
> public void focus() // moves simulated keyboard focus to the wrapped component
> public void blur() // fires a blur event; no-op if the component is not focused
> public boolean isFocused() // true if the component has focus, server-side focus() included
> ```
>
> ## Test summary
> **Implicit focus on interactions:**
> - Field focused when a value is set through the tester
> - Previously focused field blurred by a click or by setting another field
> - Blur listener runs before the click listener
> - No blur or re-focus on consecutive edits of the same field
> - Click on a non-focusable component blurs the field and focuses nothing
>
> **Explicit focus API:**
> - Blur through the tester fires the blur event as from the client
> - Blur on a non-focused component is a no-op
> - `focus()` / `blur()` fail with a readable message when the component is not `Focusable`
> - Read-only field focusable, disabled or detached field fails
>
> **Server-side Focusable calls:**
> - `focus()` from a value change listener moves focus and blurs the previous field, not from client
> - `focus(FocusOption...)` detected as well
> - `blur()` fires the blur event and clears focus, not from client
> - Focus scheduled outside an interaction applied on `roundTrip()`
> - Field focused in a dialog opened by a click listener
>
> **Page reload:**
> - Focus cleared after a reload, also in a `@PreserveOnRefresh` view

(cherry picked from commit 33e025f)
@mcollovati
mcollovati enabled auto-merge (squash) October 13, 2026 13:01
@mcollovati
mcollovati merged commit e4a1cb8 into 25.3 Oct 14, 2026
6 checks passed
@mcollovati
mcollovati deleted the cherry-pick-164-to-25.3 branch October 14, 2026 03:54
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.

3 participants