mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
503f009a1f
commit
1425279a0a
7 files changed
+296
-70
No files matched your search
@@ -24,6 +24,7 @@ describe('EpgGuideKeyboardController', () => {
|
||||
blockCount: jest.fn(() => 3),
|
||||
activeRow: jest.fn(() => 2),
|
||||
isBlocked: jest.fn(() => false),
|
||||
isOwnedTarget: jest.fn(() => true),
|
||||
play: jest.fn(),
|
||||
details: jest.fn(),
|
||||
jumpNow: jest.fn(),
|
||||
@@ -122,6 +123,21 @@ describe('EpgGuideKeyboardController', () => {
|
||||
expect(controller.focus()).toEqual({ row: 3, block: null });
|
||||
});
|
||||
|
||||
it('only closes when the target is outside the guide', () => {
|
||||
host.isOwnedTarget.mockReturnValue(false);
|
||||
|
||||
expect(controller.handle(key('ArrowDown'))).toBe(false);
|
||||
expect(controller.focus()).toBeNull();
|
||||
expect(controller.handle(key('Enter'))).toBe(false);
|
||||
expect(host.play).not.toHaveBeenCalled();
|
||||
expect(controller.handle(key('n'))).toBe(false);
|
||||
expect(host.jumpNow).not.toHaveBeenCalled();
|
||||
|
||||
// Escape is the documented close key from anywhere on the page.
|
||||
expect(controller.handle(key('Escape'))).toBe(true);
|
||||
expect(host.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the catch-up button inside a card to the button', () => {
|
||||
const card = document.createElement('div');
|
||||
card.setAttribute('role', 'button');
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface EpgGuideKeyboardHost {
|
||||
activeRow(): number;
|
||||
/** True while a dialog owns the keyboard. */
|
||||
isBlocked(): boolean;
|
||||
/**
|
||||
* True when the key belongs to the guide: the listener is on the document,
|
||||
* so a target that is neither inside the guide nor an unfocused page keeps
|
||||
* its own keys (the docked player, the header).
|
||||
*/
|
||||
isOwnedTarget(target: EventTarget | null): boolean;
|
||||
play(row: number): void;
|
||||
details(row: number, block: number): void;
|
||||
jumpNow(): void;
|
||||
@@ -60,8 +66,9 @@ function clamp(value: number, min: number, max: number): number {
|
||||
/**
|
||||
* 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()`.
|
||||
* now, PgUp/PgDn change the day, Esc closes. Every key but Esc requires the
|
||||
* host to own the event's target. Returns whether the event was consumed so
|
||||
* the caller can `preventDefault()`.
|
||||
*/
|
||||
export class EpgGuideKeyboardController {
|
||||
readonly focus = signal<EpgGuideFocus | null>(null);
|
||||
@@ -79,10 +86,17 @@ export class EpgGuideKeyboardController {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Escape is the documented close key and works from anywhere on the
|
||||
// page; every other guide key applies only while the guide owns the
|
||||
// target, or it would be stolen from whatever else has the focus.
|
||||
if (event.key === 'Escape') {
|
||||
this.host.close();
|
||||
return true;
|
||||
}
|
||||
if (!this.host.isOwnedTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
this.host.close();
|
||||
return true;
|
||||
case 'ArrowDown':
|
||||
return this.moveRow(1);
|
||||
case 'ArrowUp':
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
// The guide lives in a lane that clips its overflow (the player dock, a split
|
||||
// pane), so a toolbar that cannot fit one row must wrap: pushed-out controls
|
||||
// would simply be unreachable. Everything but the two text fields keeps its
|
||||
// size; the fields shrink first, then the row breaks.
|
||||
.guide-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
row-gap: 8px;
|
||||
min-height: 52px;
|
||||
padding: 0 18px;
|
||||
padding: 6px 18px;
|
||||
border-bottom: 1px solid $line;
|
||||
background: var(--app-content-bg);
|
||||
color: $text-primary;
|
||||
@@ -21,6 +27,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__day,
|
||||
.guide-toolbar__now,
|
||||
.guide-toolbar__scope,
|
||||
.guide-toolbar__toggle,
|
||||
.guide-toolbar__density,
|
||||
.guide-toolbar__zoom {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.guide-toolbar__day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -178,7 +193,12 @@
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 4px 0 10px;
|
||||
width: 190px;
|
||||
// Grows to its old fixed width when there is room and shrinks before the
|
||||
// row wraps; `max-width` keeps the wide-window layout unchanged.
|
||||
flex: 1 1 160px;
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
max-width: 190px;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 8px;
|
||||
background: $surface-1;
|
||||
@@ -217,7 +237,34 @@
|
||||
}
|
||||
|
||||
.guide-toolbar__input--search {
|
||||
width: 230px;
|
||||
flex-basis: 200px;
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
// Too narrow for one row: the flexible spacer goes, the toggle keeps only
|
||||
// its switch (the label stays in the DOM for assistive technology and the
|
||||
// tooltip still names it) and the programme search takes the second row.
|
||||
.guide-toolbar__spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guide-toolbar__toggle span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.guide-toolbar__input--search {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__count {
|
||||
|
||||
@@ -1,59 +1,63 @@
|
||||
<app-epg-guide-toolbar
|
||||
[dayMs]="view.axis().startMs"
|
||||
[isToday]="view.isToday()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[scopes]="scopes()"
|
||||
[scopeId]="scopeId()"
|
||||
[onlyWithEpg]="onlyWithEpg()"
|
||||
[coverageLoaded]="coverageLoaded()"
|
||||
[density]="density()"
|
||||
[zoom]="zoom()"
|
||||
[filter]="filter()"
|
||||
[searchEnabled]="searchEnabled"
|
||||
[searchQuery]="searchQuery()"
|
||||
[shownCount]="rows().length"
|
||||
[totalCount]="totalCount()"
|
||||
(stepDay)="stepDay($event)"
|
||||
(jumpNow)="jumpNow()"
|
||||
(scopeChange)="setScope($event)"
|
||||
(onlyWithEpgChange)="setOnlyWithEpg($event)"
|
||||
(densityChange)="setDensity($event)"
|
||||
(zoomChange)="setZoom($event)"
|
||||
(filterChange)="setFilter($event)"
|
||||
(searchQueryChange)="onSearchQueryChange($event)"
|
||||
/>
|
||||
<!-- The toolbar wraps in narrow windows, so the search results hang off
|
||||
its real height rather than a hard-coded one. -->
|
||||
<div class="epg-guide__head">
|
||||
<app-epg-guide-toolbar
|
||||
[dayMs]="view.axis().startMs"
|
||||
[isToday]="view.isToday()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[scopes]="scopes()"
|
||||
[scopeId]="scopeId()"
|
||||
[onlyWithEpg]="onlyWithEpg()"
|
||||
[coverageLoaded]="coverageLoaded()"
|
||||
[density]="density()"
|
||||
[zoom]="zoom()"
|
||||
[filter]="filter()"
|
||||
[searchEnabled]="searchEnabled"
|
||||
[searchQuery]="searchQuery()"
|
||||
[shownCount]="rows().length"
|
||||
[totalCount]="totalCount()"
|
||||
(stepDay)="stepDay($event)"
|
||||
(jumpNow)="jumpNow()"
|
||||
(scopeChange)="setScope($event)"
|
||||
(onlyWithEpgChange)="setOnlyWithEpg($event)"
|
||||
(densityChange)="setDensity($event)"
|
||||
(zoomChange)="setZoom($event)"
|
||||
(filterChange)="setFilter($event)"
|
||||
(searchQueryChange)="onSearchQueryChange($event)"
|
||||
/>
|
||||
|
||||
@if (searchPanelVisible()) {
|
||||
<div class="epg-guide__search-results" role="listbox">
|
||||
@for (
|
||||
hit of searchResults();
|
||||
track hit.program.channel + hit.program.start + $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide__search-result"
|
||||
role="option"
|
||||
[attr.aria-selected]="false"
|
||||
(click)="openSearchResult(hit)"
|
||||
>
|
||||
<span class="epg-guide__search-title">{{
|
||||
hit.program.title
|
||||
}}</span>
|
||||
<span class="epg-guide__search-meta">
|
||||
{{
|
||||
searchHitStartMs(hit)
|
||||
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
|
||||
}}
|
||||
· {{ hit.program.channel }}
|
||||
</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="epg-guide__search-empty">
|
||||
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (searchPanelVisible()) {
|
||||
<div class="epg-guide__search-results" role="listbox">
|
||||
@for (
|
||||
hit of searchResults();
|
||||
track hit.program.channel + hit.program.start + $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide__search-result"
|
||||
role="option"
|
||||
[attr.aria-selected]="false"
|
||||
(click)="openSearchResult(hit)"
|
||||
>
|
||||
<span class="epg-guide__search-title">{{
|
||||
hit.program.title
|
||||
}}</span>
|
||||
<span class="epg-guide__search-meta">
|
||||
{{
|
||||
searchHitStartMs(hit)
|
||||
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
|
||||
}}
|
||||
· {{ hit.program.channel }}
|
||||
</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="epg-guide__search-empty">
|
||||
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="epg-guide__grid"
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
// Ruler strip height; the now-line clip layer starts below it.
|
||||
$ruler-height: 34px;
|
||||
// Matches `.guide-toolbar`'s min-height in epg-guide-toolbar.component.scss —
|
||||
// the search results panel hangs directly under the toolbar.
|
||||
$toolbar-height: 52px;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -16,6 +13,14 @@ $toolbar-height: 52px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Anchors the search results panel: the toolbar wraps in narrow windows, so
|
||||
// its height is not a constant the panel could be offset by.
|
||||
.epg-guide__head {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.epg-guide__grid {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -156,9 +161,10 @@ $toolbar-height: 52px;
|
||||
|
||||
.epg-guide__search-results {
|
||||
position: absolute;
|
||||
top: $toolbar-height;
|
||||
top: 100%;
|
||||
right: 18px;
|
||||
width: 420px;
|
||||
max-width: calc(100% - 36px);
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
|
||||
@@ -41,6 +41,14 @@ function nowProgram(channelId: string): EpgProgram {
|
||||
};
|
||||
}
|
||||
|
||||
function keydown(key: string, target?: EventTarget): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key, cancelable: true });
|
||||
if (target) {
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
async function settle(fixture: ComponentFixture<unknown>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
@@ -244,6 +252,67 @@ describe('EpgGuideComponent', () => {
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('handles grid keys only for its own targets, but closes from anywhere', async () => {
|
||||
await settle(fixture);
|
||||
const viewportEl: HTMLElement = fixture.debugElement.query(
|
||||
By.css('cdk-virtual-scroll-viewport')
|
||||
).nativeElement;
|
||||
viewportEl.scrollTo = jest.fn() as unknown as HTMLElement['scrollTo'];
|
||||
const close = jest.fn();
|
||||
component.close.subscribe(close);
|
||||
const outside = document.createElement('div');
|
||||
document.body.appendChild(outside);
|
||||
|
||||
// A non-interactive surface outside the guide — the docked player, the
|
||||
// header — keeps its keys: nothing moves, nothing is consumed.
|
||||
const foreign = keydown('ArrowDown', outside);
|
||||
component.onKeydown(foreign);
|
||||
expect(component.focus()).toBeNull();
|
||||
expect(foreign.defaultPrevented).toBe(false);
|
||||
|
||||
// Nothing focused: the state right after the `G` shortcut opened it.
|
||||
component.onKeydown(keydown('ArrowDown', document.body));
|
||||
expect(component.focus()).toEqual({ row: 1, block: null });
|
||||
|
||||
const cell: HTMLElement = fixture.debugElement.query(
|
||||
By.css('.epg-guide-row__channel')
|
||||
).nativeElement;
|
||||
const own = keydown('ArrowDown', cell);
|
||||
component.onKeydown(own);
|
||||
expect(component.focus()).toEqual({ row: 2, block: null });
|
||||
expect(own.defaultPrevented).toBe(true);
|
||||
|
||||
// Escape is the documented close key wherever the focus is.
|
||||
component.onKeydown(keydown('Escape', outside));
|
||||
expect(close).toHaveBeenCalled();
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it('moves the roving focus with its channel and drops it with the row', async () => {
|
||||
await settle(fixture);
|
||||
component.focusCell(2, 0);
|
||||
await settle(fixture);
|
||||
expect(component.focus()).toEqual({ row: 2, block: 0 });
|
||||
|
||||
// The first channel disappears: the focus follows 'c' to its new index.
|
||||
channels.set([channel('b', null, 2), channel('c', 'c', 3)]);
|
||||
await settle(fixture);
|
||||
expect(component.rows().map((row) => row.id)).toEqual(['b', 'c']);
|
||||
expect(component.focus()).toEqual({ row: 1, block: null });
|
||||
|
||||
// A filter that hides the focused channel clears the focus instead of
|
||||
// leaving it pointing at whatever moved into that row.
|
||||
component.setFilter('channel b');
|
||||
await settle(fixture);
|
||||
expect(component.rows().map((row) => row.id)).toEqual(['b']);
|
||||
expect(component.focus()).toBeNull();
|
||||
|
||||
// Undoing the filter must not bring the stale focus back.
|
||||
component.setFilter('');
|
||||
await settle(fixture);
|
||||
expect(component.focus()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens a search hit with its own channel and focuses that row', async () => {
|
||||
await settle(fixture);
|
||||
// Revealing the row scrolls the CDK viewport; jsdom has no scrollTo.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
Injector,
|
||||
@@ -82,6 +83,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly close = output<void>();
|
||||
/**
|
||||
@@ -145,6 +147,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
blockCount: (row) => this.blocksFor(row).length,
|
||||
activeRow: () => this.activeRowIndex(),
|
||||
isBlocked: () => this.dialog.openDialogs.length > 0,
|
||||
isOwnedTarget: (target) => this.ownsTarget(target),
|
||||
play: (row) => this.commitRow(this.rows()[row]),
|
||||
details: (row, block) =>
|
||||
this.openDetails(this.rows()[row], this.blocksFor(row)[block]),
|
||||
@@ -153,6 +156,13 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
close: () => this.close.emit(),
|
||||
});
|
||||
readonly focus = this.keyboard.focus;
|
||||
/**
|
||||
* The channel the roving focus sits on. `rows()` is rebuilt by the filters,
|
||||
* by a scope switch and by coverage arriving late, so the focused index
|
||||
* alone goes stale — Enter would then play a different channel. Recorded
|
||||
* whenever the focus moves and reconciled whenever the rows change.
|
||||
*/
|
||||
private focusedRowId: string | null = null;
|
||||
/**
|
||||
* The row that carries the grid's single `tabindex="0"`: the focused one,
|
||||
* else the playing one, else the first — so Tab always reaches the grid,
|
||||
@@ -200,8 +210,19 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
this.rows();
|
||||
untracked(() => this.viewportController.loadRenderedRange());
|
||||
const focused = this.focus();
|
||||
untracked(() => {
|
||||
this.focusedRowId = focused
|
||||
? (this.rows()[focused.row]?.id ?? null)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const rows = this.rows();
|
||||
untracked(() => {
|
||||
this.reconcileFocus(rows);
|
||||
this.viewportController.loadRenderedRange();
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const viewport = this.viewport();
|
||||
@@ -288,9 +309,12 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
this.view.setZoom(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The focus is not cleared here: `reconcileFocus` moves it to the filtered
|
||||
* row's new index, or drops it when the filter hid that channel.
|
||||
*/
|
||||
setFilter(value: string): void {
|
||||
this.view.filter.set(value);
|
||||
this.focus.set(null);
|
||||
}
|
||||
|
||||
onSearchQueryChange(query: string): void {
|
||||
@@ -337,6 +361,52 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key aimed at `target` belongs to the guide. The listener is on
|
||||
* the document, so anything focused elsewhere — the docked player, the
|
||||
* header — keeps its own keys; only the guide's own subtree and an
|
||||
* unfocused page are ours. `document.body` is the target right after the
|
||||
* `G` shortcut opened the guide, and a synthetic event carries no target
|
||||
* at all.
|
||||
*/
|
||||
private ownsTarget(target: EventTarget | null): boolean {
|
||||
if (target === null) {
|
||||
return true;
|
||||
}
|
||||
if (!(target instanceof Node)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
target === document.body ||
|
||||
target === document.documentElement ||
|
||||
this.hostElement.nativeElement.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the roving focus on the channel it was put on rather than on a row
|
||||
* number the list has moved under it, and drop it when that channel is no
|
||||
* longer shown — `tabbableRow` then falls back to the playing row. A focus
|
||||
* that follows its channel loses its block: the list changed underneath, so
|
||||
* the programmes behind that index may have moved with it, and the row is
|
||||
* what Enter acts on.
|
||||
*/
|
||||
private reconcileFocus(rows: readonly EpgGuideChannel[]): void {
|
||||
const focused = this.focus();
|
||||
const channelId = this.focusedRowId;
|
||||
if (!focused || channelId === null) {
|
||||
return;
|
||||
}
|
||||
const row = rows.findIndex((channel) => channel.id === channelId);
|
||||
if (row < 0) {
|
||||
this.focus.set(null);
|
||||
return;
|
||||
}
|
||||
if (row !== focused.row) {
|
||||
this.focus.set({ row, block: null });
|
||||
}
|
||||
}
|
||||
|
||||
private revealRow(rowIndex: number): void {
|
||||
this.focus.set({ row: rowIndex, block: null });
|
||||
this.viewportController.revealFocus(this.focus());
|
||||
|
||||
Reference in new issue
Block a user