diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 7f5445e6e..d1e9e8f32 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -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') { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 0f4e1120a..4da626dab 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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('all'); readonly recentItems = input([]); + readonly sidebarWidth = input(null); + readonly sidebarWidthRequested = output(); + readonly sidebarWidthRequestEnded = output(); 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); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index abf936b29..407ab6faa 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -29,6 +29,9 @@ [maxWidth]="320" [defaultWidth]="208" storageKey="m3u-groups-nav-width" + (resizeStart)="onGroupsNavResizeStart()" + (widthChange)="onGroupsNavWidthChange($event)" + (resizeEnd)="onGroupsNavResizeEnd($event)" >
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index e0d3af919..1517027a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -121,6 +121,7 @@ describe('GroupsViewComponent', () => { groupedChannels: Record; progressTick: number; searchTerm: string; + sidebarWidth: number | null; shouldShowEpg: boolean; }> = {} ): void { @@ -149,6 +150,10 @@ describe('GroupsViewComponent', () => { 'favoriteIds', overrides.favoriteIds ?? new Set() ); + 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' }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 2a06319b7..1a1c4d761 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -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>(new Set()); + /** Current outer sidebar width */ + readonly sidebarWidth = input(null); + /** Emits when a channel is selected */ readonly channelSelected = output(); @@ -70,10 +74,17 @@ export class GroupsViewComponent { event: MouseEvent; }>(); + /** Emits while the groups rail requests a larger total sidebar width */ + readonly sidebarWidthRequested = output(); + + /** Emits when the groups rail resize ends */ + readonly sidebarWidthRequestEnded = output(); + readonly selectedGroupKey = signal(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 + ): 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; + } }