Skip to content

Commit

Permalink
feat(core-components-fade): add fade
Browse files Browse the repository at this point in the history
  • Loading branch information
xchaikax committed Dec 7, 2020
1 parent a5be636 commit 459d2c0
Show file tree
Hide file tree
Showing 8 changed files with 404 additions and 0 deletions.
25 changes: 25 additions & 0 deletions packages/fade/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@alfalab/core-components-fade",
"version": "1.0.0",
"description": "Fade component",
"keywords": [],
"license": "ISC",
"main": "dist/index.js",
"module": "./dist/modern/index.js",
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"classnames": "^2.2.6",
"react-transition-group": "4.4.1"
},
"devDependencies": {
"@types/react-transition-group": "4.4.0"
},
"peerDependencies": {
"react": "^16.9.0"
}
}
63 changes: 63 additions & 0 deletions packages/fade/src/Component.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { boolean, number } from '@storybook/addon-knobs';
import { Container } from 'storybook/blocks/grid';
import { ComponentHeader } from 'storybook/blocks/component-header';

import { Fade } from './Component';
import { version } from '../package.json';

<Meta
title='Компоненты'
component={Fade}
parameters={{ 'theme-switcher': { themes: ['click'] } }}
/>

<!-- Canvas -->

<Story name='Fade'>
<Fade
show={boolean('show', false)}
delayTimeout={number('delayTimeout', 0)}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officiis ducimus amet
adipisci qui earum mollitia velit consectetur voluptate deserunt, quae tempora
temporibus odit distinctio omnis quisquam saepe at molestias eum.
</Fade>
</Story>

<!-- Docs -->

<ComponentHeader
name='Fade'
version={version}
package='@alfalab/core-components-fade'
stage={1}
/>

```tsx
import { Fade } from '@alfalab/core-components-fade';
```
Компонент `Fade`. Построен на `react-transition-group`.

Используется в компоненте `Modal`.

<Props of={Fade} />

<Preview>
{React.createElement(() => {
const [show, setShow] = React.useState(false);
const handleClick = () => setShow(!show);
return (
<Container>
<button onClick={handleClick}>Click Me!</button>
<Fade
show={show}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officiis ducimus amet
adipisci qui earum mollitia velit consectetur voluptate deserunt, quae tempora
temporibus odit distinctio omnis quisquam saepe at molestias eum.
</Fade>
</Container>
);
})}
</Preview>
28 changes: 28 additions & 0 deletions packages/fade/src/Component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable import/no-extraneous-dependencies */
import React from 'react';
import { render } from '@testing-library/react';

import { Fade } from './index';

describe('Fade', () => {
it('should use a children', () => {
const text = 'Lorem ipsum';
const { getByText } = render(<Fade show={true}>{ text }</Fade>);

expect(getByText(text)).toHaveTextContent(text);
});

it('should use a className prop', () => {
const className = 'test-class';
const { container } = render(<Fade show={true} className={className} />);

expect(container.firstElementChild?.classList).toContain(className);
});

it('should use a dataTestId prop', () => {
const fadeTestId = 'fade-test-id';
const { getByTestId } = render(<Fade show={true} dataTestId={fadeTestId} />);

expect(getByTestId(fadeTestId)).toHaveAttribute('data-test-id', fadeTestId);
});
});
126 changes: 126 additions & 0 deletions packages/fade/src/Component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { ReactNode, forwardRef } from 'react';
import { Transition } from 'react-transition-group';
import { TransitionStatus } from 'react-transition-group/Transition';
import cn from 'classnames';

import {
ComponentTransitionsProps,
createTimeout,
createTransitionStyles,
reflow,
useForkRef,
} from './utils';

import styles from './index.module.css';

export type FadeProps = {
/**
* Управление видимостью элемента
*/
show: boolean;

/**
* Дополнительный класс
*/
className?: string;

/**
* Управление поведением фокуса
*/
tabIndex?: number;

/**
* Контент
*/
children?: ReactNode;

/**
* Идентификатор для систем автоматизированного тестирования
*/
dataTestId?: string;
} & ComponentTransitionsProps;

/**
* Заимствовано из [MUI](https://material-ui.com/)
*/
export const Fade = forwardRef<Element, FadeProps>((props, ref) => {
const {
appear = false,
children,
className,
dataTestId,
delayTimeout = 0,
show,
onEnter,
onExit,
onExited,
timeout = 500,
tabIndex,
} = props;

const nodeRef = React.useRef(null);
const foreignRef = useForkRef((children as { ref: React.Ref<typeof children> })?.ref, ref);
const handleRef = useForkRef(nodeRef, foreignRef);

const handleEnter = (node: HTMLElement, isAppearing: boolean) => {
const element = node;
reflow(element); // Для гарантии воспроизведения анимации с ее начала.

const { duration, delay } = createTransitionStyles({ timeout, delayTimeout }, 'enter');

element.style.transitionDuration = duration;
element.style.transitionDelay = delay;

if (onEnter) {
onEnter(element, isAppearing);
}
};

const handleExit = (node: HTMLElement) => {
const element = node;
const { duration, delay } = createTransitionStyles({ timeout, delayTimeout }, 'exit');

element.style.transitionDuration = duration;
element.style.transitionDelay = delay;

if (onExit) {
onExit(element);
}
};

const handleExited = (node: HTMLElement) => {
if (onExited) {
onExited(node);
}
};

return (
<Transition
appear={appear}
in={show}
timeout={createTimeout({ timeout, delayTimeout })}
onEnter={handleEnter}
onExit={handleExit}
onExited={handleExited}
>
{ (status: string) => (
<div
className={cn(
styles.fade,
{ [styles.hidden]: status === 'exited' && !show },
(styles as { [status: string]: TransitionStatus })[status],
className,
)}
data-test-id={dataTestId}
tabIndex={tabIndex}
>
{
React.isValidElement(children)
? React.cloneElement(children, { ref: handleRef })
: children
}
</div>
) }
</Transition>
);
});
24 changes: 24 additions & 0 deletions packages/fade/src/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.fade {
transition-property: opacity;
transition-timing-function: ease-out;
}

