Skip to content

Commit 9b68b79

Browse files
tenphicursoragent
andauthored
feat(Layout): add natural boundaries and percent maxSize support (#1053)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9bbc6de commit 9b68b79

File tree

6 files changed

+293
-20
lines changed

6 files changed

+293
-20
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": minor
3+
---
4+
5+
Added support for CSS string values (like percentages) for `maxSize` prop in Layout.Panel, added `minContentSize` prop to Layout component to control minimum content area between panels, and implemented natural boundaries logic so panels on opposite sides automatically prevent overlap and maintain minimum content space.

src/components/content/Layout/Layout.stories.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,3 +1402,96 @@ export const OverflowControl: Story = {
14021402
);
14031403
},
14041404
};
1405+
1406+
/**
1407+
* Panels support CSS string values for `maxSize` like percentages.
1408+
* This example sets `maxSize="50%"` to limit the panel to half the layout width.
1409+
*/
1410+
export const ResizablePanelWithPercentMax: Story = {
1411+
render: function ResizablePanelWithPercentMaxStory() {
1412+
const [size, setSize] = useState(250);
1413+
1414+
return (
1415+
<Layout height="100dvh">
1416+
<Layout.Panel
1417+
isResizable
1418+
side="left"
1419+
size={size}
1420+
minSize={150}
1421+
maxSize="50%"
1422+
onSizeChange={setSize}
1423+
>
1424+
<Layout.PanelHeader title="Max 50%" />
1425+
<Layout.Content>
1426+
<Text>
1427+
This panel is limited to 50% of the layout width. Drag the edge to
1428+
resize (current: {size}px).
1429+
</Text>
1430+
</Layout.Content>
1431+
</Layout.Panel>
1432+
<Layout.Content>
1433+
<Text>Main content area — always at least 50% wide.</Text>
1434+
</Layout.Content>
1435+
</Layout>
1436+
);
1437+
},
1438+
};
1439+
1440+
/**
1441+
* When two resizable panels are on opposite sides, they enforce natural
1442+
* boundaries so they never overlap. A minimum content area (default 320px)
1443+
* is always reserved between them. Panels auto-shrink on container resize.
1444+
*/
1445+
export const NaturalBoundaries: Story = {
1446+
render: function NaturalBoundariesStory() {
1447+
const [leftSize, setLeftSize] = useState(300);
1448+
const [rightSize, setRightSize] = useState(300);
1449+
1450+
return (
1451+
<Layout height="100dvh">
1452+
<Layout.Panel
1453+
isResizable
1454+
side="left"
1455+
size={leftSize}
1456+
minSize={150}
1457+
onSizeChange={setLeftSize}
1458+
>
1459+
<Layout.PanelHeader title="Left Panel" />
1460+
<Layout.Content>
1461+
<Text>Width: {leftSize}px</Text>
1462+
<Text preset="t3" color="#dark-02">
1463+
Try dragging — this panel cannot overlap the right panel, and at
1464+
least 320px is reserved for the content area.
1465+
</Text>
1466+
</Layout.Content>
1467+
</Layout.Panel>
1468+
<Layout.Panel
1469+
isResizable
1470+
side="right"
1471+
size={rightSize}
1472+
minSize={150}
1473+
onSizeChange={setRightSize}
1474+
>
1475+
<Layout.PanelHeader title="Right Panel" />
1476+
<Layout.Content>
1477+
<Text>Width: {rightSize}px</Text>
1478+
<Text preset="t3" color="#dark-02">
1479+
Both panels respect natural boundaries. Resize the browser window
1480+
to see them auto-shrink.
1481+
</Text>
1482+
</Layout.Content>
1483+
</Layout.Panel>
1484+
<Layout.Content>
1485+
<Card>
1486+
<Title level={5}>Content Area</Title>
1487+
<Text>
1488+
This area is guaranteed to be at least 320px wide (the default{' '}
1489+
<code>minContentSize</code>). Neither panel can grow beyond the
1490+
natural boundary.
1491+
</Text>
1492+
</Card>
1493+
</Layout.Content>
1494+
</Layout>
1495+
);
1496+
},
1497+
};

