fix: responsive grid breakpoints for small screens (#41)
CI / build-and-test (pull_request) Successful in 2m47s

Add a columnOpts breakpoint (768px -> 1 column) so widgets stack
full-width on mobile instead of staying at 12 columns. Persisting to
localStorage is skipped while gridstack's own responsive collapse is
active, so narrowing the window can no longer overwrite the saved
desktop layout; drag/resize is disabled below the same breakpoint to
avoid touch misfires.
This commit is contained in:
Pit Friedrich
2026-07-29 07:52:36 +02:00
parent 505ad32d4a
commit 7a4bc72799
4 changed files with 178 additions and 0 deletions
@@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement {
private observer?: MutationObserver; private observer?: MutationObserver;
private storageKey: string | null = null; private storageKey: string | null = null;
private persistTimer?: ReturnType<typeof setTimeout>; private persistTimer?: ReturnType<typeof setTimeout>;
/** The column count configured server-side (before any responsive
* collapse) — {@link persist} compares the live column count against
* this to tell a real layout change from gridstack's own breakpoint
* collapse. */
private fullColumn = 12;
private mobileQuery?: MediaQueryList;
private mobileQueryHandler?: (e: MediaQueryListEvent) => void;
connectedCallback() { connectedCallback() {
this.observer = new MutationObserver((mutations) => this.onMutation(mutations)); this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
@@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this.observer?.disconnect(); this.observer?.disconnect();
this.observer = undefined; this.observer = undefined;
if (this.mobileQuery && this.mobileQueryHandler) {
this.mobileQuery.removeEventListener('change', this.mobileQueryHandler);
}
this.mobileQuery = undefined;
this.mobileQueryHandler = undefined;
// Write out a still-pending debounced save instead of dropping it: // Write out a still-pending debounced save instead of dropping it:
// a drag/resize followed straight away by navigating to another route // a drag/resize followed straight away by navigating to another route
// detaches this element inside the debounce window, which used to lose // detaches this element inside the debounce window, which used to lose
@@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement {
this.classList.add('grid-stack'); this.classList.add('grid-stack');
const options = JSON.parse(optionsJson); const options = JSON.parse(optionsJson);
this.fullColumn = options.column ?? this.fullColumn;
const grid = GridStack.init(options, this); const grid = GridStack.init(options, this);
if (!grid) return; if (!grid) return;
this.grid = grid; this.grid = grid;
@@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement {
grid.on('change added removed', () => this.schedulePersist()); grid.on('change added removed', () => this.schedulePersist());
grid.on('resizestop', () => window.dispatchEvent(new Event('resize'))); grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
// Below the responsive breakpoint, disable drag/resize: rearranging a
// stacked mobile layout by touch is mostly misfires, and it's the
// desktop layout — not the stacked one — that's worth protecting from
// an accidental drag. Mirrors the same width gridstack's own
// columnOpts.breakpoints collapses at, so both switch together.
const breakpointWidth: number | undefined = options.columnOpts?.breakpoints?.[0]?.w;
if (breakpointWidth) {
this.mobileQuery = window.matchMedia(`(max-width: ${breakpointWidth}px)`);
this.mobileQueryHandler = (e) => this.grid?.setStatic(e.matches);
this.mobileQuery.addEventListener('change', this.mobileQueryHandler);
this.grid.setStatic(this.mobileQuery.matches);
}
} }
/** Drops any saved layout for this grid and re-applies the positions /** Drops any saved layout for this grid and re-applies the positions
@@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement {
private persist(notifyServer = true) { private persist(notifyServer = true) {
if (!this.grid) return; if (!this.grid) return;
// gridstack's responsive columnOpts collapse fires the same 'change'
// event a real drag/resize would, with save() reporting whatever
// shape the engine forced items into at 1 column. That shape is not
// something the user chose — persisting it would silently overwrite
// the desktop layout the next time the window narrows.
if (this.grid.getColumn() !== this.fullColumn) return;
const nodes = this.grid.save(false) as GridStackNode[]; const nodes = this.grid.save(false) as GridStackNode[];
if (this.storageKey) { if (this.storageKey) {
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes)); localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
@@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
return this; return this;
} }
/**
* Below {@code maxWidthPx} (measured against the browser window, not just
* this element's own width), gridstack collapses to {@code columns}
* columns — pass 1 to stack every item full-width, which is what makes a
* chart legible on a phone. Drag/resize is disabled below the same
* threshold (see {@code grid-stack.ts}): rearranging a stacked mobile
* layout by touch is mostly misfires, and gridstack still restores the
* pre-collapse positions once the window widens back past the threshold —
* that restore is the engine's own column-change cache, not something this
* class drives. Persisting to {@code localStorage} (see
* {@link #setStorageKey(String)}) is suppressed while collapsed, so a
* narrowed window can never overwrite the saved desktop layout.
*/
public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) {
Map<String, Object> breakpoint = new LinkedHashMap<>();
breakpoint.put("w", maxWidthPx);
breakpoint.put("c", columns);
Map<String, Object> columnOpts = new LinkedHashMap<>();
columnOpts.put("breakpointForWindow", true);
columnOpts.put("breakpoints", List.of(breakpoint));
options.put("columnOpts", columnOpts);
return this;
}
/** /**
* Enables browser-localStorage persistence of the layout under the given * Enables browser-localStorage persistence of the layout under the given
* key (shared across sessions/tabs on the same origin — pick something * key (shared across sessions/tabs on the same origin — pick something
@@ -59,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "dashboard"; private static final String STORAGE_KEY = "dashboard";
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
private static final int MOBILE_BREAKPOINT_PX = 768;
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */ /** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3; private static final int KPI_WIDTH = 3;
@@ -83,6 +87,8 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
grid.setWidthFull(); grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY); grid.setStorageKey(STORAGE_KEY);
// Below phone/small-tablet width, stack every widget full-width.
grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
grid.addLayoutChangeListener(e -> status.setText( grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size()))); getTranslation("gridstack.status", e.getPositions().size())));
@@ -0,0 +1,116 @@
package com.example.e2e;
import com.microsoft.playwright.Locator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* End-to-end coverage for the {@code columnOpts} breakpoint added to
* {@code GridStackLayout} (see {@code DashboardView#MOBILE_BREAKPOINT_PX}):
* gridstack's own column-change caching restores the desktop layout, but only
* a real browser proves the resize actually happens and that the collapsed,
* single-column shape never gets written into the persisted layout.
*/
class ResponsiveGridPlaywrightTest extends PlaywrightTestBase {
private static final int DESKTOP_WIDTH = 1280;
private static final int DESKTOP_HEIGHT = 900;
private static final int MOBILE_WIDTH = 375;
private static final int MOBILE_HEIGHT = 720;
@BeforeEach
void openDashboardAtDesktopWidth() {
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
navigate("");
assertThat(widget()).isVisible();
page.waitForCondition(() -> columns() == 12);
// Let any debounced persist from the initial makeWidget() calls settle
// before a test starts measuring, so it isn't racing that write.
page.waitForTimeout(300);
}
@Test
void mobileViewport_stacksWidgetsFullWidthAndDisablesDragging() {
double gridWidthDesktop = gridWidth();
double itemWidthDesktop = itemWidth();
assertTrue(itemWidthDesktop < gridWidthDesktop - 1,
"at desktop width the widget must not already span the whole grid");
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
page.waitForCondition(() -> columns() == 1);
double gridWidthMobile = gridWidth();
double itemWidthMobile = itemWidth();
assertEquals(gridWidthMobile, itemWidthMobile, 1.0,
"below the breakpoint every widget must span the full grid width");
assertTrue(isStatic(), "dragging/resizing must be disabled below the breakpoint");
}
@Test
void collapsingToMobile_doesNotOverwriteThePersistedDesktopLayout() {
String before = slot(widget());
String storedBefore = storedLayout();
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
page.waitForCondition(() -> columns() == 1);
// give the (debounced) persist path a chance to run, if it were going to
page.waitForTimeout(300);
assertEquals(storedBefore, storedLayout(),
"collapsing to one column must not touch the persisted desktop layout");
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
page.waitForCondition(() -> columns() == 12);
assertEquals(before, slot(widget()), "the original desktop layout must be restored");
assertFalse(isStatic(), "dragging/resizing must be re-enabled back above the breakpoint");
}
private Locator widget() {
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
}
private int columns() {
Object value = page.locator(".grid-stack").evaluate(
"el => el.style.getPropertyValue('--gs-columns')");
return Integer.parseInt(String.valueOf(value));
}
private boolean isStatic() {
return (boolean) page.locator(".grid-stack").evaluate(
"el => el.classList.contains('grid-stack-static')");
}
private double gridWidth() {
return ((Number) page.locator(".grid-stack").evaluate(
"el => el.getBoundingClientRect().width")).doubleValue();
}
private double itemWidth() {
return ((Number) widget().evaluate(
"el => el.getBoundingClientRect().width")).doubleValue();
}
/** The value {@code GridStackLayout} keeps in {@code localStorage} for this
* view, unaffected by the responsive collapse. */
private String storedLayout() {
Object value = page.evaluate("() => localStorage.getItem('gridstack:dashboard')");
return value == null ? null : String.valueOf(value);
}
/** The item's box measured against the grid, not the viewport — same as
* {@code WidgetActionMenuPlaywrightTest#slot}. */
private String slot(Locator item) {
return (String) item.evaluate("""
el => {
const grid = el.closest('.grid-stack').getBoundingClientRect();
const box = el.getBoundingClientRect();
return [box.left - grid.left, box.top - grid.top, box.width, box.height]
.map(Math.round).join(',');
}""");
}
}