AvatarGroupElement.java

package org.vaadin.addons.dramafinder.element;

import java.util.ArrayList;
import java.util.List;

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;

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

/**
 * PlaywrightElement for {@code <vaadin-avatar-group>}.
 * <p>
 * The group renders one {@code <vaadin-avatar>} per visible item as a direct
 * light-DOM child, plus a single overflow avatar carrying {@code slot="overflow"}
 * once {@code maxItemsVisible} (or the available width) hides some of them.
 * Clicking the overflow avatar opens an overlay holding a
 * {@code <vaadin-avatar-group-menu>} whose {@code <vaadin-avatar-group-menu-item>}
 * children each wrap the avatar of one hidden item. Those avatars are in the DOM
 * as soon as the group overflows, but are only visible while the overlay is open.
 * <p>
 * Individual avatars — visible or overflowing — are exposed as
 * {@link AvatarElement} instances.
 */
@PlaywrightElement(AvatarGroupElement.FIELD_TAG_NAME)
public class AvatarGroupElement extends VaadinElement
        implements HasStyleElement, HasThemeElement {

    public static final String FIELD_TAG_NAME = "vaadin-avatar-group";
    public static final String FIELD_MENU_TAG_NAME = "vaadin-avatar-group-menu";
    public static final String FIELD_MENU_ITEM_TAG_NAME = "vaadin-avatar-group-menu-item";

    /**
     * Selector for the visible avatars: direct {@code <vaadin-avatar>} children
     * without a {@code slot} attribute. The explicit xpath does not pierce the
     * shadow DOM, which keeps the overflow avatar ({@code slot="overflow"}) and
     * the avatars nested in the overlay menu out of the match.
     */
    private static final String VISIBLE_AVATARS_XPATH =
            "xpath=./" + AvatarElement.FIELD_TAG_NAME + "[not(@slot)]";

    private static final String OVERFLOW_AVATAR_SELECTOR =
            AvatarElement.FIELD_TAG_NAME + "[slot='overflow']";

    /**
     * Create a new {@code AvatarGroupElement}.
     *
     * @param locator the locator for the {@code <vaadin-avatar-group>} element
     */
    public AvatarGroupElement(Locator locator) {
        super(locator);
    }

    // ── Static Factory Methods ─────────────────────────────────────────

    /**
     * Get the first {@code AvatarGroupElement} on the page.
     *
     * @param page the Playwright page
     * @return the first matching {@code AvatarGroupElement}
     */
    public static AvatarGroupElement get(Page page) {
        return new AvatarGroupElement(page.locator(FIELD_TAG_NAME).first());
    }

    /**
     * Get the first {@code AvatarGroupElement} within a scope.
     *
     * @param locator the scope containing the group (not the group itself)
     * @return the first matching {@code AvatarGroupElement}
     */
    public static AvatarGroupElement get(Locator locator) {
        return new AvatarGroupElement(locator.locator(FIELD_TAG_NAME).first());
    }

    // ── Visible avatars ────────────────────────────────────────────────

    /**
     * Locator matching every visible avatar, in rendering order. The overflow
     * avatar is not included.
     *
     * @return locator for the visible {@code <vaadin-avatar>} children
     */
    public Locator getAvatarsLocator() {
        return getLocator().locator(VISIBLE_AVATARS_XPATH);
    }

    /**
     * Get the visible avatars, in rendering order.
     *
     * @return the visible avatars, empty when the group has no items
     */
    public List<AvatarElement> getAvatars() {
        return toAvatarElements(getAvatarsLocator());
    }

    /**
     * Get the avatar at the given position among the visible avatars.
     *
     * @param index zero-based index
     * @return the avatar at that position
     */
    public AvatarElement getAvatar(int index) {
        return new AvatarElement(getAvatarsLocator().nth(index));
    }

    /**
     * Get the number of visible avatars, excluding the overflow avatar.
     *
     * @return the visible avatar count
     */
    public int getVisibleCount() {
        return getAvatarsLocator().count();
    }

    /**
     * Get the names of the visible avatars, in rendering order.
     *
     * @return the names; an entry is {@code null} when that avatar has no name
     */
    public List<String> getNames() {
        return toNames(getAvatarsLocator());
    }

    /**
     * Get the maximum number of avatars the group displays before overflowing.
     *
     * @return the {@code maxItemsVisible} property, or {@code null} when unset
     */
    public Integer getMaxItemsVisible() {
        Object value = getProperty("maxItemsVisible");
        return value == null ? null : ((Number) value).intValue();
    }

    /**
     * Set the maximum number of avatars to display before overflowing.
     *
     * @param maxItemsVisible the maximum number of visible avatars
     */
    public void setMaxItemsVisible(int maxItemsVisible) {
        setProperty("maxItemsVisible", maxItemsVisible);
    }

    // ── Overflow ───────────────────────────────────────────────────────

    /**
     * Get the overflow avatar, the one summarising the hidden items as
     * {@code +N}. It is present even when nothing overflows, in which case it
     * is hidden.
     *
     * @return the overflow avatar
     */
    public AvatarElement getOverflowAvatar() {
        return new AvatarElement(getLocator().locator(OVERFLOW_AVATAR_SELECTOR).first());
    }

    /**
     * Whether some avatars are hidden behind the overflow avatar.
     *
     * @return {@code true} when the group has an overflow
     */
    public boolean hasOverflow() {
        return getLocator().getAttribute("has-overflow") != null;
    }

    /**
     * Open the overflow overlay by clicking the overflow avatar, and wait until
     * it is open. Does nothing when the overlay is already open — clicking the
     * overflow avatar toggles the overlay, so an unconditional click would
     * close it again.
     */
    public void openOverflow() {
        if (!isOverflowOpen()) {
            getOverflowAvatar().click();
        }
        assertOverflowOpen();
    }

    /**
     * Close the overflow overlay with the {@code Escape} key, and wait until it
     * is closed.
     */
    public void closeOverflow() {
        getLocator().page().keyboard().press("Escape");
        assertOverflowClosed();
    }

    /**
     * Whether the overflow overlay is currently open.
     *
     * @return {@code true} when the overlay is open
     */
    public boolean isOverflowOpen() {
        return "true".equals(getOverflowAvatar().getLocator().getAttribute("aria-expanded"));
    }

    /**
     * Locator for the overflow overlay menu, which holds one menu item per
     * hidden avatar.
     *
     * @return locator for {@code <vaadin-avatar-group-menu>}
     */
    public Locator getOverflowMenuLocator() {
        return getLocator().locator(FIELD_MENU_TAG_NAME).first();
    }

    /**
     * Locator matching every avatar inside the overflow overlay, in rendering
     * order.
     *
     * @return locator for the overflowing {@code <vaadin-avatar>} elements
     */
    public Locator getOverflowAvatarsLocator() {
        return getOverflowMenuLocator()
                .locator(FIELD_MENU_ITEM_TAG_NAME + " " + AvatarElement.FIELD_TAG_NAME);
    }

    /**
     * Get the avatars hidden behind the overflow avatar, in rendering order.
     * They are in the DOM as soon as the group overflows, but only become
     * visible once {@link #openOverflow()} has opened the overlay.
     *
     * @return the overflowing avatars, empty when nothing overflows
     */
    public List<AvatarElement> getOverflowAvatars() {
        return toAvatarElements(getOverflowAvatarsLocator());
    }

    /**
     * Get the names of the avatars hidden behind the overflow avatar, in
     * rendering order.
     *
     * @return the names; an entry is {@code null} when that avatar has no name
     */
    public List<String> getOverflowNames() {
        return toNames(getOverflowAvatarsLocator());
    }

    // ── Assertions ─────────────────────────────────────────────────────

    /**
     * Assert the names of the visible avatars, in order.
     *
     * @param names the expected names
     */
    public void assertNames(String... names) {
        assertNames(getAvatarsLocator(), names);
    }

    /**
     * Assert the names of the avatars hidden behind the overflow avatar, in
     * order.
     *
     * @param names the expected names
     */
    public void assertOverflowNames(String... names) {
        assertNames(getOverflowAvatarsLocator(), names);
    }

    /**
     * Assert the number of visible avatars, excluding the overflow avatar.
     *
     * @param count the expected visible avatar count
     */
    public void assertVisibleCount(int count) {
        assertThat(getAvatarsLocator()).hasCount(count);
    }

    /**
     * Assert that some avatars are hidden behind the overflow avatar.
     */
    public void assertHasOverflow() {
        assertThat(getLocator()).hasAttribute("has-overflow", "");
    }

    /**
     * Assert that no avatar is hidden behind the overflow avatar.
     */
    public void assertHasNoOverflow() {
        assertThat(getLocator()).not().hasAttribute("has-overflow", "");
    }

    /**
     * Assert that the overflow overlay is open.
     */
    public void assertOverflowOpen() {
        assertThat(getOverflowAvatar().getLocator()).hasAttribute("aria-expanded", "true");
    }

    /**
     * Assert that the overflow overlay is closed.
     */
    public void assertOverflowClosed() {
        assertThat(getOverflowAvatar().getLocator()).hasAttribute("aria-expanded", "false");
    }

    private static void assertNames(Locator avatars, String... names) {
        assertThat(avatars).hasCount(names.length);
        for (int i = 0; i < names.length; i++) {
            assertThat(avatars.nth(i)).hasJSProperty("name", names[i]);
        }
    }

    private static List<AvatarElement> toAvatarElements(Locator avatars) {
        List<AvatarElement> elements = new ArrayList<>();
        for (Locator avatar : avatars.all()) {
            elements.add(new AvatarElement(avatar));
        }
        return elements;
    }

    /**
     * Read the {@code name} property of every matched avatar in a single
     * round-trip to the browser.
     */
    private static List<String> toNames(Locator avatars) {
        Object evaluated = avatars
                .evaluateAll("avatars => avatars.map(avatar => avatar.name ?? null)");
        List<String> names = new ArrayList<>();
        for (Object name : (List<?>) evaluated) {
            names.add((String) name);
        }
        return names;
    }

}