src/components/content/Layout/Layout.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useResizeObserver } from '@react-aria/utils';
12
import {
23
CSSProperties,
34
FocusEvent,
@@ -104,6 +105,8 @@ export interface CubeLayoutProps
104105
contentPadding?: Styles['padding'];
105106
/** Enable transition animation for Inner content when panels open/close */
106107
hasTransition?: boolean;
108+
/** Minimum size reserved for the content area between panels. Default: 320 */
109+
minContentSize?: number;
107110
/** Styles for wrapper and Inner sub-element */
108111
styles?: Styles;
109112
children?: ReactNode;
@@ -139,6 +142,7 @@ function LayoutInner(
139142
template,
140143
contentPadding,
141144
hasTransition = false,
145+
minContentSize,
142146
styles,
143147
children,
144148
style,
@@ -151,6 +155,19 @@ function LayoutInner(
151155
} = props;
152156

153157
const combinedInnerRef = useCombinedRefs(innerRefProp);
158+
const updateContainerSize = layoutActions?.updateContainerSize;
159+
160+
useResizeObserver({
161+
ref: localRef,
162+
onResize: useCallback(() => {
163+
if (localRef.current) {
164+
updateContainerSize?.(
165+
localRef.current.offsetWidth,
166+
localRef.current.offsetHeight,
167+
);
168+
}
169+
}, [updateContainerSize]),
170+
});
154171

155172
// Extract outer wrapper styles and inner content styles
156173
const outerStyles = extractStyles(otherProps, OUTER_STYLES);
@@ -377,10 +394,13 @@ function LayoutInner(
377394
* Uses a two-element architecture (wrapper + content) to ensure content never overflows.
378395
*/
379396
function Layout(props: CubeLayoutProps, ref: ForwardedRef<HTMLDivElement>) {
380-
const { hasTransition } = props;
397+
const { hasTransition, minContentSize } = props;
381398

382399
return (
383-
<LayoutProvider hasTransition={hasTransition}>
400+
<LayoutProvider
401+
hasTransition={hasTransition}
402+
minContentSize={minContentSize}
403+
>
384404
<LayoutInner {...props} forwardedRef={ref} />
385405
</LayoutProvider>
386406
);

src/components/content/Layout/LayoutContext.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,16 @@ export interface LayoutActionsContextValue {
5151
setDragging: (isDragging: boolean) => void;
5252
/** Mark the layout as ready (after initial mount) */
5353
markReady: () => void;
54+
/** Update the container dimensions (called from ResizeObserver) */
55+
updateContainerSize: (width: number, height: number) => void;
5456
/** Register an overlay panel's dismiss callback. Returns unregister function. */
5557
registerOverlayPanel: (dismiss: OverlayDismissCallback) => () => void;
5658
/** Dismiss all overlay panels */
5759
dismissOverlayPanels: () => void;
5860
/** Whether transitions are enabled for panels (stable config value) */
5961
hasTransition: boolean;
62+
/** Minimum size reserved for the content area between panels */
63+
minContentSize: number;
6064
}
6165

6266
/** State context - reactive state that triggers re-renders */
@@ -65,6 +69,8 @@ export interface LayoutStateContextValue {
6569
isDragging: boolean;
6670
isReady: boolean;
6771
hasOverlayPanels: boolean;
72+
containerWidth: number;
73+
containerHeight: number;
6874
}
6975

7076
export const LayoutActionsContext =
@@ -108,11 +114,14 @@ export interface LayoutProviderProps {
108114
children: ReactNode;
109115
/** Whether transitions are enabled for panels */
110116
hasTransition?: boolean;
117+
/** Minimum size reserved for the content area between panels. Default: 320 */
118+
minContentSize?: number;
111119
}
112120

113121
export function LayoutProvider({
114122
children,
115123
hasTransition = false,
124+
minContentSize = 320,
116125
}: LayoutProviderProps) {
117126
const registeredPanels = useRef<Set<Side>>(new Set());
118127
const overlayPanelCallbacks = useRef<Set<OverlayDismissCallback>>(new Set());
@@ -134,6 +143,8 @@ export function LayoutProvider({
134143
const [isDragging, setIsDragging] = useState(false);
135144
const [isReady, setIsReady] = useState(false);
136145
const [hasOverlayPanels, setHasOverlayPanels] = useState(false);
146+
const [containerWidth, setContainerWidth] = useState(0);
147+
const [containerHeight, setContainerHeight] = useState(0);
137148

138149
const updatePanelSize = useEvent((side: Side, size: number) => {
139150
setPanelSizes((prev) => {
@@ -198,6 +209,11 @@ export function LayoutProvider({
198209
overlayPanelCallbacks.current.forEach((dismiss) => dismiss());
199210
});
200211

212+
const updateContainerSize = useEvent((width: number, height: number) => {
213+
setContainerWidth((prev) => (prev === width ? prev : width));
214+
setContainerHeight((prev) => (prev === height ? prev : height));
215+
});
216+
201217
// Actions context - stable because all callbacks use useEvent
202218
const actionsValue = useMemo(
203219
() => ({
@@ -206,12 +222,14 @@ export function LayoutProvider({
206222
updatePanelSize,
207223
setDragging,
208224
markReady,
225+
updateContainerSize,
209226
hasTransition,
227+
minContentSize,
210228
registerOverlayPanel,
211229
dismissOverlayPanels,
212230
}),
213-
// Only hasTransition can change - all other values are stable useEvent callbacks
214-
[hasTransition],
231+
// Only hasTransition and minContentSize can change - all other values are stable useEvent callbacks
232+
[hasTransition, minContentSize],
215233
);
216234

217235
// State context - changes when state updates
@@ -221,8 +239,17 @@ export function LayoutProvider({
221239
isDragging,
222240
isReady,
223241
hasOverlayPanels,
242+
containerWidth,
243+
containerHeight,
224244
}),
225-
[panelSizes, isDragging, isReady, hasOverlayPanels],
245+
[
246+
panelSizes,
247+
isDragging,
248+
isReady,
249+
hasOverlayPanels,
250+
containerWidth,
251+
containerHeight,
252+
],
226253
);
227254

228255
// Refs context - includes container ready state

0 commit comments

Comments
 (0)