diff --git a/dev/vscode-split-layout/min-size.html b/dev/vscode-split-layout/min-size.html
new file mode 100644
index 000000000..ade68782a
--- /dev/null
+++ b/dev/vscode-split-layout/min-size.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+ Split Layout - Min Size
+
+
+
+
+
+
+
+
+ Split Layout - Min Size Examples
+
+ These demos show how min-start and min-end clamp
+ the sash so panes never collapse below sensible limits. Pixels and
+ percentages can be combined to match your layout.
+
+
+ Vertical mix: 240px start, 25% end
+
+ Drag the handle and notice how the master pane refuses to shrink past
+ 240px while the detail pane keeps 25% of
+ the width.
+
+
+
+
+
Sources
+
This slot refuses to shrink past 240px.
+
+ Try dragging the handle all the way left—the clamp keeps the list
+ readable.
+
+
+
+
Details
+
+ This pane holds onto 25% of the available width for preview content.
+
+
+
+
+
+ Horizontal ratio clamp (30%)
+
+ Here both panes use percentage-based minimums to keep toolbars usable in a
+ horizontal split.
+
+
+
+
+
Console
+
The console keeps at least 30% height so filters don’t vanish.
+
+
+
Inspector
+
The inspector pane also clamps at 30% to preserve actions.
+
+
+
+
+
diff --git a/src/vscode-split-layout/vscode-split-layout.test.ts b/src/vscode-split-layout/vscode-split-layout.test.ts
index 87ab15142..96bf87e2f 100644
--- a/src/vscode-split-layout/vscode-split-layout.test.ts
+++ b/src/vscode-split-layout/vscode-split-layout.test.ts
@@ -389,6 +389,142 @@ describe('vscode-split-layout', () => {
});
});
+ describe('min size constraints', () => {
+ it('prevents dragging the start pane below the configured pixel minimum', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLDivElement;
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ await dragElement(handle, -300);
+
+ expect(startPane.offsetWidth).to.eq(240);
+ expect(endPane.offsetWidth).to.eq(260);
+ });
+
+ it('prevents dragging the end pane below the configured pixel minimum', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLDivElement;
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ await dragElement(handle, 300);
+
+ expect(startPane.offsetWidth).to.eq(320);
+ expect(endPane.offsetWidth).to.eq(180);
+ });
+
+ it('applies percentage-based minimums when handlePosition changes programmatically', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ el.handlePosition = '0px';
+ await el.updateComplete;
+
+ expect(startPane.offsetWidth).to.eq(180);
+ expect(endPane.offsetWidth).to.eq(420);
+
+ el.handlePosition = '100%';
+ await el.updateComplete;
+
+ expect(startPane.offsetWidth).to.eq(450);
+ expect(endPane.offsetWidth).to.eq(150);
+ });
+
+ it('respects minimum sizes in horizontal layouts', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLDivElement;
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ await dragElement(handle, 0, -250);
+
+ expect(startPane.offsetHeight).to.eq(150);
+ expect(endPane.offsetHeight).to.eq(250);
+ });
+
+ it('handles overlapping minimums without crashing', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ expect(startPane.offsetWidth).to.be.closeTo(400, 1);
+ expect(endPane.offsetWidth).to.be.closeTo(0, 1);
+ });
+
+ it('accepts zero minimum values', async () => {
+ const el = await fixture(
+ html` `
+ );
+
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLDivElement;
+ const startPane = el.shadowRoot?.querySelector(
+ '.start'
+ ) as HTMLDivElement;
+ const endPane = el.shadowRoot?.querySelector('.end') as HTMLDivElement;
+
+ await dragElement(handle, -400);
+ expect(startPane.offsetWidth).to.be.closeTo(0, 1);
+ expect(endPane.offsetWidth).to.be.closeTo(400, 1);
+
+ await dragElement(handle, 400);
+ expect(startPane.offsetWidth).to.be.closeTo(400, 1);
+ expect(endPane.offsetWidth).to.be.closeTo(0, 1);
+ });
+ });
+
describe('interactions', () => {
it('should panes resize in vertical mode', async () => {
const el = await fixture(
diff --git a/src/vscode-split-layout/vscode-split-layout.ts b/src/vscode-split-layout/vscode-split-layout.ts
index f2b1e43a5..bd289e675 100644
--- a/src/vscode-split-layout/vscode-split-layout.ts
+++ b/src/vscode-split-layout/vscode-split-layout.ts
@@ -56,6 +56,8 @@ export type VscSplitLayoutChangeEvent = CustomEvent<{
* @tag vscode-split-layout
*
* @prop {'start' | 'end' | 'none'} fixedPane
+ * @prop {string} minStart - Minimum size of the start pane expressed in `px` or `%`.
+ * @prop {string} minEnd - Minimum size of the end pane expressed in `px` or `%`.
*
* @cssprop [--separator-border=#454545]
* @cssprop [--vscode-editorWidget-border=#454545]
@@ -130,6 +132,44 @@ export class VscodeSplitLayout extends VscElement {
}
private _fixedPane: FixedPaneType = 'none';
+ /**
+ * Sets the minimum size of the start pane. Accepts pixel or percentage values.
+ */
+ @property({attribute: 'min-start'})
+ set minStart(newVal: string | null | undefined) {
+ const normalized = newVal ?? undefined;
+
+ if (this._minStart === normalized) {
+ return;
+ }
+
+ this._minStart = normalized;
+ this._applyMinSizeConstraints();
+ }
+ get minStart(): string | undefined {
+ return this._minStart;
+ }
+ private _minStart?: string;
+
+ /**
+ * Sets the minimum size of the end pane. Accepts pixel or percentage values.
+ */
+ @property({attribute: 'min-end'})
+ set minEnd(newVal: string | null | undefined) {
+ const normalized = newVal ?? undefined;
+
+ if (this._minEnd === normalized) {
+ return;
+ }
+
+ this._minEnd = normalized;
+ this._applyMinSizeConstraints();
+ }
+ get minEnd(): string | undefined {
+ return this._minEnd;
+ }
+ private _minEnd?: string;
+
@state()
private _handlePosition = 0;
@@ -181,11 +221,9 @@ export class VscodeSplitLayout extends VscElement {
this.initialHandlePosition ?? DEFAULT_INITIAL_POSITION
);
- if (unit === 'percent') {
- this._handlePosition = percentToPx(value, max);
- } else {
- this._handlePosition = value;
- }
+ const nextValue = unit === 'percent' ? percentToPx(value, max) : value;
+ this._handlePosition = this._clampHandlePosition(nextValue, max);
+ this._updateFixedPaneSize(max);
}
override connectedCallback(): void {
@@ -246,6 +284,59 @@ export class VscodeSplitLayout extends VscElement {
}
}
+ private _applyMinSizeConstraints() {
+ if (!this._wrapperEl) {
+ return;
+ }
+
+ this._boundRect = this._wrapperEl.getBoundingClientRect();
+ const {width, height} = this._boundRect;
+ const max = this.split === 'vertical' ? width : height;
+
+ this._handlePosition = this._clampHandlePosition(this._handlePosition, max);
+ this._updateFixedPaneSize(max);
+ }
+
+ private _resolveMinSizePx(value: string | undefined, max: number): number {
+ if (!value) {
+ return 0;
+ }
+
+ const {unit, value: parsedValue} = parseValue(value);
+ const resolved =
+ unit === 'percent' ? percentToPx(parsedValue, max) : parsedValue;
+
+ if (!isFinite(resolved)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(resolved, max));
+ }
+
+ private _clampHandlePosition(value: number, max: number): number {
+ if (!isFinite(max) || max <= 0) {
+ return 0;
+ }
+
+ const minStartPx = this._resolveMinSizePx(this._minStart, max);
+ const minEndPx = this._resolveMinSizePx(this._minEnd, max);
+
+ const lowerBound = Math.min(minStartPx, max);
+ const upperBound = Math.max(lowerBound, max - minEndPx);
+
+ const boundedValue = Math.max(lowerBound, Math.min(value, upperBound));
+
+ return Math.max(0, Math.min(boundedValue, max));
+ }
+
+ private _updateFixedPaneSize(max: number) {
+ if (this.fixedPane === 'start') {
+ this._fixedPaneSize = this._handlePosition;
+ } else if (this.fixedPane === 'end') {
+ this._fixedPaneSize = max - this._handlePosition;
+ }
+ }
+
private _handleResize = (entries: ResizeObserverEntry[]) => {
const rect = entries[0].contentRect;
const {width, height} = rect;
@@ -259,13 +350,19 @@ export class VscodeSplitLayout extends VscElement {
if (this.fixedPane === 'end') {
this._handlePosition = max - this._fixedPaneSize;
}
+
+ this._handlePosition = this._clampHandlePosition(this._handlePosition, max);
+ this._updateFixedPaneSize(max);
};
private _setPosition(value: number, unit: PositionUnit) {
const {width, height} = this._boundRect;
const max = this.split === 'vertical' ? width : height;
- this._handlePosition = unit === 'percent' ? percentToPx(value, max) : value;
+ const nextValue = unit === 'percent' ? percentToPx(value, max) : value;
+
+ this._handlePosition = this._clampHandlePosition(nextValue, max);
+ this._updateFixedPaneSize(max);
}
private _handleMouseOver() {
@@ -339,18 +436,10 @@ export class VscodeSplitLayout extends VscElement {
const maxPos = vert ? width : height;
const mousePos = vert ? clientX - left : clientY - top;
- this._handlePosition = Math.max(
- 0,
- Math.min(mousePos - this._handleOffset + this.handleSize / 2, maxPos)
- );
+ const rawPosition = mousePos - this._handleOffset + this.handleSize / 2;
- if (this.fixedPane === 'start') {
- this._fixedPaneSize = this._handlePosition;
- }
-
- if (this.fixedPane === 'end') {
- this._fixedPaneSize = maxPos - this._handlePosition;
- }
+ this._handlePosition = this._clampHandlePosition(rawPosition, maxPos);
+ this._updateFixedPaneSize(maxPos);
};
private _handleDblClick() {