diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts new file mode 100644 index 000000000..c8542b04a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts @@ -0,0 +1,94 @@ +import { + EpgGuideKeyboardController, + EpgGuideKeyboardHost, +} from './epg-guide-keyboard.controller'; + +function key( + keyName: string, + init: Partial & { 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; + 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(); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts new file mode 100644 index 000000000..1c414bdcd --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts @@ -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(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; + } +}