From a101bd0bedbd4076b9cac63e99ade34c44ed3c20 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 11:18:05 +0200 Subject: [PATCH] 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. --- src/renderer/App.tsx | 5 + .../views/collector/CollectorView.tsx | 15 ++- src/renderer/views/collector/collector.css | 4 +- .../views/downloads/DownloadsView.tsx | 14 +-- .../download-disclosure-transition.ts | 6 +- src/renderer/views/downloads/downloads.css | 4 + tests/collector-view.test.tsx | 33 ++++++ tests/downloads-view.test.tsx | 21 +++- .../download-disclosure-transition.test.tsx | 107 ++++++++++++++++++ 9 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 tests/visual/download-disclosure-transition.test.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 35bd6c3..969f412 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -75,6 +75,7 @@ import { CollectorInputDialog, CollectorSidebar, CollectorToolbar, + toggleAllCollectorPackageIds, type CollectorViewActions } from "./views/collector/CollectorView"; import { @@ -5113,6 +5114,10 @@ export function App(): ReactElement { else next.add(packageId); return next; }), + onToggleAllPackages: () => setCollapsedCollectorPackageIds((current) => toggleAllCollectorPackageIds( + collectorPackagesRef.current.map((pkg) => pkg.id), + current + )), onRemoveSelected: removeSelectedCollectorRows }; diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 6c1f52f..013fae8 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -18,6 +18,7 @@ export interface CollectorViewActions { onLinkSelectionChange: (linkId: string, selected: boolean) => void; onPackageSelectionChange: (packageId: string, selected: boolean) => void; onPackageCollapseChange: (packageId: string) => void; + onToggleAllPackages: () => void; onRemoveSelected: () => void; } @@ -58,6 +59,15 @@ function packageSize(row: CollectorWorkspacePackageRow): string { return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`; } +export function toggleAllCollectorPackageIds( + packageIds: readonly string[], + collapsedPackageIds: ReadonlySet +): Set { + return packageIds.some((packageId) => !collapsedPackageIds.has(packageId)) + ? new Set(packageIds) + : new Set(); +} + function CollectorHosterLabel({ hoster }: { hoster: ReturnType }): ReactElement { return ( @@ -94,7 +104,10 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE - actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} /> + + actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} /> + + ); } diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css index 73b5d66..ec64f18 100644 --- a/src/renderer/views/collector/collector.css +++ b/src/renderer/views/collector/collector.css @@ -10,6 +10,8 @@ .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 .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: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); } @@ -62,4 +64,4 @@ .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); } @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; } } diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 2a1e578..3536747 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -102,9 +102,8 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct {filters.map((filter) => )} - +
-
@@ -151,11 +150,12 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct - - - - ); -} + + + + + ); +} function tableState(model: DownloadsViewModel): ReactElement | null { if (model.empty) return
Noch keine DownloadsFüge Links hinzu, um den ersten Download zu starten.
; diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index 715e89f..d8fc373 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -62,7 +62,7 @@ function itemGroup( packageId, items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })), height, - disclosureOpacity: 1, + disclosureOpacity: phase === "entering" ? 0 : 1, disclosurePhase: phase }; } @@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc return rows.map((row) => { if (row.type !== "item-group") return row; return row.disclosurePhase === "entering" - ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT } - : { ...row, height: 0 }; + ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 } + : { ...row, height: 0, disclosureOpacity: 0 }; }); } diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 5fbffbd..920a17a 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -237,6 +237,10 @@ background: var(--ui-border); } +.downloads-toolbar-toggle-all { + margin-left: auto; +} + .downloads-schedule-slot { display: grid; grid-template-columns: 0fr; diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index 97069f6..9b6a723 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -11,6 +11,7 @@ import { selectCollectorPackageLinks } from "../src/renderer/views/collector/collector-model"; 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 { if (Array.isArray(node)) { @@ -48,6 +49,7 @@ function createActions(overrides: Partial = {}): Collector onLinkSelectionChange: () => {}, onPackageSelectionChange: () => {}, onPackageCollapseChange: () => {}, + onToggleAllPackages: () => {}, onRemoveSelected: () => {}, ...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 } ]); }); + + 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) => Set; + }).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", () => { @@ -166,6 +181,24 @@ describe("CollectorView", () => { 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", () => { const html = renderToStaticMarkup(); expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\""); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 9db5939..3e203a6 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -371,7 +371,7 @@ describe("virtualisierte Paketanimation", () => { expect(prepared.animated).toBe(true); expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ ["package-a", 40, "stable", 1], - ["package-a:items", 0, "entering", 1], + ["package-a:items", 0, "entering", 0], ["package-b", 40, "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(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ ["package-a", 40, 1], - ["package-a:items", 0, 1], + ["package-a:items", 0, 0], ["package-b", 40, 1], ["b-1", 38, 1] ]); @@ -1098,6 +1098,23 @@ describe("downloads view", () => { 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(); + 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", () => { const model = withRuntime(createInput()); const component = PackageCardContent({ diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx new file mode 100644 index 0000000..eece668 --- /dev/null +++ b/tests/visual/download-disclosure-transition.test.tsx @@ -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); + }); +});