mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950
# Conflicts: # apps/electron-backend-e2e/src/detail-back-lane.e2e.ts # apps/web-e2e/src/m3u-movie-details.e2e.ts # docs/architecture/iptvnator-ui-guidelines.md # libs/ui/components/src/lib/content-hero/content-hero.component.scss # libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
commit
87d7e1d46c
122 files changed
+2080
-1040
No files matched your search
@@ -33,7 +33,11 @@
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="rail__viewport">
|
||||
<div
|
||||
#viewport
|
||||
class="rail__viewport"
|
||||
[attr.data-test-id]="testId() ? testId() + '-viewport' : null"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rail__chev rail__chev--left"
|
||||
@@ -49,7 +53,10 @@
|
||||
#track
|
||||
class="rail__track"
|
||||
[attr.data-layout]="layout()"
|
||||
[attr.data-test-id]="testId() ? testId() + '-track' : null"
|
||||
(scroll)="onScroll()"
|
||||
(focusin)="onTrackFocusIn($event)"
|
||||
(pointerdown)="onTrackPointerDown($event)"
|
||||
role="list"
|
||||
>
|
||||
@for (card of items(); track card.id) {
|
||||
@@ -67,6 +74,9 @@
|
||||
<a
|
||||
class="rail__channel-link"
|
||||
[routerLink]="card.link"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-link' : null
|
||||
"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
@@ -153,6 +163,9 @@
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-link' : null
|
||||
"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
|
||||
@@ -439,4 +439,264 @@ describe('DashboardRailComponent', () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus reveal', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as unknown as { ResizeObserver: unknown }
|
||||
).ResizeObserver = class {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
});
|
||||
|
||||
const rect = (left: number, width: number) =>
|
||||
({ left, right: left + width, top: 0, bottom: 100 }) as DOMRect;
|
||||
|
||||
/**
|
||||
* Lays the cards out at `stride` px intervals in a 1000px viewport
|
||||
* (jsdom has no layout) and records the track's `scrollTo` calls.
|
||||
*/
|
||||
const renderRail = async (options: {
|
||||
count: number;
|
||||
width: number;
|
||||
stride: number;
|
||||
scrollLeft?: number;
|
||||
}) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardRailComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { stripCountryPrefix: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(DashboardRailComponent);
|
||||
fixture.componentRef.setInput('label', 'Sources');
|
||||
fixture.componentRef.setInput(
|
||||
'items',
|
||||
Array.from({ length: options.count }, (_, index) =>
|
||||
card({
|
||||
id: `card-${index}`,
|
||||
actions: [
|
||||
{ id: 'edit', labelKey: 'EDIT', icon: 'edit' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const scrollLeft = options.scrollLeft ?? 0;
|
||||
const track = element.querySelector('.rail__track') as HTMLElement;
|
||||
const contentWidth =
|
||||
(options.count - 1) * options.stride + options.width;
|
||||
Object.defineProperties(track, {
|
||||
scrollLeft: { configurable: true, value: scrollLeft },
|
||||
clientWidth: { configurable: true, value: 1000 },
|
||||
scrollWidth: { configurable: true, value: contentWidth },
|
||||
});
|
||||
const scrollTo = jest.fn();
|
||||
track.scrollTo = scrollTo;
|
||||
jest.spyOn(
|
||||
element.querySelector('.rail__viewport') as HTMLElement,
|
||||
'getBoundingClientRect'
|
||||
).mockReturnValue(rect(0, 1000));
|
||||
element
|
||||
.querySelectorAll<HTMLElement>('.rail__card')
|
||||
.forEach((cardElement, index) =>
|
||||
jest
|
||||
.spyOn(cardElement, 'getBoundingClientRect')
|
||||
.mockReturnValue(
|
||||
rect(
|
||||
index * options.stride - scrollLeft,
|
||||
options.width
|
||||
)
|
||||
)
|
||||
);
|
||||
const focusLink = (index: number) =>
|
||||
element
|
||||
.querySelectorAll<HTMLElement>('.rail__card-link')
|
||||
[index].focus();
|
||||
return { element, scrollTo, focusLink };
|
||||
};
|
||||
|
||||
it('scrolls to the end when the last card is cut off by an overflow smaller than a card', async () => {
|
||||
// Max scroll 102px: the last card shows 70px, which Chromium
|
||||
// already treats as visible enough to skip its focus scroll.
|
||||
const { scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
focusLink(5);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 102,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves to the next snap position that reveals the card rather than the nearest one', async () => {
|
||||
// A "nearest" scroll would need 264px, which mandatory snapping
|
||||
// rounds back to 0 — leaving the card cut off.
|
||||
const { scrollTo, focusLink } = await renderRail({
|
||||
count: 5,
|
||||
width: 306,
|
||||
stride: 316,
|
||||
});
|
||||
|
||||
focusLink(3);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 316,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a card wider than the visible area at its own start instead of skipping past it', async () => {
|
||||
// The 1000px viewport cannot hold a 1200px card, so the next
|
||||
// card's snap point (2420) would move the focused one offscreen.
|
||||
const { scrollTo, focusLink } = await renderRail({
|
||||
count: 3,
|
||||
width: 1200,
|
||||
stride: 1210,
|
||||
scrollLeft: 1210,
|
||||
});
|
||||
|
||||
focusLink(1);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 1210,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('aligns a card cut off on the left with the start edge', async () => {
|
||||
const { scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
scrollLeft: 100,
|
||||
});
|
||||
|
||||
focusLink(0);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 0,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals the card when its actions button gains focus', async () => {
|
||||
const { element, scrollTo } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
element
|
||||
.querySelectorAll<HTMLElement>('.rail__action-trigger')[5]
|
||||
.focus();
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 102,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Presses a card link: a pointerdown (jsdom has no PointerEvent),
|
||||
* followed for a mouse by its mousedown. A tap's compatibility
|
||||
* mousedown only comes once the finger lifts.
|
||||
*/
|
||||
const pressCard = (
|
||||
element: HTMLElement,
|
||||
index: number,
|
||||
pointerType: 'mouse' | 'touch'
|
||||
) => {
|
||||
const link = element.querySelectorAll('.rail__card-link')[index];
|
||||
const press = new MouseEvent('pointerdown', { bubbles: true });
|
||||
Object.defineProperty(press, 'pointerType', { value: pointerType });
|
||||
link.dispatchEvent(press);
|
||||
if (pointerType === 'mouse') {
|
||||
link.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
detail: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
// Waits out a press's focus window. The rail's first-render reset to
|
||||
// the start lands meanwhile, so its scrollTo call is forgotten.
|
||||
const wait = async (ms: number, scrollTo: jest.Mock) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
scrollTo.mockClear();
|
||||
};
|
||||
|
||||
it('keeps the rail still when a mouse press focuses a partly hidden card', async () => {
|
||||
// Scrolling on mousedown would move the card from under the
|
||||
// pointer, so the click would land elsewhere.
|
||||
const { element, scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
pressCard(element, 5, 'mouse');
|
||||
focusLink(5);
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the rail still when a tap focuses the card after the finger lifts', async () => {
|
||||
const { element, scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
pressCard(element, 5, 'touch');
|
||||
await wait(150, scrollTo);
|
||||
focusLink(5);
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still reveals a card focused from script after an earlier mouse press', async () => {
|
||||
const { element, scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
pressCard(element, 0, 'mouse');
|
||||
await wait(150, scrollTo);
|
||||
focusLink(5);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 102,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the scroll position alone for a fully visible card', async () => {
|
||||
const { scrollTo, focusLink } = await renderRail({
|
||||
count: 6,
|
||||
width: 172,
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
focusLink(4);
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,6 +156,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
*/
|
||||
readonly totalCount = input<number | null>(null);
|
||||
|
||||
private readonly viewport =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('viewport');
|
||||
private readonly track =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('track');
|
||||
private readonly cardElements =
|
||||
@@ -171,6 +173,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly visibleCardIds = new Set<string>();
|
||||
private lastVisibleSignature: string | null = null;
|
||||
private resetFrameId: number | null = null;
|
||||
/** Last mouse, pen or touch press inside the track. */
|
||||
private pointerPress: { timeStamp: number; touch: boolean } | null = null;
|
||||
private settleFrameId: number | null = null;
|
||||
|
||||
/**
|
||||
@@ -281,6 +285,86 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
this.updateScrollState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a card that receives keyboard or programmatic focus (Tab or
|
||||
* `focus()`) fully into view; focus caused by a mouse or touch press
|
||||
* leaves the rail where it is.
|
||||
* Chromium skips its own focus scroll when 32px or more of the element
|
||||
* already shows, which left a card partly hidden under the edge fade
|
||||
* when the rail overflows by less than a card. A plain "nearest" scroll
|
||||
* is not enough either: mandatory snapping can round it back to where
|
||||
* the card is still cut off. So the rail moves to the first card-start
|
||||
* snap position that reveals the whole card. The visible area is the
|
||||
* viewport, not the track, whose padding bleeds under the fades.
|
||||
*/
|
||||
onTrackFocusIn(event: FocusEvent): void {
|
||||
if (this.isFocusFromPointerPress(event)) return;
|
||||
const card =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('.rail__card')
|
||||
: null;
|
||||
if (!card) return;
|
||||
const track = this.track().nativeElement;
|
||||
const visible = this.viewport().nativeElement.getBoundingClientRect();
|
||||
const rect = card.getBoundingClientRect();
|
||||
const hiddenLeft = rect.left < visible.left - 1;
|
||||
if (!hiddenLeft && rect.right <= visible.right + 1) return;
|
||||
|
||||
// Scroll offset that aligns an element's start with the visible
|
||||
// edge — where `scroll-padding-inline-start` makes each card snap.
|
||||
const snapOffset = (element: HTMLElement) =>
|
||||
track.scrollLeft +
|
||||
element.getBoundingClientRect().left -
|
||||
visible.left;
|
||||
const maxLeft = track.scrollWidth - track.clientWidth;
|
||||
let left = maxLeft;
|
||||
// A card wider than the visible area (a narrow window, or zoom) can
|
||||
// never fit: show its start rather than a later card's snap point,
|
||||
// which would move it offscreen.
|
||||
if (
|
||||
hiddenLeft ||
|
||||
rect.right - rect.left > visible.right - visible.left
|
||||
) {
|
||||
left = snapOffset(card);
|
||||
} else {
|
||||
const needed = track.scrollLeft + rect.right - visible.right;
|
||||
for (const { nativeElement } of this.cardElements()) {
|
||||
const offset = snapOffset(nativeElement);
|
||||
if (offset >= needed - 1) {
|
||||
left = offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
track.scrollTo({
|
||||
left: Math.max(0, Math.min(left, maxLeft)),
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
|
||||
onTrackPointerDown(event: PointerEvent): void {
|
||||
this.pointerPress = {
|
||||
timeStamp: event.timeStamp,
|
||||
touch: event.pointerType === 'touch',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A press focuses the card it lands on too, but scrolling then would
|
||||
* slide the card from under the pointer and lose the click. A mouse or
|
||||
* pen focuses on the mousedown right after the pointerdown; a tap only
|
||||
* with the compatibility mouse events once the finger lifts, so it gets
|
||||
* the CDK FocusMonitor's 650ms touch buffer. Focus later than that, such
|
||||
* as `focus()` after a click elsewhere, is not the press's. Only
|
||||
* timestamps are compared: nothing in the DOM changes before the click.
|
||||
*/
|
||||
private isFocusFromPointerPress(event: FocusEvent): boolean {
|
||||
const press = this.pointerPress;
|
||||
if (!press) return false;
|
||||
const elapsed = event.timeStamp - press.timeStamp;
|
||||
return elapsed >= 0 && elapsed <= (press.touch ? 650 : 100);
|
||||
}
|
||||
|
||||
scrollBy(direction: 1 | -1): void {
|
||||
const el = this.track().nativeElement;
|
||||
el.scrollBy({
|
||||
|
||||
+4
-5
@@ -9,6 +9,7 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
normalizeDashboardRailsSettings,
|
||||
@@ -286,11 +287,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
playlist.filename ||
|
||||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
|
||||
subtitle: this.data.getPlaylistProvider(playlist),
|
||||
icon: playlist.serverUrl
|
||||
? 'cloud'
|
||||
: playlist.macAddress
|
||||
? 'cast'
|
||||
: 'folder_open',
|
||||
icon: getPlaylistSourceIcon(playlist),
|
||||
link: this.data.getPlaylistLink(playlist),
|
||||
actions: buildDashboardSourceActions(
|
||||
playlist,
|
||||
@@ -675,6 +672,8 @@ export class WorkspaceDashboardRailsComponent {
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
|
||||
tone: 'destructive',
|
||||
onConfirm: () => {
|
||||
void this.removePlaylist(playlist);
|
||||
},
|
||||
|
||||
+25
-2
@@ -1,8 +1,31 @@
|
||||
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
|
||||
<!-- The page's Back, at the leading edge where desktop apps and Material's
|
||||
top app bar keep navigation (beside the macOS traffic lights). The
|
||||
header never scrolls, so the control stays put over long pages. -->
|
||||
@if (backTarget(); as back) {
|
||||
@let backLabel = back.label() || ('BACK' | translate);
|
||||
<button
|
||||
type="button"
|
||||
class="header-back"
|
||||
mat-icon-button
|
||||
data-test-id="workspace-header-back"
|
||||
(click)="backRequested.emit()"
|
||||
(keydown.escape)="onBackEscape($event)"
|
||||
[matTooltip]="
|
||||
back.escapeShortcut() ? backLabel + ' (Esc)' : backLabel
|
||||
"
|
||||
[attr.aria-label]="backLabel"
|
||||
[attr.aria-keyshortcuts]="back.escapeShortcut() ? 'Escape' : null"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Phone-only (≤640px, CSS-hidden above): on phones the context panel
|
||||
is an off-canvas drawer, and this button is the only way to open
|
||||
it, so it must never be dropped while the route has a panel. -->
|
||||
@if (showContextDrawerToggle()) {
|
||||
it. A page with Back takes its slot (one navigation icon, as in
|
||||
Material's top app bar); the list Back returns to shows it again. -->
|
||||
@if (showContextDrawerToggle() && !backTarget()) {
|
||||
<button
|
||||
#contextDrawerToggle
|
||||
type="button"
|
||||
|
||||
+8
@@ -20,6 +20,14 @@
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// Pulled toward the edge so the arrow glyph, not the button's padding, sits
|
||||
// on the header's inset.
|
||||
.header-back {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: -8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// Above the phone breakpoint the context panel is always visible beside the
|
||||
// content, so its drawer toggle would open nothing — it exists only at ≤640px.
|
||||
.context-drawer-toggle {
|
||||
|
||||
+105
@@ -4,6 +4,7 @@ import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
@@ -14,6 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
|
||||
|
||||
@Component({
|
||||
@@ -376,6 +378,109 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('page Back', () => {
|
||||
const backButton = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="workspace-header-back"]'
|
||||
);
|
||||
|
||||
function setBackTarget(
|
||||
label: string | null,
|
||||
escapeShortcut: boolean
|
||||
): WorkspaceBackTarget {
|
||||
const target: WorkspaceBackTarget = {
|
||||
label: signal(label),
|
||||
escapeShortcut: signal(escapeShortcut),
|
||||
run: jest.fn(),
|
||||
};
|
||||
fixture.componentRef.setInput('backTarget', target);
|
||||
fixture.detectChanges();
|
||||
return target;
|
||||
}
|
||||
|
||||
it('renders nothing while the page has no Back', () => {
|
||||
expect(backButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('leads the header and emits Back requests', () => {
|
||||
const requested = jest.fn();
|
||||
component.backRequested.subscribe(requested);
|
||||
setBackTarget('Back to Downloads', true);
|
||||
|
||||
const button = backButton();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('header')?.firstElementChild
|
||||
).toBe(button);
|
||||
expect(button?.type).toBe('button');
|
||||
button?.click();
|
||||
|
||||
expect(requested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('names the page Back and announces Escape only while it applies', () => {
|
||||
setBackTarget('Back to Downloads', true);
|
||||
expect(backButton()?.getAttribute('aria-label')).toBe(
|
||||
'Back to Downloads'
|
||||
);
|
||||
expect(backButton()?.getAttribute('aria-keyshortcuts')).toBe(
|
||||
'Escape'
|
||||
);
|
||||
|
||||
setBackTarget(null, false);
|
||||
// No host label: the generic translated Back.
|
||||
expect(backButton()?.getAttribute('aria-label')).toBe('BACK');
|
||||
expect(backButton()?.hasAttribute('aria-keyshortcuts')).toBe(false);
|
||||
});
|
||||
|
||||
it('runs the advertised Escape from the focused Back only while it applies', () => {
|
||||
const requested = jest.fn();
|
||||
component.backRequested.subscribe(requested);
|
||||
const press = (init: KeyboardEventInit = {}) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
backButton()?.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
setBackTarget(null, true);
|
||||
press({ repeat: true });
|
||||
press({ shiftKey: true });
|
||||
expect(requested).not.toHaveBeenCalled();
|
||||
expect(press().defaultPrevented).toBe(true);
|
||||
expect(requested).toHaveBeenCalledTimes(1);
|
||||
|
||||
// In watch, Escape belongs to the player's close shortcut.
|
||||
setBackTarget(null, false);
|
||||
expect(press().defaultPrevented).toBe(false);
|
||||
expect(requested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('takes the drawer toggle slot while the page offers Back', () => {
|
||||
fixture.componentRef.setInput('showContextDrawerToggle', true);
|
||||
setBackTarget(null, true);
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="context-drawer-toggle"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('backTarget', null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(backButton()).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="context-drawer-toggle"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the paired Material primary tokens for the download badge', () => {
|
||||
const styleSource = readFileSync(
|
||||
join(__dirname, 'workspace-shell-header.component.scss'),
|
||||
|
||||
+28
-1
@@ -12,7 +12,10 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
WorkspaceBackTarget,
|
||||
WorkspaceHeaderAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
WorkspaceHeaderBulkAction,
|
||||
@@ -46,6 +49,8 @@ export class WorkspaceShellHeaderComponent {
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
|
||||
|
||||
/** The current page's Back action; null when the page has none. */
|
||||
readonly backTarget = input<WorkspaceBackTarget | null>(null);
|
||||
readonly playlistTitle = input('');
|
||||
readonly playlistSubtitle = input('');
|
||||
readonly canOpenPlaylistInfo = input(false);
|
||||
@@ -105,6 +110,7 @@ export class WorkspaceShellHeaderComponent {
|
||||
'WORKSPACE.SHELL.CONTEXT_DRAWER_CATEGORIES_TOOLTIP'
|
||||
);
|
||||
|
||||
readonly backRequested = output<void>();
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
readonly commandPaletteRequested = output<void>();
|
||||
@@ -148,6 +154,27 @@ export class WorkspaceShellHeaderComponent {
|
||||
this.searchSubmitted.emit(target?.value ?? this.searchQuery());
|
||||
}
|
||||
|
||||
/**
|
||||
* The tooltip advertises Escape for Back. The page only handles it with
|
||||
* focus inside the page, so the button keeps that promise itself.
|
||||
*/
|
||||
onBackEscape(event: Event): void {
|
||||
const keyboard = event as KeyboardEvent;
|
||||
if (
|
||||
!this.backTarget()?.escapeShortcut() ||
|
||||
keyboard.defaultPrevented ||
|
||||
keyboard.repeat ||
|
||||
keyboard.altKey ||
|
||||
keyboard.ctrlKey ||
|
||||
keyboard.metaKey ||
|
||||
keyboard.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
keyboard.preventDefault();
|
||||
this.backRequested.emit();
|
||||
}
|
||||
|
||||
onPlaylistInfoRequested(): void {
|
||||
this.playlistInfoRequested.emit();
|
||||
}
|
||||
|
||||
+2
-13
@@ -1,17 +1,6 @@
|
||||
<!-- No brand mark: it linked to the page the first workspace link opens.
|
||||
The page's Back lives in the header's leading slot. -->
|
||||
<aside class="app-rail" [class.is-macos]="isMacOS()">
|
||||
<a
|
||||
class="brand"
|
||||
[routerLink]="brandLink()"
|
||||
[matTooltip]="brandTooltipKey() | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="brandAriaLabelKey() | translate"
|
||||
>
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
|
||||
|
||||
@if (primaryContextLinks().length > 0) {
|
||||
|
||||
-22
@@ -98,22 +98,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.rail-context-region {
|
||||
--region-accent: var(--mat-sys-outline-variant);
|
||||
display: flex;
|
||||
@@ -239,12 +223,6 @@
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
// The brand links to the dashboard, which the first workspace link
|
||||
// already does. On a bar this narrow that duplicate costs a section.
|
||||
.brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
+9
-12
@@ -61,11 +61,6 @@ describe('WorkspaceShellRailComponent', () => {
|
||||
});
|
||||
|
||||
it('renders provider context region and active settings shortcut state', () => {
|
||||
fixture.componentRef.setInput('brandLink', '/workspace/sources');
|
||||
fixture.componentRef.setInput(
|
||||
'brandAriaLabelKey',
|
||||
'WORKSPACE.SHELL.OPEN_SOURCES'
|
||||
);
|
||||
fixture.componentRef.setInput('primaryContextLinks', [
|
||||
{
|
||||
icon: 'movie',
|
||||
@@ -87,13 +82,15 @@ describe('WorkspaceShellRailComponent', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('has no brand mark duplicating the first workspace link', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('img')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.brand')?.getAttribute('href')
|
||||
).toContain('/workspace/sources');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.brand')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
|
||||
fixture.nativeElement.querySelector('.app-rail')?.firstElementChild
|
||||
?.tagName
|
||||
).toBe('APP-WORKSPACE-SHELL-RAIL-LINKS');
|
||||
});
|
||||
});
|
||||
+1
-8
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { RouterLink } from '@angular/router';
|
||||
@@ -28,9 +24,6 @@ import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/
|
||||
})
|
||||
export class WorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<PortalRailLink[]>([]);
|
||||
readonly primaryContextLinks = input<PortalRailLink[]>([]);
|
||||
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@ import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellRoute,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateFn } from './workspace-shell-search-labels';
|
||||
|
||||
export interface CommandBuilderActions {
|
||||
@@ -275,7 +276,7 @@ export function getGlobalCommandDefinitions(
|
||||
{
|
||||
id: 'add-playlist-m3u',
|
||||
group: 'global',
|
||||
icon: 'folder_open',
|
||||
icon: SOURCE_TYPE_ICONS.m3u,
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_DESCRIPTION',
|
||||
@@ -286,7 +287,7 @@ export function getGlobalCommandDefinitions(
|
||||
{
|
||||
id: 'add-playlist-xtream',
|
||||
group: 'global',
|
||||
icon: 'cloud',
|
||||
icon: SOURCE_TYPE_ICONS.xtream,
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_DESCRIPTION',
|
||||
@@ -297,7 +298,7 @@ export function getGlobalCommandDefinitions(
|
||||
{
|
||||
id: 'add-playlist-stalker',
|
||||
group: 'global',
|
||||
icon: 'cast',
|
||||
icon: SOURCE_TYPE_ICONS.stalker,
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_DESCRIPTION',
|
||||
|
||||
-15
@@ -55,21 +55,6 @@ export class WorkspaceShellRouteStateService {
|
||||
readonly showDashboard = computed(() =>
|
||||
this.startupPreferences.showDashboard()
|
||||
);
|
||||
readonly brandLink = computed(() =>
|
||||
this.startupPreferences.getFirstAvailableWorkspacePath(
|
||||
this.showDashboard()
|
||||
)
|
||||
);
|
||||
readonly brandTooltipKey = computed(() =>
|
||||
this.showDashboard()
|
||||
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
|
||||
: 'WORKSPACE.SHELL.RAIL_SOURCES'
|
||||
);
|
||||
readonly brandAriaLabelKey = computed(() =>
|
||||
this.showDashboard()
|
||||
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
|
||||
: 'WORKSPACE.SHELL.OPEN_SOURCES'
|
||||
);
|
||||
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
|
||||
this.languageTick();
|
||||
|
||||
|
||||
-1
@@ -777,7 +777,6 @@ describe('WorkspaceShellFacade', () => {
|
||||
exact: true,
|
||||
},
|
||||
]);
|
||||
expect(facade.brandLink()).toBe('/workspace/sources');
|
||||
});
|
||||
|
||||
it('hides the Electron-only global search rail link in the web runtime', () => {
|
||||
|
||||
+7
-3
@@ -3,6 +3,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
WorkspaceHeaderContextService,
|
||||
@@ -47,6 +48,7 @@ export class WorkspaceShellFacade {
|
||||
private readonly header = inject(WorkspaceShellHeaderService);
|
||||
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
|
||||
readonly headerContext = inject(WorkspaceHeaderContextService);
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
@@ -84,9 +86,7 @@ export class WorkspaceShellFacade {
|
||||
readonly currentUrl = this.routeState.currentUrl;
|
||||
readonly currentRoute = this.routeState.currentRoute;
|
||||
readonly showDashboard = this.routeState.showDashboard;
|
||||
readonly brandLink = this.routeState.brandLink;
|
||||
readonly brandTooltipKey = this.routeState.brandTooltipKey;
|
||||
readonly brandAriaLabelKey = this.routeState.brandAriaLabelKey;
|
||||
readonly backTarget = this.backNavigation.target;
|
||||
readonly currentContext = this.routeState.currentContext;
|
||||
readonly currentSection = this.routeState.currentSection;
|
||||
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
|
||||
@@ -218,6 +218,10 @@ export class WorkspaceShellFacade {
|
||||
this.header.toggleLiveSidebar();
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.backNavigation.goBack();
|
||||
}
|
||||
|
||||
navigateToGlobalFavorites(): void {
|
||||
this.header.navigateToGlobalFavorites();
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
<app-workspace-shell-rail
|
||||
[attr.inert]="contextDrawer.isOpen() ? '' : null"
|
||||
[isMacOS]="facade.isMacOS"
|
||||
[brandLink]="facade.brandLink()"
|
||||
[brandTooltipKey]="facade.brandTooltipKey()"
|
||||
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
|
||||
[workspaceLinks]="facade.workspaceLinks()"
|
||||
[primaryContextLinks]="facade.primaryContextLinks()"
|
||||
[secondaryContextLinks]="facade.secondaryContextLinks()"
|
||||
@@ -28,6 +25,7 @@
|
||||
<app-workspace-shell-header
|
||||
#workspaceHeader
|
||||
[attr.inert]="contextDrawer.isOpen() ? '' : null"
|
||||
[backTarget]="facade.backTarget()"
|
||||
[playlistTitle]="facade.playlistTitle()"
|
||||
[playlistSubtitle]="facade.playlistSubtitle()"
|
||||
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
|
||||
@@ -52,6 +50,7 @@
|
||||
[isContextDrawerOpen]="contextDrawer.isOpen()"
|
||||
[contextDrawerToggleAriaKey]="facade.contextDrawerLabelKeys().aria"
|
||||
[contextDrawerTooltipKey]="facade.contextDrawerLabelKeys().tooltip"
|
||||
(backRequested)="facade.goBack()"
|
||||
(contextDrawerToggleRequested)="contextDrawer.toggle()"
|
||||
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
|
||||
(searchChanged)="facade.onSearchInput($event)"
|
||||
|
||||
+4
-6
@@ -33,9 +33,6 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
|
||||
})
|
||||
class MockWorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<unknown[]>([]);
|
||||
readonly primaryContextLinks = input<unknown[]>([]);
|
||||
readonly secondaryContextLinks = input<unknown[]>([]);
|
||||
@@ -51,6 +48,7 @@ class MockWorkspaceShellRailComponent {
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceShellHeaderComponent {
|
||||
readonly backTarget = input<unknown>(null);
|
||||
readonly playlistTitle = input('');
|
||||
readonly playlistSubtitle = input('');
|
||||
readonly canOpenPlaylistInfo = input(false);
|
||||
@@ -75,6 +73,7 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = input<unknown>(null);
|
||||
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
|
||||
readonly backRequested = output<void>();
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
readonly commandPaletteRequested = output<void>();
|
||||
@@ -152,9 +151,8 @@ class MockWorkspaceKeyboardShortcutsService {
|
||||
}
|
||||
|
||||
class MockWorkspaceShellFacade {
|
||||
readonly brandLink = signal('/workspace/dashboard');
|
||||
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly backTarget = signal(null);
|
||||
goBack = jest.fn();
|
||||
readonly workspaceLinks = signal([]);
|
||||
readonly primaryContextLinks = signal([]);
|
||||
readonly secondaryContextLinks = signal([]);
|
||||
|
||||
+4
-3
@@ -14,6 +14,7 @@ import {
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
@@ -55,17 +56,17 @@ export class WorkspaceSourcesFiltersPanelComponent {
|
||||
},
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: 'playlist_play',
|
||||
icon: SOURCE_TYPE_ICONS.m3u,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: 'cloud',
|
||||
icon: SOURCE_TYPE_ICONS.xtream,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: 'router',
|
||||
icon: SOURCE_TYPE_ICONS.stalker,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
},
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user