Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950

This commit is contained in:
4gray committed 2026-10-03 16:31:18 +02:00
commit 042016e578
99 files changed
+5103 -429

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/view-in-portal-action/view-in-portal-action.component';
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
export * from './lib/password-visibility-toggle/password-visibility-toggle.directive';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
@@ -0,0 +1,90 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { PasswordVisibilityToggleDirective } from './password-visibility-toggle.directive';
@Component({
imports: [PasswordVisibilityToggleDirective],
template: `
<input [type]="toggle.inputType()" />
<button
appPasswordVisibilityToggle
#toggle="appPasswordVisibilityToggle"
aria-label="Show password"
>
{{ toggle.icon() }}
</button>
`,
})
class HostComponent {}
describe('PasswordVisibilityToggleDirective', () => {
function render() {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return {
fixture,
input: root.querySelector('input') as HTMLInputElement,
button: root.querySelector('button') as HTMLButtonElement,
};
}
it('starts masked and is a non-submitting toggle button', () => {
const { input, button } = render();
expect(input.type).toBe('password');
expect(button.type).toBe('button');
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(button.textContent?.trim()).toBe('visibility');
});
it('reveals and masks again on each click, keeping its label', () => {
const { fixture, input, button } = render();
button.click();
fixture.detectChanges();
expect(input.type).toBe('text');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.textContent?.trim()).toBe('visibility_off');
expect(button.getAttribute('aria-label')).toBe('Show password');
button.click();
fixture.detectChanges();
expect(input.type).toBe('password');
expect(button.getAttribute('aria-pressed')).toBe('false');
});
it('masks again on hide()', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const button = root.querySelector('button') as HTMLButtonElement;
button.click();
fixture.detectChanges();
fixture.debugElement
.query((node) => node.name === 'button')
.injector.get(PasswordVisibilityToggleDirective)
.hide();
fixture.detectChanges();
expect(root.querySelector('input')?.type).toBe('password');
expect(button.getAttribute('aria-pressed')).toBe('false');
});
it('does not submit the surrounding form', () => {
const form = document.createElement('form');
const submit = jest.fn((event: Event) => event.preventDefault());
form.addEventListener('submit', submit);
const { button } = render();
form.appendChild(button);
document.body.appendChild(form);
button.click();
expect(submit).not.toHaveBeenCalled();
form.remove();
});
});
@@ -0,0 +1,37 @@
import { computed, Directive, signal } from '@angular/core';
/**
* Turns a `mat-icon-button` suffix into the show/hide control of a password
* field. The field starts masked; the input binds `[type]="toggle.inputType()"`
* and the icon renders `toggle.icon()`.
*
* It is a toggle button in the ARIA sense: the template gives it one constant
* label ("Show password") and `aria-pressed` carries the state, so a screen
* reader announces "Show password, toggle button, pressed" instead of a label
* that flips under the user's focus.
*/
@Directive({
selector: 'button[appPasswordVisibilityToggle]',
exportAs: 'appPasswordVisibilityToggle',
host: {
type: 'button',
'[attr.aria-pressed]': 'visible()',
'(click)': 'toggle()',
},
})
export class PasswordVisibilityToggleDirective {
readonly visible = signal(false);
readonly inputType = computed(() => (this.visible() ? 'text' : 'password'));
readonly icon = computed(() =>
this.visible() ? 'visibility_off' : 'visibility'
);
toggle(): void {
this.visible.update((visible) => !visible);
}
/** Masks the field again, e.g. when its form is cleared for a new entry. */
hide(): void {
this.visible.set(false);
}
}
@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ProgressCapsuleComponent } from './progress-capsule.component';
describe('ProgressCapsuleComponent', () => {
let fixture: ComponentFixture<ProgressCapsuleComponent>;
function render(progress: number): HTMLElement {
fixture = TestBed.createComponent(ProgressCapsuleComponent);
fixture.componentRef.setInput('progress', progress);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
it('fills the capsule to the watched share', () => {
const host = render(42);
expect(
host.querySelector<HTMLElement>('.progress-capsule__fill')?.style
.width
).toBe('42%');
expect(host.querySelector('.progress-capsule--watched')).toBeNull();
});
it('marks an item watched from 90 %', () => {
expect(
render(90).querySelector('.progress-capsule--watched')
).not.toBeNull();
});
// The capsule sits in app chrome (catalog grids, season episodes), so its
// fill is the theme's watch-progress token, like the dashboard rail card.
it('draws progress with the app watch-progress token', () => {
const source = readFileSync(
resolve(
process.cwd(),
'libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts'
),
'utf8'
);
const fill = source.match(/&__fill\s*\{([^}]*)\}/)?.[1] ?? '';
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
expect(source).not.toMatch(/#e50914|#ff4d4d/i);
});
});
@@ -34,11 +34,7 @@ import {
&__fill {
height: 100%;
background: linear-gradient(
90deg,
#e50914 0%,
#ff4d4d 100%
);
background: var(--app-progress-color);
transition: width 0.3s ease-out;
border-radius: 0 2px 2px 0;
}
@@ -770,7 +770,7 @@
&__progress-fill {
height: 100%;
background: var(--mat-sys-primary);
background: var(--app-progress-color);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Watch progress in app chrome reads one theme token in both season view
// modes; the list rows draw their own fill rather than the grid's capsule
// (see the UI guidelines' Progress Bars section).
const DIR = resolve(
process.cwd(),
'libs/ui/components/src/lib/season-container'
);
function read(file: string): string {
return readFileSync(resolve(DIR, file), 'utf8');
}
describe('season container watch-progress colour', () => {
it('fills list rows with the app watch-progress token', () => {
const styles = read('season-container.component.scss');
const listItem = styles.slice(styles.indexOf('.episode-list-item {'));
const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1];
expect(read('season-container.component.html')).toContain(
'class="episode-list-item__progress-fill"'
);
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
});
});