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<HTMLElement, RowPosition> | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( "(prefers-reduced-motion: reduce)", ); const running = new Map<HTMLElement, { animation: Animation; offset: number }>(); const entering = new Map<HTMLElement, Animation>(); const exiting = new Map<HTMLElement, Animation>(); // 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<HTMLElement, number> | 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; }, };}
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<HTMLElement, RowPosition> | null = null;
let disposed = false;
const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
"(prefers-reduced-motion: reduce)",
);
const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
const entering = new Map<HTMLElement, Animation>();
const exiting = new Map<HTMLElement, Animation>();
// 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<HTMLElement, number> | 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;
},
};
}