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

+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;