From 1d71860d9130f3e5addc306892c76456929be0e8 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 04:40:02 +0200 Subject: [PATCH] fix(history): align columns and animate details Anchor the history action column to the table edge, left-align size values with their header, and replace the raw expanded row with a measured disclosure surface. Reuse the global animation preference so history details open and close smoothly when enabled and switch immediately when animations are disabled. Preserve responsive column resizing and cover the new model and layout behavior with focused tests. --- src/renderer/App.tsx | 5 +- src/renderer/views/history/HistoryView.tsx | 112 +++++++++++++++++--- src/renderer/views/history/history-model.ts | 7 +- src/renderer/views/history/history.css | 98 +++++++++++------ tests/history-view.test.tsx | 34 ++++-- 5 files changed, 194 insertions(+), 62 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2cc9595..acda888 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1676,8 +1676,9 @@ export function App(): ReactElement { historyExpandedIds, historyLoading, historyError, - runtimeNow - ), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds]); + runtimeNow, + snapshot.settings.animatePackageDisclosure + ), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds, snapshot.settings.animatePackageDisclosure]); historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id); const statisticsViewModel = useMemo( () => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow), diff --git a/src/renderer/views/history/HistoryView.tsx b/src/renderer/views/history/HistoryView.tsx index 4601aa5..7e1e077 100644 --- a/src/renderer/views/history/HistoryView.tsx +++ b/src/renderer/views/history/HistoryView.tsx @@ -1,5 +1,7 @@ import { useEffect, + useLayoutEffect, + useRef, useState, type ChangeEvent, type KeyboardEvent, @@ -63,6 +65,8 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [ const HISTORY_TABLE_COLUMNS = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet"] as const; const HISTORY_TABLE_COLUMN_STORAGE_KEY = "mdd.history-table-columns.v1"; +const HISTORY_DISCLOSURE_DURATION_MS = 520; +const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null; function loadHistoryTableColumnWidths(): HistoryTableColumnWidths { @@ -101,20 +105,93 @@ function syncHistoryTableScroll(event: UIEvent): void { } } -function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: number }): ReactElement { - return ( -
-
-
-
Provider
{row.providerLabel}
-
Dateien
{row.fileCount}
-
Dauer
{row.durationLabel}
-
Durchschnitt
{row.averageSpeedLabel}
-
Zielordner
{row.outputDir || "—"}
-
URLs
{row.urls?.length ? row.urls.join("\n") : "—"}
-
-
-
+function HistoryRowDetails({ + row, + minWidth, + expanded, + animationsEnabled +}: { + row: HistoryRow; + minWidth: number; + expanded: boolean; + animationsEnabled: boolean; +}): ReactElement | null { + const [rendered, setRendered] = useState(expanded); + const [height, setHeight] = useState(null); + const disclosureRef = useRef(null); + const contentRef = useRef(null); + const detailRef = useRef(null); + const previousExpanded = useRef(expanded); + + useRendererLayoutEffect(() => { + const wasExpanded = previousExpanded.current; + previousExpanded.current = expanded; + let moveTimer: ReturnType | null = null; + let settleTimer: ReturnType | null = null; + + if (!animationsEnabled) { + setRendered(expanded); + setHeight(null); + return; + } + + if (expanded) { + setRendered(true); + if (wasExpanded) { + setHeight(null); + } else { + const targetHeight = detailRef.current?.scrollHeight ?? contentRef.current?.scrollHeight ?? 0; + const currentHeight = disclosureRef.current?.getBoundingClientRect().height ?? 0; + setHeight(currentHeight); + moveTimer = setTimeout(() => setHeight(targetHeight), 20); + settleTimer = setTimeout(() => setHeight(null), HISTORY_DISCLOSURE_DURATION_MS + 40); + } + } else if (wasExpanded) { + const currentHeight = disclosureRef.current?.getBoundingClientRect().height + ?? detailRef.current?.scrollHeight + ?? contentRef.current?.scrollHeight + ?? 0; + setHeight(currentHeight); + moveTimer = setTimeout(() => setHeight(0), 20); + settleTimer = setTimeout(() => { + setRendered(false); + setHeight(null); + }, HISTORY_DISCLOSURE_DURATION_MS + 40); + } + + return () => { + if (moveTimer) clearTimeout(moveTimer); + if (settleTimer) clearTimeout(settleTimer); + }; + }, [animationsEnabled, expanded]); + + if (!expanded && (!animationsEnabled || !rendered)) { + return null; + } + + const animatedHeight = animationsEnabled && expanded && !rendered ? 0 : height; + return ( +
+
+
+
+
+
Provider
{row.providerLabel}
+
Dateien
{row.fileCount}
+
Dauer
{row.durationLabel}
+
Durchschnitt
{row.averageSpeedLabel}
+
Zielordner
{row.outputDir || "—"}
+
URLs
{row.urls?.length ? row.urls.join("\n") : "—"}
+
+
+
+
+
); } @@ -356,7 +433,12 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo >⋮ - {isExpanded ? : null} + ); }) diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index 81cf4fa..ae6df26 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -36,6 +36,7 @@ export interface HistoryViewModel { loading: boolean; error: string; totalCount: number; + animationsEnabled: boolean; } export interface HistoryPage { @@ -289,7 +290,8 @@ export function buildHistoryViewModel( expandedIds: Iterable, loading: boolean, error: string, - now = Date.now() + now = Date.now(), + animationsEnabled = true ): HistoryViewModel { const rows = filterHistoryRows(entries, filter, query, now); const visibleIds = new Set(rows.map((row) => row.id)); @@ -302,7 +304,8 @@ export function buildHistoryViewModel( counts: countHistoryFilters(entries, now), loading, error, - totalCount: entries.length + totalCount: entries.length, + animationsEnabled }; } diff --git a/src/renderer/views/history/history.css b/src/renderer/views/history/history.css index 41c1762..f06166b 100644 --- a/src/renderer/views/history/history.css +++ b/src/renderer/views/history/history.css @@ -177,14 +177,12 @@ } .history-table-header-row > span:first-child, -.history-table-header-row > span:last-child, -.history-row > span:first-child, -.history-row > span:last-child { +.history-row > span:first-child { text-align: center; } -.history-table-header-row > span:nth-child(4), -.history-row > span:nth-child(4) { +.history-table-header-row > span:last-child, +.history-row > span:last-child { text-align: right; } @@ -210,8 +208,10 @@ position: relative; } -.history-table-header-row > .history-resizable-header:nth-child(4) { - justify-content: flex-end; +.history-table-header-row > span:last-child { + align-items: center; + display: grid; + justify-items: end; } .history-column-resizer { @@ -358,32 +358,56 @@ font-variant-numeric: tabular-nums; } -.history-row-action { - display: grid; - place-items: center; -} - +.history-row-action { + display: grid; + place-items: center end; +} + +.history-detail-disclosure { + opacity: 0; + overflow: hidden; + transition: height 520ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease; +} + +.history-detail-disclosure.is-expanded { + opacity: 1; +} + +.history-detail-disclosure.is-history-motion-disabled { + transition: none; +} + +.history-detail-clip { + min-height: 0; + overflow: hidden; +} + .history-detail-row { - border-bottom: 1px solid var(--ui-border); - background: var(--ui-input); -} - -.history-detail-cell { - padding: 14px 48px; -} - -.history-details-grid { - display: grid; - gap: 12px 24px; - grid-template-columns: repeat(4, minmax(120px, 1fr)); - margin: 0; -} - -.history-details-grid > div { - display: grid; - gap: 4px; - min-width: 0; -} + background: color-mix(in srgb, var(--ui-input) 76%, var(--ui-panel)); + border-bottom: 1px solid var(--ui-border); + border-top: 1px solid color-mix(in srgb, var(--ui-accent) 28%, var(--ui-border)); +} + +.history-detail-cell { + padding: 12px 48px 16px; +} + +.history-details-grid { + display: grid; + gap: 10px 12px; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + margin: 0; +} + +.history-details-grid > div { + background: color-mix(in srgb, var(--ui-panel) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent); + border-radius: 6px; + display: grid; + gap: 5px; + min-width: 0; + padding: 9px 11px; +} .history-details-grid dt { color: var(--ui-text-muted); @@ -461,7 +485,7 @@ display: none; } -@media (max-width: 1366px) { +@media (max-width: 1366px) { .history-workspace-view { grid-template-columns: 56px minmax(0, 1fr); } @@ -473,5 +497,11 @@ .history-action { padding: 0 9px; } - + +} + +@media (prefers-reduced-motion: reduce) { + .history-detail-disclosure:not(.is-history-motion-disabled) { + transition-duration: 520ms, 260ms !important; + } } diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index fd642d4..e13b95b 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -432,8 +432,9 @@ describe("HistoryView", () => { expect(actionCell.props.children.props.children).toBe("⋮"); expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s); expect(styles).toMatch(/\.history-table-header-row > span,\s*\.history-row > span\s*\{[^}]*text-align:\s*left;/s); - expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(4\),\s*\.history-row > span:nth-child\(4\)\s*\{[^}]*text-align:\s*right;/s); - expect(styles).toMatch(/\.history-row-action\s*\{[^}]*place-items:\s*center;/s); + expect(styles).not.toMatch(/\.history-table-header-row > span:nth-child\(4\),\s*\.history-row > span:nth-child\(4\)\s*\{[^}]*text-align:\s*right;/s); + expect(styles).toMatch(/\.history-table-header-row > span:last-child\s*\{[^}]*justify-items:\s*end;/s); + expect(styles).toMatch(/\.history-row-action\s*\{[^}]*place-items:\s*center end;/s); }); it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => { @@ -448,8 +449,8 @@ describe("HistoryView", () => { expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1); } expect(html.match(/data-history-row-id=/g)).toHaveLength(2); - expect(html).not.toContain("history-detail-row"); - expect(html).toContain("1–2 von 2"); + expect(html).not.toContain("history-detail-row"); + expect(html).toContain("1–2 von 2"); }); it("dispatches selection, expansion, select-all and context coordinates with exact visible ids", () => { @@ -566,11 +567,26 @@ describe("HistoryView", () => { /> ); - expect(html).toContain("history-detail-row"); - expect(html).toContain("history-copyable"); - expect(html).toContain("C:\\Downloads\\Heute Paket"); - expect(html).toContain("https://rapidgator.net/file/test"); - }); + expect(html).toContain("history-detail-row"); + expect(html).toContain("history-detail-disclosure is-expanded"); + expect(html).toContain('aria-hidden="false"'); + expect(html).toContain("history-copyable"); + expect(html).toContain("C:\\Downloads\\Heute Paket"); + expect(html).toContain("https://rapidgator.net/file/test"); + }); + + it("uses the global animation setting for the history disclosure surface", () => { + const animated = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, true); + const immediate = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, false); + const animatedHtml = renderToStaticMarkup(); + const immediateHtml = renderToStaticMarkup(); + + expect(animated.animationsEnabled).toBe(true); + expect(immediate.animationsEnabled).toBe(false); + expect(animatedHtml).toContain("history-detail-disclosure is-expanded"); + expect(animatedHtml).not.toContain("is-history-motion-disabled"); + expect(immediateHtml).toContain("history-detail-disclosure is-expanded is-history-motion-disabled"); + }); }); describe("visual history states", () => {