Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions flow-server/src/main/java/com/vaadin/flow/component/Component.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -927,6 +941,59 @@ public <T> T findAncestor(Class<T> componentType) {
return null;
}

/**
* Returns a signal that tracks the current size of this component as
* observed by the browser's {@code ResizeObserver} API.
* <p>
* 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.
* <p>
* The returned signal is read-only.
*
* @return a read-only signal with the current component size
*/
@SuppressWarnings("unchecked")
public Signal<Size> sizeSignal() {
ValueSignal<Size> signal = (ValueSignal<Size>) 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<Size> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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<Integer, ValueSignal<Component.Size>> idToSignal = new HashMap<>();
private final Map<ValueSignal<Component.Size>, 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<Component.Size> 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<Component.Size> 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<Component.Size> signal) {
Integer id = signalToId.remove(signal);
if (id != null) {
idToSignal.remove(id);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
};
Original file line number Diff line number Diff line change
@@ -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<Component.Size> 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<Component.Size> 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<Component.Size> first = div.sizeSignal();
Signal<Component.Size> 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<Component.Size> 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));
}
}
Loading