mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950
This commit is contained in:
commit
042016e578
99 files changed
+5103
-429
No files matched your search
@@ -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';
|
||||
|
||||
+90
@@ -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();
|
||||
});
|
||||
});
|
||||
+37
@@ -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\);/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user