feat(ui): add workspace groups disclosure

This commit is contained in:
4gray committed 2026-07-31 09:02:28 +02:00
1 parent 2676160b31
commit 7d11cbf6a2
9 files changed
+289 -40

No files matched your search

@@ -351,7 +351,7 @@ git commit -m "feat(ui): add independent M3U panel controls"
- Modify:
`libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.{ts,html,scss,spec.ts}`
- [ ] **Step 1: Write failing header and layout tests**
- [x] **Step 1: Write failing header and layout tests**
Assert the Groups header control is last, stays present in loading/empty/zero
search results, and emits an explicit collapse request only on Live/ITV/Radio.
@@ -359,7 +359,7 @@ Assert the context sidebar suppresses Groups at `max-width: 1023px` without
changing intent, retains collapsed DOM as inert, renders one boundary restore
button at wide widths, and transfers focus.
- [ ] **Step 2: Run and verify RED**
- [x] **Step 2: Run and verify RED**
```bash
pnpm nx test workspace-shell-feature --runInBand
@@ -367,7 +367,7 @@ pnpm nx test workspace-shell-feature --runInBand
Expected: FAIL because the Groups disclosure contract does not exist.
- [ ] **Step 3: Add the Groups header output**
- [x] **Step 3: Add the Groups header output**
Add:
@@ -383,7 +383,7 @@ readonly showLiveGroupsToggle = computed(
Render the translated 40px button last in `.context-header__actions`, with
panel-based test ID and ARIA attributes.
- [ ] **Step 4: Add state, responsive suppression, restore rail, and focus**
- [x] **Step 4: Add state, responsive suppression, restore rail, and focus**
Inject `LiveLayoutPanelStateService` from data-access and `BreakpointObserver`.
Resolve effective Groups visibility from intent, master suppression,
@@ -391,7 +391,7 @@ applicability, and `(max-width: 1023px)`. Keep the `aside` mounted with
`inert`/`aria-hidden`, reserve a 40px restore column only when restoration is
currently fulfillable, and focus `live-groups-panel-hide` after restoration.
- [ ] **Step 5: Run and verify GREEN**
- [x] **Step 5: Run and verify GREEN**
```bash
pnpm nx test workspace-shell-feature --runInBand
@@ -400,7 +400,7 @@ pnpm nx lint workspace-shell-feature
Expected: both commands exit 0.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add libs/workspace/shell/feature
@@ -64,6 +64,7 @@
type="button"
mat-icon-button
class="context-header__action"
data-testid="manage-live-groups"
(click)="openManageCategories()"
[disabled]="!canManageXtreamCategories()"
[matTooltip]="
@@ -76,6 +77,23 @@
<mat-icon>tune</mat-icon>
</button>
}
@if (showLiveGroupsToggle()) {
<button
type="button"
mat-icon-button
class="context-header__action context-header__action--panel"
data-testid="live-groups-panel-hide"
aria-controls="live-groups-panel"
[attr.aria-expanded]="groupsPanelExpanded()"
[attr.aria-label]="
'LAYOUT.HIDE_GROUPS_PANEL' | translate
"
[matTooltip]="'LAYOUT.HIDE_GROUPS_PANEL' | translate"
(click)="groupsPanelExpandedChange.emit(false)"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
</div>
</div>
<!-- @if (subtitle(); as text) {
@@ -65,6 +65,14 @@
}
}
.context-header__action.context-header__action--panel {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
line-height: 40px;
}
.context-header__badge {
display: inline-flex;
align-items: center;
@@ -194,9 +194,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-testid="manage-live-groups"]'
) as HTMLButtonElement | null;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
@@ -213,6 +213,46 @@ describe('WorkspaceContextPanelComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('keeps the live Groups disclosure last across loading and zero-result states', () => {
fixture.componentRef.setInput('section', 'live');
const groupsPanelExpandedChange = jest.fn();
fixture.componentInstance.groupsPanelExpandedChange.subscribe(
groupsPanelExpandedChange
);
fixture.detectChanges();
let actions = Array.from(
fixture.nativeElement.querySelectorAll(
'.context-header__actions > button'
)
) as HTMLButtonElement[];
let toggle = actions.at(-1) as HTMLButtonElement;
expect(toggle.dataset['testid']).toBe('live-groups-panel-hide');
expect(toggle.getAttribute('aria-controls')).toBe('live-groups-panel');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
fixture.componentInstance.categorySearchTerm.set(
'no category matches this'
);
fixture.detectChanges();
actions = Array.from(
fixture.nativeElement.querySelectorAll(
'.context-header__actions > button'
)
) as HTMLButtonElement[];
toggle = actions.at(-1) as HTMLButtonElement;
toggle.click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(false);
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
)
).toBeNull();
});
it('keeps local xtream loading states quiet when no import is running', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamIsImporting.set(false);
@@ -251,9 +291,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-testid="manage-live-groups"]'
) as HTMLButtonElement | null;
expect(countTexts).toEqual(['3', '0']);
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
@@ -460,9 +500,9 @@ describe('WorkspaceContextPanelComponent', () => {
el.textContent?.includes('For adults')
);
expect(documentary?.querySelector('.item-count')?.textContent).toContain(
'190'
);
expect(
documentary?.querySelector('.item-count')?.textContent
).toContain('190');
expect(adults?.querySelector('.item-count')).toBeNull();
});
@@ -5,6 +5,7 @@ import {
effect,
inject,
input,
output,
signal,
viewChild,
ElementRef,
@@ -72,6 +73,8 @@ export class WorkspaceContextPanelComponent {
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
readonly groupsPanelExpanded = input(true);
readonly groupsPanelExpandedChange = output<boolean>();
readonly isXtreamCategories = computed(
() =>
@@ -88,6 +91,12 @@ export class WorkspaceContextPanelComponent {
this.section() === 'itv' ||
this.section() === 'radio')
);
readonly showLiveGroupsToggle = computed(
() =>
(this.isXtreamCategories() && this.section() === 'live') ||
(this.isStalkerCategories() &&
(this.section() === 'itv' || this.section() === 'radio'))
);
readonly xtreamCategories = this.xtreamStore.getCategoriesBySelectedType;
readonly xtreamCategoryItemCounts = this.xtreamStore.getCategoryItemCounts;
@@ -12,9 +12,27 @@
} @else if (variant() === 'category') {
@if (context(); as context) {
@if (section(); as section) {
@if (canRestoreGroupsPanel()) {
<button
type="button"
mat-icon-button
class="context-panel-restore"
data-testid="live-groups-panel-restore"
aria-controls="live-groups-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_GROUPS_PANEL' | translate"
(click)="onGroupsPanelExpandedChange(true)"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<aside
id="live-groups-panel"
class="context-panel context-panel--route"
[class.context-panel--collapsed]="isContextPanelCollapsed()"
[class.context-panel--collapsed]="!groupsPanelExpanded()"
[attr.aria-hidden]="!groupsPanelExpanded()"
[attr.inert]="groupsPanelExpanded() ? null : ''"
appResizable
[minWidth]="240"
[maxWidth]="560"
@@ -24,6 +42,10 @@
<app-workspace-context-panel
[context]="context"
[section]="section"
[groupsPanelExpanded]="groupsPanelExpanded()"
(groupsPanelExpandedChange)="
onGroupsPanelExpandedChange($event)
"
/>
</aside>
}
@@ -1,9 +1,20 @@
:host {
display: block;
display: flex;
min-width: 0;
min-height: 0;
}
.context-panel-restore {
width: 40px;
min-width: 40px;
height: 100%;
min-height: 40px;
flex: 0 0 40px;
border-radius: 0;
color: var(--mat-sys-on-surface-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
}
.context-panel {
border-right: 1px solid var(--mat-sys-outline-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
@@ -28,6 +39,7 @@
padding-left: 0;
padding-right: 0;
border-right-color: transparent;
overflow: hidden;
.resize-handle {
display: none;
@@ -1,10 +1,15 @@
import {
Component,
Directive,
input,
} from '@angular/core';
import { Component, Directive, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import { BehaviorSubject } from 'rxjs';
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
@Directive({
@@ -20,12 +25,15 @@ class MockResizableDirective {
@Component({
selector: 'app-workspace-context-panel',
template: '',
template:
'<button data-testid="live-groups-panel-hide">Hide Groups</button>',
standalone: true,
})
class MockWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
readonly groupsPanelExpanded = input(true);
readonly groupsPanelExpandedChange = output<boolean>();
}
@Component({
@@ -51,13 +59,31 @@ class MockWorkspaceSourcesFiltersPanelComponent {}
describe('WorkspaceShellContextSidebarComponent', () => {
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
let liveSidebarService: LiveLayoutSidebarStateService;
let livePanelState: LiveLayoutPanelStateService;
let breakpointState: BehaviorSubject<BreakpointState>;
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state');
localStorage.removeItem('live-groups-panel-state');
localStorage.removeItem('live-channels-panel-state');
breakpointState = new BehaviorSubject<BreakpointState>({
breakpoints: {},
matches: false,
});
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
imports: [
WorkspaceShellContextSidebarComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: BreakpointObserver,
useValue: {
observe: () => breakpointState.asObservable(),
},
},
],
})
.overrideComponent(WorkspaceShellContextSidebarComponent, {
set: {
@@ -67,14 +93,20 @@ describe('WorkspaceShellContextSidebarComponent', () => {
MockWorkspaceCollectionContextPanelComponent,
MockWorkspaceSettingsContextPanelComponent,
MockWorkspaceSourcesFiltersPanelComponent,
MatIcon,
MatIconButton,
MatTooltip,
TranslateModule,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('expanded');
fixture = TestBed.createComponent(
WorkspaceShellContextSidebarComponent
);
livePanelState = TestBed.inject(LiveLayoutPanelStateService);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.GROUPS);
});
it('renders the settings panel for the settings variant', () => {
@@ -127,7 +159,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('collapses the categories rail when the shared service is collapsed on a live route', () => {
setupLiveCategory('live');
liveSidebarService.setState('collapsed');
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -141,7 +173,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
liveSidebarService.setState('collapsed');
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -155,7 +187,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('does not collapse the categories rail on non-live sections', () => {
setupLiveCategory('vod');
liveSidebarService.setState('collapsed');
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -176,5 +208,54 @@ describe('WorkspaceShellContextSidebarComponent', () => {
false
);
});
it('suppresses Groups responsively without changing persisted intent', () => {
setupLiveCategory('live');
breakpointState.next({
breakpoints: { '(max-width: 1023px)': true },
matches: true,
});
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
) as HTMLElement;
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
expect(aside.hasAttribute('inert')).toBe(true);
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-restore"]'
)
).toBeNull();
});
it('restores Groups from a boundary rail and transfers focus both ways', async () => {
setupLiveCategory('live');
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
fixture.detectChanges();
await Promise.resolve();
const restore = fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-restore"]'
) as HTMLButtonElement;
expect(restore.getAttribute('aria-controls')).toBe(
'live-groups-panel'
);
expect(document.activeElement).toBe(restore);
restore.click();
fixture.detectChanges();
await Promise.resolve();
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-hide"]'
)
);
});
});
});
@@ -1,15 +1,24 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
} from '@angular/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import {
LiveLayoutSidebarStateService,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import { ResizableDirective } from '@iptvnator/ui/components';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
@@ -29,6 +38,10 @@ const LIVE_SECTIONS: ReadonlySet<PortalRailSection> = new Set([
selector: 'app-workspace-shell-context-sidebar',
imports: [
ResizableDirective,
MatIcon,
MatIconButton,
MatTooltip,
TranslatePipe,
WorkspaceCollectionContextPanelComponent,
WorkspaceContextPanelComponent,
WorkspaceSettingsContextPanelComponent,
@@ -39,9 +52,13 @@ const LIVE_SECTIONS: ReadonlySet<PortalRailSection> = new Set([
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellContextSidebarComponent {
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly hostEl = inject(ElementRef<HTMLElement>);
private readonly compactViewport = toSignal(
inject(BreakpointObserver).observe('(max-width: 1023px)'),
{ initialValue: { breakpoints: {}, matches: false } }
);
private previousGroupsPanelExpanded: boolean | undefined;
readonly variant = input.required<WorkspaceShellContextPanel>();
readonly context = input<WorkspacePortalContext | null>(null);
@@ -56,9 +73,51 @@ export class WorkspaceShellContextSidebarComponent {
LIVE_SECTIONS.has(section)
);
});
readonly isContextPanelCollapsed = computed(
readonly groupsResponsiveSuppressed = computed(
() => this.compactViewport().matches
);
readonly groupsPanelExpanded = computed(() => {
const applicable = this.isLiveCategoryRoute();
return (
!applicable ||
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.GROUPS, {
applicable,
responsiveSuppressed: this.groupsResponsiveSuppressed(),
})
);
});
readonly canRestoreGroupsPanel = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsed()
!this.groupsResponsiveSuppressed() &&
!this.groupsPanelExpanded()
);
constructor() {
effect(() => {
const expanded = this.groupsPanelExpanded();
const previous = this.previousGroupsPanelExpanded;
this.previousGroupsPanelExpanded = expanded;
if (previous === undefined || previous === expanded) {
return;
}
queueMicrotask(() => {
const action = expanded ? 'hide' : 'restore';
this.hostEl.nativeElement
.querySelector<HTMLElement>(
`[data-testid="live-groups-panel-${action}"]`
)
?.focus();
});
});
}
onGroupsPanelExpandedChange(expanded: boolean): void {
if (expanded) {
this.livePanelState.showPanel(LIVE_LAYOUT_PANEL.GROUPS);
} else {
this.livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
}
}
}