Skip to content

Commit

Permalink
fix(AccordionContent): refactor for useCSSKeyframesAnimationControlle…
Browse files Browse the repository at this point in the history
…r() (#7083)

Чтобы не зашивать `max-height`, устанавливаем его тогда, когда нужно (с переходом с `transition` на `animation` заменил на использование `height`). Сделал для удобства, через CSS переменную, которую добавляем/удалям в `useLayoutEffect()` относительно `animationState`. По умолчанию, переменная со значением `initial`.

Для `@media (--reduce-motion)` заменяем анимацию на `opacity`.
  • Loading branch information
inomdzhon authored Jun 26, 2024
1 parent e22ef0d commit 51556d1
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 58 deletions.
93 changes: 91 additions & 2 deletions packages/vkui/src/components/Accordion/Accordion.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
15 changes: 9 additions & 6 deletions packages/vkui/src/components/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
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(
<Accordion>
<Accordion.Summary iconPosition="before" data-testid="summary">
Title
</Accordion.Summary>
<Accordion.Content data-testid="content">Content</Accordion.Content>
</Accordion>,
);
const content = screen.getByTestId<HTMLDivElement>('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');
});
});
79 changes: 40 additions & 39 deletions packages/vkui/src/components/Accordion/AccordionContent.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>,
Expand All @@ -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 (
<div
Expand All @@ -72,7 +69,11 @@ export const AccordionContent = ({
className={classNames(styles['AccordionContent'], className)}
{...restProps}
>
<div ref={inRef} className={styles['AccordionContent__in']} style={inStyle}>
<div
ref={inRef}
className={classNames(styles['AccordionContent__in'], stateClassNames[animationState])}
{...animationHandlers}
>
{children}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnimationState>(stateProp);
const [willBeEnter, setWillBeEnter] = React.useState(stateProp === 'enter');
const [willBeExit, setWillBeExit] = React.useState(stateProp === 'exit');
Expand Down Expand Up @@ -73,7 +74,7 @@ export const useCSSKeyframesAnimationController = (
function updateState() {
switch (stateProp) {
case 'enter':
if (noAnimation && state === 'enter') {
if (isFirstInitRef.current && state === 'enter') {
entered();
break;
}
Expand All @@ -87,7 +88,7 @@ export const useCSSKeyframesAnimationController = (
onEnter();
break;
case 'exit':
if (noAnimation && state === 'exit') {
if (isFirstInitRef.current && state === 'exit') {
exited();
break;
}
Expand All @@ -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 }];
Expand Down

0 comments on commit 51556d1

Please sign in to comment.