Skip to content

Commit

Permalink
feat: motion support ref
Browse files Browse the repository at this point in the history
  • Loading branch information
zombieJ committed Feb 10, 2025
1 parent 3ec24ae commit 1e8679c
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 110 deletions.
208 changes: 109 additions & 99 deletions src/CSSMotion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import type {
import { STATUS_NONE, STEP_PREPARE, STEP_START } from './interface';
import { getTransitionName, supportTransition } from './util/motion';

export interface CSSMotionRef {
nativeElement: HTMLElement;
inMotion: () => boolean;
}

export type CSSMotionConfig =
| boolean
| {
Expand Down Expand Up @@ -117,116 +122,121 @@ export function genCSSMotion(config: CSSMotionConfig) {
return !!(props.motionName && transitionSupport && contextMotion !== false);
}

const CSSMotion = React.forwardRef<any, CSSMotionProps>((props, ref) => {
const {
// Default config
visible = true,
removeOnLeave = true,

forceRender,
children,
motionName,
leavedClassName,
eventProps,
} = props;

const { motion: contextMotion } = React.useContext(Context);

const supportMotion = isSupportTransition(props, contextMotion);

// Ref to the react node, it may be a HTMLElement
const nodeRef = useRef<any>();

function getDomElement() {
return getDOM(nodeRef.current) as HTMLElement;
}

const [status, statusStep, statusStyle, mergedVisible] = useStatus(
supportMotion,
visible,
getDomElement,
props,
);

// Record whether content has rendered
// Will return null for un-rendered even when `removeOnLeave={false}`
const renderedRef = React.useRef(mergedVisible);
if (mergedVisible) {
renderedRef.current = true;
}

// ====================== Refs ======================
React.useImperativeHandle(ref, () => getDomElement());

// ===================== Render =====================
let motionChildren: React.ReactNode;
const mergedProps = { ...eventProps, visible };

if (!children) {
// No children
motionChildren = null;
} else if (status === STATUS_NONE) {
// Stable children
if (mergedVisible) {
motionChildren = children({ ...mergedProps }, nodeRef);
} else if (!removeOnLeave && renderedRef.current && leavedClassName) {
motionChildren = children(
{ ...mergedProps, className: leavedClassName },
nodeRef,
);
} else if (forceRender || (!removeOnLeave && !leavedClassName)) {
motionChildren = children(
{ ...mergedProps, style: { display: 'none' } },
nodeRef,
);
} else {
motionChildren = null;
}
} else {
// In motion
let statusSuffix: string;
if (statusStep === STEP_PREPARE) {
statusSuffix = 'prepare';
} else if (isActive(statusStep)) {
statusSuffix = 'active';
} else if (statusStep === STEP_START) {
statusSuffix = 'start';
}
const CSSMotion = React.forwardRef<CSSMotionRef, CSSMotionProps>(
(props, ref) => {
const {
// Default config
visible = true,
removeOnLeave = true,

const motionCls = getTransitionName(
forceRender,
children,
motionName,
`${status}-${statusSuffix}`,
);
leavedClassName,
eventProps,
} = props;

const { motion: contextMotion } = React.useContext(Context);

const supportMotion = isSupportTransition(props, contextMotion);

motionChildren = children(
{
...mergedProps,
className: classNames(getTransitionName(motionName, status), {
[motionCls]: motionCls && statusSuffix,
[motionName as string]: typeof motionName === 'string',
}),
style: statusStyle,
},
nodeRef,
// Ref to the react node, it may be a HTMLElement
const nodeRef = useRef<any>();

function getDomElement() {
return getDOM(nodeRef.current) as HTMLElement;
}

const [status, statusStep, statusStyle, mergedVisible] = useStatus(
supportMotion,
visible,
getDomElement,
props,
);
}

// Auto inject ref if child node not have `ref` props
if (React.isValidElement(motionChildren) && supportRef(motionChildren)) {
const originNodeRef = getNodeRef(motionChildren);
// Record whether content has rendered
// Will return null for un-rendered even when `removeOnLeave={false}`
const renderedRef = React.useRef(mergedVisible);
if (mergedVisible) {
renderedRef.current = true;
}

// ====================== Refs ======================
React.useImperativeHandle(ref, () => ({
nativeElement: getDomElement(),
inMotion: () => status !== STATUS_NONE,
}));

// ===================== Render =====================
let motionChildren: React.ReactNode;
const mergedProps = { ...eventProps, visible };

if (!children) {
// No children
motionChildren = null;
} else if (status === STATUS_NONE) {
// Stable children
if (mergedVisible) {
motionChildren = children({ ...mergedProps }, nodeRef);
} else if (!removeOnLeave && renderedRef.current && leavedClassName) {
motionChildren = children(
{ ...mergedProps, className: leavedClassName },
nodeRef,
);
} else if (forceRender || (!removeOnLeave && !leavedClassName)) {
motionChildren = children(
{ ...mergedProps, style: { display: 'none' } },
nodeRef,
);
} else {
motionChildren = null;
}
} else {
// In motion
let statusSuffix: string;
if (statusStep === STEP_PREPARE) {
statusSuffix = 'prepare';
} else if (isActive(statusStep)) {
statusSuffix = 'active';
} else if (statusStep === STEP_START) {
statusSuffix = 'start';
}

const motionCls = getTransitionName(
motionName,
`${status}-${statusSuffix}`,
);

if (!originNodeRef) {
motionChildren = React.cloneElement(
motionChildren as React.ReactElement,
motionChildren = children(
{
ref: nodeRef,
...mergedProps,
className: classNames(getTransitionName(motionName, status), {
[motionCls]: motionCls && statusSuffix,
[motionName as string]: typeof motionName === 'string',
}),
style: statusStyle,
},
nodeRef,
);
}
}

return motionChildren as React.ReactElement;
});
// Auto inject ref if child node not have `ref` props
if (React.isValidElement(motionChildren) && supportRef(motionChildren)) {
const originNodeRef = getNodeRef(motionChildren);

if (!originNodeRef) {
motionChildren = React.cloneElement(
motionChildren as React.ReactElement,
{
ref: nodeRef,
},
);
}
}

return motionChildren as React.ReactElement;
},
);

CSSMotion.displayName = 'CSSMotion';

Expand Down
21 changes: 13 additions & 8 deletions tests/CSSMotion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import React from 'react';
import ReactDOM from 'react-dom';
import type { CSSMotionProps } from '../src';
import { Provider } from '../src';
import RefCSSMotion, { genCSSMotion } from '../src/CSSMotion';
import RefCSSMotion, {
genCSSMotion,
type CSSMotionRef,
} from '../src/CSSMotion';

describe('CSSMotion', () => {
const CSSMotion = genCSSMotion({
Expand Down Expand Up @@ -628,7 +631,7 @@ describe('CSSMotion', () => {
});

it('forwardRef', () => {
const domRef = React.createRef();
const domRef = React.createRef<CSSMotionRef>();
render(
<RefCSSMotion motionName="transition" ref={domRef}>
{({ style, className }, ref) => (
Expand All @@ -641,7 +644,7 @@ describe('CSSMotion', () => {
</RefCSSMotion>,
);

expect(domRef.current instanceof HTMLElement).toBeTruthy();
expect(domRef.current.nativeElement instanceof HTMLElement).toBeTruthy();
});

it("onMotionEnd shouldn't be fired by inner element", () => {
Expand Down Expand Up @@ -844,7 +847,7 @@ describe('CSSMotion', () => {

it('not crash when no refs are passed', () => {
const Div = () => <div />;
const cssMotionRef = React.createRef();
const cssMotionRef = React.createRef<CSSMotionRef>();
render(
<CSSMotion motionName="transition" visible ref={cssMotionRef}>
{() => <Div />}
Expand All @@ -855,7 +858,7 @@ describe('CSSMotion', () => {
jest.runAllTimers();
});

expect(cssMotionRef.current).toBeFalsy();
expect(cssMotionRef.current.nativeElement).toBeFalsy();
expect(ReactDOM.findDOMNode).not.toHaveBeenCalled();
});

Expand All @@ -874,7 +877,7 @@ describe('CSSMotion', () => {
});

it('support nativeElement of ref', () => {
const domRef = React.createRef();
const domRef = React.createRef<CSSMotionRef>();
const Div = React.forwardRef<
{
nativeElement: HTMLDivElement;
Expand All @@ -900,12 +903,14 @@ describe('CSSMotion', () => {
jest.runAllTimers();
});

expect(domRef.current).toBe(container.querySelector('.bamboo'));
expect(domRef.current.nativeElement).toBe(
container.querySelector('.bamboo'),
);
expect(ReactDOM.findDOMNode).not.toHaveBeenCalled();
});

it('does not call findDOMNode when refs are forwarded and assigned', () => {
const domRef = React.createRef();
const domRef = React.createRef<CSSMotionRef>();

render(
<CSSMotion motionName="transition" visible ref={domRef}>
Expand Down
6 changes: 3 additions & 3 deletions tests/StrictMode.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import classNames from 'classnames';
import React from 'react';
import { act } from 'react-dom/test-utils';
// import type { CSSMotionProps } from '../src/CSSMotion';
import { genCSSMotion } from '../src/CSSMotion';
import { genCSSMotion, type CSSMotionRef } from '../src/CSSMotion';
// import RefCSSMotion, { genCSSMotion } from '../src/CSSMotion';
// import ReactDOM from 'react-dom';

Expand All @@ -26,7 +26,7 @@ describe('StrictMode', () => {
});

it('motion should end', () => {
const ref = React.createRef();
const ref = React.createRef<CSSMotionRef>();

const { container } = render(
<React.StrictMode>
Expand Down Expand Up @@ -57,6 +57,6 @@ describe('StrictMode', () => {
fireEvent.transitionEnd(node);
expect(node).not.toHaveClass('transition-appear');

expect(ref.current).toBe(node);
expect(ref.current.nativeElement).toBe(node);
});
});

0 comments on commit 1e8679c

Please sign in to comment.