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:
4gray authored and GitHub committed 2026-09-05 15:02:57 +02:00
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());
@@ -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>
}
@@ -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,
@@ -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)
@@ -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,
@@ -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)"
/>
@@ -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}`,
@@ -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`,
+2
View File
@@ -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';
@@ -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"
@@ -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,
@@ -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() ||
@@ -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;
@@ -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
@@ -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 {
@@ -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"
@@ -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) {
@@ -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,
@@ -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,
@@ -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;
+4
View File
@@ -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;
@@ -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
"
@@ -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());