MultiSelectComboBoxElement.java

package org.vaadin.addons.dramafinder.element;

import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import org.vaadin.addons.dramafinder.element.utils.AccessibleNameLocator;
import org.vaadin.addons.dramafinder.element.shared.FocusableElement;
import org.vaadin.addons.dramafinder.element.shared.HasAllowedCharPatternElement;
import org.vaadin.addons.dramafinder.element.shared.HasAriaLabelElement;
import org.vaadin.addons.dramafinder.element.shared.HasClearButtonElement;
import org.vaadin.addons.dramafinder.element.shared.HasEnabledElement;
import org.vaadin.addons.dramafinder.element.shared.HasInputFieldElement;
import org.vaadin.addons.dramafinder.element.shared.HasPlaceholderElement;

import org.vaadin.addons.dramafinder.element.shared.HasThemeElement;
import org.vaadin.addons.dramafinder.element.shared.HasTooltipElement;
import org.vaadin.addons.dramafinder.element.shared.HasValidationPropertiesElement;

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

/**
 * PlaywrightElement for {@code <vaadin-multi-select-combo-box>}.
 * <p>
 * Provides helpers to open the overlay, filter items, select/deselect
 * multiple items, and query selected chips.
 */
@PlaywrightElement(MultiSelectComboBoxElement.FIELD_TAG_NAME)
public class MultiSelectComboBoxElement extends VaadinElement
        implements FocusableElement, HasAriaLabelElement, HasInputFieldElement,
        HasThemeElement, HasPlaceholderElement,
        HasEnabledElement, HasTooltipElement, HasValidationPropertiesElement,
        HasClearButtonElement, HasAllowedCharPatternElement {

    public static final String FIELD_TAG_NAME = "vaadin-multi-select-combo-box";
    public static final String FIELD_ITEM_TAG_NAME = "vaadin-multi-select-combo-box-item";
    public static final String FIELD_CHIP_TAG_NAME = "vaadin-multi-select-combo-box-chip";

    /**
     * Create a new {@code MultiSelectComboBoxElement}.
     *
     * @param locator the locator for the {@code <vaadin-multi-select-combo-box>} element
     */
    public MultiSelectComboBoxElement(Locator locator) {
        super(locator);
    }

    @Override
    public Locator getFocusLocator() {
        return getInputLocator();
    }

    @Override
    public Locator getAriaLabelLocator() {
        return getInputLocator();
    }

    @Override
    public Locator getEnabledLocator() {
        return getInputLocator();
    }

    /**
     * Get the selected values as a comma-separated string from the
     * {@code selectedItems} property.
     *
     * @return comma-separated selected values, or empty string when nothing is selected
     */
    @Override
    public String getValue() {
        List<String> items = getSelectedItems();
        return String.join(", ", items);
    }

    /**
     * Assert that the displayed value equals the expected string.
     *
     * @param expected expected comma-separated value or empty string for no selection
     */
    @Override
    public void assertValue(String expected) {
        if (expected == null || expected.isEmpty()) {
            getLocator().page().waitForCondition(() -> getSelectedItemCount() == 0);
        } else {
            getLocator().page().waitForCondition(() -> expected.equals(getValue()));
        }
    }

    // ── Selection ──────────────────────────────────────────────────────

    /**
     * Select an item by its visible label.
     * Opens the overlay, clicks the matching item (toggling its selection).
     *
     * @param item label of the item to select
     */
    public void selectItem(String item) {
        open();
        getOverlayItem(item).click();
    }

    /**
     * Deselect an item by its visible label.
     * Opens the overlay, clicks the matching item (toggling its selection off).
     *
     * @param item label of the item to deselect
     */
    public void deselectItem(String item) {
        open();
        getOverlayItem(item).click();
    }

    /**
     * Select multiple items in sequence.
     *
     * @param items labels of the items to select
     */
    public void selectItems(String... items) {
        open();
        for (String item : items) {
            getOverlayItem(item).click();
        }
    }

    /**
     * Deselect multiple items in sequence.
     *
     * @param items labels of the items to deselect
     */
    public void deselectItems(String... items) {
        open();
        for (String item : items) {
            getOverlayItem(item).click();
        }
    }

    /**
     * Type filter text into the input, then click the matching item.
     *
     * @param filter text to type for filtering
     * @param item   label of the item to select
     */
    public void filterAndSelectItem(String filter, String item) {
        setFilter(filter);
        getOverlayItem(item).click();
    }

    /**
     * Type into the input to trigger filtering.
     * Uses {@code pressSequentially} to fire keyboard events that the
     * component listens to for filtering.
     *
     * @param filter the filter text
     */
    public void setFilter(String filter) {
        // Clear input directly to avoid clear() triggering a server roundtrip
        // that may restore a non-empty default value.
        getInputLocator().clear();
        open();
        getInputLocator().pressSequentially(filter);
    }

    /**
     * Get the current filter value from the DOM property.
     *
     * @return the current filter string
     */
    public String getFilter() {
        Object value = getProperty("filter");
        return value == null ? "" : value.toString();
    }

    // ── Open / Close ───────────────────────────────────────────────────

    /**
     * Open the combo box overlay.
     */
    public void open() {
        setProperty("opened", true);
    }

    /**
     * Close the combo box overlay.
     */
    public void close() {
        setProperty("opened", false);
    }

    /**
     * Whether the overlay is currently open.
     *
     * @return {@code true} when the overlay is open
     */
    public boolean isOpened() {
        return Boolean.TRUE.equals(getProperty("opened"));
    }

    /**
     * Assert that the combo box overlay is open.
     */
    public void assertOpened() {
        assertThat(getLocator()).hasAttribute("opened", "");
    }

    /**
     * Assert that the combo box overlay is closed.
     */
    public void assertClosed() {
        assertThat(getLocator()).not().hasAttribute("opened", "");
    }

    // ── Read-only ──────────────────────────────────────────────────────

    /**
     * Whether the combo box is read-only.
     *
     * @return {@code true} when read-only
     */
    public boolean isReadOnly() {
        return getLocator().getAttribute("readonly") != null;
    }

    /**
     * Assert that the combo box is read-only.
     */
    public void assertReadOnly() {
        assertThat(getLocator()).hasAttribute("readonly", "");
    }

    /**
     * Assert that the combo box is not read-only.
     */
    public void assertNotReadOnly() {
        assertThat(getLocator()).not().hasAttribute("readonly", "");
    }

    // ── Toggle button ──────────────────────────────────────────────────

    /**
     * Locator for the toggle button part.
     *
     * @return locator for the toggle button
     */
    public Locator getToggleButtonLocator() {
        return getLocator().locator("[part~=\"toggle-button\"]");
    }

    /**
     * Click the dropdown toggle button.
     */
    public void clickToggleButton() {
        getToggleButtonLocator().click();
    }

    // ── Overlay items ──────────────────────────────────────────────────

    /**
     * Count visible overlay items.
     *
     * @return the number of visible items
     */
    public int getOverlayItemCount() {
        return getLocator().locator(FIELD_ITEM_TAG_NAME + ":not([hidden])").count();
    }

    /**
     * Assert that the overlay contains exactly the expected number of items.
     *
     * @param expected expected item count
     */
    public void assertItemCount(int expected) {
        assertThat(getLocator().locator(FIELD_ITEM_TAG_NAME + ":not([hidden])")).hasCount(expected);
    }

    // ── Chips ──────────────────────────────────────────────────────────

    /**
     * Get the locator for all non-overflow chips.
     *
     * @return locator for selected-value chips
     */
    public Locator getChipLocators() {
        return getLocator().locator(FIELD_CHIP_TAG_NAME + ":not([slot=\"overflow\"])");
    }

    /**
     * Get the locator for the overflow chip.
     *
     * @return locator for the overflow chip
     */
    public Locator getOverflowChipLocator() {
        return getLocator().locator(FIELD_CHIP_TAG_NAME + "[slot=\"overflow\"]");
    }

    /**
     * Get the labels of all currently selected items by reading the
     * {@code selectedItems} property from the web component.
     *
     * @return list of selected item labels
     */
    @SuppressWarnings("unchecked")
    public List<String> getSelectedItems() {
        Object result = getLocator().evaluate(
                "el => (el.selectedItems || []).map(i => typeof i === 'string' ? i : i[el.itemLabelPath || 'label'] || String(i))");
        if (result instanceof List<?> list) {
            List<String> items = new ArrayList<>();
            for (Object item : list) {
                items.add(String.valueOf(item));
            }
            return items;
        }
        return Collections.emptyList();
    }

    /**
     * Get the count of currently selected items from the
     * {@code selectedItems} property.
     *
     * @return number of selected items
     */
    public int getSelectedItemCount() {
        return ((Number) getLocator().evaluate(
                "el => (el.selectedItems || []).length")).intValue();
    }

    /**
     * Assert that the selected item labels match the expected values.
     *
     * @param expected expected item labels
     */
    public void assertSelectedItems(String... expected) {
        getLocator().page().waitForCondition(() -> getSelectedItemCount() == expected.length);
        List<String> actual = getSelectedItems();
        for (String exp : expected) {
            assert actual.contains(exp)
                    : "Expected item '" + exp + "' in " + actual;
        }
    }

    /**
     * Assert that the number of selected items matches.
     *
     * @param expected expected number of selected items
     */
    public void assertSelectedCount(int expected) {
        getLocator().page().waitForCondition(() -> getSelectedItemCount() == expected);
    }

    // ── Overlay item component queries ─────────────────────────────────

    /**
     * Get a typed component element from an overlay item matching the given
     * text. The component class must be annotated with
     * {@link PlaywrightElement} and have a public constructor accepting a
     * single {@link Locator} parameter.
     *
     * @param itemText text of the overlay item to search in
     * @param type     the element class (e.g. {@code ButtonElement.class})
     * @param <T>      the element type
     * @return the first matching component inside the item
     */
    public <T extends VaadinElement> T getOverlayItemComponent(String itemText, Class<T> type) {
        return createComponent(getOverlayItem(itemText), type);
    }

    /**
     * Get a typed component element from an overlay item at the given
     * visible index. The component class must be annotated with
     * {@link PlaywrightElement} and have a public constructor accepting a
     * single {@link Locator} parameter.
     *
     * @param index 0-based visible item index
     * @param type  the element class (e.g. {@code ButtonElement.class})
     * @param <T>   the element type
     * @return the first matching component inside the item
     */
    public <T extends VaadinElement> T getOverlayItemComponent(int index, Class<T> type) {
        Locator item = getLocator().locator(FIELD_ITEM_TAG_NAME + ":not([hidden])").nth(index);
        return createComponent(item, type);
    }

    // ── Static factories ───────────────────────────────────────────────

    /**
     * Get the {@code MultiSelectComboBoxElement} by its label.
     *
     * @param page  the Playwright page
     * @param label the accessible label of the field
     * @return the matching {@code MultiSelectComboBoxElement}
     */
    public static MultiSelectComboBoxElement getByLabel(Page page, String label) {
        return new MultiSelectComboBoxElement(
                AccessibleNameLocator.find(page, FIELD_TAG_NAME, AriaRole.COMBOBOX, label));
    }

    /**
     * Get the {@code MultiSelectComboBoxElement} by its label within a given scope.
     *
     * @param locator the locator to search within
     * @param label   the accessible label of the field
     * @return the matching {@code MultiSelectComboBoxElement}
     */
    public static MultiSelectComboBoxElement getByLabel(Locator locator, String label) {
        return new MultiSelectComboBoxElement(
                AccessibleNameLocator.find(locator, FIELD_TAG_NAME, AriaRole.COMBOBOX, label));
    }

    // ── Internal ───────────────────────────────────────────────────────

    private Locator getOverlayItem(String label) {
        return getLocator().locator(FIELD_ITEM_TAG_NAME + ":not([hidden])")
                .filter(new Locator.FilterOptions()
                        .setHasText(label)).first();
    }

    private <T extends VaadinElement> T createComponent(Locator parent, Class<T> type) {
        PlaywrightElement annotation = type.getAnnotation(PlaywrightElement.class);
        if (annotation == null) {
            throw new IllegalArgumentException(
                    type.getSimpleName() + " is not annotated with @PlaywrightElement");
        }
        Locator componentLocator = parent.locator(annotation.value()).first();
        try {
            return type.getConstructor(Locator.class).newInstance(componentLocator);
        } catch (InvocationTargetException e) {
            throw new IllegalArgumentException(
                    "Cannot instantiate " + type.getSimpleName(), e.getCause());
        } catch (ReflectiveOperationException e) {
            throw new IllegalArgumentException(
                    "Cannot instantiate " + type.getSimpleName(), e);
        }
    }
}