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
5 changes: 5 additions & 0 deletions .changeset/natural-boundaries-and-percent-maxsize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

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.
93 changes: 93 additions & 0 deletions src/components/content/Layout/Layout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1382,3 +1382,96 @@ export const OverflowControl: Story = {
);
},
};

/**
* Panels support CSS string values for `maxSize` like percentages.
* This example sets `maxSize="50%"` to limit the panel to half the layout width.
*/
export const ResizablePanelWithPercentMax: Story = {
render: function ResizablePanelWithPercentMaxStory() {
const [size, setSize] = useState(250);

return (
<Layout height="100dvh">
<Layout.Panel
isResizable
side="left"
size={size}
minSize={150}
maxSize="50%"
onSizeChange={setSize}
>
<Layout.PanelHeader title="Max 50%" />
<Layout.Content>
<Text>
This panel is limited to 50% of the layout width. Drag the edge to
resize (current: {size}px).
</Text>
</Layout.Content>
</Layout.Panel>
<Layout.Content>
<Text>Main content area — always at least 50% wide.</Text>
</Layout.Content>
</Layout>
);
},
};

/**
* When two resizable panels are on opposite sides, they enforce natural
* boundaries so they never overlap. A minimum content area (default 320px)
* is always reserved between them. Panels auto-shrink on container resize.
*/
export const NaturalBoundaries: Story = {
render: function NaturalBoundariesStory() {
const [leftSize, setLeftSize] = useState(300);
const [rightSize, setRightSize] = useState(300);

return (
<Layout height="100dvh">
<Layout.Panel
isResizable
side="left"
size={leftSize}
minSize={150}
onSizeChange={setLeftSize}
>
<Layout.PanelHeader title="Left Panel" />
<Layout.Content>
<Text>Width: {leftSize}px</Text>
<Text preset="t3" color="#dark-02">
Try dragging — this panel cannot overlap the right panel, and at
least 320px is reserved for the content area.
</Text>
</Layout.Content>
</Layout.Panel>
<Layout.Panel
isResizable
side="right"
size={rightSize}
minSize={150}
onSizeChange={setRightSize}
>
<Layout.PanelHeader title="Right Panel" />
<Layout.Content>
<Text>Width: {rightSize}px</Text>
<Text preset="t3" color="#dark-02">
Both panels respect natural boundaries. Resize the browser window
to see them auto-shrink.
</Text>
</Layout.Content>
</Layout.Panel>
<Layout.Content>
<Card>
<Title level={5}>Content Area</Title>
<Text>
This area is guaranteed to be at least 320px wide (the default{' '}
<code>minContentSize</code>). Neither panel can grow beyond the
natural boundary.
</Text>
</Card>
</Layout.Content>
</Layout>
);
},
};
24 changes: 22 additions & 2 deletions src/components/content/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useResizeObserver } from '@react-aria/utils';
import {
CSSProperties,
FocusEvent,
Expand Down Expand Up @@ -104,6 +105,8 @@ export interface CubeLayoutProps
contentPadding?: Styles['padding'];
/** Enable transition animation for Inner content when panels open/close */
hasTransition?: boolean;
/** Minimum size reserved for the content area between panels. Default: 320 */
minContentSize?: number;
/** Styles for wrapper and Inner sub-element */
styles?: Styles;
children?: ReactNode;
Expand Down Expand Up @@ -139,6 +142,7 @@ function LayoutInner(
template,
contentPadding,
hasTransition = false,
minContentSize,
styles,
children,
style,
Expand All @@ -151,6 +155,19 @@ function LayoutInner(
} = props;

const combinedInnerRef = useCombinedRefs(innerRefProp);
const updateContainerSize = layoutActions?.updateContainerSize;

useResizeObserver({
ref: localRef,
onResize: useCallback(() => {
if (localRef.current) {
updateContainerSize?.(
localRef.current.offsetWidth,
localRef.current.offsetHeight,
);
}
}, [updateContainerSize]),
});

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

return (
<LayoutProvider hasTransition={hasTransition}>
<LayoutProvider
hasTransition={hasTransition}
minContentSize={minContentSize}
>
<LayoutInner {...props} forwardedRef={ref} />
</LayoutProvider>
);
Expand Down
33 changes: 30 additions & 3 deletions src/components/content/Layout/LayoutContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,16 @@ export interface LayoutActionsContextValue {
setDragging: (isDragging: boolean) => void;
/** Mark the layout as ready (after initial mount) */
markReady: () => void;
/** Update the container dimensions (called from ResizeObserver) */
updateContainerSize: (width: number, height: number) => void;
/** Register an overlay panel's dismiss callback. Returns unregister function. */
registerOverlayPanel: (dismiss: OverlayDismissCallback) => () => void;
/** Dismiss all overlay panels */
dismissOverlayPanels: () => void;
/** Whether transitions are enabled for panels (stable config value) */
hasTransition: boolean;
/** Minimum size reserved for the content area between panels */
minContentSize: number;
}

/** State context - reactive state that triggers re-renders */
Expand All @@ -65,6 +69,8 @@ export interface LayoutStateContextValue {
isDragging: boolean;
isReady: boolean;
hasOverlayPanels: boolean;
containerWidth: number;
containerHeight: number;
}

export const LayoutActionsContext =
Expand Down Expand Up @@ -108,11 +114,14 @@ export interface LayoutProviderProps {
children: ReactNode;
/** Whether transitions are enabled for panels */
hasTransition?: boolean;
/** Minimum size reserved for the content area between panels. Default: 320 */
minContentSize?: number;
}

export function LayoutProvider({
children,
hasTransition = false,
minContentSize = 320,
}: LayoutProviderProps) {
const registeredPanels = useRef<Set<Side>>(new Set());
const overlayPanelCallbacks = useRef<Set<OverlayDismissCallback>>(new Set());
Expand All @@ -134,6 +143,8 @@ export function LayoutProvider({
const [isDragging, setIsDragging] = useState(false);
const [isReady, setIsReady] = useState(false);
const [hasOverlayPanels, setHasOverlayPanels] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);

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

const updateContainerSize = useEvent((width: number, height: number) => {
setContainerWidth((prev) => (prev === width ? prev : width));
setContainerHeight((prev) => (prev === height ? prev : height));
});

// Actions context - stable because all callbacks use useEvent
const actionsValue = useMemo(
() => ({
Expand All @@ -206,12 +222,14 @@ export function LayoutProvider({
updatePanelSize,
setDragging,
markReady,
updateContainerSize,
hasTransition,
minContentSize,
registerOverlayPanel,
dismissOverlayPanels,
}),
// Only hasTransition can change - all other values are stable useEvent callbacks
[hasTransition],
// Only hasTransition and minContentSize can change - all other values are stable useEvent callbacks
[hasTransition, minContentSize],
);

// State context - changes when state updates
Expand All @@ -221,8 +239,17 @@ export function LayoutProvider({
isDragging,
isReady,
hasOverlayPanels,
containerWidth,
containerHeight,
}),
[panelSizes, isDragging, isReady, hasOverlayPanels],
[
panelSizes,
isDragging,
isReady,
hasOverlayPanels,
containerWidth,
containerHeight,
],
);

// Refs context - includes container ready state
Expand Down
Loading
Loading