ContextMenuElement.java

package org.vaadin.addons.dramafinder.element;

import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.assertions.LocatorAssertions;
import com.microsoft.playwright.options.AriaRole;
import com.microsoft.playwright.options.MouseButton;
import org.vaadin.addons.dramafinder.AbstractBasePlaywrightIT;
import org.vaadin.addons.dramafinder.element.shared.HasStyleElement;
import org.vaadin.addons.dramafinder.element.utils.ItemLocator;

import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;

/**
 * PlaywrightElement for context menu overlays {@code <vaadin-context-menu-overlay>}.
 * <p>
 * Provides helpers to open a menu via a context-click on the target, inspect
 * the overlay list box, and pick menu items by their accessible label using
 * the {@code menu} role.
 */
public class ContextMenuElement extends VaadinElement implements HasStyleElement {

    public static final String FIELD_TAG_NAME = "vaadin-context-menu";
    public static final String FIELD_LIST_BOX_TAG_NAME = "vaadin-context-menu-list-box";

    private static final int OPEN_ATTEMPTS = 3;
    private static final double OPEN_TIMEOUT_MS = 2000;

    /**
     * Create a {@code ContextMenuElement} from the page overlay.
     * Get the first opened context menu
     */
    public ContextMenuElement(Page page) {
        super(page.locator(FIELD_TAG_NAME + "[opened]"));
    }

    /**
     * Create a {@code ContextMenuElement} from an existing locator.
     */
    public ContextMenuElement(Locator locator) {
        super(locator);
    }

    /**
     * Open the context menu by invoking a context-click on the provided target.
     * <p>
     * Flow renders the {@code vaadin-context-menu} element server-side, so a
     * roundtrip that is still in flight — the one triggered by the previous
     * selection, for instance — can replace that element and silently discard a
     * menu that was opened while the response travelled. This waits for Flow to
     * go idle before context-clicking, then waits for the overlay to show up,
     * and repeats the context-click if the menu was lost anyway.
     *
     * @param target the element that triggers the context menu
     */
    public static void openOn(Locator target) {
        Page page = target.page();
        Locator listBox = page.locator(FIELD_TAG_NAME + "[opened] " + FIELD_LIST_BOX_TAG_NAME).first();
        AssertionError lastError = null;
        for (int attempt = 0; attempt < OPEN_ATTEMPTS; attempt++) {
            page.waitForFunction(AbstractBasePlaywrightIT.WAIT_FOR_VAADIN_SCRIPT);
            target.click(new Locator.ClickOptions().setButton(MouseButton.RIGHT));
            try {
                assertThat(listBox).isVisible(
                        new LocatorAssertions.IsVisibleOptions().setTimeout(OPEN_TIMEOUT_MS));
                return;
            } catch (AssertionError e) {
                lastError = e;
            }
        }
        throw lastError;
    }

    /**
     * Assert that the context menu overlay is open.
     */
    public void assertOpen() {
        assertThat(getLocator()).hasAttribute("opened", "");
    }

    /**
     * Assert that the context menu overlay is closed or hidden.
     */
    public void assertClosed() {
        assertThat(getLocator()).isHidden();
    }

    /**
     * Open a submenu and return its overlay.
     *
     * @param itemLabel visible label of the parent menu item
     * @return a {@code ContextMenuElement} pointing to the submenu overlay
     */
    public ContextMenuElement openSubMenu(String itemLabel) {
        getItemLocator(itemLabel).hover();
        return new ContextMenuElement(getLocator().locator(FIELD_TAG_NAME + "[slot=\"submenu\"][opened]").first());
    }

    /**
     * Select a menu item by its accessible name.
     *
     * @param itemLabel visible label of the menu item
     */
    public void selectItem(String itemLabel) {
        getItemLocator(itemLabel).click();
    }

    /**
     * Assert that a menu item is disabled.
     *
     * @param itemLabel visible label of the menu item
     */
    public void assertItemDisabled(String itemLabel) {
        assertThat(getItemLocator(itemLabel)).isDisabled();
    }

    /**
     * Assert that a menu item is enabled.
     *
     * @param itemLabel visible label of the menu item
     */
    public void assertItemEnabled(String itemLabel) {
        assertThat(getItemLocator(itemLabel)).isEnabled();
    }

    /**
     * Locator for the context menu list box.
     *
     * @return locator for {@code <vaadin-context-menu-list-box>}
     */
    public Locator getListBoxLocator() {
        return getLocator().locator(FIELD_LIST_BOX_TAG_NAME).first();
    }

    /**
     * Assert that a checkable menu item is checked.
     *
     * @param itemLabel visible label of the menu item
     */
    public void assertItemChecked(String itemLabel) {
        assertThat(getItemLocator(itemLabel))
                .hasAttribute("menu-item-checked", "");
    }

    /**
     * Assert that a checkable menu item is not checked.
     *
     * @param itemLabel visible label of the menu item
     */
    public void assertItemNotChecked(String itemLabel) {
        assertThat(getItemLocator(itemLabel))
                .not()
                .hasAttribute("menu-item-checked", "");
    }

    /**
     * Locator for a menu item, matched on its full accessible name. Two items
     * sharing the label fail with a Playwright strict-mode error rather than
     * resolving to the first one in the DOM.
     */
    private Locator getItemLocator(String itemLabel) {
        return ItemLocator.byExactName(getListBoxLocator(), AriaRole.MENUITEM, itemLabel);
    }

}