Create reusable Alert/Snackbar component (#36141)

This commit is contained in:
diondiondion 2025-09-17 17:24:39 +02:00 committed by GitHub
parent db0cd9489c
commit 085e9ea676
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 232 additions and 41 deletions

View File

@ -0,0 +1,2 @@
<html class="no-reduce-motion">
</html>

View 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>
),
};

View 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>
);
};

View File

@ -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>
);

View File

@ -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;