fix(ui): start a sidebar drag from its CSS width, not its border box

ResizableDirective started a drag from offsetWidth, which includes
padding and border, but writes the result to style.width. The hosts
are content-box, so the first move widened a padded host by its
padding plus border: 23px on the workspace categories panel, 1px on
the Favorites sidebar. Each drag also saved a width that much larger.

A drag now starts from the computed CSS width (the box style.width
sizes), falling back to the directive's own width when that is not a
length. Border-box hosts (live-layout and groups rails) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-08 19:15:23 +02:00
1 parent 6edec0fc76
commit b45114e259
5 files changed
+73 -5

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: ui
---
A sidebar no longer jumps wider the moment you start dragging its edge. It now follows the pointer from its current width, so a resized sidebar ends up exactly as wide as you dragged it.
@@ -15,6 +15,8 @@ import {
* The workspace context panels share one stored width but clamp it to their
* own limits. Opening a narrower panel used to save its clamped width, so a
* categories panel widened past Settings' 400px maximum came back at 400.
* The drag also lands on 520 only if the padded panel does not jump wider
* when the drag starts.
*/
const SHARED_WIDTH_KEY = 'sidebar-width';
@@ -38,8 +40,9 @@ async function dragPanelTo(
if (!box) throw new Error('The panel has no resize handle');
const startX = Math.round(box.x + box.width / 2);
const y = Math.round(box.y + box.height / 2);
// The directive starts a drag from offsetWidth (border box included).
const delta = width - (await panel.evaluate((el) => el.offsetWidth));
// A drag starts from the CSS width, so the panel's padding and border
// must not count toward the distance.
const delta = width - (await renderedWidth(panel));
await page.mouse.move(startX, y);
await page.mouse.down();
@@ -220,6 +220,12 @@ renders the stored width clamped to the host's own limits and never writes
that clamped value back: only a drag that changes the width persists, so a
narrow sidebar such as Settings cannot shrink the width a wider one saved.
A drag starts from the host's computed CSS `width`, the box that `style.width`
sizes, not from `offsetWidth`. Hosts may be `content-box` (the workspace
context panels and the Favorites sidebar) or `border-box` (live-layout and
groups rails); starting from the border box widened a padded `content-box`
host by its padding and border on the first move.
## Keyboard Scrolling and Channel Focus
`ChannelScrollFocusDirective` belongs on the actual channel scroll owner,
@@ -23,6 +23,18 @@ class Host {
handlePosition: 'right' | 'left' = 'right';
}
/** A content-box element's border box: its width plus padding and border. */
function borderBoxWidth(el: HTMLElement): number {
const style = getComputedStyle(el);
return [
style.width,
style.paddingLeft,
style.paddingRight,
style.borderLeftWidth,
style.borderRightWidth,
].reduce((sum, value) => sum + (parseFloat(value) || 0), 0);
}
describe('ResizableDirective', () => {
const fixtures: ComponentFixture<Host>[] = [];
@@ -33,10 +45,11 @@ describe('ResizableDirective', () => {
fixtures.push(fixture);
const aside: HTMLElement = fixture.nativeElement.querySelector('aside');
// jsdom has no layout; a drag starts from the rendered width.
// jsdom has no layout. Give offsetWidth the border box a browser
// reports, so a drag that starts from it is caught widening.
Object.defineProperty(aside, 'offsetWidth', {
configurable: true,
get: () => parseInt(aside.style.width, 10) || 0,
get: () => borderBoxWidth(aside),
});
return aside;
}
@@ -63,6 +76,7 @@ describe('ResizableDirective', () => {
afterEach(() => {
fixtures.splice(0).forEach((fixture) => fixture.destroy());
localStorage.clear();
jest.restoreAllMocks();
});
it('clamps a shared width for rendering without writing it back', () => {
@@ -140,6 +154,32 @@ describe('ResizableDirective', () => {
expect(localStorage.getItem('sidebar-width')).toBeNull();
});
it('drags a padded host from its CSS width, not its border box', () => {
localStorage.setItem('sidebar-width', '300');
const panel = render({ maxWidth: 560 });
panel.style.padding = '0 10px 0 12px';
panel.style.borderRight = '1px solid';
expect(panel.offsetWidth).toBe(323);
drag(panel, 100, 140);
expect(panel.style.width).toBe('340px');
expect(localStorage.getItem('sidebar-width')).toBe('340');
});
it('drags from the width it set when the CSS width is not a length', () => {
localStorage.setItem('sidebar-width', '300');
const aside = render();
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
width: 'auto',
} as CSSStyleDeclaration);
drag(aside, 100, 140);
expect(aside.style.width).toBe('340px');
expect(localStorage.getItem('sidebar-width')).toBe('340');
});
it('does not persist the clamped width when the handle is only clicked', () => {
localStorage.setItem('sidebar-width', '520');
const settings = render({ maxWidth: 400 });
@@ -253,11 +253,24 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
private startResize(clientX: number): void {
this.isResizing.set(true);
this.startX = clientX;
this.startWidth = this.el.nativeElement.offsetWidth;
this.startWidth = this.renderedWidth();
this.widthBeforeResize = this.currentWidth();
this.resizeStart.emit();
}
/**
* The rendered width in the box `style.width` sizes: the content box for
* a content-box host, the border box for a border-box one. `offsetWidth`
* always includes padding and border, so starting from it widened padded
* hosts by that amount on the first move.
*/
private renderedWidth(): number {
const width = parseFloat(
window.getComputedStyle(this.el.nativeElement).width
);
return Number.isFinite(width) ? width : this.currentWidth();
}
private onMouseMove(e: MouseEvent): void {
if (!this.isResizing()) return;
this.resize(e.clientX);