diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index 7aa1d14eaa1..563542ca0ff 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -170,7 +170,7 @@ anything else: - **Global state and helper functions live under `window.Vaadin.Flow`** (e.g. `window.Vaadin.Flow.geolocation`, `window.Vaadin.Flow.pageVisibility`, - `window.Vaadin.Flow.componentSizeObserver`). Use annotations on + `window.Vaadin.Flow.elementSizeObserver`). Use annotations on `UI.java` for scripts that need to run globally. - **`init(element)` installers must be idempotent.** A facade may call `window.Vaadin.Flow.xxx.init(this)` more than once per UI element diff --git a/flow-client/src/main/frontend/ElementSizeObserver.ts b/flow-client/src/main/frontend/ElementSizeObserver.ts new file mode 100644 index 00000000000..531eea83cf7 --- /dev/null +++ b/flow-client/src/main/frontend/ElementSizeObserver.ts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +interface VaadinElementSize { + w: number; + h: number; +} + +interface UiElementWithObserver extends HTMLElement { + _elementSizeObserver?: ResizeObserver; +} + +interface ObservedElement extends Element { + _elementSizeId?: number; +} + +const $wnd = window as any; +$wnd.Vaadin ??= {}; +$wnd.Vaadin.Flow ??= {}; +$wnd.Vaadin.Flow.elementSizeObserver = { + /** + * Creates a shared ResizeObserver on the given UI element. Size changes + * are dispatched as "vaadin-element-resize" custom events on the UI + * element, with a `sizes` property mapping element IDs to their new + * width and height. + */ + init(uiElement: UiElementWithObserver): void { + uiElement._elementSizeObserver = new ResizeObserver((entries) => { + const sizes: Record = {}; + for (const entry of entries) { + const target = entry.target as ObservedElement; + if (target.isConnected && entry.contentBoxSize && target._elementSizeId !== undefined) { + sizes[target._elementSizeId] = { + w: Math.round(entry.contentRect.width), + h: Math.round(entry.contentRect.height) + }; + } + } + if (Object.keys(sizes).length > 0) { + const event = new Event('vaadin-element-resize') as Event & { sizes: typeof sizes }; + event.sizes = sizes; + uiElement.dispatchEvent(event); + } + }); + }, + + /** + * Starts observing the given element with the given numeric ID. + */ + observe(uiElement: UiElementWithObserver, element: ObservedElement, id: number): void { + element._elementSizeId = id; + uiElement._elementSizeObserver?.observe(element); + } +}; + +// Empty export to ensure TypeScript emits this as an ES module, +// which is required for Vite to load it via import. +export {}; diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 61cb614b28a..98ad5713680 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -4,6 +4,7 @@ import { type ConnectionStateChangeListener, type ConnectionStateStore } from '@vaadin/common-frontend'; +import './ElementSizeObserver'; import './Geolocation'; import { currentVisibility } from './PageVisibility'; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/Size.java b/flow-server/src/main/java/com/vaadin/flow/component/Size.java new file mode 100644 index 00000000000..10f87bc41f0 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/Size.java @@ -0,0 +1,29 @@ +/* + * 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 java.io.Serializable; + +/** + * Represents a 2D pixel size with a width and a height. + * + * @param width + * the width in pixels + * @param height + * the height in pixels + */ +public record Size(int width, int height) implements Serializable { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/ElementSizeObserver.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/ElementSizeObserver.java new file mode 100644 index 00000000000..7eab8cde1e8 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/ElementSizeObserver.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.ComponentUtil; +import com.vaadin.flow.component.Size; +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 element sizes using a single + * browser {@code ResizeObserver} instance. + *

