1const motionTiming = { duration: 150, easing: "ease-out" };
2// Rows normally ride their displaced neighbour's travel. Absent a moving
3// neighbour, a row still travels on its own, clamped so a tall card does not
4// slide its full height.
5const rowTravel = (height: number) => Math.min(height, 40);
6// A project filter change or a bulk snooze swaps a large part of the list at
7// once. Fades are the expensive part: every removed row gets a deep clone and
8// every clone and entering row gets its own animation, and the layout reads
9// in between force synchronous reflows. Translating displaced rows is cheap,
10// so only the fade count decides whether an update animates.
11const MAX_FADED_ROWS_PER_UPDATE = 40;
12
13type RowPosition = { top: number; left: number; width: number; height: number };
14
15function progress(animation: Animation) {
16 return animation.playState === "finished"
17 ? 1
18 : (animation.effect?.getComputedTiming().progress ?? 0);
19}
20
21/** Animate rows between their layout positions. The list must be
22 * positioned so every direct child's offsetTop has the same origin. */
23export function createSidebarListMotion(parent: HTMLUListElement) {
24 let positions: Map<HTMLElement, RowPosition> | null = null;
25 let disposed = false;
26 const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
27 "(prefers-reduced-motion: reduce)",
28 );
29 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
30 const entering = new Map<HTMLElement, { animation: Animation; travel: number }>();
31 const exiting = new Map<HTMLElement, Animation>();
32 // Visual tops at drag release, relative to the list, so the release
33 // commit can glide every row from where dnd-kit left it into its slot.
34 let released: Map<HTMLElement, number> | null = null;
35
36 const remainingOffset = (node: HTMLElement) => {
37 const current = running.get(node);
38 const run = current ? current.offset * (1 - progress(current.animation)) : 0;
39 const entry = entering.get(node);
40 const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0;
41 return run + enter;
42 };
43 const clearFades = () => {
44 for (const entry of entering.values()) entry.animation.cancel();
45 for (const animation of exiting.values()) animation.cancel();
46 for (const node of exiting.keys()) node.remove();
47 entering.clear();
48 exiting.clear();
49 };
50 const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => {
51 if (position.height === 0) return;
52 // React owns the removed row; only a noninteractive copy stays for the fade.
53 const clone = node.cloneNode(true) as HTMLElement;
54 for (const element of [clone, ...clone.querySelectorAll("*")]) {
55 for (const attribute of Array.from(element.attributes)) {
56 if (
57 (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
58 attribute.name === "data-thread-item" ||
59 attribute.name === "data-thread-selection-safe" ||
60 attribute.name === "data-testid"
61 ) {
62 element.removeAttribute(attribute.name);
63 }
64 }
65 }
66 clone.setAttribute("aria-hidden", "true");
67 clone.inert = true;
68 Object.assign(clone.style, {
69 position: "absolute",
70 top: `${position.top + remainingOffset(node)}px`,
71 left: `${position.left}px`,
72 width: `${position.width}px`,
73 height: `${position.height}px`,
74 margin: "0",
75 boxSizing: "border-box",
76 contentVisibility: "visible",
77 transform: "none",
78 transition: "none",
79 pointerEvents: "none",
80 });
81 parent.append(clone);
82 const entry = entering.get(node);
83 const entryProgress = entry ? progress(entry.animation) : 1;
84 const animation = clone.animate(
85 [
86 { opacity: entryProgress, transform: "translateY(0px)" },
87 { opacity: 0, transform: `translateY(${travel}px)` },
88 ],
89 motionTiming,
90 );
91 exiting.set(clone, animation);
92 animation.addEventListener(
93 "finish",
94 () => {
95 clone.remove();
96 exiting.delete(clone);
97 },
98 { once: true },
99 );
100 };
101
102 const cancel = (node: HTMLElement) => {
103 running.get(node)?.animation.cancel();
104 running.delete(node);
105 };
106 const suspend = () => {
107 for (const node of running.keys()) cancel(node);
108 clearFades();
109 positions = null;
110 released = null;
111 };
112 const move = (node: HTMLElement, offset: number) => {
113 cancel(node);
114 const entry = entering.get(node);
115 // The newer transform supersedes entry travel; its original opacity keeps fading.
116 if (entry) entry.travel = 0;
117 if (offset === 0 && !entry) return;
118 const animation = node.animate(
119 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
120 motionTiming,
121 );
122 running.set(node, { animation, offset });
123 animation.addEventListener(
124 "finish",
125 () => {
126 if (running.get(node)?.animation === animation) running.delete(node);
127 },
128 { once: true },
129 );
130 };
131
132 return {
133 update(animate: boolean) {
134 if (disposed) return;
135 const next = new Map(
136 Array.from(parent.children)
137 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
138 .map((node) => [
139 node,
140 {
141 top: node.offsetTop,
142 left: node.offsetLeft,
143 width: node.offsetWidth,
144 height: node.offsetHeight,
145 },
146 ]),
147 );
148 let fadeCount = 0;
149 if (positions !== null) {
150 for (const [node, position] of positions) {
151 if (!next.has(node) && position.height > 0) fadeCount++;
152 }
153 for (const [node, position] of next) {
154 if (!positions.has(node) && position.height > 0) fadeCount++;
155 }
156 }
157 const shouldAnimate =
158 animate &&
159 positions !== null &&
160 !reducedMotion?.matches &&
161 fadeCount <= MAX_FADED_ROWS_PER_UPDATE;
162 const movedDelta = new Map<HTMLElement, number>();
163 const nextOrder = [...next.keys()];
164 const oldOrder = positions === null ? [] : [...positions.keys()];
165 const ridingDelta = (
166 order: readonly HTMLElement[],
167 index: number,
168 retained: (node: HTMLElement) => boolean,
169 ) => {
170 for (let cursor = index - 1; cursor >= 0; cursor--) {
171 const node = order[cursor]!;
172 const delta = movedDelta.get(node);
173 if (delta !== undefined) return delta;
174 if (retained(node)) return remainingOffset(node);
175 }
176 return undefined;
177 };
178 if (!shouldAnimate) clearFades();
179 else {
180 // A shelf that opens above its collapsed anchor shifts every retained
181 // row by the same amount. Entering rows take that same displacement so
182 // the shelf arrives as one moving block instead of rows popping into
183 // their final slots; exiting rows leave by it.
184 for (const [node, position] of next) {
185 const previousTop = positions!.get(node)?.top;
186 if (previousTop === undefined || previousTop === position.top) continue;
187 movedDelta.set(node, previousTop + remainingOffset(node) - position.top);
188 }
189 for (const [node, position] of positions!) {
190 if (next.has(node)) continue;
191 const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n));
192 fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta);
193 }
194 }
195 for (const [node, entry] of entering) {
196 if (!next.has(node)) {
197 entry.animation.cancel();
198 entering.delete(node);
199 }
200 }
201 for (const node of running.keys()) {
202 if (!shouldAnimate || !next.has(node)) cancel(node);
203 }
204 if (shouldAnimate) {
205 for (const [index, node] of nextOrder.entries()) {
206 const position = next.get(node)!;
207 const previousTop = positions!.get(node)?.top;
208 if (previousTop === undefined) {
209 if (position.height > 0) {
210 const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n));
211 const travel = delta === undefined ? -rowTravel(position.height) : delta;
212 const animation = node.animate(
213 [
214 { opacity: 0, transform: `translateY(${travel}px)` },
215 { opacity: 1, transform: "translateY(0px)" },
216 ],
217 motionTiming,
218 );
219 entering.set(node, { animation, travel });
220 animation.addEventListener(
221 "finish",
222 () => {
223 if (entering.get(node)?.animation === animation) entering.delete(node);
224 },
225 { once: true },
226 );
227 }
228 continue;
229 }
230 const delta = movedDelta.get(node);
231 // Computed progress includes the effect's easing. Only our own
232 // translate is carried forward; dnd-kit's transforms are never read.
233 if (delta !== undefined) move(node, delta);
234 }
235 }
236 if (released !== null) {
237 if (!reducedMotion?.matches) {
238 for (const [node, position] of next) {
239 const top = released.get(node);
240 if (top !== undefined) move(node, top - position.top);
241 }
242 }
243 released = null;
244 }
245 positions = next;
246 },
247 /** Called on drag release, before the commit that clears dnd-kit's
248 * transforms. Takes every row's visual top, including the lifted row
249 * under the pointer and the peers holding the label gaps open, so the
250 * next update glides each of them into its committed slot. */
251 release() {
252 suspend();
253 const origin = parent.getBoundingClientRect().top;
254 released = new Map(
255 Array.from(parent.children)
256 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
257 .map((node) => [node, node.getBoundingClientRect().top - origin]),
258 );
259 },
260 suspend,
261 dispose() {
262 suspend();
263 disposed = true;
264 },
265 };
266}