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:
@@ -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),
|
||||||
|
|||||||
@@ -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,9 +105,80 @@ function syncHistoryTableScroll(event: UIEvent<HTMLDivElement>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: number }): ReactElement {
|
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<number | null>(null);
|
||||||
|
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 (
|
return (
|
||||||
<div className="history-detail-row" role="row" style={{ minWidth }}>
|
<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">
|
<div className="history-detail-cell" role="cell">
|
||||||
<dl className="history-details-grid">
|
<dl className="history-details-grid">
|
||||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||||
@@ -115,6 +190,8 @@ function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: numbe
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -360,29 +360,53 @@
|
|||||||
|
|
||||||
.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 {
|
||||||
|
background: color-mix(in srgb, var(--ui-input) 76%, var(--ui-panel));
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: var(--ui-input);
|
border-top: 1px solid color-mix(in srgb, var(--ui-accent) 28%, var(--ui-border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-detail-cell {
|
.history-detail-cell {
|
||||||
padding: 14px 48px;
|
padding: 12px 48px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-details-grid {
|
.history-details-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px 24px;
|
gap: 10px 12px;
|
||||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-details-grid > div {
|
.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;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 9px 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-details-grid dt {
|
.history-details-grid dt {
|
||||||
@@ -475,3 +499,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.history-detail-disclosure:not(.is-history-motion-disabled) {
|
||||||
|
transition-duration: 520ms, 260ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -567,10 +568,25 @@ describe("HistoryView", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("history-detail-row");
|
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("history-copyable");
|
||||||
expect(html).toContain("C:\\Downloads\\Heute Paket");
|
expect(html).toContain("C:\\Downloads\\Heute Paket");
|
||||||
expect(html).toContain("https://rapidgator.net/file/test");
|
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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user