Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/components/Toaster/ToastList/ToastAnimation.scss
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/components/Toaster/ToastList/ToastList.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use '../../variables';
@use './variables' as toastList;

$block: '.#{variables.$ns}toaster';

Expand All @@ -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;
Expand Down
29 changes: 17 additions & 12 deletions src/components/Toaster/ToastList/ToastList.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
'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 (
<TransitionGroup component={null}>
{toasts.map((toast) => (
<CSSTransition
key={`${toast.name}_${toast.addedAt}`}
nodeRef={toast.ref}
classNames={mobile ? mobileTransitionClassNames : desktopTransitionClassNames}
classNames={classNames}
addEndListener={(done) =>
toast.ref?.current?.addEventListener('animationend', done)
}
Expand Down
1 change: 1 addition & 0 deletions src/components/Toaster/ToastList/_variables.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$list-inset-end-position: 10px;
18 changes: 14 additions & 4 deletions src/components/Toaster/ToasterComponent/ToasterComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToastListProps, 'mobile' | 'alternateAnimationFunction'> {
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 = (
<ToastList toasts={list} removeCallback={remove} mobile={mobile ?? defaultMobile} />
<ToastList
toasts={list}
removeCallback={remove}
mobile={mobile ?? defaultMobile}
alternateAnimationFunction={alternateAnimationFunction}
/>
);

if (!hasPortal) {
Expand Down
14 changes: 10 additions & 4 deletions src/components/Toaster/__stories__/Toaster.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,18 @@ const views: ButtonView[] = [
'flat-contrast',
];

function viewSelect(name: string) {
function selectControl<T>(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,
Expand Down Expand Up @@ -77,6 +79,7 @@ export const Default: Story = {
setTitle: true,
showCloseIcon: true,
allowAutoHiding: true,
animation: 'default',
},
argTypes: {
mobile: disabledControl,
Expand All @@ -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'),
Expand Down Expand Up @@ -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,
Expand All @@ -145,6 +150,7 @@ export const ToastPlayground: Story = {
<ToasterComponent
mobile={args.mobile}
hasPortal={context.globals.screenshotTests !== true}
alternateAnimationFunction={args.animation === 'alternate'}
/>
);
},
Expand Down
7 changes: 6 additions & 1 deletion src/components/Toaster/__stories__/ToasterShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface Props {
showCloseIcon: boolean;
setTimeout: boolean;
allowAutoHiding: boolean;
animation: 'default' | 'alternate';
setTitle: boolean;
setContent: boolean;
setActions: boolean;
Expand All @@ -51,6 +52,7 @@ export const ToasterDemo = ({
showCloseIcon,
setTimeout,
allowAutoHiding,
animation,
setTitle,
setContent,
setActions,
Expand Down Expand Up @@ -388,7 +390,10 @@ export const ToasterDemo = ({
</Button>
);

const component = React.useMemo(() => <ToasterComponent />, []);
const component = React.useMemo(
() => <ToasterComponent alternateAnimationFunction={animation === 'alternate'} />,
[animation],
);

return (
<React.Fragment>
Expand Down
8 changes: 8 additions & 0 deletions src/components/Toaster/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
22 changes: 12 additions & 10 deletions src/components/utils/transition.ts
Original file line number Diff line number Diff line change
@@ -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'})),
};
}
Loading