diff --git a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md index 27b633a37..283fffe8c 100644 --- a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md +++ b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md @@ -561,7 +561,7 @@ git commit -m "feat(ui): separate M3U live panel toggles" - Modify: `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.{ts,html,scss,spec.ts}` -- [ ] **Step 1: Write failing Favorites/Recent tests** +- [x] **Step 1: Write failing Favorites/Recent tests** Assert exactly one panel-based Channels control in the collection header for both modes, including zero search results; no control inside the list; loading @@ -569,7 +569,7 @@ Live state retains the header control; collapsed list DOM is inert/aria-hidden; `Cmd/Ctrl+B` only suppresses Channels; external/radio Guide disclosure is absent. -- [ ] **Step 2: Run and verify RED** +- [x] **Step 2: Run and verify RED** ```bash pnpm nx test portal-shared-ui --runInBand @@ -578,7 +578,7 @@ pnpm nx test portal-shared-ui --runInBand Expected: FAIL because collection UI still uses the legacy service and `aria-pressed`. -- [ ] **Step 3: Implement the collection exception** +- [x] **Step 3: Implement the collection exception** Inject `LiveLayoutPanelStateService`, keep the one header button, bind `aria-controls="live-channels-panel"` and effective `aria-expanded`, remove @@ -586,13 +586,13 @@ Inject `LiveLayoutPanelStateService`, keep the one header button, bind control in the loading header when the selected type is Live. Add the non-typing `Cmd/Ctrl+B` host listener. -- [ ] **Step 4: Retain and gate the Live list DOM** +- [x] **Step 4: Retain and gate the Live list DOM** Bind `id="live-channels-panel"`, `inert`, and `aria-hidden` on the live-tab sidebar. Pass Guide `collapsible=shouldUseInlinePlayer()` and omit it for radio as today. -- [ ] **Step 5: Run and verify GREEN** +- [x] **Step 5: Run and verify GREEN** ```bash pnpm nx test portal-shared-ui --runInBand @@ -601,7 +601,7 @@ pnpm nx lint portal-shared-ui Expected: both commands exit 0. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add libs/portal/shared/ui diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 8b2024bca..e960c4de3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -1,5 +1,33 @@ + + + + @if (detailContext(); as detailContext) { {{ title() | translate }} + @if (showSidebarToggle()) { + + }
@if (showScopeToggle()) { @@ -18,7 +49,13 @@
@if (selectedContentType() === 'live') { -
+
@for (option of favSortOptions; track option.mode) { - + }
@@ -156,16 +173,14 @@ clearButtonTooltipKey() | translate : { - type: - currentTypeLabelKey() | translate, + type: currentTypeLabelKey() | translate, } " [attr.aria-label]=" clearButtonTooltipKey() | translate : { - type: - currentTypeLabelKey() | translate, + type: currentTypeLabelKey() | translate, } " > @@ -185,7 +200,7 @@ [autoOpenItem]="pendingAutoOpenLiveItem()" [favoriteUids]="favoriteUidSet()" [sortMode]="favSortMode()" - [isSidebarCollapsed]="isSidebarCollapsed()" + [channelsPanelExpanded]="channelsPanelExpanded()" (removeItem)="onRemoveItem($event)" (favoriteToggled)="onFavoriteToggled($event)" (reorderItems)="onReorder($event)" @@ -221,22 +236,44 @@ } @if (!hasLive() && !hasMovies() && !hasSeries()) { - @if (workspaceSearchTerm()) { -
- search_off -

- {{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }} -

-
- } @else { - - } +
+ @if (workspaceSearchTerm()) { +
+ search_off +

+ {{ + 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate + }} +

+
+ } @else { + + } +
} } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss index 9898557b4..82f3964c4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -10,6 +10,15 @@ .skeleton-list { flex: 1; overflow: hidden; + transition: + width 0.18s ease, + flex-basis 0.18s ease; + + &--collapsed { + width: 0; + flex: 0 0 0; + pointer-events: none; + } } .skeleton-grid { @@ -45,6 +54,11 @@ margin-left: -4px; } +.collection-sidebar-toggle { + min-width: 40px; + min-height: 40px; +} + .collection-header-toggles { display: flex; align-items: center; @@ -92,6 +106,22 @@ app-unified-live-tab { flex-direction: row; } +.collection-empty-panel { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + + &--collapsed { + flex: 0 0 0; + pointer-events: none; + } + + > app-empty-state { + flex: 1; + } +} + .empty-state { display: flex; flex-direction: column; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 0bfeb27dd..8214049eb 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -17,15 +17,23 @@ import { CollectionScope, FavoritesChannelSortMode, OPEN_COLLECTION_DETAIL_STATE_KEY, + LIVE_SIDEBAR_STATE_STORAGE_KEY, ScopeToggleService, UnifiedCollectionItem, WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { + LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, + LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, + LIVE_LAYOUT_PANEL, + LiveLayoutPanelStateService, UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -46,7 +54,7 @@ class StubUnifiedLiveTabComponent { readonly autoOpenItem = input(null); readonly favoriteUids = input>(new Set()); readonly sortMode = input('custom'); - readonly isSidebarCollapsed = input(false); + readonly channelsPanelExpanded = input(true); readonly removeItem = output(); readonly favoriteToggled = output(); @@ -131,6 +139,7 @@ describe('UnifiedCollectionPageComponent', () => { let workspaceParamMap$: BehaviorSubject< ReturnType >; + let livePanelState: LiveLayoutPanelStateService; const playlistsLoaded = signal(false); const playlists = signal([]); const favoritesData = { @@ -186,6 +195,9 @@ describe('UnifiedCollectionPageComponent', () => { beforeEach(async () => { playlistsLoaded.set(false); playlists.set([]); + localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY); + localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY); + localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY); jest.clearAllMocks(); workspaceViewCommands.registerCommand.mockReturnValue(jest.fn()); routeParamMap$ = new BehaviorSubject(convertToParamMap({})); @@ -313,6 +325,7 @@ describe('UnifiedCollectionPageComponent', () => { fixture = TestBed.createComponent(UnifiedCollectionPageComponent); fixture.componentRef.setInput('mode', 'favorites'); fixture.componentRef.setInput('defaultScope', 'all'); + livePanelState = TestBed.inject(LiveLayoutPanelStateService); }); it('uses compact loading rows when the runtime has no EPG support', () => { @@ -330,6 +343,103 @@ describe('UnifiedCollectionPageComponent', () => { ).toBeNull(); }); + it('keeps one real Channels control in the loading header', () => { + fixture.componentInstance.isLoading.set(true); + fixture.componentInstance.selectedContentType.set('live'); + fixture.detectChanges(); + + const toggles = fixture.nativeElement.querySelectorAll( + '[data-testid="live-channels-panel-toggle"]' + ) as NodeListOf; + expect(toggles).toHaveLength(1); + expect(toggles[0].getAttribute('aria-controls')).toBe( + 'live-channels-panel' + ); + expect(toggles[0].getAttribute('aria-expanded')).toBe('true'); + + toggles[0].click(); + fixture.detectChanges(); + + const loadingPanel = fixture.nativeElement.querySelector( + '#live-channels-panel' + ) as HTMLElement; + expect(livePanelState.channelsIntent()).toBe('collapsed'); + expect(loadingPanel.hasAttribute('inert')).toBe(true); + expect(loadingPanel.getAttribute('aria-hidden')).toBe('true'); + }); + + it('keeps the collection header as the sole Channels owner through zero results', () => { + fixture.componentInstance.isLoading.set(false); + fixture.componentInstance.selectedContentType.set('live'); + fixture.componentInstance.allItems.set([ + { + uid: 'm3u::playlist-1::live', + name: 'Live channel', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/live.m3u8', + }, + ]); + setRouteQueryParams({ q: 'no-match' }); + fixture.detectChanges(); + + const toggles = fixture.nativeElement.querySelectorAll( + '[data-testid="live-channels-panel-toggle"]' + ); + const liveTab = fixture.debugElement.query( + By.directive(StubUnifiedLiveTabComponent) + ).componentInstance as StubUnifiedLiveTabComponent; + expect(toggles).toHaveLength(1); + expect(liveTab.channelsPanelExpanded()).toBe(true); + + fixture.componentInstance.toggleChannelsPanel(); + fixture.detectChanges(); + + expect(livePanelState.channelsIntent()).toBe('collapsed'); + expect(liveTab.channelsPanelExpanded()).toBe(false); + expect( + fixture.nativeElement.querySelectorAll( + '[data-testid="live-channels-panel-toggle"]' + ) + ).toHaveLength(1); + }); + + it('uses Cmd/Ctrl+B as Channels-only temporary suppression', () => { + fixture.componentInstance.isLoading.set(false); + fixture.componentInstance.selectedContentType.set('live'); + fixture.componentInstance.allItems.set([ + { + uid: 'xtream::playlist-1::live:1', + name: 'Live channel', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + xtreamId: 1, + }, + ]); + livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS); + fixture.detectChanges(); + const shortcut = new KeyboardEvent('keydown', { + key: 'b', + metaKey: true, + }); + jest.spyOn(shortcut, 'preventDefault'); + + fixture.componentInstance.handleSidebarShortcut(shortcut); + fixture.detectChanges(); + + expect(shortcut.preventDefault).toHaveBeenCalled(); + expect(livePanelState.masterSuppressed()).toBe(true); + expect(livePanelState.groupsIntent()).toBe('collapsed'); + expect(livePanelState.channelsIntent()).toBe('expanded'); + + fixture.componentInstance.handleSidebarShortcut(shortcut); + expect(livePanelState.masterSuppressed()).toBe(false); + }); + it('reloads favorites after playlist hydration completes', async () => { fixture.detectChanges(); await fixture.whenStable(); @@ -507,16 +617,18 @@ describe('UnifiedCollectionPageComponent', () => { await fixture.whenStable(); expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem); - expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid)) - .toBe(false); + expect( + fixture.componentInstance.favoriteUidSet().has(recentItem.uid) + ).toBe(false); expect(fixture.componentInstance.allItems()).toEqual([recentItem]); liveTab.favoriteToggled.emit(recentItem); await fixture.whenStable(); expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem); - expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid)) - .toBe(true); + expect( + fixture.componentInstance.favoriteUidSet().has(recentItem.uid) + ).toBe(true); expect(fixture.componentInstance.allItems()).toEqual([recentItem]); }); @@ -826,9 +938,7 @@ describe('UnifiedCollectionPageComponent', () => { }, } ); - expect( - fixture.componentInstance.selectedDetailItem() - ).toBeNull(); + expect(fixture.componentInstance.selectedDetailItem()).toBeNull(); } finally { router.url = originalUrl; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index a03b82f7b..be28cfccc 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -6,7 +6,9 @@ import { computed, contentChild, DestroyRef, + ElementRef, effect, + HostListener, inject, linkedSignal, input, @@ -21,7 +23,10 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { ChannelListSkeletonComponent, DialogService } from '@iptvnator/ui/components'; +import { + ChannelListSkeletonComponent, + DialogService, +} from '@iptvnator/ui/components'; import { buildGlobalCollectionDetailNavigationTarget, buildCollectionViewState, @@ -37,8 +42,8 @@ import { getCollectionViewState, getOpenLiveCollectionItemState, getUnifiedCollectionNavigation, + isTypingInInput, isWorkspaceLayoutRoute, - LiveLayoutSidebarStateService, OPEN_COLLECTION_DETAIL_STATE_KEY, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, persistFavoritesChannelSortMode, @@ -52,11 +57,16 @@ import { WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { + LIVE_LAYOUT_PANEL, + LiveLayoutPanelStateService, UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; @@ -102,10 +112,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly dialogService = inject(DialogService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly translate = inject(TranslateService); - private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); - private readonly liveSidebarStateService = inject( - LiveLayoutSidebarStateService + private readonly workspaceViewCommands = inject( + WorkspaceViewCommandService ); + private readonly livePanelState = inject(LiveLayoutPanelStateService); + private readonly hostElement = inject(ElementRef); readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective); private readonly playlists = this.store.selectSignal( selectAllPlaylistsMeta @@ -276,9 +287,13 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { this.selectedContentType() === 'live' && this.hasLive() ); - readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly showSidebarToggle = computed( - () => this.selectedContentType() === 'live' && this.hasLive() + () => this.selectedContentType() === 'live' + ); + readonly channelsPanelExpanded = computed(() => + this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, { + applicable: this.showSidebarToggle(), + }) ); readonly favSortOptions: ReadonlyArray<{ mode: FavoritesChannelSortMode; @@ -339,6 +354,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { })); private loadRequestId = 0; + private previousChannelsPanelExpanded: boolean | undefined; private readonly loadEffect = effect(() => { const { mode, portalType, playlistId, scope } = this.loadRequest(); @@ -408,8 +424,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { }), keywords: () => this.mode() === 'favorites' - ? ['clear', 'favorites', 'remove', this.selectedContentType()] - : ['clear', 'recent', 'history', this.selectedContentType()], + ? [ + 'clear', + 'favorites', + 'remove', + this.selectedContentType(), + ] + : [ + 'clear', + 'recent', + 'history', + this.selectedContentType(), + ], priority: 10, run: () => this.clearAllCurrent(), }); @@ -418,6 +444,30 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { }); constructor() { + effect(() => { + const applicable = this.showSidebarToggle(); + const expanded = this.channelsPanelExpanded(); + const previous = this.previousChannelsPanelExpanded; + this.previousChannelsPanelExpanded = applicable + ? expanded + : undefined; + if ( + !applicable || + previous === undefined || + previous === expanded + ) { + return; + } + + queueMicrotask(() => { + this.hostElement.nativeElement + .querySelector( + '[data-testid="live-channels-panel-toggle"]' + ) + ?.focus(); + }); + }); + if (typeof window !== 'undefined') { const onPopState = () => { this.syncCollectionViewStateFromHistory(); @@ -468,8 +518,29 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { void this.router.navigate(['/workspace', 'dashboard']); } - toggleSidebar(): void { - this.liveSidebarStateService.toggle(); + toggleChannelsPanel(): void { + if (this.channelsPanelExpanded()) { + this.livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS); + } else { + this.livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS); + } + } + + @HostListener('document:keydown', ['$event']) + handleSidebarShortcut(event: KeyboardEvent): void { + if ( + !this.showSidebarToggle() || + isTypingInInput(event) || + (!event.metaKey && !event.ctrlKey) || + event.key.toLowerCase() !== 'b' + ) { + return; + } + + event.preventDefault(); + this.livePanelState.toggleMasterSuppression([ + LIVE_LAYOUT_PANEL.CHANNELS, + ]); } setFavSortMode(mode: FavoritesChannelSortMode): void { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index df9ce8167..3b5f88166 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -1,6 +1,9 @@