+ * One instance is created per UI, lazily via {@link #get(UI)} when the first + * element's size is observed. A single browser {@code ResizeObserver} is used + * to track all observed elements, dispatching a custom + * {@code "vaadin-element-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 ElementSizeObserver implements Serializable { + + private static final String EVENT_NAME = "vaadin-element-resize"; + + private final Element uiElement; + private final Map> idToSignal = new HashMap<>(); + private final Map, Integer> signalToId = new HashMap<>(); + private int nextId = 0; + + /** + * Returns the ElementSizeObserver for the given UI, creating it lazily. + * + * @param ui + * the UI to get the observer for + * @return the observer instance + */ + public static ElementSizeObserver get(UI ui) { + ElementSizeObserver observer = ComponentUtil.getData(ui, + ElementSizeObserver.class); + if (observer == null) { + observer = new ElementSizeObserver(ui); + ComponentUtil.setData(ui, ElementSizeObserver.class, observer); + } + return observer; + } + + private ElementSizeObserver(UI ui) { + this.uiElement = ui.getElement(); + + uiElement + .executeJs("window.Vaadin.Flow.elementSizeObserver.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 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.elementSizeObserver.observe(this, $0, $1)", + element, id); + } + + /** + * Stops observing the element associated with the given signal. + * + * @param signal + * the signal whose element 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/java/com/vaadin/flow/dom/Element.java b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java index e993d166495..0c568ee7bfb 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/Element.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java @@ -42,6 +42,9 @@ import com.vaadin.flow.component.ComponentUtil; import com.vaadin.flow.component.ScrollIntoViewOption; import com.vaadin.flow.component.ScrollOptions; +import com.vaadin.flow.component.Size; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.internal.ElementSizeObserver; import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation; import com.vaadin.flow.component.page.Page; @@ -54,7 +57,9 @@ import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.JavaScriptSemantics; import com.vaadin.flow.internal.StateNode; +import com.vaadin.flow.internal.StateTree; import com.vaadin.flow.internal.nodefeature.SignalBindingFeature; +import com.vaadin.flow.internal.nodefeature.SizeSignalFeature; import com.vaadin.flow.internal.nodefeature.VirtualChildrenList; import com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.server.Command; @@ -64,6 +69,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; /** * Represents an element in the DOM. @@ -1651,6 +1657,49 @@ public void execute() { }); } + /** + * Returns a signal that tracks the current size of this element as observed + * by the browser's {@code ResizeObserver} API. + *

+ * The signal is lazily initialized on first access. It automatically starts + * observing when the element 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 element size + */ + public Signal sizeSignal() { + SizeSignalFeature feature = getNode() + .getFeature(SizeSignalFeature.class); + ValueSignal signal = feature.getOrCreateSignal(); + if (!feature.isObserverRegistered()) { + feature.markObserverRegistered(); + registerSizeObservation(signal); + } + return signal.asReadonly(); + } + + private void registerSizeObservation(ValueSignal signal) { + ElementAttachListener attachListener = attach -> { + UI ui = ((StateTree) getNode().getOwner()).getUI(); + ElementSizeObserver.get(ui).observe(this, signal); + + Registration[] detachReg = new Registration[1]; + detachReg[0] = addDetachListener(detach -> { + if (!ui.isClosing()) { + ElementSizeObserver.get(ui).unobserve(signal); + } + detachReg[0].remove(); + }); + }; + addAttachListener(attachListener); + if (getNode().isAttached()) { + attachListener.onAttach(new ElementAttachEvent(this)); + } + } + @Override public String toString() { return getOuterHTML(); diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java index bd16e79968d..4c2892dbce7 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java @@ -56,6 +56,7 @@ import com.vaadin.flow.internal.nodefeature.ReturnChannelMap; import com.vaadin.flow.internal.nodefeature.ShadowRootData; import com.vaadin.flow.internal.nodefeature.SignalBindingFeature; +import com.vaadin.flow.internal.nodefeature.SizeSignalFeature; import com.vaadin.flow.internal.nodefeature.VirtualChildrenList; import com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.shared.Registration; @@ -90,8 +91,8 @@ public class BasicElementStateProvider extends AbstractNodeStateProvider { PolymerServerEventHandlers.class, ClientCallableHandlers.class, PolymerEventListenerMap.class, ShadowRootData.class, AttachExistingElementFeature.class, VirtualChildrenList.class, - ReturnChannelMap.class, InertData.class, - SignalBindingFeature.class }; + ReturnChannelMap.class, InertData.class, SignalBindingFeature.class, + SizeSignalFeature.class }; private BasicElementStateProvider() { // Not meant to be sub classed and only once instance should ever exist diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java index a0cf2e794fe..0e6c0bd53cb 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatureRegistry.java @@ -74,6 +74,8 @@ private NodeFeatureData( /* Signal binding features */ registerFeature(SignalBindingFeature.class, SignalBindingFeature::new, NodeFeatures.SIGNAL_BINDING); + registerFeature(SizeSignalFeature.class, SizeSignalFeature::new, + NodeFeatures.SIZE_SIGNAL); /* Common element features */ registerFeature(ElementChildrenList.class, ElementChildrenList::new, diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java index 1378bb57e09..b181e71acd9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeFeatures.java @@ -147,6 +147,11 @@ public final class NodeFeatures { */ public static final int SIGNAL_BINDING = 27; + /** + * Id for {@link SizeSignalFeature}. + */ + public static final int SIZE_SIGNAL = 28; + private NodeFeatures() { // Only static } diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/SizeSignalFeature.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/SizeSignalFeature.java new file mode 100644 index 00000000000..7214d43caae --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/SizeSignalFeature.java @@ -0,0 +1,76 @@ +/* + * 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.internal.nodefeature; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.Size; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.StateNode; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Holds the lazily-allocated {@link ValueSignal} that backs + * {@link Element#sizeSignal()} and tracks whether the per-UI resize observer + * has been wired up for the element. Used to keep the same signal instance + * across multiple {@code sizeSignal()} calls on a given element, and to ensure + * the attach/detach observer registration happens only once. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +@NullMarked +public class SizeSignalFeature extends ServerSideFeature { + + private @Nullable ValueSignal signal; + private boolean observerRegistered; + + /** + * Creates a SizeSignalFeature for the given node. + * + * @param node + * the node which supports the feature + */ + public SizeSignalFeature(StateNode node) { + super(node); + } + + /** + * Returns the underlying value signal, allocating it on first access with a + * default value of {@code Size(0, 0)}. + */ + public ValueSignal getOrCreateSignal() { + if (signal == null) { + signal = new ValueSignal<>(new Size(0, 0)); + } + return signal; + } + + /** + * Returns whether the observer registration listeners have already been + * installed for this element. + */ + public boolean isObserverRegistered() { + return observerRegistered; + } + + /** + * Marks the observer registration listeners as installed. + */ + public void markObserverRegistered() { + this.observerRegistered = true; + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/ElementJacksonTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/ElementJacksonTest.java index 5dcd7829ffd..00cdd988961 100644 --- a/flow-server/src/test/java/com/vaadin/flow/dom/ElementJacksonTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/dom/ElementJacksonTest.java @@ -178,6 +178,9 @@ void publicElementMethodsShouldReturnElement() { ignore.add("bindText"); ignore.add("bindVisible"); + // returns a read-only Signal + ignore.add("sizeSignal"); + // returns void ignore.add("flashClass"); diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/ElementSizeSignalTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/ElementSizeSignalTest.java new file mode 100644 index 00000000000..8aa28493611 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/dom/ElementSizeSignalTest.java @@ -0,0 +1,109 @@ +/* + * 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.dom; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Size; +import com.vaadin.flow.component.Tag; +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 ElementSizeSignalTest { + + @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.getElement().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.getElement().sizeSignal(); + assertEquals(new Size(0, 0), signal.peek()); + } + + @Test + void sizeSignal_returnsSameInstance() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal first = div.getElement().sizeSignal(); + Signal second = div.getElement().sizeSignal(); + // Both wrappers read from the same underlying ValueSignal + fireElementResizeEvent(ui, 0, 640, 480); + assertEquals(new Size(640, 480), first.peek()); + assertEquals(new Size(640, 480), second.peek()); + } + + @Test + void sizeSignal_updatesOnResizeEvent() { + MockUI ui = new MockUI(); + TestDiv div = new TestDiv(); + ui.add(div); + + Signal signal = div.getElement().sizeSignal(); + assertEquals(new Size(0, 0), signal.peek()); + + fireElementResizeEvent(ui, 0, 800, 600); + assertEquals(new Size(800, 600), signal.peek()); + + fireElementResizeEvent(ui, 0, 1024, 768); + assertEquals(new Size(1024, 768), signal.peek()); + } + + private void fireElementResizeEvent(MockUI ui, int elementId, 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(elementId), 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-element-resize", eventData)); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java index 3964ae29e07..62a81e36dc4 100644 --- a/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/dom/ElementTest.java @@ -180,6 +180,9 @@ void publicElementMethodsShouldReturnElement() { ignore.add("bindText"); ignore.add("bindVisible"); + // returns a read-only Signal + ignore.add("sizeSignal"); + // returns void ignore.add("flashClass"); diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java index c46bd6b04dd..dcffe0c3fc6 100644 --- a/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/internal/nodefeature/NodeFeatureTest.java @@ -106,6 +106,7 @@ private static Map, Integer> buildExpectedIdMap() { expectedIds.put(InertData.class, NodeFeatures.INERT_DATA); expectedIds.put(SignalBindingFeature.class, NodeFeatures.SIGNAL_BINDING); + expectedIds.put(SizeSignalFeature.class, NodeFeatures.SIZE_SIGNAL); return expectedIds; } @@ -148,7 +149,7 @@ void priorityOrder() { BasicTypeValue.class, /* Signal binding feature */ - SignalBindingFeature.class, + SignalBindingFeature.class, SizeSignalFeature.class, /* Common element features */ ElementChildrenList.class, ElementPropertyMap.class,