Skip to content
5 changes: 3 additions & 2 deletions junit6/src/test/java/com/example/locator/LocatorDemoView.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public LocatorDemoView() {
save.setId("save");

Button clear = new Button("Clear", e -> name.setValue(""));
clear.setAriaLabel("Reset form");

Grid<Person> people = new Grid<>(Person.class);
people.setItems(List.of(new Person("Alice", 30), new Person("Bob", 25),
Expand All @@ -62,8 +63,8 @@ public LocatorDemoView() {
*/
public static class PersonForm extends Composite<VerticalLayout> {

public final TextField nameField = new TextField("Name");
public final TextField emailField = new TextField("Email");
public final TextField nameField = new TextField("Full name");
public final TextField emailField = new TextField("Email address");
public final Button submit;

public PersonForm(Span echo) {
Expand Down
170 changes: 170 additions & 0 deletions junit6/src/test/java/com/vaadin/browserless/ComponentQueryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.NativeLabel;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.textfield.TextFieldBase;
Expand Down Expand Up @@ -617,6 +620,173 @@ void withCaptionContaining_null_throws() {
() -> query.withCaptionContaining(null));
}

@Test
void withLabel_exactMatch_getsCorrectComponent() {
TestComponent labelled = new TestComponent();
labelled.getElement().setProperty("label", "Full name");

TestComponent other = new TestComponent();
other.getElement().setProperty("label", "Email");

TestComponent withLabelComponent = new TestComponent();
withLabelComponent.setId("random123");
NativeLabel labelComponent = new NativeLabel("Native");
labelComponent.setFor(withLabelComponent);

TestComponent noLabel = new TestComponent();

UI.getCurrent().getElement().appendChild(labelled.getElement(),
other.getElement(), noLabel.getElement(),
withLabelComponent.getElement(), labelComponent.getElement());

Assertions.assertSame(labelled,
find(TestComponent.class).withLabel("Full name").single());
Assertions.assertSame(other,
find(TestComponent.class).withLabel("Email").single());
// A separate <label for="random123"> targets withLabelComponent —
// withLabel resolves the relationship via the for attribute.
Assertions.assertSame(withLabelComponent,
find(TestComponent.class).withLabel("Native").single());
Assertions.assertTrue(
find(TestComponent.class).withLabel("Missing").all().isEmpty());
}

@Test
void withLabelContaining_substringMatch() {
TestComponent fullName = new TestComponent();
fullName.getElement().setProperty("label", "Full name");

TestComponent firstName = new TestComponent();
firstName.getElement().setProperty("label", "First name");

TestComponent noLabel = new TestComponent();
Comment thread
mstahv marked this conversation as resolved.

// Non-matching label property — must be excluded from the result.
TestComponent unrelatedLabel = new TestComponent();
unrelatedLabel.getElement().setProperty("label", "Address");

TextField textFieldWithLabel = new TextField("First name");

// Component labelled via a separate <label for="..."> — substring
// match must traverse the same path as exact match.
TestComponent viaForAttr = new TestComponent();
viaForAttr.setId("via-for");
NativeLabel forLabel = new NativeLabel("Display name");
forLabel.setFor(viaForAttr);

// Non-matching <label for="..."> — must be excluded from the result.
TestComponent viaForAttrUnrelated = new TestComponent();
viaForAttrUnrelated.setId("via-for-unrelated");
NativeLabel forLabelUnrelated = new NativeLabel("Country");
forLabelUnrelated.setFor(viaForAttrUnrelated);

UI.getCurrent().getElement().appendChild(fullName.getElement(),
firstName.getElement(), noLabel.getElement(),
unrelatedLabel.getElement(), textFieldWithLabel.getElement(),
viaForAttr.getElement(), forLabel.getElement(),
viaForAttrUnrelated.getElement(),
forLabelUnrelated.getElement());

Assertions.assertIterableEquals(
List.of(fullName, firstName, textFieldWithLabel, viaForAttr),
find(Component.class).withLabelContaining("name").all());
}

@Test
void withLabel_componentInsideDialog_isFound() {
TextField field = new TextField();
field.setId("dlg-field");
NativeLabel label = new NativeLabel("Inside dialog");
label.setFor(field);

Dialog dialog = new Dialog();
dialog.add(label, field);
dialog.open();

// The <label for="dlg-field"> sits inside the dialog overlay; the
// query must walk into the dialog content to resolve the for
// relationship.
Assertions.assertSame(field,
find(TextField.class).withLabel("Inside dialog").single());
}

@Test
void withLabel_componentInsideMasterDetailLayout_isFound() {
TextField field = new TextField();
field.setId("mdl-field");
NativeLabel label = new NativeLabel("MDL label");
label.setFor(field);

MasterDetailLayout layout = new MasterDetailLayout();
layout.setMaster(new com.vaadin.flow.component.html.Span("master"));
layout.setDetail(new Div(label, field));

UI.getCurrent().getElement().appendChild(layout.getElement());

// MasterDetailLayout attaches its detail content via virtual children;
// the walker has to traverse those to find the referring <label>.
Assertions.assertSame(field,
find(TextField.class).withLabel("MDL label").single());
}

@Test
void withAriaLabel_exactMatch_getsCorrectComponent() {
TestComponent reset = new TestComponent();
reset.getElement().setAttribute("aria-label", "Reset form");

TestComponent other = new TestComponent();
other.getElement().setAttribute("aria-label", "Submit form");

TestComponent noAria = new TestComponent();

UI.getCurrent().getElement().appendChild(reset.getElement(),
other.getElement(), noAria.getElement());

Assertions.assertSame(reset,
find(TestComponent.class).withAriaLabel("Reset form").single());
Assertions.assertSame(other, find(TestComponent.class)
.withAriaLabel("Submit form").single());
Assertions.assertTrue(find(TestComponent.class).withAriaLabel("Missing")
.all().isEmpty());
}

@Test
void withAriaLabelContaining_substringMatch() {
TestComponent reset = new TestComponent();
reset.getElement().setAttribute("aria-label", "Reset form");

TestComponent submit = new TestComponent();
submit.getElement().setAttribute("aria-label", "Submit form");

TestComponent noAria = new TestComponent();
Comment thread
mstahv marked this conversation as resolved.

// Non-matching aria-label — must be excluded from the result.
TestComponent unrelated = new TestComponent();
unrelated.getElement().setAttribute("aria-label", "Close dialog");

UI.getCurrent().getElement().appendChild(reset.getElement(),
submit.getElement(), noAria.getElement(),
unrelated.getElement());

Assertions.assertIterableEquals(List.of(reset, submit),
find(TestComponent.class).withAriaLabelContaining("form")
.all());
}

@Test
void withLabel_null_throws() {
ComponentQuery<TestComponent> query = find(TestComponent.class);
Assertions.assertThrows(IllegalArgumentException.class,
() -> query.withLabel(null));
}

@Test
void withAriaLabel_null_throws() {
ComponentQuery<TestComponent> query = find(TestComponent.class);
Assertions.assertThrows(IllegalArgumentException.class,
() -> query.withAriaLabel(null));
}

@Test
void withText_exactMatch_getsCorrectComponent() {
TextComponent span1 = new TextComponent("sample text");
Expand Down
43 changes: 43 additions & 0 deletions junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,49 @@ void customLocator_viaGetSupplier_composesBuiltins() {
}
}

@Test
void filterChain_withLabel_selectsField() {
try (var app = BrowserlessApplicationContext.create(routes())) {
var window = app.newUser().newWindow();
window.navigate(LocatorDemoView.class);

// Outer TextField has label "Name"; inner PersonForm has
// "Full name" / "Email address" — labels are unique at the top
// level so withLabel resolves to exactly one match.
window.findTextField().withLabel("Name").setValue("via label");
Assertions.assertEquals("via label", window.findTextField()
.withId("name").component().getValue());

// Substring match against the inner form's label.
window.findTextField().withLabelContaining("Full")
.setValue("inner");
Assertions.assertEquals("inner", window.findTextField()
.withId("pf-name").component().getValue());
}
}

@Test
void filterChain_withAriaLabel_selectsButton() {
try (var app = BrowserlessApplicationContext.create(routes())) {
var window = app.newUser().newWindow();
window.navigate(LocatorDemoView.class);

window.findTextField().withId("name").setValue("X");

// The Clear button identifies itself to screen readers via
// aria-label="Reset form".
window.findButton().withAriaLabel("Reset form").click();
Assertions.assertEquals("", window.findTextField().withId("name")
.component().getValue());

// Substring match against the same attribute.
window.findTextField().withId("name").setValue("X");
window.findButton().withAriaLabelContaining("Reset").click();
Assertions.assertEquals("", window.findTextField().withId("name")
.component().getValue());
}
}

@Test
void filterChain_expandedSurface() {
try (var app = BrowserlessApplicationContext.create(routes())) {
Expand Down
56 changes: 56 additions & 0 deletions shared/src/main/java/com/vaadin/browserless/ComponentQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,62 @@ public ComponentQuery<T> withCaptionContaining(String text) {
return this;
}

/**
* Requires the component's {@code label} property to be exactly the given
* value. Use this for form fields (TextField, ComboBox, etc.) where the end
* user identifies a field by its label.
*
* @param label
* the expected label, not {@literal null}
* @return this element query instance for chaining
* @see com.vaadin.flow.component.HasLabel#getLabel()
*/
public ComponentQuery<T> withLabel(String label) {
locatorSpec.predicates.add(ElementConditions.hasLabel(label));
Comment thread
mstahv marked this conversation as resolved.
return this;
}

/**
* Requires the component's {@code label} property to contain the given
* text.
*
* @param text
* substring to find in the label, not {@literal null}
* @return this element query instance for chaining
*/
public ComponentQuery<T> withLabelContaining(String text) {
locatorSpec.predicates.add(ElementConditions.labelContains(text));
Comment thread
mstahv marked this conversation as resolved.
return this;
}

/**
* Requires the component's {@code aria-label} attribute to be exactly the
* given value. Useful for components like {@code Button} that don't carry a
* visible label property but identify themselves to assistive technology
* via {@code aria-label}.
*
* @param ariaLabel
* the expected aria-label, not {@literal null}
* @return this element query instance for chaining
*/
public ComponentQuery<T> withAriaLabel(String ariaLabel) {
locatorSpec.predicates.add(ElementConditions.hasAriaLabel(ariaLabel));
Comment thread
mstahv marked this conversation as resolved.
return this;
}

/**
* Requires the component's {@code aria-label} attribute to contain the
* given text.
*
* @param text
* substring to find in the aria-label, not {@literal null}
* @return this element query instance for chaining
*/
public ComponentQuery<T> withAriaLabelContaining(String text) {
locatorSpec.predicates.add(ElementConditions.ariaLabelContains(text));
Comment thread
mstahv marked this conversation as resolved.
return this;
}

/**
* Requires the text content of the component to be equal to the given text
*
Expand Down
Loading
Loading