diff --git a/packages/components/src/transition-group/TransitionGroup.tsx b/packages/components/src/transition-group/TransitionGroup.tsx index 1cd5438f..daa74a0c 100644 --- a/packages/components/src/transition-group/TransitionGroup.tsx +++ b/packages/components/src/transition-group/TransitionGroup.tsx @@ -1,8 +1,8 @@ import type { TransitionGroupProps } from './transition-group.types'; import { useChildMap, useWrapper, useFlips } from './hooks'; import { RequiredPart } from '@tool-pack/types'; -import { useForwardRef } from '@pkg/shared'; -import React from 'react'; +import { forwardRef, memo } from 'react'; +import type { FC } from 'react'; /** * v1 版有部分 bug 不好解决。 @@ -19,23 +19,21 @@ const defaultProps = { tag: 'div', } satisfies TransitionGroupProps; -const TransitionGroup: React.FC = React.forwardRef< +const TransitionGroup: FC = forwardRef< HTMLDivElement, TransitionGroupProps ->((props, _ref) => { +>((props, ref) => { const { children, name, ...rest } = props as RequiredPart< TransitionGroupProps, keyof typeof defaultProps >; - - const ref = useForwardRef(_ref); const childMap = useChildMap(children, name); const wrapper = useWrapper(childMap, rest, ref); - useFlips(ref, childMap, name); + useFlips(childMap, name); return wrapper; }); TransitionGroup.defaultProps = defaultProps; TransitionGroup.displayName = 'TransitionGroup'; -export default React.memo(TransitionGroup); +export default memo(TransitionGroup); diff --git a/packages/components/src/transition-group/demo/tag.tsx b/packages/components/src/transition-group/demo/tag.tsx new file mode 100644 index 00000000..1123d8d2 --- /dev/null +++ b/packages/components/src/transition-group/demo/tag.tsx @@ -0,0 +1,66 @@ +/** + * title: 标签 + * description: 默认标签为 div,可设置 tag 为其它标签,当 tag 为 null 时,移除包裹元素 + */ + +import { + TransitionGroup, + Transition, + Button, + Space, +} from '@tool-pack/react-ui'; +import React, { useCallback, useState, useRef } from 'react'; +import styles from './list.module.scss'; + +const App: React.FC = () => { + const [, update] = useState({}); + const forceUpdate = useCallback(() => update({}), []); + + const children = useRef([...Array.from({ length: 10 }).keys()]); + + const index = useRef(children.current.length); + function addChild() { + const list = children.current; + const splice = list.splice(~~(Math.random() * list.length), list.length); + list.push(index.current); + list.push(...splice); + forceUpdate(); + index.current++; + } + function removeChild(item: number) { + const index = children.current.indexOf(item); + if (index === -1) return; + children.current.splice(index, 1); + forceUpdate(); + } + function removeRandomChild() { + removeChild(children.current[~~(Math.random() * children.current.length)]!); + } + + return ( +
+ + + + +
+
+ + {children.current.map((item) => { + return ( + +
{item}
+
+ ); + })} +
+
+
+ ); +}; + +export default App; diff --git a/packages/components/src/transition-group/hooks/useChildMap.tsx b/packages/components/src/transition-group/hooks/useChildMap.tsx index 539de832..90392300 100644 --- a/packages/components/src/transition-group/hooks/useChildMap.tsx +++ b/packages/components/src/transition-group/hooks/useChildMap.tsx @@ -1,8 +1,16 @@ +import { + isValidElement, + cloneElement, + useEffect, + useState, + Children, +} from 'react'; import { type TransitionProps, transitionCBAdapter } from '@pkg/components'; -import type { ChildMap } from '../transition-group.types'; -import React, { useEffect, useState } from 'react'; -import { useIsInitDep } from '@pkg/shared'; -export function useChildMap(children: React.ReactNode, name: string) { +import type { RefAttributes, ReactElement, ReactNode, Key } from 'react'; +import type { ChildMapValue, ChildMap } from '../transition-group.types'; +import { useIsInitDep, forwardRefs } from '@pkg/shared'; + +export function useChildMap(children: ReactNode, name: string): ChildMap { const isInit = useIsInitDep(children); const [childMap, setChildMap] = useState((): ChildMap => { @@ -16,7 +24,7 @@ export function useChildMap(children: React.ReactNode, name: string) { }); }); - const onLeaved = (key: React.Key) => { + const onLeaved = (key: Key) => { // leave 可能会丢失 setChildMap((prevChildren) => { const map = new Map(prevChildren); @@ -34,66 +42,28 @@ export function useChildMap(children: React.ReactNode, name: string) { } const nextChildMap = ( - children: React.ReactNode, + children: ReactNode, prevChildMap: ChildMap, name: string, - onLeaved: (key: React.Key) => void, + onLeaved: (key: Key) => void, ): ChildMap => { const childMap = createMap(children); - return mergeMaps(prevChildMap, childMap, (child, key): React.ReactNode => { - if (!React.isValidElement(child)) return child; - - const inNext = childMap.get(key) !== undefined; - const inPrev = prevChildMap.get(key) !== undefined; - const inBoth = inNext && inPrev; - - const isAdd = inNext && !inPrev; - const isRemove = !inNext && inPrev; - - if (isRemove) { - if (child.props.show === false) return child; - // 因为 remove 了的 child 是不存在于 next 的,所以这个 child 是旧的,是 clone 过的 - // tips: 加了 on 就不会等待多个 remove 完才 move,而是 remove 一个 move 一个 - return cloneTransition(child, { appear: false, show: false }); - } - - const on = transitionCBAdapter({ - onAfterLeave: () => onLeaved(child.key || ''), - }); - - if (isAdd) { - // 旧的不存在,所以 child 是新创建的,是未 clone 过的 - return cloneTransition(child, { - appear: true, - name: name, - show: true, - on, - }); - } - - if (inBoth) { - // 两者皆有取最新,所以 child 是新创建的,是未 clone 过的 - return cloneTransition(child, { - appear: false, - show: true, - name: name, - on, - }); - } - return child; - }); + return mergeMaps(prevChildMap, childMap, name, onLeaved); }; function createMap( - children: React.ReactNode, - callback: (child: React.ReactElement) => React.ReactNode = (v) => v, + children: ReactNode, + callback: (child: ReactElement) => ChildMapValue = (v) => ({ + reactEl: v, + ref: null, + }), ): ChildMap { const map: ChildMap = new Map(); if (!children) return map; - // 如果没有手动添加key, React.Children.map会自动添加key - React.Children.map(children, (c) => c)?.forEach((child) => { - if (!React.isValidElement(child)) return; + // 如果没有手动添加key, Children.map会自动添加key + Children.map(children, (c) => c)?.forEach((child) => { + if (!isValidElement(child)) return; const key = child.key || ''; if (!key) return; map.set(key, callback(child)); @@ -105,14 +75,14 @@ function createMap( function mergeMaps( prevMap: ChildMap, nextMap: ChildMap, - callback: (child: React.ReactNode, key: React.Key) => React.ReactNode, + name: string, + onLeaved: (key: Key) => void, ): ChildMap { - const getValue = (key: React.Key) => nextMap.get(key) ?? prevMap.get(key); - - let insertKeys: React.Key[] = []; - const insertKeysMap = new Map(); + let insertKeys: Key[] = []; + const insertKeysMap = new Map(); + const result: ChildMap = new Map(); - prevMap.forEach((_, key) => { + prevMap.forEach((_, key): void => { if (nextMap.has(key)) { if (!insertKeys.length) return; insertKeysMap.set(key, insertKeys); @@ -121,10 +91,7 @@ function mergeMaps( } insertKeys.push(key); }); - - const result: ChildMap = new Map(); - const push = (k: React.Key) => result.set(k, callback(getValue(k), k)); - nextMap.forEach((_, key) => { + nextMap.forEach((_, key): void => { const keys = insertKeysMap.get(key); if (keys) keys.forEach(push); push(key); @@ -132,12 +99,87 @@ function mergeMaps( insertKeys.forEach(push); return result; + + function push(k: Key): void { + const value = mergeMapValue( + prevMap.get(k), + nextMap.get(k), + k, + name, + onLeaved, + ); + value && result.set(k, value); + } +} + +function mergeMapValue( + prevValue: ChildMapValue | undefined, + nextValue: ChildMapValue | undefined, + key: Key, + name: string, + onLeaved: (key: Key) => void, +): ChildMapValue | void { + const inNext = nextValue?.reactEl !== undefined; + const inPrev = prevValue?.reactEl !== undefined; + const inBoth = inNext && inPrev; + + const isAdd = inNext && !inPrev; + const isRemove = !inNext && inPrev; + + if (isRemove) { + // noinspection PointlessBooleanExpressionJS + if ( + (prevValue.reactEl as ReactElement).props.show === false + ) + return prevValue; + // 因为 remove 了的 child 是不存在于 next 的,所以这个 child 是旧的,是 clone 过的 + // tips: 加了 on 就不会等待多个 remove 完才 move,而是 remove 一个 move 一个 + return cloneTransition( + prevValue.reactEl, + { appear: false, show: false }, + prevValue.ref, + ); + } + + const on = transitionCBAdapter({ + onAfterLeave: () => onLeaved(key || ''), + }); + + if (isAdd) { + // 旧的不存在,所以 child 是新创建的,是未 clone 过的 + return cloneTransition( + nextValue.reactEl, + { + appear: true, + name: name, + show: true, + on, + }, + prevValue?.ref, + ); + } + + if (inBoth) { + // 两者皆有取最新,所以 child 是新创建的,是未 clone 过的 + return cloneTransition( + nextValue.reactEl, + { + appear: false, + show: true, + name: name, + on, + }, + prevValue.ref, + ); + } } function cloneTransition( - transition: React.ReactElement, + transition: ReactElement, props: Partial, -) { + ref: HTMLElement | null = null, +): ChildMapValue { + const result: ChildMapValue = { reactEl: transition, ref }; const _props = { ...props } as TransitionProps; const nextOn = props.on; if (nextOn) { @@ -147,5 +189,23 @@ function cloneTransition( nextOn(el, status, lifeCircle); }; } - return React.cloneElement(transition, _props); + + result.reactEl = cloneElement( + transition, + _props, + cloneChildren(), + ); + return result; + + function cloneChildren() { + const children = transition.props.children; + return isValidElement(children) + ? cloneElement(children as ReactElement, { + ref: (el: HTMLElement) => { + forwardRefs(el, (children as RefAttributes).ref); + el && (result.ref = el); + }, + }) + : undefined; + } } diff --git a/packages/components/src/transition-group/hooks/useFlips.ts b/packages/components/src/transition-group/hooks/useFlips.ts index 5476a9eb..4a304e28 100644 --- a/packages/components/src/transition-group/hooks/useFlips.ts +++ b/packages/components/src/transition-group/hooks/useFlips.ts @@ -1,27 +1,26 @@ import type { ChildMap } from '../transition-group.types'; -import React, { useLayoutEffect, useRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import { applyTranslation } from '../utils'; +import type { Key } from 'react'; -type RectMap = Map; +type RectMap = Map; -export function useFlips( - wrapperRef: React.MutableRefObject, - childMap: ChildMap, - name: string, -): void { +export function useFlips(childMap: ChildMap, name: string): void { const prevChildMapRef = useRef(childMap); - const prevRects = getChildRects(wrapperRef.current, prevChildMapRef.current); + const prevRects = getChildRects(prevChildMapRef.current); - useLayoutEffect(() => { + useLayoutEffect((): void => { prevChildMapRef.current = childMap; - const wrapperEl = wrapperRef.current; - if (!wrapperEl || !prevRects) return; + if (!prevRects.size) return; + let wrapperEl: HTMLElement | null = null; const moveClass = `${name}-move-active`; - const { children } = wrapperEl; - const nextRects = new Map(); + const nextRects = new Map(); // 获取最新的样式 - forEachEl(children, childMap, (el, key) => { + childMap.forEach(({ ref: el }, key): void => { + if (!el) return; + // ⚠️注意:这里中断了原 transition 动画,如果要修复该问题,可从这里改 + // 但是不加的话,如果之前有 flips 动画会突然中断 el.style.transition = 'none'; nextRects.set(key, el.getBoundingClientRect()); if (!prevRects.has(key)) prevRects.set(key, nextRects.get(key)!); @@ -30,14 +29,16 @@ export function useFlips( const flips: Array<() => void> = []; // 计算之前样式与现在的样式的差,并设置样式 - forEachEl(children, childMap, (el, key) => { + childMap.forEach(({ ref: el }, key): void => { + if (!el) return; + wrapperEl = el.parentElement; // if (hasTransition(el, name)) return; if (!applyTranslation(el, prevRects.get(key)!, nextRects.get(key)!)) return; - flips.push(() => { + flips.push((): void => { const { style: s } = el; el.classList.add(moveClass); - el.addEventListener('transitionend', function cb(e) { + el.addEventListener('transitionend', function cb(e): void { if (e.target !== el || !/transform$/.test(e.propertyName)) return; el.removeEventListener('transitionend', cb); el.classList.remove(moveClass); @@ -46,35 +47,18 @@ export function useFlips( }); }); // 刷新 - void wrapperEl.offsetHeight; + if (wrapperEl !== null) void (wrapperEl as HTMLElement).offsetHeight; // 动画 - flips.forEach((t) => t()); + flips.forEach((f) => f()); }); } -function getChildRects( - wrapperEl: HTMLElement | undefined | null, - childMap: ChildMap, -): RectMap | void { - if (!wrapperEl) return; +function getChildRects(childMap: ChildMap): RectMap { const rects: RectMap = new Map(); - forEachEl(wrapperEl.children, childMap, (el, key) => - rects.set(key, el.getBoundingClientRect()), - ); - return rects; -} - -function forEachEl( - els: NodeListOf | HTMLCollection | HTMLElement[], - childMap: ChildMap, - cb: (el: HTMLElement, key: React.Key) => void, -) { - let i = 0; - childMap.forEach((_, key) => { - const el = els[i++]; - if (!el) return; - cb(el as HTMLElement, key); + childMap.forEach(({ ref: el }, key): void => { + el && rects.set(key, el.getBoundingClientRect()); }); + return rects; } // function hasTransition(el: HTMLElement, name: string): boolean { diff --git a/packages/components/src/transition-group/hooks/useWrapper.tsx b/packages/components/src/transition-group/hooks/useWrapper.tsx index a2a79f24..a8222d9a 100644 --- a/packages/components/src/transition-group/hooks/useWrapper.tsx +++ b/packages/components/src/transition-group/hooks/useWrapper.tsx @@ -1,15 +1,16 @@ import type { TransitionGroupProps, ChildMap } from '../transition-group.types'; +import type { ReactElement, ForwardedRef, ReactNode } from 'react'; import { getClassNames } from '@tool-pack/basic'; import { RequiredPart } from '@tool-pack/types'; import { getComponentClass } from '@pkg/shared'; -import React from 'react'; +import { createElement } from 'react'; const rootClass = getComponentClass('transition-group'); export function useWrapper( childMap: ChildMap, props: TransitionGroupProps, - ref: React.ForwardedRef, -) { + ref: ForwardedRef, +): ReactNode { const { attrs = {}, className, @@ -17,7 +18,8 @@ export function useWrapper( } = props as RequiredPart; const children = getMapValues(childMap); - const WrapChildNode = React.createElement( + if (tag === null) return children; + return createElement( tag, { ...attrs, @@ -26,14 +28,10 @@ export function useWrapper( }, children, ); - - return <>{WrapChildNode}; } -function getMapValues>( - map: T, -): T extends Map ? V[] : unknown[] { - const result: unknown[] = []; - map.forEach((v) => result.push(v)); - return result as any; +function getMapValues(map: ChildMap): ReactElement[] { + const result: ReactElement[] = []; + map.forEach((v) => result.push(v.reactEl)); + return result; } diff --git a/packages/components/src/transition-group/index.zh-CN.md b/packages/components/src/transition-group/index.zh-CN.md index f414b272..6bda1959 100644 --- a/packages/components/src/transition-group/index.zh-CN.md +++ b/packages/components/src/transition-group/index.zh-CN.md @@ -29,18 +29,19 @@ TransitionGroup 比 Transition 多一个 `${name}-move-active`的 className。 + ## API 动画组的属性说明如下: -| 属性 | 说明 | 类型 | 默认值 | 版本 | -| ------ | ---------------------------------- | ----------------------------------------------- | --------- | ---- | -| name | 同 Transition | string | | | -| mode | 同 Transition | `out-in` \| `in-out` \| `default` | `default` | | -| appear | 同 Transition | boolean | false | | -| tag | 包裹 children 的容器的 html tag 名 | string | `div` | | -| attrs | 组件 html 根元素的所有属性 | Partial\> | -- | -- | +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| ------ | ------------------------------------------------------- | ----------------------------------------------- | --------- | ---- | +| name | 同 Transition | string | | | +| mode | 同 Transition | `out-in` \| `in-out` \| `default` | `default` | | +| appear | 同 Transition | boolean | false | | +| tag | 包裹 children 的容器的 html tag 名,为 null 时无包裹元素 | string \| null | `div` | | +| attrs | 组件 html 根元素的所有属性 | Partial\> | -- | -- | 支持原生 HTML 的其他所有属性。 diff --git a/packages/components/src/transition-group/transition-group.types.ts b/packages/components/src/transition-group/transition-group.types.ts index f90f1ab5..783b6210 100644 --- a/packages/components/src/transition-group/transition-group.types.ts +++ b/packages/components/src/transition-group/transition-group.types.ts @@ -1,13 +1,17 @@ import type { TransitionProps } from '~/transition'; +import type { ReactElement, Key } from 'react'; import type { PropsBase } from '@pkg/shared'; -import type React from 'react'; export interface TransitionGroupProps extends Omit, 'children'>, Pick { - tag?: keyof HTMLElementTagNameMap; - children?: React.ReactElement[]; + tag?: keyof HTMLElementTagNameMap | null; + children?: ReactElement[]; className?: string; } -export type ChildMap = Map; +export type ChildMapValue = { + ref: HTMLElement | null; + reactEl: ReactElement; +}; +export type ChildMap = Map; diff --git a/packages/shared/src/utils/__tests__/forwardRefs.test.ts b/packages/shared/src/utils/__tests__/forwardRefs.test.ts new file mode 100644 index 00000000..9238013b --- /dev/null +++ b/packages/shared/src/utils/__tests__/forwardRefs.test.ts @@ -0,0 +1,16 @@ +import { renderHook } from '@testing-library/react'; +import { forwardRefs } from '../'; +import { useRef } from 'react'; + +describe('forwardRefs', () => { + test('forwardRefs should work', () => { + const ref2 = jest.fn(); + const hook = renderHook(() => { + const ref1 = useRef(); + forwardRefs(100, ref1, ref2); + return ref1.current; + }); + expect(hook.result.current).toBe(100); + expect(ref2.mock.calls[0][0]).toBe(100); + }); +}); diff --git a/packages/shared/src/utils/forwardRefs.ts b/packages/shared/src/utils/forwardRefs.ts new file mode 100644 index 00000000..e9325f3d --- /dev/null +++ b/packages/shared/src/utils/forwardRefs.ts @@ -0,0 +1,21 @@ +import type { MutableRefObject, Ref } from 'react'; + +/** + * 传递 ref + */ +export function forwardRefs( + value: unknown, + ...refs: (Ref | undefined)[] +): void { + refs.forEach((ref): void => { + if (!ref) return; + switch (typeof ref) { + case 'function': + ref(value); + break; + case 'object': + (ref as MutableRefObject).current = value; + break; + } + }); +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 77f3b70e..a2923109 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './getElRealSize'; export * from './getSizeClassName'; export * from './getClasses'; export * from './isSameReactEl'; +export * from './forwardRefs';