mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## Summary Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together: 1. **Categories + channels + player** (browse, unchanged). 2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back. 3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1. Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session. ## Design notes - Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex). - The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area. - `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider. - M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched. ## Merged with #1555 (per-surface rail state) #1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree. ## Also fixed along the way - The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now. - A collapsed context panel left a 22px padding strip beside the channels rail. - The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales). Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0dcfba7045
commit
0a2373f192
68 files changed
+2710
-197
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: portals
|
||||
---
|
||||
|
||||
Live TV panels now fold in steps: hide only the categories rail and keep the
|
||||
channel list, or hide both for a player-only view. With categories hidden, the
|
||||
list header becomes a category dropdown, so switching categories stays one
|
||||
click away, and hiding both panels then bringing them back returns to the
|
||||
level you had before.
|
||||
@@ -260,6 +260,36 @@ never narrows shared provider pages or resets their index. Category changes
|
||||
reset list windows and retain playback/active EPG. Contract:
|
||||
`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
|
||||
|
||||
## Live TV Panel Levels
|
||||
|
||||
Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
|
||||
from the outside in, in three nested levels owned by `LiveSidebarState`
|
||||
(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
|
||||
+ player), `categories-hidden` (channels rail + player) and `collapsed`
|
||||
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
|
||||
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
|
||||
shell context sidebar folds the categories rail on
|
||||
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
|
||||
a selected category — the live root has no channels header to host the way
|
||||
back — and always at level 3), the channels rail folds on
|
||||
`isCollapsedFor('portal')`. While the rail is folded the
|
||||
channels header turns its title into a category dropdown that opens the same
|
||||
`WorkspaceContextPanelComponent` as a CDK popover through the
|
||||
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
|
||||
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
|
||||
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
|
||||
the live layouts reach it through `createLivePanelsController()` (level
|
||||
flags, dropdown bridge and focus handoff in one shared object; the token is
|
||||
optional). `Cmd/Ctrl+B`, the header toggle and the
|
||||
floating restore handle return to the level the user collapsed from (the
|
||||
target is session-only; every level is restored as stored per surface).
|
||||
Folded rails carry `inert`, and
|
||||
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
|
||||
replacement affordance only when the activated button was removed or inerted.
|
||||
M3U and the unified live tab have no categories rail and treat level 2 like
|
||||
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
|
||||
("Collapsible Live Sidebar").
|
||||
|
||||
## Channel and Detail Keyboard Scrolling
|
||||
|
||||
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
|
||||
|
||||
@@ -1739,6 +1739,36 @@ preventing destination failures from penalizing the initial endpoint. Contracts:
|
||||
copy and Retry now; Stalker preserves cached account data on a failed refresh.
|
||||
Contract: `docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Live TV Panel Levels
|
||||
|
||||
Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
|
||||
from the outside in, in three nested levels owned by `LiveSidebarState`
|
||||
(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
|
||||
+ player), `categories-hidden` (channels rail + player) and `collapsed`
|
||||
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
|
||||
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
|
||||
shell context sidebar folds the categories rail on
|
||||
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
|
||||
a selected category — the live root has no channels header to host the way
|
||||
back — and always at level 3), the channels rail folds on
|
||||
`isCollapsedFor('portal')`. While the rail is folded the
|
||||
channels header turns its title into a category dropdown that opens the same
|
||||
`WorkspaceContextPanelComponent` as a CDK popover through the
|
||||
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
|
||||
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
|
||||
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
|
||||
the live layouts reach it through `createLivePanelsController()` (level
|
||||
flags, dropdown bridge and focus handoff in one shared object; the token is
|
||||
optional). `Cmd/Ctrl+B`, the header toggle and the
|
||||
floating restore handle return to the level the user collapsed from (the
|
||||
target is session-only; every level is restored as stored per surface).
|
||||
Folded rails carry `inert`, and
|
||||
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
|
||||
replacement affordance only when the activated button was removed or inerted.
|
||||
M3U and the unified live tab have no categories rail and treat level 2 like
|
||||
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
|
||||
("Collapsible Live Sidebar").
|
||||
|
||||
## Live Channel Return
|
||||
|
||||
Xtream and Stalker (including radio) capture displayed playback order on explicit
|
||||
|
||||
@@ -82,6 +82,13 @@ test.describe('Electron Remote Control', () => {
|
||||
.getByTestId('channel-item');
|
||||
await expect(rows.nth(2)).toBeVisible();
|
||||
if (provider === 'xtream') {
|
||||
// Read the order before sorting: the menu closes before the
|
||||
// virtual list re-renders, and reading the titles too early
|
||||
// captured the server order while the click below landed
|
||||
// on the re-sorted first row (flaky on slow runners).
|
||||
const serverOrder = await rows
|
||||
.locator('.channel-name')
|
||||
.allTextContents();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sort channels',
|
||||
@@ -91,6 +98,11 @@ test.describe('Electron Remote Control', () => {
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name Z-A' })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
rows.locator('.channel-name').allTextContents()
|
||||
)
|
||||
.not.toEqual(serverOrder);
|
||||
}
|
||||
const titles = await rows
|
||||
.locator('.channel-name')
|
||||
@@ -448,15 +460,24 @@ async function waitForRemoteStatus(
|
||||
): Promise<RemoteControlStatus> {
|
||||
let latestStatus: RemoteControlStatus | null = null;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
latestStatus = await getRemoteStatus(request, port);
|
||||
return latestStatus ? predicate(latestStatus) : false;
|
||||
},
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toBe(true);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
latestStatus = await getRemoteStatus(request, port);
|
||||
return latestStatus ? predicate(latestStatus) : false;
|
||||
},
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toBe(true);
|
||||
} catch (error) {
|
||||
// The predicate alone says nothing about how far the status got;
|
||||
// surface the last payload so a CI failure is diagnosable.
|
||||
throw new Error(
|
||||
`Remote status never matched. Last status: ${JSON.stringify(latestStatus)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
return latestStatus as RemoteControlStatus;
|
||||
}
|
||||
|
||||
@@ -513,22 +513,50 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The rail's own chevron hides the list; the workspace header carries a
|
||||
// second toggle with the same accessible name, so scope to the rail.
|
||||
// Level 2: only the categories rail folds; the channels header turns
|
||||
// its title into a category dropdown that opens the same rail as a
|
||||
// popover, whose footer brings the rail back.
|
||||
await page
|
||||
.locator('.sidebar')
|
||||
.getByRole('button', { name: 'Hide channels list' })
|
||||
.getByRole('button', { name: 'Hide categories', exact: true })
|
||||
.click();
|
||||
const restoreButton = page.locator('.sidebar-restore');
|
||||
const categoryDropdown = page.locator(
|
||||
'[data-test-id="live-category-dropdown"]'
|
||||
);
|
||||
// The folded rail is 0px wide and inert; its rows keep their own boxes,
|
||||
// so the rail's attribute is the reliable signal.
|
||||
const categoriesRail = page.locator('aside.context-panel--route');
|
||||
await expect(categoryDropdown).toBeVisible();
|
||||
await expect(categoriesRail).toHaveAttribute('inert', '');
|
||||
await categoryDropdown.click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Show categories panel' })
|
||||
.click();
|
||||
await expect(categoryDropdown).toBeHidden();
|
||||
await expect(categoriesRail).not.toHaveAttribute('inert');
|
||||
await expect(categories.nth(1)).toBeVisible();
|
||||
|
||||
// Level 3: both rails fold; the floating handle restores them.
|
||||
await page
|
||||
.getByRole('button', { name: 'Hide categories and channels' })
|
||||
.click();
|
||||
const restoreButton = page.getByRole('button', {
|
||||
name: 'Show categories and channels',
|
||||
});
|
||||
await expect(restoreButton).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .header-sidebar-toggle')
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(categoriesRail).toHaveAttribute('inert', '');
|
||||
await expect(page.locator('.sidebar')).toHaveAttribute('inert', '');
|
||||
// The workspace header carries a second toggle that mirrors the state.
|
||||
const headerToggle = page.locator(
|
||||
'app-workspace-shell-header .header-sidebar-toggle'
|
||||
);
|
||||
await expect(headerToggle).toHaveAttribute('aria-pressed', 'false');
|
||||
await restoreButton.click();
|
||||
await expect(restoreButton).toBeHidden();
|
||||
await expect(headerToggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(categoriesRail).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('.sidebar')).not.toHaveAttribute('inert');
|
||||
await expect(stations.first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .header-sidebar-toggle')
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||||
expect(epgRequests).toHaveLength(0);
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخفية",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "إخفاء الفئات",
|
||||
"SHOW_CATEGORIES": "إظهار الفئات",
|
||||
"SHOW_CATEGORIES_PANEL": "إظهار لوحة الفئات",
|
||||
"CHOOSE_CATEGORY": "اختيار الفئة",
|
||||
"HIDE_PANELS": "إخفاء الفئات والقنوات",
|
||||
"HIDE_PANELS_TOOLTIP": "إخفاء اللوحات (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "إظهار الفئات والقنوات",
|
||||
"SHOW_PANELS_TOOLTIP": "إظهار اللوحات (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخبية",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "إخفاء الفئات",
|
||||
"SHOW_CATEGORIES": "إظهار الفئات",
|
||||
"SHOW_CATEGORIES_PANEL": "إظهار لوحة الفئات",
|
||||
"CHOOSE_CATEGORY": "اختار فئة",
|
||||
"HIDE_PANELS": "إخفاء الفئات والقنوات",
|
||||
"HIDE_PANELS_TOOLTIP": "إخفاء اللوحات (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "إظهار الفئات والقنوات",
|
||||
"SHOW_PANELS_TOOLTIP": "إظهار اللوحات (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Паказаць спіс каналаў",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Спіс каналаў схаваны",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Схаваць катэгорыі",
|
||||
"SHOW_CATEGORIES": "Паказаць катэгорыі",
|
||||
"SHOW_CATEGORIES_PANEL": "Паказаць панэль катэгорый",
|
||||
"CHOOSE_CATEGORY": "Выбраць катэгорыю",
|
||||
"HIDE_PANELS": "Схаваць катэгорыі і каналы",
|
||||
"HIDE_PANELS_TOOLTIP": "Схаваць панэлі (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Паказаць катэгорыі і каналы",
|
||||
"SHOW_PANELS_TOOLTIP": "Паказаць панэлі (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Senderliste einblenden",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Die Senderliste ist ausgeblendet",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein.",
|
||||
"HIDE_CATEGORIES": "Kategorien ausblenden",
|
||||
"SHOW_CATEGORIES": "Kategorien einblenden",
|
||||
"SHOW_CATEGORIES_PANEL": "Kategorien-Panel einblenden",
|
||||
"CHOOSE_CATEGORY": "Kategorie auswählen",
|
||||
"HIDE_PANELS": "Kategorien und Sender ausblenden",
|
||||
"HIDE_PANELS_TOOLTIP": "Panels ausblenden (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Kategorien und Sender einblenden",
|
||||
"SHOW_PANELS_TOOLTIP": "Panels einblenden (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Η λίστα καναλιών είναι κρυφή",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Απόκρυψη κατηγοριών",
|
||||
"SHOW_CATEGORIES": "Εμφάνιση κατηγοριών",
|
||||
"SHOW_CATEGORIES_PANEL": "Εμφάνιση πίνακα κατηγοριών",
|
||||
"CHOOSE_CATEGORY": "Επιλογή κατηγορίας",
|
||||
"HIDE_PANELS": "Απόκρυψη κατηγοριών και καναλιών",
|
||||
"HIDE_PANELS_TOOLTIP": "Απόκρυψη πινάκων (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Εμφάνιση κατηγοριών και καναλιών",
|
||||
"SHOW_PANELS_TOOLTIP": "Εμφάνιση πινάκων (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Show channels list",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "The channels list is hidden",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Hide categories",
|
||||
"SHOW_CATEGORIES": "Show categories",
|
||||
"SHOW_CATEGORIES_PANEL": "Show categories panel",
|
||||
"CHOOSE_CATEGORY": "Choose category",
|
||||
"HIDE_PANELS": "Hide categories and channels",
|
||||
"HIDE_PANELS_TOOLTIP": "Hide panels (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Show categories and channels",
|
||||
"SHOW_PANELS_TOOLTIP": "Show panels (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Show playing channel",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "La lista de canales está oculta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Ocultar categorías",
|
||||
"SHOW_CATEGORIES": "Mostrar categorías",
|
||||
"SHOW_CATEGORIES_PANEL": "Mostrar panel de categorías",
|
||||
"CHOOSE_CATEGORY": "Elegir categoría",
|
||||
"HIDE_PANELS": "Ocultar categorías y canales",
|
||||
"HIDE_PANELS_TOOLTIP": "Ocultar paneles (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Mostrar categorías y canales",
|
||||
"SHOW_PANELS_TOOLTIP": "Mostrar paneles (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "La liste des chaînes est masquée",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Masquer les catégories",
|
||||
"SHOW_CATEGORIES": "Afficher les catégories",
|
||||
"SHOW_CATEGORIES_PANEL": "Afficher le panneau des catégories",
|
||||
"CHOOSE_CATEGORY": "Choisir une catégorie",
|
||||
"HIDE_PANELS": "Masquer les catégories et les chaînes",
|
||||
"HIDE_PANELS_TOOLTIP": "Masquer les panneaux (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Afficher les catégories et les chaînes",
|
||||
"SHOW_PANELS_TOOLTIP": "Afficher les panneaux (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Csatornalista megjelenítése",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "A csatornalista el van rejtve",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra.",
|
||||
"HIDE_CATEGORIES": "Kategóriák elrejtése",
|
||||
"SHOW_CATEGORIES": "Kategóriák megjelenítése",
|
||||
"SHOW_CATEGORIES_PANEL": "Kategóriapanel megjelenítése",
|
||||
"CHOOSE_CATEGORY": "Kategória kiválasztása",
|
||||
"HIDE_PANELS": "Kategóriák és csatornák elrejtése",
|
||||
"HIDE_PANELS_TOOLTIP": "Panelek elrejtése (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Kategóriák és csatornák megjelenítése",
|
||||
"SHOW_PANELS_TOOLTIP": "Panelek megjelenítése (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "L'elenco canali è nascosto",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Nascondi categorie",
|
||||
"SHOW_CATEGORIES": "Mostra categorie",
|
||||
"SHOW_CATEGORIES_PANEL": "Mostra il pannello delle categorie",
|
||||
"CHOOSE_CATEGORY": "Scegli categoria",
|
||||
"HIDE_PANELS": "Nascondi categorie e canali",
|
||||
"HIDE_PANELS_TOOLTIP": "Nascondi i pannelli (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Mostra categorie e canali",
|
||||
"SHOW_PANELS_TOOLTIP": "Mostra i pannelli (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "チャンネル一覧は非表示です",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。"
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。",
|
||||
"HIDE_CATEGORIES": "カテゴリーを非表示",
|
||||
"SHOW_CATEGORIES": "カテゴリーを表示",
|
||||
"SHOW_CATEGORIES_PANEL": "カテゴリーパネルを表示",
|
||||
"CHOOSE_CATEGORY": "カテゴリーを選択",
|
||||
"HIDE_PANELS": "カテゴリーとチャンネルを非表示",
|
||||
"HIDE_PANELS_TOOLTIP": "パネルを非表示 (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "カテゴリーとチャンネルを表示",
|
||||
"SHOW_PANELS_TOOLTIP": "パネルを表示 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "채널 목록 표시",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "채널 목록이 숨겨져 있습니다",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요.",
|
||||
"HIDE_CATEGORIES": "카테고리 숨기기",
|
||||
"SHOW_CATEGORIES": "카테고리 표시",
|
||||
"SHOW_CATEGORIES_PANEL": "카테고리 패널 표시",
|
||||
"CHOOSE_CATEGORY": "카테고리 선택",
|
||||
"HIDE_PANELS": "카테고리 및 채널 숨기기",
|
||||
"HIDE_PANELS_TOOLTIP": "패널 숨기기 (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "카테고리 및 채널 표시",
|
||||
"SHOW_PANELS_TOOLTIP": "패널 표시 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Zenderlijst tonen",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "De zenderlijst is verborgen",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Categorieën verbergen",
|
||||
"SHOW_CATEGORIES": "Categorieën tonen",
|
||||
"SHOW_CATEGORIES_PANEL": "Categorieënpaneel tonen",
|
||||
"CHOOSE_CATEGORY": "Categorie kiezen",
|
||||
"HIDE_PANELS": "Categorieën en kanalen verbergen",
|
||||
"HIDE_PANELS_TOOLTIP": "Panelen verbergen (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Categorieën en kanalen tonen",
|
||||
"SHOW_PANELS_TOOLTIP": "Panelen tonen (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Huidige zender tonen",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Pokaż listę kanałów",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Lista kanałów jest ukryta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Ukryj kategorie",
|
||||
"SHOW_CATEGORIES": "Pokaż kategorie",
|
||||
"SHOW_CATEGORIES_PANEL": "Pokaż panel kategorii",
|
||||
"CHOOSE_CATEGORY": "Wybierz kategorię",
|
||||
"HIDE_PANELS": "Ukryj kategorie i kanały",
|
||||
"HIDE_PANELS_TOOLTIP": "Ukryj panele (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Pokaż kategorie i kanały",
|
||||
"SHOW_PANELS_TOOLTIP": "Pokaż panele (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canais",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "A lista de canais está oculta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Ocultar categorias",
|
||||
"SHOW_CATEGORIES": "Mostrar categorias",
|
||||
"SHOW_CATEGORIES_PANEL": "Mostrar painel de categorias",
|
||||
"CHOOSE_CATEGORY": "Escolher categoria",
|
||||
"HIDE_PANELS": "Ocultar categorias e canais",
|
||||
"HIDE_PANELS_TOOLTIP": "Ocultar painéis (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Mostrar categorias e canais",
|
||||
"SHOW_PANELS_TOOLTIP": "Mostrar painéis (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Показать список каналов",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Список каналов скрыт",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B.",
|
||||
"HIDE_CATEGORIES": "Скрыть категории",
|
||||
"SHOW_CATEGORIES": "Показать категории",
|
||||
"SHOW_CATEGORIES_PANEL": "Показать панель категорий",
|
||||
"CHOOSE_CATEGORY": "Выбрать категорию",
|
||||
"HIDE_PANELS": "Скрыть категории и каналы",
|
||||
"HIDE_PANELS_TOOLTIP": "Скрыть панели (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Показать категории и каналы",
|
||||
"SHOW_PANELS_TOOLTIP": "Показать панели (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Показать текущий канал",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "Kanal listesini göster",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Kanal listesi gizli",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın."
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın.",
|
||||
"HIDE_CATEGORIES": "Kategorileri gizle",
|
||||
"SHOW_CATEGORIES": "Kategorileri göster",
|
||||
"SHOW_CATEGORIES_PANEL": "Kategoriler panelini göster",
|
||||
"CHOOSE_CATEGORY": "Kategori seç",
|
||||
"HIDE_PANELS": "Kategorileri ve kanalları gizle",
|
||||
"HIDE_PANELS_TOOLTIP": "Panelleri gizle (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "Kategorileri ve kanalları göster",
|
||||
"SHOW_PANELS_TOOLTIP": "Panelleri göster (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "显示频道列表",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "频道列表已隐藏",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。"
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。",
|
||||
"HIDE_CATEGORIES": "隐藏分类",
|
||||
"SHOW_CATEGORIES": "显示分类",
|
||||
"SHOW_CATEGORIES_PANEL": "显示分类面板",
|
||||
"CHOOSE_CATEGORY": "选择分类",
|
||||
"HIDE_PANELS": "隐藏分类和频道",
|
||||
"HIDE_PANELS_TOOLTIP": "隐藏面板 (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "显示分类和频道",
|
||||
"SHOW_PANELS_TOOLTIP": "显示面板 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "显示正在播放的频道",
|
||||
|
||||
@@ -676,7 +676,15 @@
|
||||
"SHOW_CHANNELS_LIST": "顯示頻道列表",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "頻道列表已隱藏",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。"
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。",
|
||||
"HIDE_CATEGORIES": "隱藏類別",
|
||||
"SHOW_CATEGORIES": "顯示類別",
|
||||
"SHOW_CATEGORIES_PANEL": "顯示類別面板",
|
||||
"CHOOSE_CATEGORY": "選擇類別",
|
||||
"HIDE_PANELS": "隱藏類別和頻道",
|
||||
"HIDE_PANELS_TOOLTIP": "隱藏面板 (⌘/Ctrl+B)",
|
||||
"SHOW_PANELS": "顯示類別和頻道",
|
||||
"SHOW_PANELS_TOOLTIP": "顯示面板 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道",
|
||||
|
||||
@@ -267,8 +267,24 @@ remain local when the meaning is explicit.
|
||||
|
||||
### Collapsible Live Sidebar
|
||||
|
||||
- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that
|
||||
hides the channels rail to give the player and EPG full width.
|
||||
- Live-TV panels fold from the outside in, in three nested levels owned by
|
||||
`LiveSidebarState` (`@iptvnator/portal/shared/util`):
|
||||
1. `expanded` — categories rail + channels rail + player.
|
||||
2. `categories-hidden` — channels rail + player. The shell's categories
|
||||
rail (`WorkspaceShellContextSidebarComponent`, live sections only:
|
||||
Xtream `live`, Stalker `itv`/`radio`) folds; the channels header turns
|
||||
its category title into a dropdown that opens the same rail as a
|
||||
popover, so switching categories stays one click away.
|
||||
3. `collapsed` — player + EPG only ("theater").
|
||||
- There is deliberately no "channels hidden, categories visible" state: a
|
||||
category click has to bring the channels back anyway. Surfaces without a
|
||||
categories rail (M3U, the unified-collection live tab) treat level 2 like
|
||||
level 1 — and so does the live ROOT (no selected category, Xtream `/live`
|
||||
"All Items", Stalker's all-items grid): there is no channels rail to host
|
||||
the way back, so the shell folds the categories rail at level 2 only while
|
||||
the portal store has a selected category (`hasLiveCategorySelection`), and
|
||||
the rail's hide chevron is withheld there too. Level 3 folds it regardless,
|
||||
since the floating restore handle lives in the content area.
|
||||
- Xtream Live TV's root view (`/live` with no selected category) follows the
|
||||
same paginated `All Items` shell as VOD and Series: a widget header with the
|
||||
total channel count, page-size controls, and page navigation above the shared
|
||||
@@ -278,26 +294,69 @@ remain local when the meaning is explicit.
|
||||
selects the channel's category, highlights the active category and channel,
|
||||
and scrolls the category rail plus virtual channels list to the selected rows
|
||||
when those rails are visible.
|
||||
- In Xtream and Stalker live TV, the same toggle also collapses the workspace
|
||||
shell context sidebar (the "Live Categories" rail rendered by
|
||||
`WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets"
|
||||
behaviour. The shell categories rail only collapses when the active section
|
||||
is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites,
|
||||
and recent routes leave it untouched.
|
||||
- Affordances, each in the panel it acts on:
|
||||
- A `chevron_left` in the categories rail header
|
||||
(`WorkspaceContextPanelComponent`, `presentation="sidebar"`, live
|
||||
sections only) → level 2 (`hideCategories('portal')`).
|
||||
- A `chevron_right` at the start of the channels header
|
||||
(`data-test-id="live-show-categories"`) and the popover footer's
|
||||
"Show categories panel" → level 1, through the shell's
|
||||
`LiveCategoriesPopover.showCategoriesPanel()`: it sets
|
||||
`showCategories('portal')` and, at phone widths where the rail is the
|
||||
off-canvas context drawer whose open state the level does not drive,
|
||||
also opens that drawer (`WorkspaceShellContextDrawerService.open()`).
|
||||
- The category dropdown (`data-test-id="live-category-dropdown"`) opens
|
||||
`LIVE_CATEGORIES_POPOVER` anchored below itself. The token lives in
|
||||
`@iptvnator/portal/shared/util`; the workspace shell provides it
|
||||
(`WorkspaceLiveCategoriesPopoverService`, CDK overlay hosting
|
||||
`WorkspaceLiveCategoriesPopoverComponent`, which stamps the context
|
||||
panel with `presentation="popover"`) and the live layouts reach it
|
||||
through their `LivePanelsController` (`createLivePanelsController()`
|
||||
in a field initializer: level flags, the popover bridge and the focus
|
||||
handoff in one shared object, so the layout components carry none of
|
||||
it; without a provider the header keeps its plain title). The stamped
|
||||
panel opts out of the live-TV column keyboard contract
|
||||
(`columnHandoff=false`: no `#portal-categories` id, no ArrowRight
|
||||
handoff to `#live-channels`), since the dialog's focus trap would
|
||||
bounce that handoff back inside and a second id would shadow the
|
||||
folded rail's; the category sort preference is shared through
|
||||
`PortalCategorySortStateService`, so a sort picked in the popover
|
||||
survives into the restored rail. Backdrop, Escape, the footer, any
|
||||
category selection (`categorySelected` output), any router
|
||||
`NavigationStart` and any live-panel level change (`Cmd/Ctrl+B`
|
||||
reaches the layout through the dialog) close it; focus returns to the
|
||||
trigger. The popover host is a `role="dialog"` with `aria-modal` and a
|
||||
`CdkTrapFocus` host directive that captures focus on open, matching
|
||||
the trigger's `aria-haspopup="dialog"`.
|
||||
- The `chevron_left` in the channels header → level 3
|
||||
(`collapse('portal')`).
|
||||
- While collapsed, a floating `chevron_right` mini-fab at the left edge of
|
||||
`.content-container`, the workspace header toggle and `Cmd/Ctrl+B`
|
||||
(`toggle(surface)`) return to the level the user collapsed from, not
|
||||
always to level 1. The shortcut handler ignores events that originate
|
||||
inside `<input>`, `<textarea>`, `<select>`, or content-editable
|
||||
elements via the shared `isTypingInInput` helper. "Show playing
|
||||
channel" (`XtreamLiveChannelNavigationService`, `StalkerLiveNavigation`)
|
||||
uses `expand('portal')` for the same reason: revealing the row must not
|
||||
unfold a deliberately hidden categories rail.
|
||||
- Collapsed state is owned by `LiveLayoutSidebarStateService`
|
||||
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util` and kept **per
|
||||
surface** (`LiveSidebarSurface`): `m3u` (the M3U player), `portal` (Xtream
|
||||
and Stalker live layouts plus the shell categories rail) and `collection`
|
||||
(the unified favorites/recent live tab). Every participant injects the
|
||||
service, holds `isCollapsedFor(surface)` (a stable signal) and calls
|
||||
`toggle(surface)`; nothing reads localStorage directly. Persistence lives
|
||||
under `live-sidebar-state:<surface>`. Hiding the list is a per-context
|
||||
choice: it must not follow the user from a portal to an M3U playlist, nor
|
||||
from the desktop rail to the phone bottom drawer of another surface. The
|
||||
pre-split shared key `live-sidebar-state` is forgotten on service
|
||||
construction and never read — a stored `collapsed` there hid every channel
|
||||
list in the app behind a 32px chevron and survived restart, "Remove all
|
||||
playlists" and re-import (issue #1458).
|
||||
service and reads a derived, stable per-surface signal, never the raw
|
||||
state: the categories rail folds on `areCategoriesHiddenFor(surface)`, the
|
||||
channels rail on `isCollapsedFor(surface)`; actions are `toggle`,
|
||||
`collapse`, `expand`, `hideCategories`, `showCategories` and `setState`,
|
||||
all per surface. Nothing reads localStorage directly. Persistence lives
|
||||
under `live-sidebar-state:<surface>` and every level is restored as
|
||||
stored; the level `toggle()` comes back to is session-only. Hiding the
|
||||
list is a per-context choice: it must not follow the user from a portal to
|
||||
an M3U playlist, nor from the desktop rail to the phone bottom drawer of
|
||||
another surface. The pre-split shared key `live-sidebar-state` is forgotten
|
||||
on service construction and never read — a stored `collapsed` there hid
|
||||
every channel list in the app behind a 32px chevron and survived restart,
|
||||
"Remove all playlists" and re-import (issue #1458).
|
||||
- The control never moves. Inside the rail a `mat-icon-button` with
|
||||
`chevron_left` hides it; while collapsed a floating `chevron_right` mini-fab
|
||||
sits at the left edge of `.content-container`. Because both of those live
|
||||
@@ -320,21 +379,42 @@ remain local when the meaning is explicit.
|
||||
`app-portal-empty-state` grew optional `hint`, `actionLabel`, `actionIcon`
|
||||
inputs and an `action` output for this; the action keeps full opacity while
|
||||
icon and copy stay muted, because it is the way out of the state.
|
||||
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
|
||||
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
|
||||
the shared `isTypingInInput` helper.
|
||||
- A folded rail is 0px wide but still rendered, so it also carries `inert`
|
||||
(`isContextPanelInert` on the shell rail, `isSidebarCollapsed` on the
|
||||
Xtream/Stalker channels rail) to leave the Tab order and the accessibility
|
||||
tree; the shell rail skips `inert` while it renders as the open phone
|
||||
drawer, whose stylesheet ignores the folded state — and the rail's hide
|
||||
chevron is withheld there for the same reason (`canHideCategories`).
|
||||
Every level change removes or inerts the very button the user activated,
|
||||
so focus drops to `<body>`; the side that gains the replacement affordance
|
||||
picks it up after its next render via `focusIfFocusLost()`
|
||||
(`@iptvnator/portal/shared/util`): the layouts' `LivePanelsController`
|
||||
installs `handoffFocusOnLiveSidebarChange()` over the EFFECTIVE level (on
|
||||
the live root the first category selection folds the rail with no state
|
||||
change) and focuses the floating restore handle at player-only or the
|
||||
show-categories button while the rail is folded, and the shell sidebar,
|
||||
watching the rail's ACTUAL fold state (on the live root the rail stays
|
||||
visible at level 2, so only player-only ↔ visible is a transition there),
|
||||
focuses the control the context panel names (`focusTarget()`: its hide
|
||||
chevron, or its first header action when the chevron is withheld) and,
|
||||
while none is rendered (categories loading, a failed load), the
|
||||
`tabindex="-1"` aside itself — only on transitions, and never when another
|
||||
control still owns focus.
|
||||
- The CSS class `.sidebar-collapsed` (channels rail) and
|
||||
`.context-panel--collapsed` (workspace shell categories rail) both override
|
||||
the inline width set by the `appResizable` directive with
|
||||
`width: 0 !important; min-width: 0 !important`. The directive's persisted
|
||||
width is preserved so uncollapsing restores the user's previous resized
|
||||
width. Both rails share the same 180 ms width transition so motion stays in
|
||||
lockstep.
|
||||
lockstep. The dropdown reuses the static heading's type so folding the rail
|
||||
does not move the title; the caret is the only added ink
|
||||
(`_portal-sidebar.scss`).
|
||||
- At the phone breakpoint the M3U layout's bottom-drawer rule overrides the
|
||||
desktop collapse to `height: 0` instead of `width: 0`. The floating restore
|
||||
handle stays visible there: the collapse toggle is reachable by touch, so
|
||||
hiding the handle left a phone with no way to bring the list back short of
|
||||
`Cmd/Ctrl+B`.
|
||||
`Cmd/Ctrl+B`. The phone context drawer ignores the folded state entirely
|
||||
(the user explicitly opened it).
|
||||
|
||||
### EPG Card
|
||||
|
||||
|
||||
@@ -146,6 +146,12 @@ default. The panel header exposes a sort menu next to category search with
|
||||
synthetic "all categories" entries stay pinned before sorted provider
|
||||
categories.
|
||||
|
||||
On live sections the category panel can be folded away independently of the
|
||||
channels list; the folded panel is reachable as a popover from the channels
|
||||
header through the `LIVE_CATEGORIES_POPOVER` token the shell provides. The
|
||||
three nested levels, their affordances and persistence are specified in
|
||||
`iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar").
|
||||
|
||||
A category click in a LIVE section (Xtream `live`, Stalker `itv` and `radio`)
|
||||
changes only the selected category: the live layouts gate their player on the
|
||||
store's selected item, so the handler must not clear it — the channel the user
|
||||
|
||||
@@ -16,6 +16,10 @@ export * from './lib/portal-collection-items';
|
||||
export * from './lib/live-epg-panel-state';
|
||||
export * from './lib/live-sidebar-state';
|
||||
export * from './lib/live-layout-sidebar-state.service';
|
||||
export * from './lib/live-categories-popover.token';
|
||||
export * from './lib/focus-handoff';
|
||||
export * from './lib/live-panels-controller';
|
||||
export * from './lib/category-sort-state.service';
|
||||
export * from './lib/keyboard';
|
||||
export * from './lib/keyboard-shortcuts';
|
||||
export * from './lib/remote-channel-navigation';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
PortalCategorySortMode,
|
||||
persistPortalCategorySortMode,
|
||||
restorePortalCategorySortMode,
|
||||
} from './category-sort';
|
||||
|
||||
/**
|
||||
* The portal categories sort preference, shared by every rendered category
|
||||
* list. The workspace context panel is stamped twice while the categories
|
||||
* rail is folded (the in-flow rail stays mounted, the popover shows a second
|
||||
* copy), and a per-instance signal seeded from storage would let a sort
|
||||
* picked in the popover revert the moment the rail comes back.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PortalCategorySortStateService {
|
||||
private readonly _mode = signal<PortalCategorySortMode>(
|
||||
restorePortalCategorySortMode()
|
||||
);
|
||||
readonly mode = this._mode.asReadonly();
|
||||
|
||||
setMode(mode: PortalCategorySortMode): void {
|
||||
this._mode.set(mode);
|
||||
persistPortalCategorySortMode(mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { effect, Signal, untracked } from '@angular/core';
|
||||
import { LiveSidebarState } from './live-sidebar-state';
|
||||
|
||||
/**
|
||||
* Moves focus to `element` only when the document has none to speak of:
|
||||
* the previously focused control was removed or made `inert`, so focus
|
||||
* already fell back to `<body>`. A control that still owns focus (a mouse
|
||||
* user's category row, a keyboard user's search box) is left alone.
|
||||
*
|
||||
* Used by the live-TV panel toggles: hiding a rail removes the button that
|
||||
* was just activated, and the replacement affordance lives in another
|
||||
* component, so the receiving side picks focus up after its next render.
|
||||
*/
|
||||
export function focusIfFocusLost(
|
||||
element: HTMLElement | null | undefined
|
||||
): void {
|
||||
if (!element || !element.isConnected) {
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
const focusLost =
|
||||
!active || active === document.body || active.closest('[inert]') !== null;
|
||||
if (focusLost) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the focus handoff for a live layout's panel toggles. Must run in
|
||||
* an injection context (a component constructor). After every level change
|
||||
* and the render it causes, `targetFor(next)` names the affordance that now
|
||||
* stands in for the button the user activated — the floating restore handle
|
||||
* at player-only, the show-categories button while the rail is folded, and
|
||||
* nothing when the categories rail itself is back (its own component picks
|
||||
* focus up then) — and focus moves there only if it was lost.
|
||||
*/
|
||||
export function handoffFocusOnLiveSidebarChange(
|
||||
state: Signal<LiveSidebarState>,
|
||||
targetFor: (next: LiveSidebarState) => HTMLElement | null | undefined
|
||||
): void {
|
||||
let previous = untracked(state);
|
||||
effect(() => {
|
||||
const next = state();
|
||||
if (next === previous) {
|
||||
return;
|
||||
}
|
||||
previous = next;
|
||||
queueMicrotask(() => focusIfFocusLost(untracked(() => targetFor(next))));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Opens the live categories list as a popover anchored to an element.
|
||||
*
|
||||
* While the categories rail is folded (`LiveLayoutSidebarStateService.
|
||||
* areCategoriesHidden`), the channels header turns its category title into a
|
||||
* dropdown so switching categories stays one click away. The rail itself is
|
||||
* shell-owned (`WorkspaceContextPanelComponent`), which the portal feature
|
||||
* libs cannot import, so the workspace shell provides this token and the live
|
||||
* layouts inject it optionally: without a provider (unit tests, a host that
|
||||
* renders no categories rail) the dropdown is simply not offered.
|
||||
*/
|
||||
export interface LiveCategoriesPopover {
|
||||
/**
|
||||
* Opens the popover below `origin`, or closes it when it is already open
|
||||
* for that origin.
|
||||
*/
|
||||
open(origin: HTMLElement): void;
|
||||
/** Closes the popover if it is open. */
|
||||
close(): void;
|
||||
/**
|
||||
* Brings the categories rail back (level 1) the way the current viewport
|
||||
* shows it: on desktop the in-flow rail unfolds; at phone widths the rail
|
||||
* is the shell's off-canvas drawer, whose open state is separate from
|
||||
* the fold level, so the drawer is opened as well. Closes the popover.
|
||||
*/
|
||||
showCategoriesPanel(): void;
|
||||
}
|
||||
|
||||
export const LIVE_CATEGORIES_POPOVER = new InjectionToken<LiveCategoriesPopover>(
|
||||
'LIVE_CATEGORIES_POPOVER'
|
||||
);
|
||||
@@ -73,6 +73,68 @@ describe('LiveLayoutSidebarStateService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('folds only the categories rail on hideCategories', () => {
|
||||
const service = createService();
|
||||
|
||||
service.hideCategories('portal');
|
||||
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
|
||||
expect(service.isCollapsedFor('portal')()).toBe(false);
|
||||
expect(service.areCategoriesHiddenFor('m3u')()).toBe(false);
|
||||
expect(
|
||||
localStorage.getItem(liveSidebarStateStorageKey('portal'))
|
||||
).toBe('categories-hidden');
|
||||
|
||||
service.showCategories('portal');
|
||||
|
||||
expect(service.stateOf('portal')()).toBe('expanded');
|
||||
});
|
||||
|
||||
it('collapse folds both rails and expand returns to the level it was entered from', () => {
|
||||
const service = createService();
|
||||
service.hideCategories('portal');
|
||||
|
||||
service.collapse('portal');
|
||||
|
||||
expect(service.isCollapsedFor('portal')()).toBe(true);
|
||||
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
|
||||
|
||||
service.collapse('portal');
|
||||
service.expand('portal');
|
||||
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('toggle leaves collapsed for the previous level and otherwise collapses', () => {
|
||||
const service = createService();
|
||||
|
||||
service.toggle('portal');
|
||||
expect(service.stateOf('portal')()).toBe('collapsed');
|
||||
service.toggle('portal');
|
||||
expect(service.stateOf('portal')()).toBe('expanded');
|
||||
|
||||
service.hideCategories('portal');
|
||||
service.toggle('portal');
|
||||
expect(service.stateOf('portal')()).toBe('collapsed');
|
||||
service.toggle('portal');
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('restores a hidden categories rail from its surface key and comes back to it after a toggle', () => {
|
||||
localStorage.setItem(
|
||||
liveSidebarStateStorageKey('portal'),
|
||||
'categories-hidden'
|
||||
);
|
||||
|
||||
const service = createService();
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
|
||||
service.toggle('portal');
|
||||
service.toggle('portal');
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('ignores and removes the legacy shared key instead of inheriting it', () => {
|
||||
localStorage.setItem(
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
|
||||
@@ -34,6 +34,11 @@ function bySurface<T>(
|
||||
*
|
||||
* State is kept per surface (`live-sidebar-state:<surface>` in
|
||||
* localStorage). The pre-split shared key is forgotten on construction.
|
||||
*
|
||||
* The three levels nest (see `LiveSidebarState`). Consumers read one of two
|
||||
* derived flags rather than the raw state: the categories rail folds on
|
||||
* `areCategoriesHiddenFor(surface)`, the channels rail only on
|
||||
* `isCollapsedFor(surface)`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LiveLayoutSidebarStateService {
|
||||
@@ -42,6 +47,18 @@ export class LiveLayoutSidebarStateService {
|
||||
WritableSignal<LiveSidebarState>
|
||||
>;
|
||||
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
|
||||
private readonly categoriesHidden: Record<
|
||||
LiveSidebarSurface,
|
||||
Signal<boolean>
|
||||
>;
|
||||
/**
|
||||
* The level to come back to when leaving `collapsed`, per surface.
|
||||
* Session-only on purpose: a restart restores the stored level itself.
|
||||
*/
|
||||
private readonly restoreTargets: Record<
|
||||
LiveSidebarSurface,
|
||||
Exclude<LiveSidebarState, 'collapsed'>
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
forgetLegacyLiveSidebarState();
|
||||
@@ -51,6 +68,14 @@ export class LiveLayoutSidebarStateService {
|
||||
this.collapsed = bySurface((surface) =>
|
||||
computed(() => this.states[surface]() === 'collapsed')
|
||||
);
|
||||
this.categoriesHidden = bySurface((surface) =>
|
||||
computed(() => this.states[surface]() !== 'expanded')
|
||||
);
|
||||
// A restored `categories-hidden` is what Cmd/Ctrl+B comes back to.
|
||||
this.restoreTargets = bySurface((surface) => {
|
||||
const restored = this.states[surface]();
|
||||
return restored === 'collapsed' ? 'expanded' : restored;
|
||||
});
|
||||
}
|
||||
|
||||
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
|
||||
@@ -62,14 +87,49 @@ export class LiveLayoutSidebarStateService {
|
||||
return this.collapsed[surface];
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories rail is folded (`categories-hidden` or `collapsed`).
|
||||
* Stable per-surface signal.
|
||||
*/
|
||||
areCategoriesHiddenFor(surface: LiveSidebarSurface): Signal<boolean> {
|
||||
return this.categoriesHidden[surface];
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cmd/Ctrl+B`, the header toggle and the floating restore handle: leave
|
||||
* `collapsed` for the level the user collapsed from, otherwise collapse
|
||||
* everything.
|
||||
*/
|
||||
toggle(surface: LiveSidebarSurface): void {
|
||||
this.setState(
|
||||
surface,
|
||||
this.collapsed[surface]() ? 'expanded' : 'collapsed'
|
||||
);
|
||||
if (this.collapsed[surface]()) {
|
||||
this.expand(surface);
|
||||
} else {
|
||||
this.collapse(surface);
|
||||
}
|
||||
}
|
||||
|
||||
/** Player only. Remembers the current level for `expand()`. */
|
||||
collapse(surface: LiveSidebarSurface): void {
|
||||
this.setState(surface, 'collapsed');
|
||||
}
|
||||
|
||||
/** Leaves `collapsed` for the level it was entered from. */
|
||||
expand(surface: LiveSidebarSurface): void {
|
||||
this.setState(surface, this.restoreTargets[surface]);
|
||||
}
|
||||
|
||||
hideCategories(surface: LiveSidebarSurface): void {
|
||||
this.setState(surface, 'categories-hidden');
|
||||
}
|
||||
|
||||
showCategories(surface: LiveSidebarSurface): void {
|
||||
this.setState(surface, 'expanded');
|
||||
}
|
||||
|
||||
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
|
||||
if (state !== 'collapsed') {
|
||||
this.restoreTargets[surface] = state;
|
||||
}
|
||||
this.states[surface].set(state);
|
||||
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ElementRef, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { createLivePanelsController } from './live-panels-controller';
|
||||
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
|
||||
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
|
||||
import { liveSidebarStateStorageKey } from './live-sidebar-state';
|
||||
|
||||
describe('LivePanelsController', () => {
|
||||
const popover = {
|
||||
open: jest.fn(),
|
||||
close: jest.fn(),
|
||||
showCategoriesPanel: jest.fn(),
|
||||
};
|
||||
const hasSelectedCategory = signal(true);
|
||||
let showButton: HTMLButtonElement;
|
||||
let restoreButton: HTMLButtonElement;
|
||||
|
||||
function create(withPopover = true) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: withPopover
|
||||
? [{ provide: LIVE_CATEGORIES_POPOVER, useValue: popover }]
|
||||
: [],
|
||||
});
|
||||
return TestBed.runInInjectionContext(() =>
|
||||
createLivePanelsController({
|
||||
surface: 'portal',
|
||||
hasSelectedCategory,
|
||||
showCategoriesButton: signal(new ElementRef(showButton)),
|
||||
restoreButton: signal(new ElementRef(restoreButton)),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
popover.open.mockClear();
|
||||
popover.close.mockClear();
|
||||
popover.showCategoriesPanel.mockClear();
|
||||
hasSelectedCategory.set(true);
|
||||
showButton = document.createElement('button');
|
||||
restoreButton = document.createElement('button');
|
||||
document.body.append(showButton, restoreButton);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
showButton.remove();
|
||||
restoreButton.remove();
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
});
|
||||
|
||||
it('offers the dropdown only with a provider, a folded rail and a selected category', () => {
|
||||
const controller = create();
|
||||
const state = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
expect(controller.canOpenCategoriesPopover()).toBe(false);
|
||||
|
||||
state.hideCategories('portal');
|
||||
expect(controller.canOpenCategoriesPopover()).toBe(true);
|
||||
expect(controller.effectiveLevel()).toBe('categories-hidden');
|
||||
|
||||
hasSelectedCategory.set(false);
|
||||
expect(controller.canOpenCategoriesPopover()).toBe(false);
|
||||
expect(controller.effectiveLevel()).toBe('expanded');
|
||||
|
||||
state.collapse('portal');
|
||||
expect(controller.effectiveLevel()).toBe('collapsed');
|
||||
});
|
||||
|
||||
it('keeps the plain heading without a popover provider', () => {
|
||||
const controller = create(false);
|
||||
TestBed.inject(LiveLayoutSidebarStateService).hideCategories('portal');
|
||||
|
||||
expect(controller.canOpenCategoriesPopover()).toBe(false);
|
||||
controller.openCategoriesPopover(showButton);
|
||||
controller.showCategories();
|
||||
expect(TestBed.inject(LiveLayoutSidebarStateService).stateOf('portal')()).toBe(
|
||||
'expanded'
|
||||
);
|
||||
});
|
||||
|
||||
it('routes the actions through the shared state and the popover', () => {
|
||||
const controller = create();
|
||||
const state = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
|
||||
controller.openCategoriesPopover(showButton);
|
||||
expect(popover.open).toHaveBeenCalledWith(showButton);
|
||||
|
||||
controller.collapsePanels();
|
||||
expect(state.stateOf('portal')()).toBe('collapsed');
|
||||
controller.toggleSidebar();
|
||||
expect(state.stateOf('portal')()).toBe('expanded');
|
||||
|
||||
state.hideCategories('portal');
|
||||
controller.showCategories();
|
||||
// The shell restores the rail (and opens the phone drawer if the
|
||||
// rail is one); the controller only delegates.
|
||||
expect(popover.showCategoriesPanel).toHaveBeenCalledTimes(1);
|
||||
expect(state.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('hands focus across level changes, including a fold by category selection', async () => {
|
||||
const controller = create();
|
||||
const state = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
hasSelectedCategory.set(false);
|
||||
state.hideCategories('portal');
|
||||
await settle();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
// Live root: selecting the first category folds the rail.
|
||||
hasSelectedCategory.set(true);
|
||||
await settle();
|
||||
expect(document.activeElement).toBe(showButton);
|
||||
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
controller.collapsePanels();
|
||||
await settle();
|
||||
expect(document.activeElement).toBe(restoreButton);
|
||||
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
controller.toggleSidebar();
|
||||
await settle();
|
||||
expect(document.activeElement).toBe(showButton);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { handoffFocusOnLiveSidebarChange } from './focus-handoff';
|
||||
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
|
||||
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
|
||||
import { LiveSidebarState, LiveSidebarSurface } from './live-sidebar-state';
|
||||
|
||||
export interface LivePanelsControllerOptions {
|
||||
/** The surface whose per-surface state this layout renders. */
|
||||
readonly surface: LiveSidebarSurface;
|
||||
/**
|
||||
* A live category is selected, so the layout renders a channels rail
|
||||
* whose header can host the category dropdown and the show-categories
|
||||
* button. Mirrors the shell's fold rule: on the live root the categories
|
||||
* rail stays visible at `categories-hidden`.
|
||||
*/
|
||||
readonly hasSelectedCategory: Signal<boolean>;
|
||||
/** The header's show-categories button, once rendered. */
|
||||
readonly showCategoriesButton: Signal<ElementRef<HTMLElement> | undefined>;
|
||||
/** The floating restore handle, once rendered at player-only. */
|
||||
readonly restoreButton: Signal<ElementRef<HTMLElement> | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live-TV panel behaviour shared by the Xtream and Stalker live layouts:
|
||||
* the nested fold levels read from `LiveLayoutSidebarStateService`, the
|
||||
* category dropdown bridge to the shell's `LIVE_CATEGORIES_POPOVER`, and the
|
||||
* focus handoff across level changes. Create it in a field initializer (an
|
||||
* injection context) with `createLivePanelsController()`.
|
||||
*/
|
||||
export class LivePanelsController {
|
||||
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
|
||||
// Shell-provided; absent when no categories rail exists to fold (unit
|
||||
// tests, hosts outside the workspace shell), in which case the header
|
||||
// keeps its plain title.
|
||||
private readonly categoriesPopover = inject(LIVE_CATEGORIES_POPOVER, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
/** Player only: the channels rail is folded (and `inert`). */
|
||||
readonly isSidebarCollapsed: Signal<boolean>;
|
||||
/** The header shows the category dropdown and show-categories button. */
|
||||
readonly canOpenCategoriesPopover: Signal<boolean>;
|
||||
/**
|
||||
* The level the layout actually shows. On the live root the categories
|
||||
* rail stays visible at `categories-hidden`, and the first category
|
||||
* selection folds it without a state change — the focus handoff keys on
|
||||
* this, not on the raw state.
|
||||
*/
|
||||
readonly effectiveLevel: Signal<LiveSidebarState>;
|
||||
|
||||
constructor(private readonly options: LivePanelsControllerOptions) {
|
||||
const surface = options.surface;
|
||||
this.isSidebarCollapsed = this.sidebarState.isCollapsedFor(surface);
|
||||
const categoriesHidden =
|
||||
this.sidebarState.areCategoriesHiddenFor(surface);
|
||||
this.canOpenCategoriesPopover = computed(
|
||||
() =>
|
||||
this.categoriesPopover !== null &&
|
||||
categoriesHidden() &&
|
||||
this.options.hasSelectedCategory()
|
||||
);
|
||||
this.effectiveLevel = computed(() =>
|
||||
this.isSidebarCollapsed()
|
||||
? 'collapsed'
|
||||
: this.canOpenCategoriesPopover()
|
||||
? 'categories-hidden'
|
||||
: 'expanded'
|
||||
);
|
||||
// Every level change inerts or removes the button the user activated
|
||||
// and drops focus to <body>; hand it to the affordance that replaces
|
||||
// it once the layout has rendered (see the helper's contract).
|
||||
handoffFocusOnLiveSidebarChange(this.effectiveLevel, (next) =>
|
||||
next === 'collapsed'
|
||||
? untracked(this.options.restoreButton)?.nativeElement
|
||||
: next === 'categories-hidden'
|
||||
? untracked(this.options.showCategoriesButton)?.nativeElement
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
/** `Cmd/Ctrl+B` and the floating restore handle. */
|
||||
toggleSidebar(): void {
|
||||
this.sidebarState.toggle(this.options.surface);
|
||||
}
|
||||
|
||||
/** The channels header chevron: player only. */
|
||||
collapsePanels(): void {
|
||||
this.sidebarState.collapse(this.options.surface);
|
||||
}
|
||||
|
||||
/** Level 1. The shell knows whether the rail is a phone drawer. */
|
||||
showCategories(): void {
|
||||
if (this.categoriesPopover) {
|
||||
this.categoriesPopover.showCategoriesPanel();
|
||||
return;
|
||||
}
|
||||
this.sidebarState.showCategories(this.options.surface);
|
||||
}
|
||||
|
||||
openCategoriesPopover(origin: HTMLElement): void {
|
||||
this.categoriesPopover?.open(origin);
|
||||
}
|
||||
}
|
||||
|
||||
/** Must run in an injection context (a component field initializer). */
|
||||
export function createLivePanelsController(
|
||||
options: LivePanelsControllerOptions
|
||||
): LivePanelsController {
|
||||
return new LivePanelsController(options);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ describe('live sidebar state', () => {
|
||||
|
||||
it('accepts only known sidebar states', () => {
|
||||
expect(isLiveSidebarState('expanded')).toBe(true);
|
||||
expect(isLiveSidebarState('categories-hidden')).toBe(true);
|
||||
expect(isLiveSidebarState('collapsed')).toBe(true);
|
||||
expect(isLiveSidebarState('hidden')).toBe(false);
|
||||
expect(isLiveSidebarState(null)).toBe(false);
|
||||
|
||||
@@ -3,7 +3,20 @@ import type {
|
||||
PortalRailSection,
|
||||
} from './navigation/portal-rail-links';
|
||||
|
||||
export type LiveSidebarState = 'expanded' | 'collapsed';
|
||||
/**
|
||||
* Live-TV panel visibility, nested from the outside in:
|
||||
*
|
||||
* - `expanded`: categories rail + channels rail + player.
|
||||
* - `categories-hidden`: channels rail + player. The categories rail is
|
||||
* folded away but stays one click off through the channels header's
|
||||
* category dropdown. Surfaces without a categories rail (`m3u`,
|
||||
* `collection`) treat it exactly like `expanded`.
|
||||
* - `collapsed`: player only ("theater").
|
||||
*
|
||||
* There is deliberately no "channels hidden, categories visible" state: a
|
||||
* category click has to bring the channels back anyway.
|
||||
*/
|
||||
export type LiveSidebarState = 'expanded' | 'categories-hidden' | 'collapsed';
|
||||
|
||||
/**
|
||||
* Collapsible live-channel rails. Each surface remembers its own state:
|
||||
@@ -38,7 +51,11 @@ export function liveSidebarStateStorageKey(
|
||||
}
|
||||
|
||||
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
|
||||
return value === 'expanded' || value === 'collapsed';
|
||||
return (
|
||||
value === 'expanded' ||
|
||||
value === 'categories-hidden' ||
|
||||
value === 'collapsed'
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreLiveSidebarState(
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ describe('StalkerLiveNavigation', () => {
|
||||
parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })),
|
||||
navigateByUrl: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const sidebar = { setState: jest.fn() };
|
||||
const sidebar = { setState: jest.fn(), expand: jest.fn() };
|
||||
const play = jest.fn();
|
||||
const revealRow = jest.fn(() => true);
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('StalkerLiveNavigation', () => {
|
||||
);
|
||||
expect(store.selectedCategoryId()).toBe('news');
|
||||
expect(store.searchPhrase()).toBe('');
|
||||
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
|
||||
expect(sidebar.expand).toHaveBeenCalledWith('portal');
|
||||
expect(revealRow).toHaveBeenCalledWith('1');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(navigation.canReveal()).toBe(false);
|
||||
|
||||
+1
-1
@@ -266,7 +266,7 @@ export class StalkerLiveNavigation {
|
||||
this.options.store.setSearchPhrase('');
|
||||
this.options.store.setSelectedCategory(category);
|
||||
this.options.store.setPage(0);
|
||||
this.options.sidebar.setState('portal', 'expanded');
|
||||
this.options.sidebar.expand('portal');
|
||||
this.revealed.set({ owner, category, item: active });
|
||||
this.pendingReveal.set({
|
||||
owner,
|
||||
|
||||
+64
-24
@@ -4,6 +4,7 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
[class.sidebar-collapsed]="isSidebarCollapsed()"
|
||||
[attr.inert]="isSidebarCollapsed() ? '' : null"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
@@ -11,22 +12,62 @@
|
||||
storageKey="live-channels-sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</h2>
|
||||
@if (totalChannelCount() > 0) {
|
||||
<span class="category-subtitle"
|
||||
>{{ totalChannelCount() }}
|
||||
{{
|
||||
(totalChannelCount() === 1
|
||||
? 'PORTALS.ITEM'
|
||||
: 'PORTALS.ITEMS'
|
||||
) | translate
|
||||
}}</span
|
||||
@if (livePanels.canOpenCategoriesPopover()) {
|
||||
<!-- The categories rail is folded: the title becomes the way
|
||||
back to it (a dropdown of the same list) so switching
|
||||
categories stays one click away. -->
|
||||
<button
|
||||
#showCategoriesButton
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="sidebar-show-categories"
|
||||
data-test-id="live-show-categories"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
|
||||
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
|
||||
(click)="livePanels.showCategories()"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
#categoryDropdown
|
||||
type="button"
|
||||
class="category-dropdown"
|
||||
data-test-id="live-category-dropdown"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
|
||||
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
|
||||
>
|
||||
<span class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</span>
|
||||
@if (totalChannelCount() > 0) {
|
||||
<span class="category-count">{{
|
||||
totalChannelCount()
|
||||
}}</span>
|
||||
}
|
||||
<mat-icon class="category-dropdown__caret"
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</h2>
|
||||
@if (totalChannelCount() > 0) {
|
||||
<span class="category-subtitle"
|
||||
>{{ totalChannelCount() }}
|
||||
{{
|
||||
(totalChannelCount() === 1
|
||||
? 'PORTALS.ITEM'
|
||||
: 'PORTALS.ITEMS'
|
||||
) | translate
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (navigation.canReveal()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -57,10 +98,9 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
[attr.aria-pressed]="isSidebarCollapsed()"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
|
||||
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
|
||||
(click)="livePanels.collapsePanels()"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@@ -73,13 +113,13 @@
|
||||
<div class="content-container">
|
||||
@if (isSidebarCollapsed()) {
|
||||
<button
|
||||
#restoreButton
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="sidebar-restore"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
[attr.aria-pressed]="isSidebarCollapsed()"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
|
||||
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
|
||||
(click)="livePanels.toggleSidebar()"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
@@ -143,7 +183,7 @@
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
|
||||
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
|
||||
+22
-7
@@ -75,6 +75,7 @@ import {
|
||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
createLivePanelsController,
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
@@ -488,8 +489,26 @@ export class StalkerLiveStreamLayoutComponent
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('portal');
|
||||
// `viewChild()` must be a direct field initializer for the compiler to
|
||||
// register the query, so the refs live here and feed the controller.
|
||||
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
private readonly restoreButton = viewChild('restoreButton', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
// Nested panel levels, category dropdown bridge and focus handoff; the
|
||||
// template binds to it directly. The sidebar only renders with a
|
||||
// selected category, and the fold rule keys on the same selection.
|
||||
readonly livePanels = createLivePanelsController({
|
||||
surface: 'portal',
|
||||
hasSelectedCategory: computed(
|
||||
() => !!this.stalkerStore.selectedCategoryId()
|
||||
),
|
||||
showCategoriesButton: this.showCategoriesButton,
|
||||
restoreButton: this.restoreButton,
|
||||
});
|
||||
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(this.currentProgram())
|
||||
);
|
||||
@@ -1159,10 +1178,6 @@ export class StalkerLiveStreamLayoutComponent
|
||||
persistLiveEpgPanelState(state);
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.liveSidebarStateService.toggle('portal');
|
||||
}
|
||||
|
||||
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
|
||||
this.handleAdjacentChannelChange(direction === 'next' ? 'down' : 'up');
|
||||
}
|
||||
@@ -1179,7 +1194,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
!this.hostElement.nativeElement.closest('[inert]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.toggleSidebar();
|
||||
this.livePanels.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -167,6 +167,8 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
areCategoriesHiddenFor: () => signal(false),
|
||||
stateOf: () => signal('expanded'),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
+2
@@ -143,6 +143,8 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
areCategoriesHiddenFor: () => signal(false),
|
||||
stateOf: () => signal('expanded'),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
+2
@@ -140,6 +140,8 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
areCategoriesHiddenFor: () => signal(false),
|
||||
stateOf: () => signal('expanded'),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ describe('Xtream live auto-open playback queue', () => {
|
||||
provide: FavoritesService,
|
||||
useValue: { getFavorites: () => of([]) },
|
||||
},
|
||||
{ provide: XtreamUrlService, useValue: {} },
|
||||
{
|
||||
provide: XtreamUrlService,
|
||||
useValue: { constructAutoLiveTsUrl: () => undefined },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: { isEmbeddedPlayer: () => true },
|
||||
|
||||
+59
-19
@@ -5,6 +5,7 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
[class.sidebar-collapsed]="isSidebarCollapsed()"
|
||||
[attr.inert]="isSidebarCollapsed() ? '' : null"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
@@ -12,16 +13,56 @@
|
||||
storageKey="live-channels-sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<div class="category-heading">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
|
||||
</h2>
|
||||
@if (selectedCategoryInfo()?.count) {
|
||||
<span class="category-count">{{
|
||||
selectedCategoryInfo()?.count
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
@if (livePanels.canOpenCategoriesPopover()) {
|
||||
<!-- The categories rail is folded: the title becomes the way
|
||||
back to it (a dropdown of the same list) so switching
|
||||
categories stays one click away. -->
|
||||
<button
|
||||
#showCategoriesButton
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="sidebar-show-categories"
|
||||
data-test-id="live-show-categories"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
|
||||
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
|
||||
(click)="livePanels.showCategories()"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
#categoryDropdown
|
||||
type="button"
|
||||
class="category-dropdown"
|
||||
data-test-id="live-category-dropdown"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
|
||||
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
|
||||
>
|
||||
<span class="category-title">
|
||||
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
|
||||
</span>
|
||||
@if (selectedCategoryInfo()?.count) {
|
||||
<span class="category-count">{{
|
||||
selectedCategoryInfo()?.count
|
||||
}}</span>
|
||||
}
|
||||
<mat-icon class="category-dropdown__caret"
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
</button>
|
||||
} @else {
|
||||
<div class="category-heading">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
|
||||
</h2>
|
||||
@if (selectedCategoryInfo()?.count) {
|
||||
<span class="category-count">{{
|
||||
selectedCategoryInfo()?.count
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (channelNavigation.canReveal()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -46,10 +87,9 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
[attr.aria-pressed]="isSidebarCollapsed()"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
|
||||
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
|
||||
(click)="livePanels.collapsePanels()"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@@ -95,13 +135,13 @@
|
||||
<div class="content-container">
|
||||
@if (isSidebarCollapsed()) {
|
||||
<button
|
||||
#restoreButton
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="sidebar-restore"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
[attr.aria-pressed]="isSidebarCollapsed()"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
|
||||
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
|
||||
(click)="livePanels.toggleSidebar()"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
@@ -171,7 +211,7 @@
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
|
||||
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
|
||||
-35
@@ -1153,41 +1153,6 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
// `app-channel-list-hidden-state` (its TranslatePipe needs a
|
||||
// TranslateService); this spec is at the max-lines budget, so that branch
|
||||
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
|
||||
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
|
||||
selectedCategoryId.set(null);
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.sidebar-restore')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the floating restore button when the sidebar is expanded', () => {
|
||||
selectedCategoryId.set(1);
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.sidebar-restore')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('persists the channels sidebar width under a dedicated storage key', () => {
|
||||
// The shell context panel (category sidebar) is visible at the same
|
||||
// time as this sidebar and persists its width under the shared
|
||||
// "sidebar-width" key. Reusing that key here makes the two panels
|
||||
// overwrite each other's stored width across reloads.
|
||||
selectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebar: HTMLElement =
|
||||
fixture.nativeElement.querySelector('.sidebar');
|
||||
expect(sidebar.getAttribute('storageKey')).toBe(
|
||||
'live-channels-sidebar-width'
|
||||
);
|
||||
});
|
||||
|
||||
describe('auto-open from Ctrl+F search navigation state', () => {
|
||||
const searchChannel = {
|
||||
xtream_id: 202,
|
||||
|
||||
+24
-12
@@ -30,7 +30,7 @@ import {
|
||||
PortalEmptyStateComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
createLivePanelsController,
|
||||
PORTAL_PLAYER,
|
||||
PortalChannelSortMode,
|
||||
getPortalChannelSortModeLabel,
|
||||
@@ -149,9 +149,6 @@ export class LiveStreamLayoutComponent
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
LiveLayoutSidebarStateService
|
||||
);
|
||||
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
@@ -322,8 +319,24 @@ export class LiveStreamLayoutComponent
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('portal');
|
||||
// `viewChild()` must be a direct field initializer for the compiler to
|
||||
// register the query, so the refs live here and feed the controller.
|
||||
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
private readonly restoreButton = viewChild('restoreButton', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
// Nested panel levels, category dropdown bridge and focus handoff; the
|
||||
// template binds to it directly. Search-only rails (no selected
|
||||
// category) keep their plain heading.
|
||||
readonly livePanels = createLivePanelsController({
|
||||
surface: 'portal',
|
||||
hasSelectedCategory: computed(() => !!this.selectedCategoryId()),
|
||||
showCategoriesButton: this.showCategoriesButton,
|
||||
restoreButton: this.restoreButton,
|
||||
});
|
||||
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(
|
||||
this.activeCatchupProgram() ?? this.currentEpgItem()
|
||||
@@ -361,8 +374,11 @@ export class LiveStreamLayoutComponent
|
||||
if (!categoryId) return null;
|
||||
|
||||
const categories = this.categories();
|
||||
// Provider categories carry string ids ("101") while the selection
|
||||
// is numeric; a strict comparison missed every one and the header
|
||||
// fell back to "Channels", which the category dropdown cannot afford.
|
||||
const category = categories?.find(
|
||||
(c) => (c.category_id ?? c.id) === categoryId
|
||||
(c) => String(c.category_id ?? c.id) === String(categoryId)
|
||||
);
|
||||
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
|
||||
|
||||
@@ -625,10 +641,6 @@ export class LiveStreamLayoutComponent
|
||||
persistLiveEpgPanelState(state);
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.liveSidebarStateService.toggle('portal');
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleSidebarShortcut(event: KeyboardEvent): void {
|
||||
if (
|
||||
@@ -641,7 +653,7 @@ export class LiveStreamLayoutComponent
|
||||
!this.hostElement.nativeElement.closest('[inert]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.toggleSidebar();
|
||||
this.livePanels.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
import { Component, Directive, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import {
|
||||
LIVE_CATEGORIES_POPOVER,
|
||||
LiveLayoutSidebarStateService,
|
||||
liveSidebarStateStorageKey,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
ChannelListHiddenStateComponent,
|
||||
GridListComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
FavoritesService,
|
||||
XtreamStore,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
|
||||
/**
|
||||
* The channels header while the shell's categories rail is folded: the
|
||||
* category dropdown, the show-categories chevron and the collapse-all
|
||||
* chevron. Split from `live-stream-layout.component.spec.ts`, which sits at
|
||||
* the spec line cap; the harness here renders no player, no EPG and no
|
||||
* channel list.
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-channels-list',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPortalChannelsListComponent {
|
||||
readonly sortMode = input('server');
|
||||
readonly channelsOverride = input<unknown[] | null>(null);
|
||||
readonly searchTermInput = input('');
|
||||
readonly fullscreenPanelCopy = input(false);
|
||||
readonly revealRequest = input<unknown>(null);
|
||||
readonly filteredChannels = signal<unknown[]>([]);
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly playbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-hidden-state',
|
||||
standalone: true,
|
||||
template: '<div data-test-id="hidden-state-stub"></div>',
|
||||
})
|
||||
class StubChannelListHiddenStateComponent {
|
||||
readonly restore = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-grid-list', standalone: true, template: '' })
|
||||
class StubGridListComponent {
|
||||
readonly items = input<unknown[]>([]);
|
||||
readonly isLoading = input(false);
|
||||
readonly searchTerm = input('');
|
||||
readonly variant = input('poster');
|
||||
readonly type = input<string>();
|
||||
readonly itemClicked = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view, app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPassiveComponent {}
|
||||
|
||||
@Directive({ selector: '[appResizable]', standalone: true })
|
||||
class StubResizableDirective {}
|
||||
|
||||
describe('LiveStreamLayoutComponent sidebar levels', () => {
|
||||
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
|
||||
let service: LiveLayoutSidebarStateService;
|
||||
const categoriesPopover = {
|
||||
open: jest.fn(),
|
||||
close: jest.fn(),
|
||||
showCategoriesPanel: jest.fn(),
|
||||
};
|
||||
const selectedCategoryId = signal<number | null>(1);
|
||||
const emptyList = signal<unknown[]>([]);
|
||||
const xtreamStore = {
|
||||
getCategoriesBySelectedType: signal([
|
||||
{ category_id: 1, category_name: 'News' },
|
||||
]),
|
||||
getCategoryItemCounts: signal(new Map<number, number>([[1, 7]])),
|
||||
getPaginatedContent: emptyList,
|
||||
hasMoreContent: signal(false),
|
||||
epgItems: emptyList,
|
||||
currentEpgItem: signal(null),
|
||||
isLoadingEpg: signal(false),
|
||||
selectedTypeContentLoading: signal(false),
|
||||
selectedCategoryId,
|
||||
selectedContentType: signal('live'),
|
||||
// No selected item: the content area shows the empty state, so the
|
||||
// header is the only thing under test.
|
||||
selectedItem: signal(null),
|
||||
currentPlaylist: signal({ _id: 'pl-1', title: 'Playlist' }),
|
||||
liveStreams: emptyList,
|
||||
selectItemsFromSelectedCategory: jest.fn(() => []),
|
||||
constructStreamUrl: jest.fn(() => ''),
|
||||
openPlayer: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
loadMoreContent: jest.fn(),
|
||||
};
|
||||
|
||||
function query<T extends HTMLElement>(selector: string): T | null {
|
||||
return fixture.nativeElement.querySelector(selector);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
categoriesPopover.open.mockClear();
|
||||
categoriesPopover.close.mockClear();
|
||||
categoriesPopover.showCategoriesPanel.mockClear();
|
||||
selectedCategoryId.set(1);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
data: {},
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
pathFromRoot: [
|
||||
{ snapshot: { data: { layout: 'workspace' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
events: new Subject().asObservable(),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{
|
||||
provide: FavoritesService,
|
||||
useValue: { getFavorites: jest.fn(() => of([])) },
|
||||
},
|
||||
{
|
||||
provide: XtreamUrlService,
|
||||
useValue: { resolveCatchupUrl: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: false,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => true,
|
||||
openExternalPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: LIVE_CATEGORIES_POPOVER, useValue: categoriesPopover },
|
||||
],
|
||||
})
|
||||
.overrideComponent(LiveStreamLayoutComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
ChannelListHiddenStateComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubChannelListHiddenStateComponent,
|
||||
StubGridListComponent,
|
||||
StubPassiveComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(TranslatePipe, (value: string) => value),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
|
||||
service = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
service.setState('portal', 'expanded');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.setState('portal', 'expanded');
|
||||
fixture.destroy();
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
});
|
||||
|
||||
it('keeps the plain category heading while the rail is expanded', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
|
||||
expect(query('[data-test-id="live-show-categories"]')).toBeNull();
|
||||
expect(query('.category-heading')?.textContent).toContain('News');
|
||||
});
|
||||
|
||||
it('keeps the plain heading for a search-only rail with no selected category', () => {
|
||||
selectedCategoryId.set(null);
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
// No category and no search term: the layout renders no rail at all.
|
||||
expect(query('.sidebar')).toBeNull();
|
||||
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('turns the heading into a category dropdown that opens the shell popover', () => {
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
const dropdown = query<HTMLButtonElement>(
|
||||
'[data-test-id="live-category-dropdown"]'
|
||||
);
|
||||
expect(dropdown).not.toBeNull();
|
||||
expect(dropdown?.textContent).toContain('News');
|
||||
expect(dropdown?.textContent).toContain('7');
|
||||
expect(dropdown?.getAttribute('aria-label')).toBe(
|
||||
'LAYOUT.CHOOSE_CATEGORY'
|
||||
);
|
||||
expect(query('.category-heading')).toBeNull();
|
||||
|
||||
dropdown?.click();
|
||||
|
||||
expect(categoriesPopover.open).toHaveBeenCalledWith(dropdown);
|
||||
});
|
||||
|
||||
it('asks the shell to restore the rail from the show-categories button', () => {
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
query<HTMLButtonElement>(
|
||||
'[data-test-id="live-show-categories"]'
|
||||
)?.click();
|
||||
|
||||
expect(categoriesPopover.showCategoriesPanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('collapses both rails from the header chevron and comes back to the same level', () => {
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
query<HTMLButtonElement>(
|
||||
'.sidebar-header [aria-label="LAYOUT.HIDE_PANELS"]'
|
||||
)?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(service.stateOf('portal')()).toBe('collapsed');
|
||||
expect(query('.sidebar')?.classList).toContain('sidebar-collapsed');
|
||||
const restore = query<HTMLButtonElement>('.sidebar-restore');
|
||||
expect(restore?.getAttribute('aria-label')).toBe('LAYOUT.SHOW_PANELS');
|
||||
|
||||
restore?.click();
|
||||
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('picks focus up on the show-categories button when the rail folds and focus was lost', async () => {
|
||||
fixture.detectChanges();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves focus alone when a control still owns it', async () => {
|
||||
fixture.detectChanges();
|
||||
const sort = query<HTMLButtonElement>(
|
||||
'.sidebar-header [aria-label="Sort channels"]'
|
||||
);
|
||||
sort?.focus();
|
||||
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
|
||||
expect(document.activeElement).toBe(sort);
|
||||
});
|
||||
|
||||
it('hands focus to the floating restore handle at player-only and back to show-categories on expand', async () => {
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
service.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
expect(document.activeElement).toBe(query('.sidebar-restore'));
|
||||
|
||||
// The handle is removed with the expand, so focus is lost again.
|
||||
service.expand('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
);
|
||||
});
|
||||
|
||||
it('hands focus to show-categories when the first category selection folds the rail on the live root', async () => {
|
||||
selectedCategoryId.set(null);
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
// The category button the user activated is inert now: focus lost.
|
||||
selectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="live-show-categories"]')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the floating restore button at player-only even without a selected category, and hides it when expanded', () => {
|
||||
selectedCategoryId.set(null);
|
||||
service.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
expect(query('.sidebar-restore')).not.toBeNull();
|
||||
|
||||
selectedCategoryId.set(1);
|
||||
service.expand('portal');
|
||||
fixture.detectChanges();
|
||||
expect(query('.sidebar-restore')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists the channels sidebar width under a dedicated storage key', () => {
|
||||
// The shell context panel (category sidebar) is visible at the same
|
||||
// time as this sidebar and persists its width under the shared
|
||||
// "sidebar-width" key. Reusing that key here makes the two panels
|
||||
// overwrite each other's stored width across reloads.
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.sidebar')?.getAttribute('storageKey')).toBe(
|
||||
'live-channels-sidebar-width'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the folded channels rail inert at player-only', () => {
|
||||
service.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.sidebar')?.hasAttribute('inert')).toBe(true);
|
||||
|
||||
service.expand('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.sidebar')?.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers Cmd/Ctrl+B with the same nested toggle', () => {
|
||||
service.hideCategories('portal');
|
||||
fixture.detectChanges();
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'b', ctrlKey: true })
|
||||
);
|
||||
expect(service.stateOf('portal')()).toBe('collapsed');
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'b', metaKey: true })
|
||||
);
|
||||
expect(service.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -74,7 +74,7 @@ describe('XtreamLiveChannelNavigationService', () => {
|
||||
queryParamMap: unknown;
|
||||
};
|
||||
const navigate = jest.fn();
|
||||
const sidebar = { setState: jest.fn() };
|
||||
const sidebar = { setState: jest.fn(), expand: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
currentPlaylist.set({ id: 'source-1' });
|
||||
@@ -187,7 +187,7 @@ describe('XtreamLiveChannelNavigationService', () => {
|
||||
);
|
||||
expect(selectedCategoryId()).toBe(10);
|
||||
expect(categorySearchTerm()).toBe('');
|
||||
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
|
||||
expect(sidebar.expand).toHaveBeenCalledWith('portal');
|
||||
expect(service.revealRequest()).toEqual(
|
||||
expect.objectContaining({ channelId: 1 })
|
||||
);
|
||||
@@ -277,6 +277,7 @@ describe('XtreamLiveChannelNavigationService', () => {
|
||||
await reveal;
|
||||
expect(store.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(sidebar.setState).not.toHaveBeenCalled();
|
||||
expect(sidebar.expand).not.toHaveBeenCalled();
|
||||
expect(service.revealRequest()).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -232,7 +232,7 @@ export class XtreamLiveChannelNavigationService {
|
||||
return;
|
||||
this.store.setCategorySearchTerm('');
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
this.sidebar.setState('portal', 'expanded');
|
||||
this.sidebar.expand('portal');
|
||||
this.pendingReveal.set({
|
||||
channelId: target.xtream_id,
|
||||
categoryId,
|
||||
|
||||
@@ -77,5 +77,58 @@
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
// Shown while the shell's categories rail is folded: the way back to
|
||||
// the rail, in the same spot the rail's own chevron folded it from.
|
||||
.sidebar-show-categories {
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
// The category title as a dropdown trigger (categories rail folded).
|
||||
// Same type as the static heading so folding the rail does not move
|
||||
// the title; the caret is the only added ink.
|
||||
.category-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 4px 0 8px;
|
||||
margin-left: -8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
app-region: no-drag;
|
||||
|
||||
.category-title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__caret {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded='true'] {
|
||||
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
|
||||
border-color: var(--app-separator);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="nav-list" id="portal-categories">
|
||||
<div
|
||||
class="nav-list"
|
||||
[attr.id]="columnHandoff() ? 'portal-categories' : null"
|
||||
>
|
||||
@if (statusText()) {
|
||||
<div class="context-inline-status">
|
||||
{{ statusText() }}
|
||||
@@ -9,7 +12,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item"
|
||||
(keydown)="focusLiveChannels($event)"
|
||||
(keydown)="onCategoryKeydown($event)"
|
||||
[attr.data-category-id]="
|
||||
$any(item).category_id ?? $any(item).xtream_id ?? item.id
|
||||
"
|
||||
|
||||
+18
-1
@@ -28,7 +28,6 @@ interface WorkspaceCategoryViewItem {
|
||||
styleUrl: './workspace-context-category-view.component.scss',
|
||||
})
|
||||
export class WorkspaceContextCategoryViewComponent {
|
||||
readonly focusLiveChannels = focusLiveChannels;
|
||||
|
||||
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
@@ -43,6 +42,24 @@ export class WorkspaceContextCategoryViewComponent {
|
||||
readonly omitMissingCounts = input(false);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly statusText = input('');
|
||||
/**
|
||||
* The in-flow shell rail takes part in the live-TV column keyboard
|
||||
* contract: it is `#portal-categories` (ArrowLeft from the channels pane
|
||||
* lands on the selected category) and ArrowRight on that category hands
|
||||
* focus to `#live-channels`. The same view stamped into the categories
|
||||
* popover must opt out: the dialog's focus trap would bounce that
|
||||
* handoff back into the dialog, and a second `#portal-categories` would
|
||||
* shadow the folded rail's.
|
||||
*/
|
||||
readonly columnHandoff = input(true);
|
||||
|
||||
onCategoryKeydown(event: KeyboardEvent): void {
|
||||
// A method, not an inline `&&`: an event binding whose expression
|
||||
// evaluates to `false` makes Angular call preventDefault().
|
||||
if (this.columnHandoff()) {
|
||||
focusLiveChannels(event);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly hostEl = inject(ElementRef<HTMLElement>);
|
||||
|
||||
|
||||
+20
@@ -5,6 +5,7 @@
|
||||
<div class="context-header__actions">
|
||||
@if (canSearchCategories()) {
|
||||
<button
|
||||
#firstHeaderAction
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
@@ -64,6 +65,7 @@
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
data-test-id="context-manage-categories"
|
||||
(click)="openManageCategories()"
|
||||
[disabled]="!canManageXtreamCategories()"
|
||||
[matTooltip]="
|
||||
@@ -76,6 +78,22 @@
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canHideCategories()) {
|
||||
<button
|
||||
#hideCategoriesButton
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
data-test-id="context-hide-categories"
|
||||
(click)="hideCategories()"
|
||||
[matTooltip]="'LAYOUT.HIDE_CATEGORIES' | translate"
|
||||
[attr.aria-label]="
|
||||
'LAYOUT.HIDE_CATEGORIES' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- @if (subtitle(); as text) {
|
||||
@@ -130,6 +148,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<app-workspace-context-category-view
|
||||
[columnHandoff]="presentation() === 'sidebar'"
|
||||
[items]="filteredXtreamCategories()"
|
||||
[selectedCategoryId]="xtreamSelectedCategoryId()"
|
||||
[itemCounts]="xtreamCategoryItemCounts()"
|
||||
@@ -162,6 +181,7 @@
|
||||
/>
|
||||
} @else {
|
||||
<app-workspace-context-category-view
|
||||
[columnHandoff]="presentation() === 'sidebar'"
|
||||
[items]="filteredStalkerCategories()"
|
||||
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
|
||||
[itemCounts]="stalkerCategoryItemCounts()"
|
||||
|
||||
+193
-7
@@ -15,6 +15,7 @@ import {
|
||||
XtreamContentLoadState,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
@@ -116,6 +117,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const dialog = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
const drawerOpen = signal(false);
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
|
||||
@@ -142,6 +144,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
]);
|
||||
router.navigate.mockClear();
|
||||
dialog.open.mockClear();
|
||||
drawerOpen.set(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceContextPanelComponent, NoopAnimationsModule],
|
||||
@@ -179,7 +182,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
// Root-provided in production; stubbed because the spec's
|
||||
// Router mock has no `events` stream for the real service.
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { close: jest.fn(), isOpen: () => false },
|
||||
useValue: { close: jest.fn(), isOpen: drawerOpen },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -204,9 +207,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const status = fixture.nativeElement.querySelector(
|
||||
'.context-inline-status'
|
||||
) as HTMLElement | null;
|
||||
const manageButton = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.context-header__action')
|
||||
).at(-1) as HTMLButtonElement | undefined;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="context-manage-categories"]'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(countPlaceholders).toHaveLength(2);
|
||||
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
|
||||
@@ -261,9 +264,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
const manageButton = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.context-header__action')
|
||||
).at(-1) as HTMLButtonElement | undefined;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="context-manage-categories"]'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(countTexts).toEqual(['3', '0']);
|
||||
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
|
||||
@@ -493,6 +496,189 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('folded categories rail', () => {
|
||||
let liveSidebarService: LiveLayoutSidebarStateService;
|
||||
|
||||
beforeEach(() => {
|
||||
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
liveSidebarService.setState('portal', 'expanded');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
liveSidebarService.setState('portal', 'expanded');
|
||||
});
|
||||
|
||||
function hideButton(): HTMLButtonElement | null {
|
||||
return fixture.nativeElement.querySelector(
|
||||
'[data-test-id="context-hide-categories"]'
|
||||
);
|
||||
}
|
||||
|
||||
it('offers the hide-categories chevron on live sections of the sidebar and folds the rail', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = hideButton();
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.getAttribute('aria-label')).toBe(
|
||||
'LAYOUT.HIDE_CATEGORIES'
|
||||
);
|
||||
|
||||
button?.click();
|
||||
|
||||
expect(liveSidebarService.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('offers it for Stalker itv and radio too', () => {
|
||||
fixture.componentRef.setInput('context', {
|
||||
provider: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
});
|
||||
fixture.componentRef.setInput('section', 'radio');
|
||||
stalkerStore.selectedCategoryId.set('12');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hideButton()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('withholds the chevron inside the open phone drawer, whose stylesheet ignores the folded state', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
drawerOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hideButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('names the chevron as focus target, the first header action without one, and nothing while categories are absent', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.focusTarget()).toBe(hideButton());
|
||||
|
||||
// Live root: no chevron, the search action stands in.
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.focusTarget()).toBe(
|
||||
fixture.nativeElement.querySelector('.context-header__action')
|
||||
);
|
||||
|
||||
// Nothing loaded yet: no control at all, the caller falls back.
|
||||
xtreamCategories.set([]);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.focusTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('withholds the chevron while no category is selected (live root has no channels rail)', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hideButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps VOD and series rails without the chevron', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hideButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('renders no chevron in the popover presentation, whose footer restores the rail instead', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
fixture.componentRef.setInput('presentation', 'popover');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hideButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('shares the category sort with a second (popover) instance of the panel', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.setCategorySortMode('name-desc');
|
||||
|
||||
const popover = TestBed.createComponent(
|
||||
WorkspaceContextPanelComponent
|
||||
);
|
||||
popover.componentRef.setInput('context', {
|
||||
provider: 'xtreams',
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
popover.componentRef.setInput('section', 'live');
|
||||
popover.componentRef.setInput('presentation', 'popover');
|
||||
popover.detectChanges();
|
||||
expect(popover.componentInstance.categorySortMode()).toBe(
|
||||
'name-desc'
|
||||
);
|
||||
|
||||
popover.componentInstance.setCategorySortMode('name-asc');
|
||||
expect(fixture.componentInstance.categorySortMode()).toBe(
|
||||
'name-asc'
|
||||
);
|
||||
popover.destroy();
|
||||
});
|
||||
|
||||
it('keeps the live-TV column keyboard contract out of the popover', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#portal-categories')
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('presentation', 'popover');
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#portal-categories')
|
||||
).toBeNull();
|
||||
|
||||
// ArrowRight on the selected category must not reach the
|
||||
// background channels pane from inside the dialog.
|
||||
const pane = document.createElement('div');
|
||||
pane.id = 'live-channels';
|
||||
pane.tabIndex = -1;
|
||||
document.body.appendChild(pane);
|
||||
pane.checkVisibility = () => true;
|
||||
try {
|
||||
const selected = fixture.nativeElement.querySelector(
|
||||
'.category-item[aria-current="true"]'
|
||||
) as HTMLButtonElement;
|
||||
selected.focus();
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
selected.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(selected);
|
||||
} finally {
|
||||
pane.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a category selection so a popover host can close', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
fixture.detectChanges();
|
||||
const selected = jest.fn();
|
||||
fixture.componentInstance.categorySelected.subscribe(selected);
|
||||
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
categoryButtons[1]?.click();
|
||||
|
||||
expect(selected).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the active xtream live item when switching live categories', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
|
||||
+69
-7
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
@@ -22,9 +23,9 @@ import {
|
||||
asStalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PortalCategorySortMode,
|
||||
persistPortalCategorySortMode,
|
||||
restorePortalCategorySortMode,
|
||||
PortalCategorySortStateService,
|
||||
sortPortalCategoryItems,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
@@ -80,9 +81,49 @@ export class WorkspaceContextPanelComponent {
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly liveSidebarState = inject(LiveLayoutSidebarStateService);
|
||||
|
||||
readonly context = input.required<WorkspaceContextRoute>();
|
||||
readonly section = input.required<string>();
|
||||
/**
|
||||
* `sidebar` is the in-flow shell rail and offers the "hide categories"
|
||||
* chevron on live sections; `popover` is the same panel stamped into the
|
||||
* live categories dropdown while that rail is folded, where the chevron
|
||||
* would be meaningless (the popover's own footer restores the rail).
|
||||
*/
|
||||
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
|
||||
/** A category was picked; the popover host closes on it. */
|
||||
readonly categorySelected = output<void>();
|
||||
|
||||
readonly isLiveSection = computed(() => {
|
||||
const section = this.section();
|
||||
return (
|
||||
(this.context().provider === 'xtreams' && section === 'live') ||
|
||||
(this.context().provider === 'stalker' &&
|
||||
(section === 'itv' || section === 'radio'))
|
||||
);
|
||||
});
|
||||
// Only while a category is selected: that is when the live layout
|
||||
// renders the channels rail whose header offers the way back. Not in the
|
||||
// open phone drawer either: its stylesheet ignores the folded state, so
|
||||
// the tap would change nothing visible while persisting a preference
|
||||
// that only bites once the window is wide again.
|
||||
readonly canHideCategories = computed(
|
||||
() =>
|
||||
this.presentation() === 'sidebar' &&
|
||||
this.isLiveSection() &&
|
||||
!this.contextDrawer?.isOpen() &&
|
||||
(this.context().provider === 'xtreams'
|
||||
? this.xtreamSelectedCategoryId() !== null
|
||||
: !!this.stalkerSelectedCategoryId())
|
||||
);
|
||||
private readonly hideCategoriesButton = viewChild(
|
||||
'hideCategoriesButton',
|
||||
{ read: ElementRef<HTMLElement> }
|
||||
);
|
||||
private readonly firstHeaderAction = viewChild('firstHeaderAction', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
|
||||
readonly isXtreamCategories = computed(
|
||||
() =>
|
||||
@@ -212,9 +253,9 @@ export class WorkspaceContextPanelComponent {
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
readonly isSearchOpen = signal(false);
|
||||
readonly categorySearchTerm = signal('');
|
||||
readonly categorySortMode = signal<PortalCategorySortMode>(
|
||||
restorePortalCategorySortMode()
|
||||
);
|
||||
// Shared with the popover copy of this panel; see the service's note.
|
||||
private readonly categorySort = inject(PortalCategorySortStateService);
|
||||
readonly categorySortMode = this.categorySort.mode;
|
||||
readonly categorySortLabelKey = computed(() =>
|
||||
this.getCategorySortLabelKey(this.categorySortMode())
|
||||
);
|
||||
@@ -333,6 +374,22 @@ export class WorkspaceContextPanelComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The control the shell sidebar hands focus to once this rail has
|
||||
* unfolded (the button the user activated to bring it back is gone by
|
||||
* then): the hide chevron when it is offered, else the first header
|
||||
* action. `null` while neither is rendered — categories still loading,
|
||||
* or a failed Stalker load — and the sidebar falls back to the rail
|
||||
* itself.
|
||||
*/
|
||||
focusTarget(): HTMLElement | null {
|
||||
return (
|
||||
this.hideCategoriesButton()?.nativeElement ??
|
||||
this.firstHeaderAction()?.nativeElement ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
toggleCategorySearch(): void {
|
||||
const opening = !this.isSearchOpen();
|
||||
this.isSearchOpen.set(opening);
|
||||
@@ -352,8 +409,7 @@ export class WorkspaceContextPanelComponent {
|
||||
}
|
||||
|
||||
setCategorySortMode(mode: PortalCategorySortMode): void {
|
||||
this.categorySortMode.set(mode);
|
||||
persistPortalCategorySortMode(mode);
|
||||
this.categorySort.setMode(mode);
|
||||
}
|
||||
|
||||
openManageCategories(): void {
|
||||
@@ -398,6 +454,10 @@ export class WorkspaceContextPanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
hideCategories(): void {
|
||||
this.liveSidebarState.hideCategories('portal');
|
||||
}
|
||||
|
||||
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
|
||||
if (!this.isXtreamCategoryInteractionEnabled()) {
|
||||
return;
|
||||
@@ -415,6 +475,7 @@ export class WorkspaceContextPanelComponent {
|
||||
}
|
||||
const categoryId = numericCategoryId;
|
||||
this.contextDrawer?.close();
|
||||
this.categorySelected.emit();
|
||||
|
||||
if (section === 'live') {
|
||||
this.xtreamStore.setSelectedCategory(categoryId);
|
||||
@@ -456,6 +517,7 @@ export class WorkspaceContextPanelComponent {
|
||||
const categoryId = String(item.category_id ?? '*');
|
||||
|
||||
this.contextDrawer?.close();
|
||||
this.categorySelected.emit();
|
||||
this.stalkerStore.setSelectedCategory(categoryId);
|
||||
this.stalkerStore.setPage(0);
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
@if (context(); as context) {
|
||||
@if (section(); as section) {
|
||||
<app-workspace-context-panel
|
||||
class="popover-panel"
|
||||
presentation="popover"
|
||||
[context]="context"
|
||||
[section]="section"
|
||||
(categorySelected)="onCategorySelected()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
<div class="popover-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="popover-footer__action"
|
||||
data-test-id="live-categories-popover-show-panel"
|
||||
(click)="showCategoriesPanel()"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
<span>{{ 'LAYOUT.SHOW_CATEGORIES_PANEL' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Height-capped by the overlay to the space below the anchor: the embedded
|
||||
// panel's own list scrolls, header and footer stay put.
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
width: 300px;
|
||||
padding: 8px 6px 6px;
|
||||
border-radius: 14px;
|
||||
// Widget surface, one step above the channels rail it floats over, plus a
|
||||
// hairline so the two do not merge where their edges meet.
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container));
|
||||
outline: 1px solid var(--mat-sys-outline-variant);
|
||||
box-shadow: 0 16px 40px -16px rgba(0, 0, 0, 0.5);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.popover-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
flex: none;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.popover-footer__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-primary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { CdkTrapFocus } from '@angular/cdk/a11y';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
|
||||
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
|
||||
|
||||
/**
|
||||
* The live categories rail stamped into a popover while that rail is folded
|
||||
* (`LiveSidebarState` `categories-hidden`). It renders the very same
|
||||
* `WorkspaceContextPanelComponent` the shell sidebar uses, so search, sort,
|
||||
* counts, selection and the category click handlers stay one implementation;
|
||||
* only the presentation differs (no hide chevron, a footer that restores the
|
||||
* rail). Opened by `WorkspaceLiveCategoriesPopoverService`.
|
||||
*
|
||||
* The trigger advertises `aria-haspopup="dialog"`, so the host is that
|
||||
* dialog: it carries the role and label, and `CdkTrapFocus` (a host
|
||||
* directive) moves focus into the panel on open and keeps Tab inside it
|
||||
* while the backdrop hides the page underneath. The service returns focus
|
||||
* to the trigger on close.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-workspace-live-categories-popover',
|
||||
imports: [MatIcon, TranslatePipe, WorkspaceContextPanelComponent],
|
||||
templateUrl: './workspace-live-categories-popover.component.html',
|
||||
styleUrl: './workspace-live-categories-popover.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
hostDirectives: [CdkTrapFocus],
|
||||
host: {
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'[attr.aria-label]': 'dialogLabel',
|
||||
},
|
||||
})
|
||||
export class WorkspaceLiveCategoriesPopoverComponent {
|
||||
private readonly routeState = inject(WorkspaceShellRouteStateService);
|
||||
// The shell-provided popover bridge that hosts this component.
|
||||
private readonly popover = inject(LIVE_CATEGORIES_POPOVER);
|
||||
readonly dialogLabel = inject(TranslateService).instant(
|
||||
'LAYOUT.CHOOSE_CATEGORY'
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Host directive inputs cannot be bound from a portal-created
|
||||
// component, so the trap is configured here; the directive's own
|
||||
// AfterContentInit runs after this constructor and captures focus.
|
||||
const focusTrap = inject(CdkTrapFocus, { self: true });
|
||||
focusTrap.enabled = true;
|
||||
focusTrap.autoCapture = true;
|
||||
}
|
||||
|
||||
readonly context = this.routeState.currentContext;
|
||||
readonly section = this.routeState.currentSection;
|
||||
/** Fired when the popover wants to be closed by its host. */
|
||||
readonly closed = output<void>();
|
||||
|
||||
onCategorySelected(): void {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
showCategoriesPanel(): void {
|
||||
// Restores the rail (and opens the phone drawer where the rail is
|
||||
// one); the service closes this popover as part of it.
|
||||
this.popover.showCategoriesPanel();
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import { OverlayModule } from '@angular/cdk/overlay';
|
||||
import {
|
||||
ApplicationRef,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import {
|
||||
LIVE_CATEGORIES_POPOVER,
|
||||
LiveLayoutSidebarStateService,
|
||||
liveSidebarStateStorageKey,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
|
||||
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
|
||||
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
|
||||
import { WorkspaceLiveCategoriesPopoverService } from './workspace-live-categories-popover.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-panel',
|
||||
template: `<div data-test-id="stub-context-panel">{{ section() }}</div>
|
||||
<button data-test-id="stub-pick" (click)="categorySelected.emit()">
|
||||
pick
|
||||
</button>`,
|
||||
standalone: true,
|
||||
})
|
||||
class StubWorkspaceContextPanelComponent {
|
||||
readonly context = input.required<unknown>();
|
||||
readonly section = input.required<string>();
|
||||
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
|
||||
readonly categorySelected = output<void>();
|
||||
}
|
||||
|
||||
describe('WorkspaceLiveCategoriesPopoverService', () => {
|
||||
let service: WorkspaceLiveCategoriesPopoverService;
|
||||
let sidebarState: LiveLayoutSidebarStateService;
|
||||
let origin: HTMLButtonElement;
|
||||
let routerEvents: Subject<unknown>;
|
||||
const drawer = { open: jest.fn(), close: jest.fn(), isOpen: () => false };
|
||||
let phoneViewport = false;
|
||||
|
||||
// Portal-attached views render on the next application tick; no fixture
|
||||
// drives change detection here.
|
||||
function open(): void {
|
||||
service.open(origin);
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
}
|
||||
|
||||
function pane(): HTMLElement | null {
|
||||
return document.querySelector(
|
||||
'.workspace-live-categories-popover-pane'
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
routerEvents = new Subject();
|
||||
drawer.open.mockClear();
|
||||
phoneViewport = false;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: query.includes('640') && phoneViewport,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
}),
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [OverlayModule, NoopAnimationsModule],
|
||||
providers: [
|
||||
WorkspaceLiveCategoriesPopoverService,
|
||||
{
|
||||
provide: LIVE_CATEGORIES_POPOVER,
|
||||
useExisting: WorkspaceLiveCategoriesPopoverService,
|
||||
},
|
||||
{ provide: WorkspaceShellContextDrawerService, useValue: drawer },
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { events: routerEvents.asObservable() },
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellRouteStateService,
|
||||
useValue: {
|
||||
currentContext: signal({
|
||||
provider: 'xtreams',
|
||||
playlistId: 'pl-1',
|
||||
}),
|
||||
currentSection: signal('live'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WorkspaceLiveCategoriesPopoverComponent, {
|
||||
remove: {
|
||||
imports: [WorkspaceContextPanelComponent, TranslatePipe],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubWorkspaceContextPanelComponent,
|
||||
MockPipe(TranslatePipe, (value: string) => value),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
service = TestBed.inject(WorkspaceLiveCategoriesPopoverService);
|
||||
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
sidebarState.hideCategories('portal');
|
||||
origin = document.createElement('button');
|
||||
document.body.appendChild(origin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.close();
|
||||
origin.remove();
|
||||
sidebarState.setState('portal', 'expanded');
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
});
|
||||
|
||||
it('opens the categories panel in popover presentation under the origin', () => {
|
||||
open();
|
||||
|
||||
const panel = pane()?.querySelector(
|
||||
'[data-test-id="stub-context-panel"]'
|
||||
);
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.textContent).toContain('live');
|
||||
expect(origin.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('toggles closed when opened again from the same origin', () => {
|
||||
open();
|
||||
open();
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
expect(origin.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('restores the rail from the footer and closes', () => {
|
||||
open();
|
||||
|
||||
(
|
||||
pane()?.querySelector(
|
||||
'[data-test-id="live-categories-popover-show-panel"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
|
||||
expect(sidebarState.stateOf('portal')()).toBe('expanded');
|
||||
expect(pane()).toBeNull();
|
||||
expect(drawer.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the phone context drawer as well, since the rail is that drawer there', () => {
|
||||
phoneViewport = true;
|
||||
open();
|
||||
|
||||
service.showCategoriesPanel();
|
||||
|
||||
expect(sidebarState.stateOf('portal')()).toBe('expanded');
|
||||
expect(pane()).toBeNull();
|
||||
expect(drawer.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes after a category selection', () => {
|
||||
open();
|
||||
|
||||
(
|
||||
pane()?.querySelector(
|
||||
'[data-test-id="stub-pick"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('closes on backdrop click and leaves the rail folded', () => {
|
||||
open();
|
||||
|
||||
const backdrop = document.querySelector(
|
||||
'.cdk-overlay-backdrop'
|
||||
) as HTMLElement;
|
||||
expect(backdrop).not.toBeNull();
|
||||
backdrop.click();
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
|
||||
});
|
||||
|
||||
it('is a labelled modal dialog that traps and takes focus on open', async () => {
|
||||
// JSDOM lays nothing out, so the CDK interactivity checker sees every
|
||||
// element as invisible and never focuses one; give them geometry.
|
||||
const geometry = jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||
.mockReturnValue(10);
|
||||
try {
|
||||
open();
|
||||
// CdkTrapFocus captures focus once the zone settles.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
const host = pane()?.querySelector(
|
||||
'app-workspace-live-categories-popover'
|
||||
);
|
||||
expect(host?.getAttribute('role')).toBe('dialog');
|
||||
expect(host?.getAttribute('aria-modal')).toBe('true');
|
||||
expect(host?.getAttribute('aria-label')).toBe(
|
||||
'LAYOUT.CHOOSE_CATEGORY'
|
||||
);
|
||||
expect(
|
||||
pane()?.querySelectorAll('.cdk-focus-trap-anchor').length
|
||||
).toBe(2);
|
||||
expect(host?.contains(document.activeElement)).toBe(true);
|
||||
|
||||
service.close();
|
||||
|
||||
expect(document.activeElement).toBe(origin);
|
||||
} finally {
|
||||
geometry.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when a navigation starts, so it cannot float over the next route', () => {
|
||||
open();
|
||||
|
||||
routerEvents.next(new NavigationEnd(1, '/a', '/a'));
|
||||
expect(pane()).not.toBeNull();
|
||||
|
||||
routerEvents.next(new NavigationStart(2, '/b'));
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on any live-panel level change, e.g. Cmd/Ctrl+B reaching the layout through the dialog', () => {
|
||||
open();
|
||||
|
||||
sidebarState.collapse('portal');
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not hand focus back to a trigger inside a folded, inert rail', () => {
|
||||
const rail = document.createElement('div');
|
||||
rail.appendChild(origin);
|
||||
document.body.appendChild(rail);
|
||||
open();
|
||||
|
||||
rail.setAttribute('inert', '');
|
||||
service.close();
|
||||
|
||||
expect(document.activeElement).not.toBe(origin);
|
||||
rail.remove();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
open();
|
||||
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
|
||||
expect(pane()).toBeNull();
|
||||
});
|
||||
});
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import {
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import {
|
||||
LiveCategoriesPopover,
|
||||
LiveLayoutSidebarStateService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
|
||||
|
||||
/**
|
||||
* Shell-side implementation of `LIVE_CATEGORIES_POPOVER`. Provided on the
|
||||
* workspace shell so the live layouts rendered in its router outlet can open
|
||||
* the folded categories rail as a popover under their category dropdown.
|
||||
*
|
||||
* One popover at a time: opening from the origin that already owns it closes
|
||||
* it (toggle), opening from another origin moves it. The transparent backdrop
|
||||
* and Escape close it, and so does any category selection or the footer's
|
||||
* "show categories panel" action, which the component reports through
|
||||
* `closed`. Any router navigation closes it too: the shell (and so this
|
||||
* service) outlives the child route that owns the trigger, and browser
|
||||
* Back/Forward or a global shortcut route would otherwise leave the panel
|
||||
* floating over the destination page, re-rendered for the new route's
|
||||
* context. So does any live-panel level change: `Cmd/Ctrl+B` reaches the
|
||||
* layout's document-level handler through the dialog, and the trigger that
|
||||
* owns this popover is then inside a folded, `inert` rail.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkspaceLiveCategoriesPopoverService
|
||||
implements LiveCategoriesPopover
|
||||
{
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService);
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private origin: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.close());
|
||||
inject(Router)
|
||||
.events.pipe(
|
||||
filter((event) => event instanceof NavigationStart),
|
||||
takeUntilDestroyed()
|
||||
)
|
||||
.subscribe(() => this.close());
|
||||
// Seeded by the effect's first run, not at construction: the state
|
||||
// may move between the two before anything is open.
|
||||
const sidebarState = this.sidebarState.stateOf('portal');
|
||||
let previous: string | null = null;
|
||||
effect(() => {
|
||||
const next = sidebarState();
|
||||
const changed = previous !== null && next !== previous;
|
||||
previous = next;
|
||||
if (changed) {
|
||||
untracked(() => this.close());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open(origin: HTMLElement): void {
|
||||
if (this.overlayRef && this.origin === origin) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
|
||||
const overlayRef = this.overlay.create({
|
||||
hasBackdrop: true,
|
||||
backdropClass: 'cdk-overlay-transparent-backdrop',
|
||||
panelClass: 'workspace-live-categories-popover-pane',
|
||||
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
||||
positionStrategy: this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(origin)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: 6,
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -6,
|
||||
},
|
||||
])
|
||||
.withFlexibleDimensions(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPush(true)
|
||||
.withViewportMargin(8),
|
||||
maxHeight: 'min(480px, calc(100vh - 16px))',
|
||||
});
|
||||
this.overlayRef = overlayRef;
|
||||
this.origin = origin;
|
||||
|
||||
const componentRef = overlayRef.attach(
|
||||
new ComponentPortal(
|
||||
WorkspaceLiveCategoriesPopoverComponent,
|
||||
null,
|
||||
this.injector
|
||||
)
|
||||
);
|
||||
componentRef.instance.closed.subscribe(() => this.close());
|
||||
overlayRef.backdropClick().subscribe(() => this.close());
|
||||
overlayRef.keydownEvents().subscribe((event) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
origin.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
showCategoriesPanel(): void {
|
||||
this.close();
|
||||
this.sidebarState.showCategories('portal');
|
||||
// At phone widths the rail renders as the off-canvas context drawer,
|
||||
// whose open state the fold level does not drive.
|
||||
if (isPhoneViewport()) {
|
||||
this.contextDrawer.open();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
const overlayRef = this.overlayRef;
|
||||
const origin = this.origin;
|
||||
this.overlayRef = null;
|
||||
this.origin = null;
|
||||
origin?.setAttribute('aria-expanded', 'false');
|
||||
overlayRef?.dispose();
|
||||
// Focus returns to the trigger that opened the popover so keyboard
|
||||
// users continue where they acted instead of dropping to <body> —
|
||||
// unless that trigger sits in a rail that folded meanwhile; then the
|
||||
// layout's own handoff picks focus up.
|
||||
if (
|
||||
origin &&
|
||||
origin.isConnected &&
|
||||
overlayRef &&
|
||||
!origin.closest('[inert]')
|
||||
) {
|
||||
origin.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same breakpoint as the shell's phone drawer styles (≤640px). */
|
||||
function isPhoneViewport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(max-width: 640px)').matches
|
||||
);
|
||||
}
|
||||
+3
@@ -31,10 +31,12 @@
|
||||
@if (context(); as context) {
|
||||
@if (section(); as section) {
|
||||
<aside
|
||||
#routePanel
|
||||
tabindex="-1"
|
||||
cdkFocusInitial
|
||||
class="context-panel context-panel--route"
|
||||
[class.context-panel--collapsed]="isContextPanelCollapsed()"
|
||||
[attr.inert]="isContextPanelInert() ? '' : null"
|
||||
appResizable
|
||||
[minWidth]="240"
|
||||
[maxWidth]="560"
|
||||
@@ -42,6 +44,7 @@
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<app-workspace-context-panel
|
||||
#contextPanel
|
||||
[context]="context"
|
||||
[section]="section"
|
||||
/>
|
||||
|
||||
+5
-2
@@ -31,8 +31,11 @@
|
||||
.context-panel--collapsed {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
// !important: the `--route` / `--sources` variants set their padding
|
||||
// later in the cascade at equal specificity, and 22px of padding left a
|
||||
// visible empty strip beside the channels rail while "collapsed".
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
border-right-color: transparent;
|
||||
|
||||
.resize-handle {
|
||||
|
||||
+114
-6
@@ -1,13 +1,12 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Component, Directive, input, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
@@ -30,6 +29,7 @@ class MockResizableDirective {
|
||||
class MockWorkspaceContextPanelComponent {
|
||||
readonly context = input.required<unknown>();
|
||||
readonly section = input.required<string>();
|
||||
readonly focusTarget = jest.fn<HTMLElement | null, []>(() => null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -56,16 +56,30 @@ class MockWorkspaceSourcesFiltersPanelComponent {}
|
||||
describe('WorkspaceShellContextSidebarComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
|
||||
let liveSidebarService: LiveLayoutSidebarStateService;
|
||||
const xtreamSelectedCategoryId = signal<number | null>(1);
|
||||
const stalkerSelectedCategoryId = signal<string | null>('7');
|
||||
const drawerOpen = signal(false);
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem('live-sidebar-state:portal');
|
||||
xtreamSelectedCategoryId.set(1);
|
||||
stalkerSelectedCategoryId.set('7');
|
||||
drawerOpen.set(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellContextSidebarComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { close: jest.fn() },
|
||||
useValue: { close: jest.fn(), isOpen: drawerOpen },
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: { selectedCategoryId: xtreamSelectedCategoryId },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: { selectedCategoryId: stalkerSelectedCategoryId },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -181,6 +195,100 @@ describe('WorkspaceShellContextSidebarComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('folds the categories rail on the first level already (categories-hidden)', () => {
|
||||
setupLiveCategory('live');
|
||||
|
||||
liveSidebarService.setState('portal', 'categories-hidden');
|
||||
fixture.detectChanges();
|
||||
|
||||
const aside = fixture.nativeElement.querySelector(
|
||||
'aside.context-panel--route'
|
||||
);
|
||||
expect(aside.classList.contains('context-panel--collapsed')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the rail on the live root (no selected category) at categories-hidden, since no channels rail exists to host the way back', () => {
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
setupLiveCategory('live');
|
||||
|
||||
liveSidebarService.setState('portal', 'categories-hidden');
|
||||
fixture.detectChanges();
|
||||
|
||||
const aside = fixture.nativeElement.querySelector(
|
||||
'aside.context-panel--route'
|
||||
);
|
||||
expect(aside.classList.contains('context-panel--collapsed')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
// Player-only still folds it: the floating restore handle lives
|
||||
// in the content area and stays reachable there.
|
||||
liveSidebarService.setState('portal', 'collapsed');
|
||||
fixture.detectChanges();
|
||||
expect(aside.classList.contains('context-panel--collapsed')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the folded rail out of the focus order with inert, except inside the open phone drawer', () => {
|
||||
setupLiveCategory('live');
|
||||
const aside = fixture.nativeElement.querySelector(
|
||||
'aside.context-panel--route'
|
||||
);
|
||||
|
||||
liveSidebarService.setState('portal', 'categories-hidden');
|
||||
fixture.detectChanges();
|
||||
expect(aside.hasAttribute('inert')).toBe(true);
|
||||
|
||||
drawerOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(aside.hasAttribute('inert')).toBe(false);
|
||||
|
||||
drawerOpen.set(false);
|
||||
liveSidebarService.setState('portal', 'expanded');
|
||||
fixture.detectChanges();
|
||||
expect(aside.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('hands focus to the panel whenever the rail actually unfolds, including from player-only on the live root', async () => {
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
liveSidebarService.setState('portal', 'categories-hidden');
|
||||
setupLiveCategory('live');
|
||||
const panel = fixture.debugElement.query(
|
||||
By.directive(MockWorkspaceContextPanelComponent)
|
||||
).componentInstance as MockWorkspaceContextPanelComponent;
|
||||
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
// Root at categories-hidden: the rail is visible, no transition.
|
||||
liveSidebarService.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
liveSidebarService.expand('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
|
||||
// The panel offered no control (categories not loaded), so the
|
||||
// rail itself takes focus.
|
||||
expect(panel.focusTarget).toHaveBeenCalledTimes(1);
|
||||
expect(document.activeElement).toBe(
|
||||
fixture.nativeElement.querySelector('aside.context-panel--route')
|
||||
);
|
||||
|
||||
const control = document.createElement('button');
|
||||
fixture.nativeElement.appendChild(control);
|
||||
panel.focusTarget.mockReturnValue(control);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
liveSidebarService.collapse('portal');
|
||||
fixture.detectChanges();
|
||||
liveSidebarService.expand('portal');
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
|
||||
it('also collapses on the Stalker itv section', () => {
|
||||
setupLiveCategory('itv');
|
||||
|
||||
|
||||
+80
-1
@@ -2,16 +2,22 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
focusIfFocusLost,
|
||||
LiveLayoutSidebarStateService,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
WorkspaceShellContextDrawerService,
|
||||
WorkspacePortalContext,
|
||||
@@ -47,6 +53,8 @@ export class WorkspaceShellContextSidebarComponent {
|
||||
private readonly liveSidebarStateService = inject(
|
||||
LiveLayoutSidebarStateService
|
||||
);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
// Root-provided; optional keeps standalone unit tests light. Only relevant
|
||||
// when the sidebar renders as the phone drawer — the close button that
|
||||
// calls this is CSS-hidden above the phone breakpoint.
|
||||
@@ -67,11 +75,82 @@ export class WorkspaceShellContextSidebarComponent {
|
||||
LIVE_SECTIONS.has(section)
|
||||
);
|
||||
});
|
||||
/**
|
||||
* A live category is selected, so the layout renders a channels rail
|
||||
* whose header carries the way back to the folded categories rail (the
|
||||
* category dropdown + show chevron). On the live root there is no such
|
||||
* rail, and folding the categories there would leave no way back.
|
||||
*/
|
||||
readonly hasLiveCategorySelection = computed(() => {
|
||||
const provider = this.context()?.provider;
|
||||
if (provider === 'xtreams') {
|
||||
return this.xtreamStore.selectedCategoryId() !== null;
|
||||
}
|
||||
if (provider === 'stalker') {
|
||||
return !!this.stalkerStore.selectedCategoryId();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// The categories rail is the outermost panel, so it folds on the first
|
||||
// collapse level already (`categories-hidden`, gated on a channels rail
|
||||
// existing to host the way back), and always on `collapsed`.
|
||||
readonly isContextPanelCollapsed = computed(
|
||||
() =>
|
||||
this.isLiveCategoryRoute() &&
|
||||
this.liveSidebarStateService.isCollapsedFor('portal')()
|
||||
(this.liveSidebarStateService.isCollapsedFor('portal')() ||
|
||||
(this.liveSidebarStateService.areCategoriesHiddenFor(
|
||||
'portal'
|
||||
)() &&
|
||||
this.hasLiveCategorySelection()))
|
||||
);
|
||||
/**
|
||||
* A folded rail is only 0px wide; its search, sort and category buttons
|
||||
* would still take Tab stops and screen-reader focus. `inert` removes
|
||||
* them from both. Not while the panel renders as the open phone drawer,
|
||||
* whose stylesheet ignores the folded state.
|
||||
*/
|
||||
readonly isContextPanelInert = computed(
|
||||
() =>
|
||||
this.isContextPanelCollapsed() && !this.contextDrawer?.isOpen()
|
||||
);
|
||||
|
||||
// By template ref, not class token, so a spec's stand-in panel is found
|
||||
// the same way the real one is.
|
||||
private readonly contextPanel =
|
||||
viewChild<Pick<WorkspaceContextPanelComponent, 'focusTarget'>>(
|
||||
'contextPanel'
|
||||
);
|
||||
private readonly routePanel = viewChild('routePanel', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Unfolding the rail removes the control the user activated (the
|
||||
// layout's show-categories button or the floating restore handle),
|
||||
// so focus drops to <body>; the rail picks it up once rendered. This
|
||||
// watches the rail's ACTUAL fold state rather than the raw level:
|
||||
// on the live root the rail stays visible at `categories-hidden`,
|
||||
// and only `collapsed` ↔ visible is a real transition there. The
|
||||
// panel names its control; with none rendered (categories loading,
|
||||
// a failed load) the `tabindex="-1"` aside itself takes focus, so a
|
||||
// keyboard user always lands inside the rail.
|
||||
// Seeded on the first run: the required inputs are not readable in
|
||||
// the constructor yet.
|
||||
let wasCollapsed: boolean | null = null;
|
||||
effect(() => {
|
||||
const collapsed = this.isContextPanelCollapsed();
|
||||
const unfolded = wasCollapsed === true && !collapsed;
|
||||
wasCollapsed = collapsed;
|
||||
if (unfolded) {
|
||||
queueMicrotask(() =>
|
||||
focusIfFocusLost(
|
||||
this.contextPanel()?.focusTarget() ??
|
||||
this.routePanel()?.nativeElement
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeDrawer(): void {
|
||||
this.contextDrawer?.close();
|
||||
|
||||
@@ -28,7 +28,9 @@ import { WorkspaceShellRouteStateService } from './services/workspace-shell-rout
|
||||
import { WorkspaceShellSearchSyncService } from './services/workspace-shell-search-sync.service';
|
||||
import { WorkspaceShellSearchService } from './services/workspace-shell-search.service';
|
||||
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
|
||||
import { WorkspaceLiveCategoriesPopoverService } from '../workspace-live-categories-popover/workspace-live-categories-popover.service';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell',
|
||||
@@ -55,6 +57,12 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u
|
||||
WorkspaceShellXtreamImportService,
|
||||
WorkspaceShellCommandPaletteService,
|
||||
WorkspaceKeyboardShortcutsService,
|
||||
// The live layouts inside the router outlet open the folded categories
|
||||
// rail through this token; see `LiveCategoriesPopover`.
|
||||
{
|
||||
provide: LIVE_CATEGORIES_POPOVER,
|
||||
useClass: WorkspaceLiveCategoriesPopoverService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class WorkspaceShellComponent {
|
||||
|
||||
@@ -21,6 +21,18 @@ describe('WorkspaceShellContextDrawerService', () => {
|
||||
service = TestBed.inject(WorkspaceShellContextDrawerService);
|
||||
});
|
||||
|
||||
|
||||
it('opens on demand and stays open until closed', () => {
|
||||
const service = TestBed.inject(WorkspaceShellContextDrawerService);
|
||||
|
||||
service.open();
|
||||
expect(service.isOpen()).toBe(true);
|
||||
service.open();
|
||||
expect(service.isOpen()).toBe(true);
|
||||
service.close();
|
||||
expect(service.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('starts closed', () => {
|
||||
expect(service.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export class WorkspaceShellContextDrawerService {
|
||||
this.openState.update((open) => !open);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the drawer. Only meaningful at phone widths; on wider viewports
|
||||
* the panel is in flow and the styles ignore this state.
|
||||
*/
|
||||
open(): void {
|
||||
this.openState.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.openState.set(false);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user