diff --git a/src/main/java/com/example/MissingAPI.java b/src/main/java/com/example/MissingAPI.java index d7d1115a..7c9f561c 100644 --- a/src/main/java/com/example/MissingAPI.java +++ b/src/main/java/com/example/MissingAPI.java @@ -1,14 +1,19 @@ package com.example; +import java.io.Serializable; import java.util.List; +import java.util.UUID; import java.util.stream.Stream; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.tabs.TabSheet; import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.dom.DomListenerRegistration; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.signals.Signal; import com.vaadin.flow.signals.local.ListSignal; +import com.vaadin.flow.signals.local.ValueSignal; /** * Temporary helper class providing static methods for Signal-based component @@ -147,4 +152,76 @@ public static void tabsSyncSelectedIndex(TabSheet tabs, public static Stream getValues(ListSignal signal) { return signal.get().stream().map(Signal::get); } + + /** + * A component's size in pixels, mirroring the future + * {@code Component.Size} API from vaadin/flow#23618. + */ + public record ComponentSize(int width, int height) implements Serializable { + } + + private static final String RESIZE_EVENT = "component-resize"; + + private static final String SETUP_RESIZE_OBSERVER_JS = """ + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const width = Math.floor(entry.contentRect.width); + const height = Math.floor(entry.contentRect.height); + const event = new CustomEvent('%s', { + detail: { width: width, height: height } + }); + this.dispatchEvent(event); + } + }); + resizeObserver.observe(this); + window[$0] = resizeObserver; + const rect = this.getBoundingClientRect(); + const event = new CustomEvent('%s', { + detail: { width: Math.floor(rect.width), height: Math.floor(rect.height) } + }); + this.dispatchEvent(event); + """.formatted(RESIZE_EVENT, RESIZE_EVENT); + + private static final String CLEANUP_RESIZE_OBSERVER_JS = """ + if (window[$0]) { + window[$0].disconnect(); + delete window[$0]; + } + """; + + /** + * Creates a read-only signal that tracks the size of the given component's + * element using a ResizeObserver. + *

+ * This mirrors the future {@code Component.sizeSignal()} API from + * vaadin/flow#23618. When that PR merges, replace + * {@code MissingAPI.sizeSignal(component)} with + * {@code component.sizeSignal()}. + */ + public static Signal sizeSignal(Component component) { + ValueSignal signal = new ValueSignal<>( + new ComponentSize(0, 0)); + + component.addAttachListener(attachEvent -> { + String cleanupKey = UUID.randomUUID().toString(); + + DomListenerRegistration reg = component.getElement() + .addEventListener(RESIZE_EVENT, event -> { + signal.set(event.getEventDetail( + ComponentSize.class)); + }); + + component.getElement().executeJs(SETUP_RESIZE_OBSERVER_JS, + cleanupKey); + + component.addDetachListener(detachEvent -> { + detachEvent.unregisterListener(); + reg.remove(); + detachEvent.getUI().getPage().executeJs( + CLEANUP_RESIZE_OBSERVER_JS, cleanupKey); + }); + }); + + return signal.asReadonly(); + } } diff --git a/src/main/java/com/example/usecase11/UseCase11View.java b/src/main/java/com/example/usecase11/UseCase11View.java index cf676445..6cb92968 100644 --- a/src/main/java/com/example/usecase11/UseCase11View.java +++ b/src/main/java/com/example/usecase11/UseCase11View.java @@ -2,12 +2,10 @@ import jakarta.annotation.security.PermitAll; -import java.util.UUID; - +import com.example.MissingAPI; +import com.example.MissingAPI.ComponentSize; import com.example.views.MainLayout; -import com.vaadin.flow.component.AttachEvent; -import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H2; @@ -19,7 +17,6 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.signals.Signal; -import com.vaadin.flow.signals.local.ValueSignal; /** * Use Case 11: Responsive Layout with Container Size Signal @@ -40,25 +37,13 @@ @PermitAll public class UseCase11View extends VerticalLayout { - public record ContainerSize(int width, int height) { - public boolean isSmall() { - return width < 400; // Small breakpoint - } - - public boolean isMedium() { - return width >= 400 && width < 700; // Medium breakpoint - } - - public boolean isLarge() { - return width >= 700; // Large breakpoint - } - } + private static final int SMALL_BREAKPOINT = 400; + private static final int LARGE_BREAKPOINT = 700; - private final ValueSignal containerSizeSignal = new ValueSignal<>( - new ContainerSize(600, 400)); - private final Signal isSmall = containerSizeSignal.map(ContainerSize::isSmall); - private final Signal isMedium = containerSizeSignal.map(ContainerSize::isMedium); - private final Signal isLarge = containerSizeSignal.map(ContainerSize::isLarge); + private final Signal containerSizeSignal; + private final Signal isSmall; + private final Signal isMedium; + private final Signal isLarge; private Div responsiveContent; @@ -67,6 +52,21 @@ public UseCase11View() { setPadding(true); setSizeFull(); + // Create responsive content container first so we can set up the size + // signal before building other panels that depend on it + responsiveContent = new Div(); + responsiveContent.getStyle().set("padding", "1em") + .set("height", "100%").set("overflow-y", "auto") + .set("background-color", "#ffffff"); + containerSizeSignal = MissingAPI.sizeSignal(responsiveContent); + isSmall = containerSizeSignal + .map(size -> size.width() < SMALL_BREAKPOINT); + isMedium = containerSizeSignal.map(size -> size + .width() >= SMALL_BREAKPOINT + && size.width() < LARGE_BREAKPOINT); + isLarge = containerSizeSignal + .map(size -> size.width() >= LARGE_BREAKPOINT); + H2 title = new H2( "Use Case 11: Responsive Content in Resizable Container"); @@ -85,8 +85,8 @@ public UseCase11View() { // Left side: Static info panel Div infoPanel = createInfoPanel(); - // Right side: Responsive content area - responsiveContent = createResponsiveContent(); + // Right side: Populate responsive content + populateResponsiveContent(responsiveContent); splitLayout.addToPrimary(responsiveContent); splitLayout.addToSecondary(infoPanel); @@ -126,11 +126,11 @@ private Div createInfoPanel() { .set("padding", "1em").set("border-radius", "4px") .set("margin", "1em 0"); - Paragraph widthPara = new Paragraph(() -> "Width: " + containerSizeSignal.get().width + "px"); + Paragraph widthPara = new Paragraph(() -> "Width: " + containerSizeSignal.get().width() + "px"); widthPara.getStyle().set("font-family", "monospace").set("margin", "0.25em 0"); - Paragraph heightPara = new Paragraph(() -> "Height: " + containerSizeSignal.get().height + "px"); + Paragraph heightPara = new Paragraph(() -> "Height: " + containerSizeSignal.get().height() + "px"); heightPara.getStyle().set("font-family", "monospace").set("margin", "0.25em 0"); @@ -150,11 +150,7 @@ private Div createInfoPanel() { return panel; } - private Div createResponsiveContent() { - Div container = new Div(); - container.getStyle().set("padding", "1em").set("height", "100%") - .set("overflow-y", "auto").set("background-color", "#ffffff"); - + private void populateResponsiveContent(Div container) { // Small width content Div smallContent = createSection("📱 Small Width Layout", "This is the mobile view (width < 400px). Navigation is stacked vertically, " @@ -182,7 +178,6 @@ private Div createResponsiveContent() { container.add(smallContent, mediumContent, largeContent, cardGridTitle, cardGrid); - return container; } private Div createSection(String title, String content, @@ -236,52 +231,4 @@ private Component createResponsiveCardGrid() { return gridContainer; } - @Override - protected void onAttach(AttachEvent attachEvent) { - super.onAttach(attachEvent); - - String cleanupKey = UUID.randomUUID().toString(); - - // Set up ResizeObserver to monitor the responsive content area - String script = """ - const targetElement = $0; - const resizeObserver = new ResizeObserver(entries => { - for (let entry of entries) { - const width = Math.floor(entry.contentRect.width); - const height = Math.floor(entry.contentRect.height); - this.$server.updateContainerSize(width, height); - } - }); - resizeObserver.observe(targetElement); - - // Store observer for cleanup - window[$1] = resizeObserver; - - // Report initial size - const rect = targetElement.getBoundingClientRect(); - this.$server.updateContainerSize(Math.floor(rect.width), Math.floor(rect.height)); - """; - - getElement().executeJs(script, responsiveContent, cleanupKey); - - addDetachListener(detach -> { - detach.unregisterListener(); - - String cleanupScript = """ - if (window[$0]) { - window[$0].disconnect(); - delete window[$0]; - } - """; - detach.getUI().getPage().executeJs(cleanupScript, cleanupKey); - }); - } - - /** - * Called from JavaScript when the container is resized - */ - @ClientCallable - public void updateContainerSize(int width, int height) { - containerSizeSignal.set(new ContainerSize(width, height)); - } } diff --git a/src/test/java/com/example/usecase11/UseCase11ViewTest.java b/src/test/java/com/example/usecase11/UseCase11ViewTest.java index a1bd9c07..d82f9522 100644 --- a/src/test/java/com/example/usecase11/UseCase11ViewTest.java +++ b/src/test/java/com/example/usecase11/UseCase11ViewTest.java @@ -25,19 +25,18 @@ void viewRendersWithSplitLayout() { } @Test - void initialContainerSizeIsMedium() { + void initialContainerSizeIsSmall() { navigate(UseCase11View.class); runPendingSignalsTasks(); - // Default container size is 600x400 which is medium (400-700px) - // The medium layout section should be visible, small and large should - // not + // Initial size from sizeSignal is (0, 0) which is small (< 400px) + // until the ResizeObserver reports the actual size from the browser assertTrue( $view(H3.class).all().stream().anyMatch(h -> h.getText() != null - && h.getText().contains("Medium Width Layout"))); + && h.getText().contains("Small Width Layout"))); assertFalse( $view(H3.class).all().stream().anyMatch(h -> h.getText() != null - && h.getText().contains("Small Width Layout"))); + && h.getText().contains("Medium Width Layout"))); assertFalse( $view(H3.class).all().stream().anyMatch(h -> h.getText() != null && h.getText().contains("Large Width Layout")));