addons/isl/src/AnimatedReorderGroup.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import deepEqual from 'fast-deep-equal';
b69ab319import React, {useLayoutEffect, useRef} from 'react';
b69ab3110import {prefersReducedMotion} from './mediaQuery';
b69ab3111
b69ab3112type ReorderGroupProps = React.HTMLAttributes<HTMLDivElement> & {
b69ab3113 children: React.ReactElement[];
b69ab3114 animationDuration?: number;
b69ab3115 animationMinPixel?: number;
b69ab3116};
b69ab3117
b69ab3118type PreviousState = {
b69ab3119 // Ordered list of `data-reorder-id`s. Can ONLY be updated inside `useLayoutEffect`.
b69ab3120 // Useful to test if `children` has changed or not.
b69ab3121 idList: Array<string>;
b69ab3122
b69ab3123 // Locations of the old children, keyed by `data-reorder-id`.
b69ab3124 rectMap: Map<string, DOMRect>;
b69ab3125};
b69ab3126
b69ab3127const emptyPreviousState: Readonly<PreviousState> = {
b69ab3128 idList: [],
b69ab3129 rectMap: new Map(),
b69ab3130};
b69ab3131
b69ab3132/**
b69ab3133 * AnimatedReorderGroup tracks and animates elements with the `data-reorder-id` attribute.
b69ab3134 * Elements with the same `data-reorder-id` will be animated on position change.
b69ab3135 *
b69ab3136 * Beware that while `data-reorder-id` can be put on nested elements, animation is
b69ab3137 * only triggered when the `children` of this component is changed.
b69ab3138 *
b69ab3139 * This component only handles reordering, if you want drag and drop support or animations
b69ab3140 * on inserted or deleted items, you might want to use other components together.
b69ab3141 */
b69ab3142export const AnimatedReorderGroup: React.FC<ReorderGroupProps> = ({
b69ab3143 children,
b69ab3144 animationDuration,
b69ab3145 animationMinPixel,
b69ab3146 ...props
b69ab3147}) => {
b69ab3148 const containerRef = useRef<HTMLDivElement | null>(null);
b69ab3149 const previousStateRef = useRef<Readonly<PreviousState>>(emptyPreviousState);
b69ab3150 const reducedMotion = prefersReducedMotion();
b69ab3151
b69ab3152 useLayoutEffect(() => {
b69ab3153 if (reducedMotion) {
b69ab3154 return;
b69ab3155 }
b69ab3156 const animate = true;
b69ab3157 updatePreviousState(
b69ab3158 containerRef,
b69ab3159 previousStateRef,
b69ab3160 animate,
b69ab3161 animationDuration,
b69ab3162 animationMinPixel,
b69ab3163 );
b69ab3164 }, [children, animationDuration, animationMinPixel, reducedMotion]);
b69ab3165
b69ab3166 // Try to get the rects of old children right before rendering new children
b69ab3167 // and calling the LayoutEffect callback. This captures position changes
b69ab3168 // since the last useLayoutEffect. The position changes might be caused by
b69ab3169 // scrolling or resizing the window.
b69ab3170 if (!reducedMotion) {
b69ab3171 updatePreviousState(containerRef, previousStateRef, false, animationDuration);
b69ab3172 }
b69ab3173
b69ab3174 return (
b69ab3175 <div {...props} ref={containerRef}>
b69ab3176 {children}
b69ab3177 </div>
b69ab3178 );
b69ab3179};
b69ab3180
b69ab3181function scanElements(containerRef: React.RefObject<HTMLDivElement | null>): HTMLElement[] {
b69ab3182 const container = containerRef.current;
b69ab3183 if (container == null) {
b69ab3184 return [];
b69ab3185 }
b69ab3186 const elements = container.querySelectorAll<HTMLElement>('[data-reorder-id]');
b69ab3187 return [...elements];
b69ab3188}
b69ab3189
b69ab3190function updatePreviousState(
b69ab3191 containerRef: React.RefObject<HTMLDivElement>,
b69ab3192 previousStateRef: React.MutableRefObject<Readonly<PreviousState>>,
b69ab3193 animate = false,
b69ab3194 animationDuration = 200,
b69ab3195 animationMinPixel = 5,
b69ab3196) {
b69ab3197 const elements = scanElements(containerRef);
b69ab3198 const idList: Array<string> = [];
b69ab3199 const rectMap = new Map<string, DOMRect>();
b69ab31100 const toAnimate: Array<[HTMLElement, number, number]> = [];
b69ab31101 elements.forEach(element => {
b69ab31102 const reorderId = element.getAttribute('data-reorder-id');
b69ab31103 if (reorderId == null || reorderId === '') {
b69ab31104 return;
b69ab31105 }
b69ab31106 idList.push(reorderId);
b69ab31107 const newBox = element.getBoundingClientRect();
b69ab31108 if (animate) {
b69ab31109 const oldBox = previousStateRef.current.rectMap.get(reorderId);
b69ab31110 if (oldBox && (oldBox.x !== newBox.x || oldBox.y !== newBox.y)) {
b69ab31111 // Animate from old to the new (current) rect.
b69ab31112 const dx = oldBox.left - newBox.left;
b69ab31113 const dy = oldBox.top - newBox.top;
b69ab31114 if (Math.abs(dx) + Math.abs(dy) > animationMinPixel) {
b69ab31115 toAnimate.push([element, dx, dy]);
b69ab31116 }
b69ab31117 }
b69ab31118 }
b69ab31119 rectMap.set(reorderId, newBox);
b69ab31120 });
b69ab31121
b69ab31122 if (toAnimate.length > 0) {
b69ab31123 requestAnimationFrame(() => {
b69ab31124 toAnimate.forEach(([element, dx, dy]) => {
b69ab31125 element.animate(
b69ab31126 [{transform: `translate(${dx}px,${dy}px)`}, {transform: 'translate(0,0)'}],
b69ab31127 {duration: animationDuration, easing: 'ease-out'},
b69ab31128 );
b69ab31129 });
b69ab31130 });
b69ab31131 }
b69ab31132
b69ab31133 if (!animate && !deepEqual(idList, previousStateRef.current.idList)) {
b69ab31134 // If animate is false, we want to get the rects of the old children.
b69ab31135 // If the idList mismatches, it's not the "old" children so we discard
b69ab31136 // the result.
b69ab31137 return;
b69ab31138 }
b69ab31139
b69ab31140 previousStateRef.current = {
b69ab31141 idList,
b69ab31142 rectMap,
b69ab31143 };
b69ab31144}