Skip to content

Commit 55bebe4

Browse files
committed
fix(Layout): resizable panel logic
1 parent a72065d commit 55bebe4

File tree

13 files changed

+262
-51
lines changed

13 files changed

+262
-51
lines changed

.size-limit.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module.exports = [
2020
}),
2121
);
2222
},
23-
limit: '310kB',
23+
limit: '315kB',
2424
},
2525
{
2626
name: 'Tree shaking (just a Button)',

src/components/actions/Button/Button.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ export type ButtonVariant =
8585
const STYLE_PROPS = [...CONTAINER_STYLES, ...TEXT_STYLES];
8686

8787
export const DEFAULT_BUTTON_STYLES = {
88-
display: 'inline-grid',
88+
display: {
89+
'': 'inline-grid',
90+
'text-only': 'inline-block',
91+
},
8992
flow: 'column',
9093
placeItems: 'center start',
9194
placeContent: {
@@ -128,28 +131,31 @@ export const DEFAULT_BUTTON_STYLES = {
128131
'single-icon | type=link': 0,
129132
},
130133
width: {
131-
'': 'initial',
132-
'size=xsmall & single-icon': '$size-xs $size-xs',
133-
'size=small & single-icon': '$size-sm $size-sm',
134-
'size=medium & single-icon': '$size-md $size-md',
135-
'size=large & single-icon': '$size-lg $size-lg',
136-
'size=xlarge & single-icon': '$size-xl $size-xl',
134+
'': 'min $size',
135+
'left-icon & right-icon': 'min ($size * 2)',
136+
'single-icon': 'fixed $size',
137137
'type=link': 'initial',
138138
},
139139
height: {
140-
'': 'initial',
141-
'size=xsmall': '$size-xs $size-xs',
142-
'size=small': '$size-sm $size-sm',
143-
'size=medium': '$size-md $size-md',
144-
'size=large': '$size-lg $size-lg',
145-
'size=xlarge': '$size-xl $size-xl',
140+
'': 'fixed $size',
146141
'type=link': 'initial',
147142
},
148143
whiteSpace: 'nowrap',
144+
textOverflow: 'ellipsis',
149145
radius: {
150146
'': true,
151147
'type=link & !focused': 0,
152148
},
149+
overflow: 'hidden',
150+
151+
$size: {
152+
'': '$size-md',
153+
'size=xsmall': '$size-xs',
154+
'size=small': '$size-sm',
155+
'size=medium': '$size-md',
156+
'size=large': '$size-lg',
157+
'size=xlarge': '$size-xl',
158+
},
153159

154160
ButtonIcon: {
155161
width: 'min 1fs',
@@ -278,6 +284,7 @@ export const Button = forwardRef(function Button(
278284
'left-icon': !!icon,
279285
'right-icon': !!rightIcon,
280286
'single-icon': singleIcon,
287+
'text-only': !!(children && typeof children === 'string' && !hasIcons),
281288
...mods,
282289
}),
283290
[mods, isDisabled, isLoading, isSelected, singleIcon],

src/components/content/Item/Item.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ const ItemElement = tasty({
241241
},
242242
width: {
243243
'': 'min $size',
244+
'has-icon & has-right-icon': 'min ($size * 2)',
244245
'size=inline': 'min (1lh + 2bw)',
245246
},
246247
border: '#clear',

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

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const PanelWithTransition: Story = {
160160
<Layout height="100dvh">
161161
<Layout.Panel
162162
hasTransition
163-
side="right"
163+
side="left"
164164
size={250}
165165
isOpen={isPanelOpen}
166166
onOpenChange={setIsPanelOpen}
@@ -283,6 +283,91 @@ export const GridLayoutExample: Story = {
283283
),
284284
};
285285

286+
export const AllPanelSides: Story = {
287+
render: () => (
288+
<GridLayout
289+
height="100dvh"
290+
columns="1fr 1fr"
291+
rows="1fr 1fr"
292+
gap="1x"
293+
padding="1x"
294+
fill="#border"
295+
>
296+
<Layout fill="#white" contentPadding="2x">
297+
<Layout.Panel
298+
isResizable
299+
side="left"
300+
defaultSize={100}
301+
minSize={60}
302+
maxSize={160}
303+
>
304+
<Layout.PanelHeader title="Left" />
305+
<Layout.Content>
306+
<Text>Panel content</Text>
307+
</Layout.Content>
308+
</Layout.Panel>
309+
<Layout.Content>
310+
<Text>side=&quot;left&quot;</Text>
311+
</Layout.Content>
312+
</Layout>
313+
314+
<Layout fill="#white">
315+
<Layout.Panel
316+
isResizable
317+
side="right"
318+
defaultSize={100}
319+
minSize={60}
320+
maxSize={160}
321+
>
322+
<Layout.PanelHeader title="Right" />
323+
<Layout.Content>
324+
<Text>Panel content</Text>
325+
</Layout.Content>
326+
</Layout.Panel>
327+
<Layout.Content>
328+
<Text>side=&quot;right&quot;</Text>
329+
</Layout.Content>
330+
</Layout>
331+
332+
<Layout fill="#white">
333+
<Layout.Panel
334+
isResizable
335+
side="top"
336+
defaultSize={80}
337+
minSize={50}
338+
maxSize={120}
339+
>
340+
<Layout.PanelHeader title="Top" />
341+
<Layout.Content>
342+
<Text>Panel content</Text>
343+
</Layout.Content>
344+
</Layout.Panel>
345+
<Layout.Content>
346+
<Text>side=&quot;top&quot;</Text>
347+
</Layout.Content>
348+
</Layout>
349+
350+
<Layout fill="#white">
351+
<Layout.Panel
352+
isResizable
353+
side="bottom"
354+
defaultSize={80}
355+
minSize={50}
356+
maxSize={120}
357+
>
358+
<Layout.PanelHeader title="Bottom" />
359+
<Layout.Content>
360+
<Text>Panel content</Text>
361+
</Layout.Content>
362+
</Layout.Panel>
363+
<Layout.Content>
364+
<Text>side=&quot;bottom&quot;</Text>
365+
</Layout.Content>
366+
</Layout>
367+
</GridLayout>
368+
),
369+
};
370+
286371
export const NestedLayouts: Story = {
287372
render: () => (
288373
<Layout height="100dvh">
@@ -397,10 +482,14 @@ export const CompleteApplicationShell: Story = {
397482
</Button>
398483
}
399484
extra={
400-
<Button.Group>
401-
<Button>Export</Button>
402-
<Button type="primary">Create Report</Button>
403-
</Button.Group>
485+
<>
486+
<Button flexShrink={1} width="min 0">
487+
Export
488+
</Button>
489+
<Button type="primary" flexShrink={1} width="min 0">
490+
Create Report
491+
</Button>
492+
</>
404493
}
405494
/>
406495

src/components/content/Layout/Layout.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,89 @@ describe('Layout.Header breadcrumbs', () => {
170170
expect(screen.getAllByText('Current Page')).toHaveLength(2);
171171
});
172172
});
173+
174+
describe('Layout.Panel validation', () => {
175+
beforeEach(() => {
176+
// Suppress console.error for expected errors
177+
jest.spyOn(console, 'error').mockImplementation(() => {});
178+
});
179+
180+
afterEach(() => {
181+
jest.restoreAllMocks();
182+
});
183+
184+
it('throws error when two panels are on the same side', () => {
185+
expect(() =>
186+
renderWithRoot(
187+
<Layout>
188+
<Layout.Panel side="left" size={200}>
189+
Panel 1
190+
</Layout.Panel>
191+
<Layout.Panel side="left" size={200}>
192+
Panel 2
193+
</Layout.Panel>
194+
</Layout>,
195+
),
196+
).toThrow('Layout: Only one panel per side is allowed');
197+
});
198+
199+
it('throws error when mixing horizontal and vertical panels (left + top)', () => {
200+
expect(() =>
201+
renderWithRoot(
202+
<Layout>
203+
<Layout.Panel side="left" size={200}>
204+
Left Panel
205+
</Layout.Panel>
206+
<Layout.Panel side="top" size={100}>
207+
Top Panel
208+
</Layout.Panel>
209+
</Layout>,
210+
),
211+
).toThrow('Layout: Panels from different axes cannot be combined');
212+
});
213+
214+
it('throws error when mixing horizontal and vertical panels (right + bottom)', () => {
215+
expect(() =>
216+
renderWithRoot(
217+
<Layout>
218+
<Layout.Panel side="right" size={200}>
219+
Right Panel
220+
</Layout.Panel>
221+
<Layout.Panel side="bottom" size={100}>
222+
Bottom Panel
223+
</Layout.Panel>
224+
</Layout>,
225+
),
226+
).toThrow('Layout: Panels from different axes cannot be combined');
227+
});
228+
229+
it('allows left and right panels together', () => {
230+
expect(() =>
231+
renderWithRoot(
232+
<Layout>
233+
<Layout.Panel side="left" size={200}>
234+
Left Panel
235+
</Layout.Panel>
236+
<Layout.Panel side="right" size={200}>
237+
Right Panel
238+
</Layout.Panel>
239+
</Layout>,
240+
),
241+
).not.toThrow();
242+
});
243+
244+
it('allows top and bottom panels together', () => {
245+
expect(() =>
246+
renderWithRoot(
247+
<Layout>
248+
<Layout.Panel side="top" size={100}>
249+
Top Panel
250+
</Layout.Panel>
251+
<Layout.Panel side="bottom" size={100}>
252+
Bottom Panel
253+
</Layout.Panel>
254+
</Layout>,
255+
),
256+
).not.toThrow();
257+
});
258+
});

src/components/content/Layout/Layout.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ const LayoutElement = tasty({
4242
overflow: 'hidden',
4343
flexGrow: 1,
4444
placeSelf: 'stretch',
45-
gridArea: 'layout',
45+
46+
'$content-padding': '1x',
4647

4748
Inner: {
4849
// Direct child selector required for nested layouts
@@ -76,6 +77,8 @@ export interface CubeLayoutProps
7677
rows?: Styles['gridRows'];
7778
/** Grid template shorthand */
7879
template?: Styles['gridTemplate'];
80+
/** Padding for content areas (Layout.Content components). Default: '1x' */
81+
contentPadding?: Styles['padding'];
7982
/** Styles for wrapper and Inner sub-element */
8083
styles?: Styles;
8184
children?: ReactNode;
@@ -100,6 +103,7 @@ function LayoutInner(
100103
columns,
101104
rows,
102105
template,
106+
contentPadding,
103107
styles,
104108
children,
105109
style,
@@ -149,6 +153,7 @@ function LayoutInner(
149153
// Merge styles
150154
const finalStyles: Styles = {
151155
...wrapperStyles,
156+
...(contentPadding != null && { '$content-padding': contentPadding }),
152157
...styles,
153158
Inner: { ...contentStyles, ...(styles?.Inner as Styles) },
154159
};
@@ -180,10 +185,10 @@ function LayoutInner(
180185

181186
const insetStyle = useMemo(() => {
182187
const baseStyle: Record<string, string> = {
183-
'--inset-top': `${panelSizes.top - 1}px`,
184-
'--inset-right': `${panelSizes.right - 1}px`,
185-
'--inset-bottom': `${panelSizes.bottom - 1}px`,
186-
'--inset-left': `${panelSizes.left - 1}px`,
188+
'--inset-top': `${panelSizes.top}px`,
189+
'--inset-right': `${panelSizes.right}px`,
190+
'--inset-bottom': `${panelSizes.bottom}px`,
191+
'--inset-left': `${panelSizes.left}px`,
187192
};
188193

189194
if (style) {

src/components/content/Layout/LayoutContent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const ContentElement = tasty({
2929
position: 'absolute',
3030
inset: 0,
3131
display: 'block',
32+
padding: '($content-padding, 1x)',
3233
overflow: 'auto',
3334
scrollbar: {
3435
'': 'thin',

src/components/content/Layout/LayoutContext.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ export function LayoutProvider({ children }: LayoutProviderProps) {
4949
`A panel is already registered on the "${side}" side.`,
5050
);
5151
}
52+
53+
// Check for axis conflict
54+
const isHorizontal = side === 'left' || side === 'right';
55+
const conflictingSides: Side[] = isHorizontal
56+
? ['top', 'bottom']
57+
: ['left', 'right'];
58+
59+
for (const conflictSide of conflictingSides) {
60+
if (registeredPanels.current.has(conflictSide)) {
61+
throw new Error(
62+
`Layout: Panels from different axes cannot be combined. ` +
63+
`Cannot register "${side}" panel when "${conflictSide}" panel exists. ` +
64+
`Use either horizontal (left/right) or vertical (top/bottom) panels.`,
65+
);
66+
}
67+
}
68+
5269
registeredPanels.current.add(side);
5370
setPanelSizes((prev) => {
5471
if (prev[side] === size) return prev;

0 commit comments

Comments
 (0)