diff --git a/.changes/ui-sidebar-drag-no-jump.md b/.changes/ui-sidebar-drag-no-jump.md new file mode 100644 index 000000000..402fe1814 --- /dev/null +++ b/.changes/ui-sidebar-drag-no-jump.md @@ -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. diff --git a/apps/electron-backend-e2e/src/sidebar-width.e2e.ts b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts index e4895bf7a..4722bf6c7 100644 --- a/apps/electron-backend-e2e/src/sidebar-width.e2e.ts +++ b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts @@ -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(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 95f0a4cd8..ed0c64e77 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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, diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts b/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts index 12dfdebff..f39828885 100644 --- a/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts +++ b/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts @@ -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[] = []; @@ -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 }); diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts index d9351c828..30bc381ba 100644 --- a/libs/ui/components/src/lib/resizable/resizable.directive.ts +++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts @@ -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);