Fix disclosure fading and global package toggles
Fade the shared virtual item group from transparent to opaque while expanding and reverse the opacity while collapsing, preserving the clipping row as the geometry owner. Move the downloads package toggle to the far toolbar edge and add a collector toolbar toggle backed by raw unfiltered package IDs. Cover opacity endpoints, intermediate frame geometry, toolbar ordering, and filter-independent collector toggle semantics.
This commit is contained in:
@@ -75,6 +75,7 @@ import {
|
|||||||
CollectorInputDialog,
|
CollectorInputDialog,
|
||||||
CollectorSidebar,
|
CollectorSidebar,
|
||||||
CollectorToolbar,
|
CollectorToolbar,
|
||||||
|
toggleAllCollectorPackageIds,
|
||||||
type CollectorViewActions
|
type CollectorViewActions
|
||||||
} from "./views/collector/CollectorView";
|
} from "./views/collector/CollectorView";
|
||||||
import {
|
import {
|
||||||
@@ -5113,6 +5114,10 @@ export function App(): ReactElement {
|
|||||||
else next.add(packageId);
|
else next.add(packageId);
|
||||||
return next;
|
return next;
|
||||||
}),
|
}),
|
||||||
|
onToggleAllPackages: () => setCollapsedCollectorPackageIds((current) => toggleAllCollectorPackageIds(
|
||||||
|
collectorPackagesRef.current.map((pkg) => pkg.id),
|
||||||
|
current
|
||||||
|
)),
|
||||||
onRemoveSelected: removeSelectedCollectorRows
|
onRemoveSelected: removeSelectedCollectorRows
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface CollectorViewActions {
|
|||||||
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
|
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
|
||||||
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
|
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
|
||||||
onPackageCollapseChange: (packageId: string) => void;
|
onPackageCollapseChange: (packageId: string) => void;
|
||||||
|
onToggleAllPackages: () => void;
|
||||||
onRemoveSelected: () => void;
|
onRemoveSelected: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,15 @@ function packageSize(row: CollectorWorkspacePackageRow): string {
|
|||||||
return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`;
|
return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleAllCollectorPackageIds(
|
||||||
|
packageIds: readonly string[],
|
||||||
|
collapsedPackageIds: ReadonlySet<string>
|
||||||
|
): Set<string> {
|
||||||
|
return packageIds.some((packageId) => !collapsedPackageIds.has(packageId))
|
||||||
|
? new Set(packageIds)
|
||||||
|
: new Set();
|
||||||
|
}
|
||||||
|
|
||||||
function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHosterLabel> }): ReactElement {
|
function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHosterLabel> }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<span className="collector-hoster-label" title={hoster.title}>
|
<span className="collector-hoster-label" title={hoster.title}>
|
||||||
@@ -94,7 +104,10 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
|
|||||||
<button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
<button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
||||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
<ToolbarGroup className="collector-toolbar-tail" label="Suche und Paketdarstellung">
|
||||||
|
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
||||||
|
<button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
|
</ToolbarGroup>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
.collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
|
.collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
|
||||||
.collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; }
|
.collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; }
|
||||||
.collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); }
|
.collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); }
|
||||||
|
.collector-toolbar-tail { flex: 1 1 auto; justify-content: flex-end; margin-left: auto; }
|
||||||
|
.collector-toolbar-tail .ui-toolbar-search { margin-left: 0; }
|
||||||
.collector-action { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text-secondary); font: inherit; height: 36px; padding: 0 12px; white-space: nowrap; }
|
.collector-action { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text-secondary); font: inherit; height: 36px; padding: 0 12px; white-space: nowrap; }
|
||||||
.collector-action:hover:not(:disabled) { background: var(--ui-hover); color: var(--ui-text); }
|
.collector-action:hover:not(:disabled) { background: var(--ui-hover); color: var(--ui-text); }
|
||||||
.collector-action-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
.collector-action-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
||||||
@@ -62,4 +64,4 @@
|
|||||||
.collector-dialog-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
.collector-dialog-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
||||||
.collector-dialog-secondary { background: var(--ui-modal-secondary); color: var(--ui-text-secondary); }
|
.collector-dialog-secondary { background: var(--ui-modal-secondary); color: var(--ui-text-secondary); }
|
||||||
@media (max-width: 1366px) { .collector-view { grid-template-columns: 56px minmax(0, 1fr); } .collector-sidebar { overflow: hidden; } .collector-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } }
|
@media (max-width: 1366px) { .collector-view { grid-template-columns: 56px minmax(0, 1fr); } .collector-sidebar { overflow: hidden; } .collector-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } }
|
||||||
@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar .ui-toolbar-search { flex: 1 0 100%; width: 100%; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } }
|
@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar-tail { flex: 1 0 100%; } .collector-toolbar-tail .ui-toolbar-search { flex: 1 1 auto; width: auto; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } }
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||||
<div className="downloads-sidebar-actions">
|
<div className="downloads-sidebar-actions">
|
||||||
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
|
||||||
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||||
@@ -153,6 +152,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||||
|
<button className="downloads-toolbar-toggle-all" onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function itemGroup(
|
|||||||
packageId,
|
packageId,
|
||||||
items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
|
items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
|
||||||
height,
|
height,
|
||||||
disclosureOpacity: 1,
|
disclosureOpacity: phase === "entering" ? 0 : 1,
|
||||||
disclosurePhase: phase
|
disclosurePhase: phase
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc
|
|||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
if (row.type !== "item-group") return row;
|
if (row.type !== "item-group") return row;
|
||||||
return row.disclosurePhase === "entering"
|
return row.disclosurePhase === "entering"
|
||||||
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT }
|
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
|
||||||
: { ...row, height: 0 };
|
: { ...row, height: 0, disclosureOpacity: 0 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,10 @@
|
|||||||
background: var(--ui-border);
|
background: var(--ui-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-toolbar-toggle-all {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-schedule-slot {
|
.downloads-schedule-slot {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0fr;
|
grid-template-columns: 0fr;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
selectCollectorPackageLinks
|
selectCollectorPackageLinks
|
||||||
} from "../src/renderer/views/collector/collector-model";
|
} from "../src/renderer/views/collector/collector-model";
|
||||||
import { CollectorContent, CollectorInputDialog, CollectorSidebar, CollectorToolbar, CollectorView, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView";
|
import { CollectorContent, CollectorInputDialog, CollectorSidebar, CollectorToolbar, CollectorView, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView";
|
||||||
|
import * as collectorViewModule from "../src/renderer/views/collector/CollectorView";
|
||||||
|
|
||||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||||
if (Array.isArray(node)) {
|
if (Array.isArray(node)) {
|
||||||
@@ -48,6 +49,7 @@ function createActions(overrides: Partial<CollectorViewActions> = {}): Collector
|
|||||||
onLinkSelectionChange: () => {},
|
onLinkSelectionChange: () => {},
|
||||||
onPackageSelectionChange: () => {},
|
onPackageSelectionChange: () => {},
|
||||||
onPackageCollapseChange: () => {},
|
onPackageCollapseChange: () => {},
|
||||||
|
onToggleAllPackages: () => {},
|
||||||
onRemoveSelected: () => {},
|
onRemoveSelected: () => {},
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
@@ -111,6 +113,19 @@ describe("collector workspace model", () => {
|
|||||||
{ id: "all", label: "Alle Links", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 }
|
{ id: "all", label: "Alle Links", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles every raw package even when the workspace shows only a filtered subset", () => {
|
||||||
|
const toggleAllCollectorPackageIds = (collectorViewModule as unknown as {
|
||||||
|
toggleAllCollectorPackageIds?: (packageIds: readonly string[], collapsedPackageIds: ReadonlySet<string>) => Set<string>;
|
||||||
|
}).toggleAllCollectorPackageIds;
|
||||||
|
expect(toggleAllCollectorPackageIds).toBeTypeOf("function");
|
||||||
|
if (!toggleAllCollectorPackageIds) return;
|
||||||
|
|
||||||
|
const rawPackageIds = packages.map((pkg) => pkg.id);
|
||||||
|
expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set())].sort()).toEqual(["package-mixed", "package-sbs"]);
|
||||||
|
expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set(["package-sbs"]))].sort()).toEqual(["package-mixed", "package-sbs"]);
|
||||||
|
expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set(rawPackageIds))]).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CollectorView", () => {
|
describe("CollectorView", () => {
|
||||||
@@ -166,6 +181,24 @@ describe("CollectorView", () => {
|
|||||||
expect(all).toBe(1);
|
expect(all).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the global package toggle enabled at the right toolbar edge despite active filters", () => {
|
||||||
|
let toggles = 0;
|
||||||
|
const model = buildCollectorWorkspaceViewModel(packages, "online", "no-visible-result", false, [], [], "", true);
|
||||||
|
const toolbar = CollectorToolbar({ actions: createActions({ onToggleAllPackages: () => { toggles += 1; } }), model });
|
||||||
|
const toolbarButtons: ReactElement[] = [];
|
||||||
|
visitElements(toolbar, (element) => {
|
||||||
|
if (element.type === "button") toolbarButtons.push(element);
|
||||||
|
});
|
||||||
|
const toggle = findButton(toolbar, "Alle ein-/ausklappen");
|
||||||
|
|
||||||
|
expect(model.empty).toBe(true);
|
||||||
|
expect(model.totalCount).toBe(4);
|
||||||
|
expect(toggle.props.disabled).toBe(false);
|
||||||
|
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
|
||||||
|
toggle.props.onClick();
|
||||||
|
expect(toggles).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders accessible mixed package selection", () => {
|
it("renders accessible mixed package selection", () => {
|
||||||
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)} />);
|
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1"], [], "", true)} />);
|
||||||
expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\"");
|
expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\"");
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ describe("virtualisierte Paketanimation", () => {
|
|||||||
expect(prepared.animated).toBe(true);
|
expect(prepared.animated).toBe(true);
|
||||||
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
|
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
|
||||||
["package-a", 40, "stable", 1],
|
["package-a", 40, "stable", 1],
|
||||||
["package-a:items", 0, "entering", 1],
|
["package-a:items", 0, "entering", 0],
|
||||||
["package-b", 40, "stable", 1],
|
["package-b", 40, "stable", 1],
|
||||||
["b-1", 38, "stable", 1]
|
["b-1", 38, "stable", 1]
|
||||||
]);
|
]);
|
||||||
@@ -411,7 +411,7 @@ describe("virtualisierte Paketanimation", () => {
|
|||||||
expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]);
|
expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]);
|
||||||
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
|
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
|
||||||
["package-a", 40, 1],
|
["package-a", 40, 1],
|
||||||
["package-a:items", 0, 1],
|
["package-a:items", 0, 0],
|
||||||
["package-b", 40, 1],
|
["package-b", 40, 1],
|
||||||
["b-1", 38, 1]
|
["b-1", 38, 1]
|
||||||
]);
|
]);
|
||||||
@@ -1098,6 +1098,23 @@ describe("downloads view", () => {
|
|||||||
expect(toolbar).not.toContain("downloads-search-input");
|
expect(toolbar).not.toContain("downloads-search-input");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("places the global package disclosure action at the right toolbar edge", () => {
|
||||||
|
let toggles = 0;
|
||||||
|
const model = withRuntime(createInput({ filter: "paused" }));
|
||||||
|
const actions = createActions({ onToggleAllPackages: () => { toggles += 1; } });
|
||||||
|
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />);
|
||||||
|
const toolbar = DownloadsToolbar({ actions, model });
|
||||||
|
const toolbarButtons: ReactElement[] = [];
|
||||||
|
visitElements(toolbar, (element) => {
|
||||||
|
if (element.type === "button") toolbarButtons.push(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebar).not.toContain("Alle ein-/ausklappen");
|
||||||
|
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
|
||||||
|
findButton(toolbar, "Alle ein-/ausklappen").props.onClick();
|
||||||
|
expect(toggles).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
const component = PackageCardContent({
|
const component = PackageCardContent({
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DownloadLogicalRow } from "../../src/renderer/views/downloads/downloads-model";
|
||||||
|
import {
|
||||||
|
activateDownloadDisclosureTransition,
|
||||||
|
prepareDownloadDisclosureTransition,
|
||||||
|
stableDownloadDisclosureRows,
|
||||||
|
type DownloadDisclosureRow
|
||||||
|
} from "../../src/renderer/views/downloads/download-disclosure-transition";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_FILE_ROW_HEIGHT,
|
||||||
|
DOWNLOAD_PACKAGE_ROW_HEIGHT
|
||||||
|
} from "../../src/renderer/views/downloads/download-virtualizer";
|
||||||
|
|
||||||
|
type DisclosureFrameRow = {
|
||||||
|
id: string;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function packageRow(id: string, collapsed: boolean, itemIds: readonly string[]): DownloadLogicalRow {
|
||||||
|
return {
|
||||||
|
type: "package",
|
||||||
|
id,
|
||||||
|
packageId: id,
|
||||||
|
height: DOWNLOAD_PACKAGE_ROW_HEIGHT,
|
||||||
|
packageRow: {
|
||||||
|
id,
|
||||||
|
collapsed,
|
||||||
|
items: itemIds.map((itemId) => ({ id: itemId, packageId: id }))
|
||||||
|
}
|
||||||
|
} as unknown as DownloadLogicalRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemRow(id: string, packageId: string): DownloadLogicalRow {
|
||||||
|
return {
|
||||||
|
type: "item",
|
||||||
|
id,
|
||||||
|
packageId,
|
||||||
|
height: DOWNLOAD_FILE_ROW_HEIGHT,
|
||||||
|
item: { id, packageId }
|
||||||
|
} as unknown as DownloadLogicalRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logicalRows(firstCollapsed: boolean): DownloadLogicalRow[] {
|
||||||
|
return [
|
||||||
|
packageRow("package-a", firstCollapsed, ["a-1", "a-2"]),
|
||||||
|
...(firstCollapsed ? [] : [itemRow("a-1", "package-a"), itemRow("a-2", "package-a")]),
|
||||||
|
packageRow("package-b", false, ["b-1"]),
|
||||||
|
itemRow("b-1", "package-b")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function disclosureFrame(
|
||||||
|
startRows: readonly DownloadDisclosureRow[],
|
||||||
|
targetRows: readonly DownloadDisclosureRow[],
|
||||||
|
progress: number
|
||||||
|
): DisclosureFrameRow[] {
|
||||||
|
const targetById = new Map(targetRows.map((row) => [row.id, row]));
|
||||||
|
let top = 0;
|
||||||
|
return startRows.map((row) => {
|
||||||
|
const target = targetById.get(row.id) ?? row;
|
||||||
|
const height = row.height + (target.height - row.height) * progress;
|
||||||
|
const opacity = row.disclosureOpacity + (target.disclosureOpacity - row.disclosureOpacity) * progress;
|
||||||
|
const frameRow = { id: row.id, top, bottom: top + height, opacity };
|
||||||
|
top += height;
|
||||||
|
return frameRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareFrameRows(beforeCollapsed: boolean, afterCollapsed: boolean): {
|
||||||
|
start: DownloadDisclosureRow[];
|
||||||
|
target: DownloadDisclosureRow[];
|
||||||
|
} {
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(
|
||||||
|
stableDownloadDisclosureRows(logicalRows(beforeCollapsed)),
|
||||||
|
logicalRows(afterCollapsed)
|
||||||
|
);
|
||||||
|
expect(prepared.animated).toBe(true);
|
||||||
|
return {
|
||||||
|
start: prepared.rows,
|
||||||
|
target: activateDownloadDisclosureTransition(prepared.rows)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("download disclosure render contract", () => {
|
||||||
|
it.each([
|
||||||
|
{ beforeCollapsed: true, afterCollapsed: false, startOpacity: 0, targetOpacity: 1 },
|
||||||
|
{ beforeCollapsed: false, afterCollapsed: true, startOpacity: 1, targetOpacity: 0 }
|
||||||
|
])("keeps the shared clipping group ahead of the following package in every measured frame", ({ beforeCollapsed, afterCollapsed, startOpacity, targetOpacity }) => {
|
||||||
|
const { start, target } = prepareFrameRows(beforeCollapsed, afterCollapsed);
|
||||||
|
const measuredFrames = [0, 0.2, 0.5, 0.8, 1].map((progress) => disclosureFrame(start, target, progress));
|
||||||
|
|
||||||
|
for (const frame of measuredFrames) {
|
||||||
|
const group = frame.find((row) => row.id === "package-a:items");
|
||||||
|
const following = frame.find((row) => row.id === "package-b");
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
expect(following).toBeDefined();
|
||||||
|
expect(group!.bottom).toBeLessThanOrEqual(following!.top + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupOpacities = measuredFrames.map((frame) => frame.find((row) => row.id === "package-a:items")!.opacity);
|
||||||
|
expect(groupOpacities[0]).toBe(startOpacity);
|
||||||
|
expect(groupOpacities.at(-1)).toBe(targetOpacity);
|
||||||
|
expect(groupOpacities[2]).toBe((startOpacity + targetOpacity) / 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user