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
41 changes: 18 additions & 23 deletions client/src/components/WidgetContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { canCommitResize } from '../utils/resizeGuard';
import { shouldAcceptLayoutChange, reconcileLayoutWithLive } from '../utils/layoutSync';
import { buildLayout, savedSourcesById } from '../utils/gridPlacement';
import { resizeButtonMetrics, widgetPixelSize } from '../utils/resizeButtons.js';
import { createContainerWidthWatcher, gridColsForWidth } from '../utils/containerWidth.js';
import { readGridMetrics } from '../utils/gridMetrics';
import { frameDecoration } from '../utils/widgetFrame';
import FrameOrnaments from './FrameOrnaments';
Expand Down Expand Up @@ -198,31 +199,25 @@ const WidgetContainer = ({
}, 500);
}, []);

// Update container width and grid columns based on screen size
// The container width drives the grid, so re-measure whenever the
// container element itself changes size — not just on window resize.
// In mirror the canvas starts at the 1280 fallback width and widens to
// the target's real width once its viewport arrives; that widening is
// not a window resize, so a window-only listener leaves the grid laid
// out at the fallback size (the weather widget then lands compact at
// 294px instead of medium at 332px). The watcher reports only on an
// actual width change, so no layout rebuild runs on a no-op.
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const computed = window.getComputedStyle(containerRef.current);
const paddingLeft = parseFloat(computed.paddingLeft) || 0;
const paddingRight = parseFloat(computed.paddingRight) || 0;
const width = Math.max(0, containerRef.current.clientWidth - paddingLeft - paddingRight);
const element = containerRef.current;
if (!element) return undefined;
const watcher = createContainerWidthWatcher({
element,
onChange: (width) => {
setContainerWidth(width);

// Responsive grid columns
if (width < 600) {
setGridCols(4); // Mobile: 4 columns
} else if (width < 960) {
setGridCols(8); // Tablet: 8 columns
} else {
setGridCols(12); // Desktop: 12 columns
}
}
};

updateDimensions();
window.addEventListener('resize', updateDimensions);

return () => window.removeEventListener('resize', updateDimensions);
setGridCols(gridColsForWidth(width));
},
});
return () => watcher.disconnect();
}, []);

