mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(ui): add independent M3U panel controls
This commit is contained in:
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
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+2
@@ -63,6 +63,8 @@
|
||||
.all-channels-sidebar-toggle {
|
||||
flex: 0 0 auto;
|
||||
margin-right: -4px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.all-channels-divider {
|
||||
|
||||
+11
-5
@@ -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 () => {
|
||||
|
||||
+5
-2
@@ -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({
|
||||
|
||||
+16
-2
@@ -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') {
|
||||
|
||||
+5
-3
@@ -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;
|
||||
}
|
||||
|
||||
+336
-305
@@ -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"
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
+80
-2
@@ -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', () => {
|
||||
|
||||
+45
-4
@@ -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) {
|
||||
|
||||
+94
-9
@@ -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()"
|
||||
|
||||
+40
@@ -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;
|
||||
|
||||
+46
@@ -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);
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user