diff --git a/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java b/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java index ebeb5f8e434..de9aeabbd2f 100644 --- a/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java +++ b/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java @@ -35,6 +35,7 @@ import com.vaadin.flow.component.shared.HasThemeVariant; import com.vaadin.flow.component.shared.HasTooltip; import com.vaadin.flow.component.shared.SlotUtils; +import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.function.SerializableFunction; import com.vaadin.flow.shared.Registration; import com.vaadin.flow.signals.Signal; @@ -441,6 +442,28 @@ public void setOpened(boolean opened) { doSetOpened(opened); } + /** + * Binds the opened state to the given signal. The binding is two-way: + * signal changes push to the DOM property, and client-side property changes + * invoke the write callback. + *

+ * While a signal is bound, any attempt to set the opened 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 a read-only binding + * @since 25.1 + */ + public void bindOpened(Signal signal, + SerializableConsumer writeCallback) { + Objects.requireNonNull(signal, "Signal cannot be null"); + getElement().bindProperty("opened", + signal.map(v -> v == null ? Boolean.FALSE : v), writeCallback); + } + public static class OpenedChangeEvent extends ComponentEvent

{ private final boolean opened; diff --git a/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java b/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java new file mode 100644 index 00000000000..4479076b948 --- /dev/null +++ b/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java @@ -0,0 +1,104 @@ +/* + * 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.details; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.signals.BindingActiveException; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.tests.AbstractSignalsUnitTest; + +public class DetailsOpenedSignalTest extends AbstractSignalsUnitTest { + + private Details details; + private ValueSignal signal; + + @Before + public void setup() { + details = new Details(); + signal = new ValueSignal<>(false); + } + + @After + public void tearDown() { + if (details != null && details.isAttached()) { + details.removeFromParent(); + } + } + + @Test + public void bindOpened_signalBound_propertySync() { + details.bindOpened(signal, signal::set); + UI.getCurrent().add(details); + + Assert.assertFalse(details.isOpened()); + + signal.set(true); + Assert.assertTrue(details.isOpened()); + + signal.set(false); + Assert.assertFalse(details.isOpened()); + } + + @Test + public void bindOpened_notAttached_noEffect() { + details.bindOpened(signal, signal::set); + + boolean initial = details.isOpened(); + signal.set(true); + Assert.assertEquals(initial, details.isOpened()); + } + + @Test + public void bindOpened_detachAndReattach() { + details.bindOpened(signal, signal::set); + UI.getCurrent().add(details); + + signal.set(true); + Assert.assertTrue(details.isOpened()); + + details.removeFromParent(); + signal.set(false); + Assert.assertTrue(details.isOpened()); + + UI.getCurrent().add(details); + Assert.assertFalse(details.isOpened()); + } + + @Test(expected = BindingActiveException.class) + public void bindOpened_setWhileBound_throws() { + details.bindOpened(signal, signal::set); + UI.getCurrent().add(details); + + details.setOpened(true); + } + + @Test(expected = BindingActiveException.class) + public void bindOpened_doubleBind_throws() { + details.bindOpened(signal, signal::set); + var other = new ValueSignal<>(true); + details.bindOpened(other, other::set); + } + + @Test(expected = NullPointerException.class) + public void bindOpened_nullSignal_throwsNPE() { + details.bindOpened(null, null); + } +}