mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(ui): keep the saved sidebar width when a narrower sidebar clamps it
The workspace context panels and Favorites share one stored `sidebar-width` but clamp it to different limits. Loading wrote the clamped value back, so visiting Settings (max 400) cut a categories panel widened to 520 down to 400; the saved width could only shrink. Load now clamps for rendering only. Legacy alias keys migrate their raw value, an unparseable value falls back to the default without a write, and only a drag that changes the width persists — a click on the handle no longer saves the clamped width either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c8f5028c7
commit
6edec0fc76
5 files changed
+324
-30
No files matched your search
@@ -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: `
|
||||
<aside
|
||||
appResizable
|
||||
[minWidth]="minWidth"
|
||||
[maxWidth]="maxWidth"
|
||||
[defaultWidth]="defaultWidth"
|
||||
[storageKey]="storageKey"
|
||||
[handlePosition]="handlePosition"
|
||||
></aside>
|
||||
`,
|
||||
})
|
||||
class Host {
|
||||
minWidth = 200;
|
||||
maxWidth = 600;
|
||||
defaultWidth = 300;
|
||||
storageKey = 'sidebar-width';
|
||||
handlePosition: 'right' | 'left' = 'right';
|
||||
}
|
||||
|
||||
describe('ResizableDirective', () => {
|
||||
const fixtures: ComponentFixture<Host>[] = [];
|
||||
|
||||
function render(host: Partial<Host> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user