From 696a93162c25886dbfbaf0684432d736e4923257 Mon Sep 17 00:00:00 2001 From: dankeboy36 Date: Thu, 6 Nov 2025 17:24:50 +0100 Subject: [PATCH] fix: add missing minStart/minEnd to split-layout Closes: vscode-elements/elements#552 Signed-off-by: dankeboy36 --- dev/vscode-split-layout/min-size.html | 109 ++++++++++++++ .../vscode-split-layout.test.ts | 136 ++++++++++++++++++ .../vscode-split-layout.ts | 123 +++++++++++++--- 3 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 dev/vscode-split-layout/min-size.html 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() {