mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(epg): add guide keyboard navigation controller
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user