mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
1 parent
0dcfba7045
commit
0a2373f192
68 files changed
+2710
-197
No files matched your search
+5
-2
@@ -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
|
||||
"
|
||||
|
||||
+18
-1
@@ -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>);
|
||||
|
||||
|
||||
+20
@@ -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()"
|
||||
|
||||
+193
-7
@@ -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');
|
||||
|
||||
+69
-7
@@ -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);
|
||||
|
||||
|
||||
+22
@@ -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>
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+286
@@ -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();
|
||||
});
|
||||
});
|
||||
+168
@@ -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
|
||||
);
|
||||
}
|
||||
+3
@@ -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"
|
||||
/>
|
||||
|
||||
+5
-2
@@ -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 {
|
||||
|
||||
+114
-6
@@ -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');
|
||||
|
||||
|
||||
+80
-1
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user