| 1 | const 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. |
| 5 | const 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. |
| 11 | const MAX_FADED_ROWS_PER_UPDATE = 40; |
| 12 | |
| 13 | type RowPosition = { top: number; left: number; width: number; height: number }; |
| 14 | |
| 15 | function 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. */ |
| 23 | export 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 | } |