From c073c7a7bdff0f6ae684effe985991c0d3f0af37 Mon Sep 17 00:00:00 2001 From: Serhii Kulykov Date: Thu, 11 Jul 2024 15:27:22 +0300 Subject: [PATCH] feat: add Popover opened state and related API (#6337) --- .../component/popover/tests/PopoverView.java | 12 +- .../component/popover/tests/PopoverIT.java | 48 +++++++ .../flow/component/popover/Popover.java | 125 ++++++++++++++++++ .../PopoverOpenedChangeListenerTest.java | 98 ++++++++++++++ .../popover/testbench/PopoverElement.java | 17 +++ 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 vaadin-popover-flow-parent/vaadin-popover-flow/src/test/java/com/vaadin/flow/component/popover/PopoverOpenedChangeListenerTest.java diff --git a/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/main/java/com/vaadin/flow/component/popover/tests/PopoverView.java b/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/main/java/com/vaadin/flow/component/popover/tests/PopoverView.java index e48fa7a6ca3..f01233467e6 100644 --- a/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/main/java/com/vaadin/flow/component/popover/tests/PopoverView.java +++ b/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/main/java/com/vaadin/flow/component/popover/tests/PopoverView.java @@ -46,6 +46,16 @@ public PopoverView() { event -> add(target)); attachTarget.setId("attach-target"); - add(popover, clearTarget, detachTarget, attachTarget, target); + NativeButton disableCloseOnEsc = new NativeButton( + "Disable close on Esc", event -> popover.setCloseOnEsc(false)); + disableCloseOnEsc.setId("disable-close-on-esc"); + + NativeButton disableCloseOnOutsideClick = new NativeButton( + "Disable close on outside click", + event -> popover.setCloseOnOutsideClick(false)); + disableCloseOnOutsideClick.setId("disable-close-on-outside-click"); + + add(popover, clearTarget, detachTarget, attachTarget, disableCloseOnEsc, + disableCloseOnOutsideClick, target); } } diff --git a/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/test/java/com/vaadin/flow/component/popover/tests/PopoverIT.java b/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/test/java/com/vaadin/flow/component/popover/tests/PopoverIT.java index b11db0f90b1..5110cd5e104 100644 --- a/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/test/java/com/vaadin/flow/component/popover/tests/PopoverIT.java +++ b/vaadin-popover-flow-parent/vaadin-popover-flow-integration-tests/src/test/java/com/vaadin/flow/component/popover/tests/PopoverIT.java @@ -16,6 +16,7 @@ */ package com.vaadin.flow.component.popover.tests; +import com.vaadin.flow.component.popover.testbench.PopoverElement; import com.vaadin.flow.testutil.TestPath; import com.vaadin.tests.AbstractComponentIT; @@ -24,7 +25,9 @@ import org.junit.Test; import org.openqa.selenium.By; +import org.openqa.selenium.Keys; import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; /** * Integration tests for the {@link PopoverView}. @@ -36,18 +39,23 @@ public class PopoverIT extends AbstractComponentIT { static final String POPOVER_OVERLAY_TAG = "vaadin-popover-overlay"; + PopoverElement popover; + @Before public void init() { open(); + popover = $(PopoverElement.class).first(); } @Test public void clickTarget_popoverOpensAndCloses() { clickTarget(); checkPopoverIsOpened(); + Assert.assertTrue(popover.isOpen()); clickTarget(); checkPopoverIsClosed(); + Assert.assertFalse(popover.isOpen()); } @Test @@ -88,6 +96,46 @@ public void detachTarget_clearAndReattach_clickTarget_popoverDoesNotOpen() { checkPopoverIsClosed(); } + @Test + public void clickOutside_popoverCloses() { + clickTarget(); + checkPopoverIsOpened(); + + $("body").first().click(); + Assert.assertFalse(popover.isOpen()); + } + + @Test + public void disableCloseOnOutsideClick_clickOutside_popoverDoesNotClose() { + clickElementWithJs("disable-close-on-outside-click"); + + clickTarget(); + checkPopoverIsOpened(); + + $("body").first().click(); + Assert.assertTrue(popover.isOpen()); + } + + @Test + public void pressEsc_popoverCloses() { + clickTarget(); + checkPopoverIsOpened(); + + new Actions(getDriver()).sendKeys(Keys.ESCAPE).build().perform(); + Assert.assertFalse(popover.isOpen()); + } + + @Test + public void disableCloseOnEsc_pressEsc_popoverDoesNotClose() { + clickElementWithJs("disable-close-on-esc"); + + clickTarget(); + checkPopoverIsOpened(); + + new Actions(getDriver()).sendKeys(Keys.ESCAPE).build().perform(); + Assert.assertTrue(popover.isOpen()); + } + private void clickTarget() { clickElementWithJs("popover-target"); } diff --git a/vaadin-popover-flow-parent/vaadin-popover-flow/src/main/java/com/vaadin/flow/component/popover/Popover.java b/vaadin-popover-flow-parent/vaadin-popover-flow/src/main/java/com/vaadin/flow/component/popover/Popover.java index ac4d6c66c03..d9e6d72a86f 100644 --- a/vaadin-popover-flow-parent/vaadin-popover-flow/src/main/java/com/vaadin/flow/component/popover/Popover.java +++ b/vaadin-popover-flow-parent/vaadin-popover-flow/src/main/java/com/vaadin/flow/component/popover/Popover.java @@ -29,8 +29,12 @@ import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.DomEvent; import com.vaadin.flow.component.HasAriaLabel; import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.Synchronize; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.Text; import com.vaadin.flow.component.UI; @@ -70,10 +74,81 @@ public class Popover extends Component implements HasAriaLabel, HasComponents { public Popover() { getElement().getNode().addAttachListener(this::attachComponentRenderer); + // Workaround for: https://github.com/vaadin/flow/issues/3496 + getElement().setProperty("opened", false); + updateTrigger(); setOverlayRole("dialog"); } + + /** + * {@code opened-changed} event is sent when the overlay opened state + * changes. + */ + @DomEvent("opened-changed") + public static class OpenedChangeEvent extends ComponentEvent { + private final boolean opened; + + public OpenedChangeEvent(Popover source, boolean fromClient) { + super(source, fromClient); + this.opened = source.isOpened(); + } + + public boolean isOpened() { + return opened; + } + } + + /** + * Opens or closes the popover. + * + * @param opened + * {@code true} to open the popover, {@code false} to close it + */ + public void setOpened(boolean opened) { + if (opened != isOpened()) { + getElement().setProperty("opened", opened); + fireEvent(new OpenedChangeEvent(this, false)); + } + } + + /** + * Opens the popover. + */ + public void open() { + setOpened(true); + } + + /** + * Closes the popover. + */ + public void close() { + setOpened(false); + } + + /** + * Gets the open state from the popover. + * + * @return the {@code opened} property from the popover + */ + @Synchronize(property = "opened", value = "opened-changed") + public boolean isOpened() { + return getElement().getProperty("opened", false); + } + + /** + * Add a listener for event fired by the {@code opened-changed} events. + * + * @param listener + * the listener to add + * @return a Registration for removing the event listener + */ + public Registration addOpenedChangeListener( + ComponentEventListener listener) { + return addListener(OpenedChangeEvent.class, listener); + } + @Override public void setAriaLabel(String ariaLabel) { getElement().setProperty("accessibleName", ariaLabel); @@ -117,6 +192,56 @@ public String getOverlayRole() { return getElement().getProperty("overlayRole"); } + /** + * Gets whether this popover can be closed by pressing the Esc key or not. + *

+ * By default, the popover is closable with Esc. + * + * @return {@code true} if this popover can be closed with the Esc key, + * {@code false} otherwise + */ + public boolean isCloseOnEsc() { + return !getElement().getProperty("noCloseOnEsc", false); + } + + /** + * Sets whether this popover can be closed by pressing the Esc key or not. + *

+ * By default, the popover is closable with Esc. + * + * @param closeOnEsc + * {@code true} to enable closing this popover with the Esc key, + * {@code false} to disable it + */ + public void setCloseOnEsc(boolean closeOnEsc) { + getElement().setProperty("noCloseOnEsc", !closeOnEsc); + } + + /** + * Gets whether this popover can be closed by clicking outside of it or not. + *

+ * By default, the popover is closable with an outside click. + * + * @return {@code true} if this popover can be closed by an outside click, + * {@code false} otherwise + */ + public boolean isCloseOnOutsideClick() { + return !getElement().getProperty("noCloseOnOutsideClick", false); + } + + /** + * Sets whether this popover can be closed by clicking outside of it or not. + *

+ * By default, the popover is closable with an outside click. + * + * @param closeOnOutsideClick + * {@code true} to enable closing this popover with an outside + * click, {@code false} to disable it + */ + public void setCloseOnOutsideClick(boolean closeOnOutsideClick) { + getElement().setProperty("noCloseOnOutsideClick", !closeOnOutsideClick); + } + /** * Sets position of the popover with respect to its target. * diff --git a/vaadin-popover-flow-parent/vaadin-popover-flow/src/test/java/com/vaadin/flow/component/popover/PopoverOpenedChangeListenerTest.java b/vaadin-popover-flow-parent/vaadin-popover-flow/src/test/java/com/vaadin/flow/component/popover/PopoverOpenedChangeListenerTest.java new file mode 100644 index 00000000000..31850b6a836 --- /dev/null +++ b/vaadin-popover-flow-parent/vaadin-popover-flow/src/test/java/com/vaadin/flow/component/popover/PopoverOpenedChangeListenerTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.popover; + +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinSession; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.concurrent.atomic.AtomicReference; + +public class PopoverOpenedChangeListenerTest { + private final UI ui = new UI(); + private Popover popover; + private AtomicReference event; + private ComponentEventListener mockListener; + + @SuppressWarnings("unchecked") + @Before + public void setup() { + UI.setCurrent(ui); + + VaadinSession session = Mockito.mock(VaadinSession.class); + Mockito.when(session.hasLock()).thenReturn(true); + ui.getInternals().setSession(session); + + popover = new Popover(); + ui.add(popover); + + event = new AtomicReference<>(); + popover.addOpenedChangeListener(event::set); + + mockListener = Mockito.mock(ComponentEventListener.class); + popover.addOpenedChangeListener(mockListener); + } + + @After + public void tearDown() { + UI.setCurrent(null); + } + + @Test + public void open() { + popover.open(); + + Assert.assertFalse(event.get().isFromClient()); + Assert.assertTrue(event.get().isOpened()); + assertListenerCalls(1); + + clearCapturedData(); + popover.open(); + Assert.assertNull(event.get()); + assertListenerCalls(0); + } + + @Test + public void close() { + popover.open(); + clearCapturedData(); + + popover.close(); + Assert.assertFalse(event.get().isFromClient()); + Assert.assertFalse(event.get().isOpened()); + assertListenerCalls(1); + + clearCapturedData(); + popover.close(); + Assert.assertNull(event.get()); + assertListenerCalls(0); + } + + private void assertListenerCalls(int expectedCount) { + Mockito.verify(mockListener, Mockito.times(expectedCount)) + .onComponentEvent(Mockito.any()); + } + + private void clearCapturedData() { + event.set(null); + Mockito.reset(mockListener); + } +} diff --git a/vaadin-popover-flow-parent/vaadin-popover-testbench/src/main/java/com/vaadin/flow/component/popover/testbench/PopoverElement.java b/vaadin-popover-flow-parent/vaadin-popover-testbench/src/main/java/com/vaadin/flow/component/popover/testbench/PopoverElement.java index e034019b1b1..f5ca199204f 100644 --- a/vaadin-popover-flow-parent/vaadin-popover-testbench/src/main/java/com/vaadin/flow/component/popover/testbench/PopoverElement.java +++ b/vaadin-popover-flow-parent/vaadin-popover-testbench/src/main/java/com/vaadin/flow/component/popover/testbench/PopoverElement.java @@ -16,6 +16,7 @@ package com.vaadin.flow.component.popover.testbench; import org.openqa.selenium.SearchContext; +import org.openqa.selenium.StaleElementReferenceException; import com.vaadin.testbench.TestBenchElement; import com.vaadin.testbench.elementsbase.Element; @@ -32,4 +33,20 @@ public SearchContext getContext() { // Find child elements inside the overlay, return getPropertyElement("_overlayElement"); } + + /** + * Checks whether the popover is shown. + * + * @return true if the popover is shown, false + * otherwise + */ + public boolean isOpen() { + try { + return getPropertyBoolean("opened"); + } catch (StaleElementReferenceException e) { + // The element is no longer even attached to the DOM + // -> it's not open + return false; + } + } }