Skip to content
Merged
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
77 changes: 77 additions & 0 deletions src/main/java/com/example/MissingAPI.java
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -147,4 +152,76 @@ public static void tabsSyncSelectedIndex(TabSheet tabs,
public static <T> Stream<T> getValues(ListSignal<T> 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.
* <p>
* 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<ComponentSize> sizeSignal(Component component) {
ValueSignal<ComponentSize> 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();
}
}
109 changes: 28 additions & 81 deletions src/main/java/com/example/usecase11/UseCase11View.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<ContainerSize> containerSizeSignal = new ValueSignal<>(
new ContainerSize(600, 400));
private final Signal<Boolean> isSmall = containerSizeSignal.map(ContainerSize::isSmall);
private final Signal<Boolean> isMedium = containerSizeSignal.map(ContainerSize::isMedium);
private final Signal<Boolean> isLarge = containerSizeSignal.map(ContainerSize::isLarge);
private final Signal<ComponentSize> containerSizeSignal;
private final Signal<Boolean> isSmall;
private final Signal<Boolean> isMedium;
private final Signal<Boolean> isLarge;

private Div responsiveContent;

Expand All @@ -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");

Expand All @@ -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);
Expand Down Expand Up @@ -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");

Expand All @@ -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, "
Expand Down Expand Up @@ -182,7 +178,6 @@ private Div createResponsiveContent() {

container.add(smallContent, mediumContent, largeContent, cardGridTitle,
cardGrid);
return container;
}

private Div createSection(String title, String content,
Expand Down Expand Up @@ -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));
}
}
11 changes: 5 additions & 6 deletions src/test/java/com/example/usecase11/UseCase11ViewTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
Expand Down
Loading