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", () => {