From dc3f8298071e1f23206a8ed3d8b3004ce3e8f3cb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:03:29 +0200 Subject: [PATCH] fix(parental-lock): focus, tokens and phone width for lock UI (#1761) --- .changes/settings-parental-lock-ui-polish.md | 6 + apps/web-e2e/src/parental-lock-ui.e2e.ts | 412 ++++++++++++++++++ docs/architecture/parental-lock.md | 9 +- ...talker-category-lock-dialog.component.html | 3 + ...talker-category-lock-dialog.component.scss | 58 ++- ...ker-category-lock-dialog.component.spec.ts | 12 + .../category-management-dialog.component.html | 101 +++-- .../category-management-dialog.component.scss | 8 +- ...tegory-management-dialog.component.spec.ts | 140 +++++- .../category-management-dialog.component.ts | 94 ++-- .../group-management-dialog.component.html | 3 + .../group-management-dialog.component.scss | 8 +- .../groups-view/groups-view.component.scss | 18 +- .../groups-view/groups-view.component.spec.ts | 1 + .../groups-view/groups-view.component.ts | 1 + .../parental-lock-pin-dialog.component.scss | 16 +- .../workspace-context-panel.component.html | 3 + .../workspace-context-panel.component.scss | 18 +- .../workspace-context-panel.component.spec.ts | 34 ++ .../workspace-context-panel.component.ts | 2 + 20 files changed, 826 insertions(+), 121 deletions(-) create mode 100644 .changes/settings-parental-lock-ui-polish.md create mode 100644 apps/web-e2e/src/parental-lock-ui.e2e.ts diff --git a/.changes/settings-parental-lock-ui-polish.md b/.changes/settings-parental-lock-ui-polish.md new file mode 100644 index 000000000..58c0e3a62 --- /dev/null +++ b/.changes/settings-parental-lock-ui-polish.md @@ -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. diff --git a/apps/web-e2e/src/parental-lock-ui.e2e.ts b/apps/web-e2e/src/parental-lock-ui.e2e.ts new file mode 100644 index 000000000..15a37f4bd --- /dev/null +++ b/apps/web-e2e/src/parental-lock-ui.e2e.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return toggle.evaluate((el) => { + const style = getComputedStyle(el); + return `${style.color} ${style.backgroundColor}`; + }); +} + +async function expectPaintedBorder( + locator: Locator, + style: 'dashed' | 'solid' +): Promise { + 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 { + 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'); + } + }); +}); diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d049b35b0..d9b871fb0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html index 4c77372d8..c8c50ebe9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html @@ -47,6 +47,9 @@ type="button" mat-icon-button class="search-inline__clear" + [attr.aria-label]=" + 'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate + " (click)="clearSearch()" > close diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss index 6fa341147..6cda75f1b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss @@ -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; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts index 0e410f21b..e78aec3c5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts @@ -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' + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 9ade8e26d..789ade247 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -7,48 +7,66 @@

{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}

} @else { -
-
- {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: - {{ selectedCount() }} / {{ totalCount() }} - @if (showLocks()) { - - · {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: - {{ lockedCount() }} - + + @if (supportsVisibility || showLocks()) { +
+
+ @if (supportsVisibility) { + {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: + {{ selectedCount() }} / + {{ totalCount() }} + } + @if (showLocks()) { + + @if (supportsVisibility) { + · + } + {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: + {{ lockedCount() }} + + } +
+ @if (supportsVisibility) { +
+ + +
}
-
- - -
-
+ }