Skip to content

Commit b4c9c88

Browse files
authored
Merge pull request #4607 from Ivy-Interactive/fix/breakpoint-container-aware-sidebar
Make breakpoint detection container-aware so responsive layouts respect the sidebar
2 parents 0bb788d + 6e4ad36 commit b4c9c88

4 files changed

Lines changed: 212 additions & 13 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import React, { act, useRef } from "react";
3+
import { createRoot, Root } from "react-dom/client";
4+
import { useBreakpoint, type BreakpointName } from "../use-responsive";
5+
6+
let container: HTMLDivElement;
7+
let root: Root;
8+
9+
function mount(element: React.ReactElement) {
10+
act(() => {
11+
root.render(element);
12+
});
13+
}
14+
15+
beforeEach(() => {
16+
container = document.createElement("div");
17+
document.body.appendChild(container);
18+
root = createRoot(container);
19+
});
20+
21+
afterEach(() => {
22+
act(() => {
23+
root.unmount();
24+
});
25+
container.remove();
26+
vi.unstubAllGlobals();
27+
vi.restoreAllMocks();
28+
});
29+
30+
function setViewportWidth(width: number) {
31+
Object.defineProperty(window, "innerWidth", { value: width, configurable: true, writable: true });
32+
}
33+
34+
/**
35+
* A controllable ResizeObserver stand-in. happy-dom does no layout, so the real
36+
* observer never fires; this lets a test trigger the callback on demand.
37+
*/
38+
function stubResizeObserver() {
39+
const callbacks: ResizeObserverCallback[] = [];
40+
class FakeResizeObserver {
41+
constructor(cb: ResizeObserverCallback) {
42+
callbacks.push(cb);
43+
}
44+
observe() {}
45+
unobserve() {}
46+
disconnect() {}
47+
}
48+
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
49+
return {
50+
fireAll: () => {
51+
act(() => {
52+
callbacks.forEach((cb) => cb([], {} as ResizeObserver));
53+
});
54+
},
55+
};
56+
}
57+
58+
/** Pins clientWidth on the node the instant it attaches, before effects run. */
59+
function pinWidth(width: number) {
60+
return (el: HTMLDivElement | null) => {
61+
if (el) Object.defineProperty(el, "clientWidth", { value: width, configurable: true });
62+
};
63+
}
64+
65+
function Display({ width }: { width?: number }) {
66+
const ref = useRef<HTMLDivElement>(null);
67+
const bp = useBreakpoint(width !== undefined ? ref : undefined);
68+
return (
69+
<div
70+
ref={(el) => {
71+
ref.current = el;
72+
if (width !== undefined) pinWidth(width)(el);
73+
}}
74+
data-testid="bp"
75+
>
76+
{bp}
77+
</div>
78+
);
79+
}
80+
81+
function readBp(): BreakpointName {
82+
return container.querySelector('[data-testid="bp"]')!.textContent as BreakpointName;
83+
}
84+
85+
describe("useBreakpoint", () => {
86+
it("falls back to the viewport width when no container ref is supplied", () => {
87+
setViewportWidth(500);
88+
mount(<Display />);
89+
expect(readBp()).toBe("mobile");
90+
});
91+
92+
it("derives the breakpoint from the container width, not the viewport", () => {
93+
// Wide viewport, but a narrow content container (e.g. sidebar open) — the
94+
// breakpoint must follow the container so buttons collapse correctly.
95+
setViewportWidth(1920);
96+
stubResizeObserver();
97+
mount(<Display width={700} />);
98+
expect(readBp()).toBe("tablet");
99+
});
100+
101+
it("re-evaluates when the observed container changes width", async () => {
102+
setViewportWidth(1920);
103+
const { fireAll } = stubResizeObserver();
104+
105+
let measured = 1100;
106+
function Resizable() {
107+
// A getter lets the test mutate the reported width between observer fires.
108+
const setRef = (el: HTMLDivElement | null) => {
109+
if (el)
110+
Object.defineProperty(el, "clientWidth", { get: () => measured, configurable: true });
111+
};
112+
const ref = useRef<HTMLDivElement>(null);
113+
const bp = useBreakpoint(ref);
114+
return (
115+
<div
116+
ref={(el) => {
117+
ref.current = el;
118+
setRef(el);
119+
}}
120+
data-testid="bp"
121+
>
122+
{bp}
123+
</div>
124+
);
125+
}
126+
127+
mount(<Resizable />);
128+
expect(readBp()).toBe("wide");
129+
130+
// Sidebar opens — content shrinks below the desktop band. The observer
131+
// callback is debounced by 100ms (RESIZE_DEBOUNCE_MS) before re-measuring.
132+
measured = 800;
133+
fireAll();
134+
await act(async () => {
135+
await new Promise((r) => setTimeout(r, 150));
136+
});
137+
expect(readBp()).toBe("desktop");
138+
});
139+
});

src/frontend/src/hooks/use-breakpoint-context.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
import React, { createContext, useContext } from "react";
1+
import React, { createContext, useContext, type RefObject } from "react";
22
import { type BreakpointName, useBreakpoint } from "./use-responsive";
33

44
const BreakpointContext = createContext<BreakpointName>("desktop");
55

