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