From 0f13db23f259926246c4e57b4b2e6958c6cee807 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Fri, 25 Oct 2024 21:05:27 +0300 Subject: [PATCH 1/3] feat: create useCSSTransition() --- packages/vkui/src/lib/animation/index.ts | 6 + .../useCSSTransition.stories.module.css | 59 +++ .../animation/useCSSTransition.stories.tsx | 171 +++++++ .../lib/animation/useCSSTransition.test.tsx | 418 ++++++++++++++++++ .../src/lib/animation/useCSSTransition.ts | 199 +++++++++ 5 files changed, 853 insertions(+) create mode 100644 packages/vkui/src/lib/animation/useCSSTransition.stories.module.css create mode 100644 packages/vkui/src/lib/animation/useCSSTransition.stories.tsx create mode 100644 packages/vkui/src/lib/animation/useCSSTransition.test.tsx create mode 100644 packages/vkui/src/lib/animation/useCSSTransition.ts diff --git a/packages/vkui/src/lib/animation/index.ts b/packages/vkui/src/lib/animation/index.ts index 0f734ae631..4771fc3414 100644 --- a/packages/vkui/src/lib/animation/index.ts +++ b/packages/vkui/src/lib/animation/index.ts @@ -3,3 +3,9 @@ export { REDUCE_MOTION_MEDIA_QUERY, useReducedMotion } from './useReducedMotion' export { rubberbandIfOutOfBounds } from './rubberbandIfOutOfBounds'; export { animationFadeClassNames } from './fades'; export { transformOriginClassNames } from './transformOrigin'; +export { + type UseCSSTransitionState, + type UseCSSTransitionOptions, + type UseCSSTransition, + useCSSTransition, +} from './useCSSTransition'; diff --git a/packages/vkui/src/lib/animation/useCSSTransition.stories.module.css b/packages/vkui/src/lib/animation/useCSSTransition.stories.module.css new file mode 100644 index 0000000000..20abe7983b --- /dev/null +++ b/packages/vkui/src/lib/animation/useCSSTransition.stories.module.css @@ -0,0 +1,59 @@ +.host { + --css-transition-duration: 1s; + + inline-size: 50vh; + block-size: 50vh; + background-color: var(--vkui--color_background_accent); + border-radius: 8px; +} + +.appear { + opacity: 0; +} + +.appearing { + opacity: 1; + transition: opacity var(--css-transition-duration) ease-in-out; +} + +.appeared { + opacity: 1; +} + +.enter { + opacity: 0; + transform: translateY(-25%) rotate(25deg); + transform-origin: center center; +} + +.entering { + opacity: 1; + transform: translateY(0) rotate(0deg); + transform-origin: center center; + transition: + transform var(--css-transition-duration) ease-in-out, + opacity var(--css-transition-duration) ease-in-out; +} + +.entered { + opacity: 1; +} + +.exit { + opacity: 1; + transform: translateY(0) rotate(0deg); +} + +.exiting { + opacity: 0; + transform: translateY(-25%) rotate(25deg); + transform-origin: center center; + transition: + transform var(--css-transition-duration) ease-in-out, + opacity var(--css-transition-duration) ease-in-out; +} + +.exited { + opacity: 0; + transform: translateY(-25%) rotate(25deg); +} diff --git a/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx b/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx new file mode 100644 index 0000000000..2abcd9baf4 --- /dev/null +++ b/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx @@ -0,0 +1,171 @@ +/* eslint-disable no-console, import/no-default-export */ +'use client'; + +import { type Meta, type StoryObj } from '@storybook/react'; +import { classNames } from '@vkontakte/vkjs'; +import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants'; +import type { CSSCustomProperties } from '../../types'; +import { + useCSSTransition, + type UseCSSTransitionOptions, + type UseCSSTransitionState, +} from './useCSSTransition'; +import styles from './useCSSTransition.stories.module.css'; + +interface DemoProps extends UseCSSTransitionOptions { + in: boolean; + duration: number; +} + +const story: Meta = { + title: 'Experimental/useCSSTransition', + component: () =>
, + parameters: { ...CanvasFullLayout, ...DisableCartesianParam }, + argTypes: { + in: { control: { type: 'boolean' } }, + enableAppear: { control: { type: 'boolean' } }, + enableEnter: { control: { type: 'boolean' } }, + enableExit: { control: { type: 'boolean' } }, + duration: { + control: { + type: 'number', + }, + table: { + defaultValue: { + summary: '⚠️ Это параметр данного Story', + }, + }, + }, + }, + args: { + duration: 1, + in: true, + enableAppear: true, + enableEnter: true, + enableExit: true, + onEnter(appear) { + console.log('onEnter', appear); + }, + onEntering(appear) { + console.log('onEntering', appear); + }, + onEntered(propertyName, appear) { + console.log('onEntered', propertyName, appear); + }, + onExit() { + console.log('onExit'); + }, + onExiting() { + console.log('onExiting'); + }, + onExited(propertyName) { + console.log('onExited', propertyName); + }, + }, +}; + +export default story; + +const transitionClassNames = { + appear: styles.appear, + appearing: styles.appearing, + appeared: styles.appeared, + enter: styles.enter, + entering: styles.entering, + entered: styles.entered, + exit: styles.exit, + exiting: styles.exiting, + exited: styles.exited, +}; + +export const WithClassNameAttribute: StoryObj = { + render: function Render({ in: inProp, duration, ...restProps }) { + const [state, { ref, onTransitionEnd }] = useCSSTransition(inProp, restProps); + + if (state === 'exited') { + return
; + } + + return ( +
+ ); + }, +}; + +const getTransition = (state: UseCSSTransitionState, duration = 1) => + ({ + appear: { + opacity: 0, + }, + + appearing: { + opacity: 1, + transition: `opacity ${duration}s ease-in-out`, + }, + + appeared: { + opacity: 1, + }, + + enter: { + opacity: 0, + transform: 'translateY(-25%) rotate(25deg)', + transformOrigin: 'center center', + }, + + entering: { + opacity: 1, + transform: 'translateY(0) rotate(0deg)', + transformOrigin: 'center center', + transition: `transform ${duration}s ease-in-out, opacity ${duration}s ease-in-out`, + }, + + entered: { + opacity: 1, + }, + + exit: { + opacity: 1, + transform: 'translateY(0) rotate(0deg)', + }, + + exiting: { + opacity: 0, + transform: 'translateY(-25%) rotate(25deg)', + transformOrigin: 'center center', + transition: `transform ${duration}s ease-in-out, opacity ${duration}s ease-in-out`, + }, + + exited: { + opacity: 0, + transform: 'translateY(-25%) rotate(25deg)', + }, + })[state]; + +export const WithStyleAttribute: StoryObj = { + render: function Render({ in: inProp, duration, ...restProps }) { + const [state, { ref, onTransitionEnd }] = useCSSTransition(inProp, restProps); + + if (state === 'exited') { + return
; + } + + return ( +
+ ); + }, +}; diff --git a/packages/vkui/src/lib/animation/useCSSTransition.test.tsx b/packages/vkui/src/lib/animation/useCSSTransition.test.tsx new file mode 100644 index 0000000000..a1bd65d640 --- /dev/null +++ b/packages/vkui/src/lib/animation/useCSSTransition.test.tsx @@ -0,0 +1,418 @@ +import { fireEvent, render, renderHook } from '@testing-library/react'; +import { useCSSTransition } from './useCSSTransition'; + +describe(useCSSTransition, () => { + const callbacks = { onEnter: jest.fn(), onEntering: jest.fn(), onEntered: jest.fn(), onExit: jest.fn(), onExiting: jest.fn(), onExited: jest.fn() }; // prettier-ignore + + beforeEach(() => { + for (const key in callbacks) { + if (callbacks.hasOwnProperty(key)) { + callbacks[key].mockClear(); + } + } + }); + + const expectEveryCallbacksHaveNotBeenCalled = () => { + for (const key in callbacks) { + if (callbacks.hasOwnProperty(key)) { + expect(callbacks[key]).toHaveBeenCalledTimes(0); + } + } + }; + + describe('first mount', () => { + it.each([{ state: 'enter' }, { state: 'exit' }])( + 'should mount $state state with transition', + ({ state }) => { + const inProp = state === 'enter'; + const { result } = renderHook(() => + useCSSTransition(inProp, { enableAppear: true, ...callbacks }), + ); + + render(
); + + if (inProp) { + expect(result.current[0]).toBe('appearing'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('appeared'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEnter).toHaveBeenCalledWith(true); + + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledWith(true); + + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledWith('opacity', true); + } else { + expect(result.current[0]).toBe('exited'); + expectEveryCallbacksHaveNotBeenCalled(); + } + }, + ); + + it.each([false, true])( + 'should mount with enter state but without transition (check callbacks is %s)', + (checkCallbacks) => { + const { result } = renderHook(() => + useCSSTransition(true, checkCallbacks ? callbacks : undefined), + ); + + render(
); + expect(result.current[0]).toBe('entered'); + expectEveryCallbacksHaveNotBeenCalled(); + }, + ); + + it.each([false, true])( + 'should mount with exit state but without transition (check callbacks is %s)', + (checkCallbacks) => { + const { result } = renderHook(() => + useCSSTransition(false, checkCallbacks ? callbacks : undefined), + ); + + render(
); + expect(result.current[0]).toBe('exited'); + expectEveryCallbacksHaveNotBeenCalled(); + }, + ); + }); + + describe('updates after mount', () => { + it.each([{ enableAppear: false }, { enableAppear: true }])( + 'should enter and exit with transition (%p)', + (option) => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { ...option, ...callbacks }), + { initialProps: false }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entering'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('entered'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exiting'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('exited'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledWith('opacity'); + + expect(callbacks.onExit).toHaveBeenCalledTimes(1); + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith('opacity'); + }, + ); + + it.each([{ enableAppear: false }, { enableAppear: true }])( + 'should exit and enter with transition (%p)', + (option) => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { ...option, ...callbacks }), + { initialProps: true }, + ); + + const cmp = render(
); + if (option.enableAppear) { + expect(result.current[0]).toBe('appearing'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('appeared'); + } else { + expect(result.current[0]).toBe('entered'); + } + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exiting'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entering'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('entered'); + + const enterCount = option.enableAppear ? 2 : 1; + expect(callbacks.onEnter).toHaveBeenCalledTimes(enterCount); + expect(callbacks.onEntering).toHaveBeenCalledTimes(enterCount); + expect(callbacks.onEntered).toHaveBeenCalledTimes(enterCount); + if (option.enableAppear) { + expect(callbacks.onEntered).toHaveBeenNthCalledWith(1, 'opacity', true); + expect(callbacks.onEntered).toHaveBeenNthCalledWith(2, 'opacity'); + } else { + expect(callbacks.onEntered).toHaveBeenCalledWith('opacity'); + } + + expect(callbacks.onExit).toHaveBeenCalledTimes(1); + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith('opacity'); + }, + ); + + it('should enter immediately and exit with transition', () => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { enableEnter: false, ...callbacks }), + { initialProps: false }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entered'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exiting'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(0); + expect(callbacks.onEntering).toHaveBeenCalledTimes(0); + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledWith(); + + expect(callbacks.onExit).toHaveBeenCalledTimes(1); + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith('opacity'); + }); + + it('should exit immediately and enter with transition', () => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { enableExit: false, ...callbacks }), + { initialProps: true }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('entered'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entering'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('entered'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledWith('opacity'); + + expect(callbacks.onExit).toHaveBeenCalledTimes(0); + expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith(); + }); + + it('should enter and exit immediately', () => { + const { result, rerender } = renderHook( + (inProp) => + useCSSTransition(inProp, { + enableEnter: false, + enableExit: false, + ...callbacks, + }), + { initialProps: false }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entered'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exited'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(0); + expect(callbacks.onEntering).toHaveBeenCalledTimes(0); + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledWith(); + + expect(callbacks.onExit).toHaveBeenCalledTimes(0); + expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith(); + }); + + it('should appear with transition but enter and exit immediately', () => { + const { result, rerender } = renderHook( + (inProp) => + useCSSTransition(inProp, { + enableAppear: true, + enableEnter: false, + enableExit: false, + ...callbacks, + }), + { initialProps: true }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('appearing'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + expect(result.current[0]).toBe('appeared'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entered'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntered).toHaveBeenCalledTimes(2); + expect(callbacks.onEntered).toHaveBeenNthCalledWith(1, 'opacity', true); + expect(callbacks.onEntered).toHaveBeenNthCalledWith(2); + + expect(callbacks.onExit).toHaveBeenCalledTimes(0); + expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith(); + }); + }); + + describe('corner cases', () => { + it.each([{ enableExit: true }, { enableExit: false }])( + 'should exit during appear (%p)', + (option) => { + const { result, rerender } = renderHook( + (inProp) => + useCSSTransition(inProp, { + enableAppear: true, + ...option, + ...callbacks, + }), + { initialProps: true }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('appearing'); + + rerender(false); + cmp.rerender(
); + if (option.enableExit) { + expect(result.current[0]).toBe('exiting'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + } + expect(result.current[0]).toBe('exited'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEnter).toHaveBeenCalledWith(true); + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledWith(true); + expect(callbacks.onEntered).toHaveBeenCalledTimes(0); + + expect(callbacks.onExit).toHaveBeenCalledTimes(0); + if (option.enableExit) { + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith('opacity'); + } else { + expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + expect(callbacks.onExited).toHaveBeenCalledWith(); + } + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + }, + ); + + it.each([{ enableExit: true }, { enableExit: false }])( + 'should exit during enter (%p)', + (option) => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { ...option, ...callbacks }), + { initialProps: false }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('exited'); + + rerender(true); + cmp.rerender(
); + expect(result.current[0]).toBe('entering'); + + rerender(false); + cmp.rerender(
); + if (option.enableExit) { + expect(result.current[0]).toBe('exiting'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + } + expect(result.current[0]).toBe('exited'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(1); + expect(callbacks.onEnter).toHaveBeenCalledWith(); + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledWith(); + expect(callbacks.onEntered).toHaveBeenCalledTimes(0); + + expect(callbacks.onExit).toHaveBeenCalledTimes(0); + if (option.enableExit) { + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledWith('opacity'); + } else { + expect(callbacks.onExiting).toHaveBeenCalledTimes(0); + expect(callbacks.onExited).toHaveBeenCalledWith(); + } + expect(callbacks.onExited).toHaveBeenCalledTimes(1); + }, + ); + + it.each([{ enableEnter: true }, { enableEnter: false }])( + 'should enter during exit (%p)', + (option) => { + const { result, rerender } = renderHook( + (inProp) => useCSSTransition(inProp, { ...option, ...callbacks }), + { initialProps: true }, + ); + + const cmp = render(
); + expect(result.current[0]).toBe('entered'); + + rerender(false); + cmp.rerender(
); + expect(result.current[0]).toBe('exiting'); + + rerender(true); + cmp.rerender(
); + if (option.enableEnter) { + expect(result.current[0]).toBe('entering'); + fireEvent.transitionEnd(result.current[1].ref.current!, { propertyName: 'opacity' }); + } + expect(result.current[0]).toBe('entered'); + + expect(callbacks.onEnter).toHaveBeenCalledTimes(0); + if (option.enableEnter) { + expect(callbacks.onEntering).toHaveBeenCalledTimes(1); + expect(callbacks.onEntering).toHaveBeenCalledWith(); + expect(callbacks.onEntered).toHaveBeenCalledWith('opacity'); + } else { + expect(callbacks.onEntering).toHaveBeenCalledTimes(0); + expect(callbacks.onEntered).toHaveBeenCalledWith(); + } + expect(callbacks.onEntered).toHaveBeenCalledTimes(1); + + expect(callbacks.onExit).toHaveBeenCalledTimes(1); + expect(callbacks.onExiting).toHaveBeenCalledTimes(1); + expect(callbacks.onExited).toHaveBeenCalledTimes(0); + }, + ); + }); +}); diff --git a/packages/vkui/src/lib/animation/useCSSTransition.ts b/packages/vkui/src/lib/animation/useCSSTransition.ts new file mode 100644 index 0000000000..1662ef05a8 --- /dev/null +++ b/packages/vkui/src/lib/animation/useCSSTransition.ts @@ -0,0 +1,199 @@ +import { + type TransitionEvent, + type TransitionEventHandler, + useEffect, + useRef, + useState, +} from 'react'; +import { noop } from '@vkontakte/vkjs'; +import { usePrevious } from '../../hooks/usePrevious'; +import { useStableCallback } from '../../hooks/useStableCallback'; + +/* istanbul ignore next: особенность рендера в браузере когда меняется className, в Jest не воспроизвести */ +const forceReflowForFixNewMountedElement = (node: Element | null) => void node?.scrollTop; + +export type UseCSSTransitionState = + | 'appear' + | 'appearing' + | 'appeared' + | 'enter' + | 'entering' + | 'entered' + | 'exit' + | 'exiting' + | 'exited'; + +export type UseCSSTransitionOptions = { + enableAppear?: boolean; + enableEnter?: boolean; + enableExit?: boolean; + onEnter?: (appear?: boolean) => void; + onEntering?: (appear?: boolean) => void; + onEntered?: (propertyName?: string, appear?: boolean) => void; + onExit?: () => void; + onExiting?: () => void; + onExited?: (propertyName?: string) => void; +}; + +export type UseCSSTransition = [ + state: UseCSSTransitionState, + { + ref: React.RefObject; + onTransitionEnd?: TransitionEventHandler; + }, +]; + +export const useCSSTransition = ( + inProp?: boolean, + { + enableAppear = false, + enableEnter = true, + enableExit = true, + onEnter: onEnterProp, + onEntering: onEnteringProp, + onEntered: onEnteredProp, + onExit: onExitProp, + onExiting: onExitingProp, + onExited: onExitedProp, + }: UseCSSTransitionOptions = {}, +): UseCSSTransition => { + const onEnter = useStableCallback(onEnterProp || noop); + const onEntering = useStableCallback(onEnteringProp || noop); + const onEntered = useStableCallback(onEnteredProp || noop); + const onExit = useStableCallback(onExitProp || noop); + const onExiting = useStableCallback(onExitingProp || noop); + const onExited = useStableCallback(onExitedProp || noop); + + const ref = useRef(null); + const [state, setState] = useState(() => { + if (inProp) { + if (enableAppear) { + onEnter(true); + return 'appear'; + } + return 'entered'; + } + + return 'exited'; + }); + const prevState = usePrevious(state); + + useEffect( + function updateState() { + if (inProp) { + switch (state) { + case 'appear': + forceReflowForFixNewMountedElement(ref.current); + setState('appearing'); + onEntering(true); + break; + case 'enter': + forceReflowForFixNewMountedElement(ref.current); + setState('entering'); + onEntering(); + break; + case 'exiting': + if (enableEnter) { + setState('entering'); + onEntering(); + break; + } + + setState('entered'); + onEntered(); + break; + case 'exited': + if (enableEnter) { + setState('enter'); + onEnter(); + break; + } + + setState('entered'); + onEntered(); + break; + } + } else { + switch (state) { + case 'exit': + forceReflowForFixNewMountedElement(ref.current); + setState('exiting'); + onExiting(); + break; + case 'appearing': + case 'entering': + if (enableExit) { + setState('exiting'); + onExiting(); + break; + } + + setState('exited'); + onExited(); + break; + case 'appeared': + case 'entered': + if (enableExit) { + setState('exit'); + onExit(); + break; + } + + setState('exited'); + onExited(); + break; + } + } + }, + [ + inProp, + + state, + prevState, + + enableAppear, + enableEnter, + onEnter, + onEntering, + onEntered, + + enableExit, + onExit, + onExiting, + onExited, + ], + ); + + const onTransitionEnd = (event: TransitionEvent) => { + /* istanbul ignore if: на всякий случай предупреждаем всплытие, нет смысла проверять условие */ + if (event.target !== ref.current) { + return; + } + + switch (state) { + case 'appearing': + setState('appeared'); + onEntered(event.propertyName, true); + break; + case 'entering': + setState('entered'); + onEntered(event.propertyName); + break; + case 'exiting': + setState('exited'); + onExited(event.propertyName); + break; + } + }; + + return [ + state, + { + ref, + onTransitionEnd: + state !== 'appeared' && state !== 'entered' && state !== 'exited' + ? onTransitionEnd + : undefined, + }, + ]; +}; From 744609971f011ed6de051fe0a64f5f5821ab3790 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Sat, 2 Nov 2024 12:20:57 +0300 Subject: [PATCH 2/3] docs(useCSSTransition): hide from production storybook; change group from Experimental to DevTools --- .../vkui/src/lib/animation/useCSSTransition.stories.tsx | 3 ++- packages/vkui/src/lib/animation/useCSSTransition.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx b/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx index 2abcd9baf4..a0515292d4 100644 --- a/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx +++ b/packages/vkui/src/lib/animation/useCSSTransition.stories.tsx @@ -18,7 +18,8 @@ interface DemoProps extends UseCSSTransitionOptions { } const story: Meta = { - title: 'Experimental/useCSSTransition', + title: 'DevTools/useCSSTransition', + tags: ['test'], // скрываем из публичной документации, т.к. хук внутренний component: () =>
, parameters: { ...CanvasFullLayout, ...DisableCartesianParam }, argTypes: { diff --git a/packages/vkui/src/lib/animation/useCSSTransition.ts b/packages/vkui/src/lib/animation/useCSSTransition.ts index 1662ef05a8..1193311547 100644 --- a/packages/vkui/src/lib/animation/useCSSTransition.ts +++ b/packages/vkui/src/lib/animation/useCSSTransition.ts @@ -43,6 +43,13 @@ export type UseCSSTransition = [ }, ]; +/** + * Хук основан на компоненте `CSSTransition` из библиотеки `react-transition-group`. + * + * @link https://reactcommunity.org/react-transition-group/css-transition + * + * @private + */ export const useCSSTransition = ( inProp?: boolean, { From 93d235b14afb41fa296b55fd382ba0c4715cea53 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Tue, 5 Nov 2024 13:42:12 +0300 Subject: [PATCH 3/3] review(useCSSTransition): unwrap useState condition block Co-authored-by: Daniil Suvorov --- .../vkui/src/lib/animation/useCSSTransition.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/vkui/src/lib/animation/useCSSTransition.ts b/packages/vkui/src/lib/animation/useCSSTransition.ts index 1193311547..ee5c0fd778 100644 --- a/packages/vkui/src/lib/animation/useCSSTransition.ts +++ b/packages/vkui/src/lib/animation/useCSSTransition.ts @@ -73,15 +73,16 @@ export const useCSSTransition = ( const ref = useRef(null); const [state, setState] = useState(() => { - if (inProp) { - if (enableAppear) { - onEnter(true); - return 'appear'; - } - return 'entered'; + if (!inProp) { + return 'exited'; + } + + if (enableAppear) { + onEnter(true); + return 'appear'; } - return 'exited'; + return 'entered'; }); const prevState = usePrevious(state);