6-
export const BreakpointProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
7-
const breakpoint = useBreakpoint();
6+
interface BreakpointProviderProps {
7+
children: React.ReactNode;
8+
/**
9+
* When provided, the breakpoint is derived from this element's width rather than
10+
* the viewport. Mount the provider inside the app content area and pass the
11+
* content container so responsive widgets react to the space actually available
12+
* to them (e.g. an expanded sidebar narrows the content without changing the
13+
* viewport width). Omit it for window-based behavior.
14+
*/
15+
containerRef?: RefObject<HTMLElement | null>;
16+
}
17+
18+
export const BreakpointProvider: React.FC<BreakpointProviderProps> = ({
19+
children,
20+
containerRef,
21+
}) => {
22+
const breakpoint = useBreakpoint(containerRef);
823
return <BreakpointContext.Provider value={breakpoint}>{children}</BreakpointContext.Provider>;
924
};
1025

src/frontend/src/hooks/use-responsive.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { RefObject, useEffect, useState } from "react";
22

33
export interface Responsive<T> {
44
default?: T;
@@ -8,33 +8,75 @@ export interface Responsive<T> {
88
wide?: T;
99
}
1010

11+
// Lower bounds of each band, in px. NOTE: `wide` is the open-ended top band — any
12+
// width >= desktop (1024) resolves to "wide" (see getBreakpoint). The 1280 value is
13+
// retained for reference only and is intentionally NOT used as a threshold.
1114
const BREAKPOINTS = { mobile: 640, tablet: 768, desktop: 1024, wide: 1280 };
1215

1316
export type BreakpointName = "mobile" | "tablet" | "desktop" | "wide";
1417

15-
export function useBreakpoint(): BreakpointName {
16-
const [bp, setBp] = useState<BreakpointName>(() => getBreakpoint());
18+
const RESIZE_DEBOUNCE_MS = 100;
19+
20+
/**
21+
* Resolves the current breakpoint from a width.
22+
*
23+
* Pass a `containerRef` to derive the breakpoint from that element's content-box
24+
* width (via ResizeObserver) instead of `window.innerWidth`. This is what makes
25+
* responsive resolution account for chrome that steals horizontal space — most
26+
* importantly an expanded sidebar, which shrinks the app content area without
27+
* changing the viewport width. When the ref is null/unset (or its element has not
28+
* measured yet) the hook falls back to the window width.
29+
*/
30+
export function useBreakpoint(containerRef?: RefObject<HTMLElement | null>): BreakpointName {
31+
const [bp, setBp] = useState<BreakpointName>(() => getBreakpoint(widthFromRef(containerRef)));
1732

1833
useEffect(() => {
1934
let timeoutId: number | undefined;
35+
36+
const measure = () => {
37+
setBp(getBreakpoint(widthFromRef(containerRef)));
38+
};
39+
2040
const onResize = () => {
2141
if (timeoutId !== undefined) clearTimeout(timeoutId);
22-
timeoutId = window.setTimeout(() => {
23-
setBp(getBreakpoint());
24-
}, 100);
42+
timeoutId = window.setTimeout(measure, RESIZE_DEBOUNCE_MS);
2543
};
44+
2645
window.addEventListener("resize", onResize);
46+
47+
// Take an immediate reading: the useState initializer ran during the first render
48+
// pass before refs were attached, so it could only see the window width. Now that
49+
// the element exists we can measure it.
50+
measure();
51+
52+
// When a container is supplied, observe it directly so sidebar open/close and
53+
// resize-drag (which change the content width but not the viewport) re-evaluate
54+
// the breakpoint.
55+
let observer: ResizeObserver | undefined;
56+
const el = containerRef?.current;
57+
if (el && typeof ResizeObserver !== "undefined") {
58+
observer = new ResizeObserver(onResize);
59+
observer.observe(el);
60+
}
61+
2762
return () => {
2863
window.removeEventListener("resize", onResize);
64+
observer?.disconnect();
2965
if (timeoutId !== undefined) clearTimeout(timeoutId);
3066
};
31-
}, []);
67+
}, [containerRef]);
3268

3369
return bp;
3470
}
3571

36-
function getBreakpoint(): BreakpointName {
37-
const w = typeof window !== "undefined" ? window.innerWidth : 1024;
72+
function widthFromRef(containerRef?: RefObject<HTMLElement | null>): number | undefined {
73+
const el = containerRef?.current;
74+
if (el && el.clientWidth > 0) return el.clientWidth;
75+
return undefined;
76+
}
77+
78+
function getBreakpoint(width?: number): BreakpointName {
79+
const w = width !== undefined ? width : typeof window !== "undefined" ? window.innerWidth : 1024;
3880
if (w < BREAKPOINTS.mobile) return "mobile";
3981
if (w < BREAKPOINTS.tablet) return "tablet";
4082
if (w < BREAKPOINTS.desktop) return "desktop";

src/frontend/src/widgets/primitives/AppHostWidget.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export const AppHostWidget: React.FC<AppHostWidgetProps> = ({ appId, appArgs, pa
4242
return (
4343
<div ref={containerRef} className="w-full h-full p-4 overflow-y-auto">
4444
<ErrorBoundary>
45-
<BreakpointProvider>
45+
{/* Derive the breakpoint from the app content container (sidebar-aware) rather
46+
than the viewport, so progressive-collapse layouts react to the width
47+
actually available to the app. */}
48+
<BreakpointProvider containerRef={containerRef}>
4649
<EventHandlerProvider eventHandler={eventHandler}>
4750
<StreamHandlerProvider subscribeToStream={subscribeToStream}>
4851
<>{renderWidgetTree(widgetTree || loadingState())}</>

0 commit comments

Comments
 (0)