mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling * test(ui): drag below the Windows scrollbar arrow
This commit is contained in:
1 parent
0ba5107561
commit
eb602db5fc
44 files changed
+950
-49
No files matched your search
@@ -1,8 +1,10 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="nav-list">
|
||||
<div class="nav-list" id="portal-categories">
|
||||
@for (item of items(); track $index) {
|
||||
<a
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item"
|
||||
(keydown)="focusLiveChannels($event)"
|
||||
[attr.data-category-id]="$any(item).category_id ?? item.id"
|
||||
[class.selected]="isSelected(item)"
|
||||
[class.category-item--selected]="isSelected(item)"
|
||||
@@ -25,7 +27,7 @@
|
||||
{{ $any(item).count }}
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</button>
|
||||
} @empty {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { focusLiveChannels } from '@iptvnator/ui/components';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -27,6 +28,8 @@ interface CategoryViewItem {
|
||||
styleUrls: ['./category-view.component.scss'],
|
||||
})
|
||||
export class CategoryViewComponent {
|
||||
readonly focusLiveChannels = focusLiveChannels;
|
||||
|
||||
readonly items = input<CategoryViewItem[]>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
readonly itemCounts = input<Map<number, number>>(new Map());
|
||||
|
||||
+6
-8
@@ -1,4 +1,6 @@
|
||||
<div
|
||||
appChannelScrollFocus
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
class="nav-list app-scrollbar"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="!canDragDrop()"
|
||||
@@ -13,15 +15,12 @@
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT' | translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
@@ -48,6 +47,7 @@
|
||||
[showDetailsContextMenu]="hasChannelContextMenu(ch)"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(activated)="onChannelActivate(ch)"
|
||||
(keyboardActivated)="playbackRequested.emit(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
(contextMenuRequested)="onChannelContextMenu(ch, $event)"
|
||||
/>
|
||||
@@ -80,9 +80,7 @@
|
||||
<button mat-menu-item (click)="removeContextMenuChannel()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
|
||||
}}
|
||||
{{ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
@@ -20,10 +21,7 @@ import {
|
||||
ChannelDetailsDialogComponent,
|
||||
ChannelListItemComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
buildStalkerEpgMappingKey,
|
||||
buildXtreamEpgMappingKey,
|
||||
@@ -56,6 +54,7 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
|
||||
styleUrl: './global-favorites-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
DragDropModule,
|
||||
MatIconModule,
|
||||
|
||||
+8
-1
@@ -67,7 +67,13 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div #scrollContainer class="channels-list">
|
||||
<div
|
||||
#scrollContainer
|
||||
class="channels-list app-scrollbar"
|
||||
appChannelScrollFocus
|
||||
id="live-channels"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
>
|
||||
@if (isInitialChannelsLoading()) {
|
||||
<app-channel-list-skeleton
|
||||
[count]="9"
|
||||
@@ -123,6 +129,7 @@
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(keyboardActivated)="playChannel(item, true)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
(contextMenuRequested)="
|
||||
onChannelContextMenu(item, $event)
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
@@ -117,6 +118,7 @@ const FULL_LIST_RENDER_CHUNK = 100;
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
styleUrls: ['./stalker-live-stream-layout.component.scss'],
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
AudioPlayerComponent,
|
||||
ChannelListItemComponent,
|
||||
ChannelListSkeletonComponent,
|
||||
|
||||
+5
-1
@@ -17,8 +17,11 @@
|
||||
</div>
|
||||
} @else if (filteredChannels().length > 0) {
|
||||
<cdk-virtual-scroll-viewport
|
||||
appChannelScrollFocus
|
||||
id="live-channels"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
[itemSize]="channelItemSize"
|
||||
class="scroll-viewport-portals"
|
||||
class="scroll-viewport-portals app-scrollbar"
|
||||
>
|
||||
<app-channel-list-item
|
||||
*cdkVirtualFor="
|
||||
@@ -50,6 +53,7 @@
|
||||
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
|
||||
(clicked)="playClicked.emit(item)"
|
||||
(activated)="playbackRequested.emit(item)"
|
||||
(keyboardActivated)="playbackRequested.emit(item)"
|
||||
(favoriteToggled)="toggleFavorite($event, item)"
|
||||
(contextMenuRequested)="onChannelContextMenu(item, $event)"
|
||||
/>
|
||||
|
||||
+36
@@ -410,6 +410,42 @@ describe('PortalChannelsListComponent', () => {
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
|
||||
});
|
||||
|
||||
it('does not start smooth alignment when a selected row is already visible', () => {
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedChannels.set(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
title: `Channel ${index + 1}`,
|
||||
xtream_id: index + 1,
|
||||
}))
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const viewport = fixture.componentInstance.viewport()!;
|
||||
jest.spyOn(viewport, 'getViewportSize').mockReturnValue(400);
|
||||
jest.spyOn(viewport, 'measureScrollOffset').mockReturnValue(0);
|
||||
const scroll = jest.spyOn(viewport, 'scrollToIndex');
|
||||
selectedItem.set({ xtream_id: 2 });
|
||||
fixture.detectChanges();
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not realign after an update to the same selected channel', () => {
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedChannels.set(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
title: `Channel ${index + 1}`,
|
||||
xtream_id: index + 1,
|
||||
}))
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const viewport = fixture.componentInstance.viewport()!;
|
||||
selectedItem.set({ xtream_id: 2 });
|
||||
fixture.detectChanges();
|
||||
const scroll = jest.spyOn(viewport, 'scrollToIndex');
|
||||
selectedItem.set({ xtream_id: 2 });
|
||||
fixture.detectChanges();
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-scroll the virtual list when the search filter changes', () => {
|
||||
const channels = Array.from({ length: 20 }, (_, index) => ({
|
||||
title: `Channel ${index + 1}`,
|
||||
|
||||
+26
-7
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
@@ -75,6 +76,7 @@ interface XtreamCategoryLike {
|
||||
styleUrls: ['./portal-channels-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
ChannelListSkeletonComponent,
|
||||
MatButtonModule,
|
||||
@@ -157,8 +159,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
// run only records the initial value.
|
||||
let appliedOffsetMinutes: number | null = null;
|
||||
effect(() => {
|
||||
const offsetMinutes =
|
||||
this.settingsStore.resolvedEpgOffsetMinutes();
|
||||
const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes();
|
||||
if (appliedOffsetMinutes === offsetMinutes) {
|
||||
return;
|
||||
}
|
||||
@@ -170,12 +171,16 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
untracked(() => this.repickPreviewsForOffsetChange());
|
||||
});
|
||||
|
||||
const selectedChannelId = computed(() =>
|
||||
Number(
|
||||
(
|
||||
this.xtreamStore.selectedItem() as XtreamChannelListItem | null
|
||||
)?.xtream_id
|
||||
)
|
||||
);
|
||||
effect(() => {
|
||||
const selectedItem = this.xtreamStore.selectedItem();
|
||||
const selectedId = selectedChannelId();
|
||||
const viewport = this.viewport();
|
||||
const selectedId = Number(
|
||||
(selectedItem as XtreamChannelListItem | null)?.xtream_id
|
||||
);
|
||||
|
||||
if (!viewport || !Number.isFinite(selectedId) || selectedId <= 0) {
|
||||
return;
|
||||
@@ -189,6 +194,17 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = viewport.measureScrollOffset();
|
||||
const rowTop = selectedIndex * this.channelItemSize;
|
||||
if (
|
||||
rowTop >= top &&
|
||||
rowTop + this.channelItemSize <=
|
||||
top + viewport.getViewportSize()
|
||||
) {
|
||||
// Even a smooth scroll to the current offset can cancel the
|
||||
// user's first keyboard scroll after a pointer selection.
|
||||
return;
|
||||
}
|
||||
viewport.scrollToIndex(selectedIndex, 'smooth');
|
||||
});
|
||||
|
||||
@@ -529,7 +545,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
// ── Context menu ────────────────────────────────────────────
|
||||
|
||||
onChannelContextMenu(channel: XtreamChannelListItem, event: MouseEvent): void {
|
||||
onChannelContextMenu(
|
||||
channel: XtreamChannelListItem,
|
||||
event: MouseEvent
|
||||
): void {
|
||||
this.contextMenuChannel.set(channel);
|
||||
this.contextMenuPosition.set({
|
||||
x: `${event.clientX}px`,
|
||||
|
||||
@@ -28,3 +28,5 @@ export * from './lib/vod-sources/vod-sources-chip.component';
|
||||
export * from './lib/vod-sources/vod-sources-menu.component';
|
||||
export * from './lib/watched-badge/watched-badge.component';
|
||||
export * from './lib/window-controls/window-controls.component';
|
||||
|
||||
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
|
||||
+3
@@ -62,6 +62,8 @@
|
||||
</div>
|
||||
} @else {
|
||||
<cdk-virtual-scroll-viewport
|
||||
appChannelScrollFocus
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
[itemSize]="itemSize()"
|
||||
class="scroll-viewport app-scrollbar"
|
||||
>
|
||||
@@ -90,6 +92,7 @@
|
||||
"
|
||||
(clicked)="onChannelClick(channel)"
|
||||
(activated)="onChannelActivate(channel)"
|
||||
(keyboardActivated)="channelPlaybackRequested.emit(channel)"
|
||||
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
|
||||
[selected]="activeChannelUrl() === channel?.url"
|
||||
[showFavoriteButton]="true"
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
|
||||
styleUrls: ['./all-channels-view.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
|
||||
+6
-2
@@ -13,7 +13,11 @@
|
||||
@if (isDraggable()) {
|
||||
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
|
||||
}
|
||||
<div class="channel-content">
|
||||
<button
|
||||
type="button"
|
||||
class="channel-content"
|
||||
[attr.aria-label]="displayName()"
|
||||
>
|
||||
<div class="channel-logo-shell" aria-hidden="true">
|
||||
@if (showLogoFallback()) {
|
||||
<mat-icon class="channel-logo-fallback">live_tv</mat-icon>
|
||||
@@ -80,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@if (
|
||||
(showProgramInfoButton() && showEpg()) ||
|
||||
showFavoriteButton() ||
|
||||
|
||||
+15
@@ -61,6 +61,21 @@
|
||||
}
|
||||
|
||||
.channel-content {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
app-region: no-drag;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
+18
@@ -34,6 +34,24 @@ describe('ChannelListItemComponent', () => {
|
||||
fixture = TestBed.createComponent(ChannelListItemComponent);
|
||||
});
|
||||
|
||||
it('exposes a separate keyboard activation button without nesting row actions', () => {
|
||||
fixture.componentRef.setInput('name', 'News');
|
||||
fixture.componentRef.setInput('showFavoriteButton', true);
|
||||
fixture.detectChanges();
|
||||
const primary = fixture.nativeElement.querySelector(
|
||||
'button.channel-content'
|
||||
);
|
||||
expect(primary).not.toBeNull();
|
||||
expect(primary?.querySelector('button')).toBeNull();
|
||||
const activated = jest.fn();
|
||||
const clicked = jest.fn();
|
||||
fixture.componentInstance.keyboardActivated.subscribe(activated);
|
||||
fixture.componentInstance.clicked.subscribe(clicked);
|
||||
primary?.click();
|
||||
expect(activated).toHaveBeenCalledTimes(1);
|
||||
expect(clicked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats preview times from timestamp fields when raw strings are offset', () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
|
||||
+8
-1
@@ -77,7 +77,10 @@ export class ChannelListItemComponent {
|
||||
readonly auxActionTooltip = input('');
|
||||
|
||||
readonly clicked = output<void>();
|
||||
/** Pointer double click; consumers retain their existing preference gate. */
|
||||
readonly activated = output<void>();
|
||||
/** Explicit keyboard playback, independent of the pointer double-click preference. */
|
||||
readonly keyboardActivated = output<void>();
|
||||
readonly favoriteToggled = output<MouseEvent>();
|
||||
readonly auxActionClicked = output<MouseEvent>();
|
||||
readonly contextMenuRequested = output<MouseEvent>();
|
||||
@@ -132,7 +135,11 @@ export class ChannelListItemComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clicked.emit();
|
||||
if (event?.detail === 0) {
|
||||
this.keyboardActivated.emit();
|
||||
} else {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClick(): void {
|
||||
|
||||
+3
@@ -1,4 +1,6 @@
|
||||
<div
|
||||
appChannelScrollFocus
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
class="channel-list app-scrollbar"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="hasSearchTerm()"
|
||||
@@ -32,6 +34,7 @@
|
||||
[showDetailsContextMenu]="true"
|
||||
(clicked)="onChannelClick(channel)"
|
||||
(activated)="onChannelActivate(channel)"
|
||||
(keyboardActivated)="channelPlaybackRequested.emit(channel)"
|
||||
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
|
||||
[selected]="activeChannelUrl() === channel?.url"
|
||||
[showFavoriteButton]="true"
|
||||
|
||||
+5
-2
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
@@ -37,6 +38,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
|
||||
styleUrls: ['./favorites-view.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
DragDropModule,
|
||||
MatIconModule,
|
||||
@@ -90,7 +92,9 @@ export class FavoritesViewComponent {
|
||||
y: '0px',
|
||||
});
|
||||
|
||||
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0);
|
||||
readonly hasSearchTerm = computed(
|
||||
() => this.searchTerm().trim().length > 0
|
||||
);
|
||||
readonly filteredFavorites = computed(() => {
|
||||
const favorites = this.favorites();
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
@@ -188,7 +192,6 @@ export class FavoritesViewComponent {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
openChannelDetails(): void {
|
||||
const channel = this.contextMenuChannel();
|
||||
if (!channel) {
|
||||
|
||||
+5
@@ -243,6 +243,8 @@
|
||||
</header>
|
||||
|
||||
<cdk-virtual-scroll-viewport
|
||||
appChannelScrollFocus
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
[itemSize]="itemSize()"
|
||||
class="groups-channels-viewport app-scrollbar"
|
||||
>
|
||||
@@ -280,6 +282,9 @@
|
||||
"
|
||||
(clicked)="onChannelClick(channel)"
|
||||
(activated)="onChannelActivate(channel)"
|
||||
(keyboardActivated)="
|
||||
channelPlaybackRequested.emit(channel)
|
||||
"
|
||||
(contextMenuRequested)="
|
||||
onChannelContextMenu(channel, $event)
|
||||
"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
|
||||
import { KeyValue, TitleCasePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
@@ -66,6 +67,7 @@ interface FilteredGroupView {
|
||||
styleUrls: ['./groups-view.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
|
||||
+11
-2
@@ -1,4 +1,9 @@
|
||||
<div class="channel-list app-scrollbar" id="recent-list">
|
||||
<div
|
||||
appChannelScrollFocus
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
class="channel-list app-scrollbar"
|
||||
id="recent-list"
|
||||
>
|
||||
@if (enrichedRecentItems().length > 0) {
|
||||
@for (
|
||||
item of enrichedRecentItems();
|
||||
@@ -10,7 +15,8 @@
|
||||
i +
|
||||
1 +
|
||||
'. ' +
|
||||
(item.channel.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
|
||||
(item.channel.name || 'CHANNELS.UNNAMED_CHANNEL'
|
||||
| translate)
|
||||
"
|
||||
[logo]="item.logo"
|
||||
[showEpg]="shouldShowEpg()"
|
||||
@@ -26,6 +32,9 @@
|
||||
"
|
||||
(clicked)="onChannelClick(item.channel)"
|
||||
(activated)="onChannelActivate(item.channel)"
|
||||
(keyboardActivated)="
|
||||
channelPlaybackRequested.emit(item.channel)
|
||||
"
|
||||
(contextMenuRequested)="
|
||||
onChannelContextMenu(item.channel, $event)
|
||||
"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -40,6 +41,7 @@ export interface RecentViewItem {
|
||||
styleUrls: ['./recent-view.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelScrollFocusDirective,
|
||||
ChannelListItemComponent,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ChannelScrollFocusDirective,
|
||||
focusLiveChannels,
|
||||
} from './channel-scroll-focus.directive';
|
||||
|
||||
@Component({
|
||||
imports: [ChannelScrollFocusDirective],
|
||||
template: `
|
||||
<div id="portal-categories">
|
||||
<button aria-current="true" (keydown)="enter($event)">News</button>
|
||||
<button (keydown)="enter($event)">Sport</button>
|
||||
</div>
|
||||
<div appChannelScrollFocus id="live-channels" aria-label="Channels">
|
||||
<div class="channel-list-item">
|
||||
<button class="channel-content">Channel</button>
|
||||
<button class="favorite-button">Favorite</button>
|
||||
</div>
|
||||
<input />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class Host {
|
||||
readonly enter = focusLiveChannels;
|
||||
}
|
||||
|
||||
describe('Channel scroll focus', () => {
|
||||
let fixture: ComponentFixture<Host>;
|
||||
let pane: HTMLElement;
|
||||
let category: HTMLButtonElement;
|
||||
let action: HTMLButtonElement;
|
||||
const key = (element: HTMLElement, key: string, modifiers = {}) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifiers,
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(Host);
|
||||
fixture.detectChanges();
|
||||
pane = fixture.nativeElement.querySelector('#live-channels');
|
||||
category = fixture.nativeElement.querySelector(
|
||||
'#portal-categories button'
|
||||
);
|
||||
action = pane.querySelector('.channel-content')!;
|
||||
pane.checkVisibility = () => true;
|
||||
category.checkVisibility = () => true;
|
||||
});
|
||||
|
||||
it('transfers focus both ways without clicks or scroll resets', () => {
|
||||
const click = jest.fn();
|
||||
pane.addEventListener('click', click);
|
||||
pane.scrollTop = 400;
|
||||
category.focus();
|
||||
expect(key(category, 'ArrowRight').defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(pane);
|
||||
expect(pane.scrollTop).toBe(400);
|
||||
expect(key(pane, 'ArrowLeft').defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(category);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not transfer from unselected categories, modified keys or into hidden/inert panes', () => {
|
||||
const other = category.nextElementSibling as HTMLElement;
|
||||
other.focus();
|
||||
key(other, 'ArrowRight');
|
||||
expect(document.activeElement).toBe(other);
|
||||
category.focus();
|
||||
key(category, 'ArrowRight', { ctrlKey: true });
|
||||
expect(document.activeElement).toBe(category);
|
||||
pane.checkVisibility = () => false;
|
||||
key(category, 'ArrowRight');
|
||||
expect(document.activeElement).toBe(category);
|
||||
pane.checkVisibility = () => true;
|
||||
pane.classList.add('sidebar-collapsed');
|
||||
key(category, 'ArrowRight');
|
||||
expect(document.activeElement).toBe(category);
|
||||
pane.classList.remove('sidebar-collapsed');
|
||||
pane.setAttribute('inert', '');
|
||||
key(category, 'ArrowRight');
|
||||
expect(document.activeElement).toBe(category);
|
||||
});
|
||||
|
||||
it('keeps pointer selection in the scroll owner but preserves keyboard activation and row actions', () => {
|
||||
action.focus();
|
||||
action.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, detail: 1 })
|
||||
);
|
||||
expect(document.activeElement).toBe(pane);
|
||||
action.focus();
|
||||
action.click();
|
||||
expect(document.activeElement).toBe(action);
|
||||
const favorite =
|
||||
pane.querySelector<HTMLButtonElement>('.favorite-button')!;
|
||||
favorite.focus();
|
||||
favorite.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, detail: 1 })
|
||||
);
|
||||
expect(document.activeElement).toBe(favorite);
|
||||
});
|
||||
|
||||
it.each(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'])(
|
||||
'retains native %s scrolling when a virtual row is recycled',
|
||||
(name) => {
|
||||
const globalKey = jest.fn();
|
||||
document.addEventListener('keydown', globalKey);
|
||||
try {
|
||||
action.focus();
|
||||
const event = key(action, name);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(pane);
|
||||
action.remove();
|
||||
expect(document.activeElement).toBe(pane);
|
||||
expect(globalKey).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
document.removeEventListener('keydown', globalKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves Tab, Shift+Tab, Enter, Space on buttons and input keys alone', () => {
|
||||
for (const name of ['Tab', 'Enter', ' ']) {
|
||||
action.focus();
|
||||
expect(key(action, name).defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(action);
|
||||
}
|
||||
expect(key(action, 'Tab', { shiftKey: true }).defaultPrevented).toBe(
|
||||
false
|
||||
);
|
||||
const input = pane.querySelector('input')!;
|
||||
input.focus();
|
||||
key(input, 'ArrowLeft');
|
||||
key(input, 'ArrowDown');
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it('does not refocus when rows update asynchronously', () => {
|
||||
category.focus();
|
||||
pane.append(document.createElement('button'));
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement).toBe(category);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Directive, ElementRef, inject } from '@angular/core';
|
||||
|
||||
const HIDDEN_PANE = '[inert], .sidebar-collapsed, .context-panel--collapsed';
|
||||
|
||||
const SCROLL_KEYS = new Set([
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'Home',
|
||||
'End',
|
||||
' ',
|
||||
]);
|
||||
|
||||
function unmodified(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
!event.defaultPrevented &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/** Enter the visible live pane without selecting a channel or starting playback. */
|
||||
export function focusLiveChannels(event: KeyboardEvent): void {
|
||||
if (event.key !== 'ArrowRight' || !unmodified(event)) return;
|
||||
const category = event.currentTarget as HTMLElement;
|
||||
if (category.getAttribute('aria-current') !== 'true') return;
|
||||
const pane = category.ownerDocument.getElementById('live-channels');
|
||||
if (
|
||||
!pane?.checkVisibility({ checkVisibilityCSS: true }) ||
|
||||
pane.closest(HIDDEN_PANE)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pane.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** Native scroll ownership survives CDK row recycling; row actions remain buttons. */
|
||||
@Directive({
|
||||
selector: '[appChannelScrollFocus]',
|
||||
host: {
|
||||
tabindex: '0',
|
||||
role: 'region',
|
||||
class: 'channel-scroll-focus',
|
||||
'(click)': 'onClick($event)',
|
||||
'(keydown)': 'onKeydown($event)',
|
||||
},
|
||||
})
|
||||
export class ChannelScrollFocusDirective {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
onClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
if (event.detail <= 0 || !target.closest('.channel-list-item')) return;
|
||||
if (
|
||||
target.closest(
|
||||
'button:not(.channel-content), a, input, select, textarea'
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.host.nativeElement.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const pane = this.host.nativeElement;
|
||||
const target = event.target as HTMLElement;
|
||||
if (!unmodified(event) || pane.closest(HIDDEN_PANE)) return;
|
||||
const rowAction = target.matches('button.channel-content');
|
||||
if (target !== pane && !rowAction) return;
|
||||
|
||||
if (event.key === 'ArrowLeft' && pane.id === 'live-channels') {
|
||||
const category = pane.ownerDocument.querySelector<HTMLElement>(
|
||||
'#portal-categories [aria-current="true"]'
|
||||
);
|
||||
if (
|
||||
!category?.checkVisibility({ checkVisibilityCSS: true }) ||
|
||||
category.closest(HIDDEN_PANE)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
category.focus({ preventScroll: true });
|
||||
} else if (
|
||||
SCROLL_KEYS.has(event.key) &&
|
||||
!(rowAction && event.key === ' ')
|
||||
) {
|
||||
// Do not preventDefault: Chromium owns scroll distance, repetition,
|
||||
// PageUp/PageDown and Shift+Space. Only silence global player keys.
|
||||
event.stopPropagation();
|
||||
pane.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,12 @@
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
|
||||
// Hide scrollbar but keep functionality (parity with the old hero scroll)
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,42 @@ describe('PortalDetailShellComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('makes the scroll owner a named keyboard region and focuses it once', async () => {
|
||||
await fixture.whenStable();
|
||||
const shell = query('app-portal-detail-shell')!;
|
||||
expect(shell.tabIndex).toBe(0);
|
||||
expect(shell.getAttribute('aria-label')).toBe('Show Title');
|
||||
expect(document.activeElement).toBe(shell);
|
||||
const play = query('.play-btn')!;
|
||||
play.focus();
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(document.activeElement).not.toBe(shell);
|
||||
});
|
||||
|
||||
it('keeps native scrolling on the shell without forwarding keys to the player', () => {
|
||||
const shell = query('app-portal-detail-shell')!;
|
||||
const globalKey = jest.fn();
|
||||
document.addEventListener('keydown', globalKey);
|
||||
try {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
shell.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(globalKey).not.toHaveBeenCalled();
|
||||
query('.play-btn')!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true })
|
||||
);
|
||||
expect(globalKey).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
document.removeEventListener('keydown', globalKey);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders hero with stamped tags/meta/actions in browse state', () => {
|
||||
expect(query('.shell__hero--collapsed')).toBeNull();
|
||||
expect(query('app-content-hero')).toBeTruthy();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
@@ -37,6 +38,10 @@ import {
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
host: {
|
||||
tabindex: '0',
|
||||
role: 'region',
|
||||
'[attr.aria-label]': 'title() || backLabel()',
|
||||
'(keydown)': 'onScrollKey($event)',
|
||||
'[class.shell-host--watch]': 'isWatch()',
|
||||
'(document:keydown.escape)': 'onEscape($event)',
|
||||
},
|
||||
@@ -67,6 +72,18 @@ export class PortalDetailShellComponent {
|
||||
readonly isWatch = computed(() => this.playbackActive());
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
const element = this.host.nativeElement;
|
||||
const active = element.ownerDocument.activeElement;
|
||||
if (
|
||||
!this.playbackActive() &&
|
||||
!element.closest('[inert]') &&
|
||||
(active === element.ownerDocument.body ||
|
||||
active === element.closest('main'))
|
||||
) {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
@@ -77,6 +94,28 @@ export class PortalDetailShellComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onScrollKey(event: KeyboardEvent): void {
|
||||
if (
|
||||
event.target === this.host.nativeElement &&
|
||||
[
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'Home',
|
||||
'End',
|
||||
' ',
|
||||
].includes(event.key) &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
// Keep native page scrolling; a mounted player's global shortcuts
|
||||
// must not turn this into volume adjustment or pause.
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event: Event): void {
|
||||
if (!this.playbackActive()) return;
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
@@ -30,6 +30,7 @@ body.resizing-active {
|
||||
|
||||
// Resize handle base styles
|
||||
.resize-handle {
|
||||
app-region: no-drag;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
.sidebar {
|
||||
// Width controlled by the resizable directive at runtime
|
||||
width: 400px;
|
||||
box-sizing: border-box;
|
||||
padding-inline-end: 8px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid var(--app-separator);
|
||||
@@ -41,6 +43,7 @@
|
||||
// Beats the inline width set by the resizable directive while
|
||||
// preserving it so uncollapsing snaps back to the previous size.
|
||||
width: 0 !important;
|
||||
padding-inline-end: 0;
|
||||
min-width: 0 !important;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
@@ -190,6 +193,7 @@
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding-inline-end: 0;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none;
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="nav-list">
|
||||
<div class="nav-list" id="portal-categories">
|
||||
@if (statusText()) {
|
||||
<div class="context-inline-status">
|
||||
{{ statusText() }}
|
||||
@@ -9,6 +9,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item"
|
||||
(keydown)="focusLiveChannels($event)"
|
||||
[attr.data-category-id]="
|
||||
$any(item).category_id ?? $any(item).xtream_id ?? item.id
|
||||
"
|
||||
|
||||
+3
@@ -1,3 +1,4 @@
|
||||
import { focusLiveChannels } from '@iptvnator/ui/components';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -27,6 +28,8 @@ interface WorkspaceCategoryViewItem {
|
||||
styleUrl: './workspace-context-category-view.component.scss',
|
||||
})
|
||||
export class WorkspaceContextCategoryViewComponent {
|
||||
readonly focusLiveChannels = focusLiveChannels;
|
||||
|
||||
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
readonly itemCounts = input<Map<number, number>>(new Map());
|
||||
|
||||
Reference in new issue
Block a user