useEffect(() => {
Expand Down
68 changes: 68 additions & 0 deletions client/src/utils/containerWidth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Container-width measurement for the dashboard grid (WidgetContainer).
//
// The grid's width is the container element's own width, so it has to be
// re-measured whenever that element changes size — not just on window
// resize. In mirror the canvas starts at the 1280 fallback width and
// widens to the target's real width once its viewport arrives; that
// widening is not a window resize, so a window-only listener leaves the
// grid laid out at the fallback size (the weather widget then lands
// compact at 294px instead of medium at 332px).
//
// The watcher reports only when the measured width actually changes, so a
// resize storm or a state update that leaves the width alone never
// re-runs the layout rebuild. The container is width:100% of its parent,
// so changing the grid's width prop never changes the container's own
// width — the observer cannot feed itself.

export function measureContainerWidth(element) {
if (!element || typeof window === 'undefined') return null;
const computed = window.getComputedStyle(element);
const paddingLeft = parseFloat(computed.paddingLeft) || 0;
const paddingRight = parseFloat(computed.paddingRight) || 0;
return Math.max(0, element.clientWidth - paddingLeft - paddingRight);
}

// Responsive grid columns, matching the breakpoints WidgetContainer has
// always used.
export function gridColsForWidth(width) {
if (width < 600) return 4; // Mobile: 4 columns
if (width < 960) return 8; // Tablet: 8 columns
return 12; // Desktop: 12 columns
}

export function createContainerWidthWatcher({ element, onChange }) {
let lastWidth = null;
let observer = null;

const check = () => {
const width = measureContainerWidth(element);
if (width === null || width === lastWidth) return;
lastWidth = width;
onChange(width);
};

// Initial measure, so the grid is right on first paint even if the
// observer never fires.
check();

if (typeof ResizeObserver !== 'undefined' && element) {
observer = new ResizeObserver(check);
observer.observe(element);
}

// Window resize stays as a fallback for environments without
// ResizeObserver; the change guard above makes the duplicate harmless
// where both fire.
if (typeof window !== 'undefined') {
window.addEventListener('resize', check);
}

return {
disconnect() {
if (observer) observer.disconnect();
if (typeof window !== 'undefined') {
window.removeEventListener('resize', check);
}
},
};
}
127 changes: 127 additions & 0 deletions client/src/utils/containerWidth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
measureContainerWidth,
gridColsForWidth,
createContainerWidthWatcher,
} from './containerWidth.js';

function fakeElement({ clientWidth, paddingLeft = '16px', paddingRight = '16px' } = {}) {
return { clientWidth, __paddingLeft: paddingLeft, __paddingRight: paddingRight };
}

describe('measureContainerWidth', () => {
beforeEach(() => {
vi.stubGlobal('window', {
getComputedStyle: (el) => ({
paddingLeft: el.__paddingLeft,
paddingRight: el.__paddingRight,
}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('subtracts horizontal padding from clientWidth', () => {
expect(measureContainerWidth(fakeElement({ clientWidth: 1440 }))).toBe(1408);
expect(measureContainerWidth(fakeElement({ clientWidth: 1280 }))).toBe(1248);
});

it('floors at 0 and returns null without an element', () => {
expect(measureContainerWidth(fakeElement({ clientWidth: 10 }))).toBe(0);
expect(measureContainerWidth(null)).toBe(null);
expect(measureContainerWidth(undefined)).toBe(null);
});
});

describe('gridColsForWidth', () => {
it('uses 4 / 8 / 12 columns at the 600 / 960 breakpoints', () => {
expect(gridColsForWidth(0)).toBe(4);
expect(gridColsForWidth(599)).toBe(4);
expect(gridColsForWidth(600)).toBe(8);
expect(gridColsForWidth(959)).toBe(8);
expect(gridColsForWidth(960)).toBe(12);
expect(gridColsForWidth(1408)).toBe(12);
});
});

describe('createContainerWidthWatcher', () => {
let element;
let observerInstances;
let resizeListeners;

beforeEach(() => {
element = fakeElement({ clientWidth: 1280 });
observerInstances = [];
resizeListeners = [];
vi.stubGlobal('window', {
getComputedStyle: (el) => ({
paddingLeft: el.__paddingLeft,
paddingRight: el.__paddingRight,
}),
addEventListener: (type, fn) => { resizeListeners.push([type, fn]); },
removeEventListener: (type, fn) => {
resizeListeners = resizeListeners.filter(([t, f]) => !(t === type && f === fn));
},
});
vi.stubGlobal('ResizeObserver', class {
constructor(cb) { this.cb = cb; observerInstances.push(this); }
observe() {}
disconnect() { this.disconnected = true; }
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('reports the initial width immediately', () => {
const onChange = vi.fn();
const watcher = createContainerWidthWatcher({ element, onChange });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(1248);
watcher.disconnect();
});

it('reports a width change from the observer once, and ignores repeats at the same width', () => {
const onChange = vi.fn();
const watcher = createContainerWidthWatcher({ element, onChange });
expect(observerInstances).toHaveLength(1);

// Mirror viewport arrives: canvas widens 1280 -> 1440.
element.clientWidth = 1440;
observerInstances[0].cb();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(1408);

// Same width again: no further report, so no layout rebuild.
observerInstances[0].cb();
expect(onChange).toHaveBeenCalledTimes(2);
watcher.disconnect();
});

it('falls back to the window resize listener without ResizeObserver', () => {
vi.stubGlobal('ResizeObserver', undefined);
const onChange = vi.fn();
const watcher = createContainerWidthWatcher({ element, onChange });
expect(onChange).toHaveBeenCalledTimes(1);

element.clientWidth = 1440;
const resize = resizeListeners.find(([type]) => type === 'resize')[1];
resize();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(1408);
watcher.disconnect();
});

it('disconnect stops the observer and the resize listener', () => {
const onChange = vi.fn();
const watcher = createContainerWidthWatcher({ element, onChange });
watcher.disconnect();
expect(observerInstances[0].disconnected).toBe(true);
expect(resizeListeners).toHaveLength(0);
});
});
Loading