fix(parental-lock): focus, tokens and phone width for lock UI

The lock rows, lock dialogs and PIN error used Material system tokens the
theme never declares, so every one of those declarations was invalid:
keyboard focus on the "N locked" rows and Stalker lock rows was
invisible, their borders and the lock list border had no colour, locked
and unlocked toggles looked the same, and the PIN error was not red.

- UI-05: 2px inset --app-selection-color ring on :focus-visible for the
  rail locked rows and the Stalker lock rows (>= 3:1 in both themes).
- UI-08: lock UI on --app-* tokens; local per-theme error red for the PIN
  error and the adult chip; locked toggles get accent colour + tint.
- UI-10: no 440/480px content min-width; lock dialogs open with
  maxWidth calc(100vw - 32px), so they fit a 375px phone.
- UI-11: the PWA Xtream dialog hides the Electron-only selection count and
  Select/Deselect-all; a failed lock write shows PARENTAL_LOCK.SAVE_FAILED;
  clear-search buttons get a translated aria-label.

New web E2E (parental-lock-ui.e2e.ts) tabs to each lock row and checks
ring, border, PIN error and phone fit in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 23:08:39 +02:00
1 parent 963a431bb7
commit 13fa68bbb1
20 files changed
+826 -121

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: settings
---
Parental lock screens are easier to use: keyboard focus is visible on the "N locked" rows and in the Stalker lock list, locked categories stand out in the management dialogs, a wrong PIN shows in red, and the lock dialogs fit on a phone. In the browser version the category dialog no longer shows hide/show controls that do nothing there.
+412
View File
@@ -0,0 +1,412 @@
import type { Locator, Page } from '@playwright/test';
import { join } from 'path';
import { expect, test } from './fixtures';
import { pressTab, surfaceContrast } from './e2e-helpers';
import {
addStalkerPortal,
addXtreamPortal,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* Parental lock UI on app tokens (PWA).
*
* The lock rows, dialogs and PIN error once used Material system colours the
* theme never declares, so every one of them resolved to nothing: no focus
* ring after the outline reset, a dashed border and list border with no
* colour, a PIN error in body text, and dialogs 440–480px wide on a 375px
* phone. These tests pin the visible result in both themes:
*
* 1. Tabbing to each "N locked" row and each Stalker lock row shows a
* 2px ring with >= 3:1 contrast against the row and its surface.
* 2. The rail rows' dashed border and the lock list's border are painted.
* 3. The PIN error is a red with >= 4.5:1 contrast on the dialog.
* 4. The lock dialogs fit a 375px viewport without sideways overflow.
* 5. In the PWA the Xtream dialog shows lock controls only — hide/show is
* Electron-only — and its clear-search button has a name.
*
* Tag: @parental — run only this spec with:
* pnpm nx run web-e2e:e2e-ci--src/parental-lock-ui.e2e.ts
*/
const M3U_FIXTURE = join(__dirname, 'fixtures/test.m3u');
const PIN = '2468';
const PHONE = { width: 375, height: 812 };
const THEMES = ['light', 'dark'] as const;
async function setTheme(page: Page, theme: (typeof THEMES)[number]) {
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
}
async function enableParentalLock(page: Page): Promise<void> {
await page.locator('a[href$="/workspace/settings"]').click();
await page.waitForURL(/\/workspace\/settings\/general$/);
await page.locator('[data-test-id="settings-section-parental"]').click();
await page.waitForURL(/\/workspace\/settings\/parental$/);
const toggle = page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
await toggle.click();
await page.locator('[data-test-id="parental-lock-pin"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-confirm"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
}
/** Answers the unlock prompt that a PIN-gated action opens. */
async function enterPin(page: Page, pin = PIN): Promise<void> {
const input = page.locator('[data-test-id="parental-lock-pin"]');
await expect(input).toBeVisible();
await input.fill(pin);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
}
/**
* The dialog opened for `component`, once it holds focus: MatDialog moves
* focus to its first tabbable element only after the open animation, which
* would otherwise pull focus away from a row the test has just tabbed to.
*/
async function openedDialog(page: Page, component: string): Promise<Locator> {
const dialog = page.locator('mat-dialog-container', {
has: page.locator(component),
});
await expect
.poll(() =>
dialog.evaluate((el) => el.contains(document.activeElement))
)
.toBe(true);
return dialog;
}
/**
* WCAG contrast of one colour property against what is painted behind the
* element: white, each ancestor's background and, with `ownFill`, the
* element's own background (the approach of `surfaceContrast`).
*/
async function contrastBehind(
locator: Locator,
property: 'color' | 'outlineColor',
ownFill: boolean
): Promise<number> {
return locator.evaluate(
(element, [property, ownFill]) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is required');
const paint = (color: string) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
};
const luminance = () =>
Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0
);
const layers: Element[] = ownFill ? [element] : [];
for (let e = element.parentElement; e; e = e.parentElement) {
layers.unshift(e);
}
paint('white');
layers.forEach((layer) =>
paint(getComputedStyle(layer).backgroundColor)
);
const behind = luminance();
paint(getComputedStyle(element)[property]);
const front = luminance();
return (
(Math.max(front, behind) + 0.05) /
(Math.min(front, behind) + 0.05)
);
},
[property, ownFill] as const
);
}
/** Focus `from` and Tab (as that browser's user does) until `target` has focus. */
async function tabTo(
page: Page,
browserName: string,
from: Locator,
target: Locator,
maxTabs = 1
): Promise<void> {
await from.focus();
for (let i = 0; i < maxTabs; i++) {
await pressTab(page, browserName);
if (await target.evaluate((el) => el === document.activeElement)) {
break;
}
}
await expect(target).toBeFocused();
}
/**
* The ring sits inside the row (offset -2px), so it must stand out from the
* row's own fill and from the surface around the row.
*/
async function expectFocusRing(row: Locator): Promise<void> {
await expect(row).toBeFocused();
await expect(row).toHaveCSS('outline-style', 'solid');
await expect(row).toHaveCSS('outline-width', '2px');
const inside = await contrastBehind(row, 'outlineColor', true);
const outside = await contrastBehind(row, 'outlineColor', false);
expect(Math.min(inside, outside)).toBeGreaterThanOrEqual(3);
}
/** Colour and fill of a lock toggle, to tell locked from unlocked. */
async function toggleLook(toggle: Locator): Promise<string> {
return toggle.evaluate((el) => {
const style = getComputedStyle(el);
return `${style.color} ${style.backgroundColor}`;
});
}
async function expectPaintedBorder(
locator: Locator,
style: 'dashed' | 'solid'
): Promise<void> {
await expect(locator).toHaveCSS('border-top-style', style);
const { border } = await surfaceContrast(locator);
// 1 means the border colour is transparent against its own fill.
expect(border).toBeGreaterThan(1.15);
}
async function expectDialogFitsPhone(
page: Page,
dialog: Locator
): Promise<void> {
await page.setViewportSize(PHONE);
await expect
.poll(async () => {
const box = await dialog.boundingBox();
return box ? box.x >= 0 && box.x + box.width <= PHONE.width : false;
})
.toBe(true);
// A min-width on the content overflows the capped panel without
// widening the panel or the document, so measure the blocks inside.
const overflow = await dialog.evaluate((panel) => {
const edge = panel.getBoundingClientRect().right;
const blocks = panel.querySelectorAll(
'mat-dialog-content, mat-dialog-actions, .header-actions, ' +
'.search-inline, .categories-list'
);
return Math.max(
...Array.from(
blocks,
(block) => block.getBoundingClientRect().right - edge
)
);
});
expect(overflow).toBeLessThanOrEqual(0.5);
expect(
await page.evaluate(() => document.documentElement.scrollWidth)
).toBeLessThanOrEqual(PHONE.width);
}
test.describe('Parental lock UI', () => {
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
});
test('@parental @xtream PWA dialog, locked-categories row and PIN error', async ({
page,
browserName,
}) => {
test.slow();
await addXtreamPortal(page, 'Lock UI Portal');
const portalUrl = page.url();
await enableParentalLock(page);
// A reload starts locked: the unlock lives in memory only.
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-category-management-dialog'
);
const toggles = dialog.locator('[data-test-id^="category-lock-"]');
await expect(toggles.first()).toBeVisible();
// PWA: locks only, no hide/show selection controls.
await expect(
dialog.locator('[data-test-id="category-locked-count"]')
).toBeVisible();
await expect(dialog.locator('.bulk-actions')).toHaveCount(0);
await expect(dialog.locator('mat-checkbox')).toHaveCount(0);
await dialog.locator('.search-inline input').fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(dialog.locator('.search-inline input')).toHaveValue('');
// Locked reads as a state, not only as another glyph.
await toggles.first().click();
await expect(toggles.first()).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggles.first())).not.toBe(
await toggleLook(toggles.nth(1))
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator(
'[data-test-id="context-locked-categories"]'
);
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.context-panel .category-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
// A wrong PIN reads as an error in both themes.
await lockedRow.click();
await enterPin(page, '0000');
const error = page.locator('[data-test-id="parental-lock-pin-error"]');
await expect(error).toBeVisible();
for (const theme of THEMES) {
await setTheme(page, theme);
const [r, g, b] = await error.evaluate((el) =>
(getComputedStyle(el).color.match(/\d+/g) ?? []).map(Number)
);
expect(r - Math.max(g, b)).toBeGreaterThan(50);
expect(await contrastBehind(error, 'color', true)).toBeGreaterThan(
4.5
);
}
await enterPin(page);
await expect(lockedRow).toBeHidden();
await manage.click();
await expect(toggles.first()).toBeVisible();
await expectDialogFitsPhone(page, dialog);
});
test('@parental @stalker lock dialog rows, list border and phone width', async ({
page,
browserName,
}) => {
test.slow();
await addStalkerPortal(page, 'Lock UI Stalker');
const portalUrl = page.url();
await enableParentalLock(page);
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-stalker-category-lock-dialog'
);
const rows = dialog.locator('.category-item');
await expect(rows.first()).toBeVisible();
const search = dialog.locator('.search-inline input');
await tabTo(page, browserName, search, rows.first());
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(rows.first());
await expectPaintedBorder(
dialog.locator('.categories-list'),
'solid'
);
}
// The next row takes the ring over when Tab moves on.
await pressTab(page, browserName);
await expectFocusRing(rows.nth(1));
await search.fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(search).toHaveValue('');
await expectDialogFitsPhone(page, dialog);
});
test('@parental @m3u locked-groups row', async ({ page, browserName }) => {
test.slow();
// The fixture's placeholder media hosts stay offline.
await page.route(
/^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//,
(route) => route.abort()
);
await page.getByRole('button', { name: 'Add playlist' }).click();
const addDialog = page.locator('mat-dialog-container');
await addDialog.getByRole('radio', { name: /M3U file/i }).click();
await page.setInputFiles('input[type="file"]', M3U_FIXTURE);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
addDialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
const groupsUrl = page.url().replace(/\/all$/, '/groups');
await enableParentalLock(page);
await page.goto(groupsUrl);
const manage = page.locator('.groups-nav-action--manage');
await expect(manage).toBeVisible({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(page, 'app-group-management-dialog');
const toggle = dialog.locator('[data-test-id="group-lock-News"]');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggle)).not.toBe(
await toggleLook(
dialog.locator('[data-test-id="group-lock-Sport"]')
)
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator('[data-test-id="groups-locked-row"]');
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.groups-nav-list .nav-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
});
});
+8 -1
View File
@@ -374,7 +374,8 @@ so a cancelled or refused PIN leaves it showing the real state.
the PIN. The Xtream dialog loads its candidates through the
capability-selected data source (`IXtreamDataSource.getAllCategories`,
which the PWA source answers from its session cache or the API), so PWA
users can set locks too; the hide/show checkboxes remain Electron-only.
users can set locks too; the hide/show checkboxes, the selection count
and the Select/Deselect-all actions remain Electron-only.
A deliberate non-choice: no dedicated lock button in the rail header
(the header already carries search, sort and manage, and the lock is a
category-management concern) and no keyword-based "adult" recognition
@@ -415,6 +416,12 @@ so a cancelled or refused PIN leaves it showing the real state.
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
- Styling uses app tokens only (the theme declares no `--mat-sys-*`); the
PIN error and the Stalker adult chip use a local red per theme. Keyboard
focus on a lock row is a 2px inset `--app-selection-color` ring, and the
lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content
min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts`
checks rings, borders, the PIN error and phone width in both themes.
## Startup footprint
@@ -47,6 +47,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -1,3 +1,13 @@
// App tokens only: the theme declares no Material system colours. The
// adult-chip red is local, with a value per theme (>= 4.5:1 on its tint).
:host {
--lock-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--lock-dialog-error-color: #ffb4ab;
}
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
@@ -7,7 +17,7 @@
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
.header-actions {
@@ -20,15 +30,16 @@
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
strong {
color: var(--mat-sys-primary);
color: var(--app-heading-color);
}
}
.bulk-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
button {
@@ -43,19 +54,19 @@
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
border: 1px solid var(--app-search-border);
background: var(--app-search-bg);
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-selection-color);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
input {
@@ -63,7 +74,7 @@
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.84rem;
min-width: 0;
@@ -92,7 +103,7 @@
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border: 1px solid var(--app-separator);
border-radius: 8px;
}
@@ -105,29 +116,36 @@
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
background-color: var(--app-card-hover-bg);
}
// The global reset strips every button outline; an inset ring keeps
// the focused row visible inside the list's clipped scroll box.
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
border-bottom: 1px solid var(--app-widget-header-border);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
}
}
@@ -144,18 +162,22 @@
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
background: color-mix(
in srgb,
var(--lock-dialog-error-color) 16%,
transparent
);
color: var(--lock-dialog-error-color);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.8125rem;
}
// No min-width: the opener caps the panel at the viewport on a phone.
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -65,4 +65,16 @@ describe('StalkerCategoryLockDialogComponent', () => {
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('new');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
});
@@ -7,48 +7,66 @@
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
</div>
} @else {
<div class="header-actions">
<div class="selection-info">
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks()) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
<!-- Hide/show is Electron-only: the PWA lists categories for their
lock toggles alone, so it shows no selection count or bulk
selection actions. -->
@if (supportsVisibility || showLocks()) {
<div class="header-actions">
<div class="selection-info">
@if (supportsVisibility) {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> /
{{ totalCount() }}
}
@if (showLocks()) {
<span
class="selection-info__locks"
data-test-id="category-locked-count"
>
@if (supportsVisibility) {
·
}
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
@if (supportsVisibility) {
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="
isSaving() || filteredSelectedCount() === 0
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="isSaving() || filteredSelectedCount() === 0"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
</div>
}
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
@@ -66,6 +84,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -155,7 +155,6 @@
:host ::ng-deep {
.mat-mdc-dialog-content {
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -179,7 +178,7 @@ mat-dialog-actions {
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -188,7 +187,10 @@ mat-dialog-actions {
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -1,6 +1,7 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
@@ -37,6 +38,7 @@ describe('CategoryManagementDialogComponent', () => {
getAllCategories: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const snackBar = { open: jest.fn() };
const parentalLock = {
enabled: signal(false),
active: signal(false),
@@ -50,13 +52,8 @@ describe('CategoryManagementDialogComponent', () => {
itemCounts: new Map(),
};
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
data.contentType = 'live';
/** `supportsVisibility` false is the PWA: no SQLite hide/show. */
async function createDialog(supportsVisibility: boolean): Promise<void> {
await TestBed.configureTestingModule({
imports: [
CategoryManagementDialogComponent,
@@ -67,11 +64,14 @@ describe('CategoryManagementDialogComponent', () => {
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
useValue: {
supportsXtreamSqliteDataSource: supportsVisibility,
},
},
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: snackBar },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
@@ -79,6 +79,17 @@ describe('CategoryManagementDialogComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
parentalLock.setXtreamLocks.mockResolvedValue(true);
data.contentType = 'live';
await createDialog(true);
});
function selectedIds() {
@@ -253,6 +264,119 @@ describe('CategoryManagementDialogComponent', () => {
]);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
parentalLock.setXtreamLocks.mockResolvedValueOnce(false);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
expect(component.isSaving()).toBe(false);
});
it('does not blame the locks when the visibility write fails', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
db.updateCategoryVisibility.mockRejectedValueOnce(new Error('db'));
await component.save();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'Failed to save category visibility',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('fr');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
describe('in the PWA, where hide/show is unavailable', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(true);
await createDialog(false);
});
function text(): string {
fixture.detectChanges();
return fixture.nativeElement.textContent;
}
it('shows only the lock controls, not the selection count or bulk actions', () => {
expect(component.showLocks()).toBe(true);
expect(bulkButtons()).toEqual([]);
expect(text()).not.toContain('XTREAM.CATEGORY_MANAGEMENT.SELECTED');
expect(
fixture.nativeElement.querySelector('mat-checkbox')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="category-locked-count"]'
).textContent
).toContain('PARENTAL_LOCK.LOCKED_COUNT');
expect(
fixture.nativeElement.querySelectorAll('.lock-toggle')
).toHaveLength(categories.length);
});
it('shows no header at all while the lock feature is off', async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(false);
await createDialog(false);
expect(
fixture.nativeElement.querySelector('.header-actions')
).toBeNull();
});
it('saves the lock draft and never writes visibility', async () => {
component.toggleLock(component.categories()[1]);
await component.save();
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith(
'mock-playlist',
'live',
[102]
);
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.setXtreamLocks.mockRejectedValueOnce(
new Error('store')
);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
});
it.each([
['live', 'live'],
['vod', 'movies'],
@@ -18,7 +18,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
@@ -67,6 +67,7 @@ export class CategoryManagementDialogComponent implements OnInit {
*/
readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource;
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly dialogRef = inject(
MatDialogRef<CategoryManagementDialogComponent>
);
@@ -235,47 +236,74 @@ export class CategoryManagementDialogComponent implements OnInit {
this.isSaving.set(true);
try {
const categories = this.categories();
const toHide = categories
.filter((c) => !c.selected)
.map((c) => c.id);
const toShow = categories
.filter((c) => c.selected)
.map((c) => c.id);
if (this.supportsVisibility && toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
try {
await this.saveVisibility(categories);
} catch (error) {
this.logger.error('Error saving category visibility', error);
// No translation key exists for this message yet.
this.showSaveFailure('Failed to save category visibility');
return;
}
if (this.supportsVisibility && toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
// A relock while the visibility writes ran closed the dialog:
// its lock draft is dropped. The lock store refuses a removal
// that still commits after a relock (it checks at commit time).
if (this.showLocks() && !this.parentalLock.active()) {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories
.filter((c) => c.lockedDraft)
.map((c) => c.xtream_id)
if (!(await this.saveLocks(categories))) {
this.showSaveFailure(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED')
);
if (!saved) {
throw new Error('Saving category locks failed');
}
return;
}
this.dialogRef.close(true);
} catch (error) {
this.logger.error('Error saving category visibility', error);
this.snackBar.open('Failed to save category visibility', 'Close', {
duration: 3000,
});
} finally {
this.isSaving.set(false);
}
}
private async saveVisibility(
categories: CategoryWithSelection[]
): Promise<void> {
if (!this.supportsVisibility) {
return;
}
const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
const toShow = categories.filter((c) => c.selected).map((c) => c.id);
if (toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
}
if (toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
}
/** False only when a lock write was attempted and did not persist. */
private async saveLocks(
categories: CategoryWithSelection[]
): Promise<boolean> {
// A relock while the visibility writes ran closed the dialog: its
// lock draft is dropped. The lock store refuses a removal that
// still commits after a relock (it checks at commit time).
if (!this.showLocks() || this.parentalLock.active()) {
return true;
}
try {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories.filter((c) => c.lockedDraft).map((c) => c.xtream_id)
);
if (!saved) {
this.logger.error('Category locks were not saved');
}
return saved;
} catch (error) {
this.logger.error('Error saving category locks', error);
return false;
}
}
private showSaveFailure(message: string): void {
this.snackBar.open(message, this.translate.instant('CLOSE'), {
duration: 3000,
});
}
cancel(): void {
this.dialogRef.close(false);
}
@@ -48,6 +48,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -3,7 +3,6 @@
gap: 10px;
flex-direction: column;
overflow: hidden !important;
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -159,7 +158,7 @@
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -168,7 +167,10 @@
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -337,20 +337,26 @@
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -538,6 +538,7 @@ describe('GroupsViewComponent', () => {
]),
}),
maxHeight: '90vh',
maxWidth: 'calc(100vw - 32px)',
width: '500px',
})
);
@@ -579,6 +579,7 @@ export class GroupsViewComponent {
...(lockedGroupTitles ? { lockedGroupTitles } : {}),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
@@ -1,3 +1,13 @@
// The theme declares no Material system colours, so the error colour is a
// local token with a value per theme (>= 6:1 on the dialog surface in both).
:host {
--pin-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--pin-dialog-error-color: #ffb4ab;
}
.pin-dialog__title {
display: flex;
align-items: center;
@@ -12,7 +22,7 @@
.pin-dialog__description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.875rem;
}
@@ -31,14 +41,14 @@
.pin-dialog__error {
margin: 0;
color: var(--mat-sys-error);
color: var(--pin-dialog-error-color);
font-size: 0.8125rem;
}
.pin-dialog__note {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
@keyframes pin-dialog-shake {
@@ -137,6 +137,9 @@
type="button"
mat-icon-button
class="context-search__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
@@ -271,20 +271,26 @@ app-workspace-context-category-view {
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -963,6 +963,39 @@ describe('WorkspaceContextPanelComponent', () => {
expect(dialog.open).not.toHaveBeenCalled();
});
it('opens the Xtream category dialog capped at the viewport width', async () => {
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'xtream-1',
});
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
const component = fixture.componentInstance;
Object.defineProperty(component, 'canManageXtreamCategories', {
configurable: true,
value: () => true,
});
jest.spyOn(
TestBed.inject(ParentalLockService),
'requestUnlock'
).mockResolvedValue(true);
component.loadXtreamCategoryDialog = jest.fn(
async () => class DialogStub {}
);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(false) });
await component.openManageCategories();
// A 500px panel on a 375px phone would scroll sideways.
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
width: '500px',
maxWidth: 'calc(100vw - 32px)',
})
);
});
describe('Stalker lock dialog', () => {
function enableLock(): void {
const parentalLock = TestBed.inject(ParentalLockService);
@@ -1001,6 +1034,7 @@ describe('WorkspaceContextPanelComponent', () => {
{ category_id: '1', category_name: 'News' },
],
},
maxWidth: 'calc(100vw - 32px)',
})
);
});
@@ -567,6 +567,7 @@ export class WorkspaceContextPanelComponent {
this.dialog.open(dialogComponent, {
data: { playlistId: context.playlistId, contentType, categories },
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
}
@@ -620,6 +621,7 @@ export class WorkspaceContextPanelComponent {
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
dialogRef