mirror of https://github.com/mastodon/mastodon
				
				
				
			Create reusable Alert/Snackbar component (#36141)
							parent
							
								
									db0cd9489c
								
							
						
					
					
						commit
						085e9ea676
					
				@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					<html class="no-reduce-motion">
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
@ -0,0 +1,110 @@
 | 
				
			|||||||
 | 
					import type { Meta, StoryObj } from '@storybook/react-vite';
 | 
				
			||||||
 | 
					import { fn, expect } from 'storybook/test';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Alert } from '.';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const meta = {
 | 
				
			||||||
 | 
					  title: 'Components/Alert',
 | 
				
			||||||
 | 
					  component: Alert,
 | 
				
			||||||
 | 
					  args: {
 | 
				
			||||||
 | 
					    isActive: true,
 | 
				
			||||||
 | 
					    animateFrom: 'side',
 | 
				
			||||||
 | 
					    title: '',
 | 
				
			||||||
 | 
					    message: '',
 | 
				
			||||||
 | 
					    action: '',
 | 
				
			||||||
 | 
					    onActionClick: fn(),
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  argTypes: {
 | 
				
			||||||
 | 
					    isActive: {
 | 
				
			||||||
 | 
					      control: 'boolean',
 | 
				
			||||||
 | 
					      type: 'boolean',
 | 
				
			||||||
 | 
					      description: 'Animate to the active (displayed) state of the alert',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    animateFrom: {
 | 
				
			||||||
 | 
					      control: 'radio',
 | 
				
			||||||
 | 
					      type: 'string',
 | 
				
			||||||
 | 
					      options: ['side', 'below'],
 | 
				
			||||||
 | 
					      description:
 | 
				
			||||||
 | 
					        'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    title: {
 | 
				
			||||||
 | 
					      control: 'text',
 | 
				
			||||||
 | 
					      type: 'string',
 | 
				
			||||||
 | 
					      description: '(Optional) title of the alert',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    message: {
 | 
				
			||||||
 | 
					      control: 'text',
 | 
				
			||||||
 | 
					      type: 'string',
 | 
				
			||||||
 | 
					      description: 'Main alert text',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    action: {
 | 
				
			||||||
 | 
					      control: 'text',
 | 
				
			||||||
 | 
					      type: 'string',
 | 
				
			||||||
 | 
					      description:
 | 
				
			||||||
 | 
					        'Label of the alert action (requires `onActionClick` handler)',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  tags: ['test'],
 | 
				
			||||||
 | 
					} satisfies Meta<typeof Alert>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default meta;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Story = StoryObj<typeof meta>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Simple: Story = {
 | 
				
			||||||
 | 
					  args: {
 | 
				
			||||||
 | 
					    message: 'Post published.',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  render: (args) => (
 | 
				
			||||||
 | 
					    <div style={{ overflow: 'clip', padding: '1rem' }}>
 | 
				
			||||||
 | 
					      <Alert {...args} />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const WithAction: Story = {
 | 
				
			||||||
 | 
					  args: {
 | 
				
			||||||
 | 
					    ...Simple.args,
 | 
				
			||||||
 | 
					    action: 'Open',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  render: Simple.render,
 | 
				
			||||||
 | 
					  play: async ({ args, canvas, userEvent }) => {
 | 
				
			||||||
 | 
					    const button = await canvas.findByRole('button', { name: 'Open' });
 | 
				
			||||||
 | 
					    await userEvent.click(button);
 | 
				
			||||||
 | 
					    await expect(args.onActionClick).toHaveBeenCalled();
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const WithTitle: Story = {
 | 
				
			||||||
 | 
					  args: {
 | 
				
			||||||
 | 
					    title: 'Warning:',
 | 
				
			||||||
 | 
					    message: 'This is an alert',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  render: Simple.render,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const WithDismissButton: Story = {
 | 
				
			||||||
 | 
					  args: {
 | 
				
			||||||
 | 
					    message: 'More replies found',
 | 
				
			||||||
 | 
					    action: 'Show',
 | 
				
			||||||
 | 
					    onDismiss: fn(),
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  render: Simple.render,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const InSizedContainer: Story = {
 | 
				
			||||||
 | 
					  args: WithDismissButton.args,
 | 
				
			||||||
 | 
					  render: (args) => (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      style={{
 | 
				
			||||||
 | 
					        overflow: 'clip',
 | 
				
			||||||
 | 
					        padding: '1rem',
 | 
				
			||||||
 | 
					        width: '380px',
 | 
				
			||||||
 | 
					        maxWidth: '100%',
 | 
				
			||||||
 | 
					        boxSizing: 'border-box',
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Alert {...args} />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					import { useIntl } from 'react-intl';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { IconButton } from '../icon_button';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Snackbar/Toast-style notification component.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export const Alert: React.FC<{
 | 
				
			||||||
 | 
					  isActive?: boolean;
 | 
				
			||||||
 | 
					  animateFrom?: 'side' | 'below';
 | 
				
			||||||
 | 
					  title?: string;
 | 
				
			||||||
 | 
					  message: string;
 | 
				
			||||||
 | 
					  action?: string;
 | 
				
			||||||
 | 
					  onActionClick?: () => void;
 | 
				
			||||||
 | 
					  onDismiss?: () => void;
 | 
				
			||||||
 | 
					}> = ({
 | 
				
			||||||
 | 
					  isActive,
 | 
				
			||||||
 | 
					  animateFrom = 'side',
 | 
				
			||||||
 | 
					  title,
 | 
				
			||||||
 | 
					  message,
 | 
				
			||||||
 | 
					  action,
 | 
				
			||||||
 | 
					  onActionClick,
 | 
				
			||||||
 | 
					  onDismiss,
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  const intl = useIntl();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const hasAction = Boolean(action && onActionClick);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      className={classNames('notification-bar', {
 | 
				
			||||||
 | 
					        'notification-bar--active': isActive,
 | 
				
			||||||
 | 
					        'from-side': animateFrom === 'side',
 | 
				
			||||||
 | 
					        'from-below': animateFrom === 'below',
 | 
				
			||||||
 | 
					      })}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <span className='notification-bar__content'>
 | 
				
			||||||
 | 
					        {Boolean(title) && (
 | 
				
			||||||
 | 
					          <span className='notification-bar__title'>{title}</span>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					        {message}
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {hasAction && (
 | 
				
			||||||
 | 
					        <button className='notification-bar__action' onClick={onActionClick}>
 | 
				
			||||||
 | 
					          {action}
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {onDismiss && (
 | 
				
			||||||
 | 
					        <IconButton
 | 
				
			||||||
 | 
					          title={intl.formatMessage({
 | 
				
			||||||
 | 
					            id: 'dismissable_banner.dismiss',
 | 
				
			||||||
 | 
					            defaultMessage: 'Dismiss',
 | 
				
			||||||
 | 
					          })}
 | 
				
			||||||
 | 
					          icon='times'
 | 
				
			||||||
 | 
					          iconComponent={CloseIcon}
 | 
				
			||||||
 | 
					          className='notification-bar__dismiss-button'
 | 
				
			||||||
 | 
					          onClick={onDismiss}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
					Loading…
					
					
				
		Reference in New Issue