| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import deepEqual from 'fast-deep-equal'; |
| 9 | import React, {useLayoutEffect, useRef} from 'react'; |
| 10 | import {prefersReducedMotion} from './mediaQuery'; |
| 11 | |
| 12 | type ReorderGroupProps = React.HTMLAttributes<HTMLDivElement> & { |
| 13 | children: React.ReactElement[]; |
| 14 | animationDuration?: number; |
| 15 | animationMinPixel?: number; |
| 16 | }; |
| 17 | |
| 18 | type PreviousState = { |
| 19 | // Ordered list of `data-reorder-id`s. Can ONLY be updated inside `useLayoutEffect`. |
| 20 | // Useful to test if `children` has changed or not. |
| 21 | idList: Array<string>; |
| 22 | |
| 23 | // Locations of the old children, keyed by `data-reorder-id`. |
| 24 | rectMap: Map<string, DOMRect>; |
| 25 | }; |
| 26 | |
| 27 | const emptyPreviousState: Readonly<PreviousState> = { |
| 28 | idList: [], |
| 29 | rectMap: new Map(), |
| 30 | }; |
| 31 | |
| 32 | /** |
| 33 | * AnimatedReorderGroup tracks and animates elements with the `data-reorder-id` attribute. |
| 34 | * Elements with the same `data-reorder-id` will be animated on position change. |
| 35 | * |
| 36 | * Beware that while `data-reorder-id` can be put on nested elements, animation is |
| 37 | * only triggered when the `children` of this component is changed. |
| 38 | * |
| 39 | * This component only handles reordering, if you want drag and drop support or animations |
| 40 | * on inserted or deleted items, you might want to use other components together. |
| 41 | */ |
| 42 | export const AnimatedReorderGroup: React.FC<ReorderGroupProps> = ({ |
| 43 | children, |
| 44 | animationDuration, |
| 45 | animationMinPixel, |
| 46 | ...props |
| 47 | }) => { |
| 48 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 49 | const previousStateRef = useRef<Readonly<PreviousState>>(emptyPreviousState); |
| 50 | const reducedMotion = prefersReducedMotion(); |
| 51 | |
| 52 | useLayoutEffect(() => { |
| 53 | if (reducedMotion) { |
| 54 | return; |
| 55 | } |
| 56 | const animate = true; |
| 57 | updatePreviousState( |
| 58 | containerRef, |
| 59 | previousStateRef, |
| 60 | animate, |
| 61 | animationDuration, |
| 62 | animationMinPixel, |
| 63 | ); |
| 64 | }, [children, animationDuration, animationMinPixel, reducedMotion]); |
| 65 | |
| 66 | // Try to get the rects of old children right before rendering new children |
| 67 | // and calling the LayoutEffect callback. This captures position changes |
| 68 | // since the last useLayoutEffect. The position changes might be caused by |
| 69 | // scrolling or resizing the window. |
| 70 | if (!reducedMotion) { |
| 71 | updatePreviousState(containerRef, previousStateRef, false, animationDuration); |
| 72 | } |
| 73 | |
| 74 | return ( |
| 75 | <div {...props} ref={containerRef}> |
| 76 | {children} |
| 77 | </div> |
| 78 | ); |
| 79 | }; |
| 80 | |
| 81 | function scanElements(containerRef: React.RefObject<HTMLDivElement | null>): HTMLElement[] { |
| 82 | const container = containerRef.current; |
| 83 | if (container == null) { |
| 84 | return []; |
| 85 | } |
| 86 | const elements = container.querySelectorAll<HTMLElement>('[data-reorder-id]'); |
| 87 | return [...elements]; |
| 88 | } |
| 89 | |
| 90 | function updatePreviousState( |
| 91 | containerRef: React.RefObject<HTMLDivElement>, |
| 92 | previousStateRef: React.MutableRefObject<Readonly<PreviousState>>, |
| 93 | animate = false, |
| 94 | animationDuration = 200, |
| 95 | animationMinPixel = 5, |
| 96 | ) { |
| 97 | const elements = scanElements(containerRef); |
| 98 | const idList: Array<string> = []; |
| 99 | const rectMap = new Map<string, DOMRect>(); |
| 100 | const toAnimate: Array<[HTMLElement, number, number]> = []; |
| 101 | elements.forEach(element => { |
| 102 | const reorderId = element.getAttribute('data-reorder-id'); |
| 103 | if (reorderId == null || reorderId === '') { |
| 104 | return; |
| 105 | } |
| 106 | idList.push(reorderId); |
| 107 | const newBox = element.getBoundingClientRect(); |
| 108 | if (animate) { |
| 109 | const oldBox = previousStateRef.current.rectMap.get(reorderId); |
| 110 | if (oldBox && (oldBox.x !== newBox.x || oldBox.y !== newBox.y)) { |
| 111 | // Animate from old to the new (current) rect. |
| 112 | const dx = oldBox.left - newBox.left; |
| 113 | const dy = oldBox.top - newBox.top; |
| 114 | if (Math.abs(dx) + Math.abs(dy) > animationMinPixel) { |
| 115 | toAnimate.push([element, dx, dy]); |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | rectMap.set(reorderId, newBox); |
| 120 | }); |
| 121 | |
| 122 | if (toAnimate.length > 0) { |
| 123 | requestAnimationFrame(() => { |
| 124 | toAnimate.forEach(([element, dx, dy]) => { |
| 125 | element.animate( |
| 126 | [{transform: `translate(${dx}px,${dy}px)`}, {transform: 'translate(0,0)'}], |
| 127 | {duration: animationDuration, easing: 'ease-out'}, |
| 128 | ); |
| 129 | }); |
| 130 | }); |
| 131 | } |
| 132 | |
| 133 | if (!animate && !deepEqual(idList, previousStateRef.current.idList)) { |
| 134 | // If animate is false, we want to get the rects of the old children. |
| 135 | // If the idList mismatches, it's not the "old" children so we discard |
| 136 | // the result. |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | previousStateRef.current = { |
| 141 | idList, |
| 142 | rectMap, |
| 143 | }; |
| 144 | } |
| 145 | |