.hidden {
display: none;
}

.exited {
opacity: 0;
}

.entering {
opacity: 1;
}

.entered {
opacity: 1;
}

.exiting {
opacity: 0;
}
1 change: 1 addition & 0 deletions packages/fade/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Component';
125 changes: 125 additions & 0 deletions packages/fade/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React from 'react';
import {
TransitionProps,
TransitionActions,
EnterHandler,
ExitHandler,
} from 'react-transition-group/Transition';

// TODO: перенести в alfalab/utils

/**
* Типы для свойств компонентов с анимацией
*/
export type ComponentTransitionsProps<RefElement extends undefined | HTMLElement = undefined> = {
/** Таймауты задержек */
delayTimeout?: number | Timeout;

/**
* Колбэк, выполнеяемый перед назначением статуса `entering`.
* Дополнительный парметр `isAppearing` необходим для того, чтобы определить,
* выполняется ли `enter`-фаза при первичном маунте компонента.
*/
onEnter?: EnterHandler<RefElement>;

/**
* Колбэк, выполнеяемый перед назначением статуса `exiting`.
*/
onExit?: ExitHandler<RefElement>;

/**
* Колбэк, выполнеяемый после назначения статуса `exited`.
*/
onExited?: ExitHandler<RefElement>;
} & Partial<Pick<TransitionProps<RefElement>, 'timeout'>> & Pick<TransitionActions, 'appear'>;

type Timeout = { enter?: number; exit?: number };
type TransitionMode = 'enter' | 'exit';

function formatMs(milliseconds: number) {
return `${Math.round(milliseconds)}ms`;
}

function convertTransitionProp(prop?: Timeout, mode: TransitionMode = 'enter') {
return prop ? prop[mode] || 0 : 0;
}

function getTransitionProp(prop?: Timeout | number, mode?: TransitionMode) {
return typeof prop === 'number' ? prop : convertTransitionProp(prop, mode);
}

function getFullTimeout(
{ timeout, delayTimeout }: ComponentTransitionsProps,
mode: TransitionMode,
) {
return getTransitionProp(timeout, mode) + getTransitionProp(delayTimeout, mode);
}

/**
* Создаёт стили для анимации
*/
export const createTransitionStyles = (
{ timeout, delayTimeout }: ComponentTransitionsProps,
mode: TransitionMode,
) => ({
duration: formatMs(getTransitionProp(timeout, mode)),
delay: formatMs(getTransitionProp(delayTimeout, mode)),
});

/**
* Создаёт таймауты для Transition c учётом задержки
*/
export const createTimeout = (props: ComponentTransitionsProps) => {
const { timeout } = props;
const enter = getTransitionProp(timeout);

return {
appear: timeout === 'object' ? timeout.appear : enter,
enter,
exit: getFullTimeout(props, 'exit'),
};
};

export const reflow = (node: HTMLElement) => node.scrollTop;

/**
* passes {value} to {ref}
*
* WARNING: Be sure to only call this inside a callback that is passed as a ref.
* Otherwise make sure to cleanup previous {ref} if it changes. See
* https://github.com/mui-org/material-ui/issues/13539
*
* useful if you want to expose the ref of an inner component to the public api
* while still using it inside the component
*
* @param ref a ref callback or ref object if anything falsy this is a no-op
* @param value
*/
export function setRef<T>(
ref: React.RefObject<T> | ((instance: T | null) => void) | null | undefined,
value: T | null,
): void {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
// eslint-disable-next-line no-param-reassign
(ref as React.MutableRefObject<T | null>).current = value;
}
}

export function useForkRef<T>(refA: React.Ref<T>, refB: React.Ref<T>): React.Ref<T> {
/**
* This will create a new function if the ref props change and are defined.
* This means react will call the old forkRef with `null` and the new forkRef
* with the ref. Cleanup naturally emerges from this behavior
*/
return React.useMemo(() => {
if (refA == null && refB == null) {
return null;
}
return (refValue) => {
setRef(refA as React.MutableRefObject<T>, refValue);
setRef(refB as React.MutableRefObject<T>, refValue);
};
}, [refA, refB]);
}

0 comments on commit 459d2c0

Please sign in to comment.