diff --git a/.changes/ui-sidebar-width-kept.md b/.changes/ui-sidebar-width-kept.md new file mode 100644 index 000000000..fbabe190b --- /dev/null +++ b/.changes/ui-sidebar-width-kept.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +A sidebar you widen now keeps its width after you visit a page with a narrower sidebar, such as Settings. Opening that page no longer saves its smaller width over yours. diff --git a/apps/electron-backend-e2e/src/sidebar-width.e2e.ts b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts new file mode 100644 index 000000000..e4895bf7a --- /dev/null +++ b/apps/electron-backend-e2e/src/sidebar-width.e2e.ts @@ -0,0 +1,86 @@ +import { Locator, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** + * 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. + */ + +const SHARED_WIDTH_KEY = 'sidebar-width'; + +/** The CSS width the directive sets; the panels are content-box. */ +function renderedWidth(panel: Locator): Promise { + return panel.evaluate((el) => parseFloat(getComputedStyle(el).width)); +} + +function storedWidth(page: Page): Promise { + return page.evaluate((key) => localStorage.getItem(key), SHARED_WIDTH_KEY); +} + +async function dragPanelTo( + page: Page, + panel: Locator, + width: number +): Promise { + const handle = panel.locator(':scope > .resize-handle'); + const box = await handle.boundingBox(); + 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)); + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(startX + delta, y, { steps: 10 }); + await page.mouse.up(); +} + +test.describe('Electron shared sidebar width', () => { + test('@persistence @electron keeps a widened categories panel after Settings clamps it', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await page.setViewportSize({ width: 1440, height: 900 }); + await addXtreamPortal(page); + await openWorkspaceSection(page, 'Movies'); + await waitForXtreamWorkspaceReady(page); + const categories = page.locator('aside.context-panel--route'); + await expect(categories).toBeVisible(); + const categoriesUrl = page.url(); + + await dragPanelTo(page, categories, 520); + await expect.poll(() => renderedWidth(categories)).toBe(520); + await expect.poll(() => storedWidth(page)).toBe('520'); + + await openSettings(page); + const settings = page.locator('aside.context-panel--settings'); + await expect(settings).toBeVisible(); + await expect.poll(() => renderedWidth(settings)).toBe(400); + expect(await storedWidth(page)).toBe('520'); + + await page.goBack(); + await page.waitForURL(categoriesUrl); + await expect(categories).toBeVisible(); + await expect.poll(() => renderedWidth(categories)).toBe(520); + expect(await storedWidth(page)).toBe('520'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a9f26aa52..95f0a4cd8 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -212,6 +212,14 @@ directive-generated `.resize-handle` sets this centrally in `resizable.scss`. The shared live-layout sidebar reserves 8 px at its right edge so the inward half of the 12 px resize handle cannot cover the channel scrollbar. +## Resizable Sidebar Widths + +Workspace context panels and Favorites share one `sidebar-width` key (its +legacy aliases map onto it) but have different limits. `ResizableDirective` +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. + ## 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 new file mode 100644 index 000000000..12dfdebff --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.directive.spec.ts @@ -0,0 +1,181 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResizableDirective } from './resizable.directive'; + +@Component({ + imports: [ResizableDirective], + template: ` + + `, +}) +class Host { + minWidth = 200; + maxWidth = 600; + defaultWidth = 300; + storageKey = 'sidebar-width'; + handlePosition: 'right' | 'left' = 'right'; +} + +describe('ResizableDirective', () => { + const fixtures: ComponentFixture[] = []; + + function render(host: Partial = {}): HTMLElement { + const fixture = TestBed.createComponent(Host); + Object.assign(fixture.componentInstance, host); + fixture.detectChanges(); + fixtures.push(fixture); + + const aside: HTMLElement = fixture.nativeElement.querySelector('aside'); + // jsdom has no layout; a drag starts from the rendered width. + Object.defineProperty(aside, 'offsetWidth', { + configurable: true, + get: () => parseInt(aside.style.width, 10) || 0, + }); + return aside; + } + + function drag(aside: HTMLElement, fromX: number, toX: number): void { + const handle = aside.querySelector('.resize-handle') as HTMLElement; + handle.dispatchEvent( + new MouseEvent('mousedown', { clientX: fromX, bubbles: true }) + ); + if (toX !== fromX) { + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: toX, bubbles: true }) + ); + } + document.dispatchEvent( + new MouseEvent('mouseup', { clientX: toX, bubbles: true }) + ); + } + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + fixtures.splice(0).forEach((fixture) => fixture.destroy()); + localStorage.clear(); + }); + + it('clamps a shared width for rendering without writing it back', () => { + localStorage.setItem('sidebar-width', '520'); + + const settings = render({ minWidth: 200, maxWidth: 400 }); + + expect(settings.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + + const categories = render({ minWidth: 240, maxWidth: 560 }); + + expect(categories.style.width).toBe('520px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + }); + + it('does not raise a shared width a wider minimum clamps up', () => { + localStorage.setItem('sidebar-width', '210'); + + const favorites = render({ minWidth: 250, maxWidth: 600 }); + + expect(favorites.style.width).toBe('250px'); + expect(localStorage.getItem('sidebar-width')).toBe('210'); + }); + + it('reads an alias key from the shared width without writing it', () => { + localStorage.setItem('sidebar-width', '520'); + + const panel = render({ + maxWidth: 400, + storageKey: 'workspace-settings-panel-width', + }); + + expect(panel.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + expect( + localStorage.getItem('workspace-settings-panel-width') + ).toBeNull(); + }); + + it('migrates the raw legacy width, not the clamped one', () => { + localStorage.setItem('downloads-sidebar-width', '520'); + + const downloads = render({ + maxWidth: 400, + storageKey: 'downloads-sidebar-width', + }); + + expect(downloads.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + expect(localStorage.getItem('downloads-sidebar-width')).toBeNull(); + }); + + it('persists the width a user drags to', () => { + localStorage.setItem('sidebar-width', '300'); + const aside = render({ maxWidth: 560 }); + + drag(aside, 100, 180); + + expect(aside.style.width).toBe('380px'); + expect(localStorage.getItem('sidebar-width')).toBe('380'); + }); + + it('persists a left-handle drag on a key of its own', () => { + localStorage.setItem('live-channels-sidebar-width', '400'); + const aside = render({ + storageKey: 'live-channels-sidebar-width', + handlePosition: 'left', + }); + + drag(aside, 400, 350); + + expect(aside.style.width).toBe('450px'); + expect(localStorage.getItem('live-channels-sidebar-width')).toBe('450'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + }); + + it('does not persist the clamped width when the handle is only clicked', () => { + localStorage.setItem('sidebar-width', '520'); + const settings = render({ maxWidth: 400 }); + + drag(settings, 100, 100); + + expect(settings.style.width).toBe('400px'); + expect(localStorage.getItem('sidebar-width')).toBe('520'); + }); + + it('falls back to the default without writing an unparseable width', () => { + localStorage.setItem('sidebar-width', 'wide'); + + const aside = render({ defaultWidth: 290 }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBe('wide'); + }); + + it('keeps an unparseable legacy width instead of migrating it', () => { + localStorage.setItem('downloads-sidebar-width', 'wide'); + + const aside = render({ + defaultWidth: 290, + storageKey: 'downloads-sidebar-width', + }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + expect(localStorage.getItem('downloads-sidebar-width')).toBe('wide'); + }); + + it('renders the default without writing when nothing is stored', () => { + const aside = render({ defaultWidth: 290 }); + + expect(aside.style.width).toBe('290px'); + expect(localStorage.getItem('sidebar-width')).toBeNull(); + }); +}); diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts index 13003dc25..d9351c828 100644 --- a/libs/ui/components/src/lib/resizable/resizable.directive.ts +++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts @@ -23,6 +23,11 @@ const SIDEBAR_WIDTH_KEY_ALIASES = new Set([ 'iptvnator_video_sidebar_width', ]); +function parseStoredWidth(value: string): number | null { + const width = parseInt(value, 10); + return Number.isNaN(width) ? null : width; +} + /** * ResizableDirective - Makes an element horizontally resizable by dragging its edge. * @@ -72,6 +77,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { private isResizing = signal(false); private startX = 0; private startWidth = 0; + private widthBeforeResize = 0; private currentWidth = signal(0); // Event listener references for cleanup @@ -164,39 +170,41 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.renderer.appendChild(this.el.nativeElement, handle); } + /** + * Sidebars sharing a key have different limits, so the stored width is + * clamped for rendering only: writing it back would hand every other + * sidebar the narrowest (or widest) limit. Only a user resize persists. + */ private loadPersistedWidth(): void { + const storedWidth = this.readStoredWidth(); + this.setWidthImmediate( + storedWidth === null + ? this.defaultWidth() + : this.clampWidth(storedWidth) + ); + } + + /** The unclamped stored width, migrating a legacy key's raw value. */ + private readStoredWidth(): number | null { const { key, legacyKeys } = this.resolveStorageKey(); - let widthToApply = this.defaultWidth(); - - if (key) { - let stored = localStorage.getItem(key); - let sourceLegacyKey: string | null = null; - - if (!stored) { - for (const legacyKey of legacyKeys) { - const legacyValue = localStorage.getItem(legacyKey); - if (legacyValue) { - stored = legacyValue; - sourceLegacyKey = legacyKey; - break; - } - } - } - - if (stored) { - const width = parseInt(stored, 10); - if (!isNaN(width)) { - widthToApply = this.clampWidth(width); - localStorage.setItem(key, widthToApply.toString()); - if (sourceLegacyKey && sourceLegacyKey !== key) { - localStorage.removeItem(sourceLegacyKey); - } - } - } + const stored = localStorage.getItem(key); + if (stored) { + return parseStoredWidth(stored); } - // Apply width immediately without transition to avoid flash - this.setWidthImmediate(widthToApply); + for (const legacyKey of legacyKeys) { + const legacyValue = localStorage.getItem(legacyKey); + if (!legacyValue) continue; + + const width = parseStoredWidth(legacyValue); + if (width !== null) { + localStorage.setItem(key, legacyValue); + localStorage.removeItem(legacyKey); + } + return width; + } + + return null; } /** Set width immediately without CSS transition */ @@ -246,6 +254,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.isResizing.set(true); this.startX = clientX; this.startWidth = this.el.nativeElement.offsetWidth; + this.widthBeforeResize = this.currentWidth(); this.resizeStart.emit(); } @@ -284,7 +293,11 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { this.isResizing.set(false); this.removeGlobalListeners(); - this.persistWidth(); + // A click on the handle is not a resize: persisting would save the + // width this sidebar clamped instead of the shared one. + if (this.currentWidth() !== this.widthBeforeResize) { + this.persistWidth(); + } this.resizeEnd.emit(this.currentWidth()); }