From 002faebb080191f5a16ba112da5424551ae853c4 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:21:56 +0200 Subject: [PATCH] refactor(ui): one channel context menu for every channel list (#1948) Seven channel lists (the M3U all-channels, groups, favorites and recent views, the Stalker and Xtream live lists, and the unified favorites and recents list) each carried their own anchor element, MatMenuTrigger, position signal, open-at-pointer code, mat-menu markup and anchor CSS. `app-channel-context-menu` (@iptvnator/ui/components) now owns the anchor, the menu and its rows in one order: Map EPG, Show details, View in a playlist, and Remove from history as the kit's danger row after a divider. A host sets which rows apply, handles their outputs with its existing handlers, and calls `open(event)` from the row's contextMenuRequested. The host specs now assert that the shared menu opens at the pointer; the new component spec covers row order, the divider, re-opening at a new point and each output. Co-authored-by: Claude Opus 5.5 --- .changes/ui-channel-context-menu.md | 7 + docs/architecture/iptvnator-ui-guidelines.md | 8 + .../global-favorites-list.component.html | 60 ++------ .../global-favorites-list.component.scss | 8 - .../global-favorites-list.component.spec.ts | 24 ++- .../global-favorites-list.component.ts | 39 ++--- .../stalker-live-stream-layout.component.html | 16 +- .../stalker-live-stream-layout.component.scss | 8 - .../stalker-live-stream-layout.component.ts | 30 ++-- .../portal-channels-list.component.html | 19 +-- .../portal-channels-list.component.scss | 19 ++- .../portal-channels-list.component.ts | 30 ++-- libs/ui/components/src/index.ts | 1 + .../all-channels-view.component.html | 27 +--- .../all-channels-view.component.scss | 8 - .../all-channels-view.component.spec.ts | 10 +- .../all-channels-view.component.ts | 31 ++-- .../channel-context-menu.component.html | 52 +++++++ .../channel-context-menu.component.scss | 12 ++ .../channel-context-menu.component.spec.ts | 144 ++++++++++++++++++ .../channel-context-menu.component.ts | 76 +++++++++ .../favorites-view.component.html | 27 +--- .../favorites-view.component.scss | 8 - .../favorites-view.component.spec.ts | 10 +- .../favorites-view.component.ts | 31 +--- .../groups-view/groups-view.component.html | 27 +--- .../groups-view/groups-view.component.scss | 8 - .../groups-view/groups-view.component.spec.ts | 10 +- .../groups-view/groups-view.component.ts | 31 ++-- .../recent-view/recent-view.component.html | 33 +--- .../recent-view/recent-view.component.scss | 8 - .../recent-view/recent-view.component.spec.ts | 15 +- .../recent-view/recent-view.component.ts | 33 ++-- 33 files changed, 452 insertions(+), 418 deletions(-) create mode 100644 .changes/ui-channel-context-menu.md create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-context-menu/channel-context-menu.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-context-menu/channel-context-menu.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-context-menu/channel-context-menu.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-context-menu/channel-context-menu.component.ts diff --git a/.changes/ui-channel-context-menu.md b/.changes/ui-channel-context-menu.md new file mode 100644 index 000000000..1cabfc0e7 --- /dev/null +++ b/.changes/ui-channel-context-menu.md @@ -0,0 +1,7 @@ +--- +type: fix +area: ui +--- + +The right-click menu on a channel looks and behaves the same in every channel +list, and "Remove from history" is marked in red after a separator. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 66a6c1394..7226adbd2 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -515,6 +515,14 @@ dialogs, player controls and hidden/inert panes keep their own behavior. The shared row should be reused instead of rebuilding channel markup per view. +Its right-click menu is `app-channel-context-menu` (`@iptvnator/ui/components`), +shared by the M3U views, the Xtream and Stalker live lists and the unified +favorites and recents. A host sets which rows apply (`showMapEpg`, +`showDetails`, `openInPlaylistName`, `showRemoveFromHistory`), handles their +outputs, and calls `open(event)` from the row's `contextMenuRequested`; the +rows keep one order and Remove from history is the danger row after a +divider. + ### Current Reference Values - Current minimum height: diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index 93d680eb2..e69b8bbac 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -63,47 +63,19 @@ } - - - - @if (supportsEpgMapping && !contextMenuUnavailable()) { - - } - @if (contextMenuChannel()?.m3uChannel && !contextMenuUnavailable()) { - - } - @if (contextMenuCanOpenInPlaylist()) { - - } - @if (mode() === 'recent') { - - } - + + diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss index e22cb4a93..3bb74e868 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss @@ -40,14 +40,6 @@ gap: 2px; } -.channel-context-menu-anchor { - position: fixed; - width: 0; - height: 0; - opacity: 0; - pointer-events: none; -} - .empty-state { display: flex; flex-direction: column; diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index 4610a349b..17588f193 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -191,7 +191,7 @@ describe('GlobalFavoritesListComponent', () => { fixture.detectChanges(); const openMenuSpy = jest - .spyOn(fixture.componentInstance.contextMenuTrigger(), 'openMenu') + .spyOn(fixture.componentInstance.channelContextMenu(), 'open') .mockImplementation(); fixture.componentInstance.onChannelContextMenu( @@ -203,11 +203,9 @@ describe('GlobalFavoritesListComponent', () => { ); await Promise.resolve(); - expect(fixture.componentInstance.contextMenuPosition()).toEqual({ - x: '24px', - y: '32px', - }); - expect(openMenuSpy).toHaveBeenCalled(); + expect(openMenuSpy).toHaveBeenCalledWith( + expect.objectContaining({ clientX: 24, clientY: 32 }) + ); fixture.componentInstance.openChannelDetails(); @@ -230,8 +228,8 @@ describe('GlobalFavoritesListComponent', () => { fixture.detectChanges(); jest.spyOn( - fixture.componentInstance.contextMenuTrigger(), - 'openMenu' + fixture.componentInstance.channelContextMenu(), + 'open' ).mockImplementation(); fixture.componentInstance.onChannelContextMenu( @@ -266,8 +264,8 @@ describe('GlobalFavoritesListComponent', () => { expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true); jest.spyOn( - fixture.componentInstance.contextMenuTrigger(), - 'openMenu' + fixture.componentInstance.channelContextMenu(), + 'open' ).mockImplementation(); fixture.componentInstance.onChannelContextMenu( fixture.componentInstance.enrichedChannels()[0], @@ -293,11 +291,11 @@ describe('GlobalFavoritesListComponent', () => { expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true); const closeMenu = jest - .spyOn(fixture.componentInstance.contextMenuTrigger(), 'closeMenu') + .spyOn(fixture.componentInstance.channelContextMenu(), 'close') .mockImplementation(); jest.spyOn( - fixture.componentInstance.contextMenuTrigger(), - 'openMenu' + fixture.componentInstance.channelContextMenu(), + 'open' ).mockImplementation(); fixture.componentInstance.onChannelContextMenu( diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 134449b4c..003bc67a4 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -1,5 +1,8 @@ import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; -import { ChannelScrollFocusDirective } from '@iptvnator/ui/components'; +import { + ChannelContextMenuComponent, + ChannelScrollFocusDirective, +} from '@iptvnator/ui/components'; import { CdkDragDrop, DragDropModule, @@ -17,7 +20,6 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { ChannelDetailsDialogComponent, ChannelListItemComponent, @@ -57,11 +59,11 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent'; styleUrl: './global-favorites-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + ChannelContextMenuComponent, ChannelScrollFocusDirective, ChannelListItemComponent, DragDropModule, MatIconModule, - MatMenuModule, TranslateModule, ], }) @@ -72,8 +74,9 @@ export class GlobalFavoritesListComponent { private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); - readonly contextMenuTrigger = - viewChild.required('contextMenuTrigger'); + readonly channelContextMenu = viewChild.required( + ChannelContextMenuComponent + ); readonly openStreamOnDoubleClick = computed(() => this.settingsStore.openStreamOnDoubleClick() ); @@ -113,10 +116,6 @@ export class GlobalFavoritesListComponent { readonly contextMenuPlaylistName = computed(() => playlistDisplayLabel(this.contextMenuChannel()?.playlistName) ); - readonly contextMenuPosition = signal({ - x: '0px', - y: '0px', - }); readonly isCustomSort = computed(() => this.sortMode() === 'custom'); readonly canDragDrop = computed( @@ -197,19 +196,7 @@ export class GlobalFavoritesListComponent { event: MouseEvent ): void { this.contextMenuChannel.set(channel); - this.contextMenuPosition.set({ - x: `${event.clientX}px`, - y: `${event.clientY}px`, - }); - - const trigger = this.contextMenuTrigger(); - if (trigger.menuOpen) { - trigger.closeMenu(); - } - - queueMicrotask(() => { - this.contextMenuTrigger().openMenu(); - }); + this.channelContextMenu().open(event); } hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean { @@ -235,7 +222,7 @@ export class GlobalFavoritesListComponent { return; } - this.contextMenuTrigger().closeMenu(); + this.channelContextMenu().close(); this.openInPlaylistRequested.emit(channel); } @@ -245,7 +232,7 @@ export class GlobalFavoritesListComponent { return; } - this.contextMenuTrigger().closeMenu(); + this.channelContextMenu().close(); const stalkerId = this.stalkerItemId(item); const channelKey = item.m3uChannel ? resolveChannelEpgLookupKey(item.m3uChannel) @@ -308,7 +295,7 @@ export class GlobalFavoritesListComponent { return; } - this.contextMenuTrigger().closeMenu(); + this.channelContextMenu().close(); this.dialog.open(ChannelDetailsDialogComponent, { data: channel, maxWidth: '720px', @@ -322,7 +309,7 @@ export class GlobalFavoritesListComponent { return; } - this.contextMenuTrigger().closeMenu(); + this.channelContextMenu().close(); this.removeRequested.emit(channel); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index cf7dedeca..440a4dd6c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -244,21 +244,7 @@ - - - - - +