Skip to main content

Locator Patterns

Drama Finder uses sophisticated locator patterns to handle Vaadin’s shadow DOM structure and component composition. Understanding these patterns helps you work with complex components and create custom element wrappers.

Locator Delegation Pattern

Elements delegate to specific internal locators for different operations. This pattern ensures actions and assertions target the correct DOM element within a component’s shadow DOM.

Why Delegation Matters

Vaadin components often have a wrapper element and internal input elements. Different operations need to target different parts:
// Component root (vaadin-text-field)
getLocator().getAttribute("disabled");

// Internal input element
getInputLocator().getAttribute("value");

Focus Locator

The focus locator determines which element receives keyboard focus:
public interface FocusableElement extends HasLocatorElement {
    default Locator getFocusLocator() {
        return getLocator(); // Default: component root
    }

    default void focus() {
        getFocusLocator().focus();
    }
}
Input fields override this to focus the internal input:
@Override
public Locator getFocusLocator() {
    return getInputLocator(); // Focus goes to input, not wrapper
}

Enabled Locator

The enabled locator determines where to check the disabled state:
@Override
public Locator getEnabledLocator() {
    return getInputLocator(); // Disabled state is on input
}

public void assertDisabled() {
    assertThat(getEnabledLocator()).isDisabled();
}

ARIA Label Locator

The ARIA label locator specifies which element has the aria-label attribute:
@Override
public Locator getAriaLabelLocator() {
    return getInputLocator(); // aria-label is on input
}

public void assertAriaLabel(String expected) {
    assertThat(getAriaLabelLocator()).hasAttribute("aria-label", expected);
}

Complete Example

public class TextFieldElement extends VaadinElement
        implements FocusableElement, HasAriaLabelElement, HasEnabledElement {

    // Delegate focus operations to input
    @Override
    public Locator getFocusLocator() {
        return getInputLocator();
    }

    // Delegate aria-label to input
    @Override
    public Locator getAriaLabelLocator() {
        return getInputLocator();
    }

    // Delegate enabled state to input
    @Override
    public Locator getEnabledLocator() {
        return getInputLocator();
    }
}
Different component aspects (focus, enablement, aria-label) may target different internal elements. Locator delegation ensures each operation uses the correct target.

Component vs Input Locator

Understanding when to use the component locator versus the input locator is crucial:

Component Locator (getLocator)

Use for component-level attributes and state:
// Component-level attribute
getLocator().getAttribute("opened");
getLocator().getAttribute("theme");
getLocator().getAttribute("invalid");

// Component-level property
getLocator().evaluate("el => el.opened");

Input Locator (getInputLocator)

Use for input-specific operations:
// Input-level attribute
getInputLocator().getAttribute("value");
getInputLocator().getAttribute("maxlength");
getInputLocator().getAttribute("disabled");

// Input operations
getInputLocator().fill("text");
getInputLocator().focus();
Pitfall: Calling methods on the wrong locator (component root vs input).Solution: Use getInputLocator() for value/focus operations, getLocator() for component-level attributes.

Real-World Example

// Wrong: Checking disabled on component root
boolean disabled = textField.getLocator().getAttribute("disabled") != null; // May not work!

// Correct: Checking disabled on input element
boolean disabled = textField.getInputLocator().getAttribute("disabled") != null;

// Even better: Use the element's assertion
textField.assertDisabled();

Shadow DOM Piercing

Playwright automatically pierces shadow DOM with CSS selectors, but XPath requires explicit handling.

CSS Selectors (Auto-Pierce)

// Pierces shadow DOM automatically
getLocator().locator("[part~='input']");
getLocator().locator("[slot='prefix']");
getLocator().locator(".error-message");

XPath (Explicit Non-Pierce)

// Does NOT pierce shadow DOM (explicit xpath prefix)
getLocator().locator("xpath=./*[not(@slot)][1]");

// Use for direct children only
getLocator().locator("xpath=./vaadin-button[1]");
Prefer CSS selectors over XPath for Vaadin components. CSS selectors automatically pierce shadow DOM and are more readable.

Part Selectors

Vaadin components expose internal elements via part attributes for styling and testing:
// Input part
public Locator getInputLocator() {
    return getLocator().locator("[part~='input']");
}

