Virtualize collector disclosure transitions

Render only the visible collector package window, pin a bounded transition cohort and the focused package, and preserve smooth reversible 300 ms disclosure animations under reduced-motion Windows sessions when app animations are enabled. Add logical Tab navigation across unmounted controls plus complete ARIA row counts and indices, while removing collapsing file controls from focus and accessibility order immediately.
This commit is contained in:
Sucukdeluxe
2026-08-26 14:56:40 +02:00
parent aea0839b08
commit 3ceadd55b1
5 changed files with 379 additions and 19 deletions
+4 -3
View File
@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { forwardRef, type HTMLAttributes, type ReactElement, type ReactNode } from "react";
export interface DataTableProps extends HTMLAttributes<HTMLDivElement> {
label?: string;
@@ -44,18 +44,19 @@ export function DataTableHeader({ className, children, ...props }: DataTableSect
);
}
export function DataTableBody({ className, children, ...props }: DataTableSectionProps): ReactElement {
export const DataTableBody = forwardRef<HTMLDivElement, DataTableSectionProps>(function DataTableBody({ className, children, ...props }, ref): ReactElement {
return (
<div
{...props}
className={["ui-data-table-body", className].filter(Boolean).join(" ")}
data-ui-region="table-body"
ref={ref}
role="rowgroup"
>
{children}
</div>
);
}
});
export function DataTableEmpty({
title,
+203 -12
View File
@@ -1,9 +1,11 @@
import { memo, useEffect, useState, type ChangeEvent, type CSSProperties, type ReactElement } from "react";
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactElement } from "react";
import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import { SlidingSelection } from "../../ui/SlidingSelection";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT } from "../downloads/download-virtualizer";
import { getCollectorDisclosurePinnedIds, getCollectorDisclosureViewportIds, getCollectorFocusPackageId, mergeCollectorPinnedIds, resolveCollectorTransitionPins } from "./collector-disclosure";
import type {
CollectorWorkspaceFilter,
CollectorWorkspacePackageRow,
@@ -11,6 +13,8 @@ import type {
} from "./collector-model";
import "./collector.css";
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export interface CollectorViewActions {
onFilterChange: (filter: CollectorWorkspaceFilter) => void;
onOpenInput: () => void;
@@ -85,6 +89,71 @@ export function collectorPackageIntrinsicBlockSize(row: CollectorWorkspacePackag
return 46 + (row.collapsed ? 0 : row.links.length * 40);
}
export function collectorFileInteractionAttributes(
rowIndexStart: number,
focusIndexStart: number,
linkIndex: number,
collapsed: boolean
): { rowIndex: number | undefined; focusIndex: number | undefined; tabIndex: number | undefined } {
return collapsed
? { rowIndex: undefined, focusIndex: undefined, tabIndex: -1 }
: { rowIndex: rowIndexStart + linkIndex + 1, focusIndex: focusIndexStart + linkIndex + 2, tabIndex: undefined };
}
interface CollectorViewportState {
scrollTop: number;
viewportHeight: number;
}
interface CollectorVirtualPackage {
focusCount: number;
focusIndexStart: number;
id: string;
height: number;
rowIndex: number;
row: CollectorWorkspacePackageRow;
}
function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): CollectorViewportState {
const [viewport, setViewport] = useState<CollectorViewportState>({
scrollTop: 0,
viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
});
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
let frame = 0;
const measure = (): void => {
frame = 0;
const next = {
scrollTop: body.scrollTop,
viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
};
setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next);
};
const schedule = (): void => {
if (frame !== 0) return;
frame = window.requestAnimationFrame(measure);
};
measure();
body.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
return () => {
body.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
if (frame !== 0) window.cancelAnimationFrame(frame);
};
}, [bodyRef]);
return viewport;
}
function collectorVirtualPackageStyle(top: number, height: number): CSSProperties {
return {
"--collector-virtual-package-top": `${top}px`,
"--collector-virtual-package-height": `${height}px`
} as CSSProperties;
}
export function toggleAllCollectorPackageIds(
packageIds: readonly string[],
collapsedPackageIds: ReadonlySet<string>
@@ -160,25 +229,39 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
);
}
function CollectorPackageGroup({ row, model, actions, selected }: {
function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: {
focusIndexStart: number;
row: CollectorWorkspacePackageRow;
model: CollectorWorkspaceViewModel;
actions: CollectorViewActions;
selected: ReadonlySet<string>;
rowIndexStart: number;
}): ReactElement {
const allSelected = row.selectedCount === row.totalCount;
const partiallySelected = row.selectedCount > 0 && !allSelected;
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
const [renderItems, setRenderItems] = useState(!row.collapsed);
const [expanding, setExpanding] = useState(false);
const previousCollapsedRef = useRef(row.collapsed);
const packageStyle: CSSProperties = {
containIntrinsicBlockSize: `auto ${collectorPackageIntrinsicBlockSize(row)}px`,
flex: "0 0 auto"
};
useEffect(() => {
const changed = previousCollapsedRef.current !== row.collapsed;
previousCollapsedRef.current = row.collapsed;
if (!changed) return;
if (!row.collapsed) {
setRenderItems(true);
return;
if (!animateItems) {
setExpanding(false);
return;
}
setExpanding(true);
const timer = window.setTimeout(() => setExpanding(false), 300);
return () => window.clearTimeout(timer);
}
setExpanding(false);
if (!animateItems) {
setRenderItems(false);
return;
@@ -186,14 +269,16 @@ function CollectorPackageGroup({ row, model, actions, selected }: {
const timer = window.setTimeout(() => setRenderItems(false), 300);
return () => window.clearTimeout(timer);
}, [animateItems, row.collapsed]);
const animateDisclosure = animateItems && (row.collapsed || expanding);
return (
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup" style={packageStyle}>
<div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
<div aria-rowindex={rowIndexStart} className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
<span className="collector-column-select" role="cell">
<input
aria-checked={partiallySelected ? "mixed" : allSelected}
aria-label={`Paket ${row.name} auswählen`}
checked={allSelected}
data-collector-focus-index={focusIndexStart}
onChange={(event) => actions.onPackageSelectionChange(row.id, event.target.checked)}
ref={(node) => { if (node) node.indeterminate = partiallySelected; }}
type="checkbox"
@@ -204,6 +289,7 @@ function CollectorPackageGroup({ row, model, actions, selected }: {
aria-expanded={!row.collapsed}
aria-label={row.collapsed ? `${row.name} ausklappen` : `${row.name} einklappen`}
className="collector-collapse-button"
data-collector-focus-index={focusIndexStart + 1}
onClick={() => actions.onPackageCollapseChange(row.id)}
type="button"
>{row.collapsed ? "+" : ""}</button>
@@ -219,14 +305,15 @@ function CollectorPackageGroup({ row, model, actions, selected }: {
<span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
</div>
{renderItems ? (
<div className={`collector-package-items-frame${row.collapsed ? " is-collapsed" : ""}${animateItems ? " is-animated" : ""}`}>
<div aria-hidden={row.collapsed ? true : undefined} className={`collector-package-items-frame${row.collapsed ? " is-collapsed" : ""}${animateDisclosure ? " is-animated" : ""}${expanding ? " is-expanding" : ""}`}>
<div className="collector-package-items">
{row.links.map((link) => {
{row.links.map((link, linkIndex) => {
const hoster = formatHosterLabel(link.hoster);
const interaction = collectorFileInteractionAttributes(rowIndexStart, focusIndexStart, linkIndex, row.collapsed);
return (
<div className={`collector-file-row${selected.has(link.id) ? " is-selected" : ""}`} key={link.id} role="row">
<div aria-rowindex={interaction.rowIndex} className={`collector-file-row${selected.has(link.id) ? " is-selected" : ""}`} key={link.id} role="row">
<span className="collector-column-select" role="cell">
<input aria-label={`${link.fileName} auswählen`} checked={selected.has(link.id)} onChange={(event) => actions.onLinkSelectionChange(link.id, event.target.checked)} type="checkbox" />
<input aria-label={`${link.fileName} auswählen`} checked={selected.has(link.id)} data-collector-focus-index={interaction.focusIndex} onChange={(event) => actions.onLinkSelectionChange(link.id, event.target.checked)} tabIndex={interaction.tabIndex} type="checkbox" />
</span>
<span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span>
<span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span>
@@ -246,11 +333,80 @@ function CollectorPackageGroup({ row, model, actions, selected }: {
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
const selected = new Set(model.selectedIds);
const bodyRef = useRef<HTMLDivElement>(null);
const viewport = useCollectorViewport(bodyRef);
const virtualPackages = useMemo<CollectorVirtualPackage[]>(() => {
let focusIndex = 0;
let rowIndex = 2;
return model.packages.map((row) => {
const focusCount = 2 + (row.collapsed ? 0 : row.links.length);
const entry = {
focusCount,
focusIndexStart: focusIndex,
id: row.id,
height: collectorPackageIntrinsicBlockSize(row),
rowIndex,
row
};
focusIndex += focusCount;
rowIndex += 1 + (row.collapsed ? 0 : row.links.length);
return entry;
});
}, [model.packages]);
const logicalRowCount = 1 + virtualPackages.reduce((count, entry) => count + 1 + (entry.row.collapsed ? 0 : entry.row.links.length), 0);
const logicalFocusCount = virtualPackages.reduce((count, entry) => count + entry.focusCount, 0);
const previousCollapsedRef = useRef(new Map(model.packages.map((row) => [row.id, row.collapsed])));
const previousVisibleIdsRef = useRef<string[]>([]);
const transitionTimerRef = useRef(0);
const [transitionPinnedIds, setTransitionPinnedIds] = useState<string[]>([]);
const [focusedPackageId, setFocusedPackageId] = useState<string | null>(null);
const [pendingFocusIndex, setPendingFocusIndex] = useState<number | null>(null);
const freshPinnedIds = getCollectorDisclosurePinnedIds(
previousVisibleIdsRef.current,
previousCollapsedRef.current,
model.packages,
model.animationsEnabled
);
const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId);
const stateOffset = (model.analyzing ? 38 : 0) + (model.error ? 38 : 0);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, {
scrollTop: Math.max(0, viewport.scrollTop - stateOffset),
viewportHeight: viewport.viewportHeight,
overscan: 2,
pinnedIds: effectivePinnedIds
}), [effectivePinnedIds.join("\u0000"), stateOffset, viewport.scrollTop, viewport.viewportHeight, virtualPackages]);
const collapsedById = new Map(model.packages.map((row) => [row.id, row.collapsed]));
const freshPinnedKey = freshPinnedIds.map((id) => `${id}:${collapsedById.get(id) ? 1 : 0}`).join("\u0000");
useRendererLayoutEffect(() => {
if (freshPinnedIds.length === 0) return;
setTransitionPinnedIds((current) => resolveCollectorTransitionPins(current, freshPinnedIds));
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = window.setTimeout(() => {
transitionTimerRef.current = 0;
setTransitionPinnedIds([]);
}, 320);
}, [freshPinnedKey]);
useRendererLayoutEffect(() => {
previousCollapsedRef.current = new Map(model.packages.map((row) => [row.id, row.collapsed]));
previousVisibleIdsRef.current = getCollectorDisclosureViewportIds(virtualWindow.rows, virtualWindow.startIndex, virtualWindow.endIndex);
}, [model.packages, virtualWindow.rows]);
useRendererLayoutEffect(() => {
if (pendingFocusIndex === null) return;
const target = bodyRef.current?.querySelector<HTMLElement>(`[data-collector-focus-index="${pendingFocusIndex}"]`);
if (!target) return;
target.focus();
target.scrollIntoView({ block: "nearest" });
setPendingFocusIndex(null);
}, [pendingFocusIndex, virtualWindow.rows]);
useEffect(() => () => {
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
}, []);
const spacerStyle = { "--collector-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties;
return (
<section className="collector-content" aria-label="Gesammelte Downloadpakete">
<DataTable className="collector-table" label="Gesammelte Downloadpakete">
<DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete">
<DataTableHeader className="collector-table-header">
<div className="collector-table-header-row" role="row">
<div aria-rowindex={1} className="collector-table-header-row" role="row">
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Name</span>
<span role="columnheader">Größe</span>
@@ -260,7 +416,23 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
<span role="columnheader">Hinzugefügt</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
<DataTableBody
className="collector-table-body"
data-visual-region="collector-table-body"
onKeyDownCapture={(event) => {
if (event.key !== "Tab") return;
const currentIndex = Number((event.target as HTMLElement).dataset.collectorFocusIndex);
if (!Number.isInteger(currentIndex)) return;
const nextIndex = currentIndex + (event.shiftKey ? -1 : 1);
if (nextIndex < 0 || nextIndex >= logicalFocusCount) return;
const packageId = getCollectorFocusPackageId(virtualPackages, nextIndex);
if (!packageId) return;
event.preventDefault();
setFocusedPackageId(packageId);
setPendingFocusIndex(nextIndex);
}}
ref={bodyRef}
>
{model.analyzing ? <div aria-live="polite" className="collector-background-state" role="status"><span />Analyse läuft im Hintergrund</div> : null}
{model.error ? <div aria-live="polite" className="collector-background-error" role="status">{model.error}</div> : null}
{model.empty ? (
@@ -269,7 +441,26 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : model.analyzing ? "Die ersten Links erscheinen sofort nach dem Import." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."}
title={model.query || model.filter !== "all" ? "Keine passenden Links" : model.analyzing ? "Links werden vorbereitet" : "Noch keine Links"}
/>
) : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} selected={selected} />)}
) : (
<div className={`collector-virtual-spacer${model.animationsEnabled ? " is-motion-enabled" : ""}`} style={spacerStyle}>
{virtualWindow.rows.map((entry) => (
<div
className="collector-virtual-package"
data-collector-package-id={entry.id}
key={entry.id}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocusedPackageId((current) => current === entry.id ? null : current);
}
}}
onFocusCapture={() => setFocusedPackageId(entry.id)}
style={collectorVirtualPackageStyle(entry.top, entry.height)}
>
<CollectorPackageGroup actions={actions} focusIndexStart={entry.source.focusIndexStart} model={model} row={entry.source.row} rowIndexStart={entry.source.rowIndex} selected={selected} />
</div>
))}
</div>
)}
</DataTableBody>
</DataTable>
</section>
@@ -0,0 +1,42 @@
export interface CollectorDisclosureRow {
id: string;
collapsed: boolean;
}
export function getCollectorDisclosurePinnedIds(
previousVisibleIds: readonly string[],
previousCollapsed: ReadonlyMap<string, boolean>,
nextRows: readonly CollectorDisclosureRow[],
enabled: boolean
): string[] {
if (!enabled || previousVisibleIds.length === 0) return [];
const nextCollapsed = new Map(nextRows.map((row) => [row.id, row.collapsed]));
return previousVisibleIds.filter((id) => previousCollapsed.has(id)
&& nextCollapsed.has(id)
&& previousCollapsed.get(id) !== nextCollapsed.get(id));
}
export function getCollectorDisclosureViewportIds(
rows: readonly { id: string; index: number; pinned?: boolean }[],
startIndex: number,
endIndex: number
): string[] {
return rows.filter((row) => row.index >= startIndex && row.index <= endIndex).map((row) => row.id);
}
export function mergeCollectorPinnedIds(transitionPinnedIds: readonly string[], focusedPackageId: string | null): string[] {
return [...new Set([...transitionPinnedIds, focusedPackageId].filter((id): id is string => Boolean(id)))];
}
export function getCollectorFocusPackageId(
ranges: readonly { id: string; focusIndexStart: number; focusCount: number }[],
focusIndex: number
): string | null {
return ranges.find((range) => focusIndex >= range.focusIndexStart && focusIndex < range.focusIndexStart + range.focusCount)?.id ?? null;
}
export function resolveCollectorTransitionPins(current: readonly string[], fresh: readonly string[], maximum = 8): string[] {
const freshSet = new Set(fresh);
const merged = [...current.filter((id) => !freshSet.has(id)), ...fresh];
return merged.slice(-Math.max(1, Math.floor(maximum)));
}
+38 -2
View File
@@ -216,7 +216,28 @@
}
.collector-package-group {
content-visibility: auto;
height: 100%;
}
.collector-virtual-spacer {
flex: 0 0 var(--collector-virtual-total-height);
height: var(--collector-virtual-total-height);
min-width: 960px;
position: relative;
}
.collector-virtual-package {
height: var(--collector-virtual-package-height);
left: 0;
overflow: hidden;
position: absolute;
right: 0;
top: 0;
transform: translateY(var(--collector-virtual-package-top));
}
.collector-virtual-spacer.is-motion-enabled .collector-virtual-package {
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.collector-package-row {
@@ -250,10 +271,13 @@
}
.collector-package-items-frame.is-animated {
animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease;
}
.collector-package-items-frame.is-expanding {
animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.collector-package-items-frame.is-collapsed {
grid-template-rows: 0fr;
opacity: 0;
@@ -446,6 +470,18 @@
.collector-background-state span {
animation: none;
}
.collector-virtual-spacer.is-motion-enabled .collector-virtual-package {
transition-duration: 300ms, 300ms !important;
}
.collector-package-items-frame.is-expanding {
animation-duration: 300ms !important;
}
.collector-package-items-frame.is-animated {
transition-duration: 300ms, 300ms !important;
}
}
@media (max-width: 1366px) {
+92 -2
View File
@@ -18,10 +18,18 @@ import {
CollectorSidebar,
CollectorToolbar,
CollectorView,
collectorFileInteractionAttributes,
collectorPackageIntrinsicBlockSize,
toggleAllCollectorPackageIds,
type CollectorViewActions
} from "../src/renderer/views/collector/CollectorView";
import {
getCollectorDisclosurePinnedIds,
getCollectorDisclosureViewportIds,
getCollectorFocusPackageId,
mergeCollectorPinnedIds,
resolveCollectorTransitionPins
} from "../src/renderer/views/collector/collector-disclosure";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
@@ -286,6 +294,44 @@ describe("collector workspace model", () => {
});
});
describe("collector disclosure virtualization", () => {
it("pins only previously visible packages whose collapsed state changes", () => {
const previousCollapsed = new Map([
["package-1", false],
["package-2", false],
["package-3", false]
]);
const nextRows = [
{ id: "package-1", collapsed: true },
{ id: "package-2", collapsed: true },
{ id: "package-3", collapsed: true }
];
expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, true)).toEqual(["package-1", "package-2"]);
expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, false)).toEqual([]);
});
it("retains transitioning and keyboard-focused packages across virtual window changes", () => {
expect(getCollectorDisclosureViewportIds([
{ id: "visible", index: 1, pinned: false },
{ id: "transitioning", index: 99, pinned: true }
], 0, 3)).toEqual(["visible"]);
expect(mergeCollectorPinnedIds(["transitioning"], "focused")).toEqual(["transitioning", "focused"]);
expect(getCollectorFocusPackageId([
{ id: "package-1", focusIndexStart: 0, focusCount: 4 },
{ id: "package-2", focusIndexStart: 4, focusCount: 2 }
], 4)).toBe("package-2");
expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 3)).toEqual(["transitioning", "new-visible-1", "new-visible-2"]);
expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 2)).toEqual(["new-visible-1", "new-visible-2"]);
expect(resolveCollectorTransitionPins([], Array.from({ length: 12 }, (_, index) => `package-${index}`))).toEqual(Array.from({ length: 8 }, (_, index) => `package-${index + 4}`));
});
it("removes collapsed transition files from logical focus and ARIA indexing", () => {
expect(collectorFileInteractionAttributes(2, 0, 0, false)).toEqual({ rowIndex: 3, focusIndex: 2, tabIndex: undefined });
expect(collectorFileInteractionAttributes(2, 0, 0, true)).toEqual({ rowIndex: undefined, focusIndex: undefined, tabIndex: -1 });
});
});
describe("CollectorView", () => {
it("reserves the exact package height while content visibility skips offscreen rows", () => {
const expanded = buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true).packages[0];
@@ -296,6 +342,47 @@ describe("CollectorView", () => {
expect(renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />)).toContain("contain-intrinsic-block-size:auto 126px");
});
it("keeps expanded child rows bounded to the virtual viewport for large collectors", () => {
const manyPackages: CollectorPackage[] = Array.from({ length: 50 }, (_, index) => ({
...packages[0],
id: `package-${index}`,
name: `Paket ${index}`,
links: packages[0].links.map((link, linkIndex) => ({
...link,
id: `link-${index}-${linkIndex}`,
url: `https://example.test/${index}/${linkIndex}`
}))
}));
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(manyPackages, "all", "", false, [], [], "", true)} />);
const renderedFileRows = html.match(/class="collector-file-row/g)?.length ?? 0;
expect(html).toContain("collector-virtual-spacer");
expect(renderedFileRows).toBeGreaterThan(0);
expect(renderedFileRows).toBeLessThanOrEqual(20);
});
it("does not replay the disclosure animation when an expanded virtual package mounts", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />);
expect(html).toContain("collector-package-items-frame");
expect(html).not.toContain("collector-package-items-frame is-animated");
});
it("exposes logical row counts and positions for virtualized assistive navigation", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true)} />);
expect(html).toContain('aria-rowcount="4"');
expect(html).toContain('aria-rowindex="1"');
expect(html).toContain('aria-rowindex="2"');
expect(html).toContain('aria-rowindex="3"');
expect(html).toContain('aria-rowindex="4"');
expect(html).toContain('data-collector-focus-index="0"');
expect(html).toContain('data-collector-focus-index="1"');
expect(html).toContain('data-collector-focus-index="2"');
expect(html).toContain('data-collector-focus-index="3"');
});
it("renders expandable package and file rows with preview columns", () => {
const html = renderToStaticMarkup(<CollectorView actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
@@ -402,11 +489,14 @@ describe("CollectorView", () => {
expect(commits).toBe(1);
});
it("uses aligned responsive package grids and content visibility", () => {
it("uses aligned responsive package grids and positioned virtual rows", () => {
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(css).toMatch(/\.collector-table-header-row,\s*\.collector-package-row,\s*\.collector-file-row\s*\{[^}]*grid-template-columns:/s);
expect(css).toMatch(/\.collector-package-group\s*\{[^}]*content-visibility:\s*auto;/s);
expect(css).toMatch(/\.collector-virtual-package\s*\{[^}]*position:\s*absolute;[^}]*transform:\s*translateY/s);
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s);
expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s);
expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s);
});
it("uses one consistent gap across collector toolbar groups", () => {