mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(workspace): slide the content and header into place when a source opens
Opening an Xtream portal from the dashboard inserted the 323 px context panel and the header Back in the frame that swaps the page: the content pane jumped right by the panel's width and the switcher, search and actions by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222 for the content pane, 0.011 for the header, scored with the pane's distance), plus 0.0001 when the category search and sort buttons appeared with the categories. LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout applies at once and only a transform animates, so nothing waits and no layout shift is recorded. The content surface covers the strip it uncovers while sliding, Back fades in, and reduced motion keeps the jump. The category search and sort buttons render disabled while the list loads. Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3 0.001, J4 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
633fde3e0e
commit
d2cb9445da
16 files changed
+572
-15
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Opening a source from the dashboard no longer makes the page jump: the content
|
||||
slides aside for the category panel, and the header controls slide over for
|
||||
the Back button. The category list's search and sort buttons are in place
|
||||
while the list loads.
|
||||
@@ -1279,9 +1279,41 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to
|
||||
|
||||
`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233
|
||||
in every iteration of every `master` run from 84aef83a6 (#1814, page Back
|
||||
buttons moved into the header) on, so its value is 0.233 with `evidenceRun`
|
||||
37372780064 (b78224376). The 0.011 that #1814 added is not explained yet;
|
||||
lowering it back is a separate change.
|
||||
buttons moved into the header) on, so its value was 0.233 with `evidenceRun`
|
||||
37372780064 (b78224376).
|
||||
|
||||
Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window;
|
||||
the score of a shift is the area of every node that moved times the largest
|
||||
distance any of them moved, as a fraction of the viewport):
|
||||
|
||||
| Shift, ms after the click | Nodes that moved | Score |
|
||||
| ------------------------- | ---------------------------------------------------------- | ------ |
|
||||
| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 |
|
||||
| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 |
|
||||
| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 |
|
||||
|
||||
The first two are one frame: the frame that replaces the dashboard with the
|
||||
portal page also inserts the 323 px context panel the dashboard has no room
|
||||
for, and the history Back that #1814 shows once an in-app previous entry
|
||||
exists. The header items move only 44 px, but a shift is scored with the
|
||||
largest distance in it, the content pane's 323 px, which is why Back added
|
||||
0.011 rather than about 0.0015. The third is the category search and sort
|
||||
buttons appearing when the categories arrive. The skeletons of the category
|
||||
list and the grid are replaced by content without any shift. Nothing was
|
||||
hidden behind the startup overlay: the "Syncing playlist" overlay of the
|
||||
cached catalog load flashes for about two frames after the shift and covers
|
||||
no further one.
|
||||
|
||||
The content frame and the header controls now slide into place instead of
|
||||
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
|
||||
applies in the same frame and only a transform animates, which Chromium does
|
||||
not record as a layout shift. The category search and sort buttons render
|
||||
disabled while the categories load. Locally (macOS, 2026-10-11) J2 then read
|
||||
0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001 and J4 0;
|
||||
`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style
|
||||
changes) and the wall-clock entries did not move. Under
|
||||
`prefers-reduced-motion` there is no slide, so a user with that preference
|
||||
still sees the jump that the journey no longer measures.
|
||||
|
||||
Being deterministic is not the same as being validated. Principle 3 of the
|
||||
plan promotes a counter to a guardrail once a PR has shown that lowering it
|
||||
|
||||
@@ -127,6 +127,49 @@ synthesized mask, unlike any content layer) may span half the content area,
|
||||
and the backdrop-filtered controls must really be composited, so the check
|
||||
cannot pass on a page with nothing to clip.
|
||||
|
||||
### Layout slides
|
||||
|
||||
When a route change adds or removes the context panel, the content frame
|
||||
moves by the panel's width (323 px on desktop), and when Back or the phone
|
||||
drawer toggle appears or leaves, the header controls after it move by the
|
||||
slot's width (44 px). Both happen in the frame that swaps the page, so they
|
||||
used to be one jump: a layout shift of 0.233 in the open-source journey,
|
||||
0.222 of it the content frame (see
|
||||
[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)).
|
||||
|
||||
`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns
|
||||
these jumps into a 180 ms slide, the duration of the context panel's own
|
||||
width transition. The new layout applies at once; each target starts
|
||||
translated back to the position it had and animates its transform to none
|
||||
(FLIP), so nothing waits for the animation and Chromium records no layout
|
||||
shift for a transform. The shell's `.workspace-body` slides
|
||||
`.workspace-content-frame` when `showContextPanel` changes; the header slides
|
||||
the drawer toggle, switcher, search and action groups when its leading
|
||||
navigation (Back, drawer toggle) changes.
|
||||
|
||||
1. **Previous positions.** `offsetLeft` values, which ignore transforms, are
|
||||
recorded after every render in which the slide key or the record key
|
||||
changed (the header's record key covers the other inputs that move its
|
||||
controls: settings route, no playlists, parental lock, shortcuts) and
|
||||
when the host or a target resizes (window resize, a context panel folding
|
||||
or being dragged wider). A slide therefore starts where the target was
|
||||
last laid out, and a target that has no recorded position or did not move
|
||||
is left alone.
|
||||
2. **The uncovered strip.** The frame is narrower beside the panel than
|
||||
without it, so the side it moves away from would show the body colour
|
||||
during the slide. While a slide runs the target carries
|
||||
`layout-slide-active` and the uncovered widths as `--layout-slide-start`
|
||||
and `--layout-slide-end`; the frame's stylesheet extends the content
|
||||
surface over that strip with an `::after` below the scroller.
|
||||
3. **Back fades in** over the same 180 ms, so the switcher still sliding away
|
||||
from its slot does not draw over the arrow.
|
||||
4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation:
|
||||
the jump (and its layout shift) remains.
|
||||
5. **Not covered.** A slide does not change what the route renders. Going
|
||||
back from a portal to the dashboard still shows one frame with the new
|
||||
dashboard below the old page before the old page leaves; that jump is a
|
||||
routing issue, not a layout slide.
|
||||
|
||||
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
|
||||
kept as a thin template-facing API and delegates ownership to component-scoped
|
||||
services:
|
||||
@@ -215,9 +258,10 @@ return logic (`backClicked`).
|
||||
|
||||
When there is nowhere to go, the slot is empty rather than a disabled arrow.
|
||||
Sessions often start on a page that never navigates (an M3U playlist or live
|
||||
TV), where a disabled arrow would stay for the whole session. The cost is one
|
||||
shift of the switcher and search when Back first appears or leaves, which
|
||||
happens only at the start of the history and together with a route change.
|
||||
TV), where a disabled arrow would stay for the whole session. When Back first
|
||||
appears or leaves, which happens only at the start of the history and together
|
||||
with a route change, the switcher and search slide by the slot's width instead
|
||||
of jumping (see [Layout slides](#layout-slides)).
|
||||
There is no Forward button: Stalker inline details are store state, not
|
||||
history entries, so Forward would skip them.
|
||||
|
||||
@@ -263,7 +307,10 @@ Xtream and Stalker category panels preserve provider/server category order by
|
||||
default. The panel header exposes a sort menu next to category search with
|
||||
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
|
||||
synthetic "all categories" entries stay pinned before sorted provider
|
||||
categories.
|
||||
categories. Search and sort render disabled while the categories load (beside
|
||||
the disabled Manage categories button), so the header row has its final width
|
||||
under the skeleton instead of growing by two buttons when the list arrives;
|
||||
they are hidden only once a load ended with no 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
|
||||
|
||||
+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