fix(downloads): animate package contents as one panel

This commit is contained in:
Sucukdeluxe
2026-08-14 23:33:19 +02:00
parent 18c8085a4a
commit c71cac7dc7
4 changed files with 169 additions and 61 deletions
@@ -7,6 +7,7 @@ import {
mergeDownloadDisclosureRows,
prepareDownloadDisclosureTransition,
scheduleDownloadDisclosureActivation,
stableDownloadDisclosureRows,
type DownloadDisclosureRow
} from "./download-disclosure-transition";
import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
@@ -69,7 +70,16 @@ function disclosureOpacity(row: DownloadLogicalRow | DownloadDisclosureRow): num
return "disclosureOpacity" in row && typeof row.disclosureOpacity === "number" ? row.disclosureOpacity : 1;
}
function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
function renderVirtualRow(row: DownloadLogicalRow | DownloadDisclosureRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
if (row.type === "item-group") {
return (
<div className="downloads-disclosure-item-group">
{row.items.map((entry) => (
<ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={entry.item} key={entry.id} selected={model.selectedIds.has(entry.item.id)} sessionRunning={model.running} />
))}
</div>
);
}
if (row.type === "item") {
return (
<ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={row.item} selected={model.selectedIds.has(row.item.id)} sessionRunning={model.running} />
@@ -140,7 +150,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
};
}, [model.disclosureRevision, model.displayMode]);
const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : desiredRows, [desiredRows, transitionRows]);
const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
scrollTop: viewport.scrollTop,
viewportHeight: viewport.viewportHeight,
@@ -155,7 +165,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
{!state ? (
<div className="downloads-virtual-spacer" style={spacerStyle}>
{virtualWindow.rows.map((entry) => (
<div className={`downloads-virtual-row${disclosureClassName(entry.source)}`} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
<div className={`downloads-virtual-row${entry.source.type === "item-group" ? " is-disclosure-group" : ""}${disclosureClassName(entry.source)}`} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
{renderVirtualRow(entry.source, model, actions)}
</div>
))}
@@ -2,6 +2,7 @@ import type { DownloadLogicalRow } from "./downloads-model";
import {
DOWNLOAD_FILE_ROW_HEIGHT,
calculateDownloadVirtualWindow,
type DownloadVirtualRowInput,
type DownloadVirtualWindowOptions
} from "./download-virtualizer";
@@ -26,28 +27,57 @@ export function scheduleDownloadDisclosureActivation(
export type DownloadDisclosurePhase = "stable" | "entering" | "leaving";
export type DownloadDisclosureRow = DownloadLogicalRow & {
type DownloadItemLogicalRow = Extract<DownloadLogicalRow, { type: "item" }>;
type DownloadDisclosureMeta = {
disclosureOpacity: number;
disclosurePhase: DownloadDisclosurePhase;
};
function stableRow(row: DownloadLogicalRow): DownloadDisclosureRow {
return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" };
}
export type DownloadDisclosureLogicalRow = DownloadLogicalRow & DownloadDisclosureMeta;
export type DownloadDisclosureGroupRow = DownloadVirtualRowInput & DownloadDisclosureMeta & {
type: "item-group";
packageId: string;
items: DownloadItemLogicalRow[];
};
export type DownloadDisclosureRow = DownloadDisclosureLogicalRow | DownloadDisclosureGroupRow;
type DownloadDisclosureSourceRow = DownloadLogicalRow | DownloadDisclosureRow;
function disclosureOpacity(row: DownloadDisclosureSourceRow): number {
return "disclosureOpacity" in row ? row.disclosureOpacity : 1;
function stableRow(row: DownloadLogicalRow): DownloadDisclosureLogicalRow {
return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" };
}
function itemGroup(
packageId: string,
items: readonly DownloadItemLogicalRow[],
height: number,
phase: Exclude<DownloadDisclosurePhase, "stable">
): DownloadDisclosureGroupRow {
return {
type: "item-group",
id: `${packageId}:items`,
packageId,
items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
height,
disclosureOpacity: 1,
disclosurePhase: phase
};
}
export function stableDownloadDisclosureRows(rows: readonly DownloadLogicalRow[]): DownloadDisclosureRow[] {
return rows.map(stableRow);
}
function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<string, DownloadDisclosureSourceRow[]> {
const grouped = new Map<string, DownloadDisclosureSourceRow[]>();
function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<string, DownloadItemLogicalRow[]> {
const grouped = new Map<string, DownloadItemLogicalRow[]>();
for (const row of rows) {
if (row.type === "item-group") {
grouped.set(row.packageId, row.items);
continue;
}
if (row.type !== "item") continue;
const entries = grouped.get(row.packageId) ?? [];
entries.push(row);
@@ -56,6 +86,10 @@ function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<st
return grouped;
}
function groupRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<string, DownloadDisclosureGroupRow> {
return new Map(rows.filter((row): row is DownloadDisclosureGroupRow => row.type === "item-group").map((row) => [row.packageId, row]));
}
export function prepareDownloadDisclosureTransition(
current: readonly DownloadDisclosureSourceRow[],
desired: readonly DownloadLogicalRow[]
@@ -65,14 +99,9 @@ export function prepareDownloadDisclosureTransition(
}
const currentItems = itemRowsByPackage(current);
const currentGroups = groupRowsByPackage(current);
const currentPackages = new Map(current.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
const desiredItems = new Map<string, DownloadLogicalRow[]>();
for (const row of desired) {
if (row.type !== "item") continue;
const entries = desiredItems.get(row.packageId) ?? [];
entries.push(row);
desiredItems.set(row.packageId, entries);
}
const desiredItems = itemRowsByPackage(desired);
let animatedItemCount = 0;
for (const row of desired) {
@@ -93,6 +122,7 @@ export function prepareDownloadDisclosureTransition(
const before = currentItems.get(row.packageId) ?? [];
const after = desiredItems.get(row.packageId) ?? [];
const previousPackage = currentPackages.get(row.packageId);
const previousGroup = currentGroups.get(row.packageId);
const disclosureChanged = previousPackage?.type === "package" && previousPackage.packageRow.collapsed !== row.packageRow.collapsed;
if (!disclosureChanged) {
rows.push(...after.map(stableRow));
@@ -100,21 +130,12 @@ export function prepareDownloadDisclosureTransition(
}
if (row.packageRow.collapsed && before.length > 0) {
animated = true;
rows.push(...before.map((entry) => ({ ...entry, disclosureOpacity: disclosureOpacity(entry), disclosurePhase: "leaving" as const })));
rows.push(itemGroup(row.packageId, before, previousGroup?.height ?? before.length * DOWNLOAD_FILE_ROW_HEIGHT, "leaving"));
continue;
}
const beforeById = new Map(before.map((entry) => [entry.id, entry]));
for (const entry of after) {
const previous = beforeById.get(entry.id);
if (!previous) {
animated = true;
rows.push({ ...entry, height: 0, disclosureOpacity: 0, disclosurePhase: "entering" });
} else if (("disclosurePhase" in previous && previous.disclosurePhase === "leaving") || previous.height === 0) {
animated = true;
rows.push({ ...entry, height: previous.height, disclosureOpacity: disclosureOpacity(previous), disclosurePhase: "entering" });
} else {
rows.push(stableRow(entry));
}
if (after.length > 0) {
animated = true;
rows.push(itemGroup(row.packageId, after, previousGroup?.height ?? 0, "entering"));
}
}
return { animated, rows };
@@ -124,33 +145,59 @@ export function mergeDownloadDisclosureRows(
transition: readonly DownloadDisclosureRow[],
desired: readonly DownloadLogicalRow[]
): DownloadDisclosureRow[] {
const transitionById = new Map(transition.map((row) => [`${row.type}:${row.id}`, row]));
const desiredIds = new Set(desired.map((row) => `${row.type}:${row.id}`));
const leavingByPackage = new Map<string, DownloadDisclosureRow[]>();
for (const row of transition) {
if (row.type !== "item" || row.disclosurePhase !== "leaving" || desiredIds.has(`item:${row.id}`)) continue;
const entries = leavingByPackage.get(row.packageId) ?? [];
entries.push(row);
leavingByPackage.set(row.packageId, entries);
const transitionPackages = new Map(transition.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
const transitionGroups = groupRowsByPackage(transition);
const desiredItems = itemRowsByPackage(desired);
const desiredPackages = new Map(desired.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
const freshGroupItems = new Map<string, DownloadItemLogicalRow[]>();
let groupedItemCount = 0;
for (const group of transitionGroups.values()) {
const packageRow = desiredPackages.get(group.packageId);
if (!packageRow || packageRow.type !== "package") continue;
const items = desiredItems.get(group.packageId) ?? packageRow.packageRow.items.map((item): DownloadItemLogicalRow => ({
type: "item",
id: item.id,
packageId: item.packageId,
item,
height: DOWNLOAD_FILE_ROW_HEIGHT
}));
groupedItemCount += items.length;
if (groupedItemCount > DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS) {
return stableDownloadDisclosureRows(desired);
}
freshGroupItems.set(group.packageId, items);
}
const rows: DownloadDisclosureRow[] = [];
for (const row of desired) {
const animated = transitionById.get(`${row.type}:${row.id}`);
rows.push(animated
? { ...row, height: animated.height, disclosureOpacity: animated.disclosureOpacity, disclosurePhase: animated.disclosurePhase }
if (row.type !== "package") continue;
const animatedPackage = transitionPackages.get(row.packageId);
rows.push(animatedPackage?.type === "package"
? { ...row, height: animatedPackage.height, disclosureOpacity: animatedPackage.disclosureOpacity, disclosurePhase: animatedPackage.disclosurePhase }
: stableRow(row));
if (row.type === "package") rows.push(...(leavingByPackage.get(row.packageId) ?? []));
const group = transitionGroups.get(row.packageId);
if (!group) {
rows.push(...(desiredItems.get(row.packageId) ?? []).map(stableRow));
continue;
}
const freshItems = freshGroupItems.get(row.packageId) ?? [];
rows.push({
...group,
items: freshItems,
height: group.height > 0 ? freshItems.length * DOWNLOAD_FILE_ROW_HEIGHT : 0
});
}
return rows;
}
export function activateDownloadDisclosureTransition(rows: readonly DownloadDisclosureRow[]): DownloadDisclosureRow[] {
return rows.map((row) => {
if (row.type !== "item" || row.disclosurePhase === "stable") return row;
if (row.type !== "item-group") return row;
return row.disclosurePhase === "entering"
? { ...row, height: DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
: { ...row, height: 0, disclosureOpacity: 0 };
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT }
: { ...row, height: 0 };
});
}
@@ -306,6 +306,10 @@
height: var(--downloads-virtual-row-height);
}
.downloads-disclosure-item-group {
width: 100%;
}
.downloads-package-card {
box-sizing: border-box;
width: 100%;