Move Toast to components/ui
This commit is contained in:
parent
c4e56e854d
commit
a3c7164147
3 changed files with 153 additions and 131 deletions
|
@ -49,6 +49,7 @@ export { default as Tabs } from './tabs/tabs';
|
||||||
export { default as TagInput } from './tag-input/tag-input';
|
export { default as TagInput } from './tag-input/tag-input';
|
||||||
export { default as Text } from './text/text';
|
export { default as Text } from './text/text';
|
||||||
export { default as Textarea } from './textarea/textarea';
|
export { default as Textarea } from './textarea/textarea';
|
||||||
|
export { default as Toast } from './toast/toast';
|
||||||
export { default as Toggle } from './toggle/toggle';
|
export { default as Toggle } from './toggle/toggle';
|
||||||
export { default as Tooltip } from './tooltip/tooltip';
|
export { default as Tooltip } from './tooltip/tooltip';
|
||||||
export { default as Widget } from './widget/widget';
|
export { default as Widget } from './widget/widget';
|
||||||
|
|
146
app/soapbox/components/ui/toast/toast.tsx
Normal file
146
app/soapbox/components/ui/toast/toast.tsx
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import classNames from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
import toast, { Toast as RHToast } from 'react-hot-toast';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ToastText, ToastType } from 'soapbox/toast';
|
||||||
|
|
||||||
|
import Icon from '../icon/icon';
|
||||||
|
|
||||||
|
const renderText = (text: ToastText) => {
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return text;
|
||||||
|
} else {
|
||||||
|
return <FormattedMessage {...text} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IToast {
|
||||||
|
t: RHToast
|
||||||
|
message: ToastText
|
||||||
|
type: ToastType
|
||||||
|
action?(): void
|
||||||
|
actionLink?: string
|
||||||
|
actionLabel?: ToastText
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customizable Toasts for in-app notifications.
|
||||||
|
*/
|
||||||
|
const Toast = (props: IToast) => {
|
||||||
|
const { t, message, type, action, actionLink, actionLabel } = props;
|
||||||
|
|
||||||
|
const dismissToast = () => toast.dismiss(t.id);
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/circle-check.svg')}
|
||||||
|
className='w-6 h-6 text-success-500 dark:text-success-400'
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/info-circle.svg')}
|
||||||
|
className='w-6 h-6 text-primary-600 dark:text-accent-blue'
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/alert-circle.svg')}
|
||||||
|
className='w-6 h-6 text-danger-600'
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAction = () => {
|
||||||
|
const classNames = 'ml-3 mt-0.5 flex-shrink-0 rounded-full text-sm font-medium text-primary-600 dark:text-accent-blue hover:underline focus:outline-none';
|
||||||
|
|
||||||
|
if (action && actionLabel) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={classNames}
|
||||||
|
onClick={() => {
|
||||||
|
dismissToast();
|
||||||
|
action();
|
||||||
|
}}
|
||||||
|
data-testid='toast-action'
|
||||||
|
>
|
||||||
|
{renderText(actionLabel)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionLink && actionLabel) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={actionLink}
|
||||||
|
onClick={dismissToast}
|
||||||
|
className={classNames}
|
||||||
|
data-testid='toast-action-link'
|
||||||
|
>
|
||||||
|
{renderText(actionLabel)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid='toast'
|
||||||
|
className={
|
||||||
|
classNames({
|
||||||
|
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
|
||||||
|
'animate-enter': t.visible,
|
||||||
|
'animate-leave': !t.visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='p-4'>
|
||||||
|
<div className='flex items-start'>
|
||||||
|
<div className='flex w-0 flex-1 justify-between items-start'>
|
||||||
|
<div className='w-0 flex-1 flex items-start'>
|
||||||
|
<div className='flex-shrink-0'>
|
||||||
|
{renderIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='ml-3 pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
||||||
|
{renderText(message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{renderAction()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dismiss Button */}
|
||||||
|
<div className='ml-4 flex flex-shrink-0 pt-0.5'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='inline-flex rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||||
|
onClick={dismissToast}
|
||||||
|
data-testid='toast-dismiss'
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Close</span>
|
||||||
|
<Icon src={require('@tabler/icons/x.svg')} className='w-5 h-5' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
|
@ -1,15 +1,13 @@
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import classNames from 'clsx';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import toast, { Toast } from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { defineMessages, FormattedMessage, MessageDescriptor } from 'react-intl';
|
import { defineMessages, MessageDescriptor } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Icon } from './components/ui';
|
import { Toast } from './components/ui';
|
||||||
import { httpErrorMessages } from './utils/errors';
|
import { httpErrorMessages } from './utils/errors';
|
||||||
|
|
||||||
type ToastText = string | MessageDescriptor
|
export type ToastText = string | MessageDescriptor
|
||||||
type ToastType = 'success' | 'error' | 'info'
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
interface IToastOptions {
|
interface IToastOptions {
|
||||||
action?(): void
|
action?(): void
|
||||||
|
@ -20,133 +18,10 @@ interface IToastOptions {
|
||||||
|
|
||||||
const DEFAULT_DURATION = 4000;
|
const DEFAULT_DURATION = 4000;
|
||||||
|
|
||||||
const renderText = (text: ToastText) => {
|
|
||||||
if (typeof text === 'string') {
|
|
||||||
return text;
|
|
||||||
} else {
|
|
||||||
return <FormattedMessage {...text} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildToast = (t: Toast, message: ToastText, type: ToastType, opts: Omit<IToastOptions, 'duration'> = {}) => {
|
|
||||||
const { action, actionLabel, actionLink } = opts;
|
|
||||||
|
|
||||||
const dismissToast = () => toast.dismiss(t.id);
|
|
||||||
|
|
||||||
const renderIcon = () => {
|
|
||||||
switch (type) {
|
|
||||||
case 'success':
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/circle-check.svg')}
|
|
||||||
className='w-6 h-6 text-success-500 dark:text-success-400'
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'info':
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/info-circle.svg')}
|
|
||||||
className='w-6 h-6 text-primary-600 dark:text-accent-blue'
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'error':
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/alert-circle.svg')}
|
|
||||||
className='w-6 h-6 text-danger-600'
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAction = () => {
|
|
||||||
const classNames = 'ml-3 mt-0.5 flex-shrink-0 rounded-full text-sm font-medium text-primary-600 dark:text-accent-blue hover:underline focus:outline-none';
|
|
||||||
|
|
||||||
if (action && actionLabel) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className={classNames}
|
|
||||||
onClick={() => {
|
|
||||||
dismissToast();
|
|
||||||
action();
|
|
||||||
}}
|
|
||||||
data-testid='toast-action'
|
|
||||||
>
|
|
||||||
{renderText(actionLabel)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionLink && actionLabel) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={actionLink}
|
|
||||||
onClick={dismissToast}
|
|
||||||
className={classNames}
|
|
||||||
data-testid='toast-action-link'
|
|
||||||
>
|
|
||||||
{renderText(actionLabel)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid='toast'
|
|
||||||
className={
|
|
||||||
classNames({
|
|
||||||
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
|
|
||||||
'animate-enter': t.visible,
|
|
||||||
'animate-leave': !t.visible,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='p-4'>
|
|
||||||
<div className='flex items-start'>
|
|
||||||
<div className='flex w-0 flex-1 justify-between items-start'>
|
|
||||||
<div className='w-0 flex-1 flex items-start'>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
{renderIcon()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className='ml-3 pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
|
||||||
{renderText(message)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
{renderAction()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dismiss Button */}
|
|
||||||
<div className='ml-4 flex flex-shrink-0 pt-0.5'>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='inline-flex rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
|
||||||
onClick={dismissToast}
|
|
||||||
data-testid='toast-dismiss'
|
|
||||||
>
|
|
||||||
<span className='sr-only'>Close</span>
|
|
||||||
<Icon src={require('@tabler/icons/x.svg')} className='w-5 h-5' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createToast = (type: ToastType, message: ToastText, opts?: IToastOptions) => {
|
const createToast = (type: ToastType, message: ToastText, opts?: IToastOptions) => {
|
||||||
const duration = opts?.duration || DEFAULT_DURATION;
|
const duration = opts?.duration || DEFAULT_DURATION;
|
||||||
|
|
||||||
toast.custom((t) => buildToast(t, message, type, opts), {
|
toast.custom((t) => <Toast t={t} message={message} type={type} {...opts} />, {
|
||||||
duration,
|
duration,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue