mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 00:22: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 type { IntlShape } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import type {
|
||||
Alert,
|
||||
Alert as AlertType,
|
||||
TranslatableString,
|
||||
TranslatableValues,
|
||||
} from 'mastodon/models/alert';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { Alert } from './alert';
|
||||
|
||||
const formatIfNeeded = (
|
||||
intl: IntlShape,
|
||||
message: TranslatableString,
|
||||
|
@ -25,8 +25,8 @@ const formatIfNeeded = (
|
|||
return message;
|
||||
};
|
||||
|
||||
const Alert: React.FC<{
|
||||
alert: Alert;
|
||||
const TimedAlert: React.FC<{
|
||||
alert: AlertType;
|
||||
dismissAfter: number;
|
||||
}> = ({
|
||||
alert: { key, title, message, values, action, onClick },
|
||||
|
@ -62,29 +62,13 @@ const Alert: React.FC<{
|
|||
}, [dispatch, setActive, key, dismissAfter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('notification-bar', {
|
||||
'notification-bar-active': active,
|
||||
})}
|
||||
>
|
||||
<div className='notification-bar-wrapper'>
|
||||
{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>
|
||||
<Alert
|
||||
isActive={active}
|
||||
title={title ? formatIfNeeded(intl, title, values) : undefined}
|
||||
message={formatIfNeeded(intl, message, values)}
|
||||
action={action ? formatIfNeeded(intl, action, values) : undefined}
|
||||
onActionClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
|
|||
return (
|
||||
<div className='notification-list'>
|
||||
{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>
|
||||
);
|
||||
|
|
|
@ -10294,7 +10294,7 @@ noscript {
|
|||
.notification-list {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-start: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -10302,9 +10302,11 @@ noscript {
|
|||
}
|
||||
|
||||
.notification-bar {
|
||||
--alert-edge-spacing: 1rem;
|
||||
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
inset-inline-start: -100%;
|
||||
width: auto;
|
||||
padding: 15px;
|
||||
margin: 0;
|
||||
|
@ -10320,33 +10322,43 @@ noscript {
|
|||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
|
||||
&.notification-bar-active {
|
||||
inset-inline-start: 1rem;
|
||||
&.from-side {
|
||||
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 & {
|
||||
transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
|
||||
transform: translateZ(0);
|
||||
will-change: translate;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-bar-title {
|
||||
margin-inline-end: 5px;
|
||||
.notification-bar__content {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.notification-bar-title,
|
||||
.notification-bar-action {
|
||||
.notification-bar__title {
|
||||
margin-inline-end: 5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-bar-action {
|
||||
.notification-bar__action {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-transform: uppercase;
|
||||
margin-inline-start: 10px;
|
||||
cursor: pointer;
|
||||
color: $blurple-300;
|
||||
font-weight: 700;
|
||||
border-radius: 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 {
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
padding: 15px;
|
||||
|
|
Loading…
Reference in New Issue
Block a user