const motionTiming = { duration: 150, easing: "ease-out" }; // A project filter change or a bulk snooze swaps a large part of the list at // once. Fades are the expensive part: every removed row gets a deep clone and // every clone and entering row gets its own animation, and the layout reads // in between force synchronous reflows. Translating displaced rows is cheap, // so only the fade count decides whether an update animates. const MAX_FADED_ROWS_PER_UPDATE = 40; type RowPosition = { top: number; left: number; width: number; height: number }; function progress(animation: Animation) { return animation.playState === "finished" ? 1 : (animation.effect?.getComputedTiming().progress ?? 0); } /** Animate rows between their layout positions. The list must be * positioned so every direct child's offsetTop has the same origin. */ export function createSidebarListMotion(parent: HTMLUListElement) { let positions: Map | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( "(prefers-reduced-motion: reduce)", ); const running = new Map(); const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. let released: Map | null = null; const remainingOffset = (node: HTMLElement) => { const current = running.get(node); return current ? current.offset * (1 - progress(current.animation)) : 0; }; const clearFades = () => { for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); for (const node of exiting.keys()) node.remove(); entering.clear(); exiting.clear(); }; const fadeOut = (node: HTMLElement, position: RowPosition) => { if (position.height === 0) return; // React owns the removed row; only a noninteractive copy stays for the fade. const clone = node.cloneNode(true) as HTMLElement; for (const element of [clone, ...clone.querySelectorAll("*")]) { for (const attribute of Array.from(element.attributes)) { if ( (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") || attribute.name === "data-thread-item" || attribute.name === "data-thread-selection-safe" || attribute.name === "data-testid" ) { element.removeAttribute(attribute.name); } } } clone.setAttribute("aria-hidden", "true"); clone.inert = true; Object.assign(clone.style, { position: "absolute", top: `${position.top + remainingOffset(node)}px`, left: `${position.left}px`, width: `${position.width}px`, height: `${position.height}px`, margin: "0", boxSizing: "border-box", contentVisibility: "visible", transform: "none", transition: "none", pointerEvents: "none", }); parent.append(clone); const entry = entering.get(node); const animation = clone.animate( [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], motionTiming, ); exiting.set(clone, animation); animation.addEventListener( "finish", () => { clone.remove(); exiting.delete(clone); }, { once: true }, ); }; const cancel = (node: HTMLElement) => { running.get(node)?.animation.cancel(); running.delete(node); }; const suspend = () => { for (const node of running.keys()) cancel(node); clearFades(); positions = null; released = null; }; const move = (node: HTMLElement, offset: number) => { cancel(node); if (offset === 0) return; const animation = node.animate( [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], motionTiming, ); running.set(node, { animation, offset }); animation.addEventListener( "finish", () => { if (running.get(node)?.animation === animation) running.delete(node); }, { once: true }, ); }; return { update(animate: boolean) { if (disposed) return; const next = new Map( Array.from(parent.children) .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) .map((node) => [ node, { top: node.offsetTop, left: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight, }, ]), ); let fadeCount = 0; if (positions !== null) { for (const [node, position] of positions) { if (!next.has(node) && position.height > 0) fadeCount++; } for (const [node, position] of next) { if (!positions.has(node) && position.height > 0) fadeCount++; } } const shouldAnimate = animate && positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; if (!shouldAnimate) clearFades(); else { for (const [node, position] of positions!) { if (!next.has(node)) fadeOut(node, position); } } for (const [node, animation] of entering) { if (!next.has(node)) { animation.cancel(); entering.delete(node); } } for (const node of running.keys()) { if (!shouldAnimate || !next.has(node)) cancel(node); } if (shouldAnimate) { for (const [node, position] of next) { const previousTop = positions?.get(node)?.top; if (previousTop === undefined) { if (position.height > 0) { const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); entering.set(node, animation); animation.addEventListener( "finish", () => { if (entering.get(node) === animation) entering.delete(node); }, { once: true }, ); } continue; } if (previousTop === position.top) continue; // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. move(node, previousTop + remainingOffset(node) - position.top); } } if (released !== null) { if (!reducedMotion?.matches) { for (const [node, position] of next) { const top = released.get(node); if (top !== undefined) move(node, top - position.top); } } released = null; } positions = next; }, /** Called on drag release, before the commit that clears dnd-kit's * transforms. Takes every row's visual top, including the lifted row * under the pointer and the peers holding the label gaps open, so the * next update glides each of them into its committed slot. */ release() { suspend(); const origin = parent.getBoundingClientRect().top; released = new Map( Array.from(parent.children) .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) .map((node) => [node, node.getBoundingClientRect().top - origin]), ); }, suspend, dispose() { suspend(); disposed = true; }, }; }