fix(ui): expose guide collapse capability

This commit is contained in:
4gray committed 2026-07-31 08:49:59 +02:00
1 parent 3a0d507670
commit c7e876f33d
9 files changed
+355 -221

No files matched your search

@@ -59,6 +59,7 @@ SCSS, ngx-translate.
### Task 1: Introduce independent panel intent and legacy migration
**Files:**
- Create:
`libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.ts`
- Create:
@@ -179,12 +180,13 @@ git commit -m "feat(ui): add independent live panel state"
### Task 2: Make Guide disclosure capability explicit
**Files:**
- Modify:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.{ts,html,scss,spec.ts}`
- Modify:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.{ts,html,scss,spec.ts}`
- [ ] **Step 1: Write failing component tests**
- [x] **Step 1: Write failing component tests**
For both timeline and list, assert that `collapsible=false` renders a static
heading with no disclosure button, while `collapsible=true` exposes a 40px
@@ -195,8 +197,9 @@ label:
fixture.componentRef.setInput('collapsible', false);
fixture.componentRef.setInput('panelId', 'live-guide-panel');
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('[data-testid="live-guide-toggle"]'))
.toBeNull();
expect(
fixture.nativeElement.querySelector('[data-testid="live-guide-toggle"]')
).toBeNull();
fixture.componentRef.setInput('collapsible', true);
await fixture.whenStable();
@@ -207,7 +210,7 @@ expect(toggle.getAttribute('aria-controls')).toBe('live-guide-panel');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
```
- [ ] **Step 2: Run both targets and verify RED**
- [x] **Step 2: Run both targets and verify RED**
```bash
pnpm nx test ui-epg --runInBand
@@ -215,7 +218,7 @@ pnpm nx test ui-epg --runInBand
Expected: FAIL because the new inputs and static-heading branch do not exist.
- [ ] **Step 3: Implement capability, ARIA, and target sizing**
- [x] **Step 3: Implement capability, ARIA, and target sizing**
Add:
@@ -229,7 +232,7 @@ content in a non-interactive container. Bind `aria-controls` to `panelId`, bind
the schedule region `id`, add `data-testid="live-guide-toggle"`, and set
`min-height: 40px`.
- [ ] **Step 4: Run and verify GREEN**
- [x] **Step 4: Run and verify GREEN**
```bash
pnpm nx test ui-epg --runInBand
@@ -238,7 +241,7 @@ pnpm nx lint ui-epg
Expected: both commands exit 0.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add libs/ui/epg
@@ -248,6 +251,7 @@ git commit -m "fix(ui): expose guide collapse capability"
### Task 3: Define shared M3U panel-control contracts
**Files:**
- Modify:
`libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -266,9 +270,7 @@ and bidirectional focus:
```ts
expect(groupsHide.getAttribute('aria-controls')).toBe('live-groups-panel');
expect(channelsHide.getAttribute('aria-controls')).toBe(
'live-channels-panel'
);
expect(channelsHide.getAttribute('aria-controls')).toBe('live-channels-panel');
groupsHide.click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(false);
fixture.componentRef.setInput('groupsPanelExpanded', false);
@@ -343,6 +345,7 @@ git commit -m "feat(ui): add independent M3U panel controls"
### Task 4: Give the workspace Groups panel explicit ownership
**Files:**
- Modify:
`libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -407,6 +410,7 @@ git commit -m "feat(ui): add workspace groups disclosure"
### Task 5: Integrate independent Channels state in Xtream and Stalker
**Files:**
- Modify:
`libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -443,9 +447,7 @@ Inject `LiveLayoutPanelStateService`, compute Channels effective visibility for
```ts
this.livePanelState.toggleMasterSuppression(
this.showLiveChannelSidebar()
? ['groups', 'channels']
: ['groups']
this.showLiveChannelSidebar() ? ['groups', 'channels'] : ['groups']
);
```
@@ -485,6 +487,7 @@ git commit -m "feat(ui): separate portal live panel toggles"
### Task 6: Integrate shared state and mobile restoration in M3U
**Files:**
- Modify:
`libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.{ts,html,scss,spec.ts}`
@@ -552,6 +555,7 @@ git commit -m "feat(ui): separate M3U live panel toggles"
### Task 7: Preserve the single collection-header Channels control
**Files:**
- Modify:
`libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -607,6 +611,7 @@ git commit -m "feat(ui): align collection live panel control"
### Task 8: Translate panel actions
**Files:**
- Modify: `apps/web/src/assets/i18n/*.json`
- [ ] **Step 1: Add the English source keys**
@@ -648,6 +653,7 @@ git commit -m "feat(i18n): translate live panel controls"
### Task 9: Add atomized Electron regression coverage
**Files:**
- Create: `apps/electron-backend-e2e/src/live-panel-toggles.e2e.ts`
- Reuse: `apps/electron-backend-e2e/src/electron-test-fixtures.ts`
- Reuse: `apps/electron-backend-e2e/src/portal-mock-fixtures.ts`
@@ -695,6 +701,7 @@ git commit -m "test(e2e): cover live panel toggles"
### Task 10: Update canonical docs and release note
**Files:**
- Modify: `docs/architecture/iptvnator-ui-guidelines.md`
- Modify: `CLAUDE.md`
- Create: `.changes/ui-live-panel-toggles.md`
@@ -745,6 +752,7 @@ git commit -m "docs(ui): document live panel ownership"
### Task 11: Full verification and strict review
**Files:**
- Review every file changed relative to `origin/master`.
- [ ] **Step 1: Run affected unit targets**
@@ -7,22 +7,31 @@
>
<!-- toolbar: collapse + channel on the left, live/jump/stepper on the right -->
<div class="g-head">
<button
type="button"
class="g-collapse"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="cc"><mat-icon>keyboard_arrow_down</mat-icon></span>
<span class="gt">
@if (collapsible()) {
<button
type="button"
class="g-collapse"
data-testid="live-guide-toggle"
(click)="toggleCollapsed()"
[attr.aria-controls]="panelId()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="cc"><mat-icon>keyboard_arrow_down</mat-icon></span>
<span class="gt">
<b>{{ channelName() }}</b>
<span class="src">{{ sourceLabel() }}</span>
</span>
</button>
} @else {
<span class="gt g-heading-static">
<b>{{ channelName() }}</b>
<span class="src">{{ sourceLabel() }}</span>
</span>
</button>
}
<span class="g-spacer"></span>
@@ -112,7 +121,8 @@
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
viewDate()
| date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
@@ -128,90 +138,101 @@
}
</div>
@if (!collapsed()) {
<!-- schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'list' && !archivePlaybackAvailable()) {
<div class="g-notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
</div>
}
@switch (state) {
@case ('loading') {
<div class="g-list g-list--skeleton">
@for (row of skeletonRows; track $index) {
<div class="sk-row">
<span class="sk-time"></span>
<span class="sk-title"></span>
</div>
}
<div
class="g-panel-content"
[id]="panelId()"
[attr.aria-hidden]="collapsed()"
>
@if (!collapsed()) {
<!-- schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'list' && !archivePlaybackAvailable()) {
<div class="g-notice" role="note">
<mat-icon>info</mat-icon>
<span>{{
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
}}</span>
</div>
}
@case ('list') {
<div class="g-listwrap">
<!-- In normal flow (not an overlay): when it appears it
pushes the list down instead of covering the topmost
programme row. -->
@if (nowStripVisible() && nowRow(); as now) {
<button
type="button"
class="g-nowstrip"
(click)="jumpToNow()"
>
<span class="ns-dot" aria-hidden="true"></span>
<span class="ns-lbl">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="ns-title">{{ now.program.title }}</span>
@if (now.progress !== null) {
<span class="ns-bar" aria-hidden="true"
><i [style.width.%]="now.progress"></i
></span>
}
@if (nowRowMinutesLeft() !== null) {
<span class="ns-left"
>{{ nowRowMinutesLeft() }}
{{
'EPG.TIMELINE.MINUTES_SHORT' | translate
}}</span
>
}
<span class="ns-jump">
<mat-icon>arrow_upward</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</span>
</button>
}
<div
#list
class="g-list app-scrollbar"
(scroll)="onListScroll()"
>
@for (row of rows(); track row.key) {
<app-epg-list-view-row
[row]="row"
[selected]="row.key === selectedKey()"
[currentLocale]="currentLocale()"
[nowMs]="nowMs()"
(activate)="onRowActivate(row)"
(watch)="onWatch(row)"
(info)="openDetails(row)"
/>
@switch (state) {
@case ('loading') {
<div class="g-list g-list--skeleton">
@for (row of skeletonRows; track $index) {
<div class="sk-row">
<span class="sk-time"></span>
<span class="sk-title"></span>
</div>
}
</div>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
}
@case ('list') {
<div class="g-listwrap">
<!-- In normal flow (not an overlay): when it appears it
pushes the list down instead of covering the topmost
programme row. -->
@if (nowStripVisible() && nowRow(); as now) {
<button
type="button"
class="g-nowstrip"
(click)="jumpToNow()"
>
<span class="ns-dot" aria-hidden="true"></span>
<span class="ns-lbl">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="ns-title">{{
now.program.title
}}</span>
@if (now.progress !== null) {
<span class="ns-bar" aria-hidden="true"
><i [style.width.%]="now.progress"></i
></span>
}
@if (nowRowMinutesLeft() !== null) {
<span class="ns-left"
>{{ nowRowMinutesLeft() }}
{{
'EPG.TIMELINE.MINUTES_SHORT'
| translate
}}</span
>
}
<span class="ns-jump">
<mat-icon>arrow_upward</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</span>
</button>
}
<div
#list
class="g-list app-scrollbar"
(scroll)="onListScroll()"
>
@for (row of rows(); track row.key) {
<app-epg-list-view-row
[row]="row"
[selected]="row.key === selectedKey()"
[currentLocale]="currentLocale()"
[nowMs]="nowMs()"
(activate)="onRowActivate(row)"
(watch)="onWatch(row)"
(info)="openDetails(row)"
/>
}
</div>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
}
}
}
}
</div>
</div>
@@ -53,6 +53,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
padding: 4px 6px;
border-radius: 7px;
min-width: 0;
min-height: 40px;
}
.g-collapse:hover {
background: $surface-2;
@@ -81,6 +82,17 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
text-align: left;
min-width: 0;
}
.g-heading-static {
justify-content: center;
min-height: 40px;
}
.g-panel-content {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.gt b {
font-size: 13.5px;
font-weight: 600;
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { EpgListViewComponent } from './epg-list-view.component';
import { EpgListRow } from './epg-list-view.utils';
@@ -39,7 +39,7 @@ describe('EpgListViewComponent', () => {
undefined
);
TestBed.configureTestingModule({
imports: [EpgListViewComponent],
imports: [EpgListViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: MatDialog,
@@ -47,14 +47,6 @@ describe('EpgListViewComponent', () => {
open: () => ({ afterClosed: () => dialogResult }),
},
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject(null),
},
},
],
});
@@ -234,6 +226,38 @@ describe('EpgListViewComponent', () => {
expect(emitted).toEqual([true]);
});
it('renders an accessible Guide disclosure only when the host can collapse it', async () => {
setInputs({
channelName: 'Sports',
collapsible: true,
panelId: 'live-guide-panel',
});
await fixture.whenStable();
const toggle = fixture.nativeElement.querySelector(
'[data-testid="live-guide-toggle"]'
) as HTMLButtonElement | null;
expect(toggle).not.toBeNull();
expect(toggle?.getAttribute('aria-controls')).toBe('live-guide-panel');
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
expect(
fixture.nativeElement.querySelector('#live-guide-panel')
).not.toBeNull();
setInputs({ collapsible: false });
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-guide-toggle"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector('.g-heading-static')
?.textContent
).toContain('Sports');
});
it('derives a live progress percentage from the summary when collapsed', () => {
const start = new Date(Date.now() - 30 * 60_000).toISOString();
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
@@ -87,6 +87,8 @@ export class EpgListViewComponent {
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
@@ -135,7 +137,9 @@ export class EpgListViewComponent {
);
readonly nowRowMinutesLeft = computed(() => {
const row = this.nowRow();
return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null;
return row
? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000))
: null;
});
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
@@ -7,37 +7,46 @@
>
<!-- single header bar: collapse + channel on the left, controls/summary on the right -->
<div class="epg-timeline__toolbar">
<button
type="button"
class="epg-timeline__collapse"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="epg-timeline__collapse-icon">
<mat-icon>keyboard_arrow_down</mat-icon>
</span>
<span class="epg-timeline__heading">
@if (collapsible()) {
<button
type="button"
class="epg-timeline__collapse"
data-testid="live-guide-toggle"
(click)="toggleCollapsed()"
[attr.aria-controls]="panelId()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="epg-timeline__collapse-icon">
<mat-icon>keyboard_arrow_down</mat-icon>
</span>
<span class="epg-timeline__heading">
<b>{{ channelName() }}</b>
@if (!collapsed() && hasSummary()) {
<span
class="epg-timeline__subtitle"
[class.is-arch]="!isLivePlayback()"
[attr.title]="summary()?.title"
>
@if (!isLivePlayback()) {
<mat-icon>history</mat-icon>
}
{{ summary()?.title }}
</span>
} @else {
<span>{{ sourceLabel() }}</span>
}
</span>
</button>
} @else {
<span class="epg-timeline__heading epg-timeline__heading-static">
<b>{{ channelName() }}</b>
@if (!collapsed() && hasSummary()) {
<span
class="epg-timeline__subtitle"
[class.is-arch]="!isLivePlayback()"
[attr.title]="summary()?.title"
>
@if (!isLivePlayback()) {
<mat-icon>history</mat-icon>
}
{{ summary()?.title }}
</span>
} @else {
<span>{{ sourceLabel() }}</span>
}
<span>{{ sourceLabel() }}</span>
</span>
</button>
}
<span class="epg-timeline__spacer"></span>
@@ -55,7 +64,10 @@
summary()?.title
}}</span>
@if (progress() !== null) {
<span class="epg-timeline__summary-bar" aria-hidden="true">
<span
class="epg-timeline__summary-bar"
aria-hidden="true"
>
<i [style.width.%]="progress()"></i>
</span>
}
@@ -145,7 +157,8 @@
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
viewDate()
| date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
@@ -161,60 +174,70 @@
}
</div>
@if (!collapsed()) {
<!-- Schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
<div class="epg-timeline__notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
</div>
}
<div
class="epg-timeline__panel-content"
[id]="panelId()"
[attr.aria-hidden]="collapsed()"
>
@if (!collapsed()) {
<!-- Schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
<div class="epg-timeline__notice" role="note">
<mat-icon>info</mat-icon>
<span>{{
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
}}</span>
</div>
}
@switch (state) {
@case ('loading') {
<div class="epg-timeline__ribbon epg-timeline__ribbon--skeleton">
@for (width of skeletonWidths; track $index) {
<div
class="epg-timeline__sk"
[style.width.px]="width"
></div>
}
</div>
}
@case ('ribbon') {
<div
#ribbon
class="epg-timeline__ribbon app-scrollbar"
(scroll)="onRibbonScroll()"
>
<app-epg-timeline-track
[items]="renderItems()"
[ticks]="ticks()"
[dividers]="dividers()"
[scale]="scale()"
[trackWidthPx]="trackWidthPx()"
[playheadLeftPx]="playheadLeftPx()"
[nowLabel]="nowLabel()"
[selectedKey]="selectedKey()"
[activeProgram]="activeProgram()"
[currentLocale]="currentLocale()"
(blockClick)="onBlockClick($event)"
(watchClick)="onWatch($event)"
(infoClick)="openDetails($event)"
(groupExpand)="onGroupExpand($event)"
@switch (state) {
@case ('loading') {
<div
class="epg-timeline__ribbon epg-timeline__ribbon--skeleton"
>
@for (width of skeletonWidths; track $index) {
<div
class="epg-timeline__sk"
[style.width.px]="width"
></div>
}
</div>
}
@case ('ribbon') {
<div
#ribbon
class="epg-timeline__ribbon app-scrollbar"
(scroll)="onRibbonScroll()"
>
<app-epg-timeline-track
[items]="renderItems()"
[ticks]="ticks()"
[dividers]="dividers()"
[scale]="scale()"
[trackWidthPx]="trackWidthPx()"
[playheadLeftPx]="playheadLeftPx()"
[nowLabel]="nowLabel()"
[selectedKey]="selectedKey()"
[activeProgram]="activeProgram()"
[currentLocale]="currentLocale()"
(blockClick)="onBlockClick($event)"
(watchClick)="onWatch($event)"
(infoClick)="openDetails($event)"
(groupExpand)="onGroupExpand($event)"
/>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
}
}
}
}
</div>
</div>
@@ -65,6 +65,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border-radius: 7px;
font: inherit;
min-width: 0;
min-height: 40px;
max-width: 60%;
&:hover {
@@ -72,6 +73,18 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
}
.epg-timeline__heading-static {
min-height: 40px;
}
.epg-timeline__panel-content {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.epg-timeline__collapse-icon {
width: 18px;
height: 18px;
@@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, of } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgTimelineComponent } from './epg-timeline.component';
import { TimelineBlock } from './epg-timeline.utils';
@@ -36,18 +36,12 @@ describe('EpgTimelineComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [EpgTimelineComponent],
imports: [EpgTimelineComponent, TranslateModule.forRoot()],
providers: [
{
provide: MatDialog,
useValue: { open: () => ({ afterClosed: () => of(undefined) }) },
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject(null),
open: () => ({ afterClosed: () => of(undefined) }),
},
},
],
@@ -202,6 +196,38 @@ describe('EpgTimelineComponent', () => {
expect(emitted).toEqual([true]);
});
it('renders an accessible Guide disclosure only when the host can collapse it', async () => {
setInputs({
channelName: 'News',
collapsible: true,
panelId: 'live-guide-panel',
});
await fixture.whenStable();
const toggle = fixture.nativeElement.querySelector(
'[data-testid="live-guide-toggle"]'
) as HTMLButtonElement | null;
expect(toggle).not.toBeNull();
expect(toggle?.getAttribute('aria-controls')).toBe('live-guide-panel');
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
expect(
fixture.nativeElement.querySelector('#live-guide-panel')
).not.toBeNull();
setInputs({ collapsible: false });
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-guide-toggle"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector('.epg-timeline__heading-static')
?.textContent
).toContain('News');
});
it('derives a live progress percentage from the summary', () => {
const start = new Date(Date.now() - 30 * 60_000).toISOString();
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
@@ -37,10 +37,7 @@ import {
summaryMinutesLeft,
summaryProgress,
} from './epg-summary.util';
import {
canCatchUpProgramme,
epgDialogActionFor,
} from './epg-archive.util';
import { canCatchUpProgramme, epgDialogActionFor } from './epg-archive.util';
import {
EpgTimelineEmptyReason,
EpgTimelineEmptyStateComponent,
@@ -98,6 +95,8 @@ export class EpgTimelineComponent {
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
@@ -178,11 +177,15 @@ export class EpgTimelineComponent {
readonly dividers = computed(() => buildTimelineDayDividers(this.axis()));
readonly trackWidthPx = computed(() => {
const axis = this.axis();
return ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale();
return (
((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
);
});
readonly playheadLeftPx = computed(() => {
const axis = this.axis();
return ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale();
return (
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
);
});
readonly zoomLabelKey = computed(() => {
const scale = this.scale();
@@ -225,7 +228,9 @@ export class EpgTimelineComponent {
* could become usable, but with no programmes for the day there is nothing
* to jump to or zoom.
*/
readonly showRibbonControls = computed(() => this.renderState() === 'ribbon');
readonly showRibbonControls = computed(
() => this.renderState() === 'ribbon'
);
/**
* The date stepper navigates between days, which is only meaningful when the
@@ -241,9 +246,7 @@ export class EpgTimelineComponent {
// ── collapsed-summary state ──
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
readonly hasTimeRange = computed(() =>
summaryHasTimeRange(this.summary())
);
readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary()));
readonly progress = computed(() =>
summaryProgress(this.summary(), this.nowMs())
);