feat: add sidebar width management with resize events in channel list and groups view components

This commit is contained in:
4gray committed 2026-04-06 12:57:27 +02:00
1 parent 4e74ac2546
commit 61a582b8b7
5 files changed
+154

No files matched your search

@@ -40,8 +40,11 @@
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
[sidebarWidth]="sidebarWidth()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
/>
}
@case ('favorites') {
@@ -8,6 +8,7 @@ import {
input,
OnDestroy,
OnInit,
output,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@@ -113,6 +114,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -375,4 +379,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
}) as any
);
}
onSidebarWidthRequested(width: number): void {
this.sidebarWidthRequested.emit(width);
}
onSidebarWidthRequestEnded(width: number): void {
this.sidebarWidthRequestEnded.emit(width);
}
}
@@ -29,6 +29,9 @@
[maxWidth]="320"
[defaultWidth]="208"
storageKey="m3u-groups-nav-width"
(resizeStart)="onGroupsNavResizeStart()"
(widthChange)="onGroupsNavWidthChange($event)"
(resizeEnd)="onGroupsNavResizeEnd($event)"
>
<div class="groups-nav-shell">
<header class="groups-nav-header">
@@ -121,6 +121,7 @@ describe('GroupsViewComponent', () => {
groupedChannels: Record<string, Channel[]>;
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
shouldShowEpg: boolean;
}> = {}
): void {
@@ -149,6 +150,10 @@ describe('GroupsViewComponent', () => {
'favoriteIds',
overrides.favoriteIds ?? new Set<string>()
);
fixture.componentRef.setInput(
'sidebarWidth',
overrides.sidebarWidth ?? 460
);
fixture.detectChanges();
}
@@ -250,6 +255,43 @@ describe('GroupsViewComponent', () => {
});
});
it('emits total sidebar width requests while resizing the groups rail', () => {
const requested = jest.fn();
const committed = jest.fn();
let contentWidth = 252;
component.sidebarWidthRequested.subscribe(requested);
component.sidebarWidthRequestEnded.subscribe(committed);
const contentPanel = fixture.nativeElement.querySelector(
'.groups-content-panel'
) as HTMLElement;
jest.spyOn(contentPanel, 'getBoundingClientRect').mockImplementation(
() =>
({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: contentWidth,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect
);
component.onGroupsNavResizeStart();
contentWidth = 120;
component.onGroupsNavWidthChange(260);
component.onGroupsNavResizeEnd(260);
expect(requested).toHaveBeenCalledWith(512);
expect(committed).toHaveBeenCalledWith(512);
});
it('renders the no-results state for searches without matches', () => {
setInputs({ searchTerm: 'zzz' });
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
ElementRef,
OutputEmitterRef,
computed,
effect,
inject,
@@ -61,6 +62,9 @@ export class GroupsViewComponent {
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
@@ -70,10 +74,17 @@ export class GroupsViewComponent {
event: MouseEvent;
}>();
/** Emits while the groups rail requests a larger total sidebar width */
readonly sidebarWidthRequested = output<number>();
/** Emits when the groups rail resize ends */
readonly sidebarWidthRequestEnded = output<number>();
readonly selectedGroupKey = signal<string | null>(null);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
private previousActiveChannelUrl: string | undefined;
private preservedContentWidth = 0;
constructor() {
effect(() => {
@@ -254,6 +265,19 @@ export class GroupsViewComponent {
this.selectedGroupKey.set(groupKey);
}
onGroupsNavResizeStart(): void {
this.preservedContentWidth = this.measureContentPanelWidth();
}
onGroupsNavWidthChange(width: number): void {
this.emitSidebarWidthRequest(width, this.sidebarWidthRequested);
}
onGroupsNavResizeEnd(width: number): void {
this.emitSidebarWidthRequest(width, this.sidebarWidthRequestEnded);
this.preservedContentWidth = 0;
}
trackByChannel(_: number, channel: Channel): string {
return channel?.id;
}
@@ -318,4 +342,74 @@ export class GroupsViewComponent {
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
private emitSidebarWidthRequest(
navWidth: number,
emitter: OutputEmitterRef<number>
): void {
const preservedContentWidth =
this.preservedContentWidth || this.measureContentPanelWidth(navWidth);
const requestedWidth = Math.round(navWidth + preservedContentWidth);
if (requestedWidth > 0) {
emitter.emit(requestedWidth);
}
}
private measureContentPanelWidth(currentNavWidth?: number): number {
const contentPanel = this.hostEl.nativeElement.querySelector(
'.groups-content-panel'
);
const measuredContentWidth = this.readWidth(contentPanel);
if (measuredContentWidth > 0) {
return measuredContentWidth;
}
const hostWidth = this.readWidth(this.hostEl.nativeElement);
const totalWidth =
hostWidth > 0 ? hostWidth : Math.max(0, this.sidebarWidth() ?? 0);
const navPanel = this.hostEl.nativeElement.querySelector(
'.groups-nav-panel'
);
const navWidth = currentNavWidth ?? this.readWidth(navPanel);
if (totalWidth > 0 && navWidth > 0) {
return Math.max(0, totalWidth - navWidth);
}
return 0;
}
private readWidth(element: Element | null): number {
if (!element) {
return 0;
}
const rectWidth = element.getBoundingClientRect().width;
if (rectWidth > 0) {
return rectWidth;
}
if (!(element instanceof HTMLElement)) {
return 0;
}
if (element.offsetWidth > 0) {
return element.offsetWidth;
}
const inlineWidth = Number.parseFloat(element.style.width);
if (Number.isFinite(inlineWidth) && inlineWidth > 0) {
return inlineWidth;
}
const computedWidth = Number.parseFloat(
window.getComputedStyle(element).width
);
if (Number.isFinite(computedWidth) && computedWidth > 0) {
return computedWidth;
}
return 0;
}
}