diff --git a/flow-server/src/main/java/com/vaadin/flow/component/Component.java b/flow-server/src/main/java/com/vaadin/flow/component/Component.java index 7a98f1e82e9..afd89144a50 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/Component.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/Component.java @@ -28,6 +28,7 @@ import java.util.stream.Stream; import com.vaadin.flow.component.internal.ComponentMetaData; +import com.vaadin.flow.component.internal.ComponentSizeObserver; import com.vaadin.flow.component.internal.ComponentTracker; import com.vaadin.flow.component.template.Id; import com.vaadin.flow.dom.DomListenerRegistration; @@ -45,6 +46,7 @@ import com.vaadin.flow.shared.Registration; import com.vaadin.flow.signals.BindingActiveException; import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; /** * A Component is a higher level abstraction of an {@link Element} or a @@ -62,6 +64,18 @@ public abstract class Component implements HasStyle, AttachNotifier, DetachNotifier { + /** + * Represents the size of a component as observed by the browser's + * {@code ResizeObserver} API. + * + * @param width + * the component width in pixels + * @param height + * the component height in pixels + */ + public record Size(int width, int height) implements Serializable { + } + /** * Encapsulates data required for mapping a new component instance to an * existing element. @@ -927,6 +941,59 @@ public T findAncestor(Class componentType) { return null; } + /** + * Returns a signal that tracks the current size of this component as + * observed by the browser's {@code ResizeObserver} API. + *

+ * The signal is lazily initialized on first access. It automatically starts + * observing when the component is attached and stops when detached. The + * initial value is {@code Size(0, 0)} until the browser reports the actual + * size. + *

+ * The returned signal is read-only. + * + * @return a read-only signal with the current component size + */ + @SuppressWarnings("unchecked") + public Signal sizeSignal() { + ValueSignal signal = (ValueSignal) ComponentUtil + .getData(this, Size.class.getName()); + if (signal == null) { + signal = new ValueSignal<>(new Size(0, 0)); + ComponentUtil.setData(this, Size.class.getName(), signal); + setupSizeObservation(signal); + } + return signal.asReadonly(); + } + + private void setupSizeObservation(ValueSignal signal) { + addAttachListener(attach -> { + UI ui = attach.getUI(); + ComponentSizeObserver.get(ui).observe(getElement(), signal); + + addDetachListener(detach -> { + if (!detach.getUI().isClosing()) { + ComponentSizeObserver.get(detach.getUI()).unobserve(signal); + } + detach.unregisterListener(); + }); + }); + + if (isAttached()) { + getUI().ifPresent(ui -> { + ComponentSizeObserver.get(ui).observe(getElement(), signal); + + addDetachListener(detach -> { + if (!detach.getUI().isClosing()) { + ComponentSizeObserver.get(detach.getUI()) + .unobserve(signal); + } + detach.unregisterListener(); + }); + }); + } + } + /** * Removes the component from its parent. */ diff --git a/flow-server/src/main/java/com/vaadin/flow/component/UI.java b/flow-server/src/main/java/com/vaadin/flow/component/UI.java index e0b1bfe81f2..9a60080977f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/UI.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/UI.java @@ -109,6 +109,7 @@ * @since 1.0 */ @JsModule("@vaadin/common-frontend/ConnectionIndicator.js") +@JsModule("./componentSizeObserver.js") public class UI extends Component implements PollNotifier, HasComponents, RouterLayout { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/ComponentSizeObserver.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/ComponentSizeObserver.java new file mode 100644 index 00000000000..688f032f772 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/ComponentSizeObserver.java @@ -0,0 +1,121 @@ +/* + * 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.internal; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Per-UI shared ResizeObserver manager that tracks component sizes using a + * single browser {@code ResizeObserver} instance. + *

+ * One instance is created per UI, lazily via {@link #get(UI)} when the first + * component's size is observed. A single browser {@code ResizeObserver} is used + * to track all observed elements, dispatching a custom + * {@code "vaadin-component-resize"} event on the UI element with aggregated + * size data. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public class ComponentSizeObserver implements Serializable { + + private static final String EVENT_NAME = "vaadin-component-resize"; + + private final Element uiElement; + private final Map> idToSignal = new HashMap<>(); + private final Map, Integer> signalToId = new HashMap<>(); + private int nextId = 0; + + /** + * Returns the ComponentSizeObserver for the given UI, creating it lazily. + * + * @param ui + * the UI to get the observer for + * @return the observer instance + */ + public static ComponentSizeObserver get(UI ui) { + ComponentSizeObserver observer = ComponentUtil.getData(ui, + ComponentSizeObserver.class); + if (observer == null) { + observer = new ComponentSizeObserver(ui); + ComponentUtil.setData(ui, ComponentSizeObserver.class, observer); + } + return observer; + } + + private ComponentSizeObserver(UI ui) { + this.uiElement = ui.getElement(); + + uiElement.executeJs( + "window.Vaadin.Flow.componentSizeObserver.init(this)"); + + uiElement.addEventListener(EVENT_NAME, event -> { + ObjectNode sizes = (ObjectNode) event.getEventData() + .get("event.sizes"); + for (String idStr : sizes.propertyNames()) { + int id = Integer.parseInt(idStr); + ValueSignal signal = idToSignal.get(id); + if (signal != null) { + ObjectNode size = (ObjectNode) sizes.get(idStr); + int w = size.get("w").intValue(); + int h = size.get("h").intValue(); + signal.set(new Component.Size(w, h)); + } + } + }).addEventData("event.sizes").debounce(100).allowInert(); + } + + /** + * Starts observing the given element and updates the signal with size + * changes. + * + * @param element + * the element to observe + * @param signal + * the signal to update + */ + public void observe(Element element, ValueSignal signal) { + int id = nextId++; + idToSignal.put(id, signal); + signalToId.put(signal, id); + + uiElement.executeJs( + "window.Vaadin.Flow.componentSizeObserver.observe(this, $0, $1)", + element, id); + } + + /** + * Stops observing the component associated with the given signal. + * + * @param signal + * the signal whose component should stop being observed + */ + public void unobserve(ValueSignal signal) { + Integer id = signalToId.remove(signal); + if (id != null) { + idToSignal.remove(id); + } + } +} diff --git a/flow-server/src/main/resources/META-INF/frontend/componentSizeObserver.js b/flow-server/src/main/resources/META-INF/frontend/componentSizeObserver.js new file mode 100644 index 00000000000..7e4528a8be1 --- /dev/null +++ b/flow-server/src/main/resources/META-INF/frontend/componentSizeObserver.js @@ -0,0 +1,37 @@ +window.Vaadin = window.Vaadin || {}; +window.Vaadin.Flow = window.Vaadin.Flow || {}; +window.Vaadin.Flow.componentSizeObserver = { + /** + * Creates a shared ResizeObserver on the given UI element. + * Size changes are dispatched as "vaadin-component-resize" + * custom events on the UI element. + */ + init: function (uiElement) { + uiElement._componentSizeObserver = new ResizeObserver(function (entries) { + var sizes = {}; + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (entry.target.isConnected && entry.contentBoxSize) { + var id = entry.target._componentSizeId; + sizes[id] = { + w: Math.round(entry.contentRect.width), + h: Math.round(entry.contentRect.height) + }; + } + } + if (Object.keys(sizes).length > 0) { + var event = new Event('vaadin-component-resize'); + event.sizes = sizes; + uiElement.dispatchEvent(event); + } + }); + }, + + /** + * Starts observing the given element with the given numeric ID. + */ + observe: function (uiElement, element, id) { + element._componentSizeId = id; + uiElement._componentSizeObserver.observe(element); + } +}; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/ComponentSizeSignalTest.java b/flow-server/src/test/java/com/vaadin/flow/component/ComponentSizeSignalTest.java new file mode 100644 index 00000000000..c8037b5b9af --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/ComponentSizeSignalTest.java @@ -0,0 +1,107 @@ +/* + * 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; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.shared.JsonConstants; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.tests.util.MockUI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ComponentSizeSignalTest { + + @Tag(Tag.DIV) + private static class TestDiv extends Component { + } + + @Test + void sizeSignal_isReadOnly() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal signal = div.sizeSignal(); + assertFalse(signal instanceof ValueSignal, + "sizeSignal() should return a read-only signal"); + } + + @Test + void sizeSignal_defaultValue_isZeroByZero() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal signal = div.sizeSignal(); + assertEquals(new Component.Size(0, 0), signal.get()); + } + + @Test + void sizeSignal_returnsSameInstance() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal first = div.sizeSignal(); + Signal second = div.sizeSignal(); + // Both wrappers read from the same underlying ValueSignal + fireComponentResizeEvent(ui, 0, 640, 480); + assertEquals(new Component.Size(640, 480), first.get()); + assertEquals(new Component.Size(640, 480), second.get()); + } + + @Test + void sizeSignal_updatesOnResizeEvent() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal signal = div.sizeSignal(); + assertEquals(new Component.Size(0, 0), signal.get()); + + fireComponentResizeEvent(ui, 0, 800, 600); + assertEquals(new Component.Size(800, 600), signal.get()); + + fireComponentResizeEvent(ui, 0, 1024, 768); + assertEquals(new Component.Size(1024, 768), signal.get()); + } + + private void fireComponentResizeEvent(MockUI ui, int componentId, int width, + int height) { + ObjectNode eventData = JacksonUtils.createObjectNode(); + + ObjectNode sizes = JacksonUtils.createObjectNode(); + ObjectNode sizeEntry = JacksonUtils.createObjectNode(); + sizeEntry.put("w", width); + sizeEntry.put("h", height); + sizes.set(String.valueOf(componentId), sizeEntry); + + eventData.set("event.sizes", sizes); + eventData.put(JsonConstants.EVENT_DATA_PHASE, + JsonConstants.EVENT_PHASE_TRAILING); + + ui.getElement().getNode().getFeature(ElementListenerMap.class) + .fireEvent(new DomEvent(ui.getElement(), + "vaadin-component-resize", eventData)); + } +}