diff --git a/packages/accordion/src/Component.tsx b/packages/accordion/src/Component.tsx index 11cb1d707b..519d7a4a55 100644 --- a/packages/accordion/src/Component.tsx +++ b/packages/accordion/src/Component.tsx @@ -4,10 +4,12 @@ import React, { type KeyboardEvent, type ReactNode, useCallback, + useRef, useState, } from 'react'; import cn from 'classnames'; +import { useAccordionSpringAnimation } from '@alfalab/core-components-shared'; import { TypographyText } from '@alfalab/core-components-typography'; import { DefaultControlIcon } from './components'; @@ -86,6 +88,8 @@ export type AccordionProps = { * Идентификатор для систем автоматизированного тестирования */ dataTestId?: string; + + animationVariant?: 'spring' | 'css'; } & AnchorHTMLAttributes; export const Accordion: FC = ({ @@ -103,6 +107,7 @@ export const Accordion: FC = ({ onExpandedChange, dataTestId, bodyContentClassName, + animationVariant = 'css', ...rest }) => { const uncontrolled = expanded === undefined; @@ -111,7 +116,17 @@ export const Accordion: FC = ({ const isStartPosition = controlPosition === 'start'; - const [contentHeight, contentRef] = useMeasureHeight(); + const [contentHeight, measureRef] = useMeasureHeight(); + const bodyRef = useRef(null); + const contentAnimRef = useRef(null); + + const contentRef = useCallback( + (el: HTMLDivElement | null) => { + contentAnimRef.current = el; + if (typeof measureRef === 'function') measureRef(el); + }, + [measureRef], + ); const controlContent = control === undefined ? ( @@ -136,13 +151,21 @@ export const Accordion: FC = ({ children ); + const { playEnter, playExit } = useAccordionSpringAnimation(bodyRef, contentAnimRef); + const handleExpandedChange = useCallback(() => { if (uncontrolled) { setExpanded(!isExpanded); } + if (isExpanded) { + playExit(); + } else { + playEnter(); + } + onExpandedChange?.(!isExpanded); - }, [isExpanded, onExpandedChange, uncontrolled]); + }, [isExpanded, onExpandedChange, playEnter, playExit, uncontrolled]); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { @@ -177,14 +200,24 @@ export const Accordion: FC = ({ -
-
- {bodyContent} + {animationVariant === 'spring' ? ( +
+
+ {bodyContent} +
-
+ ) : ( +
+
+ {bodyContent} +
+
+ )}
); }; diff --git a/packages/accordion/src/docs/description.mdx b/packages/accordion/src/docs/description.mdx index cad0ba557c..6f8165d5c6 100644 --- a/packages/accordion/src/docs/description.mdx +++ b/packages/accordion/src/docs/description.mdx @@ -1,96 +1,58 @@ -## Анатомия +# Приведённый код и live-демо имеют демонстрационный характер и не являются финальной реализацией -Компонент состоит из только 3 слотов: Control - элемента управления, Header - основной элемент для отображения содержимого и Body - содержимое. -Слоты могут принимать в себя как готовые компоненты, так и кастомную вёрстку. +## CSS ```jsx live - } - control={
} - > -
- +render(() => { + return ( + + + Подходит для организации сложной информации в ограниченном пространстве, + представления большого объема данных, иерархической структуры с возможностью скрытия + и открытия разделов, удовлетворения потребностей пользователей в доступе к + информации по запросу и просмотра нескольких связанных разделов контента. + + + ); +}); ``` -## Контрол -Control может быть в виде кнопки, иконки или любым другим интерактивным элементом, который инициирует раскрытие или сворачивание секции. -По умолчанию Control размещается сбоку справа от Header, но может располагаться и слева, что больше напоминает дерево. +## Spring ```jsx live - render(() => { - const [controlPosition, setControlPosition] = React.useState('end'); - - const handleControlPositionChange = React.useCallback((_, payload) => { - setControlPosition(payload.value); - }, []); - - return - } - controlPosition={controlPosition} - > -
- - - } - controlPosition={controlPosition} - > -
- - - } - controlPosition={controlPosition} - > -
- - - - - - - -}) + return ( + + + Подходит для организации сложной информации в ограниченном пространстве, + представления большого объема данных, иерархической структуры с возможностью скрытия + и открытия разделов, удовлетворения потребностей пользователей в доступе к + информации по запросу и просмотра нескольких связанных разделов контента. + + + ); +}); ``` -## Примеры -В качестве пресетов в компонент заложены текстовые контейнеры, как самый распространённый вариант использования. - +## Stub ```jsx live - render(() => { - return - - Используется для создания интерактивных списков, - которые можно разворачивать и сворачивать для - отображения дополнительной информации. - - - - Подходит для организации сложной информации в ограниченном пространстве, - представления большого объема данных, иерархической структуры с - возможностью скрытия и открытия разделов, - удовлетворения потребностей пользователей в доступе к информации - по запросу и просмотра нескольких связанных разделов контента. - - -}) + return ( + + + + + + + + ); +}); ``` diff --git a/packages/accordion/src/index.module.css b/packages/accordion/src/index.module.css index 77187a4eec..9847b89745 100644 --- a/packages/accordion/src/index.module.css +++ b/packages/accordion/src/index.module.css @@ -31,6 +31,17 @@ visibility 0s linear 400ms; } +.spring { + &.container { + overflow: hidden; + height: 0; + + & .content { + padding-top: var(--gap-12); + } + } +} + .expandedBody { visibility: visible; height: auto; diff --git a/packages/backdrop/src/index.module.css b/packages/backdrop/src/index.module.css index 23c6f7ed92..711d7bebf5 100644 --- a/packages/backdrop/src/index.module.css +++ b/packages/backdrop/src/index.module.css @@ -8,30 +8,31 @@ bottom: var(--gap-0); top: var(--gap-0); left: var(--gap-0); + background-color: var(--backdrop-visible-background); -webkit-tap-highlight-color: transparent; /* убирает хайлайт */ } .appear, .enter { - background-color: var(--backdrop-hidden-background); + opacity: 0; } .appearActive, .enterActive, .appearDone, .enterDone { - background-color: var(--backdrop-visible-background); - transition: background-color 200ms ease-in; + opacity: 1; + transition: opacity 200ms ease-in; } .exit { - background-color: var(--backdrop-visible-background); + opacity: 1; } .exitActive, .exitDone { - background-color: var(--backdrop-hidden-background); - transition: background-color 200ms ease-out; + opacity: 0; + transition: opacity 200ms ease-out; } .transparent.transparent { diff --git a/packages/base-modal/src/Component.tsx b/packages/base-modal/src/Component.tsx index 5c780346a3..80b6850845 100644 --- a/packages/base-modal/src/Component.tsx +++ b/packages/base-modal/src/Component.tsx @@ -9,6 +9,7 @@ import React, { type Ref, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -17,17 +18,22 @@ import React, { import FocusLock from 'react-focus-lock'; import mergeRefs from 'react-merge-refs'; import { RemoveScroll } from 'react-remove-scroll'; -import { CSSTransition } from 'react-transition-group'; import { type CSSTransitionProps } from 'react-transition-group/CSSTransition'; import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; import cn from 'classnames'; import { Backdrop as DefaultBackdrop, type BackdropProps } from '@alfalab/core-components-backdrop'; import { Portal, type PortalProps } from '@alfalab/core-components-portal'; -import { getScrollbarSize, isIOS } from '@alfalab/core-components-shared'; +import { + type AnimationValues, + getScrollbarSize, + isIOS, + type SpringOptions, +} from '@alfalab/core-components-shared'; import { Stack } from '@alfalab/core-components-stack'; import { stackingOrder } from '@alfalab/core-components-stack-context'; +import { AnimationWrapper, type AnimationWrapperConfig } from './components/animation-wrapper'; import { lockScroll, syncHeight, unlockScroll } from './helpers/lockScroll'; import { handleContainer, @@ -225,6 +231,16 @@ export type BaseModalProps = { * Хэндлер события прокрутки колесиком */ onWheel?: (e: WheelEvent) => void; + + springAnimation?: + | boolean + | { + springOptions: SpringOptions; + onSpringStart?: () => void; + onSpringEnd?: () => void; + enter: AnimationValues; + exit: AnimationValues; + }; }; export type BaseModalContext = { @@ -300,6 +316,7 @@ export const BaseModal = forwardRef( usePortal = true, iOSLock = false, onWheel, + springAnimation, }, ref, ) => { @@ -499,6 +516,12 @@ export const BaseModal = forwardRef( [handleScroll, onUnmount, removeResizeHandle, transitionProps], ); + useLayoutEffect(() => { + if (open && isExited) { + setExited(false); + } + }, [open, isExited]); + useEffect(() => { if (open && isExited) { /* @@ -524,8 +547,6 @@ export const BaseModal = forwardRef( restoreContainerStyles(el); }; } - - setExited(false); } if (!open) { @@ -592,6 +613,42 @@ export const BaseModal = forwardRef( ...restBackdropProps } = backdropProps; + const animationConfig: AnimationWrapperConfig = springAnimation + ? { + useSpring: true as const, + springProps: { + open, + exited, + nodeRef: componentNodeRef, + springOptions: + typeof springAnimation === 'object' ? springAnimation.springOptions : {}, + enter: typeof springAnimation === 'object' ? springAnimation.enter : {}, + exit: typeof springAnimation === 'object' ? springAnimation.exit : {}, + onEntered: () => handleEntered(componentNodeRef.current!, false), + onExited: () => handleExited(componentNodeRef.current!), + onSpringStart: + typeof springAnimation === 'object' + ? springAnimation.onSpringStart + : undefined, + onSpringEnd: + typeof springAnimation === 'object' + ? springAnimation.onSpringEnd + : undefined, + }, + } + : { + cssTransitionProps: { + appear: true, + timeout: 200, + classNames: styles, + nodeRef: componentNodeRef, + ...transitionProps, + in: open, + onEntered: handleEntered, + onExited: handleExited, + } as CSSTransitionProps, + }; + const renderContent = () => ( {(computedZIndex) => ( @@ -641,16 +698,7 @@ export const BaseModal = forwardRef( zIndex: computedZIndex, }} > - +
( {children}
- +
diff --git a/packages/base-modal/src/components/animation-wrapper.tsx b/packages/base-modal/src/components/animation-wrapper.tsx new file mode 100644 index 0000000000..8637ff8007 --- /dev/null +++ b/packages/base-modal/src/components/animation-wrapper.tsx @@ -0,0 +1,95 @@ +import React, { useLayoutEffect, useRef } from 'react'; +import { CSSTransition } from 'react-transition-group'; +import { type CSSTransitionProps } from 'react-transition-group/CSSTransition'; + +import { + type AnimationValues, + type SpringOptions, + useSpringTransition, +} from '@alfalab/core-components-shared'; + +type CSSAnimationProps = { + children: React.ReactNode; + useSpring?: false; + cssTransitionProps: CSSTransitionProps; +}; + +type SpringAnimationInnerProps = { + open: boolean; + exited: boolean | null; + nodeRef: React.RefObject; + springOptions: SpringOptions; + enter: AnimationValues; + exit: AnimationValues; + onEntered: () => void; + onExited: () => void; + onSpringStart?: () => void; + onSpringEnd?: () => void; +}; + +type SpringAnimationProps = { + children: React.ReactNode; + useSpring: true; + springProps: SpringAnimationInnerProps; +}; + +export type AnimationWrapperConfig = + | Omit + | Omit; + +const SpringAnimationInner = ({ + children, + open, + exited, + nodeRef, + springOptions, + onEntered, + onExited, + onSpringStart, + onSpringEnd, + enter, + exit, +}: SpringAnimationInnerProps & { children: React.ReactNode }) => { + const fallbackRef = useRef(null); + + const { playEnter, playExit } = useSpringTransition( + nodeRef ?? fallbackRef, + springOptions, + enter, + exit, + { + onEntered, + onExited, + }, + ); + + useLayoutEffect(() => { + if (exited !== false) return; + if (open) { + playEnter(); + onSpringStart?.(); + } else { + playExit(); + onSpringEnd?.(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, exited]); + + return children; +}; + +export const AnimationWrapper = ({ + config, + children, +}: { + config: AnimationWrapperConfig; + children: React.ReactNode; +}) => { + if (config.useSpring) { + return {children}; + } + + const { cssTransitionProps } = config; + + return {children}; +}; diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 65d68be616..010e3f5b66 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -10,7 +10,7 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; -import { getDataTestId } from '@alfalab/core-components-shared'; +import { getDataTestId, useSpringAnimation } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; import { useFocus } from '@alfalab/hooks'; @@ -53,6 +53,26 @@ export const BaseButton = forwardRef< onClick, styles = {}, colorStylesMap = { default: {}, inverted: {} }, + shake = false, + shakeSpring, + pulse = false, + pulseSpring, + bounce = false, + bounceSpring, + wobble = false, + wobbleSpring, + jelly = false, + jellySpring, + swing = false, + swingSpring, + pop = false, + popSpring, + nod = false, + nodSpring, + rubber = false, + rubberSpring, + onSpringAnimationStart, + onSpringAnimationEnd, labelClassName, hintClassName, ...restProps @@ -64,15 +84,38 @@ export const BaseButton = forwardRef< const [focused] = useFocus(buttonRef, 'keyboard'); const [loaderTimePassed, setLoaderTimePassed] = useState(true); - const timerId = useRef(0); - const showLoader = loading || !loaderTimePassed; - const showHint = hint && [56, 64, 72].includes(size); + const animationCallbacks = { onStart: onSpringAnimationStart, onEnd: onSpringAnimationEnd }; - const iconOnly = !children; + const shakeAnim = useSpringAnimation(buttonRef, 'shake', shakeSpring, animationCallbacks); + const pulseAnim = useSpringAnimation(buttonRef, 'pulse', pulseSpring, animationCallbacks); + const bounceAnim = useSpringAnimation( + buttonRef, + 'bounce', + bounceSpring, + animationCallbacks, + ); + const wobbleAnim = useSpringAnimation( + buttonRef, + 'wobble', + wobbleSpring, + animationCallbacks, + ); + const jellyAnim = useSpringAnimation(buttonRef, 'jelly', jellySpring, animationCallbacks); + const swingAnim = useSpringAnimation(buttonRef, 'swing', swingSpring, animationCallbacks); + const popAnim = useSpringAnimation(buttonRef, 'pop', popSpring, animationCallbacks); + const nodAnim = useSpringAnimation(buttonRef, 'nod', nodSpring, animationCallbacks); + const rubberAnim = useSpringAnimation( + buttonRef, + 'rubber', + rubberSpring, + animationCallbacks, + ); + const showHint = hint && [56, 64, 72].includes(size); + const iconOnly = !children; const sizeStyle = `size-${size}`; const componentProps = { @@ -182,6 +225,15 @@ export const BaseButton = forwardRef< return; } onClick?.(e); + if (shake) shakeAnim.trigger(); + else if (pulse) pulseAnim.trigger(); + else if (bounce) bounceAnim.trigger(); + else if (wobble) wobbleAnim.trigger(); + else if (jelly) jellyAnim.trigger(); + else if (swing) swingAnim.trigger(); + else if (pop) popAnim.trigger(); + else if (nod) nodAnim.trigger(); + else if (rubber) rubberAnim.trigger(); }; if (href) { diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index bce8464879..f3816be1df 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -1,426 +1,1213 @@ -## Виды кнопок +# Приведённый код и live-демо имеют демонстрационный характер и не являются финальной реализацией. + +## Shake + +Shake-анимация с эффектом «выброса» — кнопка смещается по оси X и плавно возвращается благодаря spring-физике. ```jsx live mobileHeight={500} render(() => { - const [disabled, setDisabled] = React.useState(false); + const [state, setState] = React.useState({ stiffness: 100, damping: 10, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( <> - - - Accent - - - Primary - - - Secondary - - - Outlined - - - Transparent - - - Text - - - - - - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
); }); -//MOBILE +``` + +## Pulse + +Pulse-анимация — кнопка кратковременно увеличивается в масштабе и возвращается обратно. + +```jsx live mobileHeight={500} render(() => { - const [disabled, setDisabled] = React.useState(false); + const [state, setState] = React.useState({ stiffness: 420, damping: 34, mass: 0.82 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( <> - - Accent - - - - Primary - - - - Secondary - - - - Outlined - - - - Transparent - - - - Text - - - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
); }); ``` -## Размеры - -Кнопка доступна в размерах 72, 64, 56, 48, 40, 32. - -```jsx live mobileHeight={460} -const BIG_SIZES = [72, 64, 56]; -const SMALL_SIZES = [48, 40, 32]; - -render( - <> - - {BIG_SIZES.map((size) => ( - - {`${size}px`} - - ))} - - - - {SMALL_SIZES.map((size) => ( - - {`${size}px`} - - ))} - - , -); -//MOBILE -const SIZES = [72, 64, 56, 48, 40, 32]; - -render( - - {SIZES.map((size, idx) => ( - - - {`${size}px`} - - {SIZES.length - 1 !== idx && } - - ))} - , -); -``` - -## Форма +## Bounce -Для кнопки доступно два варианта скругления углов. +Bounce-анимация — кнопка подпрыгивает вверх и возвращается обратно. -```jsx live +```jsx live mobileHeight={500} render(() => { - const [shape, setShape] = React.useState('rectangular'); + const [state, setState] = React.useState({ stiffness: 200, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( <> - - - - - setShape(value)} - breakpoint={BREAKPOINT} - > - - - +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
); }); ``` -## Ширина - -Кнопка адаптируется под длину контента. Для каждого вертикального размера кнопки задан минимальный горизонтальный размер. -С помощью свойства `block` можно заставить кнопку занимать всю ширину контейнера. -Через доступ по classname можно задать кнопке ширину в рх. - -```jsx live -
- - - - - - - - -
+## Wobble + +Wobble-анимация — кнопка покачивается вокруг своей оси. Создаёт более мягкий эффект привлечения внимания по сравнению со shake. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 150, damping: 8, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); ``` -## Анатомия +## Jelly -С помощью слотов `leftAddons` и `rightAddons` можно кастомизировать кнопку. Например, добавить иконку. -Переданный контент будет отрисован слева или справа от текста кнопки. Если текста нет — будет отрисована квадратная кнопка. -В 56, 64 и 72 размерах доступна подпись под лейблом. +Jelly-анимация — кнопка деформируется: растягивается по X и сжимается по Y, затем возвращается. Эффект желе благодаря одновременной анимации `scaleX` и `scaleY`. -```jsx live +```jsx live mobileHeight={500} render(() => { - const [label, setLabel] = React.useState(true); - const [hint, setHint] = React.useState(); - const [leftAddons, setLeftAddons] = React.useState(false); - const [rightAddons, setRightAddons] = React.useState(false); - - const handleLabelChange = () => setLabel(!label); - const handleHintChange = () => setHint((p) => (p ? undefined : 'Hint')); - const handleLeftAddonsChange = () => setLeftAddons(!leftAddons); - const handleRightAddonsChange = () => setRightAddons(!rightAddons); + const [state, setState] = React.useState({ stiffness: 300, damping: 10, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( -
- - - - - - - - - - - -
+ <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ ); }); ``` -## Поведение лейбла +## Swing -С помощью свойства `textResizing` можно сжать или растянуть текстовый контент внутри кнопки. +Swing-анимация — маятниковое качание с затуханием. Отличается от wobble тем, что амплитуда уменьшается с каждым качком. -```jsx live +```jsx live mobileHeight={500} render(() => { - const [textResizing, setTextResizing] = React.useState('hug'); + const [state, setState] = React.useState({ stiffness: 100, damping: 8, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( -
- - - - - setTextResizing(value)} - breakpoint={BREAKPOINT} - > - - - -
+ <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ ); }); ``` -## Перенос текста внутри кнопки +## Pop -С помощью свойства `nowrap` можно запретить перенос текста на новую строку. +Pop-анимация — быстрый масштаб вверх, чуть ниже единицы, обратно. -```jsx live +```jsx live mobileHeight={500} render(() => { - const [checked, setChecked] = React.useState(true); - - const handleChange = () => setChecked(!checked); + const [state, setState] = React.useState({ stiffness: 400, damping: 20, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( - -
- - Пример длинного текста - + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - - + ); }); -//MOBILE -render(() => { - const [checked, setChecked] = React.useState(true); +``` + +## Nod - const handleChange = () => setChecked(!checked); +Nod-анимация — кивок вниз и обратно. Противоположность bounce по направлению. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 200, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( - -
- - Пример длинного текста - + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - - + ); }); ``` -## Размытие фона +## Rubber -Для кнопок можно включить размытие фона, если она полупрозрачная, располагается поверх динамического контента или изображения. +Rubber-анимация — кнопка сужается по X как резинка, затем растягивается и возвращается. Акцент на горизонтальной эластичности. -```jsx live expanded +```jsx live mobileHeight={500} render(() => { - const [checked, setChecked] = React.useState(true); - const handleChange = () => setChecked(!checked); - - const wrapper = { - position: 'relative', - borderRadius: '16px', - width: '330px', - height: '100px', - }; - - const image = { - width: '100%', - height: '100%', - borderRadius: 'inherit', - objectFit: 'cover', - }; - - const wrapperButton = { - position: 'absolute', - display: 'flex', - inset: '0px', - padding: '20px', - justifyContent: 'space-between', - }; + const [state, setState] = React.useState({ stiffness: 250, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( <> -
- -
- - + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
- - - - ); }); ``` -## Обработка событий +--- -С помощью свойства `loading` можно отобразить состояние загрузки. -Минимальное время отображения лоадера — 500мс, чтобы при быстрых ответах от сервера кнопка не «моргала». +## Модуль анимаций: `useSpringAnimation` -```jsx live expanded -render(() => { - const [loading, setLoading] = React.useState(false); - const [loadTimeout, setLoadTimeout] = React.useState('30'); - const timeoutId = React.useRef(); +Хук реализован в `packages/button/src/hooks/useSpringAnimation.ts` и инкапсулирует всю логику spring-анимаций на основе библиотеки [motion](https://motion.dev/docs/animate). - const handleClick = () => { - setLoading(true); +### Зависимости и размер бандла - clearTimeout(timeoutId.current); +Для уменьшения размера бандла используется `motion/mini` вместо полного `motion`: - timeoutId.current = setTimeout(() => { - setLoading(false); - }, Number(loadTimeout)); - }; +```ts +import { animate } from 'motion/mini'; // WAAPI-обёртка без встроенного spring +import { spring } from 'motion'; // точечный импорт spring-генератора +``` - const handleTimeoutChange = (_, { value }) => { - clearTimeout(timeoutId.current); - setLoading(false); - setLoadTimeout(value); - }; +`motion/mini` использует браузерный Web Animations API (WAAPI) и весит значительно меньше полного пакета. Однако WAAPI не поддерживает spring-физику нативно, поэтому `spring` импортируется точечно из основного пакета. + +**Ограничение WAAPI:** принимает только валидные CSS-свойства. Motion-шорткаты `x`, `y`, `scaleX`, `scaleY` не работают — вместо них используются CSS Individual Transform Properties: + +- смещение по X → CSS `translate: '10px'` +- смещение по Y → CSS `translate: '0px -14px'` +- независимый scaleX/scaleY → CSS `scale: '1.25 0.75'` + +Требует браузеры с поддержкой CSS Transforms Level 2 (Chrome 104+, Firefox 72+, Safari 14.1+). + +### Зачем хук, а не инлайн-код + +Каждая анимация — это цепочка вызовов `animate()` (motion не поддерживает больше двух keyframes в spring-режиме). Хук берёт на себя: + +- построение цепочки шагов из пресета +- блокировку повторного запуска пока анимация идёт +- отмену с мгновенным сбросом трансформов +- cleanup при размонтировании компонента + +### API хука + +```tsx +const { trigger, cancel, isPlaying } = useSpringAnimation( + ref, // RefObject — элемент для анимации + type, // 'shake' | 'pulse' | 'bounce' | 'wobble' + springOptions, // { stiffness?, damping?, mass? } — переопределяют дефолты пресета + callbacks, // { onStart?(cancel): void, onEnd?(): void } +); +``` + + + + + + + + + + + + + + + + + + + + + + + + + + +
ВозвращаемоеТипОписание
+ trigger + + () => void + Запускает анимацию. Игнорируется, если уже играет.
+ cancel + + () => void + Останавливает анимацию и мгновенно возвращает элемент в исходное положение.
+ isPlaying + + boolean + + React-состояние: true пока анимация воспроизводится. +
+ +### Пресеты анимаций + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ТипЭффектСвойствоДефолтный spring
+ shake + Горизонтальный сдвиг влево-вправо + translate (X) + stiffness 100, damping 10
+ pulse + Масштабирование вверх и обратно + scale + stiffness 300, damping 15
+ bounce + Прыжок вверх и обратно + translate (Y) + stiffness 200, damping 12
+ wobble + Вращение влево-вправо + rotate + stiffness 150, damping 8
+ jelly + Деформация по X и Y в противофазе + scale (X Y) + stiffness 300, damping 10
+ swing + Маятниковое вращение с затуханием + rotate + stiffness 100, damping 8
+ pop + Резкий масштаб вверх и обратно + scale + stiffness 400, damping 20
+ nod + Кивок вниз и обратно + translate (Y) + stiffness 200, damping 12
+ rubber + Сужение и растяжение по X + scale (X 1) + stiffness 250, damping 12
+ +### Использование в компоненте + +```tsx +import { useRef } from 'react'; +import { useSpringAnimation } from './hooks'; + +function MyComponent() { + const ref = useRef(null); + + const { trigger, cancel, isPlaying } = useSpringAnimation( + ref, + 'shake', + { stiffness: 150, damping: 12 }, + { + onStart: (cancelFn) => console.log('started', cancelFn), + onEnd: () => console.log('finished'), + }, + ); return ( - <> - - - - - - - - - +
+ + {isPlaying && } +
); -}); +} ``` -## Другие кнопки +### Пропсы Button для анимаций + +Button оборачивает хук и предоставляет декларативный API через пропсы. Одновременно активна только одна анимация — они взаимоисключающие. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПропТипОписание
+ shake + + boolean + Включить shake-анимацию по клику
+ shakeSpring + + SpringOptions + Переопределить spring-параметры shake
+ pulse + + boolean + Включить pulse-анимацию по клику
+ pulseSpring + + SpringOptions + Переопределить spring-параметры pulse
+ bounce + + boolean + Включить bounce-анимацию по клику
+ bounceSpring + + SpringOptions + Переопределить spring-параметры bounce
+ wobble + + boolean + Включить wobble-анимацию по клику
+ wobbleSpring + + SpringOptions + Переопределить spring-параметры wobble
+ onSpringAnimationStart + + (cancel: () => void) => void + Вызывается при старте; получает функцию отмены
+ onSpringAnimationEnd + + () => void + Вызывается при завершении или отмене
+ +### Добавление нового пресета -Если нужна кнопка с одной иконкой, но без подложки, используйте [IconButton](/docs/iconbutton--docs). +Чтобы добавить новый тип анимации, достаточно расширить `PRESETS` в файле хука и добавить тип в `AnimationType`: -Если нужна кнопка с другим цветом фона, используйте [CustomButton](/docs/custombutton--docs). +```ts +// packages/button/src/hooks/useSpringAnimation.ts + +export type AnimationType = 'shake' | 'pulse' | 'bounce' | 'wobble' | 'flip'; + +const PRESETS: Record = { + // ...существующие пресеты... + flip: { + defaultSpring: { stiffness: 200, damping: 20, mass: 1 }, + steps: [ + { property: 'rotateY', from: 0, to: 90 }, + { property: 'rotateY', from: -90, to: 0 }, + ], + }, +}; +``` -Если нужна кнопка с выпадающим списком, используйте [PickerButton](/docs/pickerbutton--docs). +Новый тип сразу доступен в хуке и может использоваться в любом компоненте. diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index 30ba826cdc..ae6aebf83d 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -5,6 +5,8 @@ import { type ReactNode, } from 'react'; +import { type SpringOptions } from '@alfalab/core-components-shared'; + export type StyleColors = { default: { [key: string]: string; @@ -114,6 +116,115 @@ type ComponentProps = { */ children?: ReactNode; + /** + * Включить shake-анимацию по клику + * @default false + */ + shake?: boolean; + + /** + * Spring-параметры для shake-анимации + */ + shakeSpring?: SpringOptions; + + /** + * Включить pulse-анимацию по клику (масштабирование) + * @default false + */ + pulse?: boolean; + + /** + * Spring-параметры для pulse-анимации + */ + pulseSpring?: SpringOptions; + + /** + * Включить bounce-анимацию по клику (прыжок по Y) + * @default false + */ + bounce?: boolean; + + /** + * Spring-параметры для bounce-анимации + */ + bounceSpring?: SpringOptions; + + /** + * Включить wobble-анимацию по клику (вращение) + * @default false + */ + wobble?: boolean; + + /** + * Spring-параметры для wobble-анимации + */ + wobbleSpring?: SpringOptions; + + /** + * Включить jelly-анимацию по клику (деформация scaleX/scaleY) + * @default false + */ + jelly?: boolean; + + /** + * Spring-параметры для jelly-анимации + */ + jellySpring?: SpringOptions; + + /** + * Включить swing-анимацию по клику (маятниковое вращение) + * @default false + */ + swing?: boolean; + + /** + * Spring-параметры для swing-анимации + */ + swingSpring?: SpringOptions; + + /** + * Включить pop-анимацию по клику (резкий отклик масштабом) + * @default false + */ + pop?: boolean; + + /** + * Spring-параметры для pop-анимации + */ + popSpring?: SpringOptions; + + /** + * Включить nod-анимацию по клику (кивок вниз) + * @default false + */ + nod?: boolean; + + /** + * Spring-параметры для nod-анимации + */ + nodSpring?: SpringOptions; + + /** + * Включить rubber-анимацию по клику (растяжение по X) + * @default false + */ + rubber?: boolean; + + /** + * Spring-параметры для rubber-анимации + */ + rubberSpring?: SpringOptions; + + /** + * Вызывается при старте spring-анимации. Получает функцию отмены текущей анимации. + */ + onSpringAnimationStart?: (cancel: () => void) => void; + + /** + * Вызывается при завершении или отмене spring-анимации. + */ + onSpringAnimationEnd?: () => void; + /** * Дополнительный класс для label */ diff --git a/packages/shared/package.json b/packages/shared/package.json index a6bdeca991..ca7bd5c6ba 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -18,6 +18,7 @@ "@maskito/core": "^1.7.0", "classnames": "^2.5.1", "detect-browser": "^5.3.0", + "motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/packages/shared/src/hooks/animation/spring-options.ts b/packages/shared/src/hooks/animation/spring-options.ts new file mode 100644 index 0000000000..440040f255 --- /dev/null +++ b/packages/shared/src/hooks/animation/spring-options.ts @@ -0,0 +1,5 @@ +export type SpringOptions = { + stiffness?: number; + damping?: number; + mass?: number; +}; diff --git a/packages/shared/src/hooks/animation/use-accordion-spring-animation.ts b/packages/shared/src/hooks/animation/use-accordion-spring-animation.ts new file mode 100644 index 0000000000..fbe97c5e14 --- /dev/null +++ b/packages/shared/src/hooks/animation/use-accordion-spring-animation.ts @@ -0,0 +1,77 @@ +import { type RefObject, useRef } from 'react'; +import { spring } from 'motion'; +import { animate } from 'motion/mini'; + +export function useAccordionSpringAnimation( + ref: RefObject, + refContent: RefObject, +) { + const animationRef = useRef | null>(null); + + const playEnter = () => { + const el = ref.current; + + if (!el) { + return; + } + + animationRef.current?.stop(); + + const content = refContent.current; + + if (content) { + animate( + content, + { filter: ['blur(2.5px)', 'blur(0px)'] }, + { type: spring, stiffness: 315, damping: 30, mass: 0.74 }, + ); + } + + // scrollHeight даёт реальную высоту контента даже когда элемент height:0 + const targetHeight = el.scrollHeight; + + animationRef.current = animate( + el, + { height: targetHeight }, + { type: spring, stiffness: 315, damping: 30, mass: 1.74 }, + ); + + // После завершения ставим auto, чтобы контент мог менять размер + animationRef.current.then(() => { + el.style.height = 'auto'; + }); + }; + + const playExit = () => { + const el = ref.current; + + if (!el) { + return; + } + + animationRef.current?.stop(); + + // Если высота auto — фиксируем пиксели перед анимацией + if (el.style.height === 'auto' || el.style.height === '') { + el.style.height = `${el.offsetHeight}px`; + } + + const content = refContent.current; + + if (content) { + animate( + content, + { filter: ['blur(0px)', 'blur(2.5px)'] }, + { type: spring, stiffness: 315, damping: 30, mass: 0.74 }, + ); + } + + animationRef.current = animate( + el, + { height: 0 }, + { type: spring, stiffness: 315, damping: 30, mass: 0.74 }, + ); + }; + + return { playEnter, playExit }; +} diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 533a4a3e98..3e64e62d63 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -8,3 +8,12 @@ export const hooks = { export * from './use-force-update'; export * from './use-ref-as-state'; + +export { + useSpringAnimation, + useSpringTransition, + type AnimationValues, +} from './useSpringAnimation'; + +export { type SpringOptions } from './animation/spring-options'; +export { useAccordionSpringAnimation } from './animation/use-accordion-spring-animation'; diff --git a/packages/shared/src/hooks/useSpringAnimation.ts b/packages/shared/src/hooks/useSpringAnimation.ts new file mode 100644 index 0000000000..e7ff9f787a --- /dev/null +++ b/packages/shared/src/hooks/useSpringAnimation.ts @@ -0,0 +1,239 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { spring } from 'motion'; +import { animate } from 'motion/mini'; + +import { type SpringOptions } from './animation/spring-options'; + +type AnimationType = + | 'shake' + | 'pulse' + | 'bounce' + | 'wobble' + | 'jelly' + | 'swing' + | 'pop' + | 'nod' + | 'rubber'; + +export type AnimationValues = Record; + +type AnimationPreset = { + defaultSpring: Required; + steps: AnimationValues[]; +}; + +const PRESETS: Record = { + shake: { + defaultSpring: { stiffness: 100, damping: 10, mass: 1 }, + steps: [ + { translate: ['0px', '10px'] }, + { translate: ['10px', '-10px'] }, + { translate: ['-10px', '0px'] }, + ], + }, + pulse: { + defaultSpring: { stiffness: 900, damping: 42, mass: 0.3 }, + steps: [ + { scale: [1, 0.93], filter: ['blur(0.5px)', 'blur(0.25px)'] }, + { scale: [0.93, 1], filter: ['blur(0.25px)', 'blur(0px)'] }, + ], + }, + bounce: { + defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, + steps: [{ translate: ['0px 0px', '0px -14px'] }, { translate: ['0px -14px', '0px 0px'] }], + }, + wobble: { + defaultSpring: { stiffness: 150, damping: 8, mass: 1 }, + steps: [ + { rotate: ['0deg', '-6deg'] }, + { rotate: ['-6deg', '6deg'] }, + { rotate: ['6deg', '-3deg'] }, + { rotate: ['-3deg', '0deg'] }, + ], + }, + jelly: { + defaultSpring: { stiffness: 300, damping: 10, mass: 1 }, + steps: [ + { scale: ['1 1', '1.25 0.75'] }, + { scale: ['1.25 0.75', '0.85 1.15'] }, + { scale: ['0.85 1.15', '1 1'] }, + ], + }, + swing: { + defaultSpring: { stiffness: 100, damping: 8, mass: 1 }, + steps: [ + { rotate: ['0deg', '-12deg'] }, + { rotate: ['-12deg', '8deg'] }, + { rotate: ['8deg', '-4deg'] }, + { rotate: ['-4deg', '0deg'] }, + ], + }, + pop: { + defaultSpring: { stiffness: 400, damping: 20, mass: 1 }, + steps: [{ scale: [1, 1.15] }, { scale: [1.15, 0.95] }, { scale: [0.95, 1] }], + }, + nod: { + defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, + steps: [{ translate: ['0px 0px', '0px 8px'] }, { translate: ['0px 8px', '0px 0px'] }], + }, + rubber: { + defaultSpring: { stiffness: 250, damping: 12, mass: 1 }, + steps: [ + { scale: ['1 1', '0.85 1'] }, + { scale: ['0.85 1', '1.1 1'] }, + { scale: ['1.1 1', '1 1'] }, + ], + }, +}; + +type UseSpringAnimationCallbacks = { + onStart?: (cancel: () => void) => void; + onEnd?: () => void; +}; + +type UseSpringTransitionCallbacks = { + onEntered?: () => void; + onExited?: () => void; +}; + +export function useSpringTransition( + ref: RefObject, + springOptions: SpringOptions, + enter: AnimationValues, + exit: AnimationValues, + callbacks?: UseSpringTransitionCallbacks, +): { + playEnter: () => void; + playExit: () => void; +} { + const animationRef = useRef | null>(null); + const callbacksRef = useRef(callbacks); + + callbacksRef.current = callbacks; + + const springOptionsRef = useRef(springOptions); + + springOptionsRef.current = springOptions; + + const playEnter = useCallback(() => { + if (!ref.current) return; + const merged = { ...springOptionsRef.current }; + + animationRef.current?.cancel(); + + animationRef.current = animate(ref.current, enter, { + type: spring, + ...merged, + }); + animationRef.current.then(() => { + callbacksRef.current?.onEntered?.(); + }); + }, [enter, ref]); + + const playExit = useCallback(() => { + if (!ref.current) return; + const merged = { ...springOptionsRef.current }; + + animationRef.current?.cancel(); + + animationRef.current = animate(ref.current, exit, { + type: spring, + ...merged, + }); + animationRef.current.then(() => { + callbacksRef.current?.onExited?.(); + }); + }, [exit, ref]); + + useEffect( + () => () => { + animationRef.current?.cancel(); + }, + [], + ); + + return { playEnter, playExit }; +} + +export function useSpringAnimation( + ref: RefObject, + type: AnimationType, + springOptions?: SpringOptions, + callbacks?: UseSpringAnimationCallbacks, +): { + trigger: () => void; + cancel: () => void; + isPlaying: boolean; +} { + const isPlayingRef = useRef(false); + const [isPlaying, setIsPlaying] = useState(false); + const animationRef = useRef | null>(null); + + const callbacksRef = useRef(callbacks); + + callbacksRef.current = callbacks; + + const springOptionsRef = useRef(springOptions); + + springOptionsRef.current = springOptions; + + const cancel = useCallback(() => { + animationRef.current?.cancel(); + animationRef.current = null; + if (ref.current) { + animate( + ref.current, + { translate: '0px 0px', scale: '1 1', rotate: '0deg' }, + { duration: 0 }, + ); + } + if (isPlayingRef.current) { + isPlayingRef.current = false; + setIsPlaying(false); + callbacksRef.current?.onEnd?.(); + } + }, [ref]); + + const trigger = useCallback(() => { + if (!ref.current || isPlayingRef.current) return; + + const el = ref.current; + const preset = PRESETS[type]; + const merged = { ...preset.defaultSpring, ...springOptionsRef.current }; + const springOpts = { type: spring, ...merged }; + + isPlayingRef.current = true; + setIsPlaying(true); + callbacksRef.current?.onStart?.(cancel); + + const { steps } = preset; + + const runStep = (index: number): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + animationRef.current = animate(el, steps[index] as any, springOpts); + + if (index < steps.length - 1) { + animationRef.current.then(() => runStep(index + 1)); + } else { + animationRef.current.then(() => { + if (isPlayingRef.current) { + isPlayingRef.current = false; + setIsPlaying(false); + callbacksRef.current?.onEnd?.(); + } + }); + } + }; + + runStep(0); + }, [ref, type, cancel]); + + useEffect( + () => () => { + animationRef.current?.cancel(); + }, + [], + ); + + return { trigger, cancel, isPlaying }; +} diff --git a/packages/steps/src/Component.tsx b/packages/steps/src/Component.tsx index 7a23d72941..10c5bf1512 100644 --- a/packages/steps/src/Component.tsx +++ b/packages/steps/src/Component.tsx @@ -85,6 +85,7 @@ export type StepsProps = { * @param stepNumber - номер активного шага */ onChange?: (stepNumber: number) => void; + animateSpring?: boolean; } & CommonProps; export const Steps: React.FC = ({ @@ -108,6 +109,7 @@ export const Steps: React.FC = ({ onChange, dataTestId, completedDashColor, + animateSpring, }) => { const uncontrolled = activeStepProp === undefined; const [activeStep, setActiveStep] = useState(defaultActiveStep); @@ -173,6 +175,7 @@ export const Steps: React.FC = ({ minSpaceBetweenSteps={minSpaceBetweenSteps} completedDashColor={completedDashColor} dataTestId={dataTestId} + animateSpring={animateSpring} > {step} diff --git a/packages/steps/src/components/step/Component.tsx b/packages/steps/src/components/step/Component.tsx index ddd14ad397..99605beaa3 100644 --- a/packages/steps/src/components/step/Component.tsx +++ b/packages/steps/src/components/step/Component.tsx @@ -1,8 +1,8 @@ -import React, { type FC, useRef } from 'react'; +import React, { type FC, useEffect, useRef } from 'react'; import cn from 'classnames'; import { Badge } from '@alfalab/core-components-badge'; -import { getDataTestId } from '@alfalab/core-components-shared'; +import { getDataTestId, useSpringAnimation } from '@alfalab/core-components-shared'; import { useFocus } from '@alfalab/hooks'; import { type CommonProps } from '../../types/common-props'; @@ -71,6 +71,8 @@ interface StepProps extends CommonProps { * @param stepNumber - номер шага */ onClick: (stepNumber: number) => void; + + animateSpring?: boolean; } export const Step: FC = ({ @@ -94,8 +96,12 @@ export const Step: FC = ({ minSpaceBetweenSteps = 24, completedDashColor, dataTestId, + animateSpring, }) => { const stepRef = useRef(null); + const optionRef = useRef(null); + const prevOptionSelected = useRef(isSelected); + const prevIsStepCompleted = useRef(isStepCompleted); const [focused] = useFocus(stepRef, 'keyboard'); @@ -176,6 +182,23 @@ export const Step: FC = ({ /> ); + const pulseAnim = useSpringAnimation(optionRef, 'pulse'); + + useEffect(() => { + if (animateSpring) { + if (!prevIsStepCompleted.current && isStepCompleted) { + pulseAnim.trigger(); + } + if (!prevOptionSelected.current && isSelected) { + pulseAnim.trigger(); + } + prevIsStepCompleted.current = isStepCompleted; + prevOptionSelected.current = isSelected; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isStepCompleted, isSelected]); + return (
= ({ [styles.vertical]: isVerticalAlign, [styles.error]: isError, })} + ref={optionRef} > {getStepIndicator()}
diff --git a/packages/steps/src/docs/description.mdx b/packages/steps/src/docs/description.mdx index 3b1ca4b68b..aeb08dafef 100644 --- a/packages/steps/src/docs/description.mdx +++ b/packages/steps/src/docs/description.mdx @@ -1,227 +1,43 @@ -## Примеры - -Горизонтальный. - -```jsx live desktopOnly -render(() => { - const [ordered, setOrdered] = React.useState(true); - const [error, setError] = React.useState(false); - const [step, setStep] = React.useState(2); - - const handleStepsChange = (stepNumber) => setStep(stepNumber); - - const handleOrderChange = () => setOrdered((prev) => !prev); - - const handleErrorChange = () => { - setError((prev) => { - if (!prev && step > 3) { - setStep(3); - } - - return !prev; - }); - }; - - const handleStepDisable = (stepNumber) => { - if (error) { - return stepNumber === 4 || stepNumber === 5; - } - - return stepNumber === 4; - }; - - const handleStepError = (stepNumber) => { - if (error) { - return stepNumber === 3; - } - }; - - return ( - - - {[1, 2, 3, 4, 5].map((item, key) => ( -
Шаг {item}
- ))} -
- - Выбран шаг {step} - - <> - - - - -
- ); -}); -``` - -Вертикальный. +# Приведённый код и live-демо имеют демонстрационный характер и не являются финальной реализацией ```jsx live mobileHeight={560} render(() => { - const [ordered, setOrdered] = React.useState(true); - const [error, setError] = React.useState(false); - const [step, setStep] = React.useState(2); - - const handleReset = () => setStep(1); - - const handleStepsChange = (stepNumber) => setStep(stepNumber); - - const handleOrderChange = () => setOrdered((prev) => !prev); + const [step, setStep] = React.useState(1); - const handleErrorChange = () => { - setError((prev) => { - if (!prev && step > 3) { - setStep(3); + const handleClick = () => { + setStep((prev) => { + if (prev === 5) { + return 1; } - - return !prev; + return prev + 1; }); }; - const handleStepDisable = (stepNumber) => { - if (error) { - return stepNumber === 4 || stepNumber === 5; - } - - return stepNumber === 4; - }; - - const handleStepError = (stepNumber) => { - if (error) { - return stepNumber === 3; - } - }; - return ( - {[1, 2, 3, 4, 5].map((item, key) => ( + {[1, 2, 3, 4].map((item, key) => ( Шаг {item} - - {item === 2 && ( - -
- - Label - - - Label - -
-
- )}
))}
- - Выбран шаг {step} - - <> - - - - -
- ); -}); -``` - -Виды индикаторов. - -```jsx live -render(() => { - const styles = { - display: 'flex', - alignItems: 'center', - gap: '12px', - }; - - const stylesCustom = { - width: 24, - height: 24, - background: 'rgba(55, 120, 251, 0.1)', - border: '1px dashed #3778FB', - borderRadius: 8, - boxSizing: 'border-box', - }; - - const viewIndicators = [ - { - view: 'positive-checkmark', - content: 'Completed', - }, - { - view: 'positive-checkmark', - content: 'Positive', - }, - { - view: 'negative-alert', - content: 'Negative', - }, - { - view: 'negative-cross', - content: 'CriticalError', - }, - { - view: 'attention-alert', - content: 'Warning', - }, - { - view: 'neutral-operation', - content: 'Warning', - icon: , - }, - { - content: 'Custom', - }, - ]; - return ( - - {viewIndicators.map((indicator, key) => ( -
- {indicator.view ? ( - - ) : ( -
- )} - - {indicator.content} - -
- ))} + ); }); diff --git a/packages/universal-modal/src/desktop/components/center-modal/center-modal.tsx b/packages/universal-modal/src/desktop/components/center-modal/center-modal.tsx index 5a2d7b4efa..d7db4437c2 100644 --- a/packages/universal-modal/src/desktop/components/center-modal/center-modal.tsx +++ b/packages/universal-modal/src/desktop/components/center-modal/center-modal.tsx @@ -52,6 +52,20 @@ export const CenterModal = forwardRef( return ( { - const [open, setOpen] = React.useState(false); - - return ( - - - setOpen(false)} - > -
-
-
-
-
-
-
-
-
-
-
- - - ); -}); - -//MOBILE -const dashedBackground = { - boxSizing: 'border-box', - border: '1px dashed #2288FA', - backgroundColor: '#D8EAFF', - borderRadius: 8, -}; - -const headerAnatomyStyles = { - padding: '20px 20px 0', - display: 'flex', - addonStyles: { - width: 48, - height: 48, - ...dashedBackground, - }, - titleStyles: { - flexGrow: 1, - height: 48, - ...dashedBackground, - }, -}; - -const contentAnatomyStyles = { - padding: '0 32px', - height: 336, - content: { - height: '100%', - ...dashedBackground, - }, - flex: 1, -}; - -const footerAnatomyStyles = { - boxSizing: 'border-box', - padding: '16px 32px 32px', - footer: { - height: 48, - ...dashedBackground, - }, -}; -render(() => { - const [open, setOpen] = React.useState(false); - - return ( - - - setOpen(false)} - > -
-
-
-
-
-
-
-
-
-
-
- - - ); -}); -``` - -## Модальный экран +## Сайдпанель -Для десктопа в зависимости от положения и размера меняется стиль заголовка в хедере и размер кнопок в футере. +Выровненные по левому или правому краю модалки, занимающие всю доступную высоту, мы назывем сайд-панелями. ```jsx live mobileHeight={835} -const verticalOptions = [ - { key: 'top', content: 'Top' }, - { key: 'center', content: 'Center' }, - { key: 'bottom', content: 'Bottom' }, -]; - -const heightOptions = [ - { key: 'hugContent', content: 'Hug Content', value: 'hugContent' }, - { key: '500', content: '500', value: 500 }, - { key: '600', content: '600', value: 600 }, - { key: '800', content: '800', value: 800 }, - { key: 'fullHeight', content: 'Full', value: 'fullHeight' }, +const horizontalOptions = [ + { key: 'start', content: 'Start' }, + { key: 'end', content: 'End' }, ]; const widthOptions = [ @@ -241,10 +91,8 @@ const collapse = () => { render(() => { const [open, setOpen] = React.useState(false); - const [verticalAlign, setVerticalAlign] = React.useState('center'); + const [horizontalAlign, setHorizontalAlign] = React.useState('end'); const [modalWidth, setModalWidth] = React.useState('500'); - const [modalHeight, setModalHeight] = React.useState('hugContent'); - const [selected, setSelected] = React.useState('hugContent'); const [headerButtonCross, setHeaderButtonCross] = React.useState(true); const [headerButtonArrow, setHeaderButtonArrow] = React.useState(false); @@ -253,175 +101,65 @@ render(() => { const [mainFooterButton, setMainFooterButton] = React.useState(true); const [additionalFooterButton, setAdditionalFooterButton] = React.useState(false); const [verticalFooterButtons, setVerticalFooterButtons] = React.useState(false); - const [stretchFooterButtons, setStretchFooterButtons] = React.useState(false); const [contentTitle, setContentTitle] = React.useState(false); const [contentButton, setContentButton] = React.useState(false); + const [disabled, setDisabled] = React.useState(false); + const [spring, setSpring] = React.useState(true); + const getButtonSize = () => { - if (modalWidth >= 800) { - return 56; - } - return 48; + return 56; }; return ( - - - - - -
- { - setModalHeight(selected.value); - setSelected(selected.key); - }} - /> -
-
- setModalWidth(selected.key)} - /> -
-
- setVerticalAlign(selected.key)} - /> -
-
- - Шапка - -
- setHeaderButtonCross((prev) => !prev)} - /> - - setHeaderButtonArrow((prev) => !prev)} - /> - -
- setHeaderTitle((prev) => !prev)} - /> -
-
- - Футер - -
- { - setContentButton(false); - setMainFooterButton((prev) => !prev); - }} - /> - - { - setContentButton(false); - setAdditionalFooterButton((prev) => !prev); - }} - /> - - setStretchFooterButtons((prev) => !prev)} - /> - - setVerticalFooterButtons((prev) => !prev)} - /> -
- - Контент +
{ - setContentTitle((prev) => !prev); - }} - /> - - { - setMainFooterButton(false); - setAdditionalFooterButton(false); - setContentButton((prev) => !prev); - }} + label='Spring' + checked={spring} + onChange={() => setSpring((prev) => !prev)} />
setOpen(false)} + springAnimation={ + spring + ? { + onSpringStart: () => setDisabled(true), + onSpringEnd: () => setDisabled(false), + } + : undefined + } > - {(headerTitle || headerButtonArrow || headerButtonCross) && ( - = 800 || modalWidth === 'fullWidth', - })} - {...(headerButtonArrow && { - hasBackButton: headerButtonArrow, - onBack: () => setOpen(false), - align: 'center', - ...(headerTitle && { - bottomAddons: 'Почему банк проверяет мои операции?', - bigTitle: modalWidth >= 800 || modalWidth === 'fullWidth', - }), + = 800 || modalWidth === 'fullWidth', })} - hasCloser={headerButtonCross} - /> - )} + {...(headerButtonArrow && { + hasBackButton: headerButtonArrow, + onBack: () => setOpen(false), + align: 'center', + ...(headerTitle && { + bottomAddons: 'Почему банк проверяет мои операции?', + bigTitle: modalWidth >= 800 || modalWidth === 'fullWidth', + }), + })} + hasCloser={headerButtonCross} + /> {contentTitle && ( @@ -455,7 +193,6 @@ render(() => { - - - - Шапка - -
- setHeaderButtonCross((prev) => !prev)} - /> - - setHeaderButtonArrow((prev) => !prev)} - /> - - { - setContentTitle(false); - setHeaderTitle((prev) => !prev); - }} - /> - - setSubtitle((prev) => !prev)} - /> - - setAlignment((prev) => !prev)} - /> -
- - Настройки футера - -
- { - setContentButton(false); - setMainFooterButton((prev) => !prev); - }} - /> - - { - setContentButton(false); - setAdditionalFooterButton((prev) => !prev); - }} - /> - - setVerticalFooterButtons((prev) => !prev)} - /> -
- - Настройки контента +
{ - setHeaderTitle(false); - setContentTitle((prev) => !prev); - }} - /> - - { - setMainFooterButton(false); - setAdditionalFooterButton(false); - setContentButton((prev) => !prev); - }} + label='Sping' + checked={spring} + onChange={() => setSpring((prev) => !prev)} />
- setOpen(false)}> + setOpen(false)} + springAnimation={ + spring + ? { + onSpringStart: () => setDisabled(true), + onSpringEnd: () => setDisabled(false), + } + : undefined + } + > {(headerTitle || headerButtonArrow || headerButtonCross) && ( - = 800 || modalWidth === 'fullWidth', + })} {...(headerButtonArrow && { - leftAddons: ( - - } - onClick={() => setOpen(false)} - /> - ), + hasBackButton: headerButtonArrow, + onBack: () => setOpen(false), + align: 'center', + ...(headerTitle && { + bottomAddons: 'Почему банк проверяет мои операции?', + bigTitle: modalWidth >= 800 || modalWidth === 'fullWidth', + }), })} hasCloser={headerButtonCross} - {...(alignment && { align: 'center' })} - {...(headerTitle && { title: 'Почему банк проверяет мои операции?' })} - {...(subtitle && { subtitle: 'Подпись', titleSize: 'compact' })} /> )} - + {contentTitle && ( @@ -698,29 +404,30 @@ render(() => { )} - {textMobile()} + {text()} - {collapseMobile()} + {collapse()} {contentButton && ( - + + )} - +
{(mainFooterButton || additionalFooterButton) && ( - {mainFooterButton && ( )} - + )} - -
- ); -}); -``` - -## Сайдпанель - -Выровненные по левому или правому краю модалки, занимающие всю доступную высоту, мы назывем сайд-панелями. - -```jsx live mobileHeight={835} -const horizontalOptions = [ - { key: 'start', content: 'Start' }, - { key: 'end', content: 'End' }, -]; - -const widthOptions = [ - { key: '500', content: '500' }, - { key: '600', content: '600' }, - { key: '800', content: '800' }, - { key: 'fullWidth', content: 'Full' }, -]; - -const text = () => { - return ( - - - В 2001 году в России начал действовать Федеральный закон №115 «О противодействии - легализации доходов, полученных преступным путём, и финансированию терроризма». В - рамках закона банки могут блокировать карты, отказывать в проведении сомнительных - операций, ограничить доступ в интернет-банк или запрашивать документы, если по - операции клиента возникли подозрения. - -
- - Требования 115-ФЗ и связанных с ним документов Банка России часто меняются, - предприниматели не всегда успевают за ними следить. Последствия нарушений - «антиотмывочного» законодательства всегда неприятны: приходится остановить - бизнес-процессы и доказать банку законность операций. Специалисты «Альфа-банка» - собрали понятные рекомендации, как сэкономить время на объяснения и предотвратить - блокировки - -
- ); -}; - -const collapse = () => { - return ( - - - 115-ФЗ Касается всех предпринимателей, фирм и физлиц, а также тех, кто пользуется - банковским счётом для бизнеса, крупных денежных переводов или личных расчётов. - Ограничения интернет-банка, блокировка карт добросовестных компаний могут произойти - из-за неправильно оформленных документов, ошибок в платёжке или попыток снизить - налоги. - -
- - Клиенты воспринимают ограничения как атаку со стороны банка, но чаще всего сами - допускают ошибки или нарушения, которых можно избежать. Банки не преследуют цели - доставить неудобства клиентам — они обязаны соблюдать законодательство и следовать - инструкциям и рекомендациям ЦБ, а в противном случае рискуют лишиться лицензии. - -
- - Обналичивание — сомнительные операции, когда юрлицо или предприниматель снимает со - счёта более 80% от оборота или переводит деньги на счета физлиц, которые затем - снимают в наличной форме. - -
- - Вывод капитала за границу — это переводы нерезидентам по договорам об импорте - работ/услуг и результатов интеллектуальной деятельности, по которым проведение - расчётов осуществляется без одновременной уплаты НДС; по сделкам купли-продажи - ценных бумаг, а также товаров, которые не пересекают границу России. - -
- - Транзитные операции — операции, в процессе которых деньги поступают на счёт компании - от других резидентов и списываются в короткие сроки. При этом, как правило, в этих - случаях по счёту нет начислений зарплат, уплаты налогов, и они не соответствуют - заявленному компанией виду деятельности. - -
- - Запрашивать могут любые документы и устанавливать разные сроки их предоставления — - это зависит от службы контроля конкретного банка. Обычно банки запрашивают чеки, - счета или договора с контрагентами. В некоторых случаях бывает достаточно устных - объяснений. Для проверки информации и пересмотра уровня риска банк может пригласить - клиента в банк для устного разъяснения или выехать по месту ведения бизнеса клиента. - -
- ); -}; - -render(() => { - const [open, setOpen] = React.useState(false); - const [horizontalAlign, setHorizontalAlign] = React.useState('end'); - const [modalWidth, setModalWidth] = React.useState('500'); - - const [headerButtonCross, setHeaderButtonCross] = React.useState(true); - const [headerButtonArrow, setHeaderButtonArrow] = React.useState(false); - const [headerTitle, setHeaderTitle] = React.useState(false); - - const [mainFooterButton, setMainFooterButton] = React.useState(true); - const [additionalFooterButton, setAdditionalFooterButton] = React.useState(false); - const [verticalFooterButtons, setVerticalFooterButtons] = React.useState(false); - - const [contentTitle, setContentTitle] = React.useState(false); - const [contentButton, setContentButton] = React.useState(false); - - const getButtonSize = () => { - return 56; - }; - - return ( - - - - - - -
- setModalWidth(selected.key)} - /> -
-
- setHorizontalAlign(selected.key)} - /> -
-
- - Шапка - -
- setHeaderButtonCross((prev) => !prev)} - /> - - setHeaderButtonArrow((prev) => !prev)} - /> - -
- setHeaderTitle((prev) => !prev)} - /> -
-
- - Футер - -
- { - setContentButton(false); - setMainFooterButton((prev) => !prev); - }} - /> - - { - setContentButton(false); - setAdditionalFooterButton((prev) => !prev); - }} - /> - - setVerticalFooterButtons((prev) => !prev)} - /> -
- - Контент - -
- { - setContentTitle((prev) => !prev); - }} - /> - - { - setMainFooterButton(false); - setAdditionalFooterButton(false); - setContentButton((prev) => !prev); - }} - /> -
- setOpen(false)} - > - = 800 || modalWidth === 'fullWidth', - })} - {...(headerButtonArrow && { - hasBackButton: headerButtonArrow, - onBack: () => setOpen(false), - align: 'center', - ...(headerTitle && { - bottomAddons: 'Почему банк проверяет мои операции?', - bigTitle: modalWidth >= 800 || modalWidth === 'fullWidth', - }), - })} - hasCloser={headerButtonCross} - /> - - {contentTitle && ( - - - Почему банк проверяет мои операции? - - - - )} - - {text()} - - {collapse()} - - {contentButton && ( - - - - - - )} - - {(mainFooterButton || additionalFooterButton) && ( - - {mainFooterButton && ( - - )} - - {additionalFooterButton && ( - - )} - - )} - -
- ); -}); - -//MOBILE -const textMobile = () => { - return ( - - - В 2001 году в России начал действовать Федеральный закон №115 «О противодействии - легализации доходов, полученных преступным путём, и финансированию терроризма». В - рамках закона банки могут блокировать карты, отказывать в проведении сомнительных - операций, ограничить доступ в интернет-банк или запрашивать документы, если по - операции клиента возникли подозрения. - -
- - Требования 115-ФЗ и связанных с ним документов Банка России часто меняются, - предприниматели не всегда успевают за ними следить. Последствия нарушений - «антиотмывочного» законодательства всегда неприятны: приходится остановить - бизнес-процессы и доказать банку законность операций. Специалисты «Альфа-банка» - собрали понятные рекомендации, как сэкономить время на объяснения и предотвратить - блокировки - -
- ); -}; - -const collapseMobile = () => { - return ( - - - 115-ФЗ Касается всех предпринимателей, фирм и физлиц, а также тех, кто пользуется - банковским счётом для бизнеса, крупных денежных переводов или личных расчётов. - Ограничения интернет-банка, блокировка карт добросовестных компаний могут произойти - из-за неправильно оформленных документов, ошибок в платёжке или попыток снизить - налоги. - -
- - Клиенты воспринимают ограничения как атаку со стороны банка, но чаще всего сами - допускают ошибки или нарушения, которых можно избежать. Банки не преследуют цели - доставить неудобства клиентам — они обязаны соблюдать законодательство и следовать - инструкциям и рекомендациям ЦБ, а в противном случае рискуют лишиться лицензии. - -
- - Обналичивание — сомнительные операции, когда юрлицо или предприниматель снимает со - счёта более 80% от оборота или переводит деньги на счета физлиц, которые затем - снимают в наличной форме. - -
- - Вывод капитала за границу — это переводы нерезидентам по договорам об импорте - работ/услуг и результатов интеллектуальной деятельности, по которым проведение - расчётов осуществляется без одновременной уплаты НДС; по сделкам купли-продажи - ценных бумаг, а также товаров, которые не пересекают границу России. - -
- - Транзитные операции — операции, в процессе которых деньги поступают на счёт компании - от других резидентов и списываются в короткие сроки. При этом, как правило, в этих - случаях по счёту нет начислений зарплат, уплаты налогов, и они не соответствуют - заявленному компанией виду деятельности. - -
- - Запрашивать могут любые документы и устанавливать разные сроки их предоставления — - это зависит от службы контроля конкретного банка. Обычно банки запрашивают чеки, - счета или договора с контрагентами. В некоторых случаях бывает достаточно устных - объяснений. Для проверки информации и пересмотра уровня риска банк может пригласить - клиента в банк для устного разъяснения или выехать по месту ведения бизнеса клиента. - -
- ); -}; - -render(() => { - const [open, setOpen] = React.useState(false); - - const [headerButtonCross, setHeaderButtonCross] = React.useState(true); - const [headerButtonArrow, setHeaderButtonArrow] = React.useState(false); - const [headerTitle, setHeaderTitle] = React.useState(true); - - const [mainFooterButton, setMainFooterButton] = React.useState(true); - const [additionalFooterButton, setAdditionalFooterButton] = React.useState(false); - const [verticalFooterButtons, setVerticalFooterButtons] = React.useState(false); - const [subtitle, setSubtitle] = React.useState(true); - const [alignment, setAlignment] = React.useState(false); - - const [contentTitle, setContentTitle] = React.useState(false); - const [contentButton, setContentButton] = React.useState(false); - - return ( - - - - - - Шапка - -
- setHeaderButtonCross((prev) => !prev)} - /> - - - setHeaderButtonArrow((prev) => !prev)} - /> - - - { - setContentTitle(false); - setHeaderTitle((prev) => !prev); - }} - /> - - setSubtitle((prev) => !prev)} - /> - - setAlignment((prev) => !prev)} - /> -
- - Настройки футера - -
- { - setContentButton(false); - setMainFooterButton((prev) => !prev); - }} - /> - - { - setContentButton(false); - setAdditionalFooterButton((prev) => !prev); - }} - /> - - setVerticalFooterButtons((prev) => !prev)} - /> -
- - Настройки контента - -
- { - setHeaderTitle(false); - setContentTitle((prev) => !prev); - }} - /> - - { - setMainFooterButton(false); - setAdditionalFooterButton(false); - setContentButton((prev) => !prev); - }} - /> -
- setOpen(false)}> - - } - onClick={() => setOpen(false)} - /> - ), - align: 'left', - ...(headerTitle && { title: 'Почему банк проверяет мои операции?' }), - })} - hasCloser={headerButtonCross} - {...(alignment && { align: 'center' })} - {...(subtitle && { subtitle: 'Подпись', titleSize: 'compact' })} - /> - - {contentTitle && ( - - - Почему банк проверяет мои операции? - - - - )} - - {textMobile()} - - {collapseMobile()} - - {contentButton && ( - - - - - )} - - - {(mainFooterButton || additionalFooterButton) && ( - - {mainFooterButton && ( - - )} - - {additionalFooterButton && ( - - )} - - )} - -
- ); -}); -``` - -## Настройка отступов - -Чтобы корректно расположить модалку вблизи края экрана можно воспользоваться изменяемыми отступами. - -```jsx live desktopOnly -const margins = [ - { key: '0', content: '0' }, - { key: '2', content: '2' }, - { key: '4', content: '4' }, - { key: '8', content: '8' }, - { key: '12', content: '12' }, - { key: '16', content: '16' }, - { key: '20', content: '20' }, - { key: '24', content: '24' }, - { key: '32', content: '32' }, - { key: '40', content: '40' }, - { key: '48', content: '48' }, - { key: '56', content: '56' }, - { key: '64', content: '64' }, - { key: '72', content: '72' }, - { key: '80', content: '80' }, - { key: '96', content: '96' }, - { key: '128', content: '128' }, -]; - -render(() => { - const [open, setOpen] = React.useState(false); - - const [margin, setMargin] = React.useState({ - top: 12, - right: 12, - bottom: 12, - left: 12, - }); - - const updateMargin = (value, key) => { - setMargin((prev) => { - return { - ...prev, - [key]: Number(value), - }; - }); - }; - - return ( - - - - - -
-
-
- updateMargin(selected.key, 'left')} - /> -
- -
- updateMargin(selected.key, 'right')} - /> -
-
- -
-
- updateMargin(selected.key, 'top')} - /> -
- -
- updateMargin(selected.key, 'bottom')} - /> -
-
-
- setOpen(false)} - > - - - - В 2001 году в России начал действовать Федеральный закон №115 «О - противодействии легализации доходов, полученных преступным путём, и - финансированию терроризма». В рамках закона банки могут блокировать карты, - отказывать в проведении сомнительных операций, ограничить доступ в - интернет-банк или запрашивать документы, если по операции клиента возникли - подозрения. - -
- - Требования 115-ФЗ и связанных с ним документов Банка России часто меняются, - предприниматели не всегда успевают за ними следить. Последствия нарушений - «антиотмывочного» законодательства всегда неприятны: приходится остановить - бизнес-процессы и доказать банку законность операций. Специалисты - «Альфа-банка» собрали понятные рекомендации, как сэкономить время на - объяснения и предотвратить блокировки - - - - - 115-ФЗ Касается всех предпринимателей, фирм и физлиц, а также тех, кто - пользуется банковским счётом для бизнеса, крупных денежных переводов или - личных расчётов. Ограничения интернет-банка, блокировка карт - добросовестных компаний могут произойти из-за неправильно оформленных - документов, ошибок в платёжке или попыток снизить налоги. - -
- - Клиенты воспринимают ограничения как атаку со стороны банка, но чаще - всего сами допускают ошибки или нарушения, которых можно избежать. Банки - не преследуют цели доставить неудобства клиентам — они обязаны соблюдать - законодательство и следовать инструкциям и рекомендациям ЦБ, а в - противном случае рискуют лишиться лицензии. - -
- - Обналичивание — сомнительные операции, когда юрлицо или предприниматель - снимает со счёта более 80% от оборота или переводит деньги на счета - физлиц, которые затем снимают в наличной форме. - -
- - Вывод капитала за границу — это переводы нерезидентам по договорам об - импорте работ/услуг и результатов интеллектуальной деятельности, по - которым проведение расчётов осуществляется без одновременной уплаты НДС; - по сделкам купли-продажи ценных бумаг, а также товаров, которые не - пересекают границу России. - -
- - Транзитные операции — операции, в процессе которых деньги поступают на - счёт компании от других резидентов и списываются в короткие сроки. При - этом, как правило, в этих случаях по счёту нет начислений зарплат, - уплаты налогов, и они не соответствуют заявленному компанией виду - деятельности. - -
- - Запрашивать могут любые документы и устанавливать разные сроки их - предоставления — это зависит от службы контроля конкретного банка. - Обычно банки запрашивают чеки, счета или договора с контрагентами. В - некоторых случаях бывает достаточно устных объяснений. Для проверки - информации и пересмотра уровня риска банк может пригласить клиента в - банк для устного разъяснения или выехать по месту ведения бизнеса - клиента. - -
-
- - - - -
-
- ); -}); -``` - -## Изменение размеров - -Размером десктопной модалки или сайдпанели можно управлять через UI. - -```jsx live desktopOnly -render(() => { - const [open, setOpen] = React.useState(false); - const [dialogWidth, setDialogWidth] = React.useState(0); - const dialogRef = React.useRef(null); - - const [modalWidth, setModalWidth] = React.useState(500); - - const handleIncrease = () => { - setModalWidth((prev) => prev + 200); - }; - - const handleDecrease = () => { - setModalWidth((prev) => prev - 200); - }; - - React.useEffect(() => { - if (!dialogRef.current) { - dialogRef.current = document.querySelector('div[role="dialog"]'); - } - - if (dialogRef.current) { - setDialogWidth(dialogRef.current.clientWidth); - } - - return () => { - dialogRef.current = null; - }; - }); - - return ( - - - setOpen(false)} - > - - - - В 2001 году в России начал действовать Федеральный закон №115 «О - противодействии легализации доходов, полученных преступным путём, и - финансированию терроризма». В рамках закона банки могут блокировать карты, - отказывать в проведении сомнительных операций, ограничить доступ в - интернет-банк или запрашивать документы, если по операции клиента возникли - подозрения. - -
- - Требования 115-ФЗ и связанных с ним документов Банка России часто меняются, - предприниматели не всегда успевают за ними следить. Последствия нарушений - «антиотмывочного» законодательства всегда неприятны: приходится остановить - бизнес-процессы и доказать банку законность операций. Специалисты - «Альфа-банка» собрали понятные рекомендации, как сэкономить время на - объяснения и предотвратить блокировки - - - - - 115-ФЗ Касается всех предпринимателей, фирм и физлиц, а также тех, кто - пользуется банковским счётом для бизнеса, крупных денежных переводов или - личных расчётов. Ограничения интернет-банка, блокировка карт - добросовестных компаний могут произойти из-за неправильно оформленных - документов, ошибок в платёжке или попыток снизить налоги. - -
- - Клиенты воспринимают ограничения как атаку со стороны банка, но чаще - всего сами допускают ошибки или нарушения, которых можно избежать. Банки - не преследуют цели доставить неудобства клиентам — они обязаны соблюдать - законодательство и следовать инструкциям и рекомендациям ЦБ, а в - противном случае рискуют лишиться лицензии. - -
- - Обналичивание — сомнительные операции, когда юрлицо или предприниматель - снимает со счёта более 80% от оборота или переводит деньги на счета - физлиц, которые затем снимают в наличной форме. - -
- - Вывод капитала за границу — это переводы нерезидентам по договорам об - импорте работ/услуг и результатов интеллектуальной деятельности, по - которым проведение расчётов осуществляется без одновременной уплаты НДС; - по сделкам купли-продажи ценных бумаг, а также товаров, которые не - пересекают границу России. - -
- - Транзитные операции — операции, в процессе которых деньги поступают на - счёт компании от других резидентов и списываются в короткие сроки. При - этом, как правило, в этих случаях по счёту нет начислений зарплат, - уплаты налогов, и они не соответствуют заявленному компанией виду - деятельности. - -
- - Запрашивать могут любые документы и устанавливать разные сроки их - предоставления — это зависит от службы контроля конкретного банка. - Обычно банки запрашивают чеки, счета или договора с контрагентами. В - некоторых случаях бывает достаточно устных объяснений. Для проверки - информации и пересмотра уровня риска банк может пригласить клиента в - банк для устного разъяснения или выехать по месту ведения бизнеса - клиента. - -
-
- - - - -
-
- ); -}); -``` - -## Смена контента - -Компонент можно настроить для реализации многошаговых модалок. В этом случае верхний край модалки должен быть зафиксирован, чтобы избежать неприятных скачков. - -```jsx live mobileHeight={640} -const modalsData = { - 1: { - title: 'Первый уровень', - btnText: 'Дальше', - bg: '#D8EAFF', - color: '#2288FA', - }, - 2: { - title: 'Второй уровень', - btnText: 'Дальше', - bg: '#D1F1D7', - color: '#0CC44D', - }, - 3: { - title: 'Третий уровень', - btnText: 'Готово', - bg: '#FDE6C8', - color: '#FA9313', - }, -}; - -render(() => { - const [open, setOpen] = React.useState(false); - const [step, setStep] = React.useState(1); - - const item = modalsData[step]; - - const commonStyles = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'background 0.2s ease-in, border 0.2s ease-in', - borderRadius: '8px', - background: item.bg, - color: item.color, - width: '100%', - boxSizing: 'border-box', - }; - - const handleNextButtonClick = () => { - if (step === Object.keys(modalsData).length) { - setOpen(false); - return; - } - setStep((prev) => prev + 1); - }; - - return ( - - - setOpen(false)}> - - {item.title} - - ), - })} - {...(step > 1 && { - bottomAddons: ( - - {item.title} - - ), - })} - {...(step > 1 && { - hasBackButton: true, - onBack: () => setStep((prev) => prev - 1), - })} - hasCloser={true} - align='center' - {...(step === 1 && { align: 'left' })} - /> - -
- {step} -
-
- - - -
-
- ); -}); - -//MOBILE -const modalsDataMobile = { - 1: { - title: 'Первый уровень', - btnText: 'Дальше', - bg: '#D8EAFF', - color: '#2288FA', - }, - 2: { - title: 'Второй уровень', - btnText: 'Дальше', - bg: '#D1F1D7', - color: '#0CC44D', - }, - 3: { - title: 'Третий уровень', - btnText: 'Готово', - bg: '#FDE6C8', - color: '#FA9313', - }, -}; -render(() => { - const [open, setOpen] = React.useState(false); - const [step, setStep] = React.useState(1); - - const item = modalsDataMobile[step]; - - const commonStyles = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'background 0.2s ease-in, border 0.2s ease-in', - borderRadius: '8px', - background: item.bg, - color: item.color, - width: '100%', - boxSizing: 'border-box', - }; - - const handleNextButtonClick = () => { - if (step === Object.keys(modalsDataMobile).length) { - setOpen(false); - return; - } - setStep((prev) => prev + 1); - }; - - return ( - - - setOpen(false)}> - - {item.title} - - ), - })} - {...(step > 1 && { - bottomAddons: ( - - {item.title} - - ), - })} - {...(step > 1 && { - leftAddons: ( - - } - onClick={() => setStep((prev) => prev - 1)} - /> - ), - })} - hasCloser={true} - align='center' - {...(step === 1 && { align: 'left' })} - /> - -
- {step} -
-
- - - -
-
- ); -}); -``` - -## Смена модалок - -При смене одной модалки на другую важно дождаться окончания анимации закрытия первой модалки перед появлением новой. - -```jsx live desktopOnly -render(() => { - const [openModal, setOpenModal] = React.useState(false); - const [openSecondModal, setOpenSecondModal] = React.useState(false); - const [backdropOpen, setBackdropOpen] = React.useState(false); - - const handleButtonClick = () => { - setOpenModal(true); - setBackdropOpen(true); - }; - - const handleCloseModal = () => { - setOpenModal(false); - setTimeout(() => { - setOpenSecondModal(true); - }, 300); - }; - - const handleCloseSecondModal = () => { - setOpenSecondModal(false); - setBackdropOpen(false); - }; - - return ( -
- - - - - - - В 2001 году в России начал действовать Федеральный закон №115 «О - противодействии легализации доходов, полученных преступным путём, и - финансированию терроризма». В рамках закона банки могут блокировать карты, - отказывать в проведении сомнительных операций, ограничить доступ в - интернет-банк или запрашивать документы, если по операции клиента возникли - подозрения. - - - - - - - - - - - - В 2001 году в России начал действовать Федеральный закон №115 «О - противодействии легализации доходов, полученных преступным путём, и - финансированию терроризма». - - - - - - -
- ); -}); -``` - -## Оверлей - -Чтобы дать доступ одновременно к модальному и немодальному слою можно отключить оверлей у десктопной версии модалки. - -```jsx live desktopOnly -render(() => { - const [open, setOpen] = React.useState(false); - - return ( - - - setOpen(false)} - margin={{ top: 12, right: 12, bottom: 12, left: 12 }} - > - - - - В 2001 году в России начал действовать Федеральный закон №115 «О - противодействии легализации доходов, полученных преступным путём, и - финансированию терроризма». В рамках закона банки могут блокировать карты, - отказывать в проведении сомнительных операций, ограничить доступ в - интернет-банк или запрашивать документы, если по операции клиента возникли - подозрения. - -
- - Требования 115-ФЗ и связанных с ним документов Банка России часто меняются, - предприниматели не всегда успевают за ними следить. Последствия нарушений - «антиотмывочного» законодательства всегда неприятны: приходится остановить - бизнес-процессы и доказать банку законность операций. Специалисты - «Альфа-банка» собрали понятные рекомендации, как сэкономить время на - объяснения и предотвратить блокировки - - - - - 115-ФЗ Касается всех предпринимателей, фирм и физлиц, а также тех, кто - пользуется банковским счётом для бизнеса, крупных денежных переводов или - личных расчётов. Ограничения интернет-банка, блокировка карт - добросовестных компаний могут произойти из-за неправильно оформленных - документов, ошибок в платёжке или попыток снизить налоги. - -
- - Клиенты воспринимают ограничения как атаку со стороны банка, но чаще - всего сами допускают ошибки или нарушения, которых можно избежать. Банки - не преследуют цели доставить неудобства клиентам — они обязаны соблюдать - законодательство и следовать инструкциям и рекомендациям ЦБ, а в - противном случае рискуют лишиться лицензии. - -
- - Обналичивание — сомнительные операции, когда юрлицо или предприниматель - снимает со счёта более 80% от оборота или переводит деньги на счета - физлиц, которые затем снимают в наличной форме. - -
- - Вывод капитала за границу — это переводы нерезидентам по договорам об - импорте работ/услуг и результатов интеллектуальной деятельности, по - которым проведение расчётов осуществляется без одновременной уплаты НДС; - по сделкам купли-продажи ценных бумаг, а также товаров, которые не - пересекают границу России. - -
- - Транзитные операции — операции, в процессе которых деньги поступают на - счёт компании от других резидентов и списываются в короткие сроки. При - этом, как правило, в этих случаях по счёту нет начислений зарплат, - уплаты налогов, и они не соответствуют заявленному компанией виду - деятельности. - -
- - Запрашивать могут любые документы и устанавливать разные сроки их - предоставления — это зависит от службы контроля конкретного банка. - Обычно банки запрашивают чеки, счета или договора с контрагентами. В - некоторых случаях бывает достаточно устных объяснений. Для проверки - информации и пересмотра уровня риска банк может пригласить клиента в - банк для устного разъяснения или выехать по месту ведения бизнеса - клиента. - -
-
- - - -
); diff --git a/yarn.lock b/yarn.lock index 7df377a8de..d7f1c9bf2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1707,6 +1707,7 @@ __metadata: "@maskito/core": "npm:^1.7.0" classnames: "npm:^2.5.1" detect-browser: "npm:^5.3.0" + motion: "npm:^12.40.0" tslib: "npm:^2.4.0" peerDependencies: react: ^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 @@ -19406,6 +19407,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.41.0": + version: 12.41.0 + resolution: "framer-motion@npm:12.41.0" + dependencies: + motion-dom: "npm:^12.41.0" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/77c9676586f1e4632ec66d73eec18d512b59f3268e7e9c8ef4061b8ae0ce29724b740ccb81630c4632202d62ea06e91065b26b732dad21fdeae732461f597175 + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -25327,6 +25350,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.41.0": + version: 12.41.0 + resolution: "motion-dom@npm:12.41.0" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/9a7225979e54f575f7f5f9f72c9650bf5e7e818e6f9fd7cfe188896eeac5ba63b529dc61980456ae73774fa07b67e6d18e68144cce3102336b494dd6f249a088 + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"motion@npm:^12.40.0": + version: 12.41.0 + resolution: "motion@npm:12.41.0" + dependencies: + framer-motion: "npm:^12.41.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/ae14ea40cd6f4ce5e8c9e995e5a2335dba110b6bd5b2c4f148da25ce9f4f94e7c12d09d3466a46111fc6d3436429daf586c05a83e04be1dad7ed529be2570760 + languageName: node + linkType: hard + "mri@npm:^1.2.0": version: 1.2.0 resolution: "mri@npm:1.2.0"