FormLayoutElement.java
package org.vaadin.addons.dramafinder.element;
import java.util.Map;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import org.vaadin.addons.dramafinder.element.shared.HasStyleElement;
import org.vaadin.addons.dramafinder.element.shared.HasThemeElement;
/**
* PlaywrightElement for {@code <vaadin-form-layout>}.
* <p>
* The component is purely structural, so the surface is deliberately small: it
* exposes the laid out fields and the two things a responsive form is worth
* asserting on — how many columns the layout currently renders and where the
* labels sit. Both are resolved for the layout's <em>current</em> width, so a
* test can resize the viewport and assert the effect of the responsive steps.
* <p>
* Both layout modes are supported. With responsive steps (the default) the
* step matching the layout's width decides the column count and the label
* position; in auto-responsive mode the columns actually rendered by the CSS
* grid are counted, and labels are aside only while they fit.
*/
@PlaywrightElement(FormLayoutElement.FIELD_TAG_NAME)
public class FormLayoutElement extends VaadinElement implements HasThemeElement, HasStyleElement {
public static final String FIELD_TAG_NAME = "vaadin-form-layout";
/** Tag name of a form item, the label + field wrapper. */
public static final String FORM_ITEM_TAG_NAME = "vaadin-form-item";
/** Tag name of a form row, the explicit row grouping used in auto-responsive mode. */
public static final String FORM_ROW_TAG_NAME = "vaadin-form-row";
/** Label position placing labels next to their field. */
public static final String LABEL_POSITION_ASIDE = "aside";
/** Label position placing labels above their field. */
public static final String LABEL_POSITION_TOP = "top";
/**
* Resolves the column count and the label position for the layout's current
* width, mirroring the component's own layout algorithm.
* <p>
* In auto-responsive mode the rendered CSS grid tracks are counted, skipping
* the {@code 0px} auto columns the component uses as padding; labels are
* aside exactly when the component decided they fit. Otherwise the
* responsive steps are walked in order and the last one whose
* {@code minWidth} still fits the layout wins — {@code minWidth} is resolved
* to pixels by parking it in an unrelated length property, which is how the
* component converts {@code em} thresholds itself.
*/
private static final String LAYOUT_STATE_SCRIPT = """
el => {
const layout = el.shadowRoot.querySelector('#layout');
if (el.hasAttribute('auto-responsive')) {
return {
columns: getComputedStyle(layout).gridTemplateColumns
.split(' ').filter(track => track !== '0px' && track !== 'none').length,
labelsPosition: layout.hasAttribute('fits-labels-aside') ? 'aside' : 'top'
};
}
const probe = 'background-position';
let selected = null;
(el.responsiveSteps || []).forEach(step => {
layout.style.setProperty(probe, String(step.minWidth === undefined ? 0 : step.minWidth));
const minWidth = parseFloat(getComputedStyle(layout).getPropertyValue(probe));
if (!Number.isNaN(minWidth) && minWidth <= el.offsetWidth) {
selected = step;
}
});
layout.style.removeProperty(probe);
return {
columns: selected ? selected.columns : 1,
labelsPosition: selected && selected.labelsPosition === 'top' ? 'top' : 'aside'
};
}
""";
/**
* Create a new {@code FormLayoutElement}.
*
* @param locator the locator for the {@code <vaadin-form-layout>} element
*/
public FormLayoutElement(Locator locator) {
super(locator);
}
/**
* Get the first {@code FormLayoutElement} on the page.
*
* @param page the Playwright page
* @return the first matching {@code FormLayoutElement}
*/
public static FormLayoutElement get(Page page) {
return new FormLayoutElement(page.locator(FIELD_TAG_NAME).first());
}
/**
* Get the first {@code FormLayoutElement} within a parent locator.
*
* @param parent the parent locator to search within
* @return the first matching {@code FormLayoutElement}
*/
public static FormLayoutElement get(Locator parent) {
return new FormLayoutElement(parent.locator(FIELD_TAG_NAME).first());
}
/**
* Get a {@code FormLayoutElement} by its {@code id} attribute.
*
* @param page the Playwright page
* @param id the element id
* @return the matching {@code FormLayoutElement}
*/
public static FormLayoutElement getById(Page page, String id) {
return new FormLayoutElement(page.locator("#" + id));
}
// ── Fields ─────────────────────────────────────────────────────────
/**
* Locator matching every field the layout positions, in DOM order.
* <p>
* A field is whatever the layout treats as one item of the grid: a plain
* component, or a {@code <vaadin-form-item>} wrapping a label and a field.
* {@code <vaadin-form-row>} groups are flattened to the fields they contain,
* and the {@code <br>} elements used to force a row break are skipped, so
* the count matches what the user sees rather than the raw child list.
* <p>
* The lookup is an XPath union because a CSS child selector would pierce the
* shadow DOM and pick up the layout's internal wrapper as a child too.
*
* @return a locator resolving to all fields of this layout
*/
public Locator getFields() {
return getLocator().locator("xpath=./*[not(self::br)][not(self::" + FORM_ROW_TAG_NAME + ")]"
+ " | ./" + FORM_ROW_TAG_NAME + "/*[not(self::br)]");
}
/**
* Get a field by its zero-based position in the layout.
*
* @param index the zero-based field index
* @return a locator for that field
*/
public Locator getField(int index) {
return getFields().nth(index);
}
/**
* Get the number of fields the layout positions.
*
* @return the field count, as counted by {@link #getFields()}
*/
public int getFieldCount() {
return getFields().count();
}
/**
* Locator matching every {@code <vaadin-form-item>} of this layout, in DOM
* order. Empty for a layout built from plain fields.
*
* @return a locator resolving to all form items of this layout
*/
public Locator getFormItems() {
return getLocator().locator(FORM_ITEM_TAG_NAME);
}
// ── Layout ─────────────────────────────────────────────────────────
/**
* Get the number of columns the layout renders at its current width.
* <p>
* With responsive steps this is the {@code columns} value of the step that
* matches the layout's width; in auto-responsive mode it is the number of
* columns the layout actually created for the available space.
*
* @return the current column count, at least {@code 1}
*/
public int getColumnCount() {
return ((Number) getLayoutState().get("columns")).intValue();
}
/**
* Get where the layout places the labels of its form items at its current
* width.
* <p>
* With responsive steps this is the {@code labelsPosition} of the matching
* step; in auto-responsive mode labels are {@code aside} only when the
* layout enabled aside labels and is wide enough for them.
*
* @return {@link #LABEL_POSITION_ASIDE} or {@link #LABEL_POSITION_TOP},
* never {@code null}
*/
public String getLabelPosition() {
return (String) getLayoutState().get("labelsPosition");
}
@SuppressWarnings("unchecked")
private Map<String, Object> getLayoutState() {
return (Map<String, Object>) locator.evaluate(LAYOUT_STATE_SCRIPT);
}
// ── Assertions ─────────────────────────────────────────────────────
/**
* Assert that the layout renders the given number of columns. Retries until
* the layout settles, so it is safe to call right after a viewport resize.
*
* @param expected the expected column count
*/
public void assertColumnCount(int expected) {
locator.page().waitForCondition(() -> getColumnCount() == expected);
}
/**
* Assert where the layout places the labels of its form items. Retries until
* the layout settles, so it is safe to call right after a viewport resize.
*
* @param expected {@link #LABEL_POSITION_ASIDE} or {@link #LABEL_POSITION_TOP}
*/
public void assertLabelPosition(String expected) {
locator.page().waitForCondition(() -> getLabelPosition().equals(expected));
}
/**
* Assert that the layout positions the given number of fields.
*
* @param expected the expected field count
*/
public void assertFieldCount(int expected) {
locator.page().waitForCondition(() -> getFieldCount() == expected);
}
}