// Clear button part
public void clickClearButton() {
    getLocator().locator("[part~='clear-button']").click();
}

// Toggle button part (ComboBox)
public Locator getToggleButtonLocator() {
    return getLocator().locator("[part~='toggle-button']");
}

Common Parts

ComponentPartPurpose
Text FieldinputThe actual input element
Text Fieldclear-buttonClear button
Combo Boxtoggle-buttonDropdown toggle
Date Pickertoggle-buttonCalendar toggle
ButtonprefixPrefix slot content
ButtonsuffixSuffix slot content

Mixin Interfaces (Composition)

Shared behavior is extracted into interfaces with default implementations:
public interface HasClearButtonElement extends HasLocatorElement {
    default void clickClearButton() {
        getLocator().locator("[part~='clear-button']").click();
    }

    default Locator getClearButtonLocator() {
        return getLocator().locator("[part~='clear-button']");
    }
}
Elements implement mixins to inherit behavior:
public class TextFieldElement extends VaadinElement
        implements HasClearButtonElement, HasPrefixElement, HasSuffixElement {
    // Automatically gets clickClearButton(), getPrefixLocator(), etc.
}

Common Mixins

  • FocusableElement - Focus and blur operations
  • HasInputFieldElement - Value, label, helper text
  • HasValidationPropertiesElement - Validation state and messages
  • HasEnabledElement - Enabled/disabled state
  • HasClearButtonElement - Clear button interaction
  • HasPrefixElement - Prefix slot content
  • HasSuffixElement - Suffix slot content
  • HasThemeElement - Theme variant checking

Composite Element Pattern

Complex components compose simpler elements internally:
public class DateTimePickerElement extends VaadinElement {
    private final DatePickerElement datePickerElement;
    private final TimePickerElement timePickerElement;

    public DateTimePickerElement(Locator locator) {
        super(locator);
        // Compose internal elements
        datePickerElement = new DatePickerElement(
            locator.locator(DatePickerElement.FIELD_TAG_NAME)
        );
        timePickerElement = new TimePickerElement(
            locator.locator(TimePickerElement.FIELD_TAG_NAME)
        );
    }

    public DatePickerElement getDatePicker() {
        return datePickerElement;
    }

    public TimePickerElement getTimePicker() {
        return timePickerElement;
    }
}

Using Composite Elements

// Access the composite element
DateTimePickerElement dateTime = DateTimePickerElement.getByLabel(page, "Appointment");

// Access internal elements
dateTime.getDatePicker().setValue(LocalDate.now());
dateTime.getTimePicker().setValue(LocalTime.of(14, 30));

// Or use the composite's own methods
dateTime.setValue(LocalDateTime.now());

Scoped Lookup Pattern

Factory methods support both page-level and scoped lookups:
// Page-level lookup
public static ButtonElement getByText(Page page, String text) {
    return new ButtonElement(
        page.getByRole(AriaRole.BUTTON,
            new Page.GetByRoleOptions().setName(text))
        .and(page.locator(FIELD_TAG_NAME))
    );
}

// Scoped lookup (within a container)
public static ButtonElement getByText(Locator locator, String text) {
    return new ButtonElement(
        locator.getByRole(AriaRole.BUTTON,
            new Locator.GetByRoleOptions().setName(text))
        .and(locator.page().locator(FIELD_TAG_NAME))
    );
}

Practical Scoping

// Multiple dialogs on page - scope to the open one
Locator openDialog = page.locator("vaadin-dialog[opened]");
ButtonElement okButton = ButtonElement.getByText(openDialog, "OK");

// Multiple forms - scope to specific form
Locator loginForm = page.locator("form#login");
Locator signupForm = page.locator("form#signup");

TextFieldElement loginEmail = TextFieldElement.getByLabel(loginForm, "Email");
TextFieldElement signupEmail = TextFieldElement.getByLabel(signupForm, "Email");

Grid Cell Access Pattern

Grid uses a specialized pattern for accessing virtualized cells:
// Find a cell by row and column index
Optional<GridElement.CellElement> cell = grid.findCell(0, 1);

// Find a cell by row index and column header text
Optional<GridElement.CellElement> cell = grid.findCell(0, "Name");

