diff --git a/src/renderer/ui/DataTable.tsx b/src/renderer/ui/DataTable.tsx index 1cb47ff..f286914 100644 --- a/src/renderer/ui/DataTable.tsx +++ b/src/renderer/ui/DataTable.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { forwardRef, type HTMLAttributes, type ReactElement, type ReactNode } from "react"; export interface DataTableProps extends HTMLAttributes { label?: string; @@ -44,18 +44,19 @@ export function DataTableHeader({ className, children, ...props }: DataTableSect ); } -export function DataTableBody({ className, children, ...props }: DataTableSectionProps): ReactElement { +export const DataTableBody = forwardRef(function DataTableBody({ className, children, ...props }, ref): ReactElement { return (
{children}
); -} +}); export function DataTableEmpty({ title, diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 2bfc71a..0939ccb 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -1,9 +1,11 @@ -import { memo, useEffect, useState, type ChangeEvent, type CSSProperties, type ReactElement } from "react"; +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactElement } from "react"; import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format"; import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable"; import { Dialog } from "../../ui/Dialog"; import { SlidingSelection } from "../../ui/SlidingSelection"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; +import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT } from "../downloads/download-virtualizer"; +import { getCollectorDisclosurePinnedIds, getCollectorDisclosureViewportIds, getCollectorFocusPackageId, mergeCollectorPinnedIds, resolveCollectorTransitionPins } from "./collector-disclosure"; import type { CollectorWorkspaceFilter, CollectorWorkspacePackageRow, @@ -11,6 +13,8 @@ import type { } from "./collector-model"; import "./collector.css"; +const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + export interface CollectorViewActions { onFilterChange: (filter: CollectorWorkspaceFilter) => void; onOpenInput: () => void; @@ -85,6 +89,71 @@ export function collectorPackageIntrinsicBlockSize(row: CollectorWorkspacePackag return 46 + (row.collapsed ? 0 : row.links.length * 40); } +export function collectorFileInteractionAttributes( + rowIndexStart: number, + focusIndexStart: number, + linkIndex: number, + collapsed: boolean +): { rowIndex: number | undefined; focusIndex: number | undefined; tabIndex: number | undefined } { + return collapsed + ? { rowIndex: undefined, focusIndex: undefined, tabIndex: -1 } + : { rowIndex: rowIndexStart + linkIndex + 1, focusIndex: focusIndexStart + linkIndex + 2, tabIndex: undefined }; +} + +interface CollectorViewportState { + scrollTop: number; + viewportHeight: number; +} + +interface CollectorVirtualPackage { + focusCount: number; + focusIndexStart: number; + id: string; + height: number; + rowIndex: number; + row: CollectorWorkspacePackageRow; +} + +function useCollectorViewport(bodyRef: React.RefObject): CollectorViewportState { + const [viewport, setViewport] = useState({ + scrollTop: 0, + viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + }); + useEffect(() => { + const body = bodyRef.current; + if (!body) return; + let frame = 0; + const measure = (): void => { + frame = 0; + const next = { + scrollTop: body.scrollTop, + viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + }; + setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); + }; + const schedule = (): void => { + if (frame !== 0) return; + frame = window.requestAnimationFrame(measure); + }; + measure(); + body.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule); + return () => { + body.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame !== 0) window.cancelAnimationFrame(frame); + }; + }, [bodyRef]); + return viewport; +} + +function collectorVirtualPackageStyle(top: number, height: number): CSSProperties { + return { + "--collector-virtual-package-top": `${top}px`, + "--collector-virtual-package-height": `${height}px` + } as CSSProperties; +} + export function toggleAllCollectorPackageIds( packageIds: readonly string[], collapsedPackageIds: ReadonlySet @@ -160,25 +229,39 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE ); } -function CollectorPackageGroup({ row, model, actions, selected }: { +function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: { + focusIndexStart: number; row: CollectorWorkspacePackageRow; model: CollectorWorkspaceViewModel; actions: CollectorViewActions; selected: ReadonlySet; + rowIndexStart: number; }): ReactElement { const allSelected = row.selectedCount === row.totalCount; const partiallySelected = row.selectedCount > 0 && !allSelected; const animateItems = model.animationsEnabled && row.allLinks.length <= 64; const [renderItems, setRenderItems] = useState(!row.collapsed); + const [expanding, setExpanding] = useState(false); + const previousCollapsedRef = useRef(row.collapsed); const packageStyle: CSSProperties = { containIntrinsicBlockSize: `auto ${collectorPackageIntrinsicBlockSize(row)}px`, flex: "0 0 auto" }; useEffect(() => { + const changed = previousCollapsedRef.current !== row.collapsed; + previousCollapsedRef.current = row.collapsed; + if (!changed) return; if (!row.collapsed) { setRenderItems(true); - return; + if (!animateItems) { + setExpanding(false); + return; + } + setExpanding(true); + const timer = window.setTimeout(() => setExpanding(false), 300); + return () => window.clearTimeout(timer); } + setExpanding(false); if (!animateItems) { setRenderItems(false); return; @@ -186,14 +269,16 @@ function CollectorPackageGroup({ row, model, actions, selected }: { const timer = window.setTimeout(() => setRenderItems(false), 300); return () => window.clearTimeout(timer); }, [animateItems, row.collapsed]); + const animateDisclosure = animateItems && (row.collapsed || expanding); return (
-
0 ? " is-selected" : ""}`} role="row"> +
0 ? " is-selected" : ""}`} role="row"> actions.onPackageSelectionChange(row.id, event.target.checked)} ref={(node) => { if (node) node.indeterminate = partiallySelected; }} type="checkbox" @@ -204,6 +289,7 @@ function CollectorPackageGroup({ row, model, actions, selected }: { aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${row.name} ausklappen` : `${row.name} einklappen`} className="collector-collapse-button" + data-collector-focus-index={focusIndexStart + 1} onClick={() => actions.onPackageCollapseChange(row.id)} type="button" >{row.collapsed ? "+" : "−"} @@ -219,14 +305,15 @@ function CollectorPackageGroup({ row, model, actions, selected }: { {formatDateTime(row.addedAt)}
{renderItems ? ( -
+
- {row.links.map((link) => { + {row.links.map((link, linkIndex) => { const hoster = formatHosterLabel(link.hoster); + const interaction = collectorFileInteractionAttributes(rowIndexStart, focusIndexStart, linkIndex, row.collapsed); return ( -
+
- actions.onLinkSelectionChange(link.id, event.target.checked)} type="checkbox" /> + actions.onLinkSelectionChange(link.id, event.target.checked)} tabIndex={interaction.tabIndex} type="checkbox" /> {link.fileName} {link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)} @@ -246,11 +333,80 @@ function CollectorPackageGroup({ row, model, actions, selected }: { export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement { const selected = new Set(model.selectedIds); + const bodyRef = useRef(null); + const viewport = useCollectorViewport(bodyRef); + const virtualPackages = useMemo(() => { + let focusIndex = 0; + let rowIndex = 2; + return model.packages.map((row) => { + const focusCount = 2 + (row.collapsed ? 0 : row.links.length); + const entry = { + focusCount, + focusIndexStart: focusIndex, + id: row.id, + height: collectorPackageIntrinsicBlockSize(row), + rowIndex, + row + }; + focusIndex += focusCount; + rowIndex += 1 + (row.collapsed ? 0 : row.links.length); + return entry; + }); + }, [model.packages]); + const logicalRowCount = 1 + virtualPackages.reduce((count, entry) => count + 1 + (entry.row.collapsed ? 0 : entry.row.links.length), 0); + const logicalFocusCount = virtualPackages.reduce((count, entry) => count + entry.focusCount, 0); + const previousCollapsedRef = useRef(new Map(model.packages.map((row) => [row.id, row.collapsed]))); + const previousVisibleIdsRef = useRef([]); + const transitionTimerRef = useRef(0); + const [transitionPinnedIds, setTransitionPinnedIds] = useState([]); + const [focusedPackageId, setFocusedPackageId] = useState(null); + const [pendingFocusIndex, setPendingFocusIndex] = useState(null); + const freshPinnedIds = getCollectorDisclosurePinnedIds( + previousVisibleIdsRef.current, + previousCollapsedRef.current, + model.packages, + model.animationsEnabled + ); + const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId); + const stateOffset = (model.analyzing ? 38 : 0) + (model.error ? 38 : 0); + const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, { + scrollTop: Math.max(0, viewport.scrollTop - stateOffset), + viewportHeight: viewport.viewportHeight, + overscan: 2, + pinnedIds: effectivePinnedIds + }), [effectivePinnedIds.join("\u0000"), stateOffset, viewport.scrollTop, viewport.viewportHeight, virtualPackages]); + const collapsedById = new Map(model.packages.map((row) => [row.id, row.collapsed])); + const freshPinnedKey = freshPinnedIds.map((id) => `${id}:${collapsedById.get(id) ? 1 : 0}`).join("\u0000"); + useRendererLayoutEffect(() => { + if (freshPinnedIds.length === 0) return; + setTransitionPinnedIds((current) => resolveCollectorTransitionPins(current, freshPinnedIds)); + if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current); + transitionTimerRef.current = window.setTimeout(() => { + transitionTimerRef.current = 0; + setTransitionPinnedIds([]); + }, 320); + }, [freshPinnedKey]); + useRendererLayoutEffect(() => { + previousCollapsedRef.current = new Map(model.packages.map((row) => [row.id, row.collapsed])); + previousVisibleIdsRef.current = getCollectorDisclosureViewportIds(virtualWindow.rows, virtualWindow.startIndex, virtualWindow.endIndex); + }, [model.packages, virtualWindow.rows]); + useRendererLayoutEffect(() => { + if (pendingFocusIndex === null) return; + const target = bodyRef.current?.querySelector(`[data-collector-focus-index="${pendingFocusIndex}"]`); + if (!target) return; + target.focus(); + target.scrollIntoView({ block: "nearest" }); + setPendingFocusIndex(null); + }, [pendingFocusIndex, virtualWindow.rows]); + useEffect(() => () => { + if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current); + }, []); + const spacerStyle = { "--collector-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; return (
- + -
+
Name Größe @@ -260,7 +416,23 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE Hinzugefügt
- + { + if (event.key !== "Tab") return; + const currentIndex = Number((event.target as HTMLElement).dataset.collectorFocusIndex); + if (!Number.isInteger(currentIndex)) return; + const nextIndex = currentIndex + (event.shiftKey ? -1 : 1); + if (nextIndex < 0 || nextIndex >= logicalFocusCount) return; + const packageId = getCollectorFocusPackageId(virtualPackages, nextIndex); + if (!packageId) return; + event.preventDefault(); + setFocusedPackageId(packageId); + setPendingFocusIndex(nextIndex); + }} + ref={bodyRef} + > {model.analyzing ?
Analyse läuft im Hintergrund
: null} {model.error ?
{model.error}
: null} {model.empty ? ( @@ -269,7 +441,26 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : model.analyzing ? "Die ersten Links erscheinen sofort nach dem Import." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."} title={model.query || model.filter !== "all" ? "Keine passenden Links" : model.analyzing ? "Links werden vorbereitet" : "Noch keine Links"} /> - ) : model.packages.map((row) => )} + ) : ( +
+ {virtualWindow.rows.map((entry) => ( +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocusedPackageId((current) => current === entry.id ? null : current); + } + }} + onFocusCapture={() => setFocusedPackageId(entry.id)} + style={collectorVirtualPackageStyle(entry.top, entry.height)} + > + +
+ ))} +
+ )}
diff --git a/src/renderer/views/collector/collector-disclosure.ts b/src/renderer/views/collector/collector-disclosure.ts new file mode 100644 index 0000000..3b244ce --- /dev/null +++ b/src/renderer/views/collector/collector-disclosure.ts @@ -0,0 +1,42 @@ +export interface CollectorDisclosureRow { + id: string; + collapsed: boolean; +} + +export function getCollectorDisclosurePinnedIds( + previousVisibleIds: readonly string[], + previousCollapsed: ReadonlyMap, + nextRows: readonly CollectorDisclosureRow[], + enabled: boolean +): string[] { + if (!enabled || previousVisibleIds.length === 0) return []; + const nextCollapsed = new Map(nextRows.map((row) => [row.id, row.collapsed])); + return previousVisibleIds.filter((id) => previousCollapsed.has(id) + && nextCollapsed.has(id) + && previousCollapsed.get(id) !== nextCollapsed.get(id)); +} + +export function getCollectorDisclosureViewportIds( + rows: readonly { id: string; index: number; pinned?: boolean }[], + startIndex: number, + endIndex: number +): string[] { + return rows.filter((row) => row.index >= startIndex && row.index <= endIndex).map((row) => row.id); +} + +export function mergeCollectorPinnedIds(transitionPinnedIds: readonly string[], focusedPackageId: string | null): string[] { + return [...new Set([...transitionPinnedIds, focusedPackageId].filter((id): id is string => Boolean(id)))]; +} + +export function getCollectorFocusPackageId( + ranges: readonly { id: string; focusIndexStart: number; focusCount: number }[], + focusIndex: number +): string | null { + return ranges.find((range) => focusIndex >= range.focusIndexStart && focusIndex < range.focusIndexStart + range.focusCount)?.id ?? null; +} + +export function resolveCollectorTransitionPins(current: readonly string[], fresh: readonly string[], maximum = 8): string[] { + const freshSet = new Set(fresh); + const merged = [...current.filter((id) => !freshSet.has(id)), ...fresh]; + return merged.slice(-Math.max(1, Math.floor(maximum))); +} diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css index b537bad..94eabbd 100644 --- a/src/renderer/views/collector/collector.css +++ b/src/renderer/views/collector/collector.css @@ -216,7 +216,28 @@ } .collector-package-group { - content-visibility: auto; + height: 100%; +} + +.collector-virtual-spacer { + flex: 0 0 var(--collector-virtual-total-height); + height: var(--collector-virtual-total-height); + min-width: 960px; + position: relative; +} + +.collector-virtual-package { + height: var(--collector-virtual-package-height); + left: 0; + overflow: hidden; + position: absolute; + right: 0; + top: 0; + transform: translateY(var(--collector-virtual-package-top)); +} + +.collector-virtual-spacer.is-motion-enabled .collector-virtual-package { + transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1); } .collector-package-row { @@ -250,10 +271,13 @@ } .collector-package-items-frame.is-animated { - animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1); transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease; } +.collector-package-items-frame.is-expanding { + animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + .collector-package-items-frame.is-collapsed { grid-template-rows: 0fr; opacity: 0; @@ -446,6 +470,18 @@ .collector-background-state span { animation: none; } + + .collector-virtual-spacer.is-motion-enabled .collector-virtual-package { + transition-duration: 300ms, 300ms !important; + } + + .collector-package-items-frame.is-expanding { + animation-duration: 300ms !important; + } + + .collector-package-items-frame.is-animated { + transition-duration: 300ms, 300ms !important; + } } @media (max-width: 1366px) { diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index 07eb7ea..3d2493d 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -18,10 +18,18 @@ import { CollectorSidebar, CollectorToolbar, CollectorView, + collectorFileInteractionAttributes, collectorPackageIntrinsicBlockSize, toggleAllCollectorPackageIds, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView"; +import { + getCollectorDisclosurePinnedIds, + getCollectorDisclosureViewportIds, + getCollectorFocusPackageId, + mergeCollectorPinnedIds, + resolveCollectorTransitionPins +} from "../src/renderer/views/collector/collector-disclosure"; function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void { if (Array.isArray(node)) { @@ -286,6 +294,44 @@ describe("collector workspace model", () => { }); }); +describe("collector disclosure virtualization", () => { + it("pins only previously visible packages whose collapsed state changes", () => { + const previousCollapsed = new Map([ + ["package-1", false], + ["package-2", false], + ["package-3", false] + ]); + const nextRows = [ + { id: "package-1", collapsed: true }, + { id: "package-2", collapsed: true }, + { id: "package-3", collapsed: true } + ]; + + expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, true)).toEqual(["package-1", "package-2"]); + expect(getCollectorDisclosurePinnedIds(["package-1", "package-2"], previousCollapsed, nextRows, false)).toEqual([]); + }); + + it("retains transitioning and keyboard-focused packages across virtual window changes", () => { + expect(getCollectorDisclosureViewportIds([ + { id: "visible", index: 1, pinned: false }, + { id: "transitioning", index: 99, pinned: true } + ], 0, 3)).toEqual(["visible"]); + expect(mergeCollectorPinnedIds(["transitioning"], "focused")).toEqual(["transitioning", "focused"]); + expect(getCollectorFocusPackageId([ + { id: "package-1", focusIndexStart: 0, focusCount: 4 }, + { id: "package-2", focusIndexStart: 4, focusCount: 2 } + ], 4)).toBe("package-2"); + expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 3)).toEqual(["transitioning", "new-visible-1", "new-visible-2"]); + expect(resolveCollectorTransitionPins(["transitioning"], ["new-visible-1", "new-visible-2"], 2)).toEqual(["new-visible-1", "new-visible-2"]); + expect(resolveCollectorTransitionPins([], Array.from({ length: 12 }, (_, index) => `package-${index}`))).toEqual(Array.from({ length: 8 }, (_, index) => `package-${index + 4}`)); + }); + + it("removes collapsed transition files from logical focus and ARIA indexing", () => { + expect(collectorFileInteractionAttributes(2, 0, 0, false)).toEqual({ rowIndex: 3, focusIndex: 2, tabIndex: undefined }); + expect(collectorFileInteractionAttributes(2, 0, 0, true)).toEqual({ rowIndex: undefined, focusIndex: undefined, tabIndex: -1 }); + }); +}); + describe("CollectorView", () => { it("reserves the exact package height while content visibility skips offscreen rows", () => { const expanded = buildCollectorWorkspaceViewModel([packages[0]], "all", "", false, [], [], "", true).packages[0]; @@ -296,6 +342,47 @@ describe("CollectorView", () => { expect(renderToStaticMarkup()).toContain("contain-intrinsic-block-size:auto 126px"); }); + it("keeps expanded child rows bounded to the virtual viewport for large collectors", () => { + const manyPackages: CollectorPackage[] = Array.from({ length: 50 }, (_, index) => ({ + ...packages[0], + id: `package-${index}`, + name: `Paket ${index}`, + links: packages[0].links.map((link, linkIndex) => ({ + ...link, + id: `link-${index}-${linkIndex}`, + url: `https://example.test/${index}/${linkIndex}` + })) + })); + + const html = renderToStaticMarkup(); + const renderedFileRows = html.match(/class="collector-file-row/g)?.length ?? 0; + + expect(html).toContain("collector-virtual-spacer"); + expect(renderedFileRows).toBeGreaterThan(0); + expect(renderedFileRows).toBeLessThanOrEqual(20); + }); + + it("does not replay the disclosure animation when an expanded virtual package mounts", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("collector-package-items-frame"); + expect(html).not.toContain("collector-package-items-frame is-animated"); + }); + + it("exposes logical row counts and positions for virtualized assistive navigation", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-rowcount="4"'); + expect(html).toContain('aria-rowindex="1"'); + expect(html).toContain('aria-rowindex="2"'); + expect(html).toContain('aria-rowindex="3"'); + expect(html).toContain('aria-rowindex="4"'); + expect(html).toContain('data-collector-focus-index="0"'); + expect(html).toContain('data-collector-focus-index="1"'); + expect(html).toContain('data-collector-focus-index="2"'); + expect(html).toContain('data-collector-focus-index="3"'); + }); + it("renders expandable package and file rows with preview columns", () => { const html = renderToStaticMarkup(); @@ -402,11 +489,14 @@ describe("CollectorView", () => { expect(commits).toBe(1); }); - it("uses aligned responsive package grids and content visibility", () => { + it("uses aligned responsive package grids and positioned virtual rows", () => { const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); expect(css).toMatch(/\.collector-table-header-row,\s*\.collector-package-row,\s*\.collector-file-row\s*\{[^}]*grid-template-columns:/s); - expect(css).toMatch(/\.collector-package-group\s*\{[^}]*content-visibility:\s*auto;/s); + expect(css).toMatch(/\.collector-virtual-package\s*\{[^}]*position:\s*absolute;[^}]*transform:\s*translateY/s); expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s); + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s); + expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s); + expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s); }); it("uses one consistent gap across collector toolbar groups", () => {