feat(ui): add independent M3U panel controls

This commit is contained in:
4gray committed 2026-07-31 08:58:17 +02:00
1 parent c7e876f33d
commit 2676160b31
17 files changed
+744 -343

No files matched your search

@@ -261,7 +261,7 @@ git commit -m "fix(ui): expose guide collapse capability"
- Modify:
`libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.{ts,html,scss,spec.ts}`
- [ ] **Step 1: Write failing tests for panel-specific outputs**
- [x] **Step 1: Write failing tests for panel-specific outputs**
Assert that All Channels emits `channelsPanelExpandedChange(false)` from its
header. Assert that Groups has separate Groups and Channels buttons/restore
@@ -282,7 +282,7 @@ expect(document.activeElement).toBe(groupsRestore);
The loading-state spec must prove both controls remain in the Groups skeleton
and Channels remains in the All Channels skeleton.
- [ ] **Step 2: Run Components tests and verify RED**
- [x] **Step 2: Run Components tests and verify RED**
```bash
pnpm nx test components --runInBand
@@ -290,7 +290,7 @@ pnpm nx test components --runInBand
Expected: FAIL because panel-specific inputs/outputs and controls do not exist.
- [ ] **Step 3: Implement the All Channels and loading contracts**
- [x] **Step 3: Implement the All Channels and loading contracts**
Replace ambiguous `sidebarToggleRequested` with:
@@ -303,7 +303,7 @@ Use `live-channels-panel-hide` and `live-channels-panel-restore` test IDs,
`aria-controls="live-channels-panel"`, `aria-expanded`, translated names, and
40px targets. Loading-state controls emit the same explicit requested value.
- [ ] **Step 4: Implement independent Groups layout**
- [x] **Step 4: Implement independent Groups layout**
Give `GroupsViewComponent`:
@@ -320,13 +320,13 @@ the hidden width, and focus the counterpart control after a zero-delay render
turn. The Groups toggle is the final action after search/sort/manage; the
Channels toggle remains the final action after channel sort.
- [ ] **Step 5: Thread the contract through `ChannelListContainerComponent`**
- [x] **Step 5: Thread the contract through `ChannelListContainerComponent`**
Add matching inputs and outputs and pass them through the All/Groups/loading
branches. Do not render panel actions for legacy M3U `favorites`/`recent`
branches.
- [ ] **Step 6: Run and verify GREEN**
- [x] **Step 6: Run and verify GREEN**
```bash
pnpm nx test components --runInBand
@@ -335,7 +335,7 @@ pnpm nx lint components
Expected: both commands exit 0.
- [ ] **Step 7: Commit**
- [x] **Step 7: Commit**
```bash
git add libs/ui/components
@@ -15,9 +15,12 @@
type="button"
mat-icon-button
class="all-channels-sidebar-toggle"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
(click)="sidebarToggleRequested.emit()"
(click)="channelsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -63,6 +63,8 @@
.all-channels-sidebar-toggle {
flex: 0 0 auto;
margin-right: -4px;
min-width: 40px;
min-height: 40px;
}
.all-channels-divider {
@@ -191,17 +191,23 @@ describe('AllChannelsViewComponent', () => {
).toEqual(['Zulu Vision', 'Middle News', 'Alpha Signal']);
});
it('emits sidebar toggle requests from the inline header action', () => {
const sidebarToggleRequested = jest.fn();
component.sidebarToggleRequested.subscribe(sidebarToggleRequested);
it('requests an explicit Channels collapse from the inline header action', () => {
const channelsPanelExpandedChange = jest.fn();
component.channelsPanelExpandedChange.subscribe(
channelsPanelExpandedChange
);
const toggleButton = fixture.nativeElement.querySelector(
'.all-channels-sidebar-toggle'
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement;
expect(toggleButton.getAttribute('aria-controls')).toBe(
'live-channels-panel'
);
expect(toggleButton.getAttribute('aria-expanded')).toBe('true');
toggleButton.click();
expect(sidebarToggleRequested).toHaveBeenCalledTimes(1);
expect(channelsPanelExpandedChange).toHaveBeenCalledWith(false);
});
it('stores viewport coordinates for the context menu and opens the dialog for that channel', async () => {
@@ -41,6 +41,9 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
templateUrl: './all-channels-view.component.html',
styleUrls: ['./all-channels-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
id: 'live-channels-panel',
},
imports: [
ChannelListItemComponent,
MatButtonModule,
@@ -93,8 +96,8 @@ export class AllChannelsViewComponent {
event: MouseEvent;
}>();
/** Emits when the user clicks the inline collapse toggle in the list header */
readonly sidebarToggleRequested = output<void>();
readonly channelsPanelExpanded = input(true);
readonly channelsPanelExpandedChange = output<boolean>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
@@ -2,6 +2,10 @@
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="shouldShowEpg()"
[groupsPanelExpanded]="groupsPanelExpanded()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(groupsPanelExpandedChange)="groupsPanelExpandedChange.emit($event)"
(channelsPanelExpandedChange)="channelsPanelExpandedChange.emit($event)"
/>
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
@@ -36,10 +40,13 @@
[itemSize]="itemSize()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(channelSelected)="onChannelSelected($event)"
(channelPlaybackRequested)="onChannelPlaybackRequested($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
(channelsPanelExpandedChange)="
channelsPanelExpandedChange.emit($event)
"
/>
}
@case ('groups') {
@@ -55,13 +62,20 @@
[favoriteIds]="favoriteIds()"
[hiddenGroupTitles]="hiddenGroupTitles()"
[sidebarWidth]="sidebarWidth()"
[groupsPanelExpanded]="groupsPanelExpanded()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(channelSelected)="onChannelSelected($event)"
(channelPlaybackRequested)="onChannelPlaybackRequested($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
(groupsPanelExpandedChange)="
groupsPanelExpandedChange.emit($event)
"
(channelsPanelExpandedChange)="
channelsPanelExpandedChange.emit($event)
"
/>
}
@case ('favorites') {
@@ -184,10 +184,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
readonly activeView = input<string>('all');
readonly channelsLoading = input(false);
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly groupsPanelExpanded = input(true);
readonly channelsPanelExpanded = input(true);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -442,8 +445,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
}
private getPlaylistEpgLookupOptions():
| { sourceUrls: string[] }
| undefined {
{ sourceUrls: string[] } | undefined {
const sourceUrls = this.playlistEpgUrls();
return sourceUrls.length > 0 ? { sourceUrls } : undefined;
}
@@ -1,325 +1,356 @@
@if (!hasAnyGroups()) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
}}</mat-icon>
<div class="empty-state-title">
{{
(searchTerm()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(searchTerm()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
) | translate
}}
</div>
</div>
} @else {
<div class="groups-view-layout">
<aside
class="groups-nav-panel"
appResizable
[minWidth]="164"
[maxWidth]="320"
[defaultWidth]="208"
storageKey="m3u-groups-nav-width"
(resizeStart)="onGroupsNavResizeStart()"
(widthChange)="onGroupsNavWidthChange($event)"
(resizeEnd)="onGroupsNavResizeEnd($event)"
<div class="groups-view-layout">
@if (!groupsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="panel-restore-rail"
data-testid="live-groups-panel-restore"
aria-controls="live-groups-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
(click)="groupsPanelExpandedChange.emit(true)"
>
<div class="groups-nav-shell">
<header class="groups-nav-header">
<div class="groups-nav-header__content">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<div class="groups-nav-actions">
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--search"
[matTooltip]="
'CHANNELS.SEARCH_GROUPS' | translate
"
[attr.aria-pressed]="
isGroupSearchOpen() ? 'true' : 'false'
"
[attr.aria-label]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(click)="toggleGroupSearch()"
>
<mat-icon>search</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--manage"
[matTooltip]="
'CHANNELS.MANAGE_GROUPS' | translate
"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
</div>
<mat-icon>chevron_right</mat-icon>
</button>
}
<aside
id="live-groups-panel"
class="groups-nav-panel"
[class.panel-collapsed]="!groupsPanelExpanded()"
[attr.aria-hidden]="!groupsPanelExpanded()"
[attr.inert]="groupsPanelExpanded() ? null : ''"
appResizable
[minWidth]="164"
[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">
<div class="groups-nav-header__content">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<div class="groups-nav-actions">
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--search"
[matTooltip]="'CHANNELS.SEARCH_GROUPS' | translate"
[attr.aria-pressed]="
isGroupSearchOpen() ? 'true' : 'false'
"
[attr.aria-label]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(click)="toggleGroupSearch()"
>
<mat-icon>search</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--manage"
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="groups-nav-action panel-hide-button"
data-testid="live-groups-panel-hide"
aria-controls="live-groups-panel"
[attr.aria-expanded]="groupsPanelExpanded()"
[attr.aria-label]="
'LAYOUT.HIDE_GROUPS_PANEL' | translate
"
[matTooltip]="
'LAYOUT.HIDE_GROUPS_PANEL' | translate
"
(click)="groupsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
</div>
</div>
@if (isGroupSearchOpen()) {
<label class="groups-nav-search">
<mat-icon class="groups-nav-search__icon">
search
</mat-icon>
<input
#groupSearchInput
type="search"
spellcheck="false"
[value]="localGroupSearchTerm()"
[placeholder]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(input)="
updateGroupSearchTerm(
$any($event.target).value
)
"
(keydown.escape)="closeGroupSearch()"
/>
<button
type="button"
mat-icon-button
class="groups-nav-search__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="closeGroupSearch()"
>
<mat-icon>close</mat-icon>
</button>
</label>
@if (isGroupSearchOpen()) {
<label class="groups-nav-search">
<mat-icon class="groups-nav-search__icon">
search
</mat-icon>
<input
#groupSearchInput
type="search"
spellcheck="false"
[value]="localGroupSearchTerm()"
[placeholder]="'CHANNELS.SEARCH_GROUPS' | translate"
(input)="
updateGroupSearchTerm($any($event.target).value)
"
(keydown.escape)="closeGroupSearch()"
/>
<button
type="button"
mat-icon-button
class="groups-nav-search__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="closeGroupSearch()"
>
<mat-icon>close</mat-icon>
</button>
</label>
}
</header>
<div class="groups-nav-list nav-list">
@if (filteredGroups().length > 0) {
@for (
group of filteredGroups();
track trackByGroupKey($index, group)
) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="selectedGroupKey() === group.key"
[attr.aria-current]="
selectedGroupKey() === group.key ? 'true' : null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
}
</header>
<div class="groups-nav-list nav-list">
@if (filteredGroups().length > 0) {
@for (
group of filteredGroups();
track trackByGroupKey($index, group)
) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="
selectedGroupKey() === group.key
"
[attr.aria-current]="
selectedGroupKey() === group.key
? 'true'
: null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
}
} @else {
<div class="groups-nav-empty">
<mat-icon class="groups-nav-empty__icon">
} @else {
<div class="groups-nav-empty">
<mat-icon class="groups-nav-empty__icon">
{{ hasSearchQuery() ? 'search_off' : 'folder_off' }}
</mat-icon>
<div class="groups-nav-empty__title">
{{
(hasSearchQuery()
? 'CHANNELS.NO_GROUPS_FOUND'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
@if (hasSearchQuery()) {
<div class="groups-nav-empty__hint">
{{
hasSearchQuery()
? 'search_off'
: 'folder_off'
}}
</mat-icon>
<div class="groups-nav-empty__title">
{{
(hasSearchQuery()
? 'CHANNELS.NO_GROUPS_FOUND'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
'CHANNELS.TRY_DIFFERENT_SEARCH' | translate
}}
</div>
@if (hasSearchQuery()) {
<div class="groups-nav-empty__hint">
{{
'CHANNELS.TRY_DIFFERENT_SEARCH'
| translate
}}
</div>
}
</div>
}
</div>
</div>
</aside>
<section class="sidebar groups-content-panel">
@if (selectedGroup(); as group) {
<header class="sidebar-header groups-content-header">
<div class="category-meta category-meta--stacked">
<h2 class="category-title">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</h2>
<span class="category-subtitle">
{{ group.count }}
{{ group.count === 1 ? 'item' : 'items' }}
</span>
}
</div>
}
</div>
</div>
</aside>
@if (!channelsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="panel-restore-rail"
data-testid="live-channels-panel-restore"
aria-controls="live-channels-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(true)"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<section
id="live-channels-panel"
class="sidebar groups-content-panel"
[class.panel-collapsed]="!channelsPanelExpanded()"
[attr.aria-hidden]="!channelsPanelExpanded()"
[attr.inert]="channelsPanelExpanded() ? null : ''"
>
@if (selectedGroup(); as group) {
<header class="sidebar-header groups-content-header">
<div class="category-meta category-meta--stacked">
<h2 class="category-title">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</h2>
<span class="category-subtitle">
{{ group.count }}
{{ group.count === 1 ? 'item' : 'items' }}
</span>
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="groups-sidebar-toggle panel-hide-button"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
aria-label="Sort channels"
mat-menu-item
(click)="setGroupChannelSortMode('server')"
>
<mat-icon>drag_indicator</mat-icon>
<span>Playlist Order</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-asc')"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name A-Z</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-icon-button
type="button"
class="groups-sidebar-toggle"
[attr.aria-label]="
'LAYOUT.HIDE_CHANNELS_LIST' | translate
"
[matTooltip]="
'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate
"
(click)="sidebarToggleRequested.emit()"
mat-menu-item
(click)="setGroupChannelSortMode('name-desc')"
>
<mat-icon>chevron_left</mat-icon>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name Z-A</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
(click)="setGroupChannelSortMode('server')"
>
<mat-icon>drag_indicator</mat-icon>
<span>Playlist Order</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-asc')"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name A-Z</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-desc')"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name Z-A</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
</mat-menu>
</header>
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="groups-channels-viewport app-scrollbar"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of selectedGroupChannels();
index as i;
trackBy: trackByChannel
"
[name]="
i +
1 +
'. ' +
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="getLogoForChannel(channel)"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.epgProgram
"
[progressPercentage]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="
channel?.url
? favoriteIds().has(channel.url)
: false
"
(clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)"
(contextMenuRequested)="
onChannelContextMenu(channel, $event)
"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</div>
</header>
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{ hasSearchQuery() ? 'search_off' : 'visibility_off' }}
</mat-icon>
<div class="empty-state-title">
{{
(hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(hasSearchQuery()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'CHANNELS.MANAGE_GROUPS_HINT'
) | translate
}}
</div>
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="groups-channels-viewport app-scrollbar"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of selectedGroupChannels();
index as i;
trackBy: trackByChannel
"
[name]="
i +
1 +
'. ' +
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="getLogoForChannel(channel)"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.epgProgram
"
[progressPercentage]="
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="
channel?.url ? favoriteIds().has(channel.url) : false
"
(clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)"
(contextMenuRequested)="
onChannelContextMenu(channel, $event)
"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</div>
}
</section>
</div>
}
<button
mat-icon-button
type="button"
class="groups-sidebar-toggle panel-hide-button"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
</header>
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{ hasSearchQuery() ? 'search_off' : 'visibility_off' }}
</mat-icon>
<div class="empty-state-title">
{{
(!hasAnyGroups()
? 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
: hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(!hasAnyGroups()
? 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
: hasSearchQuery()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'CHANNELS.MANAGE_GROUPS_HINT'
) | translate
}}
</div>
</div>
}
</section>
</div>
<div
aria-hidden="true"
@@ -19,6 +19,36 @@
background: var(--app-content-bg, var(--mat-sys-surface));
}
.panel-collapsed {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
flex: 0 0 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden;
pointer-events: none;
}
.panel-restore-rail {
width: 40px;
min-width: 40px;
height: 100%;
min-height: 40px;
flex: 0 0 40px;
border-radius: 0;
color: var(--mat-sys-on-surface-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
}
.groups-nav-action.panel-hide-button,
.groups-sidebar-toggle.panel-hide-button {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
}
.groups-nav-panel {
width: 208px;
min-width: 164px;
@@ -133,11 +133,13 @@ describe('GroupsViewComponent', () => {
activeChannelUrl: string | undefined;
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
groupsPanelExpanded: boolean;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
shouldShowEpg: boolean;
channelsPanelExpanded: boolean;
}> = {}
): void {
fixture = TestBed.createComponent(GroupsViewComponent);
@@ -150,11 +152,13 @@ describe('GroupsViewComponent', () => {
activeChannelUrl: string | undefined;
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
groupsPanelExpanded: boolean;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
shouldShowEpg: boolean;
channelsPanelExpanded: boolean;
}> = {}
): void {
fixture.componentRef.setInput(
@@ -191,9 +195,71 @@ describe('GroupsViewComponent', () => {
'sidebarWidth',
overrides.sidebarWidth ?? 460
);
fixture.componentRef.setInput(
'groupsPanelExpanded',
overrides.groupsPanelExpanded ?? true
);
fixture.componentRef.setInput(
'channelsPanelExpanded',
overrides.channelsPanelExpanded ?? true
);
fixture.detectChanges();
}
it('keeps Groups and Channels independently operable with accessible focus transfer', async () => {
const groupsPanelExpandedChange = jest.fn();
const channelsPanelExpandedChange = jest.fn();
component.groupsPanelExpandedChange.subscribe(
groupsPanelExpandedChange
);
component.channelsPanelExpandedChange.subscribe(
channelsPanelExpandedChange
);
const groupsHide = fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
) as HTMLButtonElement;
const channelsHide = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement;
expect(groupsHide.getAttribute('aria-controls')).toBe(
'live-groups-panel'
);
expect(channelsHide.getAttribute('aria-controls')).toBe(
'live-channels-panel'
);
groupsHide.click();
channelsHide.click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(false);
expect(channelsPanelExpandedChange).toHaveBeenCalledWith(false);
setInputs({ groupsPanelExpanded: false });
await fixture.whenStable();
await Promise.resolve();
const groupsPanel = fixture.nativeElement.querySelector(
'#live-groups-panel'
) as HTMLElement;
const groupsRestore = fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-restore"]'
) as HTMLButtonElement;
expect(groupsPanel.hasAttribute('inert')).toBe(true);
expect(groupsPanel.getAttribute('aria-hidden')).toBe('true');
expect(document.activeElement).toBe(groupsRestore);
groupsRestore.click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(true);
setInputs({ groupsPanelExpanded: true });
await fixture.whenStable();
await Promise.resolve();
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
)
);
});
it('sorts groups with numeric buckets before alphabetic buckets', () => {
setInputs({
groupedChannels: {
@@ -625,14 +691,26 @@ describe('GroupsViewComponent', () => {
it('renders the empty-category state when no grouped channels exist', () => {
setInputs({ groupedChannels: {} });
const emptyState = fixture.nativeElement.querySelector(
'.groups-view-empty-state'
const layout = fixture.nativeElement.querySelector(
'.groups-view-layout'
) as HTMLElement | null;
const emptyState = fixture.nativeElement.querySelector(
'.groups-content-empty-state'
) as HTMLElement | null;
const groupsToggle = fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
);
const channelsToggle = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
);
expect(layout).not.toBeNull();
expect(emptyState).not.toBeNull();
expect(emptyState?.textContent).toContain(
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
);
expect(groupsToggle).not.toBeNull();
expect(channelsToggle).not.toBeNull();
});
it('keeps the manage action visible when all groups are hidden', () => {
@@ -107,6 +107,8 @@ export class GroupsViewComponent {
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
readonly groupsPanelExpanded = input(true);
readonly channelsPanelExpanded = input(true);
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
@@ -124,9 +126,8 @@ export class GroupsViewComponent {
/** Emits when the groups rail resize ends */
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
/** Emits when the user clicks the inline collapse toggle in the groups header */
readonly sidebarToggleRequested = output<void>();
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
@@ -150,6 +151,8 @@ export class GroupsViewComponent {
});
private previousActiveChannelUrl: string | undefined;
private previousGroupsPanelExpanded: boolean | undefined;
private previousChannelsPanelExpanded: boolean | undefined;
private preservedContentWidth = 0;
constructor() {
@@ -243,6 +246,45 @@ export class GroupsViewComponent {
this.groupSearchInput()?.nativeElement.focus();
});
});
effect(() => {
const expanded = this.groupsPanelExpanded();
this.transferPanelFocus(
expanded,
this.previousGroupsPanelExpanded,
'live-groups-panel'
);
this.previousGroupsPanelExpanded = expanded;
});
effect(() => {
const expanded = this.channelsPanelExpanded();
this.transferPanelFocus(
expanded,
this.previousChannelsPanelExpanded,
'live-channels-panel'
);
this.previousChannelsPanelExpanded = expanded;
});
}
private transferPanelFocus(
expanded: boolean,
previous: boolean | undefined,
panelId: string
): void {
if (previous === undefined || previous === expanded) {
return;
}
queueMicrotask(() => {
const action = expanded ? 'hide' : 'restore';
this.hostEl.nativeElement
.querySelector<HTMLElement>(
`[data-testid="${panelId}-${action}"]`
)
?.focus();
});
}
readonly allGroups = computed<GroupView[]>(() => {
@@ -512,7 +554,6 @@ export class GroupsViewComponent {
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -1,18 +1,54 @@
<div class="channel-list-loading-state" aria-live="polite" role="status">
<span class="visually-hidden">
<div class="channel-list-loading-state">
<span class="visually-hidden" aria-live="polite" role="status">
{{ 'CHANNELS.LOADING' | translate }}
</span>
@if (isGroupsView()) {
<div class="groups-loading-layout" aria-hidden="true">
<aside class="groups-loading-nav">
<div class="groups-loading-layout">
@if (!groupsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="loading-panel-restore"
data-testid="live-groups-panel-restore"
aria-controls="live-groups-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
(click)="groupsPanelExpandedChange.emit(true)"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<aside
id="live-groups-panel"
class="groups-loading-nav"
[class.loading-panel-collapsed]="!groupsPanelExpanded()"
[attr.aria-hidden]="!groupsPanelExpanded()"
[attr.inert]="groupsPanelExpanded() ? null : ''"
>
<header class="groups-loading-nav__header">
<h2 class="groups-loading-nav__title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<button
mat-icon-button
type="button"
class="loading-panel-hide"
data-testid="live-groups-panel-hide"
aria-controls="live-groups-panel"
[attr.aria-expanded]="groupsPanelExpanded()"
[attr.aria-label]="
'LAYOUT.HIDE_GROUPS_PANEL' | translate
"
[matTooltip]="'LAYOUT.HIDE_GROUPS_PANEL' | translate"
(click)="groupsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
</header>
<div class="groups-loading-nav__list">
<div class="groups-loading-nav__list" aria-hidden="true">
@for (row of groupRows; track row) {
<div class="groups-loading-nav__item">
<span
@@ -27,7 +63,28 @@
</div>
</aside>
<section class="groups-loading-content">
@if (!channelsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="loading-panel-restore"
data-testid="live-channels-panel-restore"
aria-controls="live-channels-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(true)"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<section
id="live-channels-panel"
class="groups-loading-content"
[class.loading-panel-collapsed]="!channelsPanelExpanded()"
[attr.aria-hidden]="!channelsPanelExpanded()"
[attr.inert]="channelsPanelExpanded() ? null : ''"
>
<header class="groups-loading-content__header">
<div class="groups-loading-content__meta">
<span
@@ -37,9 +94,24 @@
class="grid-skeleton-line groups-loading-content__subtitle"
></span>
</div>
<button
mat-icon-button
type="button"
class="loading-panel-hide"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="
'LAYOUT.HIDE_CHANNELS_PANEL' | translate
"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
</header>
<div class="groups-loading-content__list">
<div class="groups-loading-content__list" aria-hidden="true">
<app-channel-list-skeleton
[count]="9"
[showEpg]="showEpg()"
@@ -49,14 +121,27 @@
</section>
</div>
} @else {
<div class="channels-loading-state">
<div id="live-channels-panel" class="channels-loading-state">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
</div>
<button
mat-icon-button
type="button"
class="loading-panel-hide"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="channelsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
</div>
<div class="channels-loading-state__list">
<div class="channels-loading-state__list" aria-hidden="true">
<app-channel-list-skeleton
[count]="9"
[showEpg]="showEpg()"
@@ -37,11 +37,15 @@
}
.channels-loading-state__copy {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
}
.channels-loading-state__title,
.groups-loading-nav__title {
flex: 1;
margin: 0;
font-size: 0.86rem;
font-weight: 600;
@@ -49,6 +53,35 @@
color: var(--mat-sys-on-surface);
}
.loading-panel-hide {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
}
.loading-panel-restore {
width: 40px;
min-width: 40px;
height: 100%;
min-height: 40px;
flex: 0 0 40px;
border-radius: 0;
color: var(--mat-sys-on-surface-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
}
.loading-panel-collapsed {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
flex: 0 0 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden;
pointer-events: none;
}
.channels-loading-state__list,
.groups-loading-content__list {
min-height: 0;
@@ -79,6 +112,9 @@
}
.groups-loading-nav__header {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px 12px;
flex-shrink: 0;
}
@@ -140,6 +176,9 @@
}
.groups-loading-content__header {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-height: 56px;
padding: 12px 16px 10px;
@@ -147,6 +186,7 @@
}
.groups-loading-content__meta {
flex: 1;
max-width: 180px;
display: grid;
gap: 8px;
@@ -60,4 +60,50 @@ describe('ChannelListLoadingStateComponent', () => {
fixture.nativeElement.querySelector('.groups-loading-content')
).not.toBeNull();
});
it('keeps both panel controls available while Groups is loading', () => {
fixture.componentRef.setInput('view', 'groups');
fixture.detectChanges();
const groupsPanelExpandedChange = jest.fn();
const channelsPanelExpandedChange = jest.fn();
component.groupsPanelExpandedChange.subscribe(
groupsPanelExpandedChange
);
component.channelsPanelExpandedChange.subscribe(
channelsPanelExpandedChange
);
(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement
).click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(false);
expect(channelsPanelExpandedChange).toHaveBeenCalledWith(false);
});
it('keeps the Channels control available while All Channels is loading', () => {
fixture.componentRef.setInput('view', 'all');
fixture.detectChanges();
const channelsPanelExpandedChange = jest.fn();
component.channelsPanelExpandedChange.subscribe(
channelsPanelExpandedChange
);
const channelsToggle = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement;
expect(channelsToggle.getAttribute('aria-controls')).toBe(
'live-channels-panel'
);
channelsToggle.click();
expect(channelsPanelExpandedChange).toHaveBeenCalledWith(false);
});
});
@@ -3,7 +3,11 @@ import {
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListSkeletonComponent } from '../channel-list-container/channel-list-skeleton/channel-list-skeleton.component';
@@ -12,11 +16,21 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel-
templateUrl: './channel-list-loading-state.component.html',
styleUrl: './channel-list-loading-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe, ChannelListSkeletonComponent],
imports: [
ChannelListSkeletonComponent,
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslatePipe,
],
})
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly showEpg = input(true);
readonly groupsPanelExpanded = input(true);
readonly channelsPanelExpanded = input(true);
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
readonly isGroupsView = computed(() => this.view() === 'groups');
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);
@@ -22,8 +22,11 @@
[channelsLoading]="channelsLoading()"
[activeView]="activeView()"
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
[groupsPanelExpanded]="groupsPanelExpanded()"
[channelsPanelExpanded]="channelsPanelExpanded()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
(groupsPanelExpandedChange)="groupsPanelExpandedChange.emit($event)"
(channelsPanelExpandedChange)="channelsPanelExpandedChange.emit($event)"
/>
@@ -28,10 +28,13 @@ export class SidebarComponent {
readonly channelsLoading = input(false);
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
readonly groupsPanelExpanded = input(true);
readonly channelsPanelExpanded = input(true);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly translate = inject(TranslateService);