// Access cell content
if (cell.isPresent()) {
    String content = cell.get().getCellContentLocator().innerText();
    cell.get().click();
}

Grid Locator Delegation

Grid cells delegate to different locators:
public class CellElement {
    private final Locator tableCell;      // The <td> element
    private final Locator cellContent;    // The vaadin-grid-cell-content

    // Get the table cell (for structure)
    public Locator getTableCellLocator() {
        return tableCell;
    }

    // Get the cell content (for interaction)
    public Locator getCellContentLocator() {
        return cellContent;
    }

    // Click delegates to content
    public void click() {
        cellContent.click();
    }
}

Advanced Locator Chaining

Filter Options

// Chain filters for precise matching
Locator button = page.locator("vaadin-button")
    .filter(new Locator.FilterOptions()
        .setHas(page.getByRole(AriaRole.BUTTON,
            new Page.GetByRoleOptions().setName("Save"))))
    .filter(new Locator.FilterOptions()
        .setHasText("Save"))
    .first();

And/Or Combinations

// Combine locators with .and()
Locator button = page.getByRole(AriaRole.BUTTON,
        new Page.GetByRoleOptions().setName("Save"))
    .and(page.locator("vaadin-button"))
    .and(page.locator("[theme~='primary']"));

// Or combine with .or()
Locator submitButtons = page.locator("[type='submit']")
    .or(page.getByRole(AriaRole.BUTTON,
        new Page.GetByRoleOptions().setName("Submit")));

Creating Custom Elements

When creating custom element wrappers, follow these patterns:

1. Extend VaadinElement

public class CustomFieldElement extends VaadinElement {
    public static final String FIELD_TAG_NAME = "custom-field";

    public CustomFieldElement(Locator locator) {
        super(locator);
    }
}

2. Implement Relevant Mixins

public class CustomFieldElement extends VaadinElement
        implements HasInputFieldElement, FocusableElement, HasEnabledElement {
    // Mixins provide default implementations
}

3. Override Locator Delegation

@Override
public Locator getFocusLocator() {
    return getInputLocator(); // Delegate to input
}

@Override
public Locator getEnabledLocator() {
    return getInputLocator(); // Check disabled on input
}

4. Add Factory Methods

public static CustomFieldElement getByLabel(Page page, String label) {
    return new CustomFieldElement(
        page.locator(FIELD_TAG_NAME)
            .filter(new Locator.FilterOptions()
                .setHas(page.getByRole(AriaRole.TEXTBOX,
                    new Page.GetByRoleOptions().setName(label))))
            .first());
}

public static CustomFieldElement getByLabel(Locator locator, String label) {
    return new CustomFieldElement(
        locator.locator(FIELD_TAG_NAME)
            .filter(new Locator.FilterOptions()
                .setHas(locator.getByLabel(label)))
            .first());
}

Best Practices

Prefer part selectors over complex CSS for accessing internal component elements:
// Good: Using part selector
getLocator().locator("[part~='input']")

// Bad: Complex CSS selector
getLocator().locator("div.input-container > input.field-input")
Always use the appropriate locator for each operation:
// Component-level: theme, opened, invalid
getLocator().getAttribute("theme");

// Input-level: value, disabled, focus
getInputLocator().fill("value");
getInputLocator().focus();
Implement mixins to inherit common behavior instead of duplicating code:
// Implements mixins for inherited behavior
public class CustomElement extends VaadinElement
        implements FocusableElement, HasClearButtonElement {
    // Gets focus(), blur(), clickClearButton() for free
}
Factory methods should always call .first() to avoid ambiguous matches:
// Good: Ensures single element
return new ButtonElement(
    page.locator(FIELD_TAG_NAME)
        .filter(...)
        .first()  // Always add .first()
);

Common Pitfalls

Pitfall: Using XPath selectors that don’t pierce shadow DOM.Solution: Use CSS selectors which automatically pierce shadow DOM.
// Bad: XPath doesn't pierce shadow DOM
getLocator().locator("xpath=//input[@type='text']");

// Good: CSS pierces shadow DOM
getLocator().locator("[part~='input']");

Build docs developers (and LLMs) love