mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(ui): expose guide collapse capability
This commit is contained in:
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())
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user