From 3ceadd55b17926ad52cc15f3ddd63f039d1aa4d9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 26 Aug 2026 14:56:40 +0200 Subject: [PATCH] Virtualize collector disclosure transitions Render only the visible collector package window, pin a bounded transition cohort and the focused package, and preserve smooth reversible 300 ms disclosure animations under reduced-motion Windows sessions when app animations are enabled. Add logical Tab navigation across unmounted controls plus complete ARIA row counts and indices, while removing collapsing file controls from focus and accessibility order immediately. --- src/renderer/ui/DataTable.tsx | 7 +- .../views/collector/CollectorView.tsx | 215 +++++++++++++++++- .../views/collector/collector-disclosure.ts | 42 ++++ src/renderer/views/collector/collector.css | 40 +++- tests/collector-view.test.tsx | 94 +++++++- 5 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 src/renderer/views/collector/collector-disclosure.ts 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", () => {