feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)

## Summary

Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:

1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.

Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.

## Design notes

- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.

## Merged with #1555 (per-surface rail state)

#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.

## Also fixed along the way

- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).



Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-06 14:42:59 +02:00
1 parent 0dcfba7045
commit 0a2373f192
68 files changed
+2710 -197

No files matched your search

+4
View File
@@ -16,6 +16,10 @@ export * from './lib/portal-collection-items';
export * from './lib/live-epg-panel-state';
export * from './lib/live-sidebar-state';
export * from './lib/live-layout-sidebar-state.service';
export * from './lib/live-categories-popover.token';
export * from './lib/focus-handoff';
export * from './lib/live-panels-controller';
export * from './lib/category-sort-state.service';
export * from './lib/keyboard';
export * from './lib/keyboard-shortcuts';
export * from './lib/remote-channel-navigation';
@@ -0,0 +1,26 @@
import { Injectable, signal } from '@angular/core';
import {
PortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
} from './category-sort';
/**
* The portal categories sort preference, shared by every rendered category
* list. The workspace context panel is stamped twice while the categories
* rail is folded (the in-flow rail stays mounted, the popover shows a second
* copy), and a per-instance signal seeded from storage would let a sort
* picked in the popover revert the moment the rail comes back.
*/
@Injectable({ providedIn: 'root' })
export class PortalCategorySortStateService {
private readonly _mode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
readonly mode = this._mode.asReadonly();
setMode(mode: PortalCategorySortMode): void {
this._mode.set(mode);
persistPortalCategorySortMode(mode);
}
}
@@ -0,0 +1,50 @@
import { effect, Signal, untracked } from '@angular/core';
import { LiveSidebarState } from './live-sidebar-state';
/**
* Moves focus to `element` only when the document has none to speak of:
* the previously focused control was removed or made `inert`, so focus
* already fell back to `<body>`. A control that still owns focus (a mouse
* user's category row, a keyboard user's search box) is left alone.
*
* Used by the live-TV panel toggles: hiding a rail removes the button that
* was just activated, and the replacement affordance lives in another
* component, so the receiving side picks focus up after its next render.
*/
export function focusIfFocusLost(
element: HTMLElement | null | undefined
): void {
if (!element || !element.isConnected) {
return;
}
const active = document.activeElement;
const focusLost =
!active || active === document.body || active.closest('[inert]') !== null;
if (focusLost) {
element.focus();
}
}
/**
* Installs the focus handoff for a live layout's panel toggles. Must run in
* an injection context (a component constructor). After every level change
* and the render it causes, `targetFor(next)` names the affordance that now
* stands in for the button the user activated — the floating restore handle
* at player-only, the show-categories button while the rail is folded, and
* nothing when the categories rail itself is back (its own component picks
* focus up then) — and focus moves there only if it was lost.
*/
export function handoffFocusOnLiveSidebarChange(
state: Signal<LiveSidebarState>,
targetFor: (next: LiveSidebarState) => HTMLElement | null | undefined
): void {
let previous = untracked(state);
effect(() => {
const next = state();
if (next === previous) {
return;
}
previous = next;
queueMicrotask(() => focusIfFocusLost(untracked(() => targetFor(next))));
});
}
@@ -0,0 +1,33 @@
import { InjectionToken } from '@angular/core';
/**
* Opens the live categories list as a popover anchored to an element.
*
* While the categories rail is folded (`LiveLayoutSidebarStateService.
* areCategoriesHidden`), the channels header turns its category title into a
* dropdown so switching categories stays one click away. The rail itself is
* shell-owned (`WorkspaceContextPanelComponent`), which the portal feature
* libs cannot import, so the workspace shell provides this token and the live
* layouts inject it optionally: without a provider (unit tests, a host that
* renders no categories rail) the dropdown is simply not offered.
*/
export interface LiveCategoriesPopover {
/**
* Opens the popover below `origin`, or closes it when it is already open
* for that origin.
*/
open(origin: HTMLElement): void;
/** Closes the popover if it is open. */
close(): void;
/**
* Brings the categories rail back (level 1) the way the current viewport
* shows it: on desktop the in-flow rail unfolds; at phone widths the rail
* is the shell's off-canvas drawer, whose open state is separate from
* the fold level, so the drawer is opened as well. Closes the popover.
*/
showCategoriesPanel(): void;
}
export const LIVE_CATEGORIES_POPOVER = new InjectionToken<LiveCategoriesPopover>(
'LIVE_CATEGORIES_POPOVER'
);
@@ -73,6 +73,68 @@ describe('LiveLayoutSidebarStateService', () => {
);
});
it('folds only the categories rail on hideCategories', () => {
const service = createService();
service.hideCategories('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
expect(service.isCollapsedFor('portal')()).toBe(false);
expect(service.areCategoriesHiddenFor('m3u')()).toBe(false);
expect(
localStorage.getItem(liveSidebarStateStorageKey('portal'))
).toBe('categories-hidden');
service.showCategories('portal');
expect(service.stateOf('portal')()).toBe('expanded');
});
it('collapse folds both rails and expand returns to the level it was entered from', () => {
const service = createService();
service.hideCategories('portal');
service.collapse('portal');
expect(service.isCollapsedFor('portal')()).toBe(true);
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
service.collapse('portal');
service.expand('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('toggle leaves collapsed for the previous level and otherwise collapses', () => {
const service = createService();
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('collapsed');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('expanded');
service.hideCategories('portal');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('collapsed');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('restores a hidden categories rail from its surface key and comes back to it after a toggle', () => {
localStorage.setItem(
liveSidebarStateStorageKey('portal'),
'categories-hidden'
);
const service = createService();
expect(service.stateOf('portal')()).toBe('categories-hidden');
service.toggle('portal');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('ignores and removes the legacy shared key instead of inheriting it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
@@ -34,6 +34,11 @@ function bySurface<T>(
*
* State is kept per surface (`live-sidebar-state:<surface>` in
* localStorage). The pre-split shared key is forgotten on construction.
*
* The three levels nest (see `LiveSidebarState`). Consumers read one of two
* derived flags rather than the raw state: the categories rail folds on
* `areCategoriesHiddenFor(surface)`, the channels rail only on
* `isCollapsedFor(surface)`.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
@@ -42,6 +47,18 @@ export class LiveLayoutSidebarStateService {
WritableSignal<LiveSidebarState>
>;
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
private readonly categoriesHidden: Record<
LiveSidebarSurface,
Signal<boolean>
>;
/**
* The level to come back to when leaving `collapsed`, per surface.
* Session-only on purpose: a restart restores the stored level itself.
*/
private readonly restoreTargets: Record<
LiveSidebarSurface,
Exclude<LiveSidebarState, 'collapsed'>
>;
constructor() {
forgetLegacyLiveSidebarState();
@@ -51,6 +68,14 @@ export class LiveLayoutSidebarStateService {
this.collapsed = bySurface((surface) =>
computed(() => this.states[surface]() === 'collapsed')
);
this.categoriesHidden = bySurface((surface) =>
computed(() => this.states[surface]() !== 'expanded')
);
// A restored `categories-hidden` is what Cmd/Ctrl+B comes back to.
this.restoreTargets = bySurface((surface) => {
const restored = this.states[surface]();
return restored === 'collapsed' ? 'expanded' : restored;
});
}
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
@@ -62,14 +87,49 @@ export class LiveLayoutSidebarStateService {
return this.collapsed[surface];
}
/**
* The categories rail is folded (`categories-hidden` or `collapsed`).
* Stable per-surface signal.
*/
areCategoriesHiddenFor(surface: LiveSidebarSurface): Signal<boolean> {
return this.categoriesHidden[surface];
}
/**
* `Cmd/Ctrl+B`, the header toggle and the floating restore handle: leave
* `collapsed` for the level the user collapsed from, otherwise collapse
* everything.
*/
toggle(surface: LiveSidebarSurface): void {
this.setState(
surface,
this.collapsed[surface]() ? 'expanded' : 'collapsed'
);
if (this.collapsed[surface]()) {
this.expand(surface);
} else {
this.collapse(surface);
}
}
/** Player only. Remembers the current level for `expand()`. */
collapse(surface: LiveSidebarSurface): void {
this.setState(surface, 'collapsed');
}
/** Leaves `collapsed` for the level it was entered from. */
expand(surface: LiveSidebarSurface): void {
this.setState(surface, this.restoreTargets[surface]);
}
hideCategories(surface: LiveSidebarSurface): void {
this.setState(surface, 'categories-hidden');
}
showCategories(surface: LiveSidebarSurface): void {
this.setState(surface, 'expanded');
}
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
if (state !== 'collapsed') {
this.restoreTargets[surface] = state;
}
this.states[surface].set(state);
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
}
@@ -0,0 +1,129 @@
import { ElementRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { createLivePanelsController } from './live-panels-controller';
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import { liveSidebarStateStorageKey } from './live-sidebar-state';
describe('LivePanelsController', () => {
const popover = {
open: jest.fn(),
close: jest.fn(),
showCategoriesPanel: jest.fn(),
};
const hasSelectedCategory = signal(true);
let showButton: HTMLButtonElement;
let restoreButton: HTMLButtonElement;
function create(withPopover = true) {
TestBed.configureTestingModule({
providers: withPopover
? [{ provide: LIVE_CATEGORIES_POPOVER, useValue: popover }]
: [],
});
return TestBed.runInInjectionContext(() =>
createLivePanelsController({
surface: 'portal',
hasSelectedCategory,
showCategoriesButton: signal(new ElementRef(showButton)),
restoreButton: signal(new ElementRef(restoreButton)),
})
);
}
async function settle(): Promise<void> {
TestBed.flushEffects();
await new Promise((resolve) => queueMicrotask(resolve));
}
beforeEach(() => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
popover.open.mockClear();
popover.close.mockClear();
popover.showCategoriesPanel.mockClear();
hasSelectedCategory.set(true);
showButton = document.createElement('button');
restoreButton = document.createElement('button');
document.body.append(showButton, restoreButton);
});
afterEach(() => {
showButton.remove();
restoreButton.remove();
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('offers the dropdown only with a provider, a folded rail and a selected category', () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
expect(controller.canOpenCategoriesPopover()).toBe(false);
state.hideCategories('portal');
expect(controller.canOpenCategoriesPopover()).toBe(true);
expect(controller.effectiveLevel()).toBe('categories-hidden');
hasSelectedCategory.set(false);
expect(controller.canOpenCategoriesPopover()).toBe(false);
expect(controller.effectiveLevel()).toBe('expanded');
state.collapse('portal');
expect(controller.effectiveLevel()).toBe('collapsed');
});
it('keeps the plain heading without a popover provider', () => {
const controller = create(false);
TestBed.inject(LiveLayoutSidebarStateService).hideCategories('portal');
expect(controller.canOpenCategoriesPopover()).toBe(false);
controller.openCategoriesPopover(showButton);
controller.showCategories();
expect(TestBed.inject(LiveLayoutSidebarStateService).stateOf('portal')()).toBe(
'expanded'
);
});
it('routes the actions through the shared state and the popover', () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
controller.openCategoriesPopover(showButton);
expect(popover.open).toHaveBeenCalledWith(showButton);
controller.collapsePanels();
expect(state.stateOf('portal')()).toBe('collapsed');
controller.toggleSidebar();
expect(state.stateOf('portal')()).toBe('expanded');
state.hideCategories('portal');
controller.showCategories();
// The shell restores the rail (and opens the phone drawer if the
// rail is one); the controller only delegates.
expect(popover.showCategoriesPanel).toHaveBeenCalledTimes(1);
expect(state.stateOf('portal')()).toBe('categories-hidden');
});
it('hands focus across level changes, including a fold by category selection', async () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
hasSelectedCategory.set(false);
state.hideCategories('portal');
await settle();
(document.activeElement as HTMLElement | null)?.blur();
// Live root: selecting the first category folds the rail.
hasSelectedCategory.set(true);
await settle();
expect(document.activeElement).toBe(showButton);
(document.activeElement as HTMLElement | null)?.blur();
controller.collapsePanels();
await settle();
expect(document.activeElement).toBe(restoreButton);
(document.activeElement as HTMLElement | null)?.blur();
controller.toggleSidebar();
await settle();
expect(document.activeElement).toBe(showButton);
});
});
@@ -0,0 +1,116 @@
import {
computed,
ElementRef,
inject,
Signal,
untracked,
} from '@angular/core';
import { handoffFocusOnLiveSidebarChange } from './focus-handoff';
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import { LiveSidebarState, LiveSidebarSurface } from './live-sidebar-state';
export interface LivePanelsControllerOptions {
/** The surface whose per-surface state this layout renders. */
readonly surface: LiveSidebarSurface;
/**
* A live category is selected, so the layout renders a channels rail
* whose header can host the category dropdown and the show-categories
* button. Mirrors the shell's fold rule: on the live root the categories
* rail stays visible at `categories-hidden`.
*/
readonly hasSelectedCategory: Signal<boolean>;
/** The header's show-categories button, once rendered. */
readonly showCategoriesButton: Signal<ElementRef<HTMLElement> | undefined>;
/** The floating restore handle, once rendered at player-only. */
readonly restoreButton: Signal<ElementRef<HTMLElement> | undefined>;
}
/**
* The live-TV panel behaviour shared by the Xtream and Stalker live layouts:
* the nested fold levels read from `LiveLayoutSidebarStateService`, the
* category dropdown bridge to the shell's `LIVE_CATEGORIES_POPOVER`, and the
* focus handoff across level changes. Create it in a field initializer (an
* injection context) with `createLivePanelsController()`.
*/
export class LivePanelsController {
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
// Shell-provided; absent when no categories rail exists to fold (unit
// tests, hosts outside the workspace shell), in which case the header
// keeps its plain title.
private readonly categoriesPopover = inject(LIVE_CATEGORIES_POPOVER, {
optional: true,
});
/** Player only: the channels rail is folded (and `inert`). */
readonly isSidebarCollapsed: Signal<boolean>;
/** The header shows the category dropdown and show-categories button. */
readonly canOpenCategoriesPopover: Signal<boolean>;
/**
* The level the layout actually shows. On the live root the categories
* rail stays visible at `categories-hidden`, and the first category
* selection folds it without a state change — the focus handoff keys on
* this, not on the raw state.
*/
readonly effectiveLevel: Signal<LiveSidebarState>;
constructor(private readonly options: LivePanelsControllerOptions) {
const surface = options.surface;
this.isSidebarCollapsed = this.sidebarState.isCollapsedFor(surface);
const categoriesHidden =
this.sidebarState.areCategoriesHiddenFor(surface);
this.canOpenCategoriesPopover = computed(
() =>
this.categoriesPopover !== null &&
categoriesHidden() &&
this.options.hasSelectedCategory()
);
this.effectiveLevel = computed(() =>
this.isSidebarCollapsed()
? 'collapsed'
: this.canOpenCategoriesPopover()
? 'categories-hidden'
: 'expanded'
);
// Every level change inerts or removes the button the user activated
// and drops focus to <body>; hand it to the affordance that replaces
// it once the layout has rendered (see the helper's contract).
handoffFocusOnLiveSidebarChange(this.effectiveLevel, (next) =>
next === 'collapsed'
? untracked(this.options.restoreButton)?.nativeElement
: next === 'categories-hidden'
? untracked(this.options.showCategoriesButton)?.nativeElement
: null
);
}
/** `Cmd/Ctrl+B` and the floating restore handle. */
toggleSidebar(): void {
this.sidebarState.toggle(this.options.surface);
}
/** The channels header chevron: player only. */
collapsePanels(): void {
this.sidebarState.collapse(this.options.surface);
}
/** Level 1. The shell knows whether the rail is a phone drawer. */
showCategories(): void {
if (this.categoriesPopover) {
this.categoriesPopover.showCategoriesPanel();
return;
}
this.sidebarState.showCategories(this.options.surface);
}
openCategoriesPopover(origin: HTMLElement): void {
this.categoriesPopover?.open(origin);
}
}
/** Must run in an injection context (a component field initializer). */
export function createLivePanelsController(
options: LivePanelsControllerOptions
): LivePanelsController {
return new LivePanelsController(options);
}
@@ -20,6 +20,7 @@ describe('live sidebar state', () => {
it('accepts only known sidebar states', () => {
expect(isLiveSidebarState('expanded')).toBe(true);
expect(isLiveSidebarState('categories-hidden')).toBe(true);
expect(isLiveSidebarState('collapsed')).toBe(true);
expect(isLiveSidebarState('hidden')).toBe(false);
expect(isLiveSidebarState(null)).toBe(false);
@@ -3,7 +3,20 @@ import type {
PortalRailSection,
} from './navigation/portal-rail-links';
export type LiveSidebarState = 'expanded' | 'collapsed';
/**
* Live-TV panel visibility, nested from the outside in:
*
* - `expanded`: categories rail + channels rail + player.
* - `categories-hidden`: channels rail + player. The categories rail is
* folded away but stays one click off through the channels header's
* category dropdown. Surfaces without a categories rail (`m3u`,
* `collection`) treat it exactly like `expanded`.
* - `collapsed`: player only ("theater").
*
* There is deliberately no "channels hidden, categories visible" state: a
* category click has to bring the channels back anyway.
*/
export type LiveSidebarState = 'expanded' | 'categories-hidden' | 'collapsed';
/**
* Collapsible live-channel rails. Each surface remembers its own state:
@@ -38,7 +51,11 @@ export function liveSidebarStateStorageKey(
}
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
return value === 'expanded' || value === 'collapsed';
return (
value === 'expanded' ||
value === 'categories-hidden' ||
value === 'collapsed'
);
}
export function restoreLiveSidebarState(
@@ -47,7 +47,7 @@ describe('StalkerLiveNavigation', () => {
parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })),
navigateByUrl: jest.fn().mockResolvedValue(true),
};
const sidebar = { setState: jest.fn() };
const sidebar = { setState: jest.fn(), expand: jest.fn() };
const play = jest.fn();
const revealRow = jest.fn(() => true);
@@ -209,7 +209,7 @@ describe('StalkerLiveNavigation', () => {
);
expect(store.selectedCategoryId()).toBe('news');
expect(store.searchPhrase()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(sidebar.expand).toHaveBeenCalledWith('portal');
expect(revealRow).toHaveBeenCalledWith('1');
expect(play).not.toHaveBeenCalled();
expect(navigation.canReveal()).toBe(false);
@@ -266,7 +266,7 @@ export class StalkerLiveNavigation {
this.options.store.setSearchPhrase('');
this.options.store.setSelectedCategory(category);
this.options.store.setPage(0);
this.options.sidebar.setState('portal', 'expanded');
this.options.sidebar.expand('portal');
this.revealed.set({ owner, category, item: active });
this.pendingReveal.set({
owner,
@@ -4,6 +4,7 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[attr.inert]="isSidebarCollapsed() ? '' : null"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -11,22 +12,62 @@
storageKey="live-channels-sidebar-width"
>
<div class="sidebar-header category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ totalChannelCount() }}
{{
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
@if (livePanels.canOpenCategoriesPopover()) {
<!-- The categories rail is folded: the title becomes the way
back to it (a dropdown of the same list) so switching
categories stays one click away. -->
<button
#showCategoriesButton
mat-icon-button
type="button"
class="sidebar-show-categories"
data-test-id="live-show-categories"
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
(click)="livePanels.showCategories()"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button
#categoryDropdown
type="button"
class="category-dropdown"
data-test-id="live-category-dropdown"
aria-haspopup="dialog"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
>
<span class="category-title">
{{ selectedCategoryTitle() }}
</span>
@if (totalChannelCount() > 0) {
<span class="category-count">{{
totalChannelCount()
}}</span>
}
<mat-icon class="category-dropdown__caret"
>expand_more</mat-icon
>
}
</div>
</button>
} @else {
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ totalChannelCount() }}
{{
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
}
@if (navigation.canReveal()) {
<button
mat-icon-button
@@ -57,10 +98,9 @@
<button
mat-icon-button
type="button"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
(click)="livePanels.collapsePanels()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -73,13 +113,13 @@
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
#restoreButton
mat-icon-button
type="button"
class="sidebar-restore"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
(click)="livePanels.toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -143,7 +183,7 @@
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -75,6 +75,7 @@ import {
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
createLivePanelsController,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
createLogger,
@@ -488,8 +489,26 @@ export class StalkerLiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
// `viewChild()` must be a direct field initializer for the compiler to
// register the query, so the refs live here and feed the controller.
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
read: ElementRef<HTMLElement>,
});
private readonly restoreButton = viewChild('restoreButton', {
read: ElementRef<HTMLElement>,
});
// Nested panel levels, category dropdown bridge and focus handoff; the
// template binds to it directly. The sidebar only renders with a
// selected category, and the fold rule keys on the same selection.
readonly livePanels = createLivePanelsController({
surface: 'portal',
hasSelectedCategory: computed(
() => !!this.stalkerStore.selectedCategoryId()
),
showCategoriesButton: this.showCategoriesButton,
restoreButton: this.restoreButton,
});
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -1159,10 +1178,6 @@ export class StalkerLiveStreamLayoutComponent
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle('portal');
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
this.handleAdjacentChannelChange(direction === 'next' ? 'down' : 'up');
}
@@ -1179,7 +1194,7 @@ export class StalkerLiveStreamLayoutComponent
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
this.livePanels.toggleSidebar();
}
}
@@ -167,6 +167,8 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -143,6 +143,8 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -140,6 +140,8 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -109,7 +109,10 @@ describe('Xtream live auto-open playback queue', () => {
provide: FavoritesService,
useValue: { getFavorites: () => of([]) },
},
{ provide: XtreamUrlService, useValue: {} },
{
provide: XtreamUrlService,
useValue: { constructAutoLiveTsUrl: () => undefined },
},
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: () => true },
@@ -5,6 +5,7 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[attr.inert]="isSidebarCollapsed() ? '' : null"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -12,16 +13,56 @@
storageKey="live-channels-sidebar-width"
>
<div class="sidebar-header">
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
@if (livePanels.canOpenCategoriesPopover()) {
<!-- The categories rail is folded: the title becomes the way
back to it (a dropdown of the same list) so switching
categories stays one click away. -->
<button
#showCategoriesButton
mat-icon-button
type="button"
class="sidebar-show-categories"
data-test-id="live-show-categories"
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
(click)="livePanels.showCategories()"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button
#categoryDropdown
type="button"
class="category-dropdown"
data-test-id="live-category-dropdown"
aria-haspopup="dialog"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
>
<span class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</span>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
<mat-icon class="category-dropdown__caret"
>expand_more</mat-icon
>
</button>
} @else {
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
}
@if (channelNavigation.canReveal()) {
<button
mat-icon-button
@@ -46,10 +87,9 @@
<button
mat-icon-button
type="button"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
(click)="livePanels.collapsePanels()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -95,13 +135,13 @@
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
#restoreButton
mat-icon-button
type="button"
class="sidebar-restore"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
(click)="livePanels.toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -171,7 +211,7 @@
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -1153,41 +1153,6 @@ describe('LiveStreamLayoutComponent', () => {
// `app-channel-list-hidden-state` (its TranslatePipe needs a
// TranslateService); this spec is at the max-lines budget, so that branch
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).not.toBeNull();
});
it('hides the floating restore button when the sidebar is expanded', () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).toBeNull();
});
it('persists the channels sidebar width under a dedicated storage key', () => {
// The shell context panel (category sidebar) is visible at the same
// time as this sidebar and persists its width under the shared
// "sidebar-width" key. Reusing that key here makes the two panels
// overwrite each other's stored width across reloads.
selectedCategoryId.set(1);
fixture.detectChanges();
const sidebar: HTMLElement =
fixture.nativeElement.querySelector('.sidebar');
expect(sidebar.getAttribute('storageKey')).toBe(
'live-channels-sidebar-width'
);
});
describe('auto-open from Ctrl+F search navigation state', () => {
const searchChannel = {
xtream_id: 202,
@@ -30,7 +30,7 @@ import {
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
LiveLayoutSidebarStateService,
createLivePanelsController,
PORTAL_PLAYER,
PortalChannelSortMode,
getPortalChannelSortModeLabel,
@@ -149,9 +149,6 @@ export class LiveStreamLayoutComponent
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -322,8 +319,24 @@ export class LiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
// `viewChild()` must be a direct field initializer for the compiler to
// register the query, so the refs live here and feed the controller.
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
read: ElementRef<HTMLElement>,
});
private readonly restoreButton = viewChild('restoreButton', {
read: ElementRef<HTMLElement>,
});
// Nested panel levels, category dropdown bridge and focus handoff; the
// template binds to it directly. Search-only rails (no selected
// category) keep their plain heading.
readonly livePanels = createLivePanelsController({
surface: 'portal',
hasSelectedCategory: computed(() => !!this.selectedCategoryId()),
showCategoriesButton: this.showCategoriesButton,
restoreButton: this.restoreButton,
});
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
this.activeCatchupProgram() ?? this.currentEpgItem()
@@ -361,8 +374,11 @@ export class LiveStreamLayoutComponent
if (!categoryId) return null;
const categories = this.categories();
// Provider categories carry string ids ("101") while the selection
// is numeric; a strict comparison missed every one and the header
// fell back to "Channels", which the category dropdown cannot afford.
const category = categories?.find(
(c) => (c.category_id ?? c.id) === categoryId
(c) => String(c.category_id ?? c.id) === String(categoryId)
);
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
@@ -625,10 +641,6 @@ export class LiveStreamLayoutComponent
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle('portal');
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
@@ -641,7 +653,7 @@ export class LiveStreamLayoutComponent
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
this.livePanels.toggleSidebar();
}
}
@@ -0,0 +1,405 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { Subject, of } from 'rxjs';
import {
LIVE_CATEGORIES_POPOVER,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
ChannelListHiddenStateComponent,
GridListComponent,
} from '@iptvnator/portal/shared/ui';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
/**
* The channels header while the shell's categories rail is folded: the
* category dropdown, the show-categories chevron and the collapse-all
* chevron. Split from `live-stream-layout.component.spec.ts`, which sits at
* the spec line cap; the harness here renders no player, no EPG and no
* channel list.
*/
@Component({
selector: 'app-portal-channels-list',
standalone: true,
template: '',
})
class StubPortalChannelsListComponent {
readonly sortMode = input('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly fullscreenPanelCopy = input(false);
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@Component({
selector: 'app-channel-list-hidden-state',
standalone: true,
template: '<div data-test-id="hidden-state-stub"></div>',
})
class StubChannelListHiddenStateComponent {
readonly restore = output<void>();
}
@Component({ selector: 'app-grid-list', standalone: true, template: '' })
class StubGridListComponent {
readonly items = input<unknown[]>([]);
readonly isLoading = input(false);
readonly searchTerm = input('');
readonly variant = input('poster');
readonly type = input<string>();
readonly itemClicked = output<unknown>();
}
@Component({
selector: 'app-web-player-view, app-epg-timeline, app-epg-list-view',
standalone: true,
template: '',
})
class StubPassiveComponent {}
@Directive({ selector: '[appResizable]', standalone: true })
class StubResizableDirective {}
describe('LiveStreamLayoutComponent sidebar levels', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let service: LiveLayoutSidebarStateService;
const categoriesPopover = {
open: jest.fn(),
close: jest.fn(),
showCategoriesPanel: jest.fn(),
};
const selectedCategoryId = signal<number | null>(1);
const emptyList = signal<unknown[]>([]);
const xtreamStore = {
getCategoriesBySelectedType: signal([
{ category_id: 1, category_name: 'News' },
]),
getCategoryItemCounts: signal(new Map<number, number>([[1, 7]])),
getPaginatedContent: emptyList,
hasMoreContent: signal(false),
epgItems: emptyList,
currentEpgItem: signal(null),
isLoadingEpg: signal(false),
selectedTypeContentLoading: signal(false),
selectedCategoryId,
selectedContentType: signal('live'),
// No selected item: the content area shows the empty state, so the
// header is the only thing under test.
selectedItem: signal(null),
currentPlaylist: signal({ _id: 'pl-1', title: 'Playlist' }),
liveStreams: emptyList,
selectItemsFromSelectedCategory: jest.fn(() => []),
constructStreamUrl: jest.fn(() => ''),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
};
function query<T extends HTMLElement>(selector: string): T | null {
return fixture.nativeElement.querySelector(selector);
}
beforeEach(async () => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
categoriesPopover.open.mockClear();
categoriesPopover.close.mockClear();
categoriesPopover.showCategoriesPanel.mockClear();
selectedCategoryId.set(1);
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
navigate: jest.fn(),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: FavoritesService,
useValue: { getFavorites: jest.fn(() => of([])) },
},
{
provide: XtreamUrlService,
useValue: { resolveCatchupUrl: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsRemoteControl: false,
},
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openExternalPlayback: jest.fn(),
},
},
{ provide: LIVE_CATEGORIES_POPOVER, useValue: categoriesPopover },
],
})
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
ChannelListHiddenStateComponent,
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubChannelListHiddenStateComponent,
StubGridListComponent,
StubPassiveComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
MockPipe(TranslatePipe, (value: string) => value),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
service = TestBed.inject(LiveLayoutSidebarStateService);
service.setState('portal', 'expanded');
});
afterEach(() => {
service.setState('portal', 'expanded');
fixture.destroy();
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('keeps the plain category heading while the rail is expanded', () => {
fixture.detectChanges();
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
expect(query('[data-test-id="live-show-categories"]')).toBeNull();
expect(query('.category-heading')?.textContent).toContain('News');
});
it('keeps the plain heading for a search-only rail with no selected category', () => {
selectedCategoryId.set(null);
service.hideCategories('portal');
fixture.detectChanges();
// No category and no search term: the layout renders no rail at all.
expect(query('.sidebar')).toBeNull();
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
});
it('turns the heading into a category dropdown that opens the shell popover', () => {
service.hideCategories('portal');
fixture.detectChanges();
const dropdown = query<HTMLButtonElement>(
'[data-test-id="live-category-dropdown"]'
);
expect(dropdown).not.toBeNull();
expect(dropdown?.textContent).toContain('News');
expect(dropdown?.textContent).toContain('7');
expect(dropdown?.getAttribute('aria-label')).toBe(
'LAYOUT.CHOOSE_CATEGORY'
);
expect(query('.category-heading')).toBeNull();
dropdown?.click();
expect(categoriesPopover.open).toHaveBeenCalledWith(dropdown);
});
it('asks the shell to restore the rail from the show-categories button', () => {
service.hideCategories('portal');
fixture.detectChanges();
query<HTMLButtonElement>(
'[data-test-id="live-show-categories"]'
)?.click();
expect(categoriesPopover.showCategoriesPanel).toHaveBeenCalledTimes(1);
});
it('collapses both rails from the header chevron and comes back to the same level', () => {
service.hideCategories('portal');
fixture.detectChanges();
query<HTMLButtonElement>(
'.sidebar-header [aria-label="LAYOUT.HIDE_PANELS"]'
)?.click();
fixture.detectChanges();
expect(service.stateOf('portal')()).toBe('collapsed');
expect(query('.sidebar')?.classList).toContain('sidebar-collapsed');
const restore = query<HTMLButtonElement>('.sidebar-restore');
expect(restore?.getAttribute('aria-label')).toBe('LAYOUT.SHOW_PANELS');
restore?.click();
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('picks focus up on the show-categories button when the rail folds and focus was lost', async () => {
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
service.hideCategories('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('leaves focus alone when a control still owns it', async () => {
fixture.detectChanges();
const sort = query<HTMLButtonElement>(
'.sidebar-header [aria-label="Sort channels"]'
);
sort?.focus();
service.hideCategories('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(sort);
});
it('hands focus to the floating restore handle at player-only and back to show-categories on expand', async () => {
service.hideCategories('portal');
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
service.collapse('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(query('.sidebar-restore'));
// The handle is removed with the expand, so focus is lost again.
service.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('hands focus to show-categories when the first category selection folds the rail on the live root', async () => {
selectedCategoryId.set(null);
service.hideCategories('portal');
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
// The category button the user activated is inert now: focus lost.
selectedCategoryId.set(1);
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('shows the floating restore button at player-only even without a selected category, and hides it when expanded', () => {
selectedCategoryId.set(null);
service.collapse('portal');
fixture.detectChanges();
expect(query('.sidebar-restore')).not.toBeNull();
selectedCategoryId.set(1);
service.expand('portal');
fixture.detectChanges();
expect(query('.sidebar-restore')).toBeNull();
});
it('persists the channels sidebar width under a dedicated storage key', () => {
// The shell context panel (category sidebar) is visible at the same
// time as this sidebar and persists its width under the shared
// "sidebar-width" key. Reusing that key here makes the two panels
// overwrite each other's stored width across reloads.
fixture.detectChanges();
expect(query('.sidebar')?.getAttribute('storageKey')).toBe(
'live-channels-sidebar-width'
);
});
it('marks the folded channels rail inert at player-only', () => {
service.collapse('portal');
fixture.detectChanges();
expect(query('.sidebar')?.hasAttribute('inert')).toBe(true);
service.expand('portal');
fixture.detectChanges();
expect(query('.sidebar')?.hasAttribute('inert')).toBe(false);
});
it('answers Cmd/Ctrl+B with the same nested toggle', () => {
service.hideCategories('portal');
fixture.detectChanges();
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'b', ctrlKey: true })
);
expect(service.stateOf('portal')()).toBe('collapsed');
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'b', metaKey: true })
);
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
});
@@ -74,7 +74,7 @@ describe('XtreamLiveChannelNavigationService', () => {
queryParamMap: unknown;
};
const navigate = jest.fn();
const sidebar = { setState: jest.fn() };
const sidebar = { setState: jest.fn(), expand: jest.fn() };
beforeEach(() => {
currentPlaylist.set({ id: 'source-1' });
@@ -187,7 +187,7 @@ describe('XtreamLiveChannelNavigationService', () => {
);
expect(selectedCategoryId()).toBe(10);
expect(categorySearchTerm()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(sidebar.expand).toHaveBeenCalledWith('portal');
expect(service.revealRequest()).toEqual(
expect.objectContaining({ channelId: 1 })
);
@@ -277,6 +277,7 @@ describe('XtreamLiveChannelNavigationService', () => {
await reveal;
expect(store.setSelectedCategory).not.toHaveBeenCalled();
expect(sidebar.setState).not.toHaveBeenCalled();
expect(sidebar.expand).not.toHaveBeenCalled();
expect(service.revealRequest()).toBeNull();
});
});
@@ -232,7 +232,7 @@ export class XtreamLiveChannelNavigationService {
return;
this.store.setCategorySearchTerm('');
this.store.setSelectedCategory(categoryId);
this.sidebar.setState('portal', 'expanded');
this.sidebar.expand('portal');
this.pendingReveal.set({
channelId: target.xtream_id,
categoryId,
+53
View File
@@ -77,5 +77,58 @@
padding: 2px 8px;
border-radius: 999px;
}
// Shown while the shell's categories rail is folded: the way back to
// the rail, in the same spot the rail's own chevron folded it from.
.sidebar-show-categories {
flex-shrink: 0;
margin-left: -6px;
}
// The category title as a dropdown trigger (categories rail folded).
// Same type as the static heading so folding the rail does not move
// the title; the caret is the only added ink.
.category-dropdown {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
height: 32px;
padding: 0 4px 0 8px;
margin-left: -8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
app-region: no-drag;
.category-title {
flex: 0 1 auto;
min-width: 0;
}
&__caret {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&:hover,
&[aria-expanded='true'] {
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
border-color: var(--app-separator);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
}
}
}
@@ -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);
}