mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2
This commit is contained in:
commit
eef1e58142
33 files changed
+1595
-36
No files matched your search
+6
-2
@@ -3,12 +3,15 @@
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
<div class="context-header__actions">
|
||||
@if (canSearchCategories()) {
|
||||
<!-- Rendered disabled while the categories load, so the
|
||||
row has its final width before they arrive. -->
|
||||
@if (showCategoryListActions()) {
|
||||
<button
|
||||
#firstHeaderAction
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[disabled]="!canSearchCategories()"
|
||||
[class.context-header__action--active]="isSearchOpen()"
|
||||
(click)="toggleCategorySearch()"
|
||||
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
|
||||
@@ -19,11 +22,12 @@
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canSearchCategories()) {
|
||||
@if (showCategoryListActions()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[disabled]="!canSearchCategories()"
|
||||
[class.context-header__action--active]="
|
||||
categorySortMode() !== 'server'
|
||||
"
|
||||
|
||||
+29
@@ -240,6 +240,35 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the category search and sort actions disabled while the categories load', () => {
|
||||
// Present from the skeleton on, so the header row keeps its width
|
||||
// when the categories arrive instead of growing by two buttons.
|
||||
const loaded = xtreamCategories();
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamCategories.set([]);
|
||||
xtreamIsLoadingCategories.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const actions = () =>
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.context-header__action'
|
||||
)
|
||||
) as HTMLButtonElement[];
|
||||
expect(actions()).toHaveLength(3);
|
||||
expect(actions().every((button) => button.disabled)).toBe(true);
|
||||
expect(fixture.componentInstance.focusTarget()).toBeNull();
|
||||
|
||||
xtreamCategories.set(loaded);
|
||||
xtreamIsLoadingCategories.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(actions()).toHaveLength(3);
|
||||
expect(actions()[0].disabled).toBe(false);
|
||||
expect(actions()[1].disabled).toBe(false);
|
||||
expect(fixture.componentInstance.focusTarget()).toBe(actions()[0]);
|
||||
});
|
||||
|
||||
it('keeps local xtream loading states quiet when no import is running', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamIsImporting.set(false);
|
||||
|
||||
+14
-5
@@ -384,6 +384,13 @@ export class WorkspaceContextPanelComponent {
|
||||
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
|
||||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
|
||||
);
|
||||
/** Search and sort show while the list loads (disabled) or has items. */
|
||||
readonly showCategoryListActions = computed(
|
||||
() =>
|
||||
this.canSearchCategories() ||
|
||||
this.isXtreamCategoryLoading() ||
|
||||
(this.isStalkerCategories() && this.isStalkerCategoryLoading())
|
||||
);
|
||||
|
||||
readonly filteredXtreamCategories = computed(() => {
|
||||
this.languageTick();
|
||||
@@ -472,15 +479,17 @@ 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.
|
||||
* action. `null` while neither is usable — categories still loading
|
||||
* (the action is rendered but disabled), or a failed Stalker load — and
|
||||
* the sidebar falls back to the rail itself.
|
||||
*/
|
||||
focusTarget(): HTMLElement | null {
|
||||
const firstAction = this.firstHeaderAction()?.nativeElement;
|
||||
return (
|
||||
this.hideCategoriesButton()?.nativeElement ??
|
||||
this.firstHeaderAction()?.nativeElement ??
|
||||
null
|
||||
(firstAction && !firstAction.hasAttribute('disabled')
|
||||
? firstAction
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,4 +1,10 @@
|
||||
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
|
||||
<header
|
||||
class="workspace-header"
|
||||
[class.is-settings-route]="isSettingsRoute()"
|
||||
[appLayoutSlide]="leadingNavigationKey()"
|
||||
[layoutSlideRecordOn]="controlsLayoutKey()"
|
||||
layoutSlideTargets=".context-drawer-toggle, .playlist-switcher, .search-field, .header-actions"
|
||||
>
|
||||
<!-- The page's Back, or browser history while an in-app previous page
|
||||
exists, at the leading edge where desktop apps and Material's top
|
||||
app bar keep navigation (beside the macOS traffic lights). The
|
||||
|
||||
+13
@@ -32,6 +32,19 @@
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: -8px;
|
||||
app-region: no-drag;
|
||||
// Fades in while the controls after it slide aside (LayoutSlideDirective)
|
||||
// instead of showing under the switcher that is still moving away.
|
||||
animation: header-back-enter 180ms ease;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-back-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Above the phone breakpoint the context panel is always visible beside the
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import {
|
||||
WorkspaceBackTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
|
||||
import { LayoutSlideDirective } from '../../layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
@@ -67,6 +68,7 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
.overrideComponent(WorkspaceShellHeaderComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
LayoutSlideDirective,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
|
||||
+15
@@ -18,10 +18,12 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants';
|
||||
import { LayoutSlideDirective } from '../../layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-header',
|
||||
imports: [
|
||||
LayoutSlideDirective,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
@@ -119,6 +121,19 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly backYieldsToDrawerToggle = computed(
|
||||
() => this.showDrawerToggle() && this.backPhoneSlot() === 'yield'
|
||||
);
|
||||
/**
|
||||
* The leading navigation: when Back or the drawer toggle appears or
|
||||
* leaves, the controls after it slide instead of jumping.
|
||||
*/
|
||||
readonly leadingNavigationKey = computed(
|
||||
() =>
|
||||
`${this.backTarget() !== null}|${this.showDrawerToggle()}|${this.backYieldsToDrawerToggle()}`
|
||||
);
|
||||
/** Everything else that moves the controls; recorded, not slid. */
|
||||
readonly controlsLayoutKey = computed(
|
||||
() =>
|
||||
`${this.isSettingsRoute()}|${this.hasNoPlaylists()}|${this.isElectron()}|${this.parentalLockState()}|${this.headerSidebarToggle() !== null}|${this.headerShortcut() !== null}`
|
||||
);
|
||||
|
||||
readonly backRequested = output<void>();
|
||||
readonly searchChanged = output<string>();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
LAYOUT_SLIDE_ACTIVE_CLASS,
|
||||
LAYOUT_SLIDE_DURATION_MS,
|
||||
LayoutSlideDirective,
|
||||
translateX,
|
||||
} from './layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
imports: [LayoutSlideDirective],
|
||||
template: `
|
||||
<div
|
||||
[appLayoutSlide]="slideKey()"
|
||||
[layoutSlideRecordOn]="recordKey()"
|
||||
layoutSlideTargets=".target"
|
||||
>
|
||||
<span class="target"></span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly slideKey = signal('panel');
|
||||
readonly recordKey = signal(0);
|
||||
}
|
||||
|
||||
interface FakeAnimation {
|
||||
readonly keyframes: Keyframe[];
|
||||
readonly options: KeyframeAnimationOptions;
|
||||
cancelled: boolean;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
describe('LayoutSlideDirective', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let target: HTMLElement;
|
||||
let left: number;
|
||||
let animations: FakeAnimation[];
|
||||
let reducedMotion: boolean;
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
left = 60;
|
||||
animations = [];
|
||||
reducedMotion = false;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: reducedMotion && query.includes('reduce'),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
target = fixture.nativeElement.querySelector('.target');
|
||||
Object.defineProperty(target, 'offsetLeft', {
|
||||
configurable: true,
|
||||
get: () => left,
|
||||
});
|
||||
target.animate = ((
|
||||
keyframes: Keyframe[],
|
||||
options: KeyframeAnimationOptions
|
||||
) => {
|
||||
const listeners: (() => void)[] = [];
|
||||
const animation = {
|
||||
keyframes,
|
||||
options,
|
||||
cancelled: false,
|
||||
addEventListener: (_type: string, listener: () => void) =>
|
||||
listeners.push(listener),
|
||||
finish: () => listeners.forEach((listener) => listener()),
|
||||
cancel: () => {
|
||||
animation.cancelled = true;
|
||||
animation.finish();
|
||||
},
|
||||
};
|
||||
animations.push(animation);
|
||||
return animation;
|
||||
}) as unknown as HTMLElement['animate'];
|
||||
render();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
fixture.detectChanges();
|
||||
TestBed.tick();
|
||||
}
|
||||
|
||||
it('does not animate the first render', () => {
|
||||
expect(animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('slides a target from its previous position when the key changes', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
|
||||
expect(animations).toHaveLength(1);
|
||||
expect(animations[0].keyframes).toEqual([
|
||||
{ transform: 'translateX(-323px)' },
|
||||
{ transform: 'none' },
|
||||
]);
|
||||
expect(animations[0].options.duration).toBe(LAYOUT_SLIDE_DURATION_MS);
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
|
||||
'0px'
|
||||
);
|
||||
expect(target.style.getPropertyValue('--layout-slide-end')).toBe(
|
||||
'323px'
|
||||
);
|
||||
|
||||
animations[0].finish();
|
||||
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-end')).toBe('');
|
||||
});
|
||||
|
||||
it('records moves of other changes instead of sliding them', () => {
|
||||
left = 120;
|
||||
fixture.componentInstance.recordKey.set(1);
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
|
||||
left = 80;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations[0].keyframes[0]).toEqual({
|
||||
transform: 'translateX(40px)',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a finished slide from clearing a newer one', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
left = 60;
|
||||
fixture.componentInstance.slideKey.set('panel');
|
||||
render();
|
||||
|
||||
animations[0].finish();
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
|
||||
'323px'
|
||||
);
|
||||
});
|
||||
|
||||
it('starts an interrupted slide from where the target is drawn', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
// 40 ms in, the first slide draws the target at 172 px.
|
||||
target.style.transform = 'matrix(1, 0, 0, 1, -211, 0)';
|
||||
left = 60;
|
||||
fixture.componentInstance.slideKey.set('panel');
|
||||
render();
|
||||
|
||||
expect(animations).toHaveLength(2);
|
||||
expect(animations[0].cancelled).toBe(true);
|
||||
expect(animations[1].keyframes[0]).toEqual({
|
||||
transform: 'translateX(112px)',
|
||||
});
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
});
|
||||
|
||||
it('reads the x translation of computed transforms', () => {
|
||||
expect(translateX('none')).toBe(0);
|
||||
expect(translateX('matrix(1, 0, 0, 1, -211.5, 0)')).toBe(-211.5);
|
||||
expect(
|
||||
translateX(
|
||||
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 44, 0, 0, 1)'
|
||||
)
|
||||
).toBe(44);
|
||||
});
|
||||
|
||||
it('does not animate when the target did not move', () => {
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('jumps under prefers-reduced-motion', () => {
|
||||
reducedMotion = true;
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
afterRenderEffect,
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
/** Matches the context panel's own width transition. */
|
||||
export const LAYOUT_SLIDE_DURATION_MS = 180;
|
||||
|
||||
export const LAYOUT_SLIDE_ACTIVE_CLASS = 'layout-slide-active';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/**
|
||||
* Slides shell chrome from where it was to where a layout change put it
|
||||
* (FLIP) instead of letting it jump in one frame. The new layout applies at
|
||||
* once: each target only starts translated back to its previous position
|
||||
* and animates its transform to none, so nothing waits for the animation,
|
||||
* and Chromium records no layout shift for a transform.
|
||||
*
|
||||
* A change of `appLayoutSlide` slides the targets (`layoutSlideTargets`,
|
||||
* selectors inside the host; the host itself when empty). A change of
|
||||
* `layoutSlideRecordOn`, and a resize of the host or a target, only records
|
||||
* where the targets are, so the next slide starts from where they are
|
||||
* really drawn. Positions are `offsetLeft` values, which ignore transforms.
|
||||
* Under `prefers-reduced-motion` the change stays an instant jump.
|
||||
* Contract: docs/architecture/workspace-shell.md ("Layout slides").
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appLayoutSlide]',
|
||||
})
|
||||
export class LayoutSlideDirective {
|
||||
readonly appLayoutSlide = input.required<unknown>();
|
||||
readonly layoutSlideRecordOn = input<unknown>(null);
|
||||
readonly layoutSlideTargets = input('');
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly positions = new WeakMap<HTMLElement, number>();
|
||||
private readonly running = new WeakMap<HTMLElement, Animation>();
|
||||
|
||||
constructor() {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let observed: readonly HTMLElement[] = [];
|
||||
let previousKey: unknown;
|
||||
let initialized = false;
|
||||
afterRenderEffect({
|
||||
read: () => {
|
||||
const key = this.appLayoutSlide();
|
||||
this.layoutSlideRecordOn();
|
||||
const targets = this.targets();
|
||||
if (initialized && key !== previousKey) {
|
||||
this.slide(targets);
|
||||
} else {
|
||||
this.record(targets);
|
||||
}
|
||||
previousKey = key;
|
||||
initialized = true;
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
resizeObserver ??= new ResizeObserver(() =>
|
||||
this.record(this.targets())
|
||||
);
|
||||
const next = [this.host.nativeElement, ...targets];
|
||||
if (!sameElements(observed, next)) {
|
||||
resizeObserver.disconnect();
|
||||
next.forEach((element) => resizeObserver?.observe(element));
|
||||
observed = next;
|
||||
}
|
||||
},
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => resizeObserver?.disconnect());
|
||||
}
|
||||
|
||||
private targets(): HTMLElement[] {
|
||||
const host = this.host.nativeElement;
|
||||
const selector = this.layoutSlideTargets();
|
||||
return selector
|
||||
? Array.from(host.querySelectorAll<HTMLElement>(selector))
|
||||
: [host];
|
||||
}
|
||||
|
||||
private record(targets: readonly HTMLElement[]): void {
|
||||
for (const target of targets) {
|
||||
this.positions.set(target, target.offsetLeft);
|
||||
}
|
||||
}
|
||||
|
||||
private slide(targets: readonly HTMLElement[]): void {
|
||||
// Every position is read before the first animation starts, so the
|
||||
// layout is computed once.
|
||||
const moves: [HTMLElement, number][] = [];
|
||||
for (const target of targets) {
|
||||
const previous = this.positions.get(target);
|
||||
const next = target.offsetLeft;
|
||||
this.positions.set(target, next);
|
||||
// A slide still running is interrupted: the new one starts where
|
||||
// the target is drawn now, not where the old one was heading.
|
||||
const drawn = this.running.has(target)
|
||||
? translateX(getComputedStyle(target).transform)
|
||||
: 0;
|
||||
if (previous !== undefined && previous + drawn !== next) {
|
||||
moves.push([target, previous + drawn - next]);
|
||||
}
|
||||
}
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
for (const [target, offset] of moves) {
|
||||
if (typeof target.animate === 'function') {
|
||||
this.animate(target, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While it runs the target carries `layout-slide-active`, and the width
|
||||
* it uncovers on either side as `--layout-slide-start` /
|
||||
* `--layout-slide-end`, so its stylesheet can fill that strip.
|
||||
*/
|
||||
private animate(target: HTMLElement, offset: number): void {
|
||||
const superseded = this.running.get(target);
|
||||
const animation = target.animate(
|
||||
[{ transform: `translateX(${offset}px)` }, { transform: 'none' }],
|
||||
{ duration: LAYOUT_SLIDE_DURATION_MS, easing: 'ease' }
|
||||
);
|
||||
this.running.set(target, animation);
|
||||
superseded?.cancel();
|
||||
target.classList.add(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
target.style.setProperty(
|
||||
'--layout-slide-start',
|
||||
`${Math.max(offset, 0)}px`
|
||||
);
|
||||
target.style.setProperty(
|
||||
'--layout-slide-end',
|
||||
`${Math.max(-offset, 0)}px`
|
||||
);
|
||||
const settle = () => {
|
||||
if (this.running.get(target) !== animation) {
|
||||
return;
|
||||
}
|
||||
this.running.delete(target);
|
||||
target.classList.remove(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
target.style.removeProperty('--layout-slide-start');
|
||||
target.style.removeProperty('--layout-slide-end');
|
||||
};
|
||||
animation.addEventListener('finish', settle);
|
||||
animation.addEventListener('cancel', settle);
|
||||
}
|
||||
}
|
||||
|
||||
function sameElements(
|
||||
a: readonly HTMLElement[],
|
||||
b: readonly HTMLElement[]
|
||||
): boolean {
|
||||
return a.length === b.length && a.every((element, i) => element === b[i]);
|
||||
}
|
||||
|
||||
/** The x translation of a computed `transform` value (0 for `none`). */
|
||||
export function translateX(transform: string): number {
|
||||
const values = /^matrix(3d)?\((.+)\)$/.exec(transform.trim());
|
||||
if (!values) {
|
||||
return 0;
|
||||
}
|
||||
const parts = values[2].split(',').map(Number);
|
||||
const x = values[1] ? parts[12] : parts[4];
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof matchMedia === 'function' &&
|
||||
matchMedia(REDUCED_MOTION_QUERY).matches
|
||||
);
|
||||
}
|
||||
@@ -71,9 +71,13 @@
|
||||
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
|
||||
/>
|
||||
|
||||
<!-- The content frame slides to make room for the context panel
|
||||
instead of jumping (see LayoutSlideDirective). -->
|
||||
<div
|
||||
class="workspace-body"
|
||||
[class.single-pane]="!facade.showContextPanel()"
|
||||
[appLayoutSlide]="facade.showContextPanel()"
|
||||
layoutSlideTargets=".workspace-content-frame"
|
||||
>
|
||||
@if (facade.showContextPanel()) {
|
||||
<!-- Phone-only (CSS-hidden above 640px): tapping the scrim is
|
||||
|
||||
@@ -114,6 +114,20 @@ app-workspace-shell-context-sidebar {
|
||||
clip-path: inset(0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// While the frame slides into place beside a context panel that appeared
|
||||
// or left (LayoutSlideDirective), its old width is wider than the new
|
||||
// one, so the side it moves away from would show the body colour. The
|
||||
// content surface extends over that strip until the slide ends.
|
||||
&.layout-slide-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 calc(-1 * var(--layout-slide-end, 0px)) 0
|
||||
calc(-1 * var(--layout-slide-start, 0px));
|
||||
z-index: -1;
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellContextPanel,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { LayoutSlideDirective } from './layout-slide.directive';
|
||||
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
|
||||
import { WorkspaceShellComponent } from './workspace-shell.component';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
@@ -265,6 +266,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -361,6 +363,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -439,6 +442,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -513,6 +517,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -613,6 +618,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh
|
||||
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
|
||||
import { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component';
|
||||
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
|
||||
import { LayoutSlideDirective } from './layout-slide.directive';
|
||||
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
|
||||
@@ -39,6 +40,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
|
||||
imports: [
|
||||
CdkTrapFocus,
|
||||
ExternalPlaybackDockComponent,
|
||||
LayoutSlideDirective,
|
||||
TranslatePipe,
|
||||
PlaylistDropOverlayComponent,
|
||||
PlaylistDropZoneDirective,
|
||||
|
||||
Reference in new issue
Block a user