diff --git a/packages/vkui/src/components/Accordion/Accordion.module.css b/packages/vkui/src/components/Accordion/Accordion.module.css index 581b206f73..0751a0a068 100644 --- a/packages/vkui/src/components/Accordion/Accordion.module.css +++ b/packages/vkui/src/components/Accordion/Accordion.module.css @@ -7,6 +7,95 @@ } .AccordionContent__in { - max-block-size: 0; - transition: max-height 100ms ease-in-out; + --vkui_internal--AccordionContent_height: initial; + + animation-duration: 100ms; + animation-timing-function: ease-in-out; + animation-fill-mode: forwards; +} + +@media (--reduce-motion) { + .AccordionContent__in { + animation-duration: 300ms; + animation-timing-function: linear; + } +} + +.AccordionContent__in--enter { + animation-name: animation-expand; +} + +@media (--reduce-motion) { + .AccordionContent__in--enter { + animation-name: animation-fade-in; + } +} + +.AccordionContent__in--entered { + block-size: var(--vkui_internal--AccordionContent_height); +} + +.AccordionContent__in--exit { + animation-name: animation-collapse; +} + +@media (--reduce-motion) { + .AccordionContent__in--exit { + animation-name: animation-fade-out; + } +} + +.AccordionContent__in--exited { + block-size: 0; +} + +@keyframes animation-expand { + 0% { + block-size: 0; + } + + 100% { + block-size: var(--vkui_internal--AccordionContent_height); + } +} +@keyframes animation-collapse { + 0% { + block-size: var(--vkui_internal--AccordionContent_height); + } + + 100% { + block-size: 0; + } +} +@keyframes animation-fade-in { + 0% { + opacity: 0; + block-size: var(--vkui_internal--AccordionContent_height); + } + + 50% { + opacity: 0; + block-size: var(--vkui_internal--AccordionContent_height); + } + + 100% { + opacity: 1; + block-size: var(--vkui_internal--AccordionContent_height); + } +} +@keyframes animation-fade-out { + 0% { + opacity: 1; + block-size: var(--vkui_internal--AccordionContent_height); + } + + 50% { + opacity: 0; + block-size: var(--vkui_internal--AccordionContent_height); + } + + 100% { + opacity: 0; + block-size: var(--vkui_internal--AccordionContent_height); + } } diff --git a/packages/vkui/src/components/Accordion/Accordion.test.tsx b/packages/vkui/src/components/Accordion/Accordion.test.tsx index 50384847f5..3951d76e60 100644 --- a/packages/vkui/src/components/Accordion/Accordion.test.tsx +++ b/packages/vkui/src/components/Accordion/Accordion.test.tsx @@ -1,13 +1,13 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { baselineComponent } from '../../testing/utils'; +import { fireEvent, render } from '@testing-library/react'; +import { baselineComponent, waitCSSKeyframesAnimation } from '../../testing/utils'; import { Accordion } from './Accordion'; describe(Accordion, () => { baselineComponent(Accordion.Content); baselineComponent(Accordion.Summary, { a11y: false }); - it('toggles on click', () => { - render( + it('toggles on click', async () => { + const result = render( Title @@ -15,14 +15,17 @@ describe(Accordion, () => { Content , ); - const content = screen.getByTestId('content'); - const summary = screen.getByTestId('summary'); + const content = result.getByTestId('content'); + const contentIn = content.firstElementChild as HTMLElement; + const summary = result.getByTestId('summary'); expect(content.getAttribute('aria-hidden')).toBe('true'); fireEvent.click(summary); + await waitCSSKeyframesAnimation(contentIn); expect(content.getAttribute('aria-hidden')).toBe('false'); fireEvent.click(summary); + await waitCSSKeyframesAnimation(contentIn); expect(content.getAttribute('aria-hidden')).toBe('true'); }); }); diff --git a/packages/vkui/src/components/Accordion/AccordionContent.tsx b/packages/vkui/src/components/Accordion/AccordionContent.tsx index 35e38d1427..0465d5682f 100644 --- a/packages/vkui/src/components/Accordion/AccordionContent.tsx +++ b/packages/vkui/src/components/Accordion/AccordionContent.tsx @@ -1,48 +1,22 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { useExternRef } from '../../hooks/useExternRef'; -import { useGlobalEventListener } from '../../hooks/useGlobalEventListener'; -import { useDOM } from '../../lib/dom'; +import { useCSSKeyframesAnimationController } from '../../lib/animation'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import { HasRef, HasRootRef } from '../../types'; import { AccordionContext } from './AccordionContext'; import styles from './Accordion.module.css'; -/** - * Функция расчета max-height, для скрытия или раскрытия контента. - */ -function calcMaxHeight(expanded: boolean, el: HTMLElement | null): string { - if (!expanded) { - return '0px'; - } +const CUSTOM_PROPERTY_ACCORDION_CONTENT_HEIGHT = '--vkui_internal--AccordionContent_height'; - // В первый рендеринг нельзя узнать высоту элемента - if (el === null) { - return 'inherit'; - } - - return `${el.scrollHeight}px`; -} - -/** - * Хук для скрывания или раскрывания контента. Возвращает стили для in элемента. - */ -function useAccordionContent(expanded: boolean) { - const ref = React.useRef(null); - - const maxHeight = calcMaxHeight(expanded, ref.current); - - const resize = () => { - const el = ref.current; - el!.style.maxHeight = calcMaxHeight(expanded, el); - }; - - const { window } = useDOM(); - useGlobalEventListener(window, 'resize', resize); - useIsomorphicLayoutEffect(resize, []); - - return [ref, { maxHeight }] as const; -} +const stateClassNames = { + enter: styles['AccordionContent__in--enter'], + entering: styles['AccordionContent__in--enter'], + entered: styles['AccordionContent__in--entered'], + exit: styles['AccordionContent__in--exit'], + exiting: styles['AccordionContent__in--exit'], + exited: styles['AccordionContent__in--exited'], +}; export interface AccordionContentProps extends HasRootRef, @@ -58,9 +32,32 @@ export const AccordionContent = ({ }: AccordionContentProps) => { const { expanded, labelId, contentId } = React.useContext(AccordionContext); - const [ref, inStyle] = useAccordionContent(expanded); + const inRef = useExternRef(getRef); + const [animationState, animationHandlers] = useCSSKeyframesAnimationController( + expanded ? 'enter' : 'exit', + undefined, + true, + ); + + useIsomorphicLayoutEffect(() => { + const inEl = inRef.current; + + /* istanbul ignore if: невозможный кейс (в SSR вызова этой функции не будет) */ + if (!inEl) { + return; + } - const inRef = useExternRef(ref, getRef); + switch (animationState) { + case 'enter': + case 'exit': + inEl.style.setProperty(CUSTOM_PROPERTY_ACCORDION_CONTENT_HEIGHT, `${inEl.scrollHeight}px`); + break; + case 'entered': + case 'exited': + inEl.style.removeProperty(CUSTOM_PROPERTY_ACCORDION_CONTENT_HEIGHT); + break; + } + }, [animationState, inRef]); return (
-
+
{children}
diff --git a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts index d7dc2fec48..74480c629a 100644 --- a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts +++ b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.test.ts @@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react'; import { useCSSKeyframesAnimationController } from './useCSSKeyframesAnimationController'; describe(useCSSKeyframesAnimationController, () => { - describe.each([false, true])('`noAnimation` prop is `%s`', (noAnimation) => { + describe.each([false, true])('`disableInitAnimation` prop is `%s`', (disableInitAnimation) => { const callbacks = { onEnter: jest.fn(), onEntering: jest.fn(), @@ -23,13 +23,13 @@ describe(useCSSKeyframesAnimationController, () => { it('should enter', () => { const { result } = renderHook(() => - useCSSKeyframesAnimationController('enter', callbacks, noAnimation), + useCSSKeyframesAnimationController('enter', callbacks, disableInitAnimation), ); - !noAnimation && expect(result.current[0]).toBe('enter'); + !disableInitAnimation && expect(result.current[0]).toBe('enter'); act(result.current[1].onAnimationStart); - if (!noAnimation) { + if (!disableInitAnimation) { expect(result.current[0]).toBe('entering'); expect(callbacks.onEntering).toHaveBeenCalledTimes(1); } @@ -40,12 +40,14 @@ describe(useCSSKeyframesAnimationController, () => { }); it('should exit', () => { - const { result } = renderHook(() => useCSSKeyframesAnimationController('exit', callbacks)); + const { result } = renderHook(() => + useCSSKeyframesAnimationController('exit', callbacks, disableInitAnimation), + ); - !noAnimation && expect(result.current[0]).toBe('exit'); + !disableInitAnimation && expect(result.current[0]).toBe('exit'); act(result.current[1].onAnimationStart); - if (!noAnimation) { + if (!disableInitAnimation) { expect(result.current[0]).toBe('exiting'); expect(callbacks.onExiting).toHaveBeenCalledTimes(1); } diff --git a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts index ff043dc3c4..b78578b7bd 100644 --- a/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts +++ b/packages/vkui/src/lib/animation/useCSSKeyframesAnimationController.ts @@ -26,8 +26,9 @@ export const useCSSKeyframesAnimationController = ( onExiting: onExitingProp = noop, onExited: onExitedProp = noop, }: UseCSSAnimationControllerCallback = {}, - noAnimation = false, + disableInitAnimation = false, ): [AnimationState, AnimationHandlers] => { + const isFirstInitRef = React.useRef(disableInitAnimation); const [state, setState] = React.useState(stateProp); const [willBeEnter, setWillBeEnter] = React.useState(stateProp === 'enter'); const [willBeExit, setWillBeExit] = React.useState(stateProp === 'exit'); @@ -73,7 +74,7 @@ export const useCSSKeyframesAnimationController = ( function updateState() { switch (stateProp) { case 'enter': - if (noAnimation && state === 'enter') { + if (isFirstInitRef.current && state === 'enter') { entered(); break; } @@ -87,7 +88,7 @@ export const useCSSKeyframesAnimationController = ( onEnter(); break; case 'exit': - if (noAnimation && state === 'exit') { + if (isFirstInitRef.current && state === 'exit') { exited(); break; } @@ -101,8 +102,10 @@ export const useCSSKeyframesAnimationController = ( onExit(); break; } + + isFirstInitRef.current = false; }, - [state, stateProp, willBeEnter, willBeExit, noAnimation, entered, exited, onEnter, onExit], + [state, stateProp, willBeEnter, willBeExit, entered, exited, onEnter, onExit], ); return [state, { onAnimationStart, onAnimationEnd }];