feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:34:49 +02:00
1 parent 9f33706404
commit 36be2cbb58
2 files changed
+244

No files matched your search

@@ -0,0 +1,94 @@
import {
EpgGuideKeyboardController,
EpgGuideKeyboardHost,
} from './epg-guide-keyboard.controller';
function key(
keyName: string,
init: Partial<KeyboardEventInit> & { target?: EventTarget } = {}
): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key: keyName, ...init });
if (init.target) {
Object.defineProperty(event, 'target', { value: init.target });
}
return event;
}
describe('EpgGuideKeyboardController', () => {
let host: jest.Mocked<EpgGuideKeyboardHost>;
let controller: EpgGuideKeyboardController;
beforeEach(() => {
host = {
rowCount: jest.fn(() => 5),
blockCount: jest.fn(() => 3),
activeRow: jest.fn(() => 2),
isBlocked: jest.fn(() => false),
play: jest.fn(),
details: jest.fn(),
jumpNow: jest.fn(),
stepDay: jest.fn(),
close: jest.fn(),
};
controller = new EpgGuideKeyboardController(host);
});
it('starts row focus from the active channel and clamps at the ends', () => {
expect(controller.handle(key('ArrowDown'))).toBe(true);
expect(controller.focus()).toEqual({ row: 3, block: null });
controller.handle(key('ArrowDown'));
controller.handle(key('ArrowDown'));
controller.handle(key('ArrowDown'));
expect(controller.focus()).toEqual({ row: 4, block: null });
host.activeRow.mockReturnValue(-1);
controller.focus.set(null);
controller.handle(key('ArrowUp'));
expect(controller.focus()).toEqual({ row: 4, block: null });
});
it('moves block focus inside the focused row', () => {
controller.handle(key('ArrowRight'));
expect(controller.focus()).toEqual({ row: 2, block: 0 });
controller.handle(key('ArrowRight'));
controller.handle(key('ArrowRight'));
controller.handle(key('ArrowRight'));
expect(controller.focus()).toEqual({ row: 2, block: 2 });
controller.handle(key('ArrowLeft'));
expect(controller.focus()).toEqual({ row: 2, block: 1 });
controller.handle(key('ArrowDown'));
expect(controller.focus()).toEqual({ row: 3, block: null });
});
it('plays the focused (or active) row on Enter and opens details with I', () => {
controller.handle(key('Enter'));
expect(host.play).toHaveBeenCalledWith(2);
controller.handle(key('ArrowDown'));
controller.handle(key('ArrowRight'));
controller.handle(key('i'));
expect(host.details).toHaveBeenCalledWith(3, 0);
controller.handle(key('Enter'));
expect(host.play).toHaveBeenLastCalledWith(3);
});
it('maps N, PageUp/PageDown and Escape', () => {
controller.handle(key('n'));
expect(host.jumpNow).toHaveBeenCalled();
controller.handle(key('PageUp'));
expect(host.stepDay).toHaveBeenCalledWith('prev');
controller.handle(key('PageDown'));
expect(host.stepDay).toHaveBeenCalledWith('next');
expect(controller.handle(key('Escape'))).toBe(true);
expect(host.close).toHaveBeenCalled();
});
it('ignores typing, modifier chords, blocked state and unknown keys', () => {
const input = document.createElement('input');
expect(controller.handle(key('ArrowDown', { target: input }))).toBe(false);
expect(controller.handle(key('ArrowDown', { ctrlKey: true }))).toBe(false);
host.isBlocked.mockReturnValue(true);
expect(controller.handle(key('Escape'))).toBe(false);
host.isBlocked.mockReturnValue(false);
expect(controller.handle(key('x'))).toBe(false);
expect(host.close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,150 @@
import { signal } from '@angular/core';
import { EpgDateNavigationDirection } from '../epg-date';
export interface EpgGuideFocus {
readonly row: number;
/** Block index inside the row, or null when the whole row is focused. */
readonly block: number | null;
}
export interface EpgGuideKeyboardHost {
rowCount(): number;
blockCount(row: number): number;
/** Index of the playing channel's row, or -1. */
activeRow(): number;
/** True while a dialog owns the keyboard. */
isBlocked(): boolean;
play(row: number): void;
details(row: number, block: number): void;
jumpNow(): void;
stepDay(direction: EpgDateNavigationDirection): void;
close(): void;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/**
* Roving focus for the guide grid. ↑/↓ move between rows, ←/→ between the
* focused row's programmes, Enter plays the row, I opens details, N jumps to
* now, PgUp/PgDn change the day, Esc closes. Returns whether the event was
* consumed so the caller can `preventDefault()`.
*/
export class EpgGuideKeyboardController {
readonly focus = signal<EpgGuideFocus | null>(null);
constructor(private readonly host: EpgGuideKeyboardHost) {}
handle(event: KeyboardEvent): boolean {
if (
event.defaultPrevented ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
isEditableTarget(event.target) ||
this.host.isBlocked()
) {
return false;
}
switch (event.key) {
case 'Escape':
this.host.close();
return true;
case 'ArrowDown':
return this.moveRow(1);
case 'ArrowUp':
return this.moveRow(-1);
case 'ArrowRight':
return this.moveBlock(1);
case 'ArrowLeft':
return this.moveBlock(-1);
case 'Enter':
return this.play();
case 'i':
case 'I':
return this.details();
case 'n':
case 'N':
this.host.jumpNow();
return true;
case 'PageUp':
this.host.stepDay('prev');
return true;
case 'PageDown':
this.host.stepDay('next');
return true;
default:
return false;
}
}
private currentRow(): number {
const focused = this.focus();
if (focused) {
return focused.row;
}
return this.host.activeRow();
}
private moveRow(delta: number): boolean {
const count = this.host.rowCount();
if (count === 0) {
return false;
}
const current = this.currentRow();
const next =
current < 0
? delta > 0
? 0
: count - 1
: clamp(current + delta, 0, count - 1);
this.focus.set({ row: next, block: null });
return true;
}
private moveBlock(delta: number): boolean {
const count = this.host.rowCount();
if (count === 0) {
return false;
}
const row = clamp(Math.max(0, this.currentRow()), 0, count - 1);
const blocks = this.host.blockCount(row);
if (blocks === 0) {
this.focus.set({ row, block: null });
return true;
}
const current = this.focus()?.row === row ? this.focus()?.block ?? null : null;
const start = current ?? (delta > 0 ? -1 : blocks);
this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) });
return true;
}
private play(): boolean {
const row = this.currentRow();
if (row < 0 || row >= this.host.rowCount()) {
return false;
}
this.host.play(row);
return true;
}
private details(): boolean {
const focused = this.focus();
if (!focused || focused.block === null) {
return false;
}
this.host.details(focused.row, focused.block);
return true;
}
}