4.5 KB145 lines
Blame
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
8import deepEqual from 'fast-deep-equal';
9import React, {useLayoutEffect, useRef} from 'react';
10import {prefersReducedMotion} from './mediaQuery';
11
12type ReorderGroupProps = React.HTMLAttributes<HTMLDivElement> & {
13 children: React.ReactElement[];
14 animationDuration?: number;
15 animationMinPixel?: number;
16};
17
18type 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
27const 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 */
42export 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
81function 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
90function 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