TooltipElement.java
package org.vaadin.addons.dramafinder.element;
import java.util.Map;
import java.util.regex.Pattern;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.assertions.LocatorAssertions;
import com.microsoft.playwright.options.AriaRole;
import org.vaadin.addons.dramafinder.element.shared.HasStyleElement;
import org.vaadin.addons.dramafinder.element.shared.HasThemeElement;
import org.vaadin.addons.dramafinder.element.shared.HasTooltipElement;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
/**
* PlaywrightElement for {@code <vaadin-tooltip>}.
* <p>
* A tooltip is never a top-level component: Flow's
* {@code com.vaadin.flow.component.shared.Tooltip} slots a
* {@code <vaadin-tooltip slot="tooltip">} into the component it describes, so
* the natural way to reach one is either
* {@link HasTooltipElement#getTooltip()} on the wrapper of that component, or
* {@link #get(Locator)} scoped to the target.
* <p>
* The host element is styled {@code display: contents} and renders nothing of
* its own: what becomes visible is the {@code <vaadin-tooltip-overlay>} in its
* shadow DOM, reachable through {@link #getOverlayLocator()}. The open/closed
* state is therefore asserted on the {@code opened} attribute the component
* reflects, which is the precise signal, rather than on visibility. The
* tooltip text lives in a {@code role="tooltip"} element that stays in the DOM
* while the tooltip is closed, which is why {@link #getText()} and
* {@link #assertText(String)} work without opening the tooltip first.
* <p>
* By default a tooltip opens after a hover or focus delay of 500 ms.
* {@link #hoverTarget()} triggers it; the assertions auto-retry, so
* {@code assertOpened()} right after it waits for the delay to elapse.
*/
@PlaywrightElement(TooltipElement.FIELD_TAG_NAME)
public class TooltipElement extends VaadinElement implements HasThemeElement, HasStyleElement {
public static final String FIELD_TAG_NAME = "vaadin-tooltip";
/** Tag name of the overlay the tooltip renders in its shadow DOM. */
private static final String OVERLAY_TAG_NAME = "vaadin-tooltip-overlay";
/**
* Scroll the tooltip's target into view and return the viewport
* coordinates of its centre, or {@code null} when the tooltip has no
* target. The target is a live element reference on the {@code target}
* property, so it cannot be expressed as a selector.
*/
private static final String TARGET_CENTER_JS = """
el => {
const target = el.target;
if (!target) {
return null;
}
target.scrollIntoView({ block: 'center', inline: 'center' });
const rect = target.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}""";
/**
* Create a new {@code TooltipElement}.
*
* @param locator the locator for the {@code <vaadin-tooltip>} element
*/
public TooltipElement(Locator locator) {
super(locator);
}
// ── Static Factory Methods ─────────────────────────────────────────
/**
* Get the first {@code TooltipElement} on the page.
*
* @param page the Playwright page
* @return the first matching {@code TooltipElement}
*/
public static TooltipElement get(Page page) {
return new TooltipElement(page.locator(FIELD_TAG_NAME).first());
}
/**
* Get the first {@code TooltipElement} within a scope, typically the
* component the tooltip is attached to.
*
* @param locator the scope containing the tooltip (not the tooltip itself)
* @return the first matching {@code TooltipElement}
*/
public static TooltipElement get(Locator locator) {
return new TooltipElement(locator.locator(FIELD_TAG_NAME).first());
}
/**
* Get a {@code TooltipElement} by its text.
* <p>
* The tooltip is matched through the accessible name of its
* {@code role="tooltip"} content element, which is present whether the
* tooltip is open or closed. The match is a case-insensitive substring
* match, so pass enough of the text to be unambiguous.
*
* @param page the Playwright page
* @param text the text the tooltip contains
* @return the first matching {@code TooltipElement}
*/
public static TooltipElement getByText(Page page, String text) {
return new TooltipElement(page.locator(FIELD_TAG_NAME)
.filter(new Locator.FilterOptions().setHas(
page.getByRole(AriaRole.TOOLTIP, new Page.GetByRoleOptions()
.setName(text)
.setIncludeHidden(true))))
.first());
}
// ── Locators ───────────────────────────────────────────────────────
/**
* Locator for the element carrying the tooltip content, i.e. the
* {@code role="tooltip"} node. It stays in the DOM while the tooltip is
* closed, so it is matched with hidden elements included.
*
* @return the tooltip content locator
*/
public Locator getContentLocator() {
return getLocator().getByRole(AriaRole.TOOLTIP,
new Locator.GetByRoleOptions().setIncludeHidden(true)).first();
}
/**
* Locator for the {@code <vaadin-tooltip-overlay>} the tooltip renders in
* its shadow DOM. The overlay exists from the first render on and is the
* element that becomes visible when the tooltip opens.
*
* @return the overlay locator
*/
public Locator getOverlayLocator() {
return getLocator().locator(OVERLAY_TAG_NAME).first();
}
// ── Content ────────────────────────────────────────────────────────
/**
* Get the tooltip text.
* <p>
* Read from the {@code role="tooltip"} content node rather than from the
* host, so it is available whether the tooltip is open or closed. When the
* tooltip renders Markdown, this is the rendered text without the markup.
*
* @return the tooltip text, or {@code null} when the content node has none
*/
@Override
public String getText() {
return getContentLocator().textContent();
}
/**
* Assert the tooltip text.
*
* @param text the expected text, or {@code null} to assert the tooltip
* shows no content — an empty tooltip keeps its overlay
* hidden, so absence is asserted on the content's visibility
*/
public void assertText(String text) {
if (text != null) {
assertThat(getContentLocator()).hasText(text,
new LocatorAssertions.HasTextOptions().setUseInnerText(true));
} else {
assertThat(getContentLocator()).not().isVisible();
}
}
// ── Opened state ───────────────────────────────────────────────────
/**
* Whether the tooltip is open.
* <p>
* Based on the {@code opened} attribute the component reflects, which flips
* exactly when the tooltip opens and closes. Visibility is a looser signal:
* the host renders nothing of its own, and the overlay only reaches its
* final state once the opening has gone through.
*
* @return {@code true} when the tooltip is open
*/
public boolean isOpened() {
return getLocator().getAttribute("opened") != null;
}
/** Assert that the tooltip is open. */
public void assertOpened() {
assertThat(getLocator()).hasAttribute("opened", "");
}
/** Assert that the tooltip is closed. */
public void assertClosed() {
assertThat(getLocator()).not().hasAttribute("opened", Pattern.compile(".*"));
}
// ── Position ───────────────────────────────────────────────────────
/**
* Get the effective position of the tooltip relative to its target, e.g.
* {@code top-start} or {@code bottom}.
* <p>
* The host holds the position as a DOM property only; the overlay reflects
* the effective value — the configured position, or the {@code bottom}
* default — as an attribute, so it is read from there.
*
* @return the effective position, never {@code null} for a rendered tooltip
*/
public String getPosition() {
return getOverlayLocator().getAttribute("position");
}
/**
* Assert the effective position of the tooltip relative to its target.
*
* @param position the expected position, e.g. {@code top-start}. A tooltip
* with no configured position is {@code bottom}, so there
* is no "absent" case to assert
*/
public void assertPosition(String position) {
assertThat(getOverlayLocator()).hasAttribute("position", position);
}
// ── Triggering ─────────────────────────────────────────────────────
/**
* Hover the element the tooltip describes, which opens the tooltip unless
* it is in manual mode.
* <p>
* The target is scrolled into view first. Opening is delayed by the
* tooltip's hover delay (500 ms by default), so follow this with
* {@link #assertOpened()} or {@link #assertText(String)}, which auto-retry,
* rather than with {@link #isOpened()}.
*
* @throws IllegalStateException when the tooltip has no target element
*/
@SuppressWarnings("unchecked")
public void hoverTarget() {
Object center = getLocator().evaluate(TARGET_CENTER_JS);
if (center == null) {
throw new IllegalStateException(
"The tooltip has no target element, so it cannot be hovered.");
}
Map<String, Object> point = (Map<String, Object>) center;
getLocator().page().mouse().move(
((Number) point.get("x")).doubleValue(),
((Number) point.get("y")).doubleValue());
}
/**
* Close the tooltip by pressing {@code Escape}.
* <p>
* The component listens for the key on the document, so the press is sent
* to the page rather than to the tooltip, whose host cannot be focused. A
* tooltip in manual mode ignores {@code Escape} and stays open.
*/
public void closeWithEscape() {
getLocator().page().keyboard().press("Escape");
}
}