mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(parental-lock): focus, tokens and phone width for lock UI (#1761)
This commit is contained in:
1 parent
03dfafd68b
commit
dc3f829807
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.
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+40
-18
@@ -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;
|
||||
}
|
||||
+12
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+61
-40
@@ -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>
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
-8
@@ -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'],
|
||||
|
||||
+61
-33
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+1
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
+13
-3
@@ -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 {
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+34
@@ -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)',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user