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.
This commit is contained in:
Sucukdeluxe
2026-08-15 04:40:02 +02:00
parent 83c9afca90
commit 1d71860d91
5 changed files with 194 additions and 62 deletions
+3 -2
View File
@@ -1676,8 +1676,9 @@ export function App(): ReactElement {
historyExpandedIds, historyExpandedIds,
historyLoading, historyLoading,
historyError, historyError,
runtimeNow runtimeNow,
), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds]); snapshot.settings.animatePackageDisclosure
), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds, snapshot.settings.animatePackageDisclosure]);
historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id); historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id);
const statisticsViewModel = useMemo( const statisticsViewModel = useMemo(
() => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow), () => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow),
+97 -15
View File
@@ -1,5 +1,7 @@
import { import {
useEffect, useEffect,
useLayoutEffect,
useRef,
useState, useState,
type ChangeEvent, type ChangeEvent,
type KeyboardEvent, 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_COLUMNS = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet"] as const;
const HISTORY_TABLE_COLUMN_STORAGE_KEY = "mdd.history-table-columns.v1"; 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; let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths { function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
@@ -101,20 +105,93 @@ function syncHistoryTableScroll(event: UIEvent<HTMLDivElement>): void {
} }
} }
function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: number }): ReactElement { function HistoryRowDetails({
return ( row,
<div className="history-detail-row" role="row" style={{ minWidth }}> minWidth,
<div className="history-detail-cell" role="cell"> expanded,
<dl className="history-details-grid"> animationsEnabled
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div> }: {
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div> row: HistoryRow;
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div> minWidth: number;
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div> expanded: boolean;
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div> animationsEnabled: boolean;
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div> }): ReactElement | null {
</dl> const [rendered, setRendered] = useState(expanded);
</div> const [height, setHeight] = useState<number | null>(null);
</div> const disclosureRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLDivElement>(null);
const previousExpanded = useRef(expanded);
useRendererLayoutEffect(() => {
const wasExpanded = previousExpanded.current;
previousExpanded.current = expanded;
let moveTimer: ReturnType<typeof setTimeout> | null = null;
let settleTimer: ReturnType<typeof setTimeout> | 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 (
<div
aria-hidden={!expanded}
className={`history-detail-disclosure ${expanded ? "is-expanded" : "is-collapsed"}${animationsEnabled ? "" : " is-history-motion-disabled"}`}
ref={disclosureRef}
style={{ height: animatedHeight === null ? undefined : `${animatedHeight}px`, minWidth }}
>
<div className="history-detail-clip" ref={contentRef}>
<div className="history-detail-row" ref={detailRef} role="row">
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
</div>
</div>
</div>
</div>
); );
} }
@@ -356,7 +433,12 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
></button> ></button>
</span> </span>
</div> </div>
{isExpanded ? <HistoryRowDetails minWidth={minWidth} row={row} /> : null} <HistoryRowDetails
animationsEnabled={model.animationsEnabled}
expanded={isExpanded}
minWidth={minWidth}
row={row}
/>
</div> </div>
); );
}) })
+5 -2
View File
@@ -36,6 +36,7 @@ export interface HistoryViewModel {
loading: boolean; loading: boolean;
error: string; error: string;
totalCount: number; totalCount: number;
animationsEnabled: boolean;
} }
export interface HistoryPage { export interface HistoryPage {
@@ -289,7 +290,8 @@ export function buildHistoryViewModel(
expandedIds: Iterable<string>, expandedIds: Iterable<string>,
loading: boolean, loading: boolean,
error: string, error: string,
now = Date.now() now = Date.now(),
animationsEnabled = true
): HistoryViewModel { ): HistoryViewModel {
const rows = filterHistoryRows(entries, filter, query, now); const rows = filterHistoryRows(entries, filter, query, now);
const visibleIds = new Set(rows.map((row) => row.id)); const visibleIds = new Set(rows.map((row) => row.id));
@@ -302,7 +304,8 @@ export function buildHistoryViewModel(
counts: countHistoryFilters(entries, now), counts: countHistoryFilters(entries, now),
loading, loading,
error, error,
totalCount: entries.length totalCount: entries.length,
animationsEnabled
}; };
} }
+64 -34
View File
@@ -177,14 +177,12 @@
} }
.history-table-header-row > span:first-child, .history-table-header-row > span:first-child,
.history-table-header-row > span:last-child, .history-row > span:first-child {
.history-row > span:first-child,
.history-row > span:last-child {
text-align: center; text-align: center;
} }
.history-table-header-row > span:nth-child(4), .history-table-header-row > span:last-child,
.history-row > span:nth-child(4) { .history-row > span:last-child {
text-align: right; text-align: right;
} }
@@ -210,8 +208,10 @@
position: relative; position: relative;
} }
.history-table-header-row > .history-resizable-header:nth-child(4) { .history-table-header-row > span:last-child {
justify-content: flex-end; align-items: center;
display: grid;
justify-items: end;
} }
.history-column-resizer { .history-column-resizer {
@@ -358,32 +358,56 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.history-row-action { .history-row-action {
display: grid; display: grid;
place-items: center; 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 { .history-detail-row {
border-bottom: 1px solid var(--ui-border); background: color-mix(in srgb, var(--ui-input) 76%, var(--ui-panel));
background: var(--ui-input); 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: 14px 48px; .history-detail-cell {
} padding: 12px 48px 16px;
}
.history-details-grid {
display: grid; .history-details-grid {
gap: 12px 24px; display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px 12px;
margin: 0; grid-template-columns: repeat(4, minmax(120px, 1fr));
} margin: 0;
}
.history-details-grid > div {
display: grid; .history-details-grid > div {
gap: 4px; background: color-mix(in srgb, var(--ui-panel) 72%, transparent);
min-width: 0; 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 { .history-details-grid dt {
color: var(--ui-text-muted); color: var(--ui-text-muted);
@@ -461,7 +485,7 @@
display: none; display: none;
} }
@media (max-width: 1366px) { @media (max-width: 1366px) {
.history-workspace-view { .history-workspace-view {
grid-template-columns: 56px minmax(0, 1fr); grid-template-columns: 56px minmax(0, 1fr);
} }
@@ -473,5 +497,11 @@
.history-action { .history-action {
padding: 0 9px; padding: 0 9px;
} }
}
@media (prefers-reduced-motion: reduce) {
.history-detail-disclosure:not(.is-history-motion-disabled) {
transition-duration: 520ms, 260ms !important;
}
} }
+25 -9
View File
@@ -432,8 +432,9 @@ describe("HistoryView", () => {
expect(actionCell.props.children.props.children).toBe("⋮"); 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-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,\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).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-row-action\s*\{[^}]*place-items:\s*center;/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", () => { 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(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
} }
expect(html.match(/data-history-row-id=/g)).toHaveLength(2); expect(html.match(/data-history-row-id=/g)).toHaveLength(2);
expect(html).not.toContain("history-detail-row"); expect(html).not.toContain("history-detail-row");
expect(html).toContain("12 von 2"); expect(html).toContain("12 von 2");
}); });
it("dispatches selection, expansion, select-all and context coordinates with exact visible ids", () => { 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-detail-row");
expect(html).toContain("history-copyable"); expect(html).toContain("history-detail-disclosure is-expanded");
expect(html).toContain("C:\\Downloads\\Heute Paket"); expect(html).toContain('aria-hidden="false"');
expect(html).toContain("https://rapidgator.net/file/test"); 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(<HistoryView actions={createActions()} model={animated} />);
const immediateHtml = renderToStaticMarkup(<HistoryView actions={createActions()} model={immediate} />);
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", () => { describe("visual history states", () => {