Merge remote-tracking branch 'origin/master' into claude/multi-epg-view-feature-1efd4d

This commit is contained in:
4gray committed 2026-09-06 15:20:25 +02:00
commit 2ab44ebb19
68 files changed
+2710 -197

No files matched your search

@@ -1,5 +1,8 @@
@if (items().length > 0) {
<div class="nav-list" id="portal-categories">
<div
class="nav-list"
[attr.id]="columnHandoff() ? 'portal-categories' : null"
>
@if (statusText()) {
<div class="context-inline-status">
{{ statusText() }}
@@ -9,7 +12,7 @@
<button
type="button"
class="nav-item category-item"
(keydown)="focusLiveChannels($event)"
(keydown)="onCategoryKeydown($event)"
[attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id
"
@@ -28,7 +28,6 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly focusLiveChannels = focusLiveChannels;
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
@@ -43,6 +42,24 @@ export class WorkspaceContextCategoryViewComponent {
readonly omitMissingCounts = input(false);
readonly interactionEnabled = input(true);
readonly statusText = input('');
/**
* The in-flow shell rail takes part in the live-TV column keyboard
* contract: it is `#portal-categories` (ArrowLeft from the channels pane
* lands on the selected category) and ArrowRight on that category hands
* focus to `#live-channels`. The same view stamped into the categories
* popover must opt out: the dialog's focus trap would bounce that
* handoff back into the dialog, and a second `#portal-categories` would
* shadow the folded rail's.
*/
readonly columnHandoff = input(true);
onCategoryKeydown(event: KeyboardEvent): void {
// A method, not an inline `&&`: an event binding whose expression
// evaluates to `false` makes Angular call preventDefault().
if (this.columnHandoff()) {
focusLiveChannels(event);
}
}
private readonly hostEl = inject(ElementRef<HTMLElement>);
@@ -5,6 +5,7 @@
<div class="context-header__actions">
@if (canSearchCategories()) {
<button
#firstHeaderAction
type="button"
mat-icon-button
class="context-header__action"
@@ -64,6 +65,7 @@
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-manage-categories"
(click)="openManageCategories()"
[disabled]="!canManageXtreamCategories()"
[matTooltip]="
@@ -76,6 +78,22 @@
<mat-icon>tune</mat-icon>
</button>
}
@if (canHideCategories()) {
<button
#hideCategoriesButton
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-hide-categories"
(click)="hideCategories()"
[matTooltip]="'LAYOUT.HIDE_CATEGORIES' | translate"
[attr.aria-label]="
'LAYOUT.HIDE_CATEGORIES' | translate
"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
</div>
</div>
<!-- @if (subtitle(); as text) {
@@ -130,6 +148,7 @@
</div>
} @else {
<app-workspace-context-category-view
[columnHandoff]="presentation() === 'sidebar'"
[items]="filteredXtreamCategories()"
[selectedCategoryId]="xtreamSelectedCategoryId()"
[itemCounts]="xtreamCategoryItemCounts()"
@@ -162,6 +181,7 @@
/>
} @else {
<app-workspace-context-category-view
[columnHandoff]="presentation() === 'sidebar'"
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
[itemCounts]="stalkerCategoryItemCounts()"
@@ -15,6 +15,7 @@ import {
XtreamContentLoadState,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
const translations: Record<string, string> = {
@@ -116,6 +117,7 @@ describe('WorkspaceContextPanelComponent', () => {
const dialog = {
open: jest.fn(),
};
const drawerOpen = signal(false);
beforeEach(async () => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
@@ -142,6 +144,7 @@ describe('WorkspaceContextPanelComponent', () => {
]);
router.navigate.mockClear();
dialog.open.mockClear();
drawerOpen.set(false);
await TestBed.configureTestingModule({
imports: [WorkspaceContextPanelComponent, NoopAnimationsModule],
@@ -179,7 +182,7 @@ describe('WorkspaceContextPanelComponent', () => {
// Root-provided in production; stubbed because the spec's
// Router mock has no `events` stream for the real service.
provide: WorkspaceShellContextDrawerService,
useValue: { close: jest.fn(), isOpen: () => false },
useValue: { close: jest.fn(), isOpen: drawerOpen },
},
],
}).compileComponents();
@@ -204,9 +207,9 @@ describe('WorkspaceContextPanelComponent', () => {
const status = fixture.nativeElement.querySelector(
'.context-inline-status'
) as HTMLElement | null;
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
const manageButton = fixture.nativeElement.querySelector(
'[data-test-id="context-manage-categories"]'
) as HTMLButtonElement | null;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
@@ -261,9 +264,9 @@ describe('WorkspaceContextPanelComponent', () => {
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
const manageButton = fixture.nativeElement.querySelector(
'[data-test-id="context-manage-categories"]'
) as HTMLButtonElement | null;
expect(countTexts).toEqual(['3', '0']);
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
@@ -493,6 +496,189 @@ describe('WorkspaceContextPanelComponent', () => {
).toBeNull();
});
describe('folded categories rail', () => {
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(() => {
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('portal', 'expanded');
});
afterEach(() => {
liveSidebarService.setState('portal', 'expanded');
});
function hideButton(): HTMLButtonElement | null {
return fixture.nativeElement.querySelector(
'[data-test-id="context-hide-categories"]'
);
}
it('offers the hide-categories chevron on live sections of the sidebar and folds the rail', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
const button = hideButton();
expect(button).not.toBeNull();
expect(button?.getAttribute('aria-label')).toBe(
'LAYOUT.HIDE_CATEGORIES'
);
button?.click();
expect(liveSidebarService.stateOf('portal')()).toBe('categories-hidden');
});
it('offers it for Stalker itv and radio too', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'radio');
stalkerStore.selectedCategoryId.set('12');
fixture.detectChanges();
expect(hideButton()).not.toBeNull();
});
it('withholds the chevron inside the open phone drawer, whose stylesheet ignores the folded state', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
drawerOpen.set(true);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('names the chevron as focus target, the first header action without one, and nothing while categories are absent', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBe(hideButton());
// Live root: no chevron, the search action stands in.
xtreamSelectedCategoryId.set(null);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBe(
fixture.nativeElement.querySelector('.context-header__action')
);
// Nothing loaded yet: no control at all, the caller falls back.
xtreamCategories.set([]);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBeNull();
});
it('withholds the chevron while no category is selected (live root has no channels rail)', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(null);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('keeps VOD and series rails without the chevron', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('renders no chevron in the popover presentation, whose footer restores the rail instead', () => {
fixture.componentRef.setInput('section', 'live');
fixture.componentRef.setInput('presentation', 'popover');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('shares the category sort with a second (popover) instance of the panel', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
fixture.componentInstance.setCategorySortMode('name-desc');
const popover = TestBed.createComponent(
WorkspaceContextPanelComponent
);
popover.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'playlist-1',
});
popover.componentRef.setInput('section', 'live');
popover.componentRef.setInput('presentation', 'popover');
popover.detectChanges();
expect(popover.componentInstance.categorySortMode()).toBe(
'name-desc'
);
popover.componentInstance.setCategorySortMode('name-asc');
expect(fixture.componentInstance.categorySortMode()).toBe(
'name-asc'
);
popover.destroy();
});
it('keeps the live-TV column keyboard contract out of the popover', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('#portal-categories')
).not.toBeNull();
fixture.componentRef.setInput('presentation', 'popover');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('#portal-categories')
).toBeNull();
// ArrowRight on the selected category must not reach the
// background channels pane from inside the dialog.
const pane = document.createElement('div');
pane.id = 'live-channels';
pane.tabIndex = -1;
document.body.appendChild(pane);
pane.checkVisibility = () => true;
try {
const selected = fixture.nativeElement.querySelector(
'.category-item[aria-current="true"]'
) as HTMLButtonElement;
selected.focus();
const event = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
});
selected.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(selected);
} finally {
pane.remove();
}
});
it('reports a category selection so a popover host can close', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
const selected = jest.fn();
fixture.componentInstance.categorySelected.subscribe(selected);
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
categoryButtons[1]?.click();
expect(selected).toHaveBeenCalledTimes(1);
});
});
it('preserves the active xtream live item when switching live categories', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
@@ -5,6 +5,7 @@ import {
effect,
inject,
input,
output,
signal,
viewChild,
ElementRef,
@@ -22,9 +23,9 @@ import {
asStalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import {
LiveLayoutSidebarStateService,
PortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
PortalCategorySortStateService,
sortPortalCategoryItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -80,9 +81,49 @@ export class WorkspaceContextPanelComponent {
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
optional: true,
});
private readonly liveSidebarState = inject(LiveLayoutSidebarStateService);
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
/**
* `sidebar` is the in-flow shell rail and offers the "hide categories"
* chevron on live sections; `popover` is the same panel stamped into the
* live categories dropdown while that rail is folded, where the chevron
* would be meaningless (the popover's own footer restores the rail).
*/
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
/** A category was picked; the popover host closes on it. */
readonly categorySelected = output<void>();
readonly isLiveSection = computed(() => {
const section = this.section();
return (
(this.context().provider === 'xtreams' && section === 'live') ||
(this.context().provider === 'stalker' &&
(section === 'itv' || section === 'radio'))
);
});
// Only while a category is selected: that is when the live layout
// renders the channels rail whose header offers the way back. Not in the
// open phone drawer either: its stylesheet ignores the folded state, so
// the tap would change nothing visible while persisting a preference
// that only bites once the window is wide again.
readonly canHideCategories = computed(
() =>
this.presentation() === 'sidebar' &&
this.isLiveSection() &&
!this.contextDrawer?.isOpen() &&
(this.context().provider === 'xtreams'
? this.xtreamSelectedCategoryId() !== null
: !!this.stalkerSelectedCategoryId())
);
private readonly hideCategoriesButton = viewChild(
'hideCategoriesButton',
{ read: ElementRef<HTMLElement> }
);
private readonly firstHeaderAction = viewChild('firstHeaderAction', {
read: ElementRef<HTMLElement>,
});
readonly isXtreamCategories = computed(
() =>
@@ -212,9 +253,9 @@ export class WorkspaceContextPanelComponent {
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isSearchOpen = signal(false);
readonly categorySearchTerm = signal('');
readonly categorySortMode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
// Shared with the popover copy of this panel; see the service's note.
private readonly categorySort = inject(PortalCategorySortStateService);
readonly categorySortMode = this.categorySort.mode;
readonly categorySortLabelKey = computed(() =>
this.getCategorySortLabelKey(this.categorySortMode())
);
@@ -333,6 +374,22 @@ export class WorkspaceContextPanelComponent {
});
}
/**
* The control the shell sidebar hands focus to once this rail has
* unfolded (the button the user activated to bring it back is gone by
* then): the hide chevron when it is offered, else the first header
* action. `null` while neither is rendered — categories still loading,
* or a failed Stalker load — and the sidebar falls back to the rail
* itself.
*/
focusTarget(): HTMLElement | null {
return (
this.hideCategoriesButton()?.nativeElement ??
this.firstHeaderAction()?.nativeElement ??
null
);
}
toggleCategorySearch(): void {
const opening = !this.isSearchOpen();
this.isSearchOpen.set(opening);
@@ -352,8 +409,7 @@ export class WorkspaceContextPanelComponent {
}
setCategorySortMode(mode: PortalCategorySortMode): void {
this.categorySortMode.set(mode);
persistPortalCategorySortMode(mode);
this.categorySort.setMode(mode);
}
openManageCategories(): void {
@@ -398,6 +454,10 @@ export class WorkspaceContextPanelComponent {
);
}
hideCategories(): void {
this.liveSidebarState.hideCategories('portal');
}
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
if (!this.isXtreamCategoryInteractionEnabled()) {
return;
@@ -415,6 +475,7 @@ export class WorkspaceContextPanelComponent {
}
const categoryId = numericCategoryId;
this.contextDrawer?.close();
this.categorySelected.emit();
if (section === 'live') {
this.xtreamStore.setSelectedCategory(categoryId);
@@ -456,6 +517,7 @@ export class WorkspaceContextPanelComponent {
const categoryId = String(item.category_id ?? '*');
this.contextDrawer?.close();
this.categorySelected.emit();
this.stalkerStore.setSelectedCategory(categoryId);
this.stalkerStore.setPage(0);
@@ -0,0 +1,22 @@
@if (context(); as context) {
@if (section(); as section) {
<app-workspace-context-panel
class="popover-panel"
presentation="popover"
[context]="context"
[section]="section"
(categorySelected)="onCategorySelected()"
/>
}
}
<div class="popover-footer">
<button
type="button"
class="popover-footer__action"
data-test-id="live-categories-popover-show-panel"
(click)="showCategoriesPanel()"
>
<mat-icon>chevron_right</mat-icon>
<span>{{ 'LAYOUT.SHOW_CATEGORIES_PANEL' | translate }}</span>
</button>
</div>
@@ -0,0 +1,61 @@
// Height-capped by the overlay to the space below the anchor: the embedded
// panel's own list scrolls, header and footer stay put.
:host {
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
width: 300px;
padding: 8px 6px 6px;
border-radius: 14px;
// Widget surface, one step above the channels rail it floats over, plus a
// hairline so the two do not merge where their edges meet.
background: var(--app-widget-bg, var(--mat-sys-surface-container));
outline: 1px solid var(--mat-sys-outline-variant);
box-shadow: 0 16px 40px -16px rgba(0, 0, 0, 0.5);
color: var(--mat-sys-on-surface);
}
.popover-panel {
flex: 1 1 auto;
min-height: 0;
}
.popover-footer {
flex: none;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--mat-sys-outline-variant);
}
.popover-footer__action {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-primary);
font: inherit;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
text-align: left;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
}
@@ -0,0 +1,72 @@
import { CdkTrapFocus } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
Component,
inject,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
/**
* The live categories rail stamped into a popover while that rail is folded
* (`LiveSidebarState` `categories-hidden`). It renders the very same
* `WorkspaceContextPanelComponent` the shell sidebar uses, so search, sort,
* counts, selection and the category click handlers stay one implementation;
* only the presentation differs (no hide chevron, a footer that restores the
* rail). Opened by `WorkspaceLiveCategoriesPopoverService`.
*
* The trigger advertises `aria-haspopup="dialog"`, so the host is that
* dialog: it carries the role and label, and `CdkTrapFocus` (a host
* directive) moves focus into the panel on open and keeps Tab inside it
* while the backdrop hides the page underneath. The service returns focus
* to the trigger on close.
*/
@Component({
selector: 'app-workspace-live-categories-popover',
imports: [MatIcon, TranslatePipe, WorkspaceContextPanelComponent],
templateUrl: './workspace-live-categories-popover.component.html',
styleUrl: './workspace-live-categories-popover.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [CdkTrapFocus],
host: {
role: 'dialog',
'aria-modal': 'true',
'[attr.aria-label]': 'dialogLabel',
},
})
export class WorkspaceLiveCategoriesPopoverComponent {
private readonly routeState = inject(WorkspaceShellRouteStateService);
// The shell-provided popover bridge that hosts this component.
private readonly popover = inject(LIVE_CATEGORIES_POPOVER);
readonly dialogLabel = inject(TranslateService).instant(
'LAYOUT.CHOOSE_CATEGORY'
);
constructor() {
// Host directive inputs cannot be bound from a portal-created
// component, so the trap is configured here; the directive's own
// AfterContentInit runs after this constructor and captures focus.
const focusTrap = inject(CdkTrapFocus, { self: true });
focusTrap.enabled = true;
focusTrap.autoCapture = true;
}
readonly context = this.routeState.currentContext;
readonly section = this.routeState.currentSection;
/** Fired when the popover wants to be closed by its host. */
readonly closed = output<void>();
onCategorySelected(): void {
this.closed.emit();
}
showCategoriesPanel(): void {
// Restores the rail (and opens the phone drawer where the rail is
// one); the service closes this popover as part of it.
this.popover.showCategoriesPanel();
}
}
@@ -0,0 +1,286 @@
import { OverlayModule } from '@angular/cdk/overlay';
import {
ApplicationRef,
Component,
input,
output,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { Subject, of } from 'rxjs';
import {
LIVE_CATEGORIES_POPOVER,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
import { WorkspaceLiveCategoriesPopoverService } from './workspace-live-categories-popover.service';
@Component({
selector: 'app-workspace-context-panel',
template: `<div data-test-id="stub-context-panel">{{ section() }}</div>
<button data-test-id="stub-pick" (click)="categorySelected.emit()">
pick
</button>`,
standalone: true,
})
class StubWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
readonly categorySelected = output<void>();
}
describe('WorkspaceLiveCategoriesPopoverService', () => {
let service: WorkspaceLiveCategoriesPopoverService;
let sidebarState: LiveLayoutSidebarStateService;
let origin: HTMLButtonElement;
let routerEvents: Subject<unknown>;
const drawer = { open: jest.fn(), close: jest.fn(), isOpen: () => false };
let phoneViewport = false;
// Portal-attached views render on the next application tick; no fixture
// drives change detection here.
function open(): void {
service.open(origin);
TestBed.inject(ApplicationRef).tick();
}
function pane(): HTMLElement | null {
return document.querySelector(
'.workspace-live-categories-popover-pane'
);
}
beforeEach(async () => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
routerEvents = new Subject();
drawer.open.mockClear();
phoneViewport = false;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string) => ({
matches: query.includes('640') && phoneViewport,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}),
});
await TestBed.configureTestingModule({
imports: [OverlayModule, NoopAnimationsModule],
providers: [
WorkspaceLiveCategoriesPopoverService,
{
provide: LIVE_CATEGORIES_POPOVER,
useExisting: WorkspaceLiveCategoriesPopoverService,
},
{ provide: WorkspaceShellContextDrawerService, useValue: drawer },
{
provide: Router,
useValue: { events: routerEvents.asObservable() },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentContext: signal({
provider: 'xtreams',
playlistId: 'pl-1',
}),
currentSection: signal('live'),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
})
.overrideComponent(WorkspaceLiveCategoriesPopoverComponent, {
remove: {
imports: [WorkspaceContextPanelComponent, TranslatePipe],
},
add: {
imports: [
StubWorkspaceContextPanelComponent,
MockPipe(TranslatePipe, (value: string) => value),
],
},
})
.compileComponents();
service = TestBed.inject(WorkspaceLiveCategoriesPopoverService);
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
sidebarState.hideCategories('portal');
origin = document.createElement('button');
document.body.appendChild(origin);
});
afterEach(() => {
service.close();
origin.remove();
sidebarState.setState('portal', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('opens the categories panel in popover presentation under the origin', () => {
open();
const panel = pane()?.querySelector(
'[data-test-id="stub-context-panel"]'
);
expect(panel).not.toBeNull();
expect(panel?.textContent).toContain('live');
expect(origin.getAttribute('aria-expanded')).toBe('true');
});
it('toggles closed when opened again from the same origin', () => {
open();
open();
expect(pane()).toBeNull();
expect(origin.getAttribute('aria-expanded')).toBe('false');
});
it('restores the rail from the footer and closes', () => {
open();
(
pane()?.querySelector(
'[data-test-id="live-categories-popover-show-panel"]'
) as HTMLButtonElement
).click();
expect(sidebarState.stateOf('portal')()).toBe('expanded');
expect(pane()).toBeNull();
expect(drawer.open).not.toHaveBeenCalled();
});
it('opens the phone context drawer as well, since the rail is that drawer there', () => {
phoneViewport = true;
open();
service.showCategoriesPanel();
expect(sidebarState.stateOf('portal')()).toBe('expanded');
expect(pane()).toBeNull();
expect(drawer.open).toHaveBeenCalledTimes(1);
});
it('closes after a category selection', () => {
open();
(
pane()?.querySelector(
'[data-test-id="stub-pick"]'
) as HTMLButtonElement
).click();
expect(pane()).toBeNull();
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
});
it('closes on backdrop click and leaves the rail folded', () => {
open();
const backdrop = document.querySelector(
'.cdk-overlay-backdrop'
) as HTMLElement;
expect(backdrop).not.toBeNull();
backdrop.click();
expect(pane()).toBeNull();
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
});
it('is a labelled modal dialog that traps and takes focus on open', async () => {
// JSDOM lays nothing out, so the CDK interactivity checker sees every
// element as invisible and never focuses one; give them geometry.
const geometry = jest
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(10);
try {
open();
// CdkTrapFocus captures focus once the zone settles.
await new Promise((resolve) => setTimeout(resolve));
const host = pane()?.querySelector(
'app-workspace-live-categories-popover'
);
expect(host?.getAttribute('role')).toBe('dialog');
expect(host?.getAttribute('aria-modal')).toBe('true');
expect(host?.getAttribute('aria-label')).toBe(
'LAYOUT.CHOOSE_CATEGORY'
);
expect(
pane()?.querySelectorAll('.cdk-focus-trap-anchor').length
).toBe(2);
expect(host?.contains(document.activeElement)).toBe(true);
service.close();
expect(document.activeElement).toBe(origin);
} finally {
geometry.mockRestore();
}
});
it('closes when a navigation starts, so it cannot float over the next route', () => {
open();
routerEvents.next(new NavigationEnd(1, '/a', '/a'));
expect(pane()).not.toBeNull();
routerEvents.next(new NavigationStart(2, '/b'));
expect(pane()).toBeNull();
});
it('closes on any live-panel level change, e.g. Cmd/Ctrl+B reaching the layout through the dialog', () => {
open();
sidebarState.collapse('portal');
TestBed.inject(ApplicationRef).tick();
expect(pane()).toBeNull();
});
it('does not hand focus back to a trigger inside a folded, inert rail', () => {
const rail = document.createElement('div');
rail.appendChild(origin);
document.body.appendChild(rail);
open();
rail.setAttribute('inert', '');
service.close();
expect(document.activeElement).not.toBe(origin);
rail.remove();
});
it('closes on Escape', () => {
open();
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
expect(pane()).toBeNull();
});
});
@@ -0,0 +1,168 @@
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
DestroyRef,
effect,
inject,
Injectable,
Injector,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationStart, Router } from '@angular/router';
import { filter } from 'rxjs';
import {
LiveCategoriesPopover,
LiveLayoutSidebarStateService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
/**
* Shell-side implementation of `LIVE_CATEGORIES_POPOVER`. Provided on the
* workspace shell so the live layouts rendered in its router outlet can open
* the folded categories rail as a popover under their category dropdown.
*
* One popover at a time: opening from the origin that already owns it closes
* it (toggle), opening from another origin moves it. The transparent backdrop
* and Escape close it, and so does any category selection or the footer's
* "show categories panel" action, which the component reports through
* `closed`. Any router navigation closes it too: the shell (and so this
* service) outlives the child route that owns the trigger, and browser
* Back/Forward or a global shortcut route would otherwise leave the panel
* floating over the destination page, re-rendered for the new route's
* context. So does any live-panel level change: `Cmd/Ctrl+B` reaches the
* layout's document-level handler through the dialog, and the trigger that
* owns this popover is then inside a folded, `inert` rail.
*/
@Injectable()
export class WorkspaceLiveCategoriesPopoverService
implements LiveCategoriesPopover
{
private readonly overlay = inject(Overlay);
private readonly injector = inject(Injector);
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService);
private overlayRef: OverlayRef | null = null;
private origin: HTMLElement | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.close());
inject(Router)
.events.pipe(
filter((event) => event instanceof NavigationStart),
takeUntilDestroyed()
)
.subscribe(() => this.close());
// Seeded by the effect's first run, not at construction: the state
// may move between the two before anything is open.
const sidebarState = this.sidebarState.stateOf('portal');
let previous: string | null = null;
effect(() => {
const next = sidebarState();
const changed = previous !== null && next !== previous;
previous = next;
if (changed) {
untracked(() => this.close());
}
});
}
open(origin: HTMLElement): void {
if (this.overlayRef && this.origin === origin) {
this.close();
return;
}
this.close();
const overlayRef = this.overlay.create({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
panelClass: 'workspace-live-categories-popover-pane',
scrollStrategy: this.overlay.scrollStrategies.reposition(),
positionStrategy: this.overlay
.position()
.flexibleConnectedTo(origin)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 6,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -6,
},
])
.withFlexibleDimensions(true)
.withGrowAfterOpen(true)
.withPush(true)
.withViewportMargin(8),
maxHeight: 'min(480px, calc(100vh - 16px))',
});
this.overlayRef = overlayRef;
this.origin = origin;
const componentRef = overlayRef.attach(
new ComponentPortal(
WorkspaceLiveCategoriesPopoverComponent,
null,
this.injector
)
);
componentRef.instance.closed.subscribe(() => this.close());
overlayRef.backdropClick().subscribe(() => this.close());
overlayRef.keydownEvents().subscribe((event) => {
if (event.key === 'Escape' && !event.defaultPrevented) {
event.preventDefault();
this.close();
}
});
origin.setAttribute('aria-expanded', 'true');
}
showCategoriesPanel(): void {
this.close();
this.sidebarState.showCategories('portal');
// At phone widths the rail renders as the off-canvas context drawer,
// whose open state the fold level does not drive.
if (isPhoneViewport()) {
this.contextDrawer.open();
}
}
close(): void {
const overlayRef = this.overlayRef;
const origin = this.origin;
this.overlayRef = null;
this.origin = null;
origin?.setAttribute('aria-expanded', 'false');
overlayRef?.dispose();
// Focus returns to the trigger that opened the popover so keyboard
// users continue where they acted instead of dropping to <body> —
// unless that trigger sits in a rail that folded meanwhile; then the
// layout's own handoff picks focus up.
if (
origin &&
origin.isConnected &&
overlayRef &&
!origin.closest('[inert]')
) {
origin.focus();
}
}
}
/** Same breakpoint as the shell's phone drawer styles (≤640px). */
function isPhoneViewport(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(max-width: 640px)').matches
);
}
@@ -31,10 +31,12 @@
@if (context(); as context) {
@if (section(); as section) {
<aside
#routePanel
tabindex="-1"
cdkFocusInitial
class="context-panel context-panel--route"
[class.context-panel--collapsed]="isContextPanelCollapsed()"
[attr.inert]="isContextPanelInert() ? '' : null"
appResizable
[minWidth]="240"
[maxWidth]="560"
@@ -42,6 +44,7 @@
storageKey="sidebar-width"
>
<app-workspace-context-panel
#contextPanel
[context]="context"
[section]="section"
/>
@@ -31,8 +31,11 @@
.context-panel--collapsed {
width: 0 !important;
min-width: 0 !important;
padding-left: 0;
padding-right: 0;
// !important: the `--route` / `--sources` variants set their padding
// later in the cascade at equal specificity, and 22px of padding left a
// visible empty strip beside the channels rail while "collapsed".
padding-left: 0 !important;
padding-right: 0 !important;
border-right-color: transparent;
.resize-handle {
@@ -1,13 +1,12 @@
import {
Component,
Directive,
input,
} from '@angular/core';
import { Component, Directive, input, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
@@ -30,6 +29,7 @@ class MockResizableDirective {
class MockWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
readonly focusTarget = jest.fn<HTMLElement | null, []>(() => null);
}
@Component({
@@ -56,16 +56,30 @@ class MockWorkspaceSourcesFiltersPanelComponent {}
describe('WorkspaceShellContextSidebarComponent', () => {
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
let liveSidebarService: LiveLayoutSidebarStateService;
const xtreamSelectedCategoryId = signal<number | null>(1);
const stalkerSelectedCategoryId = signal<string | null>('7');
const drawerOpen = signal(false);
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state:portal');
xtreamSelectedCategoryId.set(1);
stalkerSelectedCategoryId.set('7');
drawerOpen.set(false);
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
providers: [
{
provide: WorkspaceShellContextDrawerService,
useValue: { close: jest.fn() },
useValue: { close: jest.fn(), isOpen: drawerOpen },
},
{
provide: XtreamStore,
useValue: { selectedCategoryId: xtreamSelectedCategoryId },
},
{
provide: StalkerStore,
useValue: { selectedCategoryId: stalkerSelectedCategoryId },
},
{
provide: TranslateService,
@@ -181,6 +195,100 @@ describe('WorkspaceShellContextSidebarComponent', () => {
);
});
it('folds the categories rail on the first level already (categories-hidden)', () => {
setupLiveCategory('live');
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('keeps the rail on the live root (no selected category) at categories-hidden, since no channels rail exists to host the way back', () => {
xtreamSelectedCategoryId.set(null);
setupLiveCategory('live');
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
false
);
// Player-only still folds it: the floating restore handle lives
// in the content area and stays reachable there.
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('takes the folded rail out of the focus order with inert, except inside the open phone drawer', () => {
setupLiveCategory('live');
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(true);
drawerOpen.set(true);
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(false);
drawerOpen.set(false);
liveSidebarService.setState('portal', 'expanded');
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(false);
});
it('hands focus to the panel whenever the rail actually unfolds, including from player-only on the live root', async () => {
xtreamSelectedCategoryId.set(null);
liveSidebarService.setState('portal', 'categories-hidden');
setupLiveCategory('live');
const panel = fixture.debugElement.query(
By.directive(MockWorkspaceContextPanelComponent)
).componentInstance as MockWorkspaceContextPanelComponent;
(document.activeElement as HTMLElement | null)?.blur();
// Root at categories-hidden: the rail is visible, no transition.
liveSidebarService.collapse('portal');
fixture.detectChanges();
liveSidebarService.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
// The panel offered no control (categories not loaded), so the
// rail itself takes focus.
expect(panel.focusTarget).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('aside.context-panel--route')
);
const control = document.createElement('button');
fixture.nativeElement.appendChild(control);
panel.focusTarget.mockReturnValue(control);
(document.activeElement as HTMLElement | null)?.blur();
liveSidebarService.collapse('portal');
fixture.detectChanges();
liveSidebarService.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(control);
});
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
@@ -2,16 +2,22 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
focusIfFocusLost,
LiveLayoutSidebarStateService,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
WorkspaceShellContextDrawerService,
WorkspacePortalContext,
@@ -47,6 +53,8 @@ export class WorkspaceShellContextSidebarComponent {
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
// Root-provided; optional keeps standalone unit tests light. Only relevant
// when the sidebar renders as the phone drawer — the close button that
// calls this is CSS-hidden above the phone breakpoint.
@@ -67,11 +75,82 @@ export class WorkspaceShellContextSidebarComponent {
LIVE_SECTIONS.has(section)
);
});
/**
* A live category is selected, so the layout renders a channels rail
* whose header carries the way back to the folded categories rail (the
* category dropdown + show chevron). On the live root there is no such
* rail, and folding the categories there would leave no way back.
*/
readonly hasLiveCategorySelection = computed(() => {
const provider = this.context()?.provider;
if (provider === 'xtreams') {
return this.xtreamStore.selectedCategoryId() !== null;
}
if (provider === 'stalker') {
return !!this.stalkerStore.selectedCategoryId();
}
return false;
});
// The categories rail is the outermost panel, so it folds on the first
// collapse level already (`categories-hidden`, gated on a channels rail
// existing to host the way back), and always on `collapsed`.
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsedFor('portal')()
(this.liveSidebarStateService.isCollapsedFor('portal')() ||
(this.liveSidebarStateService.areCategoriesHiddenFor(
'portal'
)() &&
this.hasLiveCategorySelection()))
);
/**
* A folded rail is only 0px wide; its search, sort and category buttons
* would still take Tab stops and screen-reader focus. `inert` removes
* them from both. Not while the panel renders as the open phone drawer,
* whose stylesheet ignores the folded state.
*/
readonly isContextPanelInert = computed(
() =>
this.isContextPanelCollapsed() && !this.contextDrawer?.isOpen()
);
// By template ref, not class token, so a spec's stand-in panel is found
// the same way the real one is.
private readonly contextPanel =
viewChild<Pick<WorkspaceContextPanelComponent, 'focusTarget'>>(
'contextPanel'
);
private readonly routePanel = viewChild('routePanel', {
read: ElementRef<HTMLElement>,
});
constructor() {
// Unfolding the rail removes the control the user activated (the
// layout's show-categories button or the floating restore handle),
// so focus drops to <body>; the rail picks it up once rendered. This
// watches the rail's ACTUAL fold state rather than the raw level:
// on the live root the rail stays visible at `categories-hidden`,
// and only `collapsed` ↔ visible is a real transition there. The
// panel names its control; with none rendered (categories loading,
// a failed load) the `tabindex="-1"` aside itself takes focus, so a
// keyboard user always lands inside the rail.
// Seeded on the first run: the required inputs are not readable in
// the constructor yet.
let wasCollapsed: boolean | null = null;
effect(() => {
const collapsed = this.isContextPanelCollapsed();
const unfolded = wasCollapsed === true && !collapsed;
wasCollapsed = collapsed;
if (unfolded) {
queueMicrotask(() =>
focusIfFocusLost(
this.contextPanel()?.focusTarget() ??
this.routePanel()?.nativeElement
)
);
}
});
}
closeDrawer(): void {
this.contextDrawer?.close();
@@ -28,7 +28,9 @@ import { WorkspaceShellRouteStateService } from './services/workspace-shell-rout
import { WorkspaceShellSearchSyncService } from './services/workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './services/workspace-shell-search.service';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
import { WorkspaceLiveCategoriesPopoverService } from '../workspace-live-categories-popover/workspace-live-categories-popover.service';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-workspace-shell',
@@ -55,6 +57,12 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
WorkspaceKeyboardShortcutsService,
// The live layouts inside the router outlet open the folded categories
// rail through this token; see `LiveCategoriesPopover`.
{
provide: LIVE_CATEGORIES_POPOVER,
useClass: WorkspaceLiveCategoriesPopoverService,
},
],
})
export class WorkspaceShellComponent {
@@ -21,6 +21,18 @@ describe('WorkspaceShellContextDrawerService', () => {
service = TestBed.inject(WorkspaceShellContextDrawerService);
});
it('opens on demand and stays open until closed', () => {
const service = TestBed.inject(WorkspaceShellContextDrawerService);
service.open();
expect(service.isOpen()).toBe(true);
service.open();
expect(service.isOpen()).toBe(true);
service.close();
expect(service.isOpen()).toBe(false);
});
it('starts closed', () => {
expect(service.isOpen()).toBe(false);
});
@@ -67,6 +67,14 @@ export class WorkspaceShellContextDrawerService {
this.openState.update((open) => !open);
}
/**
* Opens the drawer. Only meaningful at phone widths; on wider viewports
* the panel is in flow and the styles ignore this state.
*/
open(): void {
this.openState.set(true);
}
close(): void {
this.openState.set(false);
}