diff --git a/src/components/Toaster/ToastList/ToastAnimation.scss b/src/components/Toaster/ToastList/ToastAnimation.scss index 0144642164..940223056e 100644 --- a/src/components/Toaster/ToastList/ToastAnimation.scss +++ b/src/components/Toaster/ToastList/ToastAnimation.scss @@ -1,7 +1,9 @@ @use '../../variables'; +@use './variables' as toastList; $transition-distance: 10px; $animation-duration: 0.6s; +$animation-diration-alternate: 0.75s; @mixin hidden-toast-height { margin-block-end: 0; @@ -42,6 +44,11 @@ $animation-duration: 0.6s; position: absolute; } + &_enter#{&}_feature_alternate-animation-timing-fn { + opacity: 0.5; + transform: translateX(calc(100% + #{toastList.$list-inset-end-position * 2})); + } + &_enter_active { animation: #{variables.$ns}toast-enter-#{$platform} $animation-duration @@ -54,6 +61,50 @@ $animation-duration: 0.6s; } } + // Alternative animation timing function + &_enter_active#{&}_feature_alternate-animation-timing-fn { + animation: #{variables.$ns}toast-enter-alternate-#{$platform} + $animation-diration-alternate + linear( + 0, + 0.002 0.3%, + 0.007 0.6%, + 0.033 1.3%, + 0.073 2%, + 0.125 2.7%, + 0.254 4.1%, + 0.683 8.3%, + 0.803 9.7%, + 0.897 11%, + 0.977 12.4%, + 1.036 13.8%, + 1.058 14.5%, + 1.078 15.3%, + 1.091 16%, + 1.101 16.8%, + 1.106 17.5%, + 1.108 18.3%, + 1.107 19.2%, + 1.103 20.1%, + 1.089 21.8%, + 1.038 26.5%, + 1.014 29.1%, + 1.005 30.5%, + 0.997 32%, + 0.992 33.5%, + 0.989 35.1%, + 0.989 38.6%, + 0.998 47.4%, + 1.001 53.2%, + 1 + ) + forwards; + + @media (prefers-reduced-motion: reduce) { + animation-name: #{variables.$ns}toast-enter-reduced-motion; + } + } + &_exit_active { animation: #{variables.$ns}toast-exit-#{$platform} $animation-duration ease-in forwards; @@ -99,6 +150,17 @@ $animation-duration: 0.6s; } } + @keyframes #{variables.$ns}toast-enter-alternate-#{$platform} { + 0% { + opacity: 0.5; + transform: translateX(calc(100% + #{toastList.$list-inset-end-position * 2})); + } + 100% { + opacity: 1; + transform: translateX(0); + } + } + @keyframes #{variables.$ns}toast-enter-reduced-motion { 0% { @include hidden-toast-opacity; diff --git a/src/components/Toaster/ToastList/ToastList.scss b/src/components/Toaster/ToastList/ToastList.scss index 08984bccae..7431597432 100644 --- a/src/components/Toaster/ToastList/ToastList.scss +++ b/src/components/Toaster/ToastList/ToastList.scss @@ -1,4 +1,5 @@ @use '../../variables'; +@use './variables' as toastList; $block: '.#{variables.$ns}toaster'; @@ -7,7 +8,7 @@ $block: '.#{variables.$ns}toaster'; position: fixed; inset-block-end: 0; - inset-inline-end: 10px; + inset-inline-end: toastList.$list-inset-end-position; width: var(--g-toaster-width, var(--_--width)); z-index: 100000; display: flex; diff --git a/src/components/Toaster/ToastList/ToastList.tsx b/src/components/Toaster/ToastList/ToastList.tsx index 08162dd6f7..d205f241f5 100644 --- a/src/components/Toaster/ToastList/ToastList.tsx +++ b/src/components/Toaster/ToastList/ToastList.tsx @@ -1,26 +1,31 @@ 'use client'; +import * as React from 'react'; + import {CSSTransition, TransitionGroup} from 'react-transition-group'; import {block} from '../../utils/cn'; import {getCSSTransitionClassNames} from '../../utils/transition'; import {Toast} from '../Toast/Toast'; -import type {InternalToastProps} from '../types'; +import type {InternalToastProps, ToastListProps} from '../types'; import './ToastAnimation.scss'; import './ToastList.scss'; -const desktopTransitionClassNames = getCSSTransitionClassNames(block('toast-animation-desktop')); -const mobileTransitionClassNames = getCSSTransitionClassNames(block('toast-animation-mobile')); - -type ToastListProps = { - removeCallback: (name: string) => void; - toasts: InternalToastProps[]; - mobile?: boolean; -}; - export function ToastList(props: ToastListProps) { - const {toasts, mobile, removeCallback} = props; + const {toasts, mobile, alternateAnimationFunction, removeCallback} = props; + + const classNames = React.useMemo( + () => + mobile + ? getCSSTransitionClassNames(block('toast-animation-mobile'), { + feature: alternateAnimationFunction ? 'alternate-animation-timing-fn' : false, + }) + : getCSSTransitionClassNames(block('toast-animation-desktop'), { + feature: alternateAnimationFunction ? 'alternate-animation-timing-fn' : false, + }), + [alternateAnimationFunction, mobile], + ); return ( @@ -28,7 +33,7 @@ export function ToastList(props: ToastListProps) { toast.ref?.current?.addEventListener('animationend', done) } diff --git a/src/components/Toaster/ToastList/_variables.scss b/src/components/Toaster/ToastList/_variables.scss new file mode 100644 index 0000000000..4d60c6e98f --- /dev/null +++ b/src/components/Toaster/ToastList/_variables.scss @@ -0,0 +1 @@ +$list-inset-end-position: 10px; diff --git a/src/components/Toaster/ToasterComponent/ToasterComponent.tsx b/src/components/Toaster/ToasterComponent/ToasterComponent.tsx index 04b7f2e885..1fe144315c 100644 --- a/src/components/Toaster/ToasterComponent/ToasterComponent.tsx +++ b/src/components/Toaster/ToasterComponent/ToasterComponent.tsx @@ -8,22 +8,32 @@ import {block} from '../../utils/cn'; import {ToastsContext} from '../Provider/ToastsContext'; import {ToastList} from '../ToastList/ToastList'; import {useToaster} from '../hooks/useToaster'; +import type {ToastListProps} from '../types'; -interface Props { +interface Props extends Pick { className?: string; - mobile?: boolean; hasPortal?: boolean; } const b = block('toaster'); -export function ToasterComponent({className, mobile, hasPortal = true}: Props) { +export function ToasterComponent({ + className, + mobile, + alternateAnimationFunction, + hasPortal = true, +}: Props) { const defaultMobile = useMobile(); const {remove} = useToaster(); const list = React.useContext(ToastsContext); const toaster = ( - + ); if (!hasPortal) { diff --git a/src/components/Toaster/__stories__/Toaster.stories.tsx b/src/components/Toaster/__stories__/Toaster.stories.tsx index 21b5b0a61e..20f02b765c 100644 --- a/src/components/Toaster/__stories__/Toaster.stories.tsx +++ b/src/components/Toaster/__stories__/Toaster.stories.tsx @@ -31,16 +31,18 @@ const views: ButtonView[] = [ 'flat-contrast', ]; -function viewSelect(name: string) { +function selectControl(name: string, options: T[]) { return { name, control: 'select' as const, - defaultValue: 'outlined', - options: views, - if: {arg: 'setActions'}, + options, }; } +function viewSelect(name: string) { + return {...selectControl(name, views), defaultValue: 'outlined', if: {arg: 'setActions'}}; +} + const disabledControl = { table: { disable: true, @@ -77,6 +79,7 @@ export const Default: Story = { setTitle: true, showCloseIcon: true, allowAutoHiding: true, + animation: 'default', }, argTypes: { mobile: disabledControl, @@ -95,6 +98,7 @@ export const Default: Story = { allowAutoHiding: booleanControl('Allow auto hiding'), setTitle: booleanControl('Add title'), setContent: booleanControl('Add content'), + animation: selectControl('Animation', ['default', 'alternate']), setActions: booleanControl('Add action'), action1View: viewSelect('Action 1 view'), action2View: viewSelect('Action 2 view'), @@ -123,6 +127,7 @@ export const ToastPlayground: Story = { actions: faker.helpers.uniqueArray(getAction, faker.number.int({min: 1, max: 2})), }, argTypes: { + animation: selectControl('Animation', ['default', 'alternate']), name: disabledControl, addedAt: disabledControl, renderIcon: disabledControl, @@ -145,6 +150,7 @@ export const ToastPlayground: Story = { ); }, diff --git a/src/components/Toaster/__stories__/ToasterShowcase.tsx b/src/components/Toaster/__stories__/ToasterShowcase.tsx index 15fafec3f2..b571980c61 100644 --- a/src/components/Toaster/__stories__/ToasterShowcase.tsx +++ b/src/components/Toaster/__stories__/ToasterShowcase.tsx @@ -37,6 +37,7 @@ interface Props { showCloseIcon: boolean; setTimeout: boolean; allowAutoHiding: boolean; + animation: 'default' | 'alternate'; setTitle: boolean; setContent: boolean; setActions: boolean; @@ -51,6 +52,7 @@ export const ToasterDemo = ({ showCloseIcon, setTimeout, allowAutoHiding, + animation, setTitle, setContent, setActions, @@ -388,7 +390,10 @@ export const ToasterDemo = ({ ); - const component = React.useMemo(() => , []); + const component = React.useMemo( + () => , + [animation], + ); return ( diff --git a/src/components/Toaster/types.ts b/src/components/Toaster/types.ts index 3161e05f22..3ad2b1549a 100644 --- a/src/components/Toaster/types.ts +++ b/src/components/Toaster/types.ts @@ -46,3 +46,11 @@ export interface ToasterContextMethods { } export interface ToasterPublicMethods extends ToasterContextMethods {} + +export type ToastListProps = { + removeCallback: (name: string) => void; + toasts: InternalToastProps[]; + mobile?: boolean; + /** Experimental animation timing function */ + alternateAnimationFunction?: boolean; +}; diff --git a/src/components/utils/transition.ts b/src/components/utils/transition.ts index ae466de894..4fe2ffc3e6 100644 --- a/src/components/utils/transition.ts +++ b/src/components/utils/transition.ts @@ -1,16 +1,18 @@ +import type {NoStrictEntityMods} from '@bem-react/classname'; + import {modsClassName} from './cn'; import type {CnBlock} from './cn'; -export function getCSSTransitionClassNames(b: CnBlock) { +export function getCSSTransitionClassNames(b: CnBlock, mods?: NoStrictEntityMods) { return { - appear: modsClassName(b({appear: true})), - appearActive: modsClassName(b({appear: 'active'})), - appearDone: modsClassName(b({appear: 'done'})), - enter: modsClassName(b({enter: true})), - enterActive: modsClassName(b({enter: 'active'})), - enterDone: modsClassName(b({enter: 'done'})), - exit: modsClassName(b({exit: true})), - exitActive: modsClassName(b({exit: 'active'})), - exitDone: modsClassName(b({exit: 'done'})), + appear: modsClassName(b({...mods, appear: true})), + appearActive: modsClassName(b({...mods, appear: 'active'})), + appearDone: modsClassName(b({...mods, appear: 'done'})), + enter: modsClassName(b({...mods, enter: true})), + enterActive: modsClassName(b({...mods, enter: 'active'})), + enterDone: modsClassName(b({...mods, enter: 'done'})), + exit: modsClassName(b({...mods, exit: true})), + exitActive: modsClassName(b({...mods, exit: 'active'})), + exitDone: modsClassName(b({...mods, exit: 'done'})), }; }