mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
style(ui): one segmented control for toggles, season tabs and the switcher (#1954)
* style(ui): one segmented control for toggles, season tabs and the switcher The favorites and recent content and scope toggles, the episode list/grid switch and the season tabs (up to four seasons) become the kit's segmented control, the one the playlist switcher's type filter already used: a 1px-bordered track with 2px padding, 8px segments, no dividers, the chosen segment filled. In a header row the control is 32px tall like a small button. The season tabs keep their tab semantics: `segmented-track` and `segment` build the look from plain buttons, and only the track is the tab list, so "Back to playing" no longer sits inside it. The `segmented-control` mixin's label padding and check rules never matched: Material renders those elements without the including component's encapsulation attribute. They now go through `::ng-deep` inside the mixin, which also takes an icon size and a fixed segment width for icon-only segments. Unchosen labels move from the muted colour (about 2.9:1 on the track) to the body colour. The language filter in the VOD sources popover is no longer a native select: a filter chip with the kit's chevron opens a radio menu. Escape closes only that menu. A new PORTALS.MULTI_SOURCE.LANGUAGE key names it, translated in every locale. Docs: segmented control rule in the UI guidelines; stale season-picker facts (six seasons, dropdown thumbnails) corrected against the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): wrap a squeezed segmented control instead of clipping it The season tabs used to wrap pill by pill; inside the new track four translated season names overflowed the fullscreen episode panel, which clips, and the last tab was cut off. `segmented-track` now keeps the control within its container and wraps the segments inside the track, for the Material toggle groups as well. The E2E squeezes a season track and the scope toggle and checks that no segment leaves its frame, and it now closes its playlist server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3cc10966a3
commit
a76fb70965
38 files changed
+709
-208
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
The favorites and recent type and scope toggles, the season tabs, the
|
||||
episode list/grid switch and the playlist switcher's type filter now share
|
||||
one segmented control with easier-to-read labels. The language filter in the
|
||||
alternative sources popover opens a menu instead of the system dropdown.
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
addCurrentDetailToFavorites,
|
||||
goBackFromDetail,
|
||||
} from './dashboard-e2e-flows';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
buildM3uContent,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openGlobalFavorites,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { applyTheme, expectTextContrast } from './theme-contrast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every single choice shown side by side is the kit's segmented control: the
|
||||
// favorites content and scope toggles, the season tabs, the episode list/grid
|
||||
// switch and the playlist switcher's type filter. In a header row it is 32px
|
||||
// tall (26px segments in a 2px-padded, 1px-bordered track); the switcher
|
||||
// panel keeps 30px segments. Unchosen labels must stay legible in both
|
||||
// themes: the muted colour read about 2.9:1 on the track. A control never
|
||||
// grows past its container: squeezed, its segments wrap inside the track
|
||||
// rather than being clipped (four translated season names in the fullscreen
|
||||
// episode panel).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function frame(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
height: Math.round(element.getBoundingClientRect().height),
|
||||
radius: style.borderRadius,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Labels of the segments that stick out of the control's own frame. */
|
||||
async function segmentsOutside(control: Locator): Promise<string[]> {
|
||||
return control.evaluate((element) => {
|
||||
const frame = element.getBoundingClientRect();
|
||||
return Array.from(element.children)
|
||||
.filter((segment) => {
|
||||
const rect = segment.getBoundingClientRect();
|
||||
return (
|
||||
rect.left < frame.left - 0.5 ||
|
||||
rect.right > frame.right + 0.5 ||
|
||||
rect.bottom > frame.bottom + 0.5
|
||||
);
|
||||
})
|
||||
.map((segment) => segment.textContent?.trim() ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUnchosenLegible(page: Page, unchosen: Locator[]) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
for (const segment of unchosen) {
|
||||
await expectTextContrast(segment);
|
||||
}
|
||||
}
|
||||
await applyTheme(page, 'light');
|
||||
}
|
||||
|
||||
test.describe('Electron segmented controls', () => {
|
||||
test('draws the season tabs, list/grid switch, favorites toggles and switcher type filter as one legible segmented control', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
// A second source kind is what shows the switcher's type filter.
|
||||
const m3u = await createMutableTextServer(
|
||||
buildM3uContent([
|
||||
{
|
||||
name: 'Segment Channel',
|
||||
url: 'https://example.com/live/segment.m3u8',
|
||||
},
|
||||
]),
|
||||
{
|
||||
contentType: 'application/x-mpegurl; charset=utf-8',
|
||||
resourcePath: '/segmented.m3u',
|
||||
}
|
||||
);
|
||||
let app: Awaited<ReturnType<typeof launchElectronApp>> | undefined;
|
||||
|
||||
try {
|
||||
app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
await page.route('https://example.com/**', (route) =>
|
||||
route.abort()
|
||||
);
|
||||
await importM3uPlaylistFromUrl(page, m3u.resourceUrl);
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page.setViewportSize({ width: 1280, height: 760 });
|
||||
|
||||
// A three-season series: the tabs, not the season picker.
|
||||
await openWorkspaceSection(page, 'Series');
|
||||
const seriesCard = page
|
||||
.locator('app-category-content-view mat-card')
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 30_000 });
|
||||
await seriesCard.click();
|
||||
const tablist = page.locator(
|
||||
'app-season-tabs .season-tabs__track[role="tablist"]'
|
||||
);
|
||||
await expect(tablist).toBeVisible({ timeout: 30_000 });
|
||||
const tabs = tablist.getByRole('tab');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
await expect(tablist.locator(':scope > *')).toHaveCount(3);
|
||||
expect(await frame(tablist)).toEqual({
|
||||
height: 32,
|
||||
radius: '10px',
|
||||
});
|
||||
const viewSwitch = page.locator(
|
||||
'.view-toggle mat-button-toggle-group'
|
||||
);
|
||||
expect(await frame(viewSwitch)).toEqual({
|
||||
height: 32,
|
||||
radius: '10px',
|
||||
});
|
||||
await expectUnchosenLegible(page, [
|
||||
tabs.nth(1),
|
||||
viewSwitch.locator(
|
||||
'.mat-button-toggle:not(.mat-button-toggle-checked)'
|
||||
),
|
||||
]);
|
||||
|
||||
// Squeezed below its segments' width, the track wraps them and
|
||||
// stays inside the tabs' host.
|
||||
await tablist.evaluate((element) => {
|
||||
(
|
||||
element.closest('app-season-tabs') as HTMLElement
|
||||
).style.width = '140px';
|
||||
});
|
||||
expect(await segmentsOutside(tablist)).toEqual([]);
|
||||
expect(
|
||||
await tablist.evaluate(
|
||||
(element) =>
|
||||
element.getBoundingClientRect().right <=
|
||||
(
|
||||
element.closest('app-season-tabs') as HTMLElement
|
||||
).getBoundingClientRect().right +
|
||||
0.5
|
||||
)
|
||||
).toBe(true);
|
||||
await tablist.evaluate((element) => {
|
||||
(
|
||||
element.closest('app-season-tabs') as HTMLElement
|
||||
).style.width = '';
|
||||
});
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
const movieCard = page
|
||||
.locator('app-category-content-view mat-card')
|
||||
.first();
|
||||
await expect(movieCard).toBeVisible({ timeout: 30_000 });
|
||||
await movieCard.click();
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
|
||||
await openGlobalFavorites(page);
|
||||
const header = page
|
||||
.locator('app-unified-collection-page .collection-header')
|
||||
.first();
|
||||
const contentToggle = header.locator('.content-toggle');
|
||||
const scopeToggle = header.locator('.scope-toggle');
|
||||
await expect(contentToggle).toBeVisible({ timeout: 20_000 });
|
||||
for (const toggle of [contentToggle, scopeToggle]) {
|
||||
expect(await frame(toggle)).toEqual({
|
||||
height: 32,
|
||||
radius: '10px',
|
||||
});
|
||||
}
|
||||
const unchosen =
|
||||
'.mat-button-toggle:not(.mat-button-toggle-checked)';
|
||||
await expectUnchosenLegible(page, [
|
||||
contentToggle.locator(unchosen),
|
||||
scopeToggle.locator(unchosen),
|
||||
]);
|
||||
// A Material group wraps its toggles the same way.
|
||||
await scopeToggle.evaluate((element) => {
|
||||
(element as HTMLElement).style.width = '120px';
|
||||
});
|
||||
expect(await segmentsOutside(scopeToggle)).toEqual([]);
|
||||
await scopeToggle.evaluate((element) => {
|
||||
(element as HTMLElement).style.width = '';
|
||||
});
|
||||
|
||||
await page.getByTestId('playlist-switcher-trigger').click();
|
||||
const types = page.locator(
|
||||
'app-playlist-switcher-panel .switcher__types'
|
||||
);
|
||||
await expect(types).toBeVisible({ timeout: 10_000 });
|
||||
expect(await frame(types)).toEqual({
|
||||
height: 36,
|
||||
radius: '10px',
|
||||
});
|
||||
await expectUnchosenLegible(page, [
|
||||
types.locator(unchosen).first(),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
} finally {
|
||||
if (app) await closeElectronApp(app);
|
||||
await m3u.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "متاح",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "كل اللغات",
|
||||
"LANGUAGE": "اللغة",
|
||||
"SAME_AS_ABOVE": "كما في الأعلى",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "متوفر",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "كل اللغات",
|
||||
"LANGUAGE": "اللغة",
|
||||
"SAME_AS_ABOVE": "بحال اللي الفوق",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Даступныя",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Усе мовы",
|
||||
"LANGUAGE": "Мова",
|
||||
"SAME_AS_ABOVE": "як радком вышэй",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} з {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Verfügbar",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Alle Sprachen",
|
||||
"LANGUAGE": "Sprache",
|
||||
"SAME_AS_ABOVE": "wie oben",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} von {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Διαθέσιμα",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Όλες οι γλώσσες",
|
||||
"LANGUAGE": "Γλώσσα",
|
||||
"SAME_AS_ABOVE": "ίδιο με το παραπάνω",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} από {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Available",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "All languages",
|
||||
"LANGUAGE": "Language",
|
||||
"SAME_AS_ABOVE": "same as above",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} of {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Disponibles",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Todos los idiomas",
|
||||
"LANGUAGE": "Idioma",
|
||||
"SAME_AS_ABOVE": "igual que la anterior",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Disponibles",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Toutes les langues",
|
||||
"LANGUAGE": "Langue",
|
||||
"SAME_AS_ABOVE": "identique à la ligne au-dessus",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} sur {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Elérhető",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Minden nyelv",
|
||||
"LANGUAGE": "Nyelv",
|
||||
"SAME_AS_ABOVE": "ugyanaz, mint fent",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Disponibili",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Tutte le lingue",
|
||||
"LANGUAGE": "Lingua",
|
||||
"SAME_AS_ABOVE": "uguale alla riga sopra",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} di {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "利用可能",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "すべての言語",
|
||||
"LANGUAGE": "言語",
|
||||
"SAME_AS_ABOVE": "上と同じ",
|
||||
"SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "사용 가능",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "모든 언어",
|
||||
"LANGUAGE": "언어",
|
||||
"SAME_AS_ABOVE": "위와 동일",
|
||||
"SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Beschikbaar",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Alle talen",
|
||||
"LANGUAGE": "Taal",
|
||||
"SAME_AS_ABOVE": "zelfde als hierboven",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} van {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Dostępne",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Wszystkie języki",
|
||||
"LANGUAGE": "Język",
|
||||
"SAME_AS_ABOVE": "tak samo jak wyżej",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} z {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Disponíveis",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Todos os idiomas",
|
||||
"LANGUAGE": "Idioma",
|
||||
"SAME_AS_ABOVE": "igual à linha acima",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Доступные",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Все языки",
|
||||
"LANGUAGE": "Язык",
|
||||
"SAME_AS_ABOVE": "как строкой выше",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} из {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "Kullanılabilir",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "Tüm diller",
|
||||
"LANGUAGE": "Dil",
|
||||
"SAME_AS_ABOVE": "üsttekiyle aynı",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "可用",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "所有语言",
|
||||
"LANGUAGE": "语言",
|
||||
"SAME_AS_ABOVE": "与上行相同",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
|
||||
@@ -1480,6 +1480,7 @@
|
||||
"FILTER_AVAILABLE": "可用",
|
||||
"FILTER_HD": "HD+",
|
||||
"ALL_LANGUAGES": "所有語言",
|
||||
"LANGUAGE": "語言",
|
||||
"SAME_AS_ABOVE": "與上行相同",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
|
||||
@@ -309,19 +309,12 @@ strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` /
|
||||
same gates, from `PortalInlinePlayerComponent.seasonPosters` through
|
||||
`buildFullscreenEpisodePanelSeasons`.
|
||||
|
||||
Beyond six seasons the tabs become a `mat-menu` dropdown, and that dropdown
|
||||
carries **season thumbnails** from the same `seasonPosters` map
|
||||
(`SeasonTabsComponent.seasonPosters`, passed by the season container and by
|
||||
the fullscreen episode panel): a 28×42 poster projected into the leading slot
|
||||
of each menu row that has one, and the selected season's poster inside the
|
||||
closed trigger (`season-tabs__dropdown--with-thumb` tightens the pill around
|
||||
it). A season without a poster gets no placeholder tile — its row simply
|
||||
starts with the text — and a poster whose image request fails is dropped from
|
||||
both places rather than left as a broken-image frame (the component keeps its
|
||||
own failed-URL set, independent of the container's cover column). The pill
|
||||
row (six seasons or fewer) deliberately stays text-only: the design review
|
||||
rejected per-pill thumbnails as a second poster rail, and the season cover
|
||||
beside the tabs already shows the selected season's picture.
|
||||
`SeasonTabsComponent` draws both forms of the picker, on the detail page and
|
||||
in the fullscreen episode panel. Up to four seasons the tabs are the kit's
|
||||
segmented control (`role="tablist"` on the track only; the "back to playing
|
||||
episode" chip sits beside it). From five, the kit's toolbar picker (a small
|
||||
secondary button with a chevron) opens a `mat-menu` of radio rows. Neither
|
||||
form draws season artwork.
|
||||
|
||||
### Manual watched toggle for movies
|
||||
|
||||
|
||||
@@ -1061,13 +1061,12 @@ and drive the season container's presenters.
|
||||
|
||||
Every section below the hero (Episodes, Cast & crew, Similar) uses
|
||||
`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular
|
||||
counter, a lead slot (the 30px season pills, or past four seasons the
|
||||
season dropdown, a small secondary button with a chevron) and an end slot
|
||||
(the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
|
||||
counter, a lead slot (up to four seasons the season tabs, a segmented
|
||||
control; past four the season dropdown, a small secondary button with a
|
||||
chevron) and an end slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
|
||||
between a header and its content and 56px after the last one. The Episodes
|
||||
counter reads "N episodes · M watched". The list/grid switch is a segmented
|
||||
track whose checked segment is a neutral raised fill with heading ink; the
|
||||
list is the default view.
|
||||
counter reads "N episodes · M watched". The list/grid switch is the same
|
||||
segmented control with 30×26 icon segments; the list is the default view.
|
||||
|
||||
Episodes render through `app-episode-item` as a flat list row or grid card,
|
||||
never a bordered box. A row is a number column, a 168px 16:9 thumbnail (with
|
||||
@@ -1265,7 +1264,21 @@ current value where there is one; when the visible label is only the value,
|
||||
the accessible name adds what it picks ("Channels shown: Favorites"). An
|
||||
active picker (refinements applied) takes the selected-row tint. A panel
|
||||
header's icon actions use `ghost-icon-button`, as the category panel's
|
||||
search and sort do.
|
||||
search and sort do. In a row of filter chips (the VOD sources popover) the
|
||||
picker is a chip with the same chevron, so the row keeps one height; the
|
||||
language filter there opens a radio menu instead of a native `<select>`.
|
||||
|
||||
A single choice among a few options shown side by side is the kit's
|
||||
segmented control: a 1px-bordered track with 2px padding and a 10px radius,
|
||||
8px segments, no dividers, the chosen segment filled and in the heading
|
||||
colour. `segmented-control($height, $icon-size, $segment-width)` dresses a
|
||||
`mat-button-toggle-group` (the favorites and recent content and scope
|
||||
toggles, the list/grid switch, the playlist switcher's type filter);
|
||||
`segmented-track` and `segment` build it from plain buttons that are tabs
|
||||
(the season tabs). In a header row use 26px segments, so the control is
|
||||
32px tall like a small button. Unchosen labels use the body colour; the
|
||||
muted colour reads about 2.9:1 on the track. The settings theme switcher is
|
||||
still a pill switcher of its own.
|
||||
|
||||
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
|
||||
vocabulary, so a field reads the same wherever it appears:
|
||||
|
||||
@@ -933,7 +933,7 @@ be.
|
||||
the shared ≥90 % `watched` rule; the playing row is the one whose id equals
|
||||
`contentInfo.contentXtreamId`.
|
||||
- Body: `app-fullscreen-episode-panel` stamps `SeasonTabsComponent` (the
|
||||
detail page's tabs: pills up to six seasons, a dropdown beyond, watched
|
||||
detail page's tabs: segmented tabs up to four seasons, a menu beyond, watched
|
||||
check marks, the "Back to playing episode" chip) over the selected season's
|
||||
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
|
||||
case still looks designed; the `S01E03` label, runtime, watched check or
|
||||
|
||||
@@ -156,8 +156,8 @@ fit contract:
|
||||
The chip row composes with the host search (AND): **All (N)** resets the chip
|
||||
filters and states the total copy count, **Available** keeps only sources
|
||||
whose probe verified them, **HD+** keeps sources whose quality tag reads
|
||||
1080p or better, and the language select is built from the languages actually
|
||||
present in the list. Two of these encode a decision worth writing down:
|
||||
1080p or better, and the language chip (a filter chip with a chevron that
|
||||
opens a radio menu) offers the languages actually present in the list. Two of these encode a decision worth writing down:
|
||||
|
||||
- "Available" is strict: only `probe.status === 'ok'` passes. An unchecked
|
||||
source must never pass a filter with that name — and because checks are
|
||||
|
||||
+1
-1
@@ -119,8 +119,8 @@
|
||||
margin: 0 14px 8px;
|
||||
}
|
||||
|
||||
// The segment's 6px gap separates the count from its label.
|
||||
.switcher__type-count {
|
||||
margin-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
+2
-6
@@ -136,15 +136,11 @@
|
||||
>
|
||||
<mat-button-toggle value="playlist">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span class="toggle-label">{{
|
||||
'PORTALS.THIS_PLAYLIST' | translate
|
||||
}}</span>
|
||||
<span>{{ 'PORTALS.THIS_PLAYLIST' | translate }}</span>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="all">
|
||||
<mat-icon>public</mat-icon>
|
||||
<span class="toggle-label">{{
|
||||
'PORTALS.ALL_PLAYLISTS' | translate
|
||||
}}</span>
|
||||
<span>{{ 'PORTALS.ALL_PLAYLISTS' | translate }}</span>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
}
|
||||
|
||||
+11
-14
@@ -1,4 +1,5 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../../../../../ui/styles/app-controls' as controls;
|
||||
@use '../../styles/skeleton';
|
||||
|
||||
:host {
|
||||
@@ -91,29 +92,25 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// The loading header holds the toggles' place: a segmented control's 32px
|
||||
// frame and 10px radius, as wide as the English controls (three content
|
||||
// types; This playlist / All playlists).
|
||||
.toggle-placeholder {
|
||||
height: 43px;
|
||||
width: 120px;
|
||||
border-radius: 18px;
|
||||
height: 32px;
|
||||
width: 184px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mat-sys-on-surface) 6%, transparent);
|
||||
|
||||
&--scope {
|
||||
width: 200px;
|
||||
width: 222px;
|
||||
}
|
||||
}
|
||||
|
||||
// The kit's segmented control at the small-button height, the content types
|
||||
// beside the scope (This playlist / All playlists).
|
||||
.content-toggle,
|
||||
.scope-toggle {
|
||||
.toggle-label {
|
||||
margin-left: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
@include controls.segmented-control(26px);
|
||||
}
|
||||
|
||||
app-unified-live-tab,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../../styles/app-controls' as controls;
|
||||
|
||||
.seasons-section {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -69,64 +71,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Segmented list/grid switch: a 2px-padded track with 30×26 segments;
|
||||
// the checked one is a neutral raised segment with full-contrast ink.
|
||||
// The list/grid switch: the kit's segmented control with 30×26
|
||||
// icon-only segments, 32px tall like the season picker in this header.
|
||||
.view-toggle mat-button-toggle-group {
|
||||
gap: 2px;
|
||||
height: auto;
|
||||
padding: 2px;
|
||||
overflow: visible;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--app-widget-bg);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--app-eyebrow-color);
|
||||
|
||||
.mat-button-toggle-button {
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
@include controls.segmented-control(26px, 16px, 30px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,35 @@ describe('SeasonContainerComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps only the season tabs in the tab list, with Back to playing beside it', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
)[1] as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const tablists = fixture.nativeElement.querySelectorAll(
|
||||
'app-season-tabs [role="tablist"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
expect(tablists).toHaveLength(1);
|
||||
expect(
|
||||
Array.from(tablists[0].children).map((child) =>
|
||||
child.getAttribute('role')
|
||||
)
|
||||
).toEqual(['tab', 'tab']);
|
||||
const backToPlaying = fixture.nativeElement.querySelector(
|
||||
'[data-testid="back-to-playing"]'
|
||||
) as HTMLElement;
|
||||
expect(backToPlaying.closest('[role="tablist"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the inline-playing episode as playing and current', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="season-tabs" [attr.role]="useDropdown() ? null : 'tablist'">
|
||||
<div class="season-tabs">
|
||||
@if (useDropdown()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
@@ -59,23 +59,29 @@
|
||||
}
|
||||
</mat-menu>
|
||||
} @else {
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
class="season-tabs__pill"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="seasonKey === selectedSeason()"
|
||||
[class.season-tabs__pill--active]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
{{ 'PORTALS.SEASON_TAB' | translate: { number: seasonKey } }}
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<!-- Only the tabs sit in the tab list; "Back to playing" is a
|
||||
separate action beside it. -->
|
||||
<div class="season-tabs__track" role="tablist">
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
class="season-tabs__pill"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="seasonKey === selectedSeason()"
|
||||
[class.season-tabs__pill--active]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB' | translate: { number: seasonKey }
|
||||
}}
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showBackToPlaying()) {
|
||||
|
||||
@@ -11,40 +11,22 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
// Season pills share the section header's 30px row; past four seasons
|
||||
// the kit's toolbar picker (a small secondary button with a chevron)
|
||||
// opens the season menu instead.
|
||||
// Up to four seasons are tabs in the kit's segmented control: 26px
|
||||
// segments in a 32px track. Past four the toolbar picker, a small
|
||||
// secondary button with a chevron, opens the season menu instead. Both
|
||||
// are as tall as the list/grid switch at the header's far edge.
|
||||
&__track {
|
||||
@include controls.segmented-track;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
@include controls.segment;
|
||||
|
||||
&--active {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 14%,
|
||||
transparent
|
||||
);
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
@include controls.segment-selected;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
|
||||
import { VodSourcesChipComponent } from './vod-sources-chip.component';
|
||||
|
||||
function createSource(
|
||||
overrides: Partial<VodSourceDescriptor>
|
||||
): VodSourceDescriptor {
|
||||
return {
|
||||
id: 'playlist-1:xtream:42',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Portal',
|
||||
portalType: 'xtream',
|
||||
contentId: 42,
|
||||
rawTitle: 'Example Movie',
|
||||
matchConfidence: 'exact',
|
||||
year: 2024,
|
||||
isActive: false,
|
||||
isPinned: false,
|
||||
isTried: false,
|
||||
probe: { status: 'idle' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('VodSourcesChipComponent', () => {
|
||||
let fixture: ComponentFixture<VodSourcesChipComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodSourcesChipComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VodSourcesChipComponent);
|
||||
fixture.componentRef.setInput('sources', [
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
playlistId: 'a',
|
||||
playlistName: 'Portal A',
|
||||
rawTitle: 'EN| Example Movie (2024)',
|
||||
}),
|
||||
createSource({
|
||||
id: 'b:xtream:2',
|
||||
playlistId: 'b',
|
||||
playlistName: 'Portal B',
|
||||
rawTitle: 'DE| Example Movie (2024)',
|
||||
}),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('closes only the language menu on Escape: the sources popover stays open', async () => {
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.sources-chip'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const languageChip = document.querySelector<HTMLButtonElement>(
|
||||
'.sources-menu__lang'
|
||||
);
|
||||
expect(languageChip).not.toBeNull();
|
||||
languageChip?.focus();
|
||||
languageChip?.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(languageChip?.getAttribute('aria-expanded')).toBe('true');
|
||||
const row = document.querySelector<HTMLElement>(
|
||||
'.sources-language-menu [role="menuitemradio"]'
|
||||
);
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
// The overlay dispatcher hands a keydown to the topmost overlay
|
||||
// only: the language menu, not the popover beneath it.
|
||||
row?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(languageChip?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(fixture.componentInstance.isOpen()).toBe(true);
|
||||
expect(document.querySelector('.sources-menu__lang')).toBe(
|
||||
languageChip
|
||||
);
|
||||
|
||||
// Material detaches the menu after its exit animation; with the
|
||||
// language menu gone, the next Escape reaches the popover.
|
||||
for (
|
||||
let wait = 0;
|
||||
wait < 20 && document.querySelector('.sources-language-menu');
|
||||
wait++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
expect(document.querySelector('.sources-language-menu')).toBeNull();
|
||||
languageChip?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -98,21 +98,58 @@
|
||||
}
|
||||
|
||||
@if (languages().length > 0) {
|
||||
<select
|
||||
class="sources-menu__lang"
|
||||
[ngModel]="filters().language"
|
||||
(ngModelChange)="setLanguageFilter($event)"
|
||||
@let language = filters().language;
|
||||
@let languageLabel =
|
||||
language ?? ('PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate);
|
||||
<!-- A chip like its neighbours, with the kit's chevron: it opens
|
||||
the language menu. The name says what it picks. -->
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip sources-menu__lang"
|
||||
[class.filter-chip--on]="language !== null"
|
||||
[matMenuTriggerFor]="languageMenu"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate
|
||||
('PORTALS.MULTI_SOURCE.LANGUAGE' | translate) +
|
||||
': ' +
|
||||
languageLabel
|
||||
"
|
||||
>
|
||||
<option [ngValue]="null">
|
||||
{{ 'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate }}
|
||||
</option>
|
||||
@for (lang of languages(); track lang) {
|
||||
<option [ngValue]="lang">{{ lang }}</option>
|
||||
}
|
||||
</select>
|
||||
<span class="sources-menu__lang-label">{{
|
||||
languageLabel
|
||||
}}</span>
|
||||
<mat-icon class="sources-menu__lang-chevron" aria-hidden="true"
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
</button>
|
||||
<mat-menu #languageMenu="matMenu" class="sources-language-menu">
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.MULTI_SOURCE.LANGUAGE' | translate
|
||||
"
|
||||
>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="language === null"
|
||||
(click)="setLanguageFilter(null)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{
|
||||
'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
@for (lang of languages(); track lang) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="language === lang"
|
||||
(click)="setLanguageFilter(lang)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{ lang }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use '../../../../styles/app-controls' as controls;
|
||||
@use '../../../../styles/focus-ring';
|
||||
|
||||
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
@@ -264,22 +265,27 @@ $font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// The language filter: a `filter-chip` at the row's end that opens the
|
||||
// language menu, on while a language is picked.
|
||||
.sources-menu__lang {
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
max-width: 40%;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--app-widget-border);
|
||||
border-radius: 999px;
|
||||
background: var(--app-card-hover-bg);
|
||||
color: var(--app-eyebrow-color);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
outline: 0;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
padding-inline-end: 8px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring.focus-ring-declarations;
|
||||
.sources-menu__lang-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sources-menu__lang-chevron {
|
||||
@include controls.trigger-chevron;
|
||||
|
||||
.filter-chip--on & {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class FakeTranslateLoader implements TranslateLoader {
|
||||
FILTER_AVAILABLE: 'Available',
|
||||
FILTER_HD: 'HD+',
|
||||
ALL_LANGUAGES: 'All languages',
|
||||
LANGUAGE: 'Language',
|
||||
SAME_AS_ABOVE: 'same as above',
|
||||
SHOWING_OF_TOTAL: '{{visible}} of {{total}}',
|
||||
},
|
||||
@@ -373,7 +374,26 @@ describe('VodSourcesMenuComponent', () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('builds the language select from parsed title prefixes and filters by it', () => {
|
||||
function languageTrigger(): HTMLButtonElement {
|
||||
return fixture.debugElement.query(By.css('.sources-menu__lang'))
|
||||
.nativeElement;
|
||||
}
|
||||
|
||||
/** Opens the language menu; its rows render in the overlay. */
|
||||
function openLanguageMenu(): HTMLButtonElement[] {
|
||||
languageTrigger().click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.sources-language-menu [role="menuitemradio"]'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rowText = (row: HTMLElement) =>
|
||||
(row.textContent ?? '').replace('check', '').trim();
|
||||
|
||||
it('offers the parsed title prefixes in a radio menu and filters by the chosen one', () => {
|
||||
render([
|
||||
createSource({
|
||||
id: 'a:xtream:1',
|
||||
@@ -389,18 +409,35 @@ describe('VodSourcesMenuComponent', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const select = fixture.debugElement.query(
|
||||
By.css('.sources-menu__lang')
|
||||
// No native select: a chip trigger named with what it picks.
|
||||
expect(fixture.nativeElement.querySelector('select')).toBeNull();
|
||||
const trigger = languageTrigger();
|
||||
expect(trigger.getAttribute('aria-label')).toBe(
|
||||
'Language: All languages'
|
||||
);
|
||||
const options = select.nativeElement
|
||||
.textContent as string;
|
||||
expect(options).toContain('EN');
|
||||
expect(options).toContain('RU');
|
||||
expect(trigger.classList).not.toContain('filter-chip--on');
|
||||
|
||||
fixture.componentInstance.setLanguageFilter('RU');
|
||||
let rows = openLanguageMenu();
|
||||
expect(rows.map(rowText)).toEqual(['All languages', 'EN', 'RU']);
|
||||
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(
|
||||
['true', 'false', 'false']
|
||||
);
|
||||
|
||||
rows[2].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(rowNames()).toEqual(['Russian Portal']);
|
||||
expect(trigger.getAttribute('aria-label')).toBe('Language: RU');
|
||||
expect(trigger.textContent).toContain('RU');
|
||||
expect(trigger.classList).toContain('filter-chip--on');
|
||||
|
||||
rows = openLanguageMenu();
|
||||
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(
|
||||
['false', 'false', 'true']
|
||||
);
|
||||
rows[0].click();
|
||||
fixture.detectChanges();
|
||||
expect(rowNames()).toEqual(['English Portal', 'Russian Portal']);
|
||||
});
|
||||
|
||||
it('offers a category-derived language when the title has no prefix', () => {
|
||||
@@ -420,12 +457,10 @@ describe('VodSourcesMenuComponent', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const select = fixture.debugElement.query(
|
||||
By.css('.sources-menu__lang')
|
||||
);
|
||||
expect(select.nativeElement.textContent).toContain('DE');
|
||||
const rows = openLanguageMenu();
|
||||
expect(rows.map(rowText)).toEqual(['All languages', 'DE']);
|
||||
|
||||
fixture.componentInstance.setLanguageFilter('DE');
|
||||
rows[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(rowNames()).toEqual(['German Portal']);
|
||||
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import {
|
||||
MatSlideToggle,
|
||||
MatSlideToggleChange,
|
||||
} from '@angular/material/slide-toggle';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
} from '../menu-item-radio/menu-item-radio.directive';
|
||||
import {
|
||||
foldSearchText,
|
||||
playlistDisplayLabel,
|
||||
@@ -52,7 +57,12 @@ const FILTER_FROM = 8;
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
MatMenuTrigger,
|
||||
MatSlideToggle,
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
TranslatePipe,
|
||||
VodSourceCopyRowComponent,
|
||||
VodSourceRowComponent,
|
||||
|
||||
@@ -281,38 +281,106 @@ $_sizes: (
|
||||
}
|
||||
}
|
||||
|
||||
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
|
||||
/// dividers, the chosen segment filled.
|
||||
@mixin segmented-control($height: 30px) {
|
||||
// The segmented control: a recessed track, no dividers, the chosen segment
|
||||
// filled. `segmented-control` dresses a `mat-button-toggle-group`;
|
||||
// `segmented-track` and `segment` build the same look from plain buttons
|
||||
// (the season tabs, which are a tab list). An unchosen segment is text in the
|
||||
// body colour: the muted colour reads about 2.9:1 on the track, under the
|
||||
// 4.5:1 its 12.5px label needs.
|
||||
$_segment-text: var(--app-body-color);
|
||||
$_segment-selected-text: var(--app-heading-color);
|
||||
$_segment-selected-fill: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
);
|
||||
$_segment-hover-fill: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
|
||||
$_segment-text-size: 12.5px;
|
||||
|
||||
/// The track: 2px padding inside a 1px border, so it adds 6px to the
|
||||
/// segments' height (26px segments give a 32px control, a small button's
|
||||
/// height).
|
||||
@mixin segmented-track {
|
||||
display: inline-flex;
|
||||
// Never wider than its container: when the segments do not fit (four
|
||||
// translated season names in the fullscreen episode panel, a narrow
|
||||
// window), they wrap inside the track instead of being clipped.
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
border-radius: $radius;
|
||||
background: var(--app-content-bg);
|
||||
}
|
||||
|
||||
/// A plain-button segment of `segmented-track`. Mark the chosen one with
|
||||
/// `segment-selected`.
|
||||
@mixin segment($height: 26px) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: $height;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
color: $_segment-text;
|
||||
font: inherit;
|
||||
font-size: $_segment-text-size;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $_segment-hover-fill;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hovering the chosen segment keeps its fill.
|
||||
@mixin segment-selected {
|
||||
&,
|
||||
&:hover {
|
||||
background: $_segment-selected-fill;
|
||||
color: $_segment-selected-text;
|
||||
}
|
||||
}
|
||||
|
||||
/// `mat-button-toggle-group` as the segmented control. `$height` is the
|
||||
/// segment's height (see `segmented-track`). `$icon-size` sizes a segment's
|
||||
/// `mat-icon`; `$segment-width` gives icon-only segments one width (the
|
||||
/// list/grid switch is 30×26).
|
||||
///
|
||||
/// Include it from a component stylesheet. Material renders a segment's
|
||||
/// label and check without the including component's encapsulation
|
||||
/// attribute, so those rules go through `::ng-deep`, scoped by the selector
|
||||
/// that includes the mixin.
|
||||
@mixin segmented-control(
|
||||
$height: 30px,
|
||||
$icon-size: 14px,
|
||||
$segment-width: null
|
||||
) {
|
||||
@include mat.button-toggle-overrides(
|
||||
(
|
||||
height: $height,
|
||||
shape: 10px,
|
||||
shape: $radius,
|
||||
divider-color: transparent,
|
||||
background-color: transparent,
|
||||
text-color: var(--app-muted-color),
|
||||
selected-state-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
),
|
||||
selected-state-text-color: var(--app-heading-color),
|
||||
text-color: $_segment-text,
|
||||
selected-state-background-color: $_segment-selected-fill,
|
||||
selected-state-text-color: $_segment-selected-text,
|
||||
state-layer-color: var(--app-on-surface),
|
||||
hover-state-layer-opacity: 0.06,
|
||||
focus-state-layer-opacity: 0.06,
|
||||
label-text-size: 12.5px,
|
||||
label-text-size: $_segment-text-size,
|
||||
label-text-weight: 500,
|
||||
label-text-tracking: 0,
|
||||
)
|
||||
);
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
background: var(--app-content-bg);
|
||||
@include segmented-track;
|
||||
|
||||
.mat-button-toggle {
|
||||
border-radius: 8px;
|
||||
border-radius: $radius-sm;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -322,11 +390,34 @@ $_sizes: (
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
@include _icon-size($icon-size);
|
||||
|
||||
.mat-pseudo-checkbox {
|
||||
display: none;
|
||||
::ng-deep {
|
||||
// A flex row centres an icon beside the label. Its height is set
|
||||
// here because an icon-only segment has no text line to give it one,
|
||||
// and top alignment keeps an icon's baseline from adding a pixel.
|
||||
.mat-button-toggle-label-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: $height;
|
||||
padding: 0 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mat-pseudo-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@if $segment-width {
|
||||
.mat-button-toggle-button {
|
||||
width: $segment-width;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user