From b21d24e1389e3a9f760a0f10f75eb75581987673 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Wed, 18 Feb 2026 13:56:38 +0000 Subject: [PATCH] feat: add signal bind methods to context-menu component --- .../vaadin-context-menu-flow/pom.xml | 6 + .../component/contextmenu/MenuItemBase.java | 52 ++++++- .../contextmenu/MenuItemSignalTest.java | 127 ++++++++++++++++++ 3 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/test/java/com/vaadin/flow/component/contextmenu/MenuItemSignalTest.java diff --git a/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/pom.xml b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/pom.xml index 1f9c4d89d8e..6ab4c39867f 100644 --- a/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/pom.xml +++ b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/pom.xml @@ -33,6 +33,12 @@ vaadin-flow-components-base ${project.version} + + com.vaadin + vaadin-flow-components-test-util + ${project.version} + test + com.vaadin vaadin-renderer-flow diff --git a/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/main/java/com/vaadin/flow/component/contextmenu/MenuItemBase.java b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/main/java/com/vaadin/flow/component/contextmenu/MenuItemBase.java index 1b179fa579f..d93964f254b 100644 --- a/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/main/java/com/vaadin/flow/component/contextmenu/MenuItemBase.java +++ b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/main/java/com/vaadin/flow/component/contextmenu/MenuItemBase.java @@ -18,6 +18,7 @@ import java.io.Serializable; import java.util.Arrays; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -26,8 +27,11 @@ import com.vaadin.flow.component.HasComponents; import com.vaadin.flow.component.HasEnabled; import com.vaadin.flow.component.HasText; +import com.vaadin.flow.component.SignalPropertySupport; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.shared.internal.DisableOnClickController; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.signals.Signal; /** * Base class for item component used inside {@link ContextMenu}s. @@ -54,6 +58,9 @@ public abstract class MenuItemBase, I extends private boolean checkable = false; + private SignalPropertySupport checkedSupport; + private SerializableConsumer boundCheckedWriteCallback; + private Set themeNames = new LinkedHashSet<>(); private final DisableOnClickController> disableOnClickController = new DisableOnClickController<>( @@ -69,7 +76,11 @@ public MenuItemBase(C contextMenu) { this.contextMenu = contextMenu; getElement().addEventListener("click", e -> { if (checkable) { - setChecked(!isChecked()); + if (boundCheckedWriteCallback != null) { + boundCheckedWriteCallback.accept(!isChecked()); + } else { + setChecked(!isChecked()); + } } }); @@ -166,11 +177,42 @@ public void setChecked(boolean checked) { + "Use setCheckable() to make the item checkable first."); } - getElement().setProperty("_checked", checked); + getCheckedSupport().set(checked); + } - executeJsWhenAttached( - "window.Vaadin.Flow.contextMenuConnector.setChecked($0, $1)", - getElement(), checked); + /** + * Binds the checked state to the given signal. The binding is two-way: + * signal changes push to the DOM property, and client-side click events + * invoke the write callback. + *

+ * While a signal is bound, any attempt to set the checked state manually + * throws {@link com.vaadin.flow.signals.BindingActiveException}. + * + * @param signal + * the signal to bind, not {@code null} + * @param writeCallback + * the callback to propagate value changes back, or {@code null} + * for one-way binding + * @since 25.1 + */ + public void bindChecked(Signal signal, + SerializableConsumer writeCallback) { + Objects.requireNonNull(signal, "Signal cannot be null"); + boundCheckedWriteCallback = writeCallback; + getCheckedSupport() + .bind(signal.map(v -> v == null ? Boolean.FALSE : v)); + } + + private SignalPropertySupport getCheckedSupport() { + if (checkedSupport == null) { + checkedSupport = SignalPropertySupport.create(this, checked -> { + getElement().setProperty("_checked", checked); + executeJsWhenAttached( + "window.Vaadin.Flow.contextMenuConnector.setChecked($0, $1)", + getElement(), checked); + }); + } + return checkedSupport; } /** diff --git a/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/test/java/com/vaadin/flow/component/contextmenu/MenuItemSignalTest.java b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/test/java/com/vaadin/flow/component/contextmenu/MenuItemSignalTest.java new file mode 100644 index 00000000000..133eee2ad96 --- /dev/null +++ b/vaadin-context-menu-flow-parent/vaadin-context-menu-flow/src/test/java/com/vaadin/flow/component/contextmenu/MenuItemSignalTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2000-2026 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.contextmenu; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.signals.BindingActiveException; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.tests.AbstractSignalsUnitTest; + +public class MenuItemSignalTest extends AbstractSignalsUnitTest { + + private ContextMenu contextMenu; + private MenuItem item; + private ValueSignal signal; + + @Before + public void setup() { + // ContextMenu's MenuItemsArrayGenerator triggers chunk loading on + // attach, which requires a DeploymentConfiguration on the session. + VaadinSession session = VaadinSession.getCurrent(); + if (session != null && session.getConfiguration() == null) { + DeploymentConfiguration config = Mockito + .mock(DeploymentConfiguration.class); + Mockito.when(session.getService().getDeploymentConfiguration()) + .thenReturn(config); + } + contextMenu = new ContextMenu(); + item = contextMenu.addItem(""); + item.setCheckable(true); + signal = new ValueSignal<>(false); + } + + @After + public void tearDown() { + if (contextMenu != null && contextMenu.isAttached()) { + contextMenu.removeFromParent(); + } + } + + @Test + public void bindChecked_signalBound_propertySync() { + item.bindChecked(signal, signal::set); + UI.getCurrent().add(contextMenu); + flushBeforeClientResponse(); + + Assert.assertFalse(item.isChecked()); + + signal.set(true); + Assert.assertTrue(item.isChecked()); + + signal.set(false); + Assert.assertFalse(item.isChecked()); + } + + @Test + public void bindChecked_notAttached_noEffect() { + item.bindChecked(signal, signal::set); + + boolean initial = item.isChecked(); + signal.set(true); + Assert.assertEquals(initial, item.isChecked()); + } + + @Test + public void bindChecked_detachAndReattach() { + item.bindChecked(signal, signal::set); + UI.getCurrent().add(contextMenu); + flushBeforeClientResponse(); + + signal.set(true); + Assert.assertTrue(item.isChecked()); + + contextMenu.removeFromParent(); + signal.set(false); + Assert.assertTrue(item.isChecked()); + + UI.getCurrent().add(contextMenu); + flushBeforeClientResponse(); + Assert.assertFalse(item.isChecked()); + } + + @Test(expected = BindingActiveException.class) + public void bindChecked_setWhileBound_throws() { + item.bindChecked(signal, signal::set); + UI.getCurrent().add(contextMenu); + + item.setChecked(true); + } + + @Test(expected = BindingActiveException.class) + public void bindChecked_doubleBind_throws() { + item.bindChecked(signal, signal::set); + var other = new ValueSignal<>(true); + item.bindChecked(other, other::set); + } + + @Test(expected = NullPointerException.class) + public void bindChecked_nullSignal_throwsNPE() { + item.bindChecked(null, null); + } + + private void flushBeforeClientResponse() { + UI.getCurrent().getInternals().getStateTree() + .runExecutionsBeforeClientResponse(); + } +}