mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-06 00:52:42 +00:00
Create reusable Alert/Snackbar component (#36141)
This commit is contained in:
parent
db0cd9489c
commit
085e9ea676
2
.storybook/preview-body.html
Normal file
2
.storybook/preview-body.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<html class="no-reduce-motion">
|
||||||
|
</html>
|
110
app/javascript/mastodon/components/alert/alert.stories.tsx
Normal file
110
app/javascript/mastodon/components/alert/alert.stories.tsx
Normal file
|
@ -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>
|
||||||
|
),
|
||||||
|
};
|
68
app/javascript/mastodon/components/alert/index.tsx
Normal file
68
app/javascript/mastodon/components/alert/index.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -3,16 +3,16 @@ import { useState, useEffect } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import type { IntlShape } from 'react-intl';
|
import type { IntlShape } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||||
import type {
|
import type {
|
||||||
Alert,
|
Alert as AlertType,
|
||||||
TranslatableString,
|
TranslatableString,
|
||||||
TranslatableValues,
|
TranslatableValues,
|
||||||
} from 'mastodon/models/alert';
|
} from 'mastodon/models/alert';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { Alert } from './alert';
|
||||||
|
|
||||||
const formatIfNeeded = (
|
const formatIfNeeded = (
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
message: TranslatableString,
|
message: TranslatableString,
|
||||||
|
@ -25,8 +25,8 @@ const formatIfNeeded = (
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Alert: React.FC<{
|
const TimedAlert: React.FC<{
|
||||||
alert: Alert;
|
alert: AlertType;
|
||||||
dismissAfter: number;
|
dismissAfter: number;
|
||||||
}> = ({
|
}> = ({
|
||||||
alert: { key, title, message, values, action, onClick },
|
alert: { key, title, message, values, action, onClick },
|
||||||
|
@ -62,29 +62,13 @@ const Alert: React.FC<{
|
||||||
}, [dispatch, setActive, key, dismissAfter]);
|
}, [dispatch, setActive, key, dismissAfter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert
|
||||||
className={classNames('notification-bar', {
|
isActive={active}
|
||||||
'notification-bar-active': active,
|
title={title ? formatIfNeeded(intl, title, values) : undefined}
|
||||||
})}
|
message={formatIfNeeded(intl, message, values)}
|
||||||
>
|
action={action ? formatIfNeeded(intl, action, values) : undefined}
|
||||||
<div className='notification-bar-wrapper'>
|
onActionClick={onClick}
|
||||||
{title && (
|
/>
|
||||||
<span className='notification-bar-title'>
|
|
||||||
{formatIfNeeded(intl, title, values)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className='notification-bar-message'>
|
|
||||||
{formatIfNeeded(intl, message, values)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{action && (
|
|
||||||
<button className='notification-bar-action' onClick={onClick}>
|
|
||||||
{formatIfNeeded(intl, action, values)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className='notification-list'>
|
<div className='notification-list'>
|
||||||
{alerts.map((alert, idx) => (
|
{alerts.map((alert, idx) => (
|
||||||
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
|
<TimedAlert
|
||||||
|
key={alert.key}
|
||||||
|
alert={alert}
|
||||||
|
dismissAfter={5000 + idx * 1000}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10294,7 +10294,7 @@ noscript {
|
||||||
.notification-list {
|
.notification-list {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 1rem;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -10302,9 +10302,11 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-bar {
|
.notification-bar {
|
||||||
|
--alert-edge-spacing: 1rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
|
||||||
inset-inline-start: -100%;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -10320,33 +10322,43 @@ noscript {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 21px;
|
line-height: 21px;
|
||||||
|
|
||||||
&.notification-bar-active {
|
&.from-side {
|
||||||
inset-inline-start: 1rem;
|
translate: calc(
|
||||||
|
-1 * (100% + var(--alert-edge-spacing)) * var(--text-x-direction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.from-below {
|
||||||
|
translate: 0 calc(100% + var(--alert-edge-spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.notification-bar--active {
|
||||||
|
translate: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-reduce-motion & {
|
.no-reduce-motion & {
|
||||||
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
|
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
|
||||||
transform: translateZ(0);
|
will-change: translate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-bar-title {
|
.notification-bar__content {
|
||||||
margin-inline-end: 5px;
|
margin-inline-end: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-bar-title,
|
.notification-bar__title {
|
||||||
.notification-bar-action {
|
margin-inline-end: 5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-bar-action {
|
.notification-bar__action {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-inline-start: 10px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: $blurple-300;
|
color: $blurple-300;
|
||||||
|
font-weight: 700;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|
||||||
|
@ -10357,6 +10369,17 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-bar__dismiss-button {
|
||||||
|
margin-top: -2px;
|
||||||
|
color: rgb(from currentColor r g b / 85%);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hashtag-header {
|
.hashtag-header {
|
||||||
border-bottom: 1px solid var(--background-border-color);
|
border-bottom: 1px solid var(--background-border-color);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user