Skip to content
Source
Sign inSign up
Open History to see the last commit

Sidebar.motion.ts

252 lines8.8 KB

1const motionTiming = { duration: 150, easing: "ease-out" };
2const rowTravel = (height: number) => Math.min(height, 40);
3// Fades clone removed rows and animate clones and entries; intervening layout reads force reflows.
4const MAX_FADED_ROWS_PER_UPDATE = 40;
5​
6type RowPosition = { top: number; left: number; width: number; height: number };
7​
8function progress(animation: Animation) {
9 return animation.playState === "finished"
10 ? 1
11 : (animation.effect?.getComputedTiming().progress ?? 0);
13​
14/** Position the list so all direct children's offsetTop share an origin. */
15export function createSidebarListMotion(parent: HTMLUListElement) {
16 let positions: Map<HTMLElement, RowPosition> | null = null;
17 let disposed = false;
18 const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
19 "(prefers-reduced-motion: reduce)",
20 );
21 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
22 const entering = new Map<HTMLElement, { animation: Animation; travel: number }>();
23 const exiting = new Map<HTMLElement, Animation>();
24 let released: Map<HTMLElement, number> | null = null;
25​
26 const remainingOffset = (node: HTMLElement) => {
27 const current = running.get(node);
28 const run = current ? current.offset * (1 - progress(current.animation)) : 0;
29 const entry = entering.get(node);
30 const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0;
31 return run + enter;
32 };
33 const clearFades = () => {
34 for (const entry of entering.values()) entry.animation.cancel();
35 for (const animation of exiting.values()) animation.cancel();
36 for (const node of exiting.keys()) node.remove();
37 entering.clear();
38 exiting.clear();
39 };
40 const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => {
41 if (position.height === 0) return;
42 const clone = node.cloneNode(true) as HTMLElement;
43 for (const element of [clone, ...clone.querySelectorAll("*")]) {
44 for (const attribute of Array.from(element.attributes)) {
45 if (
46 (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
47 attribute.name === "data-thread-item" ||
48 attribute.name === "data-thread-selection-safe" ||
49 attribute.name === "data-testid"
50 ) {
51 element.removeAttribute(attribute.name);
52 }
53 }
54 }
55 clone.setAttribute("aria-hidden", "true");
56 clone.inert = true;
57 Object.assign(clone.style, {
58 position: "absolute",
59 top: `${position.top + remainingOffset(node)}px`,
60 left: `${position.left}px`,
61 width: `${position.width}px`,
62 height: `${position.height}px`,
63 margin: "0",
64 boxSizing: "border-box",
65 contentVisibility: "visible",
66 transform: "none",
67 transition: "none",
68 pointerEvents: "none",
69 });
70 parent.append(clone);
71 const entry = entering.get(node);
72 const entryProgress = entry ? progress(entry.animation) : 1;
73 const animation = clone.animate(
74 [
75 { opacity: entryProgress, transform: "translateY(0px)" },
76 { opacity: 0, transform: `translateY(${travel}px)` },
77 ],
78 motionTiming,
79 );
80 exiting.set(clone, animation);
81 animation.addEventListener(
82 "finish",
83 () => {
84 clone.remove();
85 exiting.delete(clone);
86 },
87 { once: true },
88 );
89 };
90​
91 const cancel = (node: HTMLElement) => {
92 running.get(node)?.animation.cancel();
93 running.delete(node);
94 };
95 const suspend = () => {
96 for (const node of running.keys()) cancel(node);
97 clearFades();
98 positions = null;
99 released = null;
100 };
101 const move = (node: HTMLElement, offset: number) => {
102 cancel(node);
103 const entry = entering.get(node);
104 if (entry) entry.travel = 0;
105 if (offset === 0 && !entry) return;
106 const animation = node.animate(
107 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
108 motionTiming,
109 );
110 running.set(node, { animation, offset });
111 animation.addEventListener(
112 "finish",
113 () => {
114 if (running.get(node)?.animation === animation) running.delete(node);
115 },
116 { once: true },
117 );
118 };
119​
120 return {
121 update(animate: boolean) {
122 if (disposed) return;
123 // A hidden list (the closed narrow sheet stays mounted) reports zero
124 // offsets, which would make the first pass after it opens animate every row.
125 if (parent.getClientRects().length === 0) {
126 suspend();
127 return;
128 }
129 const next = new Map(
130 Array.from(parent.children)
131 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
132 .map((node) => [
133 node,
134 {
135 top: node.offsetTop,
136 left: node.offsetLeft,
137 width: node.offsetWidth,
138 height: node.offsetHeight,
139 },
140 ]),
141 );
142 let fadeCount = 0;
143 if (positions !== null) {
144 for (const [node, position] of positions) {
145 if (!next.has(node) && position.height > 0) fadeCount++;
146 }
147 for (const [node, position] of next) {
148 if (!positions.has(node) && position.height > 0) fadeCount++;
149 }
150 }
151 const shouldAnimate =
152 animate &&
153 positions !== null &&
154 !reducedMotion?.matches &&
155 fadeCount <= MAX_FADED_ROWS_PER_UPDATE;
156 const movedDelta = new Map<HTMLElement, number>();
157 const nextOrder = [...next.keys()];
158 const oldOrder = positions === null ? [] : [...positions.keys()];
159 const ridingDelta = (
160 order: readonly HTMLElement[],
161 index: number,
162 retained: (node: HTMLElement) => boolean,
163 ) => {
164 for (let cursor = index - 1; cursor >= 0; cursor--) {
165 const node = order[cursor]!;
166 const delta = movedDelta.get(node);
167 if (delta !== undefined) return delta;
168 if (retained(node)) return remainingOffset(node);
169 }
170 return undefined;
171 };
172 if (!shouldAnimate) clearFades();
173 else {
174 for (const [node, position] of next) {
175 const previousTop = positions!.get(node)?.top;
176 if (previousTop === undefined || previousTop === position.top) continue;
177 movedDelta.set(node, previousTop + remainingOffset(node) - position.top);
178 }
179 for (const [node, position] of positions!) {
180 if (next.has(node)) continue;
181 const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n));
182 fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta);
183 }
184 }
185 for (const [node, entry] of entering) {
186 if (!next.has(node)) {
187 entry.animation.cancel();
188 entering.delete(node);
189 }
190 }
191 for (const node of running.keys()) {
192 if (!shouldAnimate || !next.has(node)) cancel(node);
193 }
194 if (shouldAnimate) {
195 for (const [index, node] of nextOrder.entries()) {
196 const position = next.get(node)!;
197 const previousTop = positions!.get(node)?.top;
198 if (previousTop === undefined) {
199 if (position.height > 0) {
200 const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n));
201 const travel = delta === undefined ? -rowTravel(position.height) : delta;
202 const animation = node.animate(
203 [
204 { opacity: 0, transform: `translateY(${travel}px)` },
205 { opacity: 1, transform: "translateY(0px)" },
206 ],
207 motionTiming,
208 );
209 entering.set(node, { animation, travel });
210 animation.addEventListener(
211 "finish",
212 () => {
213 if (entering.get(node)?.animation === animation) entering.delete(node);
214 },
215 { once: true },
216 );
217 }
218 continue;
219 }
220 const delta = movedDelta.get(node);
221 // Computed progress includes easing; carry forward only our translation, never dnd-kit's
222 // transforms.
223 if (delta !== undefined) move(node, delta);
224 }
225 }
226 if (released !== null) {
227 if (!reducedMotion?.matches) {
228 for (const [node, position] of next) {
229 const top = released.get(node);
230 if (top !== undefined) move(node, top - position.top);
231 }
232 }
233 released = null;
234 }
235 positions = next;
236 },
237 release() {
238 suspend();
239 const origin = parent.getBoundingClientRect().top;
240 released = new Map(
241 Array.from(parent.children)
242 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
243 .map((node) => [node, node.getBoundingClientRect().top - origin]),
244 );
245 },
246 suspend,
247 dispose() {
248 suspend();
249 disposed = true;
250 },
251 };