+
+
-
+
+ @if (useNativeScrolling()) {
+
+ @for (file of files | async; track identify($index, file); let isEven = $even) {
+
+ }
+
+ } @else {
+
+
+
+
+
+ }
diff --git a/src/angular/src/app/pages/files/file-list.component.scss b/src/angular/src/app/pages/files/file-list.component.scss
index 7267c4a8..4c1379d6 100644
--- a/src/angular/src/app/pages/files/file-list.component.scss
+++ b/src/angular/src/app/pages/files/file-list.component.scss
@@ -16,6 +16,19 @@
height: calc(100vh - var(--file-list-chrome-height, 160px));
height: calc(100dvh - var(--file-list-chrome-height, 160px));
min-height: 200px;
+ overflow-anchor: none;
+ scroll-behavior: auto;
+}
+
+/* Mobile rows wrap and can expand to show details/actions, so they cannot use
+ CDK's fixed-size virtual scroll strategy. Undo the bounded viewport from
+ .file-viewport so rows flow at their natural size and the page itself
+ scrolls, instead of scrolling inside a fixed-height box. */
+.native-file-viewport {
+ height: auto;
+ min-height: 0;
+ overflow-y: visible;
+ overscroll-behavior-y: auto;
}
/* striped rows — use template-driven .even class instead of :nth-child
diff --git a/src/angular/src/app/pages/files/file-list.component.spec.ts b/src/angular/src/app/pages/files/file-list.component.spec.ts
index f13d9ba4..d2980d17 100644
--- a/src/angular/src/app/pages/files/file-list.component.spec.ts
+++ b/src/angular/src/app/pages/files/file-list.component.spec.ts
@@ -175,6 +175,74 @@ describe('FileListComponent', () => {
expect(fixture.nativeElement.querySelectorAll('app-file').length).toBe(3);
});
+ it('should follow the mobile media query and remove its listener on destroy', () => {
+ fixture.destroy();
+ const mediaQueryTarget = new EventTarget();
+ const addEventListener = vi.spyOn(mediaQueryTarget, 'addEventListener');
+ const removeEventListener = vi.spyOn(mediaQueryTarget, 'removeEventListener');
+ const mediaQueryList = Object.assign(mediaQueryTarget, {
+ matches: true,
+ media: '(max-width: 600px)',
+ onchange: null,
+ }) as unknown as MediaQueryList;
+ const matchMedia = vi.fn().mockReturnValue(mediaQueryList);
+ const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia');
+ Object.defineProperty(window, 'matchMedia', {
+ value: matchMedia,
+ configurable: true,
+ });
+
+ try {
+ fixture = TestBed.createComponent(FileListComponent);
+ component = fixture.componentInstance;
+
+ expect(matchMedia).toHaveBeenCalledWith('(max-width: 600px)');
+ expect(component.useNativeScrolling()).toBe(true);
+ expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+
+ const listener = addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
+ const changeEvent = new Event('change') as MediaQueryListEvent;
+ Object.defineProperty(changeEvent, 'matches', { value: false });
+ mediaQueryList.dispatchEvent(changeEvent);
+ expect(component.useNativeScrolling()).toBe(false);
+
+ fixture.destroy();
+ expect(removeEventListener).toHaveBeenCalledWith('change', listener);
+ } finally {
+ if (!fixture.componentRef.hostView.destroyed) fixture.destroy();
+ if (originalDescriptor) {
+ Object.defineProperty(window, 'matchMedia', originalDescriptor);
+ } else {
+ delete (window as unknown as { matchMedia?: typeof window.matchMedia }).matchMedia;
+ }
+ }
+ });
+
+ it('should render naturally-sized rows instead of fixed virtual rows on mobile', () => {
+ fixture.destroy();
+ fixture = TestBed.createComponent(FileListComponent);
+ component = fixture.componentInstance;
+ component.useNativeScrolling.set(true);
+ filteredFilesSubject.next(Array.from(
+ { length: 25 },
+ (_, index) => makeViewFile({ name: `file-${index}.mkv` }),
+ ));
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('cdk-virtual-scroll-viewport')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.native-file-viewport')).not.toBeNull();
+ expect(fixture.nativeElement.querySelectorAll('.file-row').length).toBe(25);
+ });
+
+ it('should retain virtual scrolling outside the mobile breakpoint', () => {
+ component.useNativeScrolling.set(false);
+ fixture.detectChanges();
+
+ const viewport = fixture.nativeElement.querySelector('cdk-virtual-scroll-viewport');
+ expect(viewport).not.toBeNull();
+ expect(viewport.getAttribute('itemsize')).toBe('82');
+ });
+
// --- Header ---
it('should render the header row with column labels', () => {
diff --git a/src/angular/src/app/pages/files/file-list.component.ts b/src/angular/src/app/pages/files/file-list.component.ts
index 6e38f588..1c6d91da 100644
--- a/src/angular/src/app/pages/files/file-list.component.ts
+++ b/src/angular/src/app/pages/files/file-list.component.ts
@@ -8,9 +8,10 @@ import {
OnDestroy,
ViewChild,
inject,
+ signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { AsyncPipe } from '@angular/common';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
import { Observable } from 'rxjs';
import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf } from '@angular/cdk/scrolling';
@@ -26,10 +27,12 @@ import { fileKey } from '../../services/files/file-key';
import { FileComponent, FileActionEvent } from './file.component';
import { BulkActionBarComponent } from './bulk-action-bar.component';
+const MOBILE_FILE_LIST_QUERY = '(max-width: 600px)';
+
@Component({
selector: 'app-file-list',
standalone: true,
- imports: [AsyncPipe, FileComponent, BulkActionBarComponent, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf],
+ imports: [AsyncPipe, NgTemplateOutlet, FileComponent, BulkActionBarComponent, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf],
templateUrl: './file-list.component.html',
styleUrls: ['./file-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -47,12 +50,23 @@ export class FileListComponent implements AfterViewInit, OnDestroy {
private resizeObserver: ResizeObserver | null = null;
private pendingFrame: number | null = null;
+ private mobileMediaQuery: MediaQueryList | null = null;
+
+ readonly useNativeScrolling = signal(false